Promotion API Reference
Complete API contracts for the Promotion module, including routes, auth, DTOs, responses, errors, examples, and integration notes.
Promotion - API Reference
Audience: Frontend engineers, mobile engineers, backend engineers, QA, and API consumers. Scope: The evaluate route and the six admin promotion routes.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/promotion/customer/promotion-customer.controller.ts, admin/promotion-admin.controller.ts | Routes, methods, guards, permissions, rate limits |
| DTOs | dto/*.ts | Validation, field rules |
| Services | shared/promotion-evaluation.*, promotion-redemption.service.ts | Eligibility, selection, gate |
| Schema | packages/db/src/schema/promotion/*.ts | Rules as columns, CHECKs |
| Error registry | apps/api/src/common/types/error-codes.ts (// PROMOTION) | PROMOTION_* codes |
2. Module Summary
| Field | Value |
|---|---|
| Module name | promotion |
| Module slug | promotion |
| Primary actors | customer, admin |
| API surfaces | mobile (evaluate), admin (CRUD) |
| Base route prefixes | /api/mobile/promotions, /api/admin/promotions |
| Auth model | JwtAuthGuard (customer); JwtAuthGuard + RoleGuard (Promotions_*, admin) |
| Persistence | PostgreSQL (promotion, promotion_redemption), Redis (30s automatic-set cache) |
| Runtime source of truth | promotion rows + live cart rows |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
state | The only stored lifecycle value: draft/published/disabled | schema | Admin create/update |
status | The derived lifecycle: draft/scheduled/active/expired/disabled | evaluation util | List filter, responses |
discountPercentageBps | Basis points — 1000 = 10.00% | schema | Percentage promotions |
couponCode | IS NULL = automatic; normalised upper-case; single string | schema | Evaluate, admin |
combinationMode | stackable / exclusive | schema | Selection |
eligibility | Whether the submitted coupon applies — valid/reason/failedRules | evaluation | Evaluate |
shippingDiscount | null = not yet knowable (no address); 0 = nothing applied | evaluation | Evaluate |
usageRemaining | Reported only for a named coupon | evaluation | Evaluate |
version | Optimistic lock — required on PATCH | schema | Update |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Mobile | POST | /api/mobile/promotions/evaluate | Customer | JWT + IpThrottle | — | PromotionCustomerController | Evaluate a coupon/basket |
| Admin | GET | /api/admin/promotions | Admin | JWT+Role | Promotions_READ | PromotionAdminController | List (derived status filter) |
| Admin | GET | /api/admin/promotions/:promotionId | Admin | JWT+Role | Promotions_READ | same | Detail |
| Admin | POST | /api/admin/promotions | Admin | JWT+Role | Promotions_CREATE | same | Create |
| Admin | PATCH | /api/admin/promotions/:promotionId | Admin | JWT+Role | Promotions_UPDATE | same | Update (version required) |
| Admin | DELETE | /api/admin/promotions/:promotionId | Admin | JWT+Role | Promotions_DELETE | same | Soft delete (200) |
| Admin | POST | /api/admin/promotions/:promotionId/restore | Admin | JWT+Role | Promotions_RESTORE | same | Restore |
{promotionId} is always the public_id (uuid v7); no integer id appears in a response.
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Evaluate | JwtAuthGuard, IpThrottlerGuard | req.user.id | — | No | CUSTOMER_COUPON_ATTEMPT 20/min account-keyed |
| Admin | JwtAuthGuard, RoleGuard, IpThrottlerGuard | req.user | Promotions_* | No | ADMIN_READ 30/min, ADMIN_WRITE 10/min |
6. DTO and Model Reference
6.1 EvaluatePromotionDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
couponCode | string | No | 3–64 chars after trim; normalised upper-case | Single string, never an array |
districtId | UUID v7 | No | @IsUUID("7") | Makes free-shipping value knowable; omitted → customer's default address |
6.2 CreatePromotionDto — field rules (database-enforced; a 409 is worse than a disabled submit button)
| Rule | Detail |
|---|---|
promotionType ↔ discountType | free_shipping on one requires it on the other; neither may have it alone |
fixed_amount | requires discountAmount (minor units), forbids discountPercentageBps |
percentage | requires discountPercentageBps (1–10000), forbids discountAmount |
free_shipping | forbids both |
maxDiscountAmount | percentage only |
couponCode | omit for automatic — there is no isAutomatic flag; 3–64 chars, stored upper-cased |
endsAt | strictly after startsAt; omit for open-ended |
usageLimitPerCustomer | must not exceed usageLimit |
6.3 UpdatePromotionDto
Every field optional except version, which is REQUIRED. state is the only editable lifecycle field — a payload trying to set status is rejected.
6.4 Query DTO
GET /api/admin/promotions: standard QueryDto (pagination, page, size, sort, order, search) — pagination=false IS accepted (unlike cart; the table is small). sort ∈ createdAt | updatedAt | startsAt | endsAt | priority | usageCount | name (default createdAt). Filters: status (derived, not the stored column), promotionType, discountType, combinationMode, couponCode, automatic, startsFrom, startsTo (date-only startsTo covers the whole day).
6.5 Response DTOs
Evaluate response per §8.1. Admin promotion response carries state, the derived status, all rule columns, usageCount, version, and deletedAt when retired.
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
promotion_state | draft / published / disabled | The operator's stored choice | Only these are writable | enums.ts |
promotion_status (derived) | draft / scheduled / active / expired / disabled | Effective lifecycle | Computed vs clock | evaluation util |
promotion_type | cart_discount / free_shipping | What the promotion does | Selection per type | |
discount_type | fixed_amount / percentage / free_shipping | How it discounts | CHECK-tied to value columns | |
combination_mode | stackable / exclusive | Combinability | Selection | |
eligibility.reason | 11 values (not_found … exclusive_conflict) | Why a coupon failed | Response | evaluation util |
8. Endpoint Reference
8.1 POST /api/mobile/promotions/evaluate
Purpose
Evaluate a coupon (and automatic promotions) against the caller's live cart. A preview — nothing is redeemed, no usage limit is consumed by loading a page.
Auth and Permissions
JwtAuthGuard; CUSTOMER_COUPON_ATTEMPT 20/min account-keyed (a coupon code is a bearer value).
Request
{ "couponCode": "SAVE20", "districtId": "0191c2a4-…" }Both optional. couponCode is case-insensitive and whitespace-tolerant, normalised to upper-case; a single string, never an array. districtId makes a free-shipping promotion's value knowable; omitted → the customer's default address; an explicit uuid that names no district is a 404.
Response — 200
{
"success": true,
"message": "SAVE20 applied — Rs. 200.00 off and free shipping, saving Rs. 150.00.",
"data": {
"summary": { "applied": true, "automatic": false, "couponCode": "SAVE20",
"promotionType": "cart_discount", "message": "…" },
"discount": { "type": "fixed_amount", "amount": 20000, "percentageBps": null,
"shippingDiscount": 15000, "totalSavings": 35000, "currency": "NPR" },
"eligibility": { "valid": true, "reason": null, "failedRules": [] },
"promotions": { "applied": [ /* applied entries with reason: null */ ],
"rejected": [], "stackable": false },
"metadata": { "startsAt": "…", "expiresAt": "…", "priority": 0,
"usageRemaining": 87, "evaluatedAt": "…" }
}
}The five things a client must get right
- An ineligible coupon is a 200, not a 4xx. Branch on
eligibility.valid/eligibility.reason— never the status code. A client that treats a rejection as an error path shows a crash dialog for a customer who simply mistyped a code. discount.shippingDiscount: nullmeans NOT YET KNOWABLE, not zero.null→ "Free shipping unlocked — choose an address to see your saving."0→ no free-shipping promotion applied at all.totalSavingsexcludes an unknowable waiver rather than counting it as zero.- Every money value is INTEGER MINOR UNITS (
20000= Rs. 200.00);percentageBpsis basis points (1000= 10.00%,750= 7.5%). eligibilityis about the coupon the customer submitted — even when other promotions applied; when no coupon was sent,validmeans "any automatic promotion applied".- Re-evaluate on every cart change — the result is computed from live rows and is never cached.
Rejection reasons
reason | Meaning |
|---|---|
not_found / draft / disabled / not_started / expired / usage_limit_reached / customer_limit_reached / minimum_order_not_met / maximum_order_exceeded / minimum_quantity_not_met / superseded / exclusive_conflict | Per §7 + consumer handoff |
failedRules carries every failing rule (fixing one must not surprise with the next), and is deliberately empty for lifecycle reasons — a campaign the customer cannot use does not disclose its thresholds.
A rejected entry for draft, disabled or not_started is deliberately blank — empty name, promotionType, discountType, combinationMode, description: null; only id, couponCode and reason carry anything. Do not render the name for those three. expired is not blanked — that campaign WAS public.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 404 | SHIPPING_DISTRICT_NOT_FOUND | Explicit districtId names no district |
Everything else — including an unknown coupon — is a 200 with eligibility.valid: false.
8.2 GET /api/admin/promotions
Purpose
List with derived-status filtering — ?status=active filters in SQL against the database clock, so the list can never show a campaign as active after it has ended. pagination=false IS accepted.
Auth and Permissions
Promotions_READ; ADMIN_READ 30/min.
8.3 GET /api/admin/promotions/:promotionId
Detail. 404 PROMOTION_NOT_FOUND.
8.4 POST /api/admin/promotions
Purpose
Create a promotion. Field rules per §6.2.
Auth and Permissions
Promotions_CREATE; ADMIN_WRITE 10/min.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 409 | PROMOTION_COUPON_CODE_TAKEN | Another live promotion holds that code |
| 409 | PROMOTION_INVALID_CONFIGURATION | The configuration contradicts itself (field rules) |
8.5 PATCH /api/admin/promotions/:promotionId
Purpose
Update. version is REQUIRED — a concurrent edit makes it stale → 409 PROMOTION_VERSION_CONFLICT with the current version in the message. Every other field optional; state is the only editable lifecycle field.
8.6 DELETE /api/admin/promotions/:promotionId
Soft delete (200). Redemption history kept (a hard delete of anything redeemed is refused by the database); the coupon code is released for reuse.
8.7 POST /api/admin/promotions/:promotionId/restore
Restore (200). Can fail with 409 PROMOTION_COUPON_CODE_TAKEN if another promotion claimed the code while this one was retired — the one restore failure worth building a message for.
9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence (evaluate)
9.3 Error Branch (evaluate)
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/admin/promotions | offset page/size (pagination=false accepted) | 20 | 100 | createdAt (default), updatedAt, startsAt, endsAt, priority, usageCount, name | status (derived), promotionType, discountType, combinationMode, couponCode, automatic, startsFrom, startsTo | — |
| Evaluate | none | — | — | — | couponCode, districtId | — |
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details |
|---|---|---|
| Redis cache | Yes | Automatic promotion set only — 30s; coupon lookups deliberately not cached; evaluation never cached |
| BullMQ | No (redemption sweep is a plain job) | The lapsed-hold sweep returns total-limit slots |
| External API | No | — |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | DB Touches | Errors | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|
POST /api/mobile/promotions/evaluate | evaluate | EvaluatePromotionDto | PromotionEvaluationService.evaluate | JWT+IpThrottle | — | automatic set (read) | — | cart, promotions, district | 404 district | Yes |
GET /api/admin/promotions | findAll | query DTO | PromotionAdminService.list | JWT+Role+IpThrottle | Promotions_READ | — | — | promotions | — | Yes |
GET /api/admin/promotions/:promotionId | findById | params DTO | …findOne | same | Promotions_READ | — | — | promotion | 404 | Yes |
POST /api/admin/promotions | create | CreatePromotionDto | …create | same | Promotions_CREATE | clear automatic | — | promotion | 409 | Yes |
PATCH /:promotionId | update | UpdatePromotionDto | …update | same | Promotions_UPDATE | clear automatic | — | promotion | 404/409 | Yes |
DELETE /:promotionId | delete | params DTO | …delete | same | Promotions_DELETE | clear automatic | — | promotion | 404 | Yes |
POST /:promotionId/restore | restore | params DTO | …restore | same | Promotions_RESTORE | clear automatic | — | promotion | 404/409 | Yes |
13.2 Request/Response Exhaustiveness
Covered in §8: minimal/full create payloads (§6.2/8.4), the applied evaluation response (§8.1), the ineligible-but-200 response (§8.1 point 1), the shippingDiscount: null shape (§8.1 point 2), the blanked-draft rejection (§8.1), domain errors per endpoint (§8 error tables), rate-limit behavior (20/min coupon budget), admin permission errors (403).
13.3 API Diagram Pack
Route ownership (§9.1), request sequence (§9.2, backend §7.1), error decision tree (§9.3), cache flow (backend §8), async/job flow (backend §9 — the lapsed-hold sweep).
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Web frontend | Ineligible = 200; shippingDiscount null vs 0; basis points; re-evaluate per cart change | Branch on eligibility, never status | Stable |
| Mobile app | CUSTOMER_COUPON_ATTEMPT 20/min account-keyed | 429 → back off | Stable |
| Admin panel | state editable, status read-only badge; version required on PATCH | 409 VERSION_CONFLICT → re-fetch, diff, re-apply | Stable |
| QA | Derived lifecycle, blanked drafts, selection order | Reproduce via exact codes | Stable |
| Checkout (future) | Redemption contract (reserve/confirm/release); own the release edge | Branch on the three registered redemption codes | Stable |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
| Derived lifecycle | Computed vs clock | Stored status + cron | No stale payout window | Compute per read | Cheap |
No isAutomatic flag | coupon_code IS NULL | Flag | No disagreeing facts | — | Documented |
| Basis points | Integer precision | Whole percent | 7.5% expressible | Unit confusion | CHECK-tied columns |
| Two value columns | Type-driven units | Polymorphic discount_value | No ×100 misreads | — | CHECK |
| Ineligible = 200 | Business outcome | 4xx | Clients branch on eligibility | Status-code readers | Documented |
| Coupon uncached | Immediate disable | 30s cache both | No exploit window | Lookup cost | Accepted |
| Redemption internal | No page-load consumption | HTTP endpoints | Frozen checkout contract | Nobody calls it yet | Tests + docs |
| No scopes | No empty enum members | Placeholder scopes | Nothing can produce them | — | Documented |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
| New promotion types | None | Engine-only | None | No | No API change per type |
14. Zero-Omission API Checklist
- Every controller route is documented (§4, §8, §13.1).
- Every parent route prefix and runtime URL is documented (§2, §4).
- Every DTO field, enum, default, transform and validator is documented (§6, §7).
- Every response field and nullable field is documented (§6.5, §8.1).
- Every auth, guard, permission and guest identity branch is documented (§5).
- Every success, validation, not-found, conflict, rate-limit and server-error branch is documented (§8).
- Every DB read/write, cache hit/miss/invalidation and external call is documented (§11, backend §8).
- Every route has examples for minimal request, success response and representative failures (§8).
- Every endpoint family has route, sequence and error diagrams (§9, backend §7).
- Every tradeoff and compatibility risk is documented (§13.5, §13.6).
- The API doc links to backend and features/flows (§1, See Also).
15. Integration Checklist
- Every route from controllers is documented.
- Every DTO field is documented.
- Every enum value is documented.
- Every response envelope is documented.
- Every error code is documented.
- Every auth guard and permission is documented.
- Every cache key, queue job and external call is documented.
- Every diagram matches the current code.
- The API doc links to backend and features/flows.
Special deals
A special deal is a campaign that applies a discount percentage to products, brands or brand series. It is a different mechanism from a promotion and shares no code with one: a promotion discounts a basket subtotal and is redeemed at checkout; a special deal changes a line price and is never redeemed at all.
| Method | Path | Permission |
|---|---|---|
GET | /api/admin/special-deals | Promotions_READ |
GET | /api/admin/special-deals/{publicId} | Promotions_READ |
POST | /api/admin/special-deals | Promotions_CREATE |
PATCH | /api/admin/special-deals/{publicId} | Promotions_UPDATE |
DELETE | /api/admin/special-deals/{publicId} | Promotions_DELETE |
There is no customer route. A campaign is applied at read time to every storefront price, so a
customer sees its effect on pricing.effectivePrice and pricing.deal without asking for it.
The rules
stateisdraft | published | cancelled— admin intent ONLY. There is noactiveorendedstate and no worker decides whether a campaign is live. A stored status would need a job to flip it, and a failed activation run would charge a customer MORE than was advertised.- Liveness is the window.
startsAtinclusive,endsAtexclusive, so two back-to-back campaigns sharing a boundary instant never both apply. - The highest discount wins, full stop. No specificity rule, no priority. A product-level 10% and a brand-level 25% covering the same product resolve to 25%. Ties cannot matter, because equal percentages give equal prices.
- A target names exactly one of product, brand or brand series —
chk_special_deal_target_exactly_one. - Percentages are basis points.
2000is 20.00%,750is 7.5%. - A coupon still stacks. The deal sets the line price and the coupon discounts the subtotal, so 20% then 10% nets 28%, not 30%. Compounding is intended.
- Cancelling takes effect immediately — the admin write invalidates the resolver's cache.
Error codes
| errorCode | HTTP | Meaning |
|---|---|---|
SPECIAL_DEAL_NOT_FOUND | 404 | |
SPECIAL_DEAL_VERSION_CONFLICT | 409 | Optimistic lock; re-read |
SPECIAL_DEAL_TARGET_INVALID | 400 | Names none, or more than one, of product/brand/series |
SPECIAL_DEAL_TARGET_DUPLICATE | 409 | Already in this campaign |
SPECIAL_DEAL_WINDOW_INVALID | 400 | endsAt is not after startsAt |
SPECIAL_DEAL_HIGH_DISCOUNT_UNCONFIRMED | 400 | Above the safety threshold — resend with confirmHighDiscount: true |
See Also
- Backend doc: /docs/developer/promotion/backend
- Features and flows doc: /docs/developer/promotion/feature
- TDD: not yet published