07 / discounts · coupons · promotions · quote tokens
One selected discount.
Separate time controls.
Current working-tree behavior inspected on 8 Oct 2026. Follow each offer from creation to preview, checkout, redemption and recurring settlement. Pending defects are drawn as existing behavior, with concrete numbers and source citations.
Reading the evidence: every diagram node and detail table points to source. Links open repository files; the displayed suffix gives the source line. These are local, uncommitted working-tree findings, rather than a claimed committed snapshot or gateway execution. Free trial claims are a separate lifecycle; the paid-plan discount paths are shown here.
Plan discounts and the full-cycle price
The plan’s monthly, quarterly and yearly percentage reductions are already baked into its stored per-asset rates. A coupon or promotion is then calculated against that rate’s full cycle.
| Control | Actual calculation / example | Source |
|---|---|---|
| Plan interval discount | A base ₹250 rate with 20% plan discount becomes ₹200 before tax. With 18% tax, the stored rate is ₹236. A later 10% coupon is calculated from the tax-inclusive cycle base. | siem-admin-new/app/dashboard/plans/page.tsx:207 |
| Full-cycle base | At a stored ₹200 rate: 5 assets monthly = ₹1,000. At a quarterly rate of ₹200: 5 × ₹200 × 3 = ₹3,000. Interval rates can differ; the selected interval’s own stored rate is used. | siem-server-new/src/services/subscription.service.ts:52 |
| Percentage coupon / promotion | 20% of ₹1,000 = ₹200. The amount is capped at the cycle base and rounded to two decimal places. | siem-server-new/src/services/subscription.service.ts:120 |
| Fixed coupon / promotion | ₹500 against a ₹3,000 quarterly cycle gives ₹2,500. The ₹500 is deducted once per cycle, rather than once per asset or each month within that cycle. | siem-server-new/src/services/subscription.service.ts:120 |
| Currency | Fixed discounts must match USD or INR. Percentage discounts have no currency amount. ₹1,000 fixed against a ₹600 base produces a ₹600 discount and a zero charge. | siem-server-new/src/services/subscription.service.ts:238 |
| Tax and gateway amount | No second tax addition occurs at checkout. Major units become cents/paise with Math.round(amount × 100). Razorpay’s mandate ceiling uses the full base plus headroom, before the coupon deduction. | siem-server-new/src/services/subscription.service.ts:653 · siem-server-new/src/services/subscription.service.ts:78 · siem-server-new/src/services/subscription.service.ts:666 |
Manual coupons: public code, account use and limits
A Super Admin creates a reusable code. It is not assigned to an account. Any eligible authenticated customer can attempt it; the configured global limit and one-use-per-user rule apply.
| Rule | Current behavior | Source |
|---|---|---|
| Administration | Create/list/toggle/delete coupon endpoints require isSuperAdmin. Manual codes are 3–50 characters, uppercase letters, digits, underscore or hyphen; value must be positive and percentage cannot exceed 100. | siem-server-new/src/routes/admin.routes.ts:89 · siem-server-new/src/validators/discountSchema.ts:5 |
| Eligibility | Coupon exists, is active, starts no later than now, has not passed its end date, matches a restricted plan and fixed currency, has global capacity, and has no active booking or successful redemption for this user. A calculated zero discount is rejected. | siem-server-new/src/services/subscription.service.ts:226 |
| Plan restriction | null, an empty array, and a non-array value are interpreted as all plans. A non-empty array restricts the coupon to its listed plan IDs. | siem-server-new/src/services/subscription.service.ts:135 |
| Global usage | maxRedemptions = null means unlimited global uses. A finite cap checks redemptionCount + bookingCount under the coupon lock before reserving a slot. | siem-server-new/src/services/subscription.service.ts:246 |
| Per-user usage | The unique (couponId, userId) pair prevents the same user redeeming the same coupon again, including on another subscription. The coupon row lock and booking counter also enforce the global cap across different users. | siem-server-new/prisma/schema.prisma:778 |
| Duration | cyclesTotal = null means the stored discount continues every cycle. A finite number includes signup as cycle 1 of the allowance. | siem-server-new/src/services/discount.service.ts:25 · siem-server-new/src/payment/processor/PaymentEventProcessor.ts:423 |
| Deleting / toggling | A coupon with checkout or redemption history cannot be deleted; it must be deactivated. Deactivation blocks future eligibility checks and does not erase a subscription’s copied amount. | siem-server-new/src/services/discount.service.ts:46 · siem-server-new/src/services/subscription.service.ts:98 |
| Code vs quote JWT | A manual coupon code is the separate account-independent mechanism. A quote JWT locates a contact and its linked coupon; these are distinct inputs, not interchangeable token formats. | siem-server-new/prisma/schema.prisma:744 · siem-server-new/src/utils/auth.ts:88 |
Selected only: only an applied coupon code holds a slot; removing it or choosing the quote or a promotion releases the slot. A selected promotion creates no CouponRedemption. siem-server-new/src/services/discountSelection.service.ts:69
Promotions: automatic offers without a code
Promotions share the same fixed/percentage amount calculation but have their own active date range and no coupon redemption ledger.
| Question | Current answer | Source |
|---|---|---|
| What does the customer enter? | No code. Every running promotion for the plan and currency is shown as a choice beside the quote; the customer selects one. Without a quote the largest promotion is selected by default. | siem-server-new/src/services/discountSelection.service.ts:286 |
| What makes it eligible? | Active flag, inclusive start/end timestamps, plan applicability and fixed-currency match. Non-positive calculated amounts are skipped. | siem-server-new/src/services/subscription.service.ts:159 |
| Are there usage limits? | The Promotion model has no maxRedemptions or redemptionCount fields and no CouponRedemption relation. One-use-per-user validation belongs to coupons. | siem-server-new/prisma/schema.prisma:808 |
| How are several promotions ordered? | Largest per-cycle amount first; equal amounts by name. A selected promotion that ends before checkout falls back to the quote, else the next running promotion. | siem-server-new/src/services/discountSelection.service.ts:286 |
| What if a new promotion starts later? | Existing renewals use the subscription’s stored discount; they do not search for a new promotion. The active range controls selection for a new purchase. | siem-server-new/src/services/subscription.service.ts:98 |
| What if the promotion ends or is switched off? | A subscription that already copied its amount and cycle allowance keeps them until its own allowance runs out. Promotion expiry is not subscription discount expiry. | siem-server-new/src/services/discount.service.ts:110 · siem-server-new/src/services/subscription.service.ts:698 |
Quote tokens: contact link, custom plan and linked coupon
The JWT contains a contact ID and a custom_quote type. It carries no discount value. The server loads the contact’s plan and coupon to calculate the discount.
| Quote property | Actual rule | Source |
|---|---|---|
| Discount on a custom plan | A custom quote can have no coupon. A quote on a standard plan requires a positive discount, and that plan must be active and paid. | siem-server-new/src/services/plan.service.ts:759 |
| Coupon created for an offer | Random QUOTE- code; fixed/percentage value; quoted plan only; maxRedemptions = 1; validity from creation to the quote's expiry (none when the quote never expires); finite cycles or null for LIFETIME. | siem-server-new/src/services/plan.service.ts:796 |
| Where the percentage is calculated | JWT → contact_id → ContactUs.couponId → Coupon.discountType and discountValue → percentage × selected full-cycle base, or the fixed amount. The JWT itself is not an amount. | siem-server-new/src/services/subscription.service.ts:572 · siem-server-new/src/services/subscription.service.ts:617 · siem-server-new/src/services/subscription.service.ts:120 |
| What the quote email prices | The email lists the per-asset price of each interval. A percentage discount is applied in that price; a fixed discount is stated separately as an amount off the total of each billing cycle, because it does not scale with assets. D16 resolved. | siem-server-new/src/services/plan.service.ts:818 |
| D16 example | ₹200 per asset monthly with a fixed ₹100 offer: the email shows “₹200.00 / asset” and “₹100.00 off the total of each billing cycle”. Checkout with 5 assets is ₹1,000 − ₹100 = ₹900, matching what the email states. | siem-server-new/src/services/plan.service.ts:820 · siem-server-new/src/services/subscription.service.ts:669 |
| Email duration wording | The quote email says “N billing cycles, including signup” (or “Every billing cycle”). A three-cycle offer covers signup plus two renewals, the same wording as the calculator. | siem-server-new/src/services/plan.service.ts:834 · siem-server-new/src/payment/processor/PaymentEventProcessor.ts:423 · siem-client-new/components/reusable/price-calculator/index.tsx:1115 |
| Quote currency — D58 resolved | The contact form stores the inquiry's company registration country. It fixes the quote currency (IN: INR, others: USD); the admin does not choose it. Checkout through the quote link is fixed to the same country, so a quote for a company registered in India is priced, emailed and charged in INR. | siem-server-new/src/services/plan.service.ts:806 · siem-server-new/src/services/subscription.service.ts:238 |
Separate controls to remember
| Control | Expires / ends when | Effect |
|---|---|---|
| Quote | The admin's chosen date and time (entered in local time, stored as UTC), or never. The quote link (JWT) carries no expiry of its own. | After ContactUs.quoteExpiresAt the quote link, its custom plan and its discount are refused. It does not expire a stored subscription discount. siem-server-new/src/utils/auth.ts:83 |
| Quote-created coupon | validTo = the quote's expiry (none when the quote never expires). | The quote and its coupon end together. siem-server-new/src/services/plan.service.ts:796 |
| Discount selection | 35 minutes after the customer's last action, never beyond 24 hours from the first selection. | Holds the selected discount (and a coupon code's slot) until checkout. siem-server-new/src/services/couponBooking.service.ts:13 |
| Stored subscription discount | N successful discounted settlements including signup, or null = every cycle. | Controls future subtraction after purchase. Coupon validTo and promotion endDate are not read by renewal’s amount helper. siem-server-new/src/services/subscription.service.ts:98 · siem-server-new/src/payment/processor/PaymentEventProcessor.ts:428 |
A custom quote without a coupon still has the JWT expiry. getActivePlans only populates quoteExpiresAt from an eligible coupon, so that response field does not communicate every quote token’s expiry. siem-server-new/src/services/plan.service.ts:514 · siem-server-new/src/services/plan.service.ts:557
Quote link: customer routing, plan visibility and enforcement gaps
A quote link selects the quoted plan. The public list excludes other custom plans. Token checks differ between public validation, plan loading and checkout; the diagram does not treat them as a single universal guard.
| Boundary | Enforced checks and current gaps | Source |
|---|---|---|
| Public quote validation | Verifies JWT, contact/plan and quote status. The email-match guard only runs when userEmail is supplied; the public controller calls it without userEmail. validateQuote does not compare the token with contact.quoteToken. | siem-server-new/src/controllers/plan.controller.ts:107 · siem-server-new/src/services/plan.service.ts:888 · siem-server-new/src/services/plan.service.ts:914 |
| Public plan list | getActivePlans compares the JWT with the contact’s current stored quoteToken, rejects accepted/expired quotes, requires an active quoted plan, and adds only that plan to the standard public list. It returns raw rates plus quote discount metadata. | siem-server-new/src/services/plan.service.ts:508 |
| Authenticated checkout | The authenticated user is used for coupon redemption. Checkout verifies the JWT, requires SENT, checks the current stored token and selected plan, and matches contact.email to the authenticated user. | siem-server-new/src/services/subscription.service.ts:572 |
| Quote coupon as a typed code | QUOTE coupons additionally require their linked SENT contact, current valid quote JWT and the authenticated account’s email. Manual codes remain account-independent. | siem-server-new/src/services/subscription.service.ts:204 · siem-server-new/prisma/schema.prisma:744 |
| Expired / invalid JWT in checkout | An expired or invalid quote JWT rejects checkout. A custom plan requires a valid quote token. | siem-server-new/src/services/subscription.service.ts:574 |
| Revocation | revokeQuote deactivates the linked coupon and clears the contact’s token/coupon link. A subscription’s already copied discount is still used by resolveCycleChargeAmount. | siem-server-new/src/services/plan.service.ts:1057 · siem-server-new/src/services/subscription.service.ts:98 |
| Acceptance timing — D48 resolved in source | The first successful settlement transaction marks the quote ACCEPTED. A failed or abandoned checkout does not accept it. Internal ₹0 settlement follows the same rule. | siem-server-new/src/payment/processor/PaymentEventProcessor.ts:438 · siem-server-new/src/services/subscription.service.ts:581 |
The customer selects one discount
The customer chooses the quote, one running promotion, or a coupon code. Nothing stacks and nothing competes. The server holds the selection for 35 minutes, restarted by every action, never beyond 24 hours.
| Situation | Implemented outcome | Source |
|---|---|---|
| Quote 25% and promotion 15% both available on ₹1,000 | Both are shown; the quote is selected by default (charge ₹750). The customer can switch to the promotion (charge ₹850). | siem-server-new/src/services/discountSelection.service.ts:125 |
| Two promotions running | Each is a separate choice, largest per-cycle amount first; without a quote the largest is selected by default. | siem-server-new/src/services/discountSelection.service.ts:286 |
| Coupon code applied | The slot is reserved at once under the coupon lock; quote and promotions are hidden until Remove coupon, which returns to quote, else the largest promotion. An invalid or fully booked code shows its error and changes nothing. | siem-server-new/src/services/couponBooking.service.ts:38 |
| Selected discount's end date passes before Checkout | Fallback: quote → largest promotion; promotion → quote → next promotion; coupon → quote → largest promotion; else no discount. The customer sees “Your selected discount is no longer available.” | siem-server-new/src/services/discountSelection.service.ts:196 |
| Admin switches the selected discount off while the selection lives | The customer keeps it until checkout. A discount selected afterwards must be active. | siem-server-new/src/services/discountSelection.service.ts:196 |
| Price at Checkout differs from the price shown | 409 PRICE_CHANGED with the new price; the customer confirms before paying. The client's amount is only compared, never charged. | siem-server-new/src/services/subscription.service.ts:444 |
| Unusable quote discount (wrong currency, expired coupon) — D47, D58 resolved | Not offered; the selection uses a promotion or no discount. A custom plan stays purchasable while its quote itself is usable. | siem-server-new/src/services/discountSelection.service.ts:69 |
| Public pricing page (not signed in) | Shows the largest running promotion; nothing is held and no coupon field is shown. | siem-server-new/src/services/discountSelection.service.ts:171 |
Stored discount: signup, renewals, retries and expiry
Company registration country fixes currency at checkout: India uses INR; other supported countries use USD. Both fixed and percentage offers become a currency amount copied to the subscription at checkout. Normal renewals and retries subtract that stored amount while the subscription’s discount allowance remains.
| Path / event | Amount and cycle behavior | Source |
|---|---|---|
| Three-cycle example | Base ₹1,000, stored discount ₹200, cyclesTotal = 3: signup ₹800 (used 1), renewal 1 ₹800 (used 2), renewal 2 ₹800 (used 3), next renewal ₹1,000. A failed attempt between these successes consumes no extra allowance. | siem-server-new/src/payment/processor/PaymentEventProcessor.ts:423 · siem-server-new/src/services/subscription.service.ts:98 |
| Percentage after a price change | 20% of ₹1,000 stores ₹200. If an admin pushes ₹1,200 while allowance remains, renewal is ₹1,200 − ₹200 = ₹1,000. It does not recalculate to ₹960. This is the current stored-amount design; no new percentage policy is assumed. | siem-server-new/src/services/subscription.service.ts:120 · siem-server-new/src/services/subscription.service.ts:698 · siem-server-new/src/services/subscription.service.ts:105 |
| Coupon expires / promotion ends after purchase | The original definition’s dates and active flag are not queried by the normal cycle amount helper. The copied amount and cycle allowance remain effective. | siem-server-new/src/services/subscription.service.ts:98 |
| Signup is fully discounted | No gateway call. The service records a ₹0 invoice/payment, increments discountCyclesUsed once, provisions the customer and lands the paid subscription in PENDING_SETUP for admin activation. | siem-server-new/src/services/subscription.service.ts:719 |
| Fully discounted renewal | The billing worker emits an internal PAYMENT_SUCCEEDED with a deterministic cycle ID. The processor records settlement and consumes a discount cycle. | siem-server-new/src/bullmq/workers/billingCycleWorker.ts:171 · siem-server-new/src/payment/processor/PaymentEventProcessor.ts:428 |
| Discount ends and no payment method was ever registered | The next positive cycle requires provider, token and locked price. With missing payment setup, the worker moves the subscription to PAST_DUE and schedules grace actions. | siem-server-new/src/bullmq/workers/billingCycleWorker.ts:188 |
| Scheduled renewal, scheduled retry, manual retry | These paths call resolveCycleChargeAmount and preserve the stored discount while it remains active. A zero amount is internally settled rather than sent to the gateway. | siem-server-new/src/bullmq/workers/billingCycleWorker.ts:159 · siem-server-new/src/bullmq/workers/retryPaymentWorker.ts:46 · siem-server-new/src/services/subscription.service.ts:1900 |
| Payment-method recovery — D10 resolved | setupPaymentMethodForExistingSubscription charges resolveCycleChargeAmount (pushed or locked price minus the running discount), sends that discount as the server snapshot and keeps lockedPriceAmount. Company registration country fixes gateway and currency; recovery cannot switch them. | siem-server-new/src/services/subscription.service.ts:2143 · siem-server-new/src/services/subscription.service.ts:2160 · siem-server-new/src/services/subscription.service.ts:2182 |
| Upgrade / trial-to-paid conversion | These are separate change paths, not a re-run of new-checkout coupon/promotion selection. The normal recurring-discount formula above should not be read as a guarantee that upgrade proration includes that deduction. | siem-server-new/src/services/subscription.service.ts:1333 · siem-server-new/src/payment/processor/PaymentEventProcessor.ts:148 |
Gateway boundary: Stripe USD and Razorpay INR receive the server’s computed charge. Coupon dates, promotion selection and remaining discount cycles are controlled by SIEM. The initial Razorpay mandate ceiling is the undiscounted cycle base plus headroom. siem-server-new/src/services/subscription.service.ts:49 · siem-server-new/src/services/subscription.service.ts:666 · siem-server-new/src/services/subscription.service.ts:1159
D11: coupon slot from apply to redemption
Applying a coupon code reserves one slot at once, on the database clock, under the coupon row lock. Checkout attaches the slot to the signup. It becomes a successful redemption only after the first payment settles; it is released when the selection expires or the gateway shows no payment can still complete.
| Customer action | Server decision | Concrete result |
|---|---|---|
| One slot left; customer A applies the coupon, customer B applies it a minute later | A's apply reserves the slot under the coupon lock. B's apply finds no free slot. | A sees the discount and keeps it. B sees “All remaining redemptions are currently booked. Please try again later.” immediately, not at checkout. |
| A sits idle 34:59, then changes the asset count | The selection call re-prices the coupon and restarts the 35-minute hold, never beyond 24 hours from the first apply. | The slot stays with A. |
| A leaves for more than 35 minutes | The every-minute maintenance job releases the unattached slot. | Another customer can apply the coupon. |
| A clicks Checkout | The billing transaction attaches the slot to the new signup; the payment page closes after 30 minutes and the slot is checked 35 minutes after checkout. | Stripe expires_at; Razorpay popup timeout 1800 s. |
| Stripe first charge declined | The failure closes the checkout and releases the slot in the same transaction. | A starts over at today's price. |
| Razorpay popup closed without paying | At the check the server reads the order's payments: captured → settle; in progress → keep; failed within Razorpay's 3-day late-authorisation window → keep until the window ends; none → release. | No slot is freed while money can still arrive. A late payment on a closed checkout is honored (no refunds). |
| Discount covers ₹1,000 entirely | Internal ₹0 settlement redeems the slot and consumes one discounted cycle. | No Stripe or Razorpay interaction. |
Scale: per-coupon row locks, coupon → user lock order for selection and checkout, coupon → subscription for settlement, bounded conflict retries, indexed scans in batches of 100. Gateway calls happen outside database transactions. Ordinary 25-hour cleanup excludes coupon subscriptions and in-flight signup payments.
siem-server-new/src/services/couponBooking.service.ts:130 · siem-server-new/src/payment/providers/RazorpayProvider.ts:105 · siem-server-new/src/payment/providers/StripeProvider.ts:76
D11: one settlement, one discounted cycle
Settlement locks the coupon and subscription, then rechecks the invoice and current billing cycle inside the transaction. The actual discount travelled in server-created payment metadata, rather than being inferred from a later subscription edit.
| Delivery | Database result | Example |
|---|---|---|
| First successful payment | Invoice, unique payment, BOOKED → REDEEMED, counter transfer and subscription status commit together. | Booked 1 / redeemed 99 becomes booked 0 / redeemed 100. |
| Same payment delivered twice concurrently | The second transaction reloads and finds the existing invoice. It returns without another payment or discount increment. | A three-cycle offer with used = 1 becomes used = 2 after this renewal. |
| Another payment for a settled cycle | The money is recorded with its own unique external ID; the settled cycle is not applied again. | It does not consume the third discounted cycle. |
| Subscription edited while payment settles | The charged discount snapshot determines whether an allowance was consumed. | A payment charged with ₹0 discount consumes no discount cycle even if an admin subsequently adds a discount. |
| Process stops after settlement commits | The invoice retains a pending follow-up. A bounded indexed recovery scan queues provisioning, IAM, emails and the next-cycle schedule again. | Retries reuse existing membership and do not recount the discount. External emails remain at-least-once. |
siem-server-new/src/payment/processor/PaymentEventProcessor.ts:494 · siem-server-new/src/payment/processor/PaymentEventProcessor.ts:508 · siem-server-new/prisma/schema.prisma:474
Source implementation reviewed; schema application, Prisma generation and gateway/database execution are separate prerequisites. No tests were created or run.