Skip to content

Release dev-to-prod 2026-08-23 - #1228

Merged
teetangh merged 15 commits into
prodfrom
release/dev-to-prod-2026-08-23
Aug 23, 2026
Merged

Release dev-to-prod 2026-08-23#1228
teetangh merged 15 commits into
prodfrom
release/dev-to-prod-2026-08-23

Conversation

@teetangh

Copy link
Copy Markdown
Contributor

Release train: dev → prod, 2026-08-23

Promotes 15 commits from dev. Highlights:

No hotfix divergence: all 6 prod-only commits are prior release-PR merge commits.

…riod-ended detection, toast catalog, stale-reschedule fix (#1211)

Audit gaps #5/#6/#11 from the booking-journey investigation.

errorCode forwarding: all four allocate routes now include
result.errorCode in their failure response body, and the client's
AllocationResponse / AllocationResult carry it through to the hook.

Cause-specific codes:
- NO_AVAILABILITY: consultant has no published availability rows.
- PERIOD_ENDED: the scheduling period is in the past — actionable
  ("extend the period on the plan") instead of the opaque "0 of N".
- SLOT_SHORTAGE: period is future but availability/caps fall short.

Toast catalog: allocationFailedWithCode() maps these to distinct titles
("No availability published" / "The scheduling period has ended" /
"Not enough free slots") while keeping the raw server detail as the
description. Unknown codes fall back to the generic copy.

Stale-reschedule fix (#1012 regex gap): a #1012 stale-tab reschedule 409
was being mislabeled "Already allocated" because its message wasn't in
preservedMessages. Now it routes to requestChangedElsewhere() (dialog
closes + refreshes), which was a dead export before this change.

Tests: PERIOD_ENDED + SLOT_SHORTAGE code assertions via mocked allocator.
…budget model (#1204)

The hygiene guard failed ANY two recurring jobs sharing a start-minute,
treating simultaneous starts as intrinsically bad because they stampede
the Supabase pool (#932). Minute-uniqueness is only a proxy for the real
invariant — concurrent pool footprint — and it scales badly: every new
job needs a fresh minute on an increasingly crowded clock, and the grid
was running out of free minutes.

The guard now models cost instead:

- Workflows may declare their estimated DB-active runtime anywhere in
  the file with `# cron-runtime-minutes: N` (default 2, right for the
  quick sweeps). It is a comment, not YAML: metadata for this guard,
  kept beside the cron line so schedule and cost read together.
- For each start-minute shared by recurring jobs, the summed declared
  runtimes must stay within POOL_BUDGET_MINUTES = 10, derived from the
  pg.Pool guidance in lib/prisma.ts (10 clients per instance before
  PG_POOL_MAX clamps it in serverless) and Supavisor's small
  server-side pool — the saturation that turned #932 into 5-9.6s
  connects. Once-a-day overlaps stay tolerated as before.
- A single job declaring more than the whole budget fails on its own
  declaration; no staggering can ever fit it.

The heaviest sweeps are annotated from their steps (only the tsx step
touches the pool): reconcile-slot-availability 8, auto-complete-
appointments 6, cleanup-tentative-slots 5, cleanup-invalid-appointments 5.

Scheduling policy documented in docs/booking/13-cron-jobs-and-background-
tasks.md. Verified: guard exits 0 on the current fleet (12 tolerated
once-a-day overlaps preserved); a 99-minute declaration and a forced
14m co-start both fail with budget-mentioning errors.
)

Scenario 17 exercises the full B4/B5/B8 stack shipped by the hardening
train: optimistic capacity pre-check, bounded lock waits, typed BUSY 409s,
client single-retry, and RFA hold caps. Invariants: exactly one winner per
hot slot, exactly maxParticipants confirmations, zero raw 502/504s, P95
under the function ceiling. Staged — needs a load generator against
staging; listed as an exit gate alongside scenario 6.
…tion, decline/withdraw notifications (#1214)

* docs(ops): flash-sale chaos scenario 17 in the chaos-test runbook (#874 exit gate)

Scenario 17 exercises the full B4/B5/B8 stack shipped by the hardening
train: optimistic capacity pre-check, bounded lock waits, typed BUSY 409s,
client single-retry, and RFA hold caps. Invariants: exactly one winner per
hot slot, exactly maxParticipants confirmations, zero raw 502/504s, P95
under the function ceiling. Staged — needs a load generator against
staging; listed as an exit gate alongside scenario 6.

* fix(booking): partial-reschedule parent status + candidate-walk truncation + decline/withdraw notifications

#1192 — partial reschedule leaves parent APPROVED while a session is
mid-negotiation (audit B-P2-02): a partial reschedule flips released slots
to tentative+RESCHEDULED but leaves the parent subscription APPROVED with
no transition edge back. The sweep now releases stale tentative-RESCHEDULED
slots past 30 days (scoped to APPROVED subscriptions, isTentative +
completionStatus RESCHEDULED only) so ghost holds stop blocking the
calendar. The parent stays APPROVED (it has live confirmed sessions).

#1194 — MAX_CANDIDATE_STARTS_PER_ROW truncation: the 48-step cap silently
truncated candidate walks when adjacent rows kept isWithinAvailability
answering true past the row's own end. candidateStartsInRow now accepts a
rowEndMs boundary computed by the caller from the availability slot's own
duration (weekly: startDay/startTimeUtc→endDay/endTimeUtc; custom:
endsAt), and stops there. Callers (rowStartsForDay,
bestBlockForSingleSession) build {start, endMs} pairs instead of bare
dates. The 48-step safety net remains for callers that can't supply a row
end.

Decline/withdraw notifications (audit G2 tail): RescheduleOutcomeFields
widened with DECLINED/WITHDRAWN arms; reschedule-respond.ts fires
notification on decline; reschedule-withdraw.ts fires on withdraw. Both
fire-and-forget. Novu dashboard template must render the new arms before
the copy appears — the code is inert until then.

Tests: sweep no-op assertion updated for the new stale-rescheduled pass.
Full suite: 1191 tests green; tsc clean; no new eslint findings.
…t races, spikes, and stranded money (#1205)

* fix(refunds): retry settle cascade, adopt webhook bind race, tolerate P2002 declines

- Phase 3b settle tx wrapped in withSerializableRetry (the Phase 1 comment
  already promised this): a P2034 abort after the gateway refund landed used
  to surface as a raw error and strand the row in webhook-only recovery.
- Phase 3a bind is now P2002-tolerant: when the gateway's refund.created
  webhook wins the unique-id race, adopt its row, retire the placeholder
  (which would otherwise double-count against the refundable balance until
  the 24h reconciler failed it), and drive the settle cascade against the
  surviving row.
- Declined-refund branch no longer dies on the same race.

* fix(refunds): reconcile by reservation id; poll real-id PENDING rows

The matcher required gateway metadata.created, which no writer ever set,
so it could never match — every placeholder was force-FAILED at 24h even
when the refund had landed, restoring the balance and inviting a second
gateway refund under a fresh idempotency key.

- Exact bind on notes.reservationId (the identity we already ride to the
  gateway); single-unambiguous-amount fallback for legacy rows; ambiguous
  candidates page instead of guessing.
- A placeholder is FAILED only when the listing succeeded and no refund
  carrying our reservation id exists after 24h.
- Bind is P2002-tolerant: if the webhook's row won the unique-id race the
  placeholder is retired (stops double-counting against refundable
  balance) instead of crashing the run.
- New pass polls real-id PENDING refunds via getRefund until the gateway
  reports processed/failed — closing the limbo class excluded from both
  crons (earnings stayed payable on refunded money). Never aged out
  locally: normal refunds legitimately take 5-7 business days.

* fix(webhooks): serializable refund cascade, per-refund idempotency, balanced invoice-refund journal

handleRefundCreated:
- Wrap the tx in Serializable + withSerializableRetry, matching the cascade's
  documented contract. Under READ COMMITTED two distinct partial refunds on
  one payment could interleave their earnings-reversal read-modify-writes and
  silently lose an increment — overstating readyAmount into the next payout.
- Refund status guard: map the gateway string BEFORE comparing (the old
  enum-vs-raw compare never short-circuited) and treat SUCCEEDED/FAILED/
  CANCELLED as terminal so out-of-order refund.created can no longer
  downgrade a settled refund into unsweepable limbo.

Wallet top-up refunds:
- Ledger idempotency keyed on the gateway refund id, not the payment — a
  second partial refund used to be swallowed as a replay while real cash left.
- Clawback is now a conditional decrement instead of read-modify-write that
  silently relied on the nonnegative CHECK to abort losers.

Invoice refunds:
- Balanced INVOICE_REFUND journal (Dr ORG_RECEIVABLE / Cr CASH-or-WALLET)
  mirroring invoicepaid:<id>; the wallet credit is now the cache mirror of
  its journal leg written only after it commits — kills the guaranteed
  WALLET_BALANCE_DRIFT the bare walletCredit caused (#1128).
- Per-refund idempotency via the credit-note unique; partial refunds keep
  the invoice PAID and each book their own credit note/journal instead of
  the first refund flipping REFUNDED and swallowing the rest.

* fix(webhooks): sweeper claim CAS, Stripe 400 on invalid payloads, all-refund processing, SDK timeouts

- sweep-stuck-webhook-events claims each row (receivedAt CAS) before
  re-driving so two drivers can never double-run the same event.
- Stripe route: signed-but-malformed payloads now 400 instead of 500 —
  a 500 burned Stripe's full retry schedule on an unprocessable event and
  risked endpoint disablement. charge.refunded now drives every refund in
  the charge's refunds array (idempotent per refund id) instead of only
  data[0], which silently skipped the older refund under out-of-order
  delivery.
- razorpay-node has no timeout option; every SDK call is now bounded at
  30s so a hung gateway connection can't outlive the sweeper's staleness
  window and invite a concurrent re-drive.

* fix(wallets): UNFREEZE writer, org-keyed booking WALLET leg, safe display sums

- unfreezeWalletSpend + admin route (POST /api/admin/billing-accounts/
  [id]/unfreeze): the #837 freeze kill-switch had no release path anywhere —
  a frozen wallet was unrecoverable except by hand-inserted SQL.
- Booking journal WALLET debit now falls back to the billing account's
  owner org when payment.organizationId is null (#835 mirror): the refund
  path got this fix, the booking posting didn't, so org-wallet-funded B2C
  bookings debited the null-org sub-ledger while the cache decrement hit
  the org's account — guaranteed drift at reconcile.
- signedDeltaPaise asserts the safe-integer range instead of silently
  losing precision on unguarded BigInt sums.

* fix(payouts): BATCHED flip + repair SQL, Stripe transfer idempotency, CAS approve/reject

- The weekly GH-Action batch creator now flips linked earnings to BATCHED
  (parity with the canonical #993 service). Left READY with payoutId set,
  the canonical handlers — all filtering on BATCHED — never saw them:
  never PAID on completion, permanently orphaned on pre-gateway failure,
  treated as un-paid by refundEarnings, and FY gross undercounted for TDS
  thresholds. One-off repair SQL reclassifies the historical rows
  (COMPLETED→PAID, FAILED/CANCELLED/REVERSED→released, in-flight→BATCHED).
- Stripe transfers now carry the payout row's unique idempotencyKey — the
  same deterministic key RazorpayX gets. A timeout after Stripe accepted
  the transfer used to surface as a generic failure and re-batch under a
  fresh key = second transfer (RazorpayX's own docs name this exact
  duplication pattern; Stripe idempotency doctrine requires the key).
- approvePayout/rejectPayout claim PENDING via updateMany CAS; reject
  claims the payout in the same tx as the earnings release. The old
  check-then-act let approve∥reject interleave into an APPROVED payout
  whose earnings were already released — cron pays it, freed earnings
  re-batch: double pay. Admin route maps the CAS state error to 409.
- Org payout PATCH route: CAS on the validated from-state so concurrent
  transitions fail closed instead of last-writer-wins.

* fix(payouts): webhook terminal guard, quarantine TDS persistence, stuck-FAIL earnings release

- handlePayoutWebhook: terminal incoming statuses (COMPLETED/FAILED/
  CANCELLED) can no longer overwrite REVERSED (a late payout.processed
  after a bank reversal re-ran the TDS delete/recreate), and non-terminal
  statuses (queued/pending → PENDING) now apply only to PROCESSING rows —
  a late queued event used to flip FAILED back to PENDING after the
  earnings had already been released.
- The gateway-accepted quarantine write persists the TDS outcome
  (tdsDeducted/netAmount/rate/FY): the eventual COMPLETED webhook books its
  ledger legs from these fields, and zeros overstated CASH by the withheld
  amount while TDS_PAYABLE was never credited.
- The stuck-payout handler's permanent-FAIL branch now releases BATCHED
  earnings like the webhook FAILED path — they used to stay welded to a
  FAILED payout forever.

* fix(payouts): lock TTLs outlive job budgets; org-rail PROCESSING re-drive

- Payout processing lock 5min → 35min and batch-creation lock 2min → 15min:
  both were shorter than their own workflow budgets (30/15 min), so a slow
  batch let the lock expire mid-run and a second trigger enter concurrently.
  Per-payout CAS + idempotency keys kept money safe; this restores what the
  locks exist for — no duplicate gateway fan-out, no racing balance preflight.
- processPendingOrgPayouts now re-drives PROCESSING rows older than 1h —
  the actor the transient-error contract was missing. Rows without a
  gatewayPayoutId resubmit under the same deterministic idempotency key
  (RazorpayX returns the original payout if the first attempt landed);
  rows with one poll the gateway once and route through the mark* handlers,
  covering lost webhooks where the money arrived but the row sat in
  PROCESSING forever.

* fix(cron): breaker-open pages instead of skipping; explicit budgets on money txs; guarded notifies

- withCronLock: acquireLock returns null for both 'held' and 'circuit open',
  and the pre-acquire health check bypasses the breaker — so a breaker that
  tripped in between was misreported as a clean CronLockHeldError skip:
  exit 0, no page, fail-closed money job silently frozen for the reset
  window. An open breaker now throws CronLockUnavailableError (pages).
- Every default-budget Serializable money tx now sets maxWait 10s /
  timeout 15s: Prisma's 2s/5s defaults aborted mid-mutation under measured
  5-9.6s pooler connect hangs. 18 sites across refunds, disputes, capture,
  dunning, overages, invoice rollup, earnings release.
- Floating void notify* calls in webhook paths carry .catch(() => {}) so a
  synchronous throw can't become an unhandled rejection (Node crash) and
  notifications can't describe state that never committed.

* fix(schema+scope): money gate on user delete, hot-path indexes, statutory CHECKs, org-scope hardening

- DELETE /api/user/[id] gates on money history (payments, referral credits):
  a hard delete cascaded Payment → legs/utilization away and 500s on the
  first Restrict. Money-moved users get the DPDP §12 scrubUser pipeline
  instead — PII pseudonymised, financial rows retained per IT Act.
- Indexes: UsageLedgerEntry(paymentId) — reverseBookingUtilization seq-scans
  an append-only table inside the refund tx; Refund(createdAt),
  Dispute(createdAt) for finance exports and cron age scans.
- Sidecar CHECKs for the tables that had none: OrganizationInvoice /
  CreditNote amounts + tax-coherence, InvoiceLineItem, WalletTopUp > 0,
  ConsultantEarnings.shareBps ∈ [0,10000], LicensedSeatConfig pricing ranges,
  Contract.paymentTermsDays ≥ 0. (Validate violation-free on live data
  before npm run db:constraints.)
- Org scoping: reconcile-ledgers route gated to ADMIN-only via
  requireBackofficeSurface('payouts.read') (was requirePrivilegedAuth,
  which admits STAFF to cross-org ledger aggregates); wallet top-up
  idempotency reuse now ownership-checked (cross-org key probe/pre-claim →
  409); rate-card membership override resolved through an org/contract-
  scoped predicate so a dangling or cross-tenant id falls through instead
  of silently settling bookings on another org's split.
- RazorpayPayoutStatus gains 'failed' (the entity returns it; poller switch
  couldn't compile against the incomplete union).

* fix(webhooks): notify fire-and-forget safe against non-promise mocks

Promise.resolve() wraps the void notify* calls so the .catch guard works
whether the Novu wrapper returns a promise (production) or undefined
(test doubles) — a bare .catch on undefined throws synchronously inside
the Serializable tx.

* test(money): pin the fixed invariants — status guard, approval CAS, reservation matcher

- refund-webhook-status-guard: a stale refund.created cannot downgrade a
  SUCCEEDED refund, refund.processed cannot resurrect FAILED, PENDING →
  processed settles + cascades exactly once (the old enum-vs-raw compare
  never short-circuited).
- payout-approval-cas: approve claims PENDING atomically and surfaces the
  current state on a lost claim; reject claims inside the earnings-release
  tx so approve∥reject can never mint an APPROVED payout with no backing
  earnings.
- reconcile-reservation-match: exact bind on notes.reservationId; FAIL only
  after 24h with a succeeded listing and no match; ambiguous candidates
  page instead of guessing; real-id PENDING polled to settlement and never
  aged out locally. Caught a real ordering bug in the ambiguity guard while
  being written.

* test: fix arithmetic on unknown in reconcile test helper
P1 (audit #1092 Gap 1): db:push:schema now chains db:sidecars — the bare
schema push that silently dropped every money CHECK/EXCLUDE constraint and
the ledger trigger is no longer reachable via npm scripts. A dangerous
escape hatch is preserved as db:push:no-sidecars-DANGEROUS.

P2 (audit B-P1-07): trial cancellation routes through refundBookingPayment
(the booking front door) instead of raw refundPayment. Org-funded and free_
paid trials now hit the correct rail (in-ledger reversal / credit restore)
instead of throwing UNKNOWN_GATEWAY on the raw gateway path.

Test: trial-cancellation-refund.test.ts updated to mock booking-refund
instead of refund; duplicate prisma jest.mock removed.
…s quote frozen amounts (#1181, #1182) (#1217)

* fix(payments): approval payments carry their appointment; duplicate guard goes live and reuses the pending link (#1181)

createApprovalPaymentIntent was invoked without appointmentId for
consultations and subscriptions even though both already own an
appointment at mint time — consultations create it when the request is
submitted, subscriptions carry the placeholder direct checkout made for
exactly this linkage. The Payment row therefore shipped with
appointmentId null, and four things were inert or dangerous because of
it:

checkExistingPayment walks appointment.payment, so it never matched an
approval payment — every retry minted a parallel gateway order, with the
distributed lock as the only line of defense. checkConsultationPayment
and the PaidWithoutAppointmentError 409 shipped by #1172 were unreachable
the same way. The capture webhook branches on payment.appointmentId, so
an approval capture fell into the legacy-create path and built a TWIN
Appointment for a one-to-one Consultation — colliding on the unique or
stranding the request-time appointment with its tentative slots. And a
mint that failed after the gateway call left a PENDING payment no retry
could find, the #1172 deadlock shape.

Both approval routes now thread the request-time appointment through
CreateApprovalPaymentParams (subscriptions pick the first under the
route's deterministic createdAt/id order), mirroring how direct checkout
anchors its Payments. The guard became findExistingLivePayment and grew
a reuse path: a PENDING payment from a prior attempt is handed back
as-is — Razorpay's client_secret IS the stored intent, so the original
pay-link reconstructs without a second gateway order — while SUCCEEDED
still refuses and EXPIRED falls through to a fresh mint. Capture now
confirms the request-time appointment instead of fabricating one.

The lifecycle doc's claim that approval payments are invisible to the
guard was true when written and false after this change; it now states
the reuse behavior instead.

Closes #1181. Part of #1169.

* fix(dashboard): pending-payments quotes the frozen Payment.amount, not the mutable plan price (#1182)

The consultee pending-payments surface read the plan's CURRENT price for
approval-pending consultations and subscriptions (and the plan's current
trialPriceInPaise for trials), while the pay-link it links to charges the
amount createApprovalPaymentIntent froze onto the Payment row at mint
time. A consultant who repriced after accepting turned the widget into a
number the gateway would not honour.

Same rule #1180 landed on the trial checkout page: quote the frozen
Payment first — earliest live payment on the appointment, or the trial's
own direct Payment — and fall back to the plan only before any payment
exists, where the plan price genuinely is the quote. The subscription arm
now orders its take-1 appointment pick like the mint does (#1181) so the
frozen charge comes off the appointment the pay-link actually anchored to.

Closes #1182. Part of #1169's residuals.

* fix(approval-payments): live-status filter on trial reuse; operative frozen quote; personal-appointment pin

CodeRabbit triage (all three Major findings verified against source):
- findExistingLivePayment's trial arm returned TrialSession.payment
  unfiltered — an EXPIRED order would have been handed back as a reusable
  checkout link instead of minting fresh. Now filtered to SUCCEEDED|PENDING
  like the other arms (+2 tests).
- pending-payments quotes the NEWEST non-deleted payment per appointment:
  a re-mint freezes the current quote onto a newer row, so newest is the
  operative charge an expired-order retry would resume.
- subscription arm pins its take:1 appointment pick to organizationId null,
  so a mixed subscription can't leak an org-funded frozen amount onto the
  personal dashboard.
…ot just the credits (#1161) (#1218)

The free_ rail restored referral credits but stopped there: no
BookingUtilization release and no counter-posting for the booking-time
journal that debited PLATFORM_PROMO against the consultant/org payables,
GST and the platform fee — leaving ConsultantEarnings payable on a
cancelled booking and a stranded promo debit reconcile could not repair.
applyRefundCascade cannot serve these payments (its proportional math
divides by Payment.amount === 0), so refundFreeCreditPayment now runs the
ratio-1 settlement itself inside its Serializable tx: utilization
released (#1003 convention, a no-op for personal bookings), earnings
netted to REFUNDED with TDS reversal and COMPLETED-payout clawback
mirroring cascade Steps 6-7, and the funding returned via a balanced
REFUND txn — Cr PLATFORM_PROMO per the original REFERRAL_CREDIT legs (Cr
DISCOUNT on legacy rows without legs), Dr payables/GST, PLATFORM_FEE
absorbing the residual. No earnings rows → no journal ever ran → nothing
posted.

rejection-refund stops filtering credit-funded payments out on its
refundable clamp: amount 0 used to skip the front door entirely, so a
consultant rejecting such a booking restored no credits. The credits
rail takes no partial amount and routes around the clamp instead.
…ron failures page (#677 PM-10, PM-34) (#1219)

* fix(payments): fail fast on Razorpay test keys in a production posture (#677, PM-10)

A rzp_test_ key in production booted cleanly and failed only at the
first customer — charges declined, refunds dead-ended, payouts vanished
into test mode while rows read gateway-authoritative.

- core checkout/refund client: NODE_ENV=production + rzp_test_ throws
  PaymentError RAZORPAY_TEST_KEY_IN_PRODUCTION at init, naming
  RAZORPAY_KEY_ID and the live-key remediation. Dev/preview/test keep
  legitimate test keys.
- RazorpayX client: guard keyed to ENABLE_LIVE_PAYOUTS (the flag both
  payout rails hold submissions behind), not NODE_ENV — with the
  disbursement freeze on, no money moves via X even in prod, so sandbox
  keys stay legal until go-live; the day the flag flips, a test key
  throws RAZORPAYX_TEST_KEYS_IN_LIVE_MODE naming RAZORPAYX_KEY_ID (and
  its RAZORPAY_KEY_ID fallback).
- isRazorpayPayoutsConfigured rethrows that guard error instead of
  reading as 'not configured', so balance preflight fails closed.
- unit tests pin throw/init on both clients across postures.

* fix(cron): refund-earning cascade surfaces a false result as failure (#677, PM-34)

result.success === false (some SUCCEEDED refunds failed their earnings
cascade) used to exit green at both entries, so the cron monitor never
paged while money state stayed inconsistent.

- HTTP shim now returns 500 on a failed run (was unconditional 200),
  mirroring the other money crons' health-check contract.
- GH Actions entry already set process.exitCode = 1; it now routes the
  decision through the shared cascadeRunFailed helper (leaf module, so
  the failure signal is unit-testable without prisma/Redis imports).
- tests pin the helper and the wiring at both consumers.

* fix(payments): exempt next build from the test-key guards

CI and Netlify build environments run NODE_ENV=production with legitimate
test keys, and this module loads during page-data collection — the
unconditional throw broke every deploy preview plus the CI build job.
NEXT_PHASE=phase-production-build now exempts both guards (core client +
RazorpayX factory); the first real runtime boot in production still fails
loudly. Two new tests pin the exemption.
…TL 300s→600s (#1220)

* fix(money): adopt an open PENDING order across checkout remounts instead of minting parallel Razorpay orders

Rec C (bugs/finances/checkout-webhooks-idempotency.md Q2). A remount or new
tab mints a fresh clientIdempotencyKey, so the #828 same-key replay sees
nothing and each mount minted a parallel order for the same user+plan.
handleCheckout now looks for an open PENDING payment scoped to user+plan
(+org, null-safe), still inside its expiresAt window, on the same routed
gateway, and resumes THAT order. Race-safe under the existing checkout locks
(same user+plan always maps to identical lock keys); same-key duplicates stay
on the clientIdempotencyKey unique + P2002 replay.

* fix(money): raise CLASS checkout lock TTL to the 600s serverless-freeze worst case

bugs/finances/high-concurrency-and-spikes.md documents that a serverless
freeze can suspend the holder after the single checked renewal while Redis
keeps counting down, so 300s could lose lock ownership mid-checkout and admit
a second instance. 600s consolidates the old end-to-end envelope (initial
window + one renewal) into a single grant; contention losers still fail fast
via CHECKOUT_WAIT_RETRY_CONFIG and the Serializable recount + #440 GiST
constraint remain the backstops. Contract test pins the values.
…d path instructions, security analyzers (#1223)

Pro-tier tuning for maximum review depth: assertive profile, incremental
re-review on every push without auto-pause, path instructions encoding the
money-path invariants (paise math, CAS transitions, idempotency, webhook
signature order), and the security/correctness analyzer set (semgrep,
gitleaks/trufflehog secrets, presidio PII, squawk SQL migrations,
prismaLint, zizmor for the Actions cron fleet).
…ts (#1222)

* fix(stream): ledger chat-freeze state, pace UpdateChannelPartial bursts

The daily expire-event-channels cron re-issued updatePartial({frozen:true})
for every webinar/class channel in the 7-90d age band on every run. Each
no-op still spent one UpdateChannelPartial call; on 2026-08-23 05:04 UTC the
run attempted 308 freezes in ~11s (54 ok + 254 errors), breached Stream's
app-wide 300/min cap for the endpoint, and the 429s tripped the circuit
breaker, which then fast-failed the unrelated deleteChannels stage too
(deleted: 0). The same failure repeated daily since Aug 19.

- Add Webinar.chatFrozenAt / Class.chatFrozenAt as a freeze ledger (nullable,
  stamped only after the Stream call succeeds); already-frozen channels are
  filtered out before any API call. Migration applied to Supabase.
- Pace the freeze loop (~140/min via chunk sleep, STREAM_FREEZE_PACING_MS)
  and cap freezes per run at 600 so the workflow timeout is never at risk.
- Classify HTTP 429 as quota, not outage: rate-limit errors neither trip the
  circuit breaker nor page Sentry, so a burst can no longer starve other
  stages sharing the breaker.
- Chunk maintenance drain freeze/unfreeze with STREAM_CONCURRENCY_LIMIT
  instead of firing up to MAX_DRAIN_BATCH updatePartials simultaneously.

* fix(stream): pace maintenance drain + clamp freeze pacing floor; record one-off DDL

CodeRabbit review on #1222:

- Concurrency chunking alone is not rate control (10 parallel calls answered
  in ~100ms is ~6000 req/min). Add STREAM_BATCH_PAUSE_MS pauses between
  drain-sessions freeze/unfreeze batches and clamp STREAM_FREEZE_PACING_MS up
  to that floor in the expire cron, so each UpdateChannelPartial consumer
  stays under half of Stream's app-wide 300/min cap and two independent
  consumers cannot jointly breach it.
- Commit prisma/sql/one-off/2026-08-23-add-chat-frozen-at.sql as the durable,
  in-repo record of the chatFrozenAt DDL already applied to production.

Part of #1222
…prorate partial disputes (#1020) (#1225)

Findings 1-3 from the #1020 host go-live hardening register, applied to
consultant AND org earnings alike:

1. preDisputeStatus column (additive, nullable) — the dispute hold now
   records whether each row came from PENDING or READY (two CAS groups, so
   a second dispute can never clobber a first hold's marker), and WON/
   WARNING_CLOSED/CLOSED releases restore the true prior state instead of
   force-maturing PENDING rows. Legacy null-prior rows keep the historical
   READY release.

2. The LOST/CHARGE_REFUNDED loops queried status:HELD only, so earnings
   already PAID out before a late chargeback were untouched on both rails.
   Both loops now include PAID rows. Org PAID shares flow through the
   existing reversal-engine PAYOUT_CLAWBACK; the consultant rail has no
   auto-clawback (documented R-06/E-05 manual posture), so it flips state
   truthfully, reverses TDS, and pages ops ONCE per dispute with the total
   to recover by hand.

3. A PARTIAL dispute used to reverse the FULL share on both sides; every
   reversal is now floored to share x (dispute/payment), capped at the
   remaining refundable. Full-amount disputes are unchanged.

9 regression tests pin the behaviors (hold tagging + clobber-guard,
faithful release incl. legacy nulls, half-dispute proration, cumulative-
cap interaction, paid-clawback paging once with totals, prorated org
reversal).
…(CR triage) (#1227)

CodeRabbit's assertive review flagged the manual-recovery page firing
inside the Serializable tx: an SSI abort would page ops with a reversal
total that was never persisted, and the gateway redelivery would
double-page. The page is now staged in-tx and dispatched post-commit;
a new test pins that an aborted transaction never pages.

Other triage verdicts: BigInt-arithmetic finding is covered by the repo's
Prisma result extension (numbers at the JS boundary — same as the
adjacent TCS code); missing migration SQL matches the db-push convention
(no migrations dir); BATCHED-earnings exclusion from LOST reversal is a
real residual, deliberately deferred with the thread left open.
…cket draft (#1221)

* perf(payments): lazy-init razorpay/stripe SDK clients (#1124)

Module-scope instantiation put both SDK constructors on the cold boot of
every route whose import graph reaches the payments core. Defer to first
use via getRazorpayClient/getStripeClient — the same lazy-singleton
pattern lib/email.ts already uses for Resend. Semantics preserved: a
missing credentials env yields a permanent null either way, and all
call sites already null-checked before use.

Solo-cold instance boots complete in ~1.9s end-to-end (SKILL.md), so the
expected win here is bounded; this removes avoidable work from that
budget rather than touching the ~24s concurrent-instance stall, which
measurement has isolated as platform-side.

* docs(perf): record the memory A/B as assessed-and-reverted; add Netlify ticket draft

SKILL.md gains the honest 2026-08-22 record: 2048 MB measured no
better (11/12 slow, 35.9-37.6s + one 500), reverted in 08b10ce; the
CPU-share hypothesis weakened; the method traps that produced the false
'resolved' claim (inert v1 function names, deploy-to-commit mapping,
cross-preview confounds) written down so they are not relearned.

docs/perf/netlify-stall-ticket-draft.md is the evidence-backed support
ticket to file from the Pro account.

* docs(skill): close the warm-then-burst experiment — width alone forces the stall

2026-08-23 on preview-1148: idle 6-burst 6/6 slow; sustained sequential
kept one instance warm with ISR absorbing repeats at ~0.24s; immediate
12-burst still stalled 4/12 + one 504. Same day, the lazy-init build
(preview-1221) reproduced 12/12 slow after >=30min idle — second
independent confirmation (with the CPU-doubling null) that the stall is
platform-side, not application init work.

* docs: guard against re-introducing the reverted memory bump

netlify.toml gains the full #1124 record at the point someone would
edit: measured-dead verdict with both null results (CPU doubling AND
lazy-init), the v1-name silent-ignore gotcha, and what actually works.
The lazy-init comments in razorpay.ts/stripe.ts now state their own
measured outcome so neither gets cited as stall mitigation later.

* fix(payments): address CodeRabbit round on the lazy-init change

- Scope getStripeClient/getRazorpayClient to the branches that use them
  (signature verification, gateway-matched dispute lookup, refund-family
  webhook events) so a Razorpay request never constructs a Stripe client
  and vice versa.
- Cache missing-credential results via an undefined sentinel, restoring
  the original once-only init/warn semantics of module scope.
- Ticket draft: add deploy provenance to the memory A/B (deploy ids,
  commit_ref, UTC times, searchSiteFunctions m values) plus the
  2026-08-23 preview-1221 replication.

* fix(payments): keep PM-10 fail-fast guard at module load alongside lazy client

#1205's test-key guard lived inside initializeRazorpayClient, so #1221's
lazy construction deferred it past require time — the guard no longer
fired 'at boot' and razorpay-test-key-guard.test.ts lost its throw (and
two assertions went vacuous on the removed razorpayClient export).

Reconciled: the guard now runs AT MODULE LOAD as an env-only check
(microseconds — no SDK work), preserving PM-10's die-at-boot contract;
Razorpay SDK construction stays lazy behind getRazorpayClient(). Test
updated to assert require-time throw + getter-based construction, with
a header note explaining the split.

* test(payments): serve lazy getters in dispute-earnings-hardening mocks

#1225's suite mocked the pre-#1221 export shape; utils.ts now consumes
getStripeClient/getRazorpayClient. Both shapes provided, mirroring the
dispute-refund-correctness fix.
@netlify

netlify Bot commented Aug 23, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit 8c0b1f1
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a8ae06bacb7920008298d09
😎 Deploy Preview https://deploy-preview-1228--familiarise.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 41 (🔴 down 13 from production)
Accessibility: 90 (no change from production)
Best Practices: 83 (no change from production)
SEO: 82 (no change from production)
PWA: -
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1927308-45b1-4ce5-8383-b00cb413faaf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

@teetangh
teetangh merged commit c80dc1e into prod Aug 23, 2026
67 of 87 checks passed
@teetangh
teetangh deleted the release/dev-to-prod-2026-08-23 branch August 26, 2026 07:06
@teetangh teetangh mentioned this pull request Sep 1, 2026
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant