Skip to content

Plus Customer Coupons ​

Flow ID: CF-37 | Module(s): Plus, coupons, audience | Complexity: Medium | Last Updated: 2026-09-14

Business Context ​

"Plus" ("Ecommercen Plus") is the platform's marketing and audience-segmentation add-on package — not a customer loyalty tier. It groups the Audience Manager, Slide Campaigns, Coupon Campaigns, Audience Reporting, and Find Customers features under a single admin menu group accessible to ADVISABLE, ADMIN, and MARKETING roles (application/config/admin_menu.php:41-84). The canonical admin-side documentation lives in AD-22 Audience & Campaigns.

This flow documents the customer-facing read side: GET /rest/plus/customer-coupon, shipped in the #288 velora storefront-parity epic (commit bbb52b6cc, 2026-06-07). It ports the legacy storefront method Adv_front_controller::activeCustomerCoupons() (ecommercen/core/Adv_front_controller.php:1464-1478) to a dedicated REST endpoint in src/Rest/Plus/ and src/Domains/Plus/.

Eligibility is audience-mediated, never direct. There is no customer↔coupon assignment table. The eligibility chain is:

customer → shop_customer_audience (pivot)
         → audience.id
         → coupon.audience_id
         → coupons (individual codes)

A customer "has" coupons only because they belong to one or more audiences that a coupon template targets. Audience membership is populated by background jobs (AdvAddTagsToCustomers, AdvAddCustomersToSpecificAudience) feeding shop_customer_audience; those jobs are documented in AD-22 Audience & Campaigns.

The endpoint is read-only. It returns available coupon codes for the authenticated customer. No state is written; no reservation or usage decrement occurs here. Redemption is enforced downstream at cart applyCoupon / checkout validateCoupon (see CF-13 Coupons).

API Reference ​

REST Endpoints ​

MethodPathActionAuthRoles
GET/rest/plus/customer-couponCustomerCoupon::indexcustomer(none)
GET/{locale}/rest/plus/customer-couponCustomerCoupon::indexcustomer(none)

Routes defined at application/config/rest_routes.php:1179-1180 (comment header at :1178). The locale-prefixed variant ((\w{2})/rest/...) is the standard pattern for multi-language storefronts.

Policy entry at application/config/rest_policies.php:893 (FQCN import :103, provenance comment :891-892):

CustomerCoupon::class => ['defaults' => ['auth' => 'customer']]

Auth customer means: AuthorizationMiddleware requires isAuthenticated() (401 if not) and getUserType() === REST_FRONTEND_USER (403 if wrong type) (src/Rest/Middleware/AuthorizationMiddleware.php:44-54). No role array — any authenticated customer may call this endpoint.

Legacy Provenance ​

There is no admin or legacy HTTP route for this specific endpoint. The business logic was previously surfaced only on the home page through a template call:

Call SiteMethodCitation
Adv_home::index()$this->activeCustomerCoupons() (home page only)ecommercen/eshop/controllers/Adv_home.php:233

The legacy method and its model are documented in the Domain Layer section below.

Code Flow ​

Read Path (Modern REST) ​

JWT token → AuthenticationMiddleware
          → AuthorizationMiddleware (auth=customer; 401 if unauthenticated, 403 if non-customer)
          → ResourceContextMiddleware → ResourceContext::fromTokenData()
                                        (SCOPE_CUSTOMER, userId from tokenData['userId'])
                                        (src/Rest/Support/Resources/ResourceContext.php:53-68)
          → RelationFilterMiddleware
          → CustomerCoupon::index()
               |-- customerId = (int)($resourceContext?->getUserId() ?? 0)
               |-- 401 if customerId <= 0
               |     (src/Rest/Plus/Controllers/CustomerCoupon.php:58-62)
               |-- registry gate: SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAG
               |     flag OFF → return {success:true, data:[]}
               |     (src/Rest/Plus/Controllers/CustomerCoupon.php:66-69)
               |-- Service::availableForCustomer(customerId)
               |     (src/Domains/Plus/CustomerCoupon/Service.php:27-36)
               |     |-- AudienceRepository::getAudienceIdsForCustomer(customerId)
               |     |     SELECT audience_id FROM shop_customer_audience WHERE customer_id=?
               |     |     (src/Domains/Plus/Audience/Repository/Repository.php:23-36)
               |     |-- if audience ids empty → return []
               |     |     (src/Domains/Plus/CustomerCoupon/Service.php:31-33)
               |     |-- Repository::availableForAudiences(audienceIds)
               |           SQL eligibility predicate (see Business Rules)
               |           returns Entity[]
               |           (src/Domains/Plus/CustomerCoupon/Repository/Repository.php:32-64)
               |-- wrap Entity[] in Collection → apply ResourceContext
               |-- return {success:true, data:[...]}
               |     (src/Rest/Plus/Controllers/CustomerCoupon.php:71-78)

Legacy Path (Home Page Only) ​

Adv_home::index()
  → $this->activeCustomerCoupons()
       (ecommercen/core/Adv_front_controller.php:1464-1478)
       |-- if customer_id present:
       |     load coupons_model
       |     getCouponCodeForAudiences(new CouponCheckConfig(), $this->customerAudiences)
       |     (ecommercen/coupons/models/Adv_coupons_model.php:1129-1163)

$this->customerAudiences is populated by setCustomerAudience() called from :114:
  → if customer_logged_in AND registry SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAG
  →   audience_model->getCustomerAudience(customer_id)
  →   (ecommercen/core/Adv_front_controller.php:243-254)

Gate mechanism divergence (not a bug): The legacy gate lives inside setCustomerAudience() (ecommercen/core/Adv_front_controller.php:247) — flag OFF means customerAudiences is never populated, so activeCustomerCoupons receives an empty array and short-circuits. The modern controller makes the gate explicit before resolving audiences (src/Rest/Plus/Controllers/CustomerCoupon.php:66-69). Net behavior is identical (flag off → empty response); the mechanism differs by design. Do not remove the explicit gate as "redundant".

Data Model ​

There is no customer_coupon table. The endpoint projects a read-only SELECT across three joined tables. The entity is defined as a projection in src/Domains/Plus/CustomerCoupon/Repository/Entity.php:25-28.

coupon (template) ​

database/initial/initial.sql:373-388 — key columns used by this flow: id, audience_id (links to audience.id; no FK), display_name, discount_percent, discount_price, max_count_usage, coupon_type. Full schema in AD-08 Coupon Management (see canonical schema).

coupons (individual codes) ​

database/initial/initial.sql:390-404 — key columns used by this flow: coupon_id (links to coupon.id), coupon (the redeemable code string, utf8_bin UNIQUE), is_sent (must be 1 for eligibility), is_used (increment counter; checked against max_count_usage). Full schema in AD-08 Coupon Management (see canonical schema).

coupon_rules (date window) ​

database/initial/initial.sql:414-431 — columns used by this flow: coupon_id, date_start (NULL = open-ended), date_end (NULL = open-ended). Full schema in AD-08 Coupon Management (see canonical schema). Note: carries a redundant index — see Known Issues & Security Gaps item 6.

audience (segment definition) ​

database/initial/initial.sql:75-84 — id unsigned PK, name varchar(255), criteria_type tinyint NULL, creation_datetime, update_datetime NULL, user_id NULL.

shop_customer_audience (pivot) ​

database/initial/initial.sql:1174-1180 — PK(audience_id, customer_id); KEY audience; KEY customer; no FK constraints.

Canonical schema detail for audience and shop_customer_audience is in AD-22 Audience & Campaigns. Canonical schema for coupon, coupons, and coupon_rules is in AD-08 Coupon Management.

Domain Layer ​

Modern Domain (src/Domains/Plus/CustomerCoupon/) ​

Service (src/Domains/Plus/CustomerCoupon/Service.php:27-36)

availableForCustomer(int $customerId): array — orchestrates the two-step lookup: resolve audience ids then fetch eligible codes. Constructor injects Repository and AudienceRepository (src/Domains/Plus/CustomerCoupon/Service.php:13-17).

Repository (src/Domains/Plus/CustomerCoupon/Repository/Repository.php)

  • $table = 'coupons' (:11)
  • availableForAudiences(array $audienceIds): array (:32-64) — runs the eligibility SQL:
    • SELECT coupons.coupon AS code, coupon.id AS coupon_id, name, display_name, discount_percent, discount_price, coupon_type, date_start, date_end
    • JOIN coupon and coupon_rules
    • WHERE coupon.audience_id IN (...) (:56)
    • AND coupons.is_sent = 1 (:57)
    • AND coupons.is_used < coupon.max_count_usage (:58)
    • Date-window OR-block: (start < now AND end > now) OR (end NULL AND start < now) OR (start NULL AND end > now) OR (both NULL) (:46-61)
    • GROUP BY coupon.id — one row per template (:60)
    • Hydrates Entity[] via custom_result_object
  • Empty $audienceIds input → returns [] immediately (:33-36)

Entity (src/Domains/Plus/CustomerCoupon/Repository/Entity.php:25-28) — BaseEntity, read-only projection, no write methods.

RepositoryConfigurator (src/Domains/Plus/CustomerCoupon/Repository/RepositoryConfigurator.php:11-14) — getRelations() returns [].

Audience Repository (src/Domains/Plus/Audience/Repository/Repository.php:23-36)

getAudienceIdsForCustomer(int $customerId): array — SELECT audience_id FROM shop_customer_audience WHERE customer_id = ?, returns int[].

Modern REST Layer (src/Rest/Plus/) ​

Controller (src/Rest/Plus/Controllers/CustomerCoupon.php, 80 lines)

Extends Base_c (application/core/Base_c.php:3 → Adv_base_controller), not HandlesRestfulActions. Implements ResourceContextAware (:26). Uses ApiEndpointTrait (ecommercen/eshop/traits/ApiEndpointTrait.php:13-24). Injects Service only via constructor (:32-35).

Resource (src/Rest/Plus/Resources/CustomerCoupon/Resource.php:26-39)

camelCase field mapping:

JSON fieldSource columnCast
couponIdcoupon_idint
codecouponstring
namenamestring
displayNamedisplay_namestring
discountPercentdiscount_percentfloat|null
discountPricediscount_pricefloat|null
couponTypecoupon_typeint|null
validFromdate_startformatDate('Y-m-d H:i:s')
validTodate_endformatDate('Y-m-d H:i:s')

OpenAPI schema tag: PlusCustomerCouponResource (:10-23). No $this->context?->isBackend() field filtering — all projection fields are emitted to the customer caller.

Collection (src/Rest/Plus/Resources/CustomerCoupon/Collection.php:23-26) — BaseCollection, collects = Resource::class. OpenAPI tag: PlusCustomerCouponCollection.

Legacy Layer ​

MethodFileNotes
activeCustomerCoupons()ecommercen/core/Adv_front_controller.php:1464-1478Legacy provenance; calls getCouponCodeForAudiences
setCustomerAudience()ecommercen/core/Adv_front_controller.php:243-254Populates $this->customerAudiences; reads registry gate
getCouponCodeForAudiences()ecommercen/coupons/models/Adv_coupons_model.php:1129-1163SQL eligibility predicate; ported faithfully
getCustomerAudience()ecommercen/audience/models/AdvAudienceModel.php:251-262SELECT audience_id FROM shop_customer_audience

Legacy CouponCheckConfig defaults: maxUsage=true, validDates=true, validAudience=true (ecommercen/coupons/CouponChecksConfig.php:3-8).

DI Registration ​

  • REST container: src/Rest/Plus/container.php:29-30 — CustomerCoupon controller, $service Reference
  • Domain container: src/Domains/Plus/container.php:30-33 — Repository, RepositoryConfigurator, Service; Audience repos at :22-28

Configuration ​

KeyStoreEffectCitation
SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAGDB registryFalsy → controller returns {success:true, data:[]} immediately, skipping audience lookupsrc/Rest/Plus/Controllers/CustomerCoupon.php:66-69; legacy ecommercen/core/Adv_front_controller.php:247

SMART_RECOMMENDATIONS is a DB-stored registry group (no file constant). The same flag gates the entire audience/recommendation pipeline; see AD-22 Audience & Campaigns for the canonical toggle documentation. No .env variables or per-endpoint config files are involved.

Client Extension Points ​

This endpoint has no declared extension points. The controller does not use the HandlesRestfulActions trait, so the standard override pattern (subclass + re-register in custom/Rest/container.php) applies if a client needs custom filtering logic.

Audience membership population (which feeds this endpoint) is controlled by the audience-criteria jobs documented in AD-22 Audience & Campaigns.

Business Rules ​

  1. Customer id always comes from JWT — customerId = (int)($resourceContext?->getUserId() ?? 0) (src/Rest/Plus/Controllers/CustomerCoupon.php:58). No request parameter accepted. 401 if resolved id is ≤ 0.

  2. Registry gate is explicit in the modern controller — flag SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAG OFF → return empty data array without calling the service. Legacy achieves the same result through setCustomerAudience() never populating $this->customerAudiences (ecommercen/core/Adv_front_controller.php:247). Same net behavior, different mechanism; the explicit gate in the modern controller is intentional.

  3. Empty audiences short-circuit at service level — AudienceRepository::getAudienceIdsForCustomer() returning an empty array causes Service::availableForCustomer() to return [] without touching the coupons table (src/Domains/Plus/CustomerCoupon/Service.php:31-33; also enforced at repository entry src/Domains/Plus/CustomerCoupon/Repository/Repository.php:33-36).

  4. Eligibility predicate (five conditions, all required):

    • coupon.audience_id IN (customer audience ids) — audience targeting
    • coupons.is_sent = 1 — code must have been distributed
    • coupons.is_used < coupon.max_count_usage — code not exhausted
    • Date-window OR-block (server DateTime, not user input, no injection vector) — valid date range
    • GROUP BY coupon.id — one row per template (src/Domains/Plus/CustomerCoupon/Repository/Repository.php:46-61)
  5. No redemption occurs here — the endpoint is read-only. is_used is not incremented. Redemption and usage tracking happen at cart applyCoupon / checkout validateCoupon. See CF-13 Coupons.

  6. discount_percent takes priority over discount_price — when both are set on a template, percentage discount wins at apply time (enforced in the redemption layer, not here). Reflected in column ordering in coupon (database/initial/initial.sql:379-380).

  7. No events, hooks, or deferred tasks — the endpoint is a pure DB read with no side effects. No Monolog channels, no deferred task runner invocations, no cron dependency.

  8. No pagination — index() returns the full result set for the customer (src/Rest/Plus/Controllers/CustomerCoupon.php:71-78). Low cardinality is expected; audience-targeted coupon counts per customer are typically small.

Known Issues & Security Gaps ​

  1. Read-only, no reservation — footgun risk. availableForAudiences() returns codes where coupons.is_used < coupon.max_count_usage but does not decrement or reserve the code (src/Domains/Plus/CustomerCoupon/Repository/Repository.php:58). A code shown as available could be exhausted by another customer's concurrent redemption before this customer applies it. This is by design (read-only list), but callers must handle apply-time failures gracefully.

  2. No DB-level test for eligibility SQL. The join, date-window OR-block, is_used < max_count_usage predicate, and GROUP BY in availableForAudiences() (src/Domains/Plus/CustomerCoupon/Repository/Repository.php:46-61) are verified only through mocks in ServiceTest. No integration or database test exercises the actual SQL. The same gap exists for the legacy original getCouponCodeForAudiences (ecommercen/coupons/models/Adv_coupons_model.php:1129-1163).

  3. GROUP BY nondeterminism / ONLY_FULL_GROUP_BY risk. availableForAudiences() issues GROUP BY coupon.id while selecting non-aggregated columns including coupons.coupon (the code string) (src/Domains/Plus/CustomerCoupon/Repository/Repository.php:46-61). Under MySQL ONLY_FULL_GROUP_BY mode (default since MySQL 5.7 / MySQL 8.0), this query is rejected. With the mode disabled the server returns a nondeterministic code per template. This is a faithful port of the legacy behaviour (ecommercen/coupons/models/Adv_coupons_model.php:1155-1160) and is therefore a pre-existing latent issue, not introduced by #288. Verify against the project's active SQL mode before treating as a NEW_BUG.

  4. Resource has no isBackend() scope filtering — watch-point. Resource.php emits all projection fields unconditionally with no $this->context?->isBackend() guard (src/Rest/Plus/Resources/CustomerCoupon/Resource.php:26-39). The current projection contains no PII or admin-only columns (coupon rule tables are intentionally backend-only). However this diverges from the isBackend() scoping pattern used elsewhere in the #288 epic; if fields are added to the projection later, they will be emitted to customers without an explicit exclusion step.

  5. No pagination. index() returns the full unbound result set (src/Rest/Plus/Controllers/CustomerCoupon.php:71-78). The controller does not use HandlesRestfulActions, so the standard pagination envelope is absent. Low cardinality is expected in practice (audience-targeted codes per customer), but there is no hard limit.

  6. Redundant duplicate index on coupon_rules. KEY coupon_id_date_start_end and KEY coupon_id_lang_date_start_end cover identical columns (database/initial/initial.sql:429-430). One of the two is never used. Pre-existing platform issue; removing the redundant index requires a migration and schema review.

  7. No FK constraints on join columns. coupons.coupon_id, coupon_rules.coupon_id, coupon.audience_id, shop_customer_audience.audience_id, and shop_customer_audience.customer_id are all plain KEY columns with no FOREIGN KEY declaration (database/initial/initial.sql:386,392,416,1174-1179). Orphaned rows (e.g., codes pointing to a deleted template) are not prevented at the DB level. Pre-existing platform pattern.

Tests ​

Existing Test Coverage ​

FileWhat is tested
tests/Unit/Domains/Plus/CustomerCoupon/ServiceTest.php(a) empty audience ids → [], availableForAudiences never called (:30-43); (b) audience ids [3,7] → availableForAudiences([3,7]) returns coupons (:45-63). Both repositories mocked.
tests/Unit/Rest/Middleware/PolicyResolverIntegrationTest.php:134-136Data-provider row pins CustomerCoupon::index → ('customer', []). Asserts via PolicyResolver::resolve() (:148-158).

Coverage Gaps ​

The following scenarios have no automated test coverage:

  1. Controller registry-gate short-circuit — flag IS_ENABLED_CUSTOMER_TAG OFF → {data:[]} path (src/Rest/Plus/Controllers/CustomerCoupon.php:66-69) is not exercised by any test.

  2. Controller 401 path — unauthenticated or zero-id customer scenario (src/Rest/Plus/Controllers/CustomerCoupon.php:58-62) has no test.

  3. Repository SQL (DB/integration level) — Repository::availableForAudiences() (src/Domains/Plus/CustomerCoupon/Repository/Repository.php:46-61) is covered only by mocks in ServiceTest. No test exercises the actual join, date-window logic, is_used < max_count_usage predicate, or GROUP BY against a real database. Same gap for legacy getCouponCodeForAudiences (ecommercen/coupons/models/Adv_coupons_model.php:1129-1163).

  4. Audience\Repository::getAudienceIdsForCustomer — no test at any level for the pivot SELECT (src/Domains/Plus/Audience/Repository/Repository.php:23-36).

  5. Resource shape — no test verifies camelCase field names, float casts for discountPercent/discountPrice, or Y-m-d H:i:s date format emitted by validFrom/validTo (src/Rest/Plus/Resources/CustomerCoupon/Resource.php:26-39).

  • AD-22 Audience & Campaigns — canonical home for the SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAG registry gate, audience CRUD, and the background jobs that populate shop_customer_audience
  • AD-08 Coupon Management — canonical schema and admin CRUD for coupon, coupons, and coupon_rules; code generation workflow
  • CF-13 Coupons — coupon redemption at cart and checkout; validation chain; discount calculation
  • CF-26 Home Page — legacy call site for activeCustomerCoupons() (home-page render); only storefront surface before this REST endpoint