Appearance
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
| Method | Path | Action | Auth | Roles |
|---|---|---|---|---|
| GET | /rest/plus/customer-coupon | CustomerCoupon::index | customer | (none) |
| GET | /{locale}/rest/plus/customer-coupon | CustomerCoupon::index | customer | (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 Site | Method | Citation |
|---|---|---|
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
audienceandshop_customer_audienceis in AD-22 Audience & Campaigns. Canonical schema forcoupon,coupons, andcoupon_rulesis 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
couponandcoupon_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[]viacustom_result_object
- Empty
$audienceIdsinput → 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 field | Source column | Cast |
|---|---|---|
couponId | coupon_id | int |
code | coupon | string |
name | name | string |
displayName | display_name | string |
discountPercent | discount_percent | float|null |
discountPrice | discount_price | float|null |
couponType | coupon_type | int|null |
validFrom | date_start | formatDate('Y-m-d H:i:s') |
validTo | date_end | formatDate('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
| Method | File | Notes |
|---|---|---|
activeCustomerCoupons() | ecommercen/core/Adv_front_controller.php:1464-1478 | Legacy provenance; calls getCouponCodeForAudiences |
setCustomerAudience() | ecommercen/core/Adv_front_controller.php:243-254 | Populates $this->customerAudiences; reads registry gate |
getCouponCodeForAudiences() | ecommercen/coupons/models/Adv_coupons_model.php:1129-1163 | SQL eligibility predicate; ported faithfully |
getCustomerAudience() | ecommercen/audience/models/AdvAudienceModel.php:251-262 | SELECT 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—CustomerCouponcontroller,$serviceReference - Domain container:
src/Domains/Plus/container.php:30-33—Repository,RepositoryConfigurator,Service; Audience repos at:22-28
Configuration
| Key | Store | Effect | Citation |
|---|---|---|---|
SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAG | DB registry | Falsy → controller returns {success:true, data:[]} immediately, skipping audience lookup | src/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
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.Registry gate is explicit in the modern controller — flag
SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAGOFF → return empty data array without calling the service. Legacy achieves the same result throughsetCustomerAudience()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.Empty audiences short-circuit at service level —
AudienceRepository::getAudienceIdsForCustomer()returning an empty array causesService::availableForCustomer()to return[]without touching the coupons table (src/Domains/Plus/CustomerCoupon/Service.php:31-33; also enforced at repository entrysrc/Domains/Plus/CustomerCoupon/Repository/Repository.php:33-36).Eligibility predicate (five conditions, all required):
coupon.audience_id IN (customer audience ids)— audience targetingcoupons.is_sent = 1— code must have been distributedcoupons.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)
No redemption occurs here — the endpoint is read-only.
is_usedis not incremented. Redemption and usage tracking happen at cartapplyCoupon/ checkoutvalidateCoupon. See CF-13 Coupons.discount_percenttakes priority overdiscount_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 incoupon(database/initial/initial.sql:379-380).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.
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
Read-only, no reservation — footgun risk.
availableForAudiences()returns codes wherecoupons.is_used < coupon.max_count_usagebut 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.No DB-level test for eligibility SQL. The join, date-window OR-block,
is_used < max_count_usagepredicate, andGROUP BYinavailableForAudiences()(src/Domains/Plus/CustomerCoupon/Repository/Repository.php:46-61) are verified only through mocks inServiceTest. No integration or database test exercises the actual SQL. The same gap exists for the legacy originalgetCouponCodeForAudiences(ecommercen/coupons/models/Adv_coupons_model.php:1129-1163).GROUP BY nondeterminism / ONLY_FULL_GROUP_BY risk.
availableForAudiences()issuesGROUP BY coupon.idwhile selecting non-aggregated columns includingcoupons.coupon(the code string) (src/Domains/Plus/CustomerCoupon/Repository/Repository.php:46-61). Under MySQLONLY_FULL_GROUP_BYmode (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.Resource has no
isBackend()scope filtering — watch-point.Resource.phpemits 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 theisBackend()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.No pagination.
index()returns the full unbound result set (src/Rest/Plus/Controllers/CustomerCoupon.php:71-78). The controller does not useHandlesRestfulActions, so the standard pagination envelope is absent. Low cardinality is expected in practice (audience-targeted codes per customer), but there is no hard limit.Redundant duplicate index on
coupon_rules.KEY coupon_id_date_start_endandKEY coupon_id_lang_date_start_endcover 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.No FK constraints on join columns.
coupons.coupon_id,coupon_rules.coupon_id,coupon.audience_id,shop_customer_audience.audience_id, andshop_customer_audience.customer_idare 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
| File | What 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-136 | Data-provider row pins CustomerCoupon::index → ('customer', []). Asserts via PolicyResolver::resolve() (:148-158). |
Coverage Gaps
The following scenarios have no automated test coverage:
Controller registry-gate short-circuit — flag
IS_ENABLED_CUSTOMER_TAGOFF →{data:[]}path (src/Rest/Plus/Controllers/CustomerCoupon.php:66-69) is not exercised by any test.Controller 401 path — unauthenticated or zero-id customer scenario (
src/Rest/Plus/Controllers/CustomerCoupon.php:58-62) has no test.Repository SQL (DB/integration level) —
Repository::availableForAudiences()(src/Domains/Plus/CustomerCoupon/Repository/Repository.php:46-61) is covered only by mocks inServiceTest. No test exercises the actual join, date-window logic,is_used < max_count_usagepredicate, orGROUP BYagainst a real database. Same gap for legacygetCouponCodeForAudiences(ecommercen/coupons/models/Adv_coupons_model.php:1129-1163).Audience\Repository::getAudienceIdsForCustomer— no test at any level for the pivot SELECT (src/Domains/Plus/Audience/Repository/Repository.php:23-36).Resource shape — no test verifies camelCase field names, float casts for
discountPercent/discountPrice, orY-m-d H:i:sdate format emitted byvalidFrom/validTo(src/Rest/Plus/Resources/CustomerCoupon/Resource.php:26-39).
Related Flows
- AD-22 Audience & Campaigns — canonical home for the
SMART_RECOMMENDATIONS.IS_ENABLED_CUSTOMER_TAGregistry gate, audience CRUD, and the background jobs that populateshop_customer_audience - AD-08 Coupon Management — canonical schema and admin CRUD for
coupon,coupons, andcoupon_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