Live job typeArtifact + browser checks passed
task:billing-cycle
Takes a first charge or renewal after status, cycle, cancellation, downgrade, pricing and mandate checks. Also handles a paid change after a Stripe card is saved.
Triggered by: Activation, renewal — card saved, reconciliation
- Job firesbilling-cycle-<sub>-<n>
- Status + cycle valid?0: UNPAID/PAYMENT_FAILED
- Period ended?ACTIVE, cycle ≥ 1 only
- Cancel flag set?cancelAtPeriodEnd
- Downgrade scheduled?downgradePlanId set
- Charge stepcontinues in flow 1b
- Locked price set?lockedPriceAmount
- Amount ≤ 0?price − running discount
- Card on file?provider + externalTokenId
- Razorpay ceiling ok?only when price pushed
- Lock + chargecharge-in-flight, 24 h
- Gateway resultStripe or Razorpay
- Job fires now…-<cycle>-<reason>
- Change still valid?status + same cycle
- Stripe card saved?externalTokenId
- Amount ≤ 0?discount or tiny proration
- Chargelock unless upgrade
- Stripe resultwebhook decides
Stops / waits: Skip: stale job (≥1 needs ACTIVE/PAST_DUE); Re-delay job (until currentPeriodEnd); Settled internally (no gateway call); PAST_DUE + grace (no-payment-method email); PENDING_REAUTH (grace + email, no charge); Skip: lock held (other charge settles it); Unknown (Stripe) (stripe-charge-reconcile); Skip (status or cycle moved); Skip: no card (nothing charged); Declined (email, nothing changes).
Exact behavior, writes, messages and failure handling
task:billing-cycle — checks before charging
- Queued by: admin activation (cycle 1, delayed to the first period end); settlement follow-up after each paid renewal (next cycle at the new period end); Stripe TOKEN_SAVED (delay 0: the cycle-0 first charge, or a PAST_DUE recovery charge); an upgrade that changes the interval (job replaced at the new period end); task:billing-cycle-reconciliation (lost job). Job ID billing-cycle-<sub>-<cycle>, attempts 1.
- Status + cycle check: cycle 0 runs only for UNPAID or PAYMENT_FAILED; cycle ≥ 1 only for ACTIVE or PAST_DUE. The job's cycle must equal subscriptions.billingCycleNumber, otherwise it is a stale job and does nothing.
- Period check: an ACTIVE subscription (cycle ≥ 1) whose currentPeriodEnd is still ahead is moved back to delayed until that time. PAST_DUE and cycle 0 run at once.
- Cancel flag: beginCancellationGracePeriod → status CANCELLED, expiryDate = now + grace (7 / 15 / 30 days by interval), grace-warn-3d, grace-warn-1d and grace-final queued, email 'Your Paid Access Has Ended'. No charge. The job ends here.
- Scheduled downgrade: planId, assetsReqCount, interval, intervalDays and lockedPriceAmount switch to the downgrade; discountAmountPerCycle recomputed (percentage follows the new price, fixed stays); pushed price cleared; downgrade fields cleared; agents marked pendingRemoval are deleted (asset_logs) and admins get 'Action Required: Remove Agents from the SIEM Server (Downgrade)'. Then the charge step runs with the new price.
task:billing-cycle — charge and outcome
- Locked price missing → status PAST_DUE and startPaymentGracePeriod (expiryDate, grace-warn-3d/1d, grace-final). A missing price is never treated as a free cycle.
- Amount = cycle price (pendingPriceAmount once its cycle is reached and the customer was emailed, else lockedPriceAmount) − running discount (while discountCyclesUsed < discountCyclesTotal; null = forever). ≤ 0 → PaymentEventProcessor gets an internal PAYMENT_SUCCEEDED (externalId internal_zero_<sub>_cycle_<n>, 'Fully discounted — no payment taken'); period advances like a paid renewal.
- No provider or card → PAST_DUE + grace + email 'Payment Failed - Action Required': no payment method on file, amount due, days left to add one.
- Razorpay ceiling: only when a pushed price applies. Price above mandateMaxAmount → PENDING_REAUTH, paymentFailureSubReason PRICE_EXCEEDS_MANDATE, grace started, email asking to re-authorize. The old price is not charged.
- Lock + charge: Redis key charge-in-flight:<sub> (NX, 24 h). Held by another charge → job ends; that charge settles the cycle. Cycle 0 also claims the single signup debit (signupPaymentInFlight). chargeOffSession with idempotency key sub_<id>_cycle_<n>_<token> and metadata internalSubscriptionId, billingCycleNumber, retryAttempt 0, discountAmountApplied.
- Declined at once → PAYMENT_FAILED event processed now: cycle 0 → PAYMENT_FAILED (Stripe checkout closed, coupon slot released, one email); cycle ≥ 1 → PAST_DUE, first failure emails and starts grace, retry-payment +3 days if the reason is temporary. Job marked failed in Bull Board.
- Success or pending → nothing written here; the gateway webhook settles it (renewal: ACTIVE, period from old end + interval, cycle + 1, invoice, settlement follow-up). Stripe unknown outcome → lock kept, stripe-charge-reconcile queued.
task:billing-cycle (plan change paid with a just-saved Stripe card)
- Queued by: Stripe setup_intent.succeeded (TOKEN_SAVED) whose setup carried a plan change — trial conversion, an upgrade with no usable card, a reactivation with no card; and by upgradeSubscription directly for a Stripe reactivation with a card on file. Job ID billing-cycle-<sub>-<cycle>-<reason> (trial_upgrade, upgrade, reactivation) so the cycle's delayed renewal job does not swallow it. Delay 0.
- Still valid: trial_upgrade needs the current plan still free and status TRIALING or ENDED; upgrade needs ACTIVE; reactivation needs CANCELLED; the subscription's cycle must equal the job's.
- Amount ≤ 0 (a running discount covers it, or an upgrade proration under 0.50) → internal PAYMENT_SUCCEEDED with the change attached; the change is applied without a gateway call.
- Charge: trial conversion and reactivation take the charge lock; an upgrade proration does not. Idempotency key sub_<id>_<reason>_<cycle>_<plan>_<assets>_<interval>_<token>. Unknown outcome → stripe-charge-reconcile.
- Paid (webhook): trial → ACTIVE, plan applied, period starts now, paid cycle 1, next renewal cycle 2, trial and grace jobs removed, 'Payment Successful' + PDF, admins 'Payment Received'. Reactivation → ACTIVE, chosen plan, new period from payment, cycle + 1, 'Payment Successful'. Upgrade → plan, assets, price applied; changed interval restarts the period and replaces the renewal job; 'Subscription Upgrade Details'. Agents not kept are deleted and admins emailed.
- Declined: lock released (trial/reactivation), email 'Payment Failed - Action Required' saying the change did not happen; status, plan and period unchanged; no grace, no retry.
Live job typeArtifact + browser checks passed
task:retry-payment
Retries a temporarily declined renewal, while preserving cycle checks and the single-charge lock.
Triggered by: Renewal declined — temporary reason only
- Job fires +3 daysattempt 1, 2 or 3
- Still PAST_DUE?same cycle, card, price
- Amount ≤ 0?price − discount
- Charge lock free?charge-in-flight:<sub>
- Charge againkey …_retry_<k>
- Gateway resultStripe or Razorpay
Stops / waits: Skip (paid, expired, no card); Wait 15 minutes (same attempt runs again); Declined again (next attempt if temporary).
Exact behavior, writes, messages and failure handling
task:retry-payment
- Queued by: PaymentEventProcessor.handlePaymentFailed for a renewal (cycle ≥ 1) when the failure reason is temporary — Stripe insufficient_funds, processing_error, issuer_not_available, try_again_later and similar; Razorpay insufficient_funds, bank/gateway/issuer technical errors, payment_timed_out and similar — and it is not a Razorpay mandate-limit breach. Failed attempt 0 (the renewal) → attempt 1, 1 → 2, 2 → 3. A manual 'Retry Now' failure queues nothing.
- Job ID retry-payment-<sub>-cycle<n>-attempt<k>, delay 3 days, attempts 1. Completed jobs are kept so a redelivered failure webhook cannot add the same attempt twice.
- Still PAST_DUE: status must be PAST_DUE, cycle unchanged, provider, card and locked price present. Paid in the meantime, expired, or token revoked → nothing happens.
- Amount = pushed or locked price − running discount, the same as the original renewal. ≤ 0 (a discount added since) → internal settlement, chain ends.
- Lock held (a manual retry in flight) → job moved back to delayed for 15 minutes and runs again; if the other charge settles the cycle, the PAST_DUE check then skips it.
- Paid → webhook: ACTIVE, new period starts at the payment date (late payment), cycle + 1, grace and retry jobs removed, 'Payment Successful' + PDF. Declined → PAYMENT_FAILED event: no email, no new grace; the next attempt only for a temporary reason, at most 3. Monthly grace is 7 days, so attempt 3 (day 9) never runs for monthly plans.
Live job typeArtifact + browser checks passed
task:plan-price-push
Finds customers whose recurring price differs and queues an individual price update for each.
Triggered by: Super admin push — save or push button
- Job firesattempts 5
- List plan's subs4 billing statuses
- Price differs?plan rate vs current
- Gateway pair valid?country vs gateway
- Add one job eachaddBulk
Stops / waits: Same price (left alone); Logged unpushable (reason in job log).
Exact behavior, writes, messages and failure handling
task:plan-price-push
- Queued by: a super admin saving changed plan prices and choosing 'New and existing customers' (PUT /plan/plans/:id with applyToExisting), or 'Push new price to these' in Plan Details (POST /plan/plans/:id/price-push). No fixed job ID; attempts 5, exponential backoff from 5 s.
- List: subscriptions of this plan in ACTIVE, PAST_DUE, PENDING_REAUTH or PENDING_SETUP with a locked price and a provider. A free plan has nothing to push.
- Price differs: the plan rate for the subscription's own currency, interval and asset count, compared with what it pays now (pendingPriceAmount if one is staged, else lockedPriceAmount). Equal → left alone.
- Gateway pair: getSubscriptionBilling must accept the stored company country and gateway; a mismatch is written to the job log as not pushed.
- Add one job each: one task:subscription-price-push per subscription in a single addBulk, job ID price-push-<plan>-<sub>-<price> (a repeat of the same push adds nothing). The preview endpoint uses the same comparison to count subscriptions and Razorpay re-authorizations before the admin confirms.
Live job typeArtifact + browser checks passed
task:subscription-price-push
Stages one customer’s new recurring price, preserves their discount and sends advance notice.
Triggered by: Plan push fan-out — flow 4
- Job firesconcurrency 5, attempts 5
- Still applies?status, plan, assets
- Stage new pricenot charged yet
- Email customerPrice Update Notification
- Record email time24 h notice re-checked
- Charged at renewalfrom pendingPriceFromCycle
Stops / waits: Skip (changed or already told); Send failed (job throws, retried).
Exact behavior, writes, messages and failure handling
task:subscription-price-push
- Queued by plan-price-push. Job ID price-push-<plan>-<sub>-<price>; concurrency 5; attempts 5 with backoff.
- Still applies: status ACTIVE, PAST_DUE, PENDING_REAUTH or PENDING_SETUP; same plan, asset count and interval the push was priced for (a plan change since then priced itself); not already staged and notified at this price.
- Stage: pendingPriceAmount = new price; pendingPriceFromCycle = the next renewal if it is more than 24 hours away, otherwise the renewal after it (PENDING_SETUP → cycle 1, the first renewal after activation); pendingDiscountAmountPerCycle = running discount on the new price (percentage recomputed, fixed kept); priceUpdateNotifiedAt cleared.
- Email 'Subscription Price Update Notification': old and new price per cycle, the renewal date it is first charged, 'Your running discount continues…' when one runs, and for Razorpay above the mandate 'You will be asked to re-authorize it before this renewal'. A failed send throws; the job is retried.
- Record: priceUpdateNotifiedAt = time of the email; pendingPriceFromCycle moved one renewal later if the email went out less than 24 hours before the staged renewal. Until this is set the new price is never charged.
- At renewal: the cycle is charged at the new price less the running discount; on success lockedPriceAmount and discountAmountPerCycle take the new values and priceUpdateAppliedAt is set. Razorpay with the new price above mandateMaxAmount → PENDING_REAUTH, no charge, re-authorization needed. Any plan change clears the pushed price.
Live job typeArtifact + browser checks passed
task:stripe-charge-reconcile
Finds the outcome of a Stripe charge that may already have succeeded without creating a second charge.
Triggered by: Unknown Stripe result — 2 / 4 / 8 … 60 min
- Read original requestkey, metadata, first time
- Extend charge lockexcept upgrade proration
- Inside 24 hours?idempotency window
- Repeat identical chargesame idempotency key
- Record known outcomePaymentEventProcessor
Stops / waits: Unknown → next check (attempt +1, same first time).
Exact behavior, writes, messages and failure handling
task:stripe-charge-reconcile
- An unknown Stripe charge queues the full original params, attempt and firstAttemptAt; delay min(2 × 2^attempt,60) minutes. ID stripe-reconcile-<key>-<attempt>; three execution attempts per queued check. stripeChargeReconcile.ts:25–31; repeatableQueue.ts:6–11.
- Extend subscription charge lock except upgrade proration, which never held it. stripeChargeReconcile.ts:34–41.
- Within 24 hours replay the same request/key. Unknown → queue another check. Succeeded/failed → normalized processor event. A normal pending result is left to its webhook. stripeChargeReconcile.ts:43–50,67–104.
- After 24 hours do not replay: search existing PaymentIntents by charge metadata. Pending → check again; final result → process; absent → PAYMENT_FAILED with charge_not_created. stripeChargeReconcile.ts:53–64.
- The processor owns invoice/status/discount settlement, grace and next jobs. This job preserves original reason, cycle and plan-change metadata.
Live job typeArtifact + browser checks passed
task:billing-cycle-reconciliation
Restores missing renewal jobs without racing existing jobs or in-flight debits.
Runs: Every hour, on the hour.
- Find paid ACTIVE rowsperiod end ≤ now +1 h
- Renewal job exists?waiting, delayed, active
- Overdue by 15 min?else future-only repair
- Check charge in flightRedis + Razorpay pending
- Restore billing jobsame sub + cycle ID
Stops / waits: Excluded subscriptions (free / no provider / other status); Keep existing job (already pending execution); No repair in margin (ended ≤15 min ago); Wait for charge result (lock / pending debit).
Exact behavior, writes, messages and failure handling
task:billing-cycle-reconciliation
- Hourly 0 * * * *; no explicit cron timezone. repeatableQueue.ts:132–137; repeatableWorker.ts:1075–1079.
- Select ACTIVE non-free subscriptions with provider and period end within one hour or earlier. Existing waiting/delayed/active renewal → skip. billingCycleQueue.ts:73–91.
- Within overdue margin queue only a future renewal with no job at all; ended ≤15 minutes ago → leave alone. More than 15 minutes overdue → skip Redis charge lock or pending RazorpayChargeAttempt. billingCycleQueue.ts:93–105.
- Restore task:billing-cycle with current cycle and period end; negative delay becomes zero. Completed/failed retained job is removed before replacement. The billing worker rechecks before charging. billingCycleQueue.ts:29–46,108–123.
- No invoice, status write, charge or email occurs directly in this reconciliation job.
Live job typeArtifact + browser checks passed
task:payment-settlement-follow-up
Completes onboarding or renewal scheduling and invoice emails after a payment settlement commits.
Triggered by: Settlement committed — or maintenance recovery
- Load settled invoicenot already followed up
- Load owner and planmissing → throw
- First-charge invoice?billingCycleNumber = 0
- Run follow-up branchonboarding or renewal
- Mark follow-up finishedsettlementFollowUpAt
Stops / waits: Skip already done (or settlement not applied); Retry unfinished work (failed email throws).
Exact behavior, writes, messages and failure handling
task:payment-settlement-follow-up
- After commit queue settlement-follow-up-<invoiceId>. Maintenance recovers up to 100 unfinished settled invoices and claims each for five minutes. PaymentEventProcessor.ts:734–755.
- Skip unapplied/already-followed-up invoice. Missing subscription, owner or plan throws. PaymentEventProcessor.ts:758–765.
- Cycle 0: create membership if absent, bootstrap owner IAM, cancel lifecycle jobs, rebuild reminders, send onboarding emails: admin pending setup, customer in-progress with PDF, onboarding instructions. PaymentEventProcessor.ts:775–787; subscription.service.ts:817–885.
- Later invoice: only latest paid cycle on ACTIVE with period end queues next billing, cancels lifecycle/retry jobs and rebuilds reminders. Old recovered follow-up must not cancel newer grace. PaymentEventProcessor.ts:789–801.
- Later payment email includes invoice PDF. Save delivered email keys immediately; failed send throws so retry skips saved emails. Invalidate cache, set settlementFollowUpAt, clear nextSettlementCheckAt. PaymentEventProcessor.ts:768–774,805–819.
- Sending and recording an email are separate operations; the marks prevent ordinary retry duplicates, not a strict exactly-once delivery guarantee.