Skip to content

Plus Customer Coupons

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

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:1147-1149. The locale-prefixed variant ((\w{2})/rest/...) is the standard pattern for multi-language storefronts.

Policy entry at application/config/rest_policies.php:668 (FQCN import :99, provenance comment :666-667):

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-84id 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): arraySELECT 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:3Adv_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-30CustomerCoupon controller, $service Reference
  • Domain container: src/Domains/Plus/container.php:30-33Repository, 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 JWTcustomerId = (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 levelAudienceRepository::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 paginationindex() 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