Migration readiness audit
All 50 billing flows on one readiness board. Batch 1 traces foundations, signup and first charge, with source evidence and new findings.
Open migration auditSubscription paths from the SIEM codebase, audited 30 Sep 2026 and updated against the working tree on 14 Oct 2026, after the wallet-system merge. Server merge commit siem-server-new eded0be; the client and admin merges and the post-merge server changes are uncommitted. Each claim cites source files and lines.
Price summary layout: the discount name and amount occupy separate columns; the minus sign stays with the amount. Discount duration and the tax note each use a full-width row. Total payable can wrap without splitting the amount. Source: client components/reusable/price-calculator/index.tsx L1134-1168. Pricing behavior is unchanged.
Checkout UI: mandate headroom buttons have a minimum width that fits their contents and wrap onto another row when space is insufficient. Recommended uses the standard text-xs size and stays on one line inside its button. Source: client components/reusable/price-calculator/index.tsx L1199-1216. Billing flow and diagrams are unchanged.
Price preview query: the client serializes numeric assetCount to a string before constructing URLSearchParams; the server converts it back to a number for pricing. This removes the invalid TypeScript cast without changing the checkout flow. Sources: client action/subscriptionAction.ts L168; server subscription.controller.ts L273-283.
Wallet-system merge (13 Oct 2026): D74–D83 were found while resolving the merge and are resolved in source. Before running: npx prisma generate and npx prisma db push (wallet tables, WalletPurchase.invoiceNumber/paidAt). Client and admin dates show as “Feb 11, 2026” (lib/utils/date.ts, admin lib/date.ts). Nothing was executed against a gateway.
Implementation status: all 90 resolved in source. D84–D90 were found and fixed on 14 Oct 2026: downgrade agent removal and its admin email, agent selection, agent selection for paid changes, duplicate price-push emails, trial conversion to ACTIVE, invoice attachments, and currency/credit units. D30, D32 and D72 received follow-up notes. The same day the vestigial PAID status, the legacy USD quote fields and the producerless provisioning queue were removed. Schema changes awaiting prisma generate and db push: invoice_sequences, Subscription.pendingChangeRemovedAgentIds and the PAID removal. D39, D41, D46 and D50 were fixed and D40 was closed as working by design on 13 Oct 2026, per owner decisions. Also on 13 Oct 2026 D17, D20, D22, D23, D25, D33, D34, D35, D42, D43, D45, D52, D53, D55 and D59 were fixed, and D6, D48, D54, D72, D76 and D77 received follow-up fixes from the migration audit. D33's cutover script has not been run. D17 needs prisma generate and db push for its new table. D18, D19, D27, D49 and D57 were fixed on 13 Oct 2026 with the Batch 1 audit findings (see Migration audit). The discount selection (D11, D47, D58, D60; DiscountSelectionplan.md) requires prisma generate and a schema push before execution. D12, D13, D14 and company registration country require the new Prisma fields/table to be applied and the Prisma client generated before execution. Source review and git diff --check completed. No tests were created or run. Read-only TypeScript syntax and no-emit checks ran; complete type checking remains blocked by the unchanged Prisma client and existing project errors. No schema command, gateway run or fresh diagram browser check was executed. Stored Archify receipts predate these content edits.
Selected 10 high defects ? reconciled from the chat: Source fixes implemented: D8, D11, D12, D13, D14, D15, D32, D51 (8). Remaining: D10 (payment-method recovery amount), D33 (Stripe PENDING_REAUTH recovery at existing-subscription cutover). D12, D13 and D14 still require the Prisma prerequisites above. D11 includes atomic timed bookings, post-settlement redemption, actual discount snapshots and duplicate settlement guards. Razorpay always uses the popup; the confirmation page polls the webhook result and waits for signup follow-up before refreshing IAM. D48 acceptance timing is also resolved by the same settlement transaction. D21 is an additional medium defect discussed in the chat; removing its obsolete cleanup task remains pending, and current WebhookEventLog records are to be retained.
Click Comments (bottom right), turn on Comment mode, then click any defect row, email row or diagram box. Click Link folder once and pick .archify/deep-20260930: comments are written to comments.json there, which Claude reads; Claude's replies appear after Sync.
Step 2 asks where the company is registered alongside Organization Name. India fixes Razorpay/INR; every other supported country fixes Stripe/USD. Checkout saves the country on the Subscription row, including free trials and fully discounted signups. Registration, phone country and timezone keep their existing purposes.
All 50 billing flows on one readiness board. Batch 1 traces foundations, signup and first charge, with source evidence and new findings.
Open migration auditPortals, services, every status writer, gateways, webhook adapters, queues and stores.
Open diagramGuards, free plan, price lock, coupon vs promotion vs quote, $0 path, Stripe setup redirect, Razorpay popup, success page.
Open diagramAccepted gateway events, dedupe, what each normalized event writes and emails, where writes are rejected.
Open diagramBilling job gates, retry chain, manual retry, grace jobs, expiry, daily sweeps.
Open diagramUpgrade branches, downgrade, re-authorization, payment-method screens, Stripe portal.
Open diagramEvery status, every writer, admin overrides, bypass writes, rejected transitions.
Open diagramAdmin price form to tax-inclusive stored rate, locked price, discounts, charge, invoice, FX legs, revenue.
Open diagramNine detailed diagrams: plan discounts, manual coupons, automatic promotions, quote creation and link checks, winner selection, cycle duration, redemption and settlement races.
Open discount guideTop-ups on Stripe and Razorpay, wallet coupons and slot lifecycle, credit, AI usage debit, low-balance alert, admin settings and adjustments, gaps found.
Open diagramOne detailed card per job type, with separate flow diagrams for triggers, decisions, skips, database writes, emails, retries and next jobs. Unused and retired paths are labeled separately.
Open job flowsNine diagrams trace current source behavior from offer creation to recurring settlement. Pending defects are shown explicitly; Gateway and currency are fixed by company registration country. The remaining discount defects are documented explicitly.
| Path | Covered in the detailed guide |
|---|---|
| Plan rates and calculation | Interval discounts before tax, full-cycle base, fixed vs percentage, USD vs INR, rounding and mandate ceiling. |
| Manual coupons / promotions | Super Admin controls, eligibility, public codes, one use per user, global caps, automatic offer selection and ties. |
| Quote creation and expiry / link checks | Contact JWT, linked coupon, custom-plan visibility, account-binding gaps, revocation, acceptance timing and email pricing/duration. |
| One selected discount | Quote, running promotions or a coupon code; the customer chooses; no stacking; 35-minute hold; fallback when a discount ends; price-changed confirmation. |
| Stored amount and cycle allowance | Signup counts, finite vs lifetime, retries, zero charges, price changes, expiry after purchase and D10 recovery exception. |
| Redemption and last-use race / settlement race | Coupon slot reserved when the code is applied, held with the selection, attached at checkout, redeemed by the first settlement or released once no payment can complete; one discount-cycle increment per settlement. D11 is implemented in source. |
d53031b), the validTransitions map lived in SubscriptionService.validateStatusTransition and had one caller: the admin status override (git show d53031b:src/services/subscription.service.ts L114-163, call at L1753). The payment processor and the workers wrote status directly, with no check (d53031b PaymentEventProcessor.ts L225 status: PAYMENT_FAILED; d53031b lifecycleWorker.ts L108 status: EXPIRED). The uncommitted Phase 5 §11.9 moved the same map to src/utils/subscriptionStatusMachine.ts and routed every status write through subscriptionRepository.updateStatus (Phase5Implementationplan.md L3668-3696). The only map change was the PENDING_REAUTH entries (§4.1). This is the cause of D3, D4, D15 and D30. The 1 Oct 2026 map additions fixed D3 and D4, and the trial-conversion fix resolved D30; D15 is implemented: the claim deadline ends unused PENDING_SETUP trials through the status gate.Migrationphasewiseplan.md L371 says: cycle-0 decline → PAYMENT_FAILED, retry allowed, reaped after 25h. Phase3DeviationsFixplan.md §1 says CANCELLED → grace → EXPIRED, and §2 L298 says a successful retry of a PAYMENT_FAILED subscription lands in PENDING_SETUP. The map rejected all three until 1 Oct 2026; the fix added all three.SELECT on 30 Sep 2026):
PAYMENT_FAILED on 2026-09-10, under the pre-Phase-5 direct write.pay_… ids are recorded. This proves payment.captured carried notes.billingCycleNumber.payment.authorized events are logged failed with “Request failed with status code 400” (D44).UNPAID and 5 PAYMENT_FAILED rows from 2026-09-10 to 09-17 still exist, so task:abandoned-cleanup has not deleted them from this database.retry.enabled defaults to true (Razorpay docs, Standard Checkout options). The client does not set it (price-calculator/index.tsx L589-616), so a failed payment inside the popup can be retried there.| Customer situation | Where they land | Code |
|---|---|---|
Registered, never had a successful cycle-0 or free signup (user.status = paymentPending), with any subscriptions | /onboarding?step=2 (buy a plan). The client checks status === 'paymentPending' before anything else, so payment problems never route this user to /portal/subscription. After a failed first payment the customer starts over here at today's price; the unfinished checkout is closed when the new checkout starts. | authActions.ts L82-93 · SignInClient.tsx L288-317 · user.service.ts L176 (status set at registration) · onboarding/OnboardingClient.tsx L1071-1083 · onboarding/FailedSignupCard.tsx |
Same customer, onboarding checkout while an owned subscription is PAYMENT_FAILED or PAST_DUE | A renewal in PAST_DUE makes the server refuse with EXISTING_FAILED_SUBSCRIPTION; the client shows “You have a subscription with an overdue renewal payment. Settle it from your subscription page before starting a new one.” A failed first payment (PAYMENT_FAILED) does not block: the new checkout closes it. | subscription.service.ts createAndInitiatePayment (PAST_DUE check) · price-calculator/index.tsx EXISTING_FAILED_SUBSCRIPTION_MESSAGE |
user.status = active; the default subscription is PAYMENT_FAILED or PAST_DUE; the user is the owner or has billing permission | /portal/subscription with “Action Required: Please update your payment method…”. With no ACTIVE/TRIALING owned sibling, the route guard permits profile, subscription, notifications and buy-new-subscription, and redirects other portal routes to subscription. An active owned sibling preserves access. The sidebar remains empty for payment_issue (D52). | SignInClient.tsx L356-363 · GlobalRouteController.tsx L10-22, L66-71, L102-108 · user.util.ts L4-40 |
Team member without billing permission; every subscription they belong to is PAYMENT_FAILED/PAST_DUE | Login refused: “Access restricted due to payment issue.” | user.service.ts L1214-1224 |
Every subscription is PENDING_SETUP | Owner → /portal/subscription, member → /portal/profile, toast “Your account is currently being provisioned.” | SignInClient.tsx L342-353 |
Subscription is UNPAID, CANCELLED, PENDING_REAUTH or EXPIRED | The owned-subscription query excludes these statuses; active membership entries are fetched without that filter. If the subscription is present through membership, CANCELLED derives ended; the other three derive no_subscription. If absent from both lists, it derives no_subscription with no default. The portal guard limits either derived value, preserving an active owned sibling. An active customer is not sent to onboarding solely for no_subscription. | user.service.ts L1315-1385, L1441-1456 ? user.util.ts L4-40 ? GlobalRouteController.tsx L11-22, L104-106 ? portal-client-layout.tsx L80-86 |
Any customer opening /portal/subscription | The page shows exactly one subscription: the one in user.currentSubscriptionId (or the auto-picked one if empty). Other subscriptions, such as a new UNPAID or failed purchase, do not appear there; after a failed first payment of another purchase the customer starts over on /portal/buy-new-subscription. | user.service.ts L697-775 · subscription/page.tsx L37-45 |
Every email a subscription path sends, with its exact subject. “Admins” means every active admin (findAllActiveAdminEmails). If a step is missing, it sends nothing.
| Step | To | Subject | Code |
|---|---|---|---|
Free-plan signup (subscription created PENDING_SETUP) | Admins | New Subscription Pending Setup - {companyName} | subscription.service.ts L522-529 → L928-939 (currency always 'usd', D19) |
| Customer | Your Subscription is in progress | L919-926 | |
| Customer | COMMANDO SoldierX SIEM Onboarding | L928-932 | |
Free trial, 7 / 3 / 1 days before the claim-based trialEnd; queued at signup and preserved by admin TRIALING | Customer | Your {plan} Trial Ends in {d} Day(s) – Upgrade Now | subscription.service.ts L317-366, L1699-1714 ? emailQueue.service.ts L48-81 |
| Fully discounted (₹0 / $0) signup; existing onboarding emails queued after committed settlement | Admins + customer | the same three onboarding emails as E1 | subscription.service.ts L719-732 · PaymentEventProcessor.ts L508-524 |
| Stripe setup completed with an India-issued card (card detached, nothing else happens) | Customer | Payment Failed - Action Required | StripeWebhookAdapter.ts L82-101 |
| First charge succeeded → PENDING_SETUP; onboarding emails run in a durable follow-up. Trial conversion keeps its existing onboarding email path | Admins + customer | the same three onboarding emails as E1 (no “Payment Successful”) | PaymentEventProcessor.ts L508-524 (first-charge follow-up), L316-325 (trial conversion) |
First charge failed (sent once, when the subscription first becomes PAYMENT_FAILED; not again for later popup attempts or manual retries) | Customer | Payment Failed - Action Required | PaymentEventProcessor.ts handlePaymentFailed (isFirstCharge branch) |
| Admin confirms PENDING_SETUP activation | Customer | Your Subscription is Active (paid) | subscription.service.ts L1787-1834 |
| Customer | Your Free Trial is Active | same | |
| Renewal succeeded; Payment Successful runs in the durable settlement follow-up | Customer | Payment Successful | PaymentEventProcessor.ts L536-547 |
The failed charge on cycle ≥ 1 that moves the subscription into PAST_DUE (renewal or setup charge; not an upgrade or trial-conversion charge, see E29). Later failures in the same cycle (scheduled or manual retries) send nothing | Customer | Payment Failed - Action Required | PaymentEventProcessor.ts handlePaymentFailed (isFirstFailureOfCycle) |
| An upgrade charge, or a Stripe trial-conversion charge, is declined. The subscription does not change. Not sent for a Razorpay trial conversion (the decline happens inside the popup) | Customer | Payment Failed - Action Required (text: the plan change did not happen; current plan or trial unchanged) | PaymentEventProcessor.ts L564-591 |
| Grace period running: 3 days and 1 day before the final deactivation (scheduled by a failure, cancel period end, trial end, no-token renewal, ceiling gate) | Customer | Deactivation Warning: Data Deletion in {n} Day(s) | lifecycleQueue.ts L49-70 · lifecycleWorker.ts L75-100 |
| Razorpay failure classified as mandate-limit breach: day 0 and mid-grace | Customer | Payment Failed - Action Required (text: re-authorize your mandate) | lifecycleQueue.ts L75-95 · lifecycleWorker.ts L102-133 |
Renewal with a pushed price above the Razorpay ceiling → PENDING_REAUTH | Customer | Payment Failed - Action Required (text: price exceeds mandate) | billingCycleWorker.ts L214-244 |
Renewal with no saved token → PAST_DUE | Customer | payment-failed email (no payment method on file, amount due, grace days), then the E10 grace warnings | billingCycleWorker.ts L188-198 |
| Admin pushes a new price | Customer | Subscription Price Update Notification | subscription.service.ts L2247-2261 |
| Customer clicks Cancel | — | none | L1026-1061 |
Period end with cancel flag, via the billing job → CANCELLED | Customer | Your Paid Access Has Ended, plus E10 grace warnings | L2302-2329 |
Same, via the midnight task:sub-finalization instead | Customer | Your Subscription Period Has Ended, plus in-app push; no grace warnings are scheduled | repeatableWorker.ts L733-809 |
| Customer resumes (revokes cancellation) | Customer | Subscription Resumed | subscription.service.ts L1074-1089 |
| Trial ends (trial-ending job, or midnight sub-finalization) | Customer | Your Free Trial Has Ended (used or unused trial; both ending paths schedule E10 warnings for the remaining claim-based grace; daily finalization also sends the in-app push) | lifecycleWorker.ts L26-77 ? repeatableWorker.ts L714-779 |
Final deactivation → EXPIRED (from ENDED, PAST_DUE, PENDING_REAUTH or CANCELLED) | Customer | Service Deactivation: Data Permanently Removed | lifecycleWorker.ts L152-162 |
| Admins | Subscription Expired: {companyName} | L164-178 | |
| Admins | Agents Removed: {companyName} (Trial Expiry | Cancellation/Payment Failure), only if agents existed | L325-337 | |
| Upgrade charged (synchronously, or on the webhook for a pending charge) | Customer | Subscription Upgrade Details | subscription.service.ts L1446-1461 · PaymentEventProcessor.ts L216-233 |
| Downgrade scheduled | Customer | Subscription Downgrade Scheduled | subscription.service.ts L1278-1291 |
| Downgrade applied at renewal, agents removed | Admins | Agents Removed: {companyName} (Downgrade) | L994-1013 |
| Set default payment method (Stripe only) | Customer | Payment Method Updated | L2223-2243 |
| Admin sends a quote | Prospect | Re: Your Inquiry – Discounted Plan Offer | plan.service.ts L848 · email.service.ts L2192 |
02:30 sweep removes agents left on EXPIRED subscriptions | Admins | Agents Removed: {companyName} (Maintenance Safety Sweep) + admin push | repeatableWorker.ts L824-928 |
| Paid plan, 7 / 3 / 1 days before period end | Customer | Your {plan} Plan Expires in {d} Day(s): only scheduled after Resume (D42) | subscription.service.ts L362-389 |
| Defined but not called by any production path | — | sendAdminPaymentReceivedEmail (“Payment Received: …”), sendTrialEndingSoonEmail, sendDeactivateAssetsMail. Admins get no email for any payment. | grep: callers only in scripts/test-emails.ts and commented code |
| ID | Severity | Status | Finding | Where | Diagram |
|---|---|---|---|---|---|
| D1 | critical | resolved | Before the 1 Oct 2026 fix: Tax is charged twice when a plan's tax % is above 0. The admin form saves each per-asset monthly rate as round2((base − base × interval-discount%) × (1 + tax%)). Both the per-interval plan discount and the tax are baked into the stored rate; basePrice, basePriceINR, discountMonthly/Quarterly/Yearly and taxAmount are also stored separately. Checkout, renewal, retry and upgrade then multiply by (1 + taxRatePercent) again. Example: ₹100/asset, 18%, 5 assets, monthly → screen ₹590, charged ₹696.20.With tax % = 0 every tax multiplication is ×1 and splitTaxFromTotal returns tax 0, so nothing goes wrong. Coupons and promotions are not baked in; they apply per subscription at checkout.Fixed 1 Oct 2026 (approach B, tax-inclusive): stored rates stay as they are; the server no longer multiplies by tax at the first charge, mandate ceiling, renewal, renewal ceiling gate, retry, upgrade proration/ceiling, or the price-push email and re-auth check. getPricePreview no longer returns taxAmount (nothing read it). Tax labels removed: invoices store subtotal = total with no tax split (splitTaxFromTotal deleted), the invoice PDF shows Total with “inclusive of all taxes*”, the customer Payments table has no Tax column, the upgrade modal has no VAT row, and the admin plan form shows Grand Total with “inclusive of all taxes*” instead of a tax line. taxRatePercent and the admin tax-type select are still stored but nothing reads them. | admin plans/page.tsx L202-215, L298-306, L336-394 · subscription.service.ts L52-76, L90-109 (resolveCycleChargeAmount), L621-634, L1355 · billingCycleWorker.ts L160 · retryPaymentWorker.ts L46 | 01 · 06 |
| D2 | critical | resolved | Before the 1 Oct 2026 fix: Checkout shows a Total with no tax line (calculateTax exists but nothing renders it), then the server charges that total plus tax, so customers pay more than the screen said. The Razorpay line says “limit ₹(Total + headroom) · charged ₹Total”, while the server registers a ceiling of locked + locked × tax + headroom.The server never uses a client amount: /setup receives planId, assetCount, interval, companyRegistrationCountry, couponCode, quoteToken and mandateSurgeTolerance only.Since the D1 fix (1 Oct 2026): the server charges the screen Total and registers Total + headroom, so both numbers now match. Checkout Total now shows “inclusive of all taxes*” (fixed 1 Oct 2026). | price-calculator/index.tsx L420-437, L1117-1127, L1127, L1207-1210 · subscription.controller.ts L21 · subscription.service.ts L637-645 | 01 · 06 |
| D3 | critical | resolved | Before the 1 Oct 2026 fix: A failed first charge is lost. The processor calls updateStatus(UNPAID → PAYMENT_FAILED). The map allows UNPAID → [ACTIVE, CANCELLED, TRIALING, PENDING_SETUP] only, so it throws before the email line, the webhook returns 400 and the event is dropped (D36). The subscription stays UNPAID, which /users/me and /portal/subscription do not show (G6, G7), and abandoned-cleanup deletes it after 25h. On Stripe the success page never completes (D37).A successful retry would also need PAYMENT_FAILED → PENDING_SETUP, which the map rejects too. Razorpay: the popup's default retry covers card declines inside the popup. Before Phase 5 this path worked (see Ground truth).Fixed 1 Oct 2026: the map now allows UNPAID → PAYMENT_FAILED and PAYMENT_FAILED → PENDING_SETUP. The failure email is sent once, when the subscription first becomes PAYMENT_FAILED. A first charge is never retried automatically. Razorpay retries happen inside the open popup; a declined Stripe first charge closes the checkout and the customer starts over at today's price (no failed-order card, no manual retry of a first payment; 8 Oct 2026). | subscriptionStatusMachine.ts L27-33, L41-47 · subscription.repository.ts L107-117 · PaymentEventProcessor.ts L593-638 | 02 · 05 |
| D4 | critical | resolved | Before the 1 Oct 2026 fix: Cancelled subscriptions never expire. task:final-deactivation calls subscriptionRepo.updateStatus(subId, EXPIRED). The repository runs validateStatusTransition(CANCELLED, EXPIRED), the map allows CANCELLED → [ACTIVE] only, and it throws Invalid state transition from CANCELLED to EXPIRED. The job fails after 3 attempts: no agent cleanup, no closing emails (E20), no default-subscription reset. The grace warnings (E10) announcing the deletion date are still sent.Fixed 1 Oct 2026: the map now allows CANCELLED → EXPIRED. | lifecycleWorker.ts L135-146 · subscription.repository.ts L107-117 · subscriptionStatusMachine.ts L40 · lifecycleQueue.ts L7 | 03 · 05 |
| D5 | critical | resolved | Before the 1 Oct 2026 fix: Retry chains multiply. Every failure webhook, including failures of retry charges, schedules attempt 1 again (removeOnComplete frees the job ID), while the retry worker schedules the next attempt. Each failure resends the failure email. Charges repeat every 3 days until grace ends; two retries that both succeed charge twice and advance the period twice (the success handler does not compare cycle numbers).Razorpay recurring debits come back “pending”, so the worker never schedules attempt 2 and attempt 1 repeats until grace ends.Fixed 1 Oct 2026: charges carry retryAttempt metadata. The failure event drives the chain: renewal failure → attempt 1, attempt n failure → attempt n+1, up to 3, only when the gateway reason is temporary (retryableFailure.ts: insufficient funds, balance limits, bank/network errors). Manual-retry failures schedule nothing. Email and grace start only on the failure that moves the subscription into PAST_DUE. Completed retry jobs keep their ID; the retry worker checks the cycle number. A success for an already-settled cycle is recorded but not applied; refunds are not allowed. A Redis charge lock (chargeLock.ts) allows one charge in flight per subscription. | PaymentEventProcessor.ts L640-701, L428-465 · retryPaymentWorker.ts L79-107 · retryPaymentQueue.ts L24-27 · RazorpayProvider.ts L320-337 | 02 · 03 |
| D6 | critical | resolved | Before the 1 Oct 2026 fix: Manual “Retry Now” charges the raw lockedPriceAmount (no discount, no pending price, no added tax), and its idempotency key includes Date.now(), so every click is a new charge. The toast says “Payment successful!” whenever the server answers success, which includes Stripe “pending” and every Razorpay debit. On success, the webhook still consumes a discount cycle, promotes the pending price and splits tax out of the invoice.Fixed 1 Oct 2026: manual retry charges resolveCycleChargeAmount (pending price ?? locked price − running discount), the same function the billing and retry workers use. It takes the charge lock, so a second click is refused while a charge is in flight. The response and toast say “Payment successful…” or “Payment is processing…” from the real gateway result.Also fixed 13 Oct 2026 (audit): manual retry read the subscription before taking the charge lock. A scheduled retry that settled the cycle in between let the click charge the same cycle again. It now re-reads under the lock and charges only a cycle that is still unpaid. | subscription.service.ts L1844-1937 · SubscriptionAccordion.tsx L353-368 | 03 |
| D7 | high | resolved | The upgrade modal's “Re-authorize Mandate” button called setup-payment-method, which accepts only PAST_DUE or PENDING_REAUTH, so it failed for the ACTIVE subscriptions it was shown to.Fixed 8 Oct 2026 with D9: the button and reauthorizeMandateAction are removed. Confirming an upgrade above the limit opens one Razorpay popup that takes the upgrade charge and authorizes the new limit. | UpdateSubscriptionModal.tsx (mandate notice, Confirm Update) · subscription.service.ts upgradeSubscription | 04 |
| D8 | high | resolved | PENDING_REAUTH and PAST_DUE with MANDATE_LIMIT_EXCEEDED show payment setup even when the existing token is present. Razorpay mandate recovery shows Re-authorize Mandate; Stripe shows Set Up Payment Method. Retry Now is hidden for a mandate-limit problem. The recovery action uses the subscription’s country-derived gateway and requires siem:billing:retry. Missing country is required once during PENDING_REAUTH re-onboarding. D10 repricing remains pending. Recovery cannot switch the fixed gateway or currency. D33 remains pending for legitimate Stripe PENDING_REAUTH migration recovery. | SubscriptionAccordion.tsx L264-270, L332-352, L457-508 ? subscription/types.ts L76 | 04 |
| D9 | high | resolved | When the Razorpay ceiling blocked an upgrade, the server moved the ACTIVE subscription to PENDING_REAUTH and stored only the new price; the new plan was never applied. Example: old plan ₹1,000, upgrade ₹2,500, limit ₹2,000: every later renewal charged ₹2,500 for the old plan.Fixed 8 Oct 2026: an upgrade above the limit returns one Razorpay order: its payment is the upgrade charge (at least ₹1) and it registers a new mandate at the new price plus the customer's headroom; the plan change rides on the order notes. When the payment is captured, the new token and limit are saved and the upgrade settlement applies the plan, assets, interval and price. Nothing changes before that and the subscription stays ACTIVE; a declined or abandoned popup leaves the current plan. The previous mandate stays registered at Razorpay but is no longer used. | subscription.service.ts upgradeSubscription (Razorpay ceiling branch) · RazorpayWebhookAdapter.ts payment.captured token binding · PaymentEventProcessor.ts upgrade settlement · UpdateSubscriptionModal.tsx | 04 |
| D10 | high | resolved | Payment-method setup re-priced from the current plan and overwrote lockedPriceAmount, losing a grandfathered or pushed price and the running discount. Example: locked ₹1,000; plan edited to ₹1,500; recovery charged ₹1,500.Fixed 8 Oct 2026: recovery charges what the next scheduled charge would (resolveCycleChargeAmount: pushed or locked price minus the discount still running), sends that discount as the server snapshot, authorizes the Razorpay mandate at the full price plus existing headroom, and no longer writes lockedPriceAmount. Example: locked ₹1,000, plan edited to ₹1,500, ₹200 discount running: recovery charges ₹800. Only a null-country subscription moving to another currency at cutover is priced from the plan. A fully discounted cycle cannot open a Razorpay mandate payment. | subscription.service.ts setupPaymentMethodForExistingSubscription L1731-1810 | 04 · 07 |
| D11 | high | resolved | The customer selects one discount; nothing stacks. Applying a coupon code reserves its slot at once under the coupon row lock (capacity = redemptions + reservations), so a customer who sees the coupon applied keeps it; another customer gets “All remaining redemptions are currently booked.” at apply time. The selection lives 35 minutes, restarted by every server call, never beyond 24 hours. Checkout re-prices the held selection: an ended discount falls back (quote → promotion; promotion → quote → next promotion; coupon → quote → promotion), and a price different from the one shown returns PRICE_CHANGED for the customer to confirm. Checkout attaches the slot to the signup; payment pages close after 30 minutes (Stripe expires_at, Razorpay popup timeout); the slot is redeemed by the first settlement or released once the gateway shows no payment can complete (Razorpay late authorisation keeps it up to 3 days). A declined Stripe first charge or starting over closes the checkout; a late Razorpay payment on a closed checkout is honored. No refunds. Source implemented 8 Oct 2026; Prisma generate and schema push required; no gateway run. | discountSelection.service.ts · couponBooking.service.ts · subscription.service.ts createAndInitiatePayment · PaymentEventProcessor.ts handleTokenSaved, settlement, handlePaymentFailed · StripeProvider.ts / RazorpayProvider.ts closeSignupCheckout · price-calculator/index.tsx · DiscountSelectionplan.md | 01 · 02 · 03 · 05 · 06 · 07 |
| D12 | high | resolved | Fixed-amount coupons and promotions require currency: inr | usd; percentage discounts have no currency. A ?500 coupon cannot reduce a USD subscription. Mismatched fixed coupons are rejected; mismatched promotions are excluded. Admin forms select the currency, and quote-created fixed coupons retain the quote currency. Source implemented; Prisma schema application and client generation required. | schema.prisma L747-753, L784-791 ? discountSchema.ts L5-41 ? discount.service.ts L22-41, L84-95 ? subscription.service.ts L177, L227-229 ? plan.service.ts L809-813 ? admin coupons/page.tsx L322-332 ? admin promotions/page.tsx L307-317 | 01 ? 06 |
| D13 | high | resolved | Verified payment.captured binds a new token to the subscription owner and records pendingExternalTokenId. The server fetches the token from that customer's Razorpay token API and saves it only after bank confirmation. token.confirmed completes delayed confirmation. Closing the browser no longer removes the server registration path; browser verification remains supplemental. Source implemented; Prisma schema application and client generation required. | RazorpayWebhookAdapter.ts L43-53, L87-102, L106-130 ? RazorpayProvider.ts L210-224 ? PaymentEventProcessor.ts L72-94 ? schema.prisma L271 | 02 |
| D14 | high | resolved | Before creating an order or sending a recurring debit, Razorpay claims a unique durable RazorpayChargeAttempt for the subscription cycle (or the exact upgrade request). Manual and scheduled retry keys share the cycle guard. A pending or uncertain outcome is checked against the original order and never permits a second debit. Only a confirmed failure permits a new attempt. Webhooks settle the attempt. A process stopped before persisting its order ID leaves a pending guard that blocks another debit; automatic recovery is not implemented. Source implemented; Prisma schema application and client generation required. | schema.prisma L834-853 ? RazorpayProvider.ts L233-366 ? RazorpayWebhookAdapter.ts L54-64 ? subscription.service.ts L1937-1942 | 03 |
| D15 | high | resolved | Claim ? Pending_setup ? Ended ? expired applies to unused free trials. Claim creates PENDING_SETUP with a fixed trial deadline and isTrialActive = false. Admin activates an onboarded trial as TRIALING before that deadline and preserves the claim dates and queued reminders. Both trial-ending and daily finalization use the status gate to reach ENDED; EXPIRED is scheduled at trialEnd + 7 days, including when a worker runs late. Free plans cannot be activated as ACTIVE. Example: a 14-day trial claimed on day 0 ends day 14; activation day 3 leaves 11 days; grace ends day 21. | subscription.service.ts L482-510, L1681-1714 ? subscriptionStatusMachine.ts L48-54 ? lifecycleWorker.ts L26-62 ? repeatableWorker.ts L714-754 | 05 |
| D16 | medium | resolved | The quote email subtracted a fixed discount from a one-asset cycle and labelled it a per-asset price, and said “N renewal(s)” although signup is the first of the N discounted cycles. Example: ₹200 per asset monthly with ₹100 off showed ₹100 / asset; checkout for 5 assets charges ₹1,000 − ₹100 = ₹900.Fixed 8 Oct 2026: the email lists the per-asset price for each interval; a percentage discount is shown in that price, a fixed discount is stated separately as “₹100 off the total of each billing cycle”. The duration reads “N billing cycles, including signup” or “Every billing cycle”. Every discounted quote, including on a custom plan, must state its discount type and duration. | plan.service.ts sendQuote (discount validation, intervalDetails, durationLabel, discountLabel) | 01 ? 06 · 07 |
| D17 | medium | resolved | Before the 13 Oct 2026 fix:Normal settlement invoices record the charged discount snapshot and gross subtotal. Invoice numbers remain “today's count + 1”, which can repeat when two invoices are written at once.Fixed 13 Oct 2026: invoice numbers come from a per-day invoice_sequences row, incremented atomically inside the invoice's transaction and locked until it commits, so concurrent invoices get consecutive numbers (CMD{YYMMDD}{seq3} unchanged). A day's first row starts after any invoice already numbered that day. Needs prisma generate and db push for the new table. | PaymentEventProcessor.ts L381-406 · invoice.repository.ts L7-12 | 02 · 06 |
| D18 | medium | resolved | POST /verify-payment does not check that the subscription belongs to the caller.Resolved 13 Oct 2026: the controller passes the caller; verifyPayment accepts only the subscription owner or an active member of its workspace. | subscription.routes.ts L127-132 · subscription.service.ts L848-885 (order and customer binding: D57) | 02 |
| D19 | medium | resolved | The admin “New Subscription Pending Setup” email always labels the amount as USD.Resolved 13 Oct 2026: the email receives the subscription's currency. The admin-subscription-pending template prints neither amount nor currency, so the admin sees no difference. | subscription.service.ts L914 | 02 |
| D20 | medium | resolved | Before the 13 Oct 2026 fix:The midnight sub-finalization writes status through updateStatus. Its trial branch schedules the final deactivation and the grace warnings for the remaining claim-based grace. Its paid branch (cancel flag past period end → CANCELLED) schedules only the final deactivation job, with no grace warnings, unlike the billing-job path to CANCELLED.Fixed 13 Oct 2026: the paid branch of sub-finalization calls beginCancellationGracePeriod, the same CANCELLED → grace path the billing job uses: expiry date, grace warnings, final deactivation and its email. | repeatableWorker.ts L733, L746-754 | 03 · 05 |
| D21 | medium | resolved | A scheduled task deleted legacy StripeEventLog records; WebhookEventLog records support duplicate-event detection and are retained.Resolved 9 Oct 2026: the StripeEventLog table, its cleanup handler and its schedule are removed; startup removes the schedule left in Redis. WebhookEventLog is untouched. | repeatableQueue.ts initRepeatableJobs · repeatableWorker.ts · schema.prisma | 02 |
| D22 | medium | resolved | Before the 13 Oct 2026 fix:Any admin (not only a super admin) can push a price to one subscription. Plan-wide pushes are super admin only. Prices are tax-inclusive, so the push sets the price only; a separate tax-rate push no longer exists.Fixed 13 Oct 2026 (owner decision: plan-wide only): the single-subscription push is removed: route, controller, service method, validator and the admin “Push Price Update” button. Prices are pushed only plan-wide by a super admin; each subscriber of the plan gets the new price from its next renewal at least 24 hours away. A queued push skips a subscription that has left the plan (D72). The unused PushPriceUpdate.tsx file in siem-admin-new is left for the owner to delete. | subscription.routes.ts PATCH /:id/pending-price (isAdmin) | 06 |
| D23 | medium | resolved | Before the 13 Oct 2026 fix:Whether a plan change needs the upgrade or the downgrade permission is decided from the legacy priceMonthly/Quarterly/Yearly columns (the USD rates), not the subscription's currency or locked price.Fixed 13 Oct 2026: the permission is decided by quotePlanChange, the same quote that prices the change (subscription currency, asset count, what the customer pays): a downgrade needs the downgrade permission, everything else the upgrade permission. It runs after body validation and returns 404 for a missing subscription or plan instead of skipping the check. | subscription.routes.ts L28-59 | 04 |
| D24 | medium | resolved | Both gateways schedule every renewal at currentPeriodEnd. The worker delays an early queued job for an ACTIVE subscription until that boundary, before cancellation, plan changes or charging.Resolved 8 Oct 2026: Razorpay renewal charge attempts run at period end, matching Stripe. | subscription.service.ts L1489-1495 · PaymentEventProcessor.ts L524-530 · billingCycleWorker.ts L115-122 | 03 |
| D25 | medium | resolved | Before the 13 Oct 2026 fix:A renewal with no saved token moves to PAST_DUE with no failure email and no retry; only the grace warnings (E10) are sent.Fixed 13 Oct 2026 (owner decision A): a renewal with no saved payment method emails the customer: no payment was taken, the amount due in the subscription's currency, and the number of grace days to set up a payment method. The grace period also sets its deactivation date (D55). | billingCycleWorker.ts L188-198 | 03 |
| D26 | medium | resolved | Billing job IDs are fixed per cycle (billing-cycle-{sub}-{n}) and failed jobs are kept (removeOnFail: 1000), so a Stripe card re-setup for a cycle that already failed cannot queue a new charge for that cycle.Fixed 1 Oct 2026: scheduling a billing job now removes a finished job with the same ID first, so a re-setup can queue the charge again. The billing worker charges cycle 0 only for UNPAID/PAYMENT_FAILED and cycle ≥ 1 only for ACTIVE/PAST_DUE. A Razorpay mandate cannot switch to Stripe. Existing Stripe subscribers must also enter PENDING_REAUTH at cutover; their queued charge is currently skipped (D33 pending). | billingCycleQueue.ts L7-41 · PaymentEventProcessor.ts L97-334 | 03 |
| D27 | medium | resolved | The onboarding success page calls the legacy /session-details endpoint; for a setup-mode Checkout session it finds no invoice, so no invoice link is shown.Resolved 13 Oct 2026: /status/:id returns the first-charge invoice's externalId; the success page shows Download from it and no longer calls /session-details (the route itself remains, unused by the client). | onboarding/success/page.tsx L76-85 · subscription.service.ts L1811-1825 | 01 |
| D28 | medium | resolved | Creating or editing a plan created Stripe Products and Prices; a Stripe outage, or a product missing in the Stripe account (“No such product”), failed the save after the database was already written.Resolved 9 Oct 2026: plan create and edit write only the database. The Stripe product and price columns, the stub price-sync button with its three routes, and the unused sync-report email are removed. | plan.service.ts createPlan, updatePlan · plans.routes.ts · admin plans/page.tsx · schema.prisma | 06 |
| D29 | critical | resolved | Before the 1 Oct 2026 fix: Any failed charge on cycle 1 or later is treated as a renewal failure. A declined upgrade charge moves a paid-up ACTIVE subscription to PAST_DUE (access cut), sends the failure email, schedules grace and final deactivation (7/15/30 days by interval), and, when the decline reason is temporary, a retry job then charges a full renewal early. On a non-temporary decline a monthly subscription expires 7 days later although it is paid to period end.Fixed 1 Oct 2026: the failure event now carries the charge's reason (from the upgrade metadata the charge already had) and the attempted amount. A declined upgrade or trial_upgrade charge changes nothing: no status write, no grace, no retry; the current plan or trial continues. The customer gets “Payment Failed - Action Required” saying the plan change did not happen and the current plan is unchanged (not for a Razorpay trial conversion, whose failures happen inside the popup). Upgrade charges never take the charge lock, so their outcome no longer releases one. | PaymentEventProcessor.ts L551-591 · StripeWebhookAdapter.ts L150-174 · RazorpayWebhookAdapter.ts L153-177 · planChangeMetadata.ts | 02 · 03 · 04 |
| D30 | critical | resolved | Before the 1 Oct 2026 fix: Upgrading a free trial (PENDING_SETUP, TRIALING or ENDED) writes the paid plan, assets and price before any payment. Stripe: the card is saved but never charged (the cycle-0 job requires UNPAID or PAYMENT_FAILED). Razorpay: the money is captured but the TRIALING/ENDED → PENDING_SETUP write is rejected (on 1 Oct, before this fix, it was recorded as a duplicate charge and not applied). The trial still ends and expires.Fixed 1 Oct 2026: an upgrade of a subscription on a free plan goes to startTrialConversion. Nothing about the plan is written on click; gateway and currency were fixed at trial claim from company registration country, and conversion records only the (Razorpay) default headroom of 3000. The chosen plan, assets, interval and price ride on the setup as trial_upgrade metadata. Stripe: setup_intent.succeeded queues the billing job with that plan, which charges its price. Razorpay: the popup takes it. On payment success the processor writes invoice + payment, applies the plan, clears the trial flags and period, moves to PENDING_SETUP (map now allows TRIALING/ENDED → PENDING_SETUP), cancels trial jobs and reminders, and sends the onboarding emails; the admin then activates it like any paid signup. A declined or abandoned payment leaves the trial unchanged (D29). Admin activation of a free plan never uses the paid-period path, even if a conversion was started and abandoned.Changed 14 Oct 2026 (owner decision, D88): a paid conversion now makes the trial ACTIVE at once, with its first paid period starting at the payment, instead of PENDING_SETUP awaiting admin activation. Only TRIALING and ENDED trials can be converted. | subscription.service.ts L1097-1169, L1230-1233, L1711-1713, L1763 · billingCycleWorker.ts L41-89 · PaymentEventProcessor.ts L237-334 · StripeWebhookAdapter.ts L111-112 · subscriptionStatusMachine.ts L13, L38 | 04 · 05 |
| D36 | critical | resolved | Before the 1 Oct 2026 fix: A webhook event that fails once is never processed again. On error the adapter saves a webhook_event_logs row with status “failed”; the dedupe check only tests whether a row exists, so the gateway's redelivery gets 200 and is skipped.Fixed 1 Oct 2026: both adapters now skip only rows with status “processed”; a failed event runs again on redelivery. An event the state map rejects still fails on every redelivery. | StripeWebhookAdapter.ts L27-33, L48-58 · RazorpayWebhookAdapter.ts L24-30, L72-82 | 02 |
| D31 | high | resolved | Before the 1 Oct 2026 fix: isTrialSubscription is never cleared. A trial converted to a paid plan is treated as a trial forever: cancel is refused, the UI says “Trial Ends”, and a missed renewal is forced to ENDED by sub-finalization with a 7-day trial grace instead of PAST_DUE retries.Fixed 1 Oct 2026 (with D30): the only path that moves a trial onto a paid plan is now the trial conversion, which clears isTrialSubscription, isTrialActive and the trial period when the plan is applied. The upgrade path for paid plans is no longer used for trials. | subscription.service.ts L1010 · repeatableWorker.ts L689, L695 · SubscriptionAccordion.tsx L165-167 | 05 |
| D32 | high | resolved | Admin can only confirm PENDING_SETUP to TRIALING for a claimed free trial, or ACTIVE for a paid subscription. The server checks the source and destination and conditionally updates the PENDING_SETUP row, rejecting concurrent activation. The confirmation dialog explains agent onboarding, the preserved trial deadline, the paid period and next renewal amount, and that activation takes no additional payment. Jobs and webhooks manage subsequent status changes. Failed renewal scheduling restores the activation fields.Also fixed 14 Oct 2026 (audit flow 39): the activation confirmation calculated locked price minus discount and missed an already-notified pushed price. It now shows firstRenewalCharge, the server's own amount for cycle 1 (pushed price, scheduled downgrade and running discount included). | subscription.service.ts updateSubscriptionStatus L1671, conditional activation and scheduling rollback ? subscriptionSchema.ts L26 ? EditSubscriptionStatus.tsx L50-142 ? admin SubscriptionAccordion.tsx L145-149 | 05 |
| D33 | high | resolved | Before the 13 Oct 2026 fix:Production's subscriptions created before company registration country have no defined path. Dropping the legacy columns deletes stripeSubscriptionId, so a production subscription still backed by a Stripe Subscription keeps being renewed by Stripe while the app records nothing (Stripe invoice events are no longer handled). On our side it has no gateway, token or locked price, so it never renews and never expires. seed-subscription-countries.ts copies the user's country, which for an Indian Stripe/USD subscriber makes every charge fail “gateway does not match country”. The re-onboarding path the plan intended also fails: a Stripe card saved by a country-less PENDING_REAUTH subscription queues a renewal job that skips it (the worker accepts only ACTIVE/PAST_DUE after cycle 0). Nothing sets that state now that the migration script is removed. Local data was cleaned on 9 Oct 2026; production must be handled before its schema push: cancel the Stripe Subscription(s) at period end and remove or expire those rows, or build a cutover path.Fixed 13 Oct 2026 in source (owner decisions A1, B1, C1, 3:A); not yet run: scripts/stripe-cutover.ts. export runs before the schema push: it saves each Stripe subscription's status and paid-through period and the Stripe invoice/payment IDs (A34), then cancels the Stripe subscriptions through the API with no proration and no final invoice. apply runs after the push and the seeds. A Stripe-active subscription becomes ACTIVE until its paid-through date at cycle 1, with its renewal job and reminders. A past_due/unpaid one becomes PAST_DUE with the normal grace period. Each is priced from the seeded plan rate in its own currency, and the customer is emailed to add a card; the first card added becomes the default. seed:subscription-countries defaults a missing country to IN and writes the matching gateway, currency and Razorpay mandate headroom. Migrated customers no longer pass through PENDING_REAUTH. | PaymentEventProcessor.ts L97-334 · billingCycleWorker.ts L93-102 · migrate-to-dumb-pipe.ts L28-65 · seed-subscription-countries.ts L5-50 | 01 · 02 · 03 · 04 · 05 |
| D35 | high | resolved | Before the 13 Oct 2026 fix:The Upgrade modal is offered on CANCELLED subscriptions. With a saved card the upgrade charges and changes the plan, but the status stays CANCELLED.Fixed 13 Oct 2026 (owner decision A): a plan change on a CANCELLED subscription is a reactivation. It charges the chosen plan's price less the running discount (Stripe card on file: by the billing job under the charge lock; Razorpay or no card: one setup/popup payment that also saves the card or mandate). Its settlement applies the plan and sets ACTIVE with a new period starting at the payment, cycle +1. The follow-up schedules the renewal and cancels the grace jobs. A declined reactivation leaves it CANCELLED and emails the customer. The upgrade screen allows renewing on the current plan and shows the server's amount. | client SubscriptionAccordion.tsx L111-113 · UpdateSubscriptionModal.tsx L111 · subscription.service.ts upgradeSubscription L1191 (no status guard) | 04 |
| D34 | medium | resolved | Before the 13 Oct 2026 fix:Upgrade comparison and proration use the current plan's live rates, not the subscription's lockedPriceAmount, so a grandfathered or pushed price is ignored.Fixed 13 Oct 2026 (owner decision A): quotePlanChange starts from what the customer actually pays (the locked price, or a pushed price once it applies), not the current plan's list rates. It is the one pricing rule for the change, its preview, its permission and its charge. The running discount follows the new price by the price-push rule: a percentage is recomputed, a fixed amount is kept (discountOnPrice, also applied at a downgrade, upgrade and reactivation). | subscription.service.ts L1269-1270 | 04 |
| D37 | high | resolved | Before the 1 Oct 2026 fix: Stripe signup: Checkout runs in mode: 'setup' (card saved, no money) and returns the customer to /onboarding/success?session_id=…, which says “Payment received.” before any charge exists. The page polls /users/me; progress reaches 100% only for ACTIVE or PENDING_SETUP and stops at 90% otherwise, so a failed or missing cycle-0 charge leaves “Finalizing Setup…” forever.Fixed 1 Oct 2026: every signup path passes ?subscriptionId=; the page polls GET /subscription/status/:id (owner only). UNPAID: “Confirming your payment…”. PENDING_SETUP/ACTIVE/TRIALING: “Success!”, plus “Payment received.” only when a gateway took money. PAYMENT_FAILED: “Payment failed” with “Start again”. Not found or a closed checkout: “This checkout expired” with “Start again”. Both buttons go to /onboarding?step=2 for paymentPending users, else /portal/buy-new-subscription. | StripeProvider.ts L42-60 · subscription.controller.ts L29 · onboarding/success/page.tsx L44-75, L90-92, L111 | 01 |
| D38 | high | resolved | Before the 1 Oct 2026 fix: First-time customers with a failed first charge loop between sign-in and onboarding: login routes every paymentPending user to /onboarding?step=2; checkout there refuses with EXISTING_FAILED_SUBSCRIPTION and sends them to /signin (G1, G2). The planned “Retry Now” for PAYMENT_FAILED lives on /portal/subscription, which this user is never sent to.Fixed 1 Oct 2026: the price calculator no longer redirects EXISTING_FAILED_SUBSCRIPTION to /signin; it shows a message. Since 8 Oct 2026 a failed first payment no longer blocks a new checkout; only a renewal in PAST_DUE does, and the customer starts over at today's price (D3). | authActions.ts L82-93 · SignInClient.tsx L288-317 · price-calculator/index.tsx L553-557 · subscription.service.ts L442-453 | 01 |
| D43 | high | resolved | Before the 13 Oct 2026 fix:“Open Stripe Portal” in the red payment box has no billing effect. Cards changed there are never picked up: the Stripe adapter accepts only a setup_intent.succeeded carrying metadata.internalSubscriptionId, and the portal session sets no such metadata. Nothing is charged.Fixed 13 Oct 2026 (owner decision A): the Stripe portal is removed end to end:
| SubscriptionAccordion.tsx L441-514 · subscription.service.ts L2244-2263 · StripeWebhookAdapter.ts L66-71 | 04 |
| D45 | high | resolved | Before the 13 Oct 2026 fix:The upgrade modal's price estimate is its own calculation and differs from what the server charges. The client credits unused time from amountPaid and, on an interval change, shows new total − credit. The server charges the full new price with no credit on an interval change, and (new − current) × remaining fraction from plan rates on the same interval.Fixed 13 Oct 2026: the modal's own calculation is removed. It shows the server's quote from GET /subscription/upgrade-preview/:id (current price, new price, per-cycle charge with the discount, amount due today or next billing amount and date), the same quotePlanChange the change charges. | UpdateSubscriptionModal.tsx L182-272 · subscription.service.ts L1333-1335 | 04 |
| D39 | medium | resolved | Before the 13 Oct 2026 fix:getAuthContext filters owned subscriptions to ACTIVE, TRIALING, PENDING_SETUP, PAYMENT_FAILED, PAST_DUE and ENDED. UNPAID, CANCELLED, PENDING_REAUTH and EXPIRED are missing from that owned list. Active membership entries load subscriptions without this status filter, so these rows can still be visible through membership. Routing and default selection depend on which relation contains the subscription (G6).Fixed 13 Oct 2026 (with D41): getAuthContext loads every owned subscription, in any status. When no current subscription applies, an active owned one is chosen first and an unpaid signup or expired one only last. PENDING_REAUTH counts as a payment issue (renewal unpaid) beside PAST_DUE and PAYMENT_FAILED. | user.service.ts L1311-1326, L1410-1456 | 00 |
| D40 | medium | resolved | Reported 13 Oct 2026:/portal/subscription shows one subscription: currentSubscriptionId. That pointer moves to a new subscription only on its cycle-0 success, free signup or $0 signup. An existing customer's failed or UNPAID extra purchase is therefore not visible there, and a successful extra purchase switches their current workspace to the new PENDING_SETUP subscription.Closed 13 Oct 2026 as working by design (owner decision): a paid extra purchase switches the workspace to it. A failed one leaves the customer on their current workspace. A first payment is never retried from the subscription page, which retries renewals only. A declined Stripe charge closes that checkout, and the failure email links to start over. Razorpay retries happen inside the open popup, and a Razorpay payment the bank confirms after the popup closed is still honored automatically. The failed signup row is removed after 25 hours. | user.service.ts L697-775 · PaymentEventProcessor.ts L440-443 · subscription.service.ts L514-517, L695-698 | 01 · 02 |
| D41 | medium | resolved | Before the 13 Oct 2026 fix:checkSubscriptionStatus switches on 'active', 'trialing', 'past_due', 'unpaid', 'cancelled', 'expired', 'ended', 'pending_setup', but calculateSubscriptionStatus only produces 'active', 'pending_setup', 'payment_issue', 'ended', 'no_subscription'. 'payment_issue' and 'no_subscription' fall to the default 403 “Valid subscription not found.” Example: a PAST_DUE owner on /portal/profile cannot save the profile (PUT /users/profile uses this middleware). The client has no handling for these 403 responses.Fixed 13 Oct 2026 (owner decision, one access rule): utils/subscriptionAccess.ts maps the current subscription's real status to SIEM / team / profile / subscription / wallet access:
/me as subscriptionAccess and followed by:
/iam/me), /users/me and notifications are open to every status. | middlewares/auth.ts L293-359 · user.util.ts L4-39 · user.routes.ts L59-66 | 00 |
| D42 | medium | resolved | Before the 13 Oct 2026 fix:Paid-plan expiry reminders are never scheduled for a normal card signup. Reminders are (re)built only on free signup, $0 signup, cancel, resume, and by the 03:00 sync for rows whose emailReminders is NULL. A card signup reaches PENDING_SETUP with no currentPeriodEnd, so the sync writes [] and never picks it up again; admin activation and renewals do not reschedule. When reminders do exist (after Resume), their text is “Plan Expires … Renew Plan Now” on an auto-renewing plan, and they are not refreshed after each renewal.Fixed 13 Oct 2026 (owner decision A): 7/3/1-day reminders count down to a trial's end or an ACTIVE subscription's period end. Each is rendered when it is sent, from the subscription as it is then, and skipped if its date moved.
| subscription.service.ts L311-406 (callers L532, L744, L1080, L1132) · repeatableWorker.ts L635-680 | 03 |
| D44 | medium | resolved | For payment.authorized, the adapter called Razorpay's capture API after processing. Razorpay captures automatically (order.paid and payment.captured arrive about 1 second later), so that call failed: every payment.authorized event in the test database is logged “failed: Request failed with status code 400” (latest 7 Oct 2026; one redelivery received a 500 from the capture API), the route answered 400 and Razorpay kept redelivering.Fixed 8 Oct 2026: the capture call and its axios import are removed. payment.authorized creates no payment event and is logged processed; payments are recorded from payment.captured. Razorpay Dashboard Payment Capture must stay Automatic. | RazorpayWebhookAdapter.ts L42-68 · server.ts L91-107 · webhook_event_logs | 02 |
| D46 | medium | resolved | Before the 13 Oct 2026 fix:The customer “Delete” button for UNPAID/PAYMENT_FAILED subscriptions can never render: CancelSubscriptionButton is only mounted in the header actions for ACTIVE and TRIALING.Fixed 13 Oct 2026 (owner decision A): customers cannot delete a subscription. The button's “Delete” branch is removed, and the cancel API refuses an UNPAID/PAYMENT_FAILED signup instead of deleting it. Abandoned-signup cleanup removes those rows. A paid subscription is only cancelled at its period end. | SubscriptionAccordion.tsx L111-116, L520-565 | 04 |
| D47 | medium | resolved | When a linked quote coupon was expired, used or inactive and no typed code was supplied, checkout threw that coupon's error before winner selection, even when a promotion could apply.Resolved 8 Oct 2026 by the discount-selection design: an unusable quote discount is simply not offered; the selection falls back to a running promotion or no discount, and a standard plan stays purchasable. A custom plan still requires a usable quote. | subscription.service.ts L611-615 (invalid quote coupon without a typed code), L617-630 (typed code validation) | 01 · 07 |
| D48 | medium | resolved | The quote becomes ACCEPTED only inside the first successful settlement transaction, including internal ₹0 settlement. Failed or abandoned checkout leaves the quote unaccepted. Authenticated checkout additionally checks the current quote JWT, SENT status, matching plan and contact email.Also fixed 13 Oct 2026 (audit): the public POST /plan/quote/accept marked a quote ACCEPTED with no payment, for anyone holding the email link. It had no caller. The route, controller, service method and validator are deleted. Owner decision: a quote sent before its plan is deactivated stays valid until it expires. | subscription.service.ts L581-586 · PaymentEventProcessor.ts L438-439 | 01 · 07 |
| D49 | medium | resolved | The Stripe Checkout cancel link points to /onboarding/pricing, a route that does not exist in the client (app/(web)/onboarding has only page, success, payment-failed).Resolved 13 Oct 2026: the cancel link is signupStartAgainUrl: onboarding step 2 for a customer who has never paid, otherwise the portal buy page. | subscription.controller.ts L30 · siem-client-new/app/(web)/onboarding/ | 01 |
| D50 | medium | resolved | Before the 13 Oct 2026 fix:Access stops the moment a subscription leaves ACTIVE/TRIALING: agents are loaded only for those statuses, and severity alerts only run for them. Scheduled renewals now run at period end for both gateways. A successful retry starts the new period at the old period end, so days spent PAST_DUE count against the new period.Fixed 13 Oct 2026 (owner decision B): a renewal paid late, while the subscription is PAST_DUE or PENDING_REAUTH inside its grace period, starts its new period on the payment date. Example: monthly, period end 1 Feb, paid 5 Feb gives 5 Feb – 7 Mar. The renewal date moves with it. An on-time renewal keeps starting at the old period end, and a reactivation also starts at payment (D35). Access still stops while the payment is outstanding. A monthly subscription's 7-day grace ends in EXPIRED, which cannot be recovered. | user.service.ts L1482-1486 · repeatableWorker.ts L411-416 · PaymentEventProcessor.ts L443-445, L595-604 | 03 |
| D51 | high | resolved | The portal guard compares the server's derived active, pending_setup, payment_issue, ended and no_subscription values. A restricted customer without an ACTIVE/TRIALING owned subscription can open profile, subscription, notifications and buy-new-subscription; other portal routes redirect to subscription. An active owned sibling preserves access. The profile dropdown uses derived status values, and active customers with no subscription are not redirected to onboarding solely for that reason. D39, D41, D52 and D53 remain pending. | GlobalRouteController.tsx L10-22, L66-71, L102-108 ? header-profile-dropdown.tsx L50-63 ? portal-client-layout.tsx L80-86 ? user.util.ts L4-40 | 00 |
| D52 | medium | resolved | Before the 13 Oct 2026 fix:The sidebar shows “Access Restricted” only for derived PENDING_SETUP, ENDED or EXPIRED (EXPIRED is never sent). For payment_issue it renders the menu, but every item returns null, so the sidebar is empty with no link to the subscription page. Its PENDING_SETUP exception at L261 is unreachable, because L251 already shows “Access Restricted” for PENDING_SETUP; the agent list is still fetched for PENDING_SETUP.Fixed 13 Oct 2026: every status without SIEM access shows “Access Restricted” with a “Go to Subscription” link; the unreachable branch and the agent fetch for PENDING_SETUP are removed. What restricted customers may still open is decided with D39/D41. | SidebarMenuNew.tsx L152-168, L251-261 | 00 |
| D53 | medium | resolved | Before the 13 Oct 2026 fix:Comments say a server component PortalGuard (portal-guard.tsx) checks auth and subscription before the portal renders. No such file exists; portal/layout.tsx renders PortalClientLayout only. All portal page gating is client-side (GlobalRouteController and the onboarding guard in portal-client-layout.tsx); server API middleware is the only server-side check.Fixed 13 Oct 2026: the comments in portal-client-layout.tsx, iam-client-layout.tsx and (monitoring)/layout.tsx now state the real gating: GlobalRouteController on the client, and the API's own authentication and IAM checks for data. | portal/layout.tsx L14-18 · portal-client-layout.tsx L48-54, L151 · (monitoring)/layout.tsx L4-8 | 00 |
| D54 | high | resolved | A Stripe manual “Retry Now” could charge twice for one cycle: after a connection or 5xx error, retrySubscriptionPayment released the charge lock and the next click sent a new Stripe request (its key contains Date.now()). Example: a $500 cycle collected $1,000. A Stripe first charge with an unknown outcome also stayed “payment in flight” forever, holding its coupon slot.Fixed 8 Oct 2026: a Stripe charge with an unknown outcome (connection error, 5xx, idempotency conflict) keeps its charge lock and in-flight flag, and task:stripe-charge-reconcile resends the exact request with the same idempotency key after 2 minutes, backing off to hourly: Stripe returns the original result or performs the one intended charge, and the result goes through the normal success or failure handling. After Stripe's 24-hour idempotency window, the customer's PaymentIntents are searched by the charge's chargeKey metadata; a charge that does not exist is recorded as failed, which releases the lock (and, for a first charge, closes the checkout and its coupon slot). Applies to scheduled renewals, scheduled retries, manual retry, first charges and Stripe trial conversion. Razorpay keeps its durable charge-attempt guard.Also fixed 13 Oct 2026 (audit): each reconcile attempt restarts the TTL of the charge lock it still holds (never re-creating a released one), so the 24-hour TTL cannot open a second charge while reconciliation runs. A payment Stripe still shows as processing after the idempotency window is checked again instead of ending the reconciliation. | stripeChargeReconcile.ts · StripeProvider.ts chargeOffSession, findOffSessionCharge · billingCycleWorker.ts · retryPaymentWorker.ts · subscription.service.ts retrySubscriptionPayment · repeatableWorker.ts | 03 |
| D55 | medium | resolved | Before the 13 Oct 2026 fix:Grace warning emails for PAST_DUE and PENDING_REAUTH state the wrong deletion date. Paid activation sets expiryDate to the first period end; renewals never update it; the failure handler, the no-token renewal and the ceiling gate set no expiryDate; task:grace-warning sends sub.expiryDate as the deactivation date. Example: activated 1 Jan (expiryDate 31 Jan), renewed 31 Jan and 2 Mar, card fails at the 1 Apr renewal with a 7-day grace: the “Data Deletion in 3 Day(s)” email says 31 Jan; the subscription expires 8 Apr.Fixed 13 Oct 2026: one startPaymentGracePeriod sets expiryDate to the deactivation date and schedules the warnings and final deactivation from the same moment. It is used by the renewal-failure handler, the no-card or missing-price renewal, the mandate-ceiling gate and the cutover. The “Data Deletion in N Day(s)” email now states the real date. | subscription.service.ts L1685 · PaymentEventProcessor.ts L456-479, L605-608 · billingCycleWorker.ts L191, L217 · lifecycleWorker.ts L87-96 | 03 |
| D56 | medium | resolved | The upgrade ceiling path moved an ACTIVE Razorpay subscription to PENDING_REAUTH without scheduling the grace warnings or final deactivation that the billing-job ceiling gate schedules; PENDING_REAUTH expires only through that scheduled job, and the renewal job skips PENDING_REAUTH, so nothing ever scheduled it.Resolved 8 Oct 2026 with D9: the upgrade path no longer sets PENDING_REAUTH. | subscription.service.ts upgradeSubscription · billingCycleWorker.ts L93-102, L217-223 | 04 · 05 |
| D57 | high | resolved | verifyPayment checks the Razorpay signature and the token's bank confirmation only. It does not check that the order belongs to the posted subscription, or that payment.customer_id is the subscription owner's Razorpay customer (the webhook path checks this). Customer A can post A's own valid payment with B's subscription ID: B's saved token is replaced by A's, B's next renewal is sent with B's customer and A's token, fails, and B goes PAST_DUE. D18 (caller ownership) is the related route-level gap.Resolved 13 Oct 2026: verifySetupCompletion returns the paying customer and the subscription named in the server-created order; verifyPayment refuses the payment unless the order names this subscription and the customer is the owner's Razorpay customer, before any token is saved. | subscription.service.ts L848-885 · RazorpayProvider.ts L179-213 · PaymentEventProcessor.ts L89-93 · RazorpayWebhookAdapter.ts L118-123 | 02 |
| D58 | medium | resolved | The quote currency is chosen by the admin in sendQuote, independent of the prospect's company registration country. A $200 fixed USD quote sent to a company registered in India made INR checkout throw, so the custom plan could not be bought.Resolved 8 Oct 2026: the contact form takes a required company registration country and stores it on the inquiry. sendQuote derives the quote currency from it (IN: INR, others: USD); the admin form shows the currency read-only and sends none. Checkout through the quote link is fixed to that country: the calculator pre-fills and disables the country field, and the server does not offer the quote for a different country. An inquiry with no country cannot be sent a quote. | schema.prisma ContactUs.companyRegistrationCountry · contactUsSchema.ts createContactUsSchema, replyWithQuoteSchema · plan.service.ts sendQuote, getActivePlans · discountSelection.service.ts quoteContext · client ContactForm.tsx, price-calculator/index.tsx quoteCountry · admin contact-us/page.tsx | 01 · 07 |
| D59 | medium | resolved | Before the 13 Oct 2026 fix:An upgrade on a subscription with no saved payment method writes the new plan, assets, interval and locked price before any payment. Example: a ₹0-signup customer (100% lifetime coupon on a ₹1,000 plan) upgrades from 10 to 100 assets and closes the payment page: 100 assets apply immediately with nothing charged; the next renewal is ₹10,000 − ₹1,000 = ₹9,000 and, with no saved token, moves to PAST_DUE.Fixed 13 Oct 2026: nothing is written before payment.
| subscription.service.ts L1217-1247 · billingCycleWorker.ts L188-198 | 04 |
| D60 | medium | resolved | The public plan list returned a quote discount using its own eligibility check (ignoring reservations and the fixed coupon's currency), and the calculator priced from it.Resolved 8 Oct 2026: the calculator's discount and total come from the server's discount selection. The plan list's quote details remain an information banner (terms and expiry) and are not used for pricing. | plan.service.ts L549-568 · subscription.service.ts L238-240, L257-260 | 01 · 07 |
| D61 | medium | resolved | The coupon and promotion forms listed the free plan as an applicable plan, the quote form offered the free plan, and the server accepted both. The coupon and promotion pickers also loaded only the first 10 plans (the admin plan list pages by 10).Fixed 8 Oct 2026: the pickers load every plan and hide free plans (“Empty = All Paid Plans”); creating a coupon or promotion refuses a free or unknown plan ID; the quote plan list excludes free plans and sendQuote refuses a free plan. | discount.service.ts assertPaidPlans · plan.repository.ts findActiveQuotePlans · plan.service.ts sendQuote · admin coupons/page.tsx, promotions/page.tsx | 07 |
| D62 | medium | resolved | Invoices did not show discounts: discountDetails was never written, the PDF totals showed only Total and Amount Paid, and the line item carried the gross amount. Example: ₹3,995 plan with a ₹100 Festive offer: line ₹3,995, Total ₹3,895, no discount row.Fixed 8 Oct 2026 (Stripe and Razorpay share the settlement path): settlement writes discountDetails (source COUPON, QUOTE, PROMOTION or DISCOUNT; label; coupon code; which discounted cycle; total cycles) and the billing interval and, for renewals, the service period on the line item. The PDF totals read Subtotal ₹3,995 → “Festive offer · billing cycle 1 of 3” − ₹100 → Total ₹3,895 → Amount Paid; a ₹0 signup shows the full discount. Upgrade and trial-conversion invoices carry no discount. Invoices settled before this change have no discountDetails and print “Discount” without a name. | PaymentEventProcessor.ts settlement (discountDetails, renewalPeriod) · invoice.repository.ts buildInvoiceLineItems, InvoiceDiscountDetails · invoice-pdf.service.ts buildTotalsRows, renderLineItemDescription | 06 |
| D63 | high | resolved | The quote email link was built from BASE_URL (the API server) and kept its trailing slash: http://192.168.1.38:8000//portal/buy-new-subscription?token=…. The customer clicks “View Offer & Proceed to Payment” and lands on the API server, which has no such page.Resolved 8 Oct 2026: the link is built with new URL('/portal/buy-new-subscription', FRONTEND_URL), so it opens the customer portal and has one slash. | plan.service.ts sendQuote (link) | 07 |
| D64 | medium | resolved | Quote email content and sending. (1) With an empty message the admin form sent the placeholder “Custom Email Sent”, and the email printed it as the message. (2) A quote without a discount still said “Discounted Plan Offer”, “Discounted Price / Asset” and printed an empty “•” line; a fixed discount was also headed “Discounted Price / Asset” although the per-asset price was undiscounted. (3) The plan was named “Business - Discount Offer Plan”. (4) A failed email send was swallowed: the admin saw success and the inquiry was marked SENT with no email delivered. (5) The expiry was typed in the browser's date format.Resolved 8 Oct 2026: the form sends only the typed message and the email omits the message row when it is empty; title, subject, price column and closing line follow whether the quote has a discount; the plan keeps its own name; a failed send deletes the coupon just created and returns an error, nothing is marked SENT; the expiry is picked from a calendar and shown as “15 October 26”, and the email shows “15 October 26, 13:35 UTC”. | admin contact-us/page.tsx handleSendReply, quote expiry field · plan.service.ts sendQuote · email.service.ts sendContactReplyWithQuoteEmail · contact-reply-quote.html | 07 |
| D65 | medium | resolved | Discount choice in the calculator. An applied coupon code hid the quote and the promotions; switching back meant removing the coupon. With one promotion and no quote, no choice was shown at all, so the customer could not see that a promotion and a coupon were alternatives.Resolved 8 Oct 2026: one list shows every choice (quote, each running promotion, the applied coupon code) whenever at least one exists, and the coupon code box is always shown beneath it. The customer switches freely; an applied coupon stays in the list while another discount is selected, and the server checks it again when it is selected. | client price-calculator/index.tsx discountOptions, appliedCouponCode | 07 |
| D66 | high | resolved | The plan-change dialog read priceMonthlyINR / priceMonthlyUSD from the plan, fields GET /api/v1/plan/plans does not return; the API returns the prices in recurringPricing[].price (USD) and .priceINR. Every plan therefore priced at 0: the dialog showed ₹0.00 everywhere, “This plan does not have a price configured in INR” and a disabled Confirm Update, for USD subscriptions as well. No customer could change plan from the portal.Resolved 8 Oct 2026: the dialog reads the rate from recurringPricing for the subscription's currency. Professional monthly in INR is ₹475 per asset. | client UpdateSubscriptionModal.tsx rateFor · plan.service.ts getActivePlans recurringPricing | 05 |
| D67 | medium | resolved | Plan change paid through Razorpay: the customer clicks Confirm, the Razorpay popup opens, and nothing in it can be clicked or typed into. The “Are you sure?” dialog stayed open behind the popup; an open dialog blocks clicks and keyboard focus for everything outside it.Resolved 8 Oct 2026: the confirmation dialog closes before the Razorpay popup opens. | client UpdateSubscriptionModal.tsx handleConfirmedSubmit | 05 |
| D68 | medium | resolved | A Razorpay customer with an ACTIVE subscription could not add a payment method: the only Razorpay path was the recovery path, which charges a full billing cycle, so it refused ACTIVE with “Subscription is ACTIVE — payment method setup is not applicable.” A card about to expire could be replaced only after a renewal failed. “Set as default” also swapped the mandate without its limit, so a mandate with a lower limit would be charged above it at renewal, and no Razorpay method was ever marked default.Resolved 8 Oct 2026: POST /register-payment-method/:id registers an extra mandate on an ACTIVE subscription with a ₹1 payment (Razorpay registers a mandate only through a payment). The payment carries reason: method_registration: it gets its own invoice (“Payment method registration”), settles no billing cycle, moves no period and does not replace the default; a declined one changes nothing. “Set as default” stores the chosen mandate's own limit and refuses a mandate whose limit is below the renewal price. The default is the mandate the subscription charges. | subscription.service.ts registerPaymentMethod, setDefaultPaymentMethod, getPaymentMethods, verifyPayment · PaymentEventProcessor.ts method_registration · RazorpayWebhookAdapter.ts · constants.ts PAYMENT_METHOD_REGISTRATION_AMOUNT_INR · client PaymentMethods.tsx | 04 |
| D69 | medium | resolved | Stripe “add card” on an existing subscription sent an empty subscription id, so the webhook ignored the saved card and never ran the Indian-issued-card check. A customer on a USD subscription could add an Indian card, set it as default, and have the next renewal fail off-session.Resolved 8 Oct 2026: the add-card session carries the subscription id and reason: method_registration. The webhook applies the Indian-issued-card check and then stops: the card does not become the default and no charge is started. | subscription.service.ts createSetupSession · StripeWebhookAdapter.ts setup_intent.succeeded | 04 |
| D70 | medium | resolved | One card listed as several payment methods. A gateway saves a new payment method on every registration, even for a card it already holds. A Razorpay upgrade above the mandate limit registers a new mandate on the same card and the subscription moves to it; the replaced mandate stayed on the customer. One card upgraded twice showed three entries.Resolved 8 Oct 2026: when a saved payment method replaces the subscription's previous one, the previous one is removed at the gateway if it is the same card (brand, last four digits, expiry) or the same UPI ID and no subscription charges it. A different card is kept. Applies to Stripe and Razorpay. Duplicates created before this change stay until the customer removes them. | PaymentEventProcessor.ts handleTokenSaved, removeReplacedDuplicate | 04 |
| D71 | medium | resolved | The calculator showed every discount as an amount only (“$62.40 off”), so a 40% quote looked like a fixed discount, and a promotion was listed by its bare name (“Haha”) with nothing saying it is a promotion.Resolved 8 Oct 2026: every discount shows its percentage of the full price with the amount: “40% off ($62.40)”. A fixed discount shows the percentage it works out to for the chosen plan, assets and interval. Each choice carries its kind: “Quote discount”, “Promotion: Haha”, “Coupon CHANGE”. Same wording in the price summary and on the public pricing page. | client price-calculator/index.tsx percentOff, discountLabel, describeOffer | 07 |
| D72 | high | resolved | Price push defects. (1) A push less than 24 hours before renewal was charged at that renewal; the RBI 24-hour notice was stored but never checked. (2) A failed price-update email was not caught: the push still reported “Customer has been notified”. (3) The email printed “$” before amounts that already carried a symbol (“$₹3995.00”) and had no link. (4) An upgrade, trial conversion or scheduled downgrade kept the old pushed price, so the next renewal charged the old plan's pushed price. (5) A percentage discount stayed a fixed amount after a push (40% of $156 = $62.40 stayed $62.40 on $208). (6) Admin activation of a PENDING_SETUP subscription cleared a pushed price. (7) Saving a plan price could not reach existing customers at all.Resolved 9 Oct 2026: pushes run on the price-push-jobs queue, one job per subscription, and the email is sent at once. The pushed price starts at pendingPriceFromCycle — the next renewal when more than 24 hours away, otherwise the one after — and is never charged before priceUpdateNotifiedAt is set after a successful send. A percentage discount is recomputed on the new price, a fixed one keeps its amount. Plan changes clear the pushed price; activation keeps it. Saving changed plan prices asks “New customers only” or “New and existing customers”, with counts of affected subscriptions and Razorpay re-authorizations; Plan Details lists subscriptions on an older price with a push button.Also fixed 13 Oct 2026 (audit): a queued push job now carries its plan and skips a subscription that changed plan after the push was queued. Pushes are plan-wide only (D22).Also fixed 14 Oct 2026 (audit flow 38): the 24-hour notice was measured from staging, not from the email. When the email is recorded as sent, the first pushed cycle is re-checked against the send time under the subscription lock, so a slow or retried email moves the price one cycle later, never earlier. Duplicate push emails: D87. | pricePush.service.ts · pricePushQueue.ts · pricePushWorker.ts · subscription.service.ts resolveCyclePricing, PUSHED_PRICE_CLEARED · PaymentEventProcessor.ts settlement · billingCycleWorker.ts · plan.service.ts updatePlan · price-update.html · admin plans/page.tsx, price-differences-section.tsx | 06 |
| D73 | medium | resolved | Plans page and Plan Details. (1) “Active Subscriptions” counted every status except CANCELLED and EXPIRED (44 shown, 14 ACTIVE). (2) Revenue mixed currencies through a squeezed USD/INR switch. (3) Opening a plan in the edit form recalculated and overwrote its stored prices (Essential $42 shown as $7); the recalculation also used a stale tax value. (4) The table showed only USD, from the legacy price columns, with “-0%” on every interval. (5) Typing in search dropped characters (the input was bound to the 300 ms-delayed filter). (6) Edit was reachable only by mouse hover. (7) A plan could not be deactivated: the switch was locked on edit and the status route was commented out. (8) The plan list request returned 10 plans by default. (9) A paid plan could be saved with a $0 price or 0 minimum assets, and a validation failure gave no message.Resolved 9 Oct 2026: stats count each status in one group (active, pending setup, payment issues, not started, ended) and report revenue collected per currency without conversion; the form recalculates prices only after a base price, discount or tax changes; the table shows USD and INR per interval with the discount only when set; search is immediate; Edit and Activate/Deactivate are visible on each row (PATCH /plan/plans/:id/status, super admin, with a confirmation); all plans are loaded; paid plans need a USD and an INR price; invalid saves show a message. Plan Details shows the plan's rates, readable status names, and its latest subscriptions open the customer page.Completed 13 Oct 2026 (migration audit, flow 4): the server now refuses to create or save a paid plan unless all six USD and INR rates are above 0 (a 100% interval discount produced a 0 rate that checkout then refused). plan.service.ts assertPaidPlanRates. | plan.service.ts getPlanSubscriptionStats, setStatus, getAllPlans · plans.routes.ts · admin plans/page.tsx · plan-details-modal.tsx | 06 |
| D74 | high | resolved | Wallet top-up webhooks (wallet-system branch). (1) The Razorpay adapter detected a top-up from payment.notes, which the browser checkout sets; the wallet checkout sets none, so detection depended on Razorpay copying order notes. (2) A failed Razorpay top-up carried the customer's real subscription ID and cycle 0, so handlePaymentFailed ran the subscription failure path. (3) A failed Stripe top-up was never recorded: only the Checkout Session carried the metadata, not its PaymentIntent. (4) The failure handler also ran a walletPurchase lookup by externalId on every failed subscription charge; it could never match.Resolved 13 Oct 2026: adapters read the top-up keys only from server-created metadata (Razorpay order notes, Stripe session and PaymentIntent metadata) and put walletPurchaseId on the event. process() sends those events to handleWalletTopup; subscription handlers no longer see them. The Indian-card check applies to subscription payments only. | RazorpayWebhookAdapter.ts L106-110, L149, L155-158, L177 · StripeWebhookAdapter.ts L155 · StripeProvider.ts L220 · NormalizedPaymentEvent.types.ts · PaymentEventProcessor.ts L40-41, L78 | 02 |
| D75 | high | resolved | A top-up on the other gateway was charged and never credited. The wallet page lets the customer pick Stripe or Razorpay; process() rejected any event whose gateway or currency differed from the subscription's. Example: Razorpay (INR) subscription, $50 top-up through Stripe: $50 charged, checkout.session.completed rejected, 0 credits.Resolved 13 Oct 2026: decision A1: the customer chooses the top-up gateway; the country rule binds only off-session subscription charges. handleWalletTopup checks the event's gateway and currency against the WalletPurchase, before the subscription gate. | PaymentEventProcessor.ts L40-41, L78-104 · wallet/page.tsx gateway choice · Migrationphasewiseplan.md W.3 | 02 |
| D76 | high | resolved | Customer replaces a declined card in the same popup: the decline marked the purchase failed; handleTopupSucceeded completed only pending/expired, so the paid retry added 0 credits. Verify-payment accepted only pending, so the customer also saw “Cannot verify payment…” after paying; a payment finished after the 15-minute expiry got the same error. A late decline webhook could flip a completed purchase back to failed.Resolved 13 Oct 2026: success and verify both accept pending, expired and failed; a completed purchase is never marked failed.Also fixed 13 Oct 2026 (audit): the failure write was unconditional after a read, so a decline arriving while a success completed could still mark the purchase failed, and a later success credited it again. It is now one conditional update that never touches a completed purchase. | wallet-payment-event.service.ts L34, L57, L235 · wallet.controller.ts L180 | 02 |
| D77 | medium | resolved | Wallet coupon slots. A declined, abandoned coupon top-up kept its reserved slot forever (expiry scanned only pending). Completing an expired purchase called finalizeSlot, releasing a slot expiry had already released, which freed another customer's reservation.Resolved 13 Oct 2026: expiry also expires failed coupon purchases; an expired purchase that completes only counts the redemption.Also fixed 13 Oct 2026 (audit):
| wallet-topup-expiry.service.ts L16 · wallet-payment-event.service.ts L128 | — |
| D78 | medium | resolved | The top-up confirmation email printed “$” before the INR amount: a ₹1,000 top-up read “$1000.00”.Resolved 13 Oct 2026: the amount is formatted in the purchase currency. | email.service.ts L2665 · wallet-payment-event.service.ts queueWalletCreditPurchaseEmail call | — |
| D79 | medium | resolved | Razorpay top-up verify counted an authorized payment as paid and added credits before capture; an authorized payment that is never captured is auto-refunded and the credits stayed.Resolved 13 Oct 2026: only captured succeeds; other statuses return 202 pending and the portal says the credits will be added in a moment; the payment.captured webhook completes the purchase. | RazorpayProvider.ts L369 · wallet.controller.ts L204 · wallet/page.tsx verify handler | 02 |
| D80 | medium | resolved | Top-up verify skipped the order-ID match when the purchase had no stored order ID, so a signed payment from another order would pass.Resolved 13 Oct 2026: a missing order ID is rejected. | wallet.controller.ts L186 | — |
| D81 | medium | resolved | Wallet top-ups produced no invoice.Resolved 13 Oct 2026: separate from subscription invoices. Completing a purchase sets invoiceNumber (WLT{YYMMDD}{seq3}) and paidAt in the credit transaction; the PDF (“Wallet Top-up Receipt”) is rendered from the purchase. Portal and admin wallet transaction tables link each top-up row. Top-ups completed before this change have no invoice. | schema.prisma L1307 · wallet-invoice-pdf.service.ts · wallet-payment-event.service.ts L146 · wallet.controller.ts L229 · wallet.routes.ts L74 · admin-wallet.routes.ts L25 · client wallet/utils/invoice.ts · admin SubscriptionWalletSection.tsx | — |
| D82 | medium | resolved | Any subscription could buy credits, including CANCELLED, ENDED and EXPIRED; the sidebar Wallet item had no permission check.Resolved 13 Oct 2026: only ACTIVE and TRIALING can top up (403 otherwise); the wallet summary returns can_top_up and the portal disables “Top Up Credits”; the sidebar item requires siem:wallet:read. | wallet-topup.constants.ts · wallet-topup.service.ts L33 · wallet.service.ts L63 · client wallet/page.tsx Top Up button · nav-user.tsx | — |
| D83 | medium | resolved | Breaks introduced by the merge itself. Server: the wallet side re-added getSubscriptionDetails importing the deleted stripe-webhook.service. Client: the price-preview query sent paymentMethodChoice instead of companyRegistrationCountry (rejected by .strict()). Admin: the date-fns import was dropped while the quote-expiry picker still called format; Plan Details declared formatDate twice; apiPatch was declared twice; the customer summary lost its Current Period and Renews On tiles.Resolved 13 Oct 2026 while resolving each conflict; client and admin type checks pass. | subscription.service.ts · client subscriptionAction.ts · admin contact-us/page.tsx · plan-details-modal.tsx · auth/lib/apiWrapper.ts · customers/[id]/components/SubscriptionAccordion.tsx | — |
| D84 | high | resolved | Before the 14 Oct 2026 fix: agents removed by a downgrade never left the SIEM server. removeWazuhAgent was commented out, so only the database row was deleted. The agent kept sending logs and using engine capacity while the customer paid for fewer, and the admin email subject said “Agents Removed”.Fixed 14 Oct 2026 (owner decision B: no automatic engine change): the agent leaves the customer's portal, the asset log records that an admin was asked to remove it from the SIEM server, and the email subject reads “Action Required: Remove Agents from the SIEM Server” with the reason (Downgrade, Upgrade, Reactivation or Trial conversion). Custom agent names are listed, and the count covers only processed agents. | subscription.service.ts processPendingAgentRemovals · email.service.ts sendAdminAgentsRemovedEmail | 04 |
| D85 | high | resolved | Before the 14 Oct 2026 fix: the agent selection for a lower asset count could remove every agent.
| subscription.service.ts markAgentsForDowngrade, agentsRemovedByChange · UpdateSubscriptionModal.tsx · AssetsSection.tsx (client, admin) · user.controller.ts getAssets | 04 |
| D86 | medium | resolved | Before the 14 Oct 2026 fix: an upgrade, reactivation or trial conversion to fewer assets than onboarded ignored the agent selection and kept every agent.Fixed 14 Oct 2026 (owner decision A): any change below the onboarded count asks which agents to keep.
db push. | subscription.service.ts recordAgentsForPaidChange, applyPaidChangeAgentRemovals, applyDowngradeAgentRemovals · PaymentEventProcessor.ts upgrade, reactivation and trial settlement · billingCycleWorker.ts · schema.prisma | 04 |
| D87 | medium | resolved | Before the 14 Oct 2026 fix: a plan-wide price push could email a customer twice. The per-subscription jobs had no fixed ID, so a retried plan job or a second click on Push queued the same subscription again. Two jobs that both ran before the first recorded its email both sent one (nothing was double-charged).Fixed 14 Oct 2026: the plan job adds every subscription job in one addBulk, with job ID price-push-{planId}-{subscriptionId}-{price}, so BullMQ drops repeats; a repeat after the job history is cleared finds the price already staged and emailed. Price pushes are plan-wide, by a super admin, from a plan save (“New and existing customers”) or the Price differences section. | pricePushQueue.ts pushPlanSubscriptions · pricePushWorker.ts | 06 |
| D88 | medium | resolved | Before the 14 Oct 2026 fix: a paid trial conversion waited in PENDING_SETUP for admin activation, unlike any other upgrade. The API also converted a trial still in PENDING_SETUP although the portal never offered it, and the Stripe conversion charge kept its charge lock for up to 24 hours after succeeding.Fixed 14 Oct 2026 (owner decisions 2: A, 3: A):
| PaymentEventProcessor.ts trial_upgrade settlement · subscription.service.ts TRIAL_CONVERTIBLE_STATUSES, startTrialConversion | 04 · 05 |
| D89 | medium | resolved | Before the 14 Oct 2026 fix: the payment-success email linked to {FRONTEND_URL}/api/v1/subscription/invoice/{id}/pdf. The client app does not forward /api to the server and the route needs a login, so the link worked only through a deployment proxy and an existing session. The signup confirmation carried no invoice at all.Fixed 14 Oct 2026 (owner decision A): the invoice PDF is attached to the renewal payment-success email and the signup confirmation (paid or $0 signup; a free claim has none), with the same filename as the portal download (InvoicePdfService.fileName). The email link opens /portal/subscription. | PaymentEventProcessor.ts completeSettlementFollowUp · subscription.service.ts sendOnboardingEmails · invoice-pdf.service.ts · payment-success.html | 06 |
| D90 | medium | resolved | Before the 14 Oct 2026 fix: two screens showed the wrong unit.
| client price-calculator/index.tsx · admin subscriptions/[...id]/page.tsx | — |