Happy House - Ecommerce Docs
Developer ResourcesPromotion

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

AreaFiles InspectedWhat Was Verified
Controllersapps/api/src/modules/promotion/customer/promotion-customer.controller.ts, admin/promotion-admin.controller.tsRoutes, methods, guards, permissions, rate limits
DTOsdto/*.tsValidation, field rules
Servicesshared/promotion-evaluation.*, promotion-redemption.service.tsEligibility, selection, gate
Schemapackages/db/src/schema/promotion/*.tsRules as columns, CHECKs
Error registryapps/api/src/common/types/error-codes.ts (// PROMOTION)PROMOTION_* codes

2. Module Summary

FieldValue
Module namepromotion
Module slugpromotion
Primary actorscustomer, admin
API surfacesmobile (evaluate), admin (CRUD)
Base route prefixes/api/mobile/promotions, /api/admin/promotions
Auth modelJwtAuthGuard (customer); JwtAuthGuard + RoleGuard (Promotions_*, admin)
PersistencePostgreSQL (promotion, promotion_redemption), Redis (30s automatic-set cache)
Runtime source of truthpromotion rows + live cart rows
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
stateThe only stored lifecycle value: draft/published/disabledschemaAdmin create/update
statusThe derived lifecycle: draft/scheduled/active/expired/disabledevaluation utilList filter, responses
discountPercentageBpsBasis points — 1000 = 10.00%schemaPercentage promotions
couponCodeIS NULL = automatic; normalised upper-case; single stringschemaEvaluate, admin
combinationModestackable / exclusiveschemaSelection
eligibilityWhether the submitted coupon applies — valid/reason/failedRulesevaluationEvaluate
shippingDiscountnull = not yet knowable (no address); 0 = nothing appliedevaluationEvaluate
usageRemainingReported only for a named couponevaluationEvaluate
versionOptimistic lock — required on PATCHschemaUpdate

4. API Surface Map

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
MobilePOST/api/mobile/promotions/evaluateCustomerJWT + IpThrottlePromotionCustomerControllerEvaluate a coupon/basket
AdminGET/api/admin/promotionsAdminJWT+RolePromotions_READPromotionAdminControllerList (derived status filter)
AdminGET/api/admin/promotions/:promotionIdAdminJWT+RolePromotions_READsameDetail
AdminPOST/api/admin/promotionsAdminJWT+RolePromotions_CREATEsameCreate
AdminPATCH/api/admin/promotions/:promotionIdAdminJWT+RolePromotions_UPDATEsameUpdate (version required)
AdminDELETE/api/admin/promotions/:promotionIdAdminJWT+RolePromotions_DELETEsameSoft delete (200)
AdminPOST/api/admin/promotions/:promotionId/restoreAdminJWT+RolePromotions_RESTOREsameRestore

{promotionId} is always the public_id (uuid v7); no integer id appears in a response.

5. Auth, Identity, and Permissions

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
EvaluateJwtAuthGuard, IpThrottlerGuardreq.user.idNoCUSTOMER_COUPON_ATTEMPT 20/min account-keyed
AdminJwtAuthGuard, RoleGuard, IpThrottlerGuardreq.userPromotions_*NoADMIN_READ 30/min, ADMIN_WRITE 10/min

6. DTO and Model Reference

6.1 EvaluatePromotionDto

FieldTypeRequiredValidationNotes
couponCodestringNo3–64 chars after trim; normalised upper-caseSingle string, never an array
districtIdUUID v7No@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)

RuleDetail
promotionTypediscountTypefree_shipping on one requires it on the other; neither may have it alone
fixed_amountrequires discountAmount (minor units), forbids discountPercentageBps
percentagerequires discountPercentageBps (1–10000), forbids discountAmount
free_shippingforbids both
maxDiscountAmountpercentage only
couponCodeomit for automatic — there is no isAutomatic flag; 3–64 chars, stored upper-cased
endsAtstrictly after startsAt; omit for open-ended
usageLimitPerCustomermust 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). sortcreatedAt | 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

EnumValueMeaningRuntime EffectSource
promotion_statedraft / published / disabledThe operator's stored choiceOnly these are writableenums.ts
promotion_status (derived)draft / scheduled / active / expired / disabledEffective lifecycleComputed vs clockevaluation util
promotion_typecart_discount / free_shippingWhat the promotion doesSelection per type
discount_typefixed_amount / percentage / free_shippingHow it discountsCHECK-tied to value columns
combination_modestackable / exclusiveCombinabilitySelection
eligibility.reason11 values (not_foundexclusive_conflict)Why a coupon failedResponseevaluation 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

  1. 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.
  2. discount.shippingDiscount: null means NOT YET KNOWABLE, not zero. null → "Free shipping unlocked — choose an address to see your saving." 0 → no free-shipping promotion applied at all. totalSavings excludes an unknowable waiver rather than counting it as zero.
  3. Every money value is INTEGER MINOR UNITS (20000 = Rs. 200.00); percentageBps is basis points (1000 = 10.00%, 750 = 7.5%).
  4. eligibility is about the coupon the customer submitted — even when other promotions applied; when no coupon was sent, valid means "any automatic promotion applied".
  5. Re-evaluate on every cart change — the result is computed from live rows and is never cached.

Rejection reasons

reasonMeaning
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_conflictPer §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

HTTPCodeCondition
404SHIPPING_DISTRICT_NOT_FOUNDExplicit 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

HTTPCodeCondition
409PROMOTION_COUPON_CODE_TAKENAnother live promotion holds that code
409PROMOTION_INVALID_CONFIGURATIONThe 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)

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /api/admin/promotionsoffset page/size (pagination=false accepted)20100createdAt (default), updatedAt, startsAt, endsAt, priority, usageCount, namestatus (derived), promotionType, discountType, combinationMode, couponCode, automatic, startsFrom, startsTo
EvaluatenonecouponCode, districtId

11. Caching, Jobs, and External Integrations

IntegrationUsed?Details
Redis cacheYesAutomatic promotion set only — 30s; coupon lookups deliberately not cached; evaluation never cached
BullMQNo (redemption sweep is a plain job)The lapsed-hold sweep returns total-limit slots
External APINo

13. Mandatory Deep API Documentation Pack

13.1 Route-by-Route Completeness Matrix

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsDB TouchesErrorsDocumented?
POST /api/mobile/promotions/evaluateevaluateEvaluatePromotionDtoPromotionEvaluationService.evaluateJWT+IpThrottleautomatic set (read)cart, promotions, district404 districtYes
GET /api/admin/promotionsfindAllquery DTOPromotionAdminService.listJWT+Role+IpThrottlePromotions_READpromotionsYes
GET /api/admin/promotions/:promotionIdfindByIdparams DTO…findOnesamePromotions_READpromotion404Yes
POST /api/admin/promotionscreateCreatePromotionDto…createsamePromotions_CREATEclear automaticpromotion409Yes
PATCH /:promotionIdupdateUpdatePromotionDto…updatesamePromotions_UPDATEclear automaticpromotion404/409Yes
DELETE /:promotionIddeleteparams DTO…deletesamePromotions_DELETEclear automaticpromotion404Yes
POST /:promotionId/restorerestoreparams DTO…restoresamePromotions_RESTOREclear automaticpromotion404/409Yes

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

ConsumerRequired KnowledgeFailure HandlingContract Stability
Web frontendIneligible = 200; shippingDiscount null vs 0; basis points; re-evaluate per cart changeBranch on eligibility, never statusStable
Mobile appCUSTOMER_COUPON_ATTEMPT 20/min account-keyed429 → back offStable
Admin panelstate editable, status read-only badge; version required on PATCH409 VERSION_CONFLICT → re-fetch, diff, re-applyStable
QADerived lifecycle, blanked drafts, selection orderReproduce via exact codesStable
Checkout (future)Redemption contract (reserve/confirm/release); own the release edgeBranch on the three registered redemption codesStable

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Derived lifecycleComputed vs clockStored status + cronNo stale payout windowCompute per readCheap
No isAutomatic flagcoupon_code IS NULLFlagNo disagreeing factsDocumented
Basis pointsInteger precisionWhole percent7.5% expressibleUnit confusionCHECK-tied columns
Two value columnsType-driven unitsPolymorphic discount_valueNo ×100 misreadsCHECK
Ineligible = 200Business outcome4xxClients branch on eligibilityStatus-code readersDocumented
Coupon uncachedImmediate disable30s cache bothNo exploit windowLookup costAccepted
Redemption internalNo page-load consumptionHTTP endpointsFrozen checkout contractNobody calls it yetTests + docs
No scopesNo empty enum membersPlaceholder scopesNothing can produce themDocumented

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
New promotion typesNoneEngine-onlyNoneNoNo 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.

MethodPathPermission
GET/api/admin/special-dealsPromotions_READ
GET/api/admin/special-deals/{publicId}Promotions_READ
POST/api/admin/special-dealsPromotions_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

  1. state is draft | published | cancelled — admin intent ONLY. There is no active or ended state 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.
  2. Liveness is the window. startsAt inclusive, endsAt exclusive, so two back-to-back campaigns sharing a boundary instant never both apply.
  3. 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.
  4. A target names exactly one of product, brand or brand series — chk_special_deal_target_exactly_one.
  5. Percentages are basis points. 2000 is 20.00%, 750 is 7.5%.
  6. 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.
  7. Cancelling takes effect immediately — the admin write invalidates the resolver's cache.

Error codes

errorCodeHTTPMeaning
SPECIAL_DEAL_NOT_FOUND404
SPECIAL_DEAL_VERSION_CONFLICT409Optimistic lock; re-read
SPECIAL_DEAL_TARGET_INVALID400Names none, or more than one, of product/brand/series
SPECIAL_DEAL_TARGET_DUPLICATE409Already in this campaign
SPECIAL_DEAL_WINDOW_INVALID400endsAt is not after startsAt
SPECIAL_DEAL_HIGH_DISCOUNT_UNCONFIRMED400Above the safety threshold — resend with confirmHighDiscount: true

See Also