feat(money): #778 — finance correctness: rounding policy, #773 journal, TDS flag, chargeback parity, overage audit (PR-C)#824
Conversation
…erce at the boundary (#781 §A) Payment/Refund/Dispute/ConsultantEarnings/ConsultantPayout/DiscountCode/ ReferralCredit.currency and the four plan priceCurrency columns move from free String to the Currency enum (@default(INR)); WalletTopUp gains an explicit currency; paRouteProvider becomes the PayoutRailProvider enum. A typo'd currency on a money row is now unrepresentable. Gateways still speak free-form ISO codes (incl. display currencies), so the gateway-facing payment-core types stay string and every row-write passes through toCurrencyEnum() — throws (dead-letters, not 500-loops) on a code we can't book. Plan create/update Zod schemas validate the enum at the request edge (400, not 500). The shipped LEDGER_ACCOUNT_NON_INR reconcile invariant remains the runtime backstop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
§B — money history is delete-proof: - onDelete Cascade→Restrict on the 9 financial-history FKs (Organization/Consultant Earnings+Payouts, Refund, Dispute, TDSRecord); the two SetNulls that survive are documented in-schema (abandoned side-charge sweep; invoice re-rollup link). - deletedAt on Organization/Payment/ConsultantProfile. Org DELETE becomes three-way: live obligations 409 (wind-down doctrine), settled history soft-deletes (DEACTIVATED + deletedAt + contact-PII scrub — name/GSTIN/ PAN stay for the invoice trail; status tuples already gate every surface), money-untouched shells still hard-delete. Consultant DELETE gets the same split (earnings/payouts/TDS ⇒ soft-delete, slots removed so nothing is bookable). DPDP erasure now stamps deletedAt — statutory retention beats erasure under the legal-obligation exemption. - deleteExpiredPayments documented as deliberately-safe (PENDING rows predate any Restrict FK). §C — rates can no longer disagree on units: - tdsRate/tdsRateApplied/TDSRecord.tdsRate Float→integer bps. This kills a real pre-existing bug: the 194-O engine stored fractions (0.001) while the legacy engine stored percent (10) in the same column type. - FX snapshots Float→Decimal(18,6), converted to number at the client boundary (dn()) so Prisma.Decimal never crosses the RSC boundary. - 26Q export emits tdsRatePercent derived from bps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#781 §B) deletedAt: null scoping on the browse/detail/booking reads: explore metadata+curated+reviews, home rails, public consultant list route, plan list/detail surfaces (nullable consultantProfile handled via OR-null so ownerless plans stay listed), and checkout's four calculateAmountAndValidate branches reject a soft-deleted owner exactly like plan-not-found. Org surfaces need no scoping — DEACTIVATED status tuples already gate them. Money/admin/self reads deliberately keep seeing soft-deleted rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… decrement-in-place (Closes #786) Funding legs are now append-only, matching the journal's discipline: a refund on an unbilled accrual appends a negative INVOICE_ACCRUAL_REVERSAL / OVERAGE_INVOICE_ACCRUAL_REVERSAL sibling instead of mutating the original. One reversal per source keeps @@unique([paymentId, source]) intact (the P2002 #786 reported); subsequent partials net into the existing reversal leg. Side benefits over decrement-in-place: each partial refund's proportional split (and each Sec 34 credit note) now computes against the immutable original split, killing the attribution drift the remainder-absorption was papering over; and the funding history stays readable from the legs. Readers updated: invoice rollup sums original+reversal so it bills the net (fully-refunded-before-billing bookings are stamped but excluded from the issued document); checkPaymentLegsSumToAmount becomes pair-aware — originals must still sum to Payment.amount exactly, and every reversal must be negative and never exceed its sibling. The enum addition lands inside the schema freeze — post-freeze it would have been a migration. Regression tests pin append-once-then-net. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…781 §D, #784, #753) §D residue (schema lands in the freeze; automations stay deferred): - CreditNote: IRN/e-invoice cluster mirroring OrganizationInvoice (CRN/DBN must reach the IRP for in-scope taxpayers; ≥₹10cr AATO adds a 30-day window), docType enum, and disputeId @unique so the #738-B chargeback credit-note path (PR-C) mints idempotently. - TdsRate lookup table, law-aware: the Income-tax Act 2025 renumbered TDS sections (194-O → §393 Sl.8(v)), re-keyed challans to payment codes 1001–1092, and renamed forms 26Q/27Q→140/144 from 2026-04-01 — a (lawCode, section, effectiveFrom)-keyed table is the only shape that survives that; rows are append-only. - ConsultantProfile: Sec-197 certificate validity window + the cert's own rateBps (the engine stops re-purposing the consultant default). - DataBreach: principal-notification leg — DPDP's 72-hour duty is dual (Board AND affected principals); reportedAt alone proved one leg. - ProgramAssignment overlap guard (app-level; exclusion constraints aren't Prisma-expressible) + ASSIGNMENT_PERIOD_OVERLAP reconcile detector + regression test. §D indexes: [organizationId, status, createdAt] on earnings; [programAssignmentId, chargeStatus, createdAt] on OverageEvent; the covered single/two-field subsets dropped. Trims (user decision 2026-06-10): PROJECT/RETAINER/RESELLER marked most-likely-never in every reserving comment (the ProgramType enum never carried them — the comments claimed otherwise); dead minimumCreditsPerPeriod dropped (#753's money-meter half shipped as consumedPaise; nothing ever read the commitment-minimum); the TaxJurisdiction enum stays documented-reserved (#769). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cisions Payment-legs and refunds docs describe the #786 reversal-sibling model (append-only funding legs, pair-aware sum invariant); the money overview records the Currency-enum row rule with the toCurrencyEnum gateway boundary; the compliance band marks the DPDP breach gap as schema-closed (principal-notification leg now on DataBreach, cron still Board-only) and documents the bps + law-aware TdsRate readiness for the Form 140/144 generator, with CBDT payment codes deliberately unhardcoded pending the final notification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lass 1:1 wrappers (Consultation/Subscription) carry request/feedback/ cancellation because the wrapper IS the relationship; group events are 1:N and delegate per-attendee state to Appointment. Asymmetry is intentional — the comment exists so it stops resurfacing as a gap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#784) The two models were field-identical except the plan FK and role enum. Merged: collaboratorType discriminator, nullable webinarPlanId/classPlanId (XOR app-enforced via assertCollaboratorPlanXor — Postgres CHECKs aren't Prisma-expressible), unified CollaboratorRole (union of both enums; the per-type role subsets now live in the zod invite schemas, turning the old Prisma-runtime-500 on an invalid role into a 400), per-plan-type @@unique pairs (NULLs are distinct, so each bites only its own type). service.ts collapses every webinar/class branch through planScope/ planWhere; public signatures unchanged; revenue-split math untouched. Pre-MVP is the cheap time for this — post-freeze it would have been a data-shuffling migration. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l to a named party (#778 §C) splitByBps/prorate in lib/payments/utils/money.ts encode THE policy: floor every share; the leftover paise go to exactly one designated party (OWNER within a collaborator pool, PLATFORM_FEE across funding/tax splits) so splits always sum to the total. calculateRevenueSplit's Math.round-per-collaborator — which could overshoot the pool and push the owner's remainder NEGATIVE (#778 §C-2) — becomes floor + a Σbps > 10000 refusal. GST's headline levy keeps nearest-rounding by documented exemption (flooring a LEVY under-collects tax; the CGST/SGST halves already floor+remainder). Adds the §C-1 regression the #812 fix never got: a negative platform plug (earnings allocated off originalAmount exceeding the post-credit funding) must POST a balanced txn with a PLATFORM_FEE credit — the pre-#812 silent skip was unrepairable EARNINGS_LEDGER_DRIFT. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…xn (Closes #773) The last partially-journaled money flow closes. createEarningsFromPayment resolves every collaborator's HOST-org settlement up front, then posts a single balanced booking:<paymentId> txn: funding debits by leg source (#786 reversal sources explicitly filtered — they can't exist at booking), Dr DISCOUNT for the platform-funded gap, then Cr PLATFORM_FEE (primary fee + settled collaborators' fee slices), Cr CONSULTANT_PAYABLE per party — a settled collaborator's earnings row now stores the share NET of the host-org cut, so the cache equals the journal credit exactly — Cr ORG_PAYABLE per host org, Cr GST_PAYABLE. A posting failure rolls the earnings creation back (#812 discipline). The same-org collision P2002 catch became unsafe under in-txn posting and is replaced by deterministic upfront detection (v1 outcome preserved). The hourly sync-payment-earnings repair script now DELEGATES to createEarningsFromPayment instead of minting its own full-share collaborator rows with no journal — every multi-party payment it synced was born as ledger drift. Seeds post the booking journal per earnings row (atomic with the row), so a fresh seed reconciles clean; seed fee math switched round→floor so the seeded journal balances to the paise. Part of #778 §C-2/§G. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t artifacts (#778 §E, #738 A/B) TDS_ENGINE env flag (default LEGACY): LEGACY keeps the conservative ₹50K-gated 194-O hybrid that ships today; 194O drops the gate for pure Section 194-O semantics — one env flip at launch once the CA confirms in writing. The 194J percent calculator is marked deprecated; the engine of record stays lib/compliance/tds.ts. Every TDS reversal now ALSO emits a TdsAdjustment row — the filing artifact the Form 140/144 export will read — alongside the negative TDSRecord that remains the YTD/dedup source (#778 §D, closes the 'TdsAdjustment unwired' gap). Statutory TdsRate rows seeded law-aware: IT1961 194-O/194J through 2026-03-31, IT2025 §393 Sl.8(v) from 2026-04-01 with paymentCode deliberately null pending the CBDT notification. Chargeback parity (#738-B): the dispute LOST/CHARGE_REFUNDED branch now reverses withholding for paid-out earnings via the shared recordTdsReversal (its cap prevents double-reversal after a prior app refund), mints the Sec 34 credit note idempotently on CreditNote.disputeId (mintRefundCreditNote generalized to either trigger), and emits GstTcsAdjustment when TCS collection stamped the payment. Refund parity (#738-A): the cascade emits the same TCS adjustment — inert until Sec 52 collection ships. Also fixed from the overage audit: refunding a CHARGE_MEMBER side-payment now debits ORG_PAYABLE (pulling back the org's relief credit) instead of billing the platform via the plug. X-Payout-Idempotency verified already-sent with deterministic keys (mandatory at RazorpayX since 2025-03-15) — no change needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, coverage-overlap 409 (#775 #782 #751) Full config→compute→breaker→charge→settle→refund→sweep trace (verdicts in docs + the PR description). Real defects fixed: - Capture-vs-reversal race: the CHARGE_MEMBER webhook posted the ORG_PAYABLE credit BEFORE the CHARGED transition — a booking refunded after order-mint left the org holding the member's money. The transition now gates the posting; a 0-row transition records an OVERAGE system error ('refund the side-payment') instead of crediting the org. - OverageEvent.currency hardcoded INR while the side-Payment carried the booking currency — events now mirror it (and the timeout notification stops lying). - Config guards: CHARGE_* without a positive circuit-breaker ceiling is unbounded liability — rejected at create AND at piecemeal PATCH (merged current+patch state re-checked); dead knobs (surcharge with BLOCK, any overage knob with unlimited coverage) rejected; NaN inputs normalized in the calculator. - #751: creating a program whose coveredPlanTypes intersect another ACTIVE program on the same contract is now a 409 (PROGRAM_COVERAGE_OVERLAP, names the conflicts) unless forceOverlap:true — overlapping coverage made checkout's program resolution ambiguous. - Sweep write-offs now stamp chargeFailureReason for auditability. The locked settlement decision is documented at the handler: a member side-payment's ORG_PAYABLE credit is realized at org payout — no invoice-netting, no wallet credit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hreshold zero, poller honesty (Closes #750; #778 §G) - SPLIT_SUM_MISMATCH: per earnings-bearing payment, Σ platform fee + consultant shares + org shares must equal Payment.originalAmount to the paise — proves the floor+residual policy end-to-end under the #773 netting model. - OVERAGE_SETTLEMENT_MISMATCH: a CHARGED member overage must have a SUCCEEDED side-payment, a matching overage:<sidePaymentId> txn whose amounts agree, and settledAt; PENDING/FAILED events must have no txn. The capture-raced-reversal case self-reports via system error and is deliberately not double-flagged. - earningsPaymentsWithoutBookingTxn promoted from info metric to a finding over RECONCILE_UNJOURNALED_MAX (default 0 — the platform must never silently run partially journaled again; pre-#773 seeded DBs trip it until reseeded, by design). - #750 residue: resolveActiveAssignment orders by periodEnd desc (boundary instants matched two cycles arbitrarily); the TDS-reversal original lookup and the dunning-block probe get deterministic orders. - Payout poller: pre-completion gateway 'reversed' stays FAILED — that is correct accounting (no PAYOUT txn was ever posted; webhooks own the post-COMPLETED reversal path) — but the distinction is now recorded in failureReason instead of silently collapsed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every money workflow's 'Notify on failure' stub (15 of them) now calls the shared scripts/ci/notify-ops-failure.sh — a Slack webhook post with the run URL, env-gated on SLACK_OPS_WEBHOOK_URL and a visible ::warning no-op when the secret isn't provisioned (forks/previews don't fail). The full #709 schedule-stagger audit stays in #709. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rrectness landing booking-to-earnings drops the multi-collaborator deferral (#773 closed; posting shape + the netting model + the standing ORG_PAYABLE overage-relief credit documented); the programs band gains the SUBSCRIPTION lazy-debit silent-under-charge gap found by the overage audit (needs a #715-style price-basis decision); the compliance band and docs/compliance/05 flip the refund/chargeback adjustment matrix to wired (TdsAdjustment + GstTcsAdjustment + dispute parity), record the TDS_ENGINE flag, and resolve the stale 16-char Rule 46(b) divergence (#807 enforced via GST_DOC_NUMBER_MAX_LEN). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces comprehensive financial correctness and statutory compliance updates, including floored overage and revenue split calculations, inline balanced journal postings for multi-collaborator bookings, and fully wired tax adjustments (TDS, GST, and TCS) for refunds and lost chargebacks. It also adds robust validation guards for program overage configurations and expands ledger reconciliation checks to prevent financial drift. Feedback on these changes highlights a potential shell-scripting issue in the CI notification script where raw string interpolation could produce malformed JSON, and a performance concern in the ledger reconciler where a massive IN clause query could hit PostgreSQL parameter limits.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
…ists; deletedAt optional-chained - ASSIGNMENT_PERIOD_OVERLAP compared only against the previous row — one long cycle overlapping several later short ones flagged just the first. Track the group's max periodEnd instead. - tdsRateAppliedBps used truthiness, so a legitimate Sec 197 zero-rate certificate stored null instead of 0 bps; != null preserves it. - checkout's consultation/subscription deletedAt checks use optional chaining for consistency with the webinar/class branches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # lib/payments/payouts/payout-service.ts
…bind-param cap - notify-ops-failure.sh builds its JSON via jq (printf interpolation produced malformed payloads for job names with quotes). - The SPLIT_SUM org-earnings sum drops its giant IN list (org-filtered groupBy + map join); the gross lookup and the overage-txn key probe — the same class the review flagged once — chunk their id lists at 5k to stay under Postgres's 65,535 bind-parameter cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
PR-C of the production-grade stack (stacked on #823 / PR-B). With the schema frozen by PR-A+B, this PR makes the runtime finance-correct: one rounding policy proven to the paise, the last partially-journaled flow closed, the TDS regime consolidated behind a launch flag, refunds and chargebacks at full statutory parity, an end-to-end overage architecture audit with its findings fixed, the reconcile suite completed, and money-cron failures actually paging someone.
Closes #773. Closes #750. Closes #751. Closes #775. Part of #778 (§C, §E, §G), #738 (Items A/B), #709 (alerting slice), #715/#716 (overage/refund correctness slices).
What changed
One rounding/residual policy (#778 §C)
splitByBps()/prorate()encode the policy — floor every share, residual to exactly one named party (OWNER within a collaborator pool, PLATFORM_FEE across funding/tax splits). The §C-2 bug is fixed at its source:calculateRevenueSplit's round-per-collaborator could overshoot the pool and push the owner's remainder negative; it now floors with a Σbps>10000 refusal. GST's headline levy keeps nearest-rounding by documented exemption (flooring a levy under-collects tax; its CGST/SGST halves already floor+remainder). The §C-1 negative-plug regression test the #812 fix never got now pins the balanced posting. The new reconcileSPLIT_SUM_MISMATCHproves the policy end-to-end on every earnings-bearing payment.#773 — the booking journal is complete
Multi-collaborator payments now post one balanced
booking:<paymentId>transaction (funding debits by leg source → PLATFORM_FEE + per-party CONSULTANT_PAYABLE + per-org ORG_PAYABLE + GST_PAYABLE credits), with settled collaborators' earnings rows storing the share net of the host-org cut so cache equals journal exactly. A posting failure rolls the earnings creation back. The hourlysync-payment-earningsrepair script — which minted full-share collaborator rows with no journal — now delegates to the same function. Seeds post the journal atomically with each earnings row.earningsPaymentsWithoutBookingTxnis promoted from an info metric to a threshold-zero reconcile finding: the platform can never silently run partially journaled again.TDS consolidation + statutory parity (#778 §E, #738 A/B)
TDS_ENGINEenv flag, defaultLEGACY(the conservative ₹50K-gated 194-O hybrid shipping today);194Odrops the gate for pure Section 194-O semantics — one env flip at launch once the CA confirms in writing. The 194J calculator is marked deprecated.TdsAdjustmentrow (the Form 140/144 filing artifact) alongside the negativeTDSRecord— closing the long-standing "TdsAdjustment unwired" gap.CreditNote.disputeId, and emitsGstTcsAdjustmentwhere TCS was collected. The refund cascade emits the same TCS adjustment (inert until Sec 52 collection ships).TdsRaterows seeded: IT1961 sections through 2026-03-31, IT2025 §393 Sl. 8(v) from 2026-04-01,paymentCodedeliberately null pending the CBDT notification.X-Payout-Idempotency(mandatory at RazorpayX since 2025-03-15) verified already sent with deterministic keys.Overage architecture audit (user-requested) — verdicts and fixes
Full trace of config → computeOverage → breaker → CHARGE_MEMBER/CHARGE_ORG/BLOCK → settlement → refund propagation → timeout/sweep. Fixed: a capture-vs-reversal race that could credit the org with money belonging to a refunded member (the CHARGED transition now gates the ORG_PAYABLE posting; the raced case self-reports for manual refund); refunding a CHARGE_MEMBER side-payment now debits ORG_PAYABLE instead of billing the platform via the plug;
OverageEvent.currencymirrored the booking instead of hardcoding INR; config combos that meant unbounded liability (CHARGE_* with no circuit-breaker ceiling) or dead knobs are rejected at create and at piecemeal PATCH; sweep write-offs stamp an auditable reason. The locked settlement decision is documented at the handler: a member side-payment's ORG_PAYABLE credit is realized at org payout — no invoice-netting, no wallet credit — and the standing relief credit on that account is documented as expected, not drift. #751 ships alongside: overlappingcoveredPlanTypeson the same contract is a 409 naming the conflicts unlessforceOverlap: true.Found, documented, deliberately not fixed here (needs a #715-style price-basis decision): SUBSCRIPTION lazy-debit discards
recordBookingUtilization's result, so a cap-crossing at slot-allocation never creates an OverageEvent — a silent under-charge thatOVERAGE_COUNT_DRIFTalready detects. Flagged 🟡 in the programs band.Reconcile completion (#778 §G) and ops (#709)
New findings:
SPLIT_SUM_MISMATCH,OVERAGE_SETTLEMENT_MISMATCH(exact semantics from the audit, including the legitimate leg-net cases under #786),EARNINGS_WITHOUT_BOOKING_TXN(threshold 0), plus PR-A/B'sMONEY_VALUE_WITHIN_SAFE_RANGEandASSIGNMENT_PERIOD_OVERLAP. The payout poller keeps pre-completion gatewayreversedas FAILED — that is the correct accounting (no PAYOUT txn was ever posted; webhooks own post-COMPLETED reversals) — but now records the distinction infailureReason. All 15 money workflows' failure stubs now page#ops-alertsthrough one env-gated script that no-ops loudly when the secret is absent.Docs (updated in-PR)
booking-to-earnings reflects the completed journal + netting model; the programs band carries the lazy-debit gap; the compliance band and
docs/compliance/05flip the refund/chargeback adjustment matrix to wired and record theTDS_ENGINEflag; the stale 16-char Rule 46(b) divergence is marked resolved (#807'sGST_DOC_NUMBER_MAX_LEN).Verification
npx tsc --noEmit: 0 errors.npm test: 92 suites, 1187 tests green (+16 over the PR-B baseline: rounding-policy floors, negative-plug posting, multi-party journal, overage webhook race/redelivery, calculator hardening, overlap guard). Lint: clean (one pre-existingeqeqeqwarning untouched).db push→ triggers → seed →reconcile-ledgersok:truewith the new invariants → Verify invoice PDF generation after dropping the react-pdf reconciler hack #707 invoice-PDF smoke (%PDFmagic bytes vianext build && next start) → one live money round-trip. Run before merging the stack intofeature/enterprise.Known follow-ups (non-blocking, for the merge-time review)
30-programs-and-lifecycle/02-programs.md).OVERAGE_COUNT_DRIFT).deletedAt(micro-race).ENABLE_LIVE_PAYOUTS, dunning-suspend, IRP uploader, and the GSTR/Form-140 exports remain deliberate launch gates per Enterprise v3 mega-audit — schema accuracy & finance correctness (mission-critical) #778 §F.🤖 Generated with Claude Code