diff --git a/__tests__/enterprise/org-payout-withholding-postings.test.ts b/__tests__/enterprise/org-payout-withholding-postings.test.ts new file mode 100644 index 000000000..78ddc6408 --- /dev/null +++ b/__tests__/enterprise/org-payout-withholding-postings.test.ts @@ -0,0 +1,268 @@ +/** + * @jest-environment node + */ + +/** + * #1470 — the ORG_PAYOUT journal must respect the withholding identity + * `amountPaise + tdsAmountPaise === netPayoutPaise`. + * + * `createOrgPayoutBatch` stores `netPayoutPaise` as the host org's share BEFORE + * withholding and `amountPaise` as what the rail actually transfers. The + * completion posting used to debit `netPayoutPaise + tds` and credit CASH + * `netPayoutPaise`, which balances — so the leg-sum trigger accepted it — while + * clearing ORG_PAYABLE and crediting CASH by one TDS amount too much on every + * single org payout. The reversal mirrored the same wrong shape, so only a + * payout that stayed COMPLETED carried the overstatement. + * + * The figures below are the ones observed on deploy-preview-1422 (payout + * `7cf818fb…`): 852,516 pre-withholding, 852 withheld, 851,664 transferred. + */ + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + organizationPayout: { + updateMany: jest.fn(), + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn(), + aggregate: jest.fn(), + }, + organizationEarnings: { updateMany: jest.fn().mockResolvedValue({}) }, + orgAuditLog: { create: jest.fn().mockResolvedValue({}) }, + tDSRecord: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) }, + $transaction: jest.fn(), + }, +})); + +jest.mock("../../lib/payments/ledger/post", () => ({ + __esModule: true, + postLedgerTxn: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../lib/enterprise/system-events", () => ({ + __esModule: true, + recordSystemError: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../lib/observability/report", () => ({ + __esModule: true, + reportSentryError: jest.fn(), + reportSentryMessage: jest.fn(), +})); + +jest.mock("../../lib/payments/tax/tds-service", () => ({ + __esModule: true, + ...jest.requireActual("../../lib/payments/tax/tds-service"), + recordOrgTDSDeduction: jest.fn().mockResolvedValue(undefined), + recordOrgTdsReversal: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../lib/novu/org-workflows", () => ({ + __esModule: true, + notifyOrgPayoutCompleted: jest.fn().mockResolvedValue(undefined), + notifyOrgPayoutFailed: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../lib/payments/payouts/razorpay-payouts", () => ({ + __esModule: true, + getRazorpayPayoutsService: jest.fn(), +})); + +import prisma from "@/lib/prisma"; +import { postLedgerTxn } from "@/lib/payments/ledger/post"; +import { recordSystemError } from "@/lib/enterprise/system-events"; +import { reportSentryError } from "@/lib/observability/report"; +import { recordOrgTDSDeduction } from "@/lib/payments/tax/tds-service"; +import { + markOrgPayoutCompleted, + markOrgPayoutReversed, + OrgPayoutWithholdingMismatchError, +} from "@/lib/payments/payouts/org-payout-service"; + +const PAYOUT_ID = "op_7cf818fb"; +const ORG_ID = "org-host-1"; + +/** The deploy-preview figures: pre-withholding, withheld, transferred. */ +const NET_PAYOUT_PAISE = 852_516; +const TDS_PAISE = 852; +const AMOUNT_PAISE = 851_664; + +const mockedPrisma = prisma as unknown as { + organizationPayout: { + updateMany: jest.Mock; + findUniqueOrThrow: jest.Mock; + aggregate: jest.Mock; + }; + organizationEarnings: { updateMany: jest.Mock }; + tDSRecord: { deleteMany: jest.Mock }; + $transaction: jest.Mock; +}; + +function payoutRow(overrides: Record = {}) { + return { + id: PAYOUT_ID, + organizationId: ORG_ID, + netPayoutPaise: NET_PAYOUT_PAISE, + amountPaise: AMOUNT_PAISE, + tdsAmountPaise: TDS_PAISE, + tdsRateAppliedBps: 10, // 0.1% — Section 194-O with a PAN on file + tdsSectionApplied: "194O", + currency: "INR", + organization: { name: "Host Org" }, + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + // The service runs `prisma.$transaction(async (tx) => ...)`; handing the + // callback the same mocked client puts every inner call on these spies. + mockedPrisma.$transaction.mockImplementation(async (fn: unknown) => + typeof fn === "function" + ? (fn as (tx: typeof mockedPrisma) => Promise)(mockedPrisma) + : undefined, + ); + mockedPrisma.organizationPayout.updateMany.mockResolvedValue({ count: 1 }); + mockedPrisma.organizationEarnings.updateMany.mockResolvedValue({ count: 0 }); + mockedPrisma.organizationPayout.aggregate.mockResolvedValue({ + _sum: { netPayoutPaise: 0 }, + }); + mockedPrisma.organizationPayout.findUniqueOrThrow.mockResolvedValue( + payoutRow(), + ); +}); + +describe("#1470 — markOrgPayoutCompleted ORG_PAYOUT posting", () => { + it("debits ORG_PAYABLE pre-withholding and credits CASH post-withholding", async () => { + const result = await markOrgPayoutCompleted(PAYOUT_ID); + + expect(result).toEqual({ wasNoOp: false, status: "COMPLETED" }); + expect(postLedgerTxn).toHaveBeenCalledTimes(1); + const [, txnArg] = (postLedgerTxn as jest.Mock).mock.calls[0]; + expect(txnArg.idempotencyKey).toBe(`orgpayout:${PAYOUT_ID}`); + expect(txnArg.kind).toBe("ORG_PAYOUT"); + expect(txnArg.postings).toEqual([ + { + account: { kind: "ORG_PAYABLE", organizationId: ORG_ID }, + direction: "DEBIT", + amountPaise: NET_PAYOUT_PAISE, // 852,516 — NOT 853,368 + }, + { + account: { kind: "CASH" }, + direction: "CREDIT", + amountPaise: AMOUNT_PAISE, // 851,664 — NOT 852,516 + }, + { + account: { kind: "TDS_PAYABLE" }, + direction: "CREDIT", + amountPaise: TDS_PAISE, // 852 + }, + ]); + }); + + it("files the 194-O return on the pre-withholding gross, not gross + TDS", async () => { + // One prior COMPLETED payout of 1,000,000 pre-withholding in the same FY. + mockedPrisma.organizationPayout.aggregate.mockResolvedValue({ + _sum: { netPayoutPaise: 1_000_000 }, + }); + + await markOrgPayoutCompleted(PAYOUT_ID); + + expect(recordOrgTDSDeduction).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + tdsDeducted: TDS_PAISE, + // 1,000,000 + 852,516. The old code added both payouts' TDS on top. + cumulativeAmountCredited: 1_852_516, + }), + ); + }); + + it("refuses to post a guessed figure when the withholding identity is broken", async () => { + // The pre-#1470 row shape: amountPaise never reduced by the withholding. + mockedPrisma.organizationPayout.findUniqueOrThrow.mockResolvedValue( + payoutRow({ amountPaise: NET_PAYOUT_PAISE }), + ); + + await expect(markOrgPayoutCompleted(PAYOUT_ID)).rejects.toThrow( + OrgPayoutWithholdingMismatchError, + ); + + // Nothing was journalled, so the CAS transaction rolls back and the + // at-least-once webhook (or the stuck-payout sweep) re-drives it. + expect(postLedgerTxn).not.toHaveBeenCalled(); + expect(recordOrgTDSDeduction).not.toHaveBeenCalled(); + expect(recordSystemError).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + category: "PAYOUT", + summary: expect.stringContaining("ORG_PAYOUT_WITHHOLDING_MISMATCH"), + context: expect.objectContaining({ + orgPayoutId: PAYOUT_ID, + netPayoutPaise: NET_PAYOUT_PAISE, + amountPaise: NET_PAYOUT_PAISE, + tdsAmountPaise: TDS_PAISE, + }), + }), + ); + expect(reportSentryError).toHaveBeenCalledWith( + expect.any(OrgPayoutWithholdingMismatchError), + expect.objectContaining({ + subsystem: "payments", + op: "markOrgPayoutCompleted", + }), + ); + }); +}); + +describe("#1470 — markOrgPayoutReversed mirrors the corrected posting", () => { + it("debits CASH post-withholding and credits ORG_PAYABLE pre-withholding", async () => { + const result = await markOrgPayoutReversed( + PAYOUT_ID, + "bank returned funds", + ); + + expect(result).toEqual({ wasNoOp: false, status: "REVERSED" }); + expect(postLedgerTxn).toHaveBeenCalledTimes(1); + const [, txnArg] = (postLedgerTxn as jest.Mock).mock.calls[0]; + expect(txnArg.idempotencyKey).toBe(`orgpayout-reversal:${PAYOUT_ID}`); + expect(txnArg.postings).toEqual([ + { + account: { kind: "CASH" }, + direction: "DEBIT", + amountPaise: AMOUNT_PAISE, // 851,664 + }, + { + account: { kind: "ORG_PAYABLE", organizationId: ORG_ID }, + direction: "CREDIT", + amountPaise: NET_PAYOUT_PAISE, // 852,516 + }, + { + account: { kind: "TDS_PAYABLE" }, + direction: "DEBIT", + amountPaise: TDS_PAISE, // 852 + }, + ]); + }); + + it("refuses the reversal when the withholding identity is broken", async () => { + mockedPrisma.organizationPayout.findUniqueOrThrow.mockResolvedValue( + payoutRow({ amountPaise: NET_PAYOUT_PAISE }), + ); + + await expect( + markOrgPayoutReversed(PAYOUT_ID, "bank returned funds"), + ).rejects.toThrow(OrgPayoutWithholdingMismatchError); + + expect(postLedgerTxn).not.toHaveBeenCalled(); + expect(recordSystemError).toHaveBeenCalledWith( + expect.objectContaining({ + summary: expect.stringContaining("ORG_PAYOUT_WITHHOLDING_MISMATCH"), + }), + ); + expect(reportSentryError).toHaveBeenCalledWith( + expect.any(OrgPayoutWithholdingMismatchError), + expect.objectContaining({ op: "markOrgPayoutReversed" }), + ); + }); +}); diff --git a/__tests__/enterprise/tds-org-payout-input.test.ts b/__tests__/enterprise/tds-org-payout-input.test.ts index 8a10e4cba..a115f6d41 100644 --- a/__tests__/enterprise/tds-org-payout-input.test.ts +++ b/__tests__/enterprise/tds-org-payout-input.test.ts @@ -210,7 +210,7 @@ describe("markOrgPayoutCompleted — withheld TDS with no stored rate (#1354)", ); mockedPrisma.organizationPayout.updateMany.mockResolvedValue({ count: 1 }); mockedPrisma.organizationPayout.aggregate.mockResolvedValue({ - _sum: { netPayoutPaise: 0, tdsAmountPaise: 0 }, + _sum: { netPayoutPaise: 0 }, }); }); @@ -223,6 +223,9 @@ describe("markOrgPayoutCompleted — withheld TDS with no stored rate (#1354)", id: PAYOUT_ID, organizationId: ORG_ID, netPayoutPaise: 999_000, + // #1470 — amountPaise is the post-withholding transfer, so it must + // satisfy amountPaise + tds === netPayoutPaise or the posting is refused. + amountPaise: 998_000, tdsAmountPaise: 1_000, tdsRateAppliedBps: null, tdsSectionApplied: null, diff --git a/__tests__/maintenance/release-earnings-org-arm.test.ts b/__tests__/maintenance/release-earnings-org-arm.test.ts new file mode 100644 index 000000000..334620e80 --- /dev/null +++ b/__tests__/maintenance/release-earnings-org-arm.test.ts @@ -0,0 +1,148 @@ +/** + * @jest-environment node + */ + +/** + * #1471 — `scripts/earnings/release-earnings.ts` is the module every scheduled + * entry point imports (the GitHub Actions job, the `/api/cleanup` twin, the + * admin system-jobs runner), and until this change its queries touched only + * `consultantEarnings`. `OrganizationEarnings` rows therefore never left + * PENDING, and `createOrgPayoutBatch` — which selects READY rows only — could + * never pick up a host organisation's retained share through any scheduled + * path. + * + * This pin drives the real query shape through an in-memory table so the CAS + * predicate itself is exercised: a PENDING row past its hold is released, a + * PENDING row still inside its hold is not. + */ + +jest.mock("../../lib/cron/with-cron-lock", () => ({ + __esModule: true, + withCronLock: jest.fn( + async (_key: string, _opts: unknown, fn: () => Promise) => fn(), + ), +})); + +interface OrgEarningRow { + id: string; + status: string; + holdUntil: Date; + orgSharePaise: number; + organization: { name: string }; +} + +const ORG_ROWS: OrgEarningRow[] = [ + { + id: "oe_past_hold", + status: "PENDING", + holdUntil: new Date("2026-06-01T00:00:00.000Z"), + orgSharePaise: 80_000, + organization: { name: "Host Org" }, + }, + { + id: "oe_inside_hold", + status: "PENDING", + // Far enough out that the job's `new Date()` can never pass it. + holdUntil: new Date("2099-01-01T00:00:00.000Z"), + orgSharePaise: 90_000, + organization: { name: "Host Org" }, + }, + { + id: "oe_already_ready", + status: "READY", + holdUntil: new Date("2026-06-01T00:00:00.000Z"), + orgSharePaise: 70_000, + organization: { name: "Host Org" }, + }, +]; + +const releasedIds: string[] = []; + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + consultantEarnings: { + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + aggregate: jest.fn().mockResolvedValue({ _count: 0, _sum: {} }), + }, + organizationEarnings: { + findMany: jest.fn(), + updateMany: jest.fn(), + }, + $transaction: jest.fn(), + $disconnect: jest.fn().mockResolvedValue(undefined), + }, +})); + +import prisma from "@/lib/prisma"; +import { releaseEarningsFromHold } from "@/scripts/earnings/release-earnings"; + +const mockedPrisma = prisma as unknown as { + organizationEarnings: { findMany: jest.Mock; updateMany: jest.Mock }; + $transaction: jest.Mock; +}; + +describe("#1471 — release-earnings releases host-organization earnings", () => { + beforeEach(() => { + releasedIds.length = 0; + mockedPrisma.$transaction.mockImplementation(async (fn: unknown) => + typeof fn === "function" + ? (fn as (tx: typeof prisma) => Promise)(prisma) + : undefined, + ); + + // Apply the real predicate against the fixture table rather than trusting + // a hand-written expectation of the `where` object. + mockedPrisma.organizationEarnings.findMany.mockImplementation( + async (args: { where: { status: string; holdUntil: { lte: Date } } }) => + ORG_ROWS.filter( + (r) => + r.status === args.where.status && + r.holdUntil.getTime() <= args.where.holdUntil.lte.getTime(), + ), + ); + mockedPrisma.organizationEarnings.updateMany.mockImplementation( + async (args: { + where: { id: { in: string[] }; status: string }; + data: { status: string }; + }) => { + const hit = ORG_ROWS.filter( + (r) => + args.where.id.in.includes(r.id) && r.status === args.where.status, + ); + releasedIds.push(...hit.map((r) => r.id)); + return { count: hit.length }; + }, + ); + }); + + it("releases a PENDING row past its hold and leaves one inside its hold alone", async () => { + const result = await releaseEarningsFromHold(); + + expect(result.success).toBe(true); + expect(result.organizationEarningsReleased).toBe(1); + expect(releasedIds).toEqual(["oe_past_hold"]); + // The consultant count keeps its original meaning (#1471). + expect(result.releasedCount).toBe(0); + }); + + it("re-states status: PENDING on the claim so a concurrent writer wins", async () => { + await releaseEarningsFromHold(); + + expect(mockedPrisma.organizationEarnings.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: "PENDING" }), + data: { status: "READY" }, + }), + ); + }); + + it("applies the ticker limit to the organization arm as its own budget", async () => { + await releaseEarningsFromHold({ limit: 25 }); + + expect(mockedPrisma.organizationEarnings.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 25, orderBy: { holdUntil: "asc" } }), + ); + }); +}); diff --git a/__tests__/pdf/statutory-pdf-jsx-runtime.test.ts b/__tests__/pdf/statutory-pdf-jsx-runtime.test.ts new file mode 100644 index 000000000..d355531ac --- /dev/null +++ b/__tests__/pdf/statutory-pdf-jsx-runtime.test.ts @@ -0,0 +1,69 @@ +/** + * @jest-environment node + * + * #1468 — the statutory PDFs must create their elements with the same React + * that `@react-pdf/reconciler` loads, because that package picks one of three + * bundled reconcilers by reading `React.version` and each one recognises only + * its own era's element stamp. On the deployed build the renderer is an + * external package resolved by Node while route-handler code is compiled + * against Next's vendored React, and the two disagreed: every invoice PDF + * answered 500 with React error #31. + * + * Jest cannot reproduce that split — it has exactly one React — so these + * assertions pin the two halves that survive into the bundle: the runtime the + * components compile against, and the fact that Next's vendored React is a + * genuinely different stamp rather than an interchangeable one. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { nodeRequire } from "@/lib/pdf/react-runtime/node-require"; +import * as pdfJsxRuntime from "@/lib/pdf/react-runtime/jsx-runtime"; + +/** The `react` that `@react-pdf/reconciler` resolves at runtime. */ +function reconcilerJsxRuntime(): typeof import("react/jsx-runtime") { + const reconcilerDir = path.dirname( + require.resolve("@react-pdf/reconciler/package.json"), + ); + return nodeRequire( + require.resolve("react/jsx-runtime", { paths: [reconcilerDir] }), + ) as typeof import("react/jsx-runtime"); +} + +describe("statutory PDF JSX runtime", () => { + it("stamps elements the way the reconciler's React does", () => { + const expected = reconcilerJsxRuntime().jsx("div", {}); + const actual = pdfJsxRuntime.jsx("div", {}); + + expect(actual.$$typeof).toBe(expected.$$typeof); + expect(pdfJsxRuntime.Fragment).toBe(reconcilerJsxRuntime().Fragment); + }); + + it("does not stamp elements the way Next's vendored React does", () => { + const vendored = nodeRequire( + "next/dist/compiled/react/jsx-runtime", + ) as typeof import("react/jsx-runtime"); + + expect(vendored.jsx("div", {}).$$typeof).not.toBe( + pdfJsxRuntime.jsx("div", {}).$$typeof, + ); + }); + + it("compiles every react-pdf component file against that runtime", () => { + const dir = path.join(process.cwd(), "lib", "pdf"); + const componentFiles = fs + .readdirSync(dir) + .filter((f) => f.endsWith(".tsx")) + .map((f) => path.join(dir, f)) + .filter((f) => + fs.readFileSync(f, "utf8").includes("@react-pdf/renderer"), + ); + + expect(componentFiles.length).toBeGreaterThan(0); + for (const file of componentFiles) { + expect(fs.readFileSync(file, "utf8")).toContain( + "@jsxImportSource @/lib/pdf/react-runtime", + ); + } + }); +}); diff --git a/app/api/admin/system-jobs/run/route.ts b/app/api/admin/system-jobs/run/route.ts index 7f0a1783b..9e44f6084 100644 --- a/app/api/admin/system-jobs/run/route.ts +++ b/app/api/admin/system-jobs/run/route.ts @@ -167,6 +167,8 @@ const JOB_FUNCTIONS: Record = { return { success: result.success, releasedCount: result.releasedCount, + // #1471 — the same run now also releases host-org earnings. + organizationEarningsReleased: result.organizationEarningsReleased, errorCount: result.errorCount, }; }, diff --git a/app/api/cleanup/release-earnings/route.ts b/app/api/cleanup/release-earnings/route.ts index a7b810638..feafe22e7 100644 --- a/app/api/cleanup/release-earnings/route.ts +++ b/app/api/cleanup/release-earnings/route.ts @@ -15,6 +15,9 @@ export const { GET, POST } = cleanupRoute({ run: (req) => releaseEarningsFromHold({ limit: parseLimitParam(req) }), summarize: (r) => ({ releasedCount: r.releasedCount, + // #1471 — the host-org arm is reported separately so the existing + // `releasedCount` keeps meaning "consultant earnings released". + organizationEarningsReleased: r.organizationEarningsReleased, errorCount: r.errorCount, }), // #1390 review — the constant 200 masked a caught job error (success:false) diff --git a/app/api/dev/mock-webhook/route.ts b/app/api/dev/mock-webhook/route.ts index ffd6b580b..38005ddc7 100644 --- a/app/api/dev/mock-webhook/route.ts +++ b/app/api/dev/mock-webhook/route.ts @@ -20,6 +20,7 @@ * - payout.rejected: Marks payout as failed */ +import { createHash, timingSafeEqual } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { handlePaymentSuccess } from "@/lib/payments/webhooks/handlers"; @@ -71,8 +72,28 @@ interface MockWebhookResponse { * was never load-bearing here — this app deploys on Netlify, which sets no * `VERCEL_ENV`, so the branch had been dead since it was written. */ -function isDevelopment(): boolean { - return process.env.NODE_ENV === "development"; +function isDevelopment(request?: NextRequest): boolean { + if ( + process.env.NODE_ENV === "development" || + process.env.VERCEL_ENV === "preview" + ) { + return true; + } + // test/finance-union ONLY (this branch is never merged): a Netlify deploy + // preview may replay events when the caller proves it holds the preview's + // CRON_SECRET. ALLOW_PREVIEW_WEBHOOK_REPLAY is set only in the Netlify + // deploy-preview context (CONTEXT is not visible to the function at runtime). + return previewReplayAuthorized(request); +} + +function previewReplayAuthorized(request?: NextRequest): boolean { + if (process.env.ALLOW_PREVIEW_WEBHOOK_REPLAY !== "true" || !request) + return false; + const secret = process.env.CRON_SECRET; + if (!secret) return false; + const auth = request.headers.get("authorization") ?? ""; + const sha = (s: string) => createHash("sha256").update(s).digest(); + return timingSafeEqual(sha(auth), sha(`Bearer ${secret}`)); } // ============================================ @@ -356,7 +377,7 @@ async function handleMockPayoutRejected( export async function POST(request: NextRequest) { // Block in production - if (!isDevelopment()) { + if (!isDevelopment(request)) { return NextResponse.json( { success: false, diff --git a/docs/enterprise/10-money-and-ledger/03-ledger-and-postings.md b/docs/enterprise/10-money-and-ledger/03-ledger-and-postings.md index efb262626..06159efa6 100644 --- a/docs/enterprise/10-money-and-ledger/03-ledger-and-postings.md +++ b/docs/enterprise/10-money-and-ledger/03-ledger-and-postings.md @@ -157,10 +157,11 @@ Dr CONSULTANT_PAYABLE(consultant) net + TDS ### 4.5 Host-org payout — `ORG_PAYOUT` (`orgpayout:`) The host-org mirror of 4.4. ``` -Dr ORG_PAYABLE(org) net + TDS - Cr CASH(platform) net paid - Cr TDS_PAYABLE TDS withheld (only if > 0) +Dr ORG_PAYABLE(org) netPayoutPaise (pre-withholding org share) + Cr CASH(platform) amountPaise (what the rail transferred) + Cr TDS_PAYABLE tdsAmountPaise (withheld, only if > 0) ``` +`OrganizationPayout.netPayoutPaise` is the pre-withholding figure and `amountPaise` is the post-withholding one, so `amountPaise + tdsAmountPaise` must equal `netPayoutPaise` for these legs to be right. `markOrgPayoutCompleted` asserts exactly that before posting and throws — rolling the completion back rather than journalling a guess — when it does not hold (#1470). `markOrgPayoutReversed` posts the exact mirror, `Dr CASH amountPaise` and `Dr TDS_PAYABLE tdsAmountPaise` against `Cr ORG_PAYABLE netPayoutPaise`, under the same assertion. See the [payout pipeline](07-payout-pipeline.md) for the history: the earlier shape debited the payable at `net + TDS` and credited cash at `net`, which balanced and so passed every write-time check while overstating both sides by the withholding. ### 4.6 Top-up refund — `TOPUP_REFUND` (`topup-refund:`) A confirmed top-up is refunded; the IOU shrinks, cash returns to the gateway. Exact reverse of 4.1. diff --git a/docs/enterprise/10-money-and-ledger/07-payout-pipeline.md b/docs/enterprise/10-money-and-ledger/07-payout-pipeline.md index 5440ab7cd..5ec42f87f 100644 --- a/docs/enterprise/10-money-and-ledger/07-payout-pipeline.md +++ b/docs/enterprise/10-money-and-ledger/07-payout-pipeline.md @@ -61,12 +61,16 @@ Two details distinguish the org machine from the consultant machine. First, the On `PROCESSING → COMPLETED`, `markOrgPayoutCompleted` posts the settlement (`idempotencyKey = orgpayout:`, `kind = ORG_PAYOUT`) inside the same transaction that flips the status, so a rolled-back transition cannot leave a half-posted ledger. ``` -Dr ORG_PAYABLE(org) net + TDS (clear what we owed the org) - Cr CASH(platform) net paid - Cr TDS_PAYABLE TDS withheld (only if > 0) +Dr ORG_PAYABLE(org) netPayoutPaise (clear what we owed the org, pre-withholding) + Cr CASH(platform) amountPaise (what the rail actually transferred) + Cr TDS_PAYABLE tdsAmountPaise (withheld, only if > 0) ``` -The **consultant** payout is the mirror — `payout:`, `kind = PAYOUT`: `Dr CONSULTANT_PAYABLE / Cr CASH + TDS_PAYABLE` (`lib/payments/payouts/payout-service.ts`). On `FAILED` (and, on the consultant rail, `CANCELLED`), the linked earnings are released back to `READY` with their `orgPayoutId` / `payoutId` cleared, and provisional `TDSRecord` rows are deleted. The reconciler asserts `sum(orgShare − refunded) == netPayoutPaise` (`ORG_PAYOUT_TOTAL_MISMATCH`, [ledger integrity](13-ledger-integrity.md)). +The two money columns on `OrganizationPayout` are easy to read the wrong way round, so the schema now says which is which in a `///` comment and the service asserts the relationship before it posts. `netPayoutPaise` is the host organisation's share net of platform fee and refunds, taken **before** withholding, and it is also the base `computeTdsForPayout` is given. `amountPaise` is that figure minus the withholding, which is the sum RazorpayX or Stripe Connect actually moves. The identity `amountPaise + tdsAmountPaise == netPayoutPaise` therefore has to hold for the three legs above to tie out, and `assertOrgPayoutWithholdingIdentity` checks it inside the CAS transaction. When it does not hold there is no correct posting available — any figure we chose would clear the payable or credit cash by an amount that never moved — so the service records a `SystemEvent`, reports to Sentry from outside the transaction (a global-client write while a `$transaction` holds the only pooled connection would deadlock under `PG_POOL_MAX=1`) and throws, which rolls the completion back for the at-least-once webhook or the stuck-payout sweep to re-drive. + +Until #1470 the posting debited `netPayoutPaise + TDS` and credited `CASH` at `netPayoutPaise`. That set balances, so the write-time check and the nightly imbalance finding both accepted it, but it cleared `ORG_PAYABLE` and credited `CASH` by exactly one TDS amount too much on every host-org payout, and `markOrgPayoutReversed` mirrored the same wrong shape so only a payout that stayed `COMPLETED` carried the overstatement. The same misreading also sat in the `TDSRecord` the completion files: `cumulativeAmountCredited` summed `netPayoutPaise + tdsAmountPaise` across the financial year, which counts the withholding twice, because `netPayoutPaise` is already the gross credited figure that Section 194-O asks for. Both are corrected, and the reversal is now the exact mirror of the corrected legs (`Dr CASH amountPaise`, `Dr TDS_PAYABLE tdsAmountPaise`, `Cr ORG_PAYABLE netPayoutPaise`) under the same assertion. + +The **consultant** payout is the mirror — `payout:`, `kind = PAYOUT`: `Dr CONSULTANT_PAYABLE / Cr CASH + TDS_PAYABLE` (`lib/payments/payouts/payout-service.ts`). On `FAILED` (and, on the consultant rail, `CANCELLED`), the linked earnings are released back to `READY` with their `orgPayoutId` / `payoutId` cleared, and provisional `TDSRecord` rows are deleted. The reconciler asserts `sum(orgShare − refunded) == netPayoutPaise` (`ORG_PAYOUT_TOTAL_MISMATCH`, [ledger integrity](13-ledger-integrity.md)), and it does so only for payouts in `PENDING`, `APPROVED`, `PROCESSING` or `COMPLETED`. A `FAILED`, `REVERSED` or `CANCELLED` payout has deliberately detached its earnings back to `READY` with `orgPayoutId` cleared, so it ends up with nothing attached against a retained `netPayoutPaise`; reporting that as drift was noise rather than a finding (#1471). > 🔒 **`ENABLE_LIVE_PAYOUTS` is still off.** The whole pipeline runs — batching, TDS/MSME, the status machine, the ledger posting — but **gateway submission is held**, so org payouts sit at `PENDING` (surfaced in the UI as "pending platform enablement", never as a failure). The `ORG_PAYOUT` / `PAYOUT` ledger leg posts only on a real `PROCESSING → COMPLETED`, so no cash-leaving entry exists until go-live. See the [live-payout go-live runbook](../50-operations/06-live-payout-go-live-runbook.md). diff --git a/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md b/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md index 648ce5010..e062d12a0 100644 --- a/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md +++ b/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md @@ -51,7 +51,7 @@ flowchart TD | `ACTIVE_SEAT_COUNT_DRIFT` | per `BillingSubscription` | `activeSeatCount == count(in-period LICENSED_SEAT ACTIVE assignments)` | the per-seat invoice line-item counter missed a write (or reflects historical drift before its writer existed) | | `PAYMENT_LEG_SUM_MISMATCH` | per `Payment` with org legs | `sum(non-reversal, non-REFERRAL_CREDIT PaymentLeg.amountPaise) == Payment.amount` (LICENSE legs are 0; the referral credit is already netted out of `amount`, #1347) | a leg writer (checkout / wallet / referral / overage) emitted the wrong amount | | `INVOICE_TOTAL_MISMATCH` | per `OrganizationInvoice` | `totalPaise == subtotalPaise + CGST + SGST + IGST` | a mis-totaled GST invoice (filing defect); the issue-time assert in `invoice-rollup.ts` blocks new ones, this sweeps legacy/manual rows | -| `ORG_PAYOUT_TOTAL_MISMATCH` | per `OrganizationPayout` | `sum(orgShare − refunded) of batched earnings == netPayoutPaise` | the batch claim updated earnings but the payout total diverged | +| `ORG_PAYOUT_TOTAL_MISMATCH` | per `OrganizationPayout` in `PENDING` / `APPROVED` / `PROCESSING` / `COMPLETED` | `sum(orgShare − refunded) of batched earnings == netPayoutPaise` | the batch claim updated earnings but the payout total diverged. Terminal-with-release statuses (`FAILED`, `REVERSED`, `CANCELLED`) are skipped because they detach their earnings back to `READY` by design (#1471) | | `LEDGER_TXN_IMBALANCE` | per `LedgerTransaction` (**full scope only**) | `Σdebit == Σcredit` | a manual SQL edit or a future writer bug broke a posting; **zero of these across a reseed is the gate** that justified removing the three legacy logs | | `LEDGER_BALANCE_SNAPSHOT_DRIFT` | per `LedgerAccount` (**full scope only**) | maintained `LedgerAccountBalance` snapshot == journal `Σ(DEBIT)−Σ(CREDIT)` (#776) | the O(1) running-balance cache drifted, or an account with entries has no snapshot row (a posting bypassed `postLedgerTxn`) | | `REFUND_BOOKING_COHERENCE` | per `BookingUtilization` (**full scope only**) | fully-refunded payment ⇒ utilization reversed; reversed utilization ⇒ a `SUCCEEDED` refund backs it (#776 §C) | a cap leak (money back but the seat still consumed) or a seat released for free | diff --git a/docs/maintenance/04-cron-jobs-reference.md b/docs/maintenance/04-cron-jobs-reference.md index 6557484db..046070386 100644 --- a/docs/maintenance/04-cron-jobs-reference.md +++ b/docs/maintenance/04-cron-jobs-reference.md @@ -108,10 +108,12 @@ The payout and earnings pipeline, which turns completed sessions into money leav | **Handle Stuck Payouts**
`handle-stuck-payouts` | `52 */4 * * *` | `jobs/payouts/handle-stuck-payouts.ts`
→ `scripts/payouts/handle-stuck-payouts.ts` | closed | yes | `ConsultantPayout` stuck-state recovery, `SystemEvent`; queries RazorpayX and Stripe | Skips: OFFLINE + DEGRADED | | **Process Payouts**
`process-payouts` | `0 21 * * 1` | `jobs/payouts/process-payouts.ts` | bespoke | yes | Consultant and org payout status, `ConsultantEarnings`, `TDSRecord`, ledger; submits RazorpayX and Stripe payouts | Skips: OFFLINE + DEGRADED | | **Reconcile Payout Status**
`reconcile-payout-status` | `33 */6 * * *` | `jobs/payouts/reconcile-payout-status.ts`
→ `scripts/payouts/reconcile-payout-status.ts` | closed | yes | `ConsultantPayout` status and TDS fields, `ConsultantEarnings`, `TDSRecord`, ledger entries | Skips: OFFLINE + DEGRADED | -| **Release Earnings from Hold**
`release-earnings` | `17 * * * *` | `jobs/earnings/release-earnings.ts`
→ `scripts/earnings/release-earnings.ts` | closed | yes | `ConsultantEarnings` released from hold | Skips: OFFLINE + DEGRADED | +| **Release Earnings from Hold**
`release-earnings` | `17 * * * *` | `jobs/earnings/release-earnings.ts`
→ `scripts/earnings/release-earnings.ts` | closed | yes | `ConsultantEarnings` and `OrganizationEarnings` released from hold | Skips: OFFLINE + DEGRADED | | **Release PENDING_TRUST Earnings**
`release-pending-trust-earnings` | `42 * * * *` | `jobs/cleanup/release-pending-trust-earnings.ts` | closed | yes | Consultant and org earnings released from PENDING_TRUST | Skips: OFFLINE, DEGRADED | | **Sync Payment to Earnings**
`sync-payment-earnings` | `22 * * * *` | `jobs/earnings/sync-payment-earnings.ts`
→ `scripts/earnings/sync-payment-earnings.ts` | closed | yes | Creates consultant and org earnings plus the booking ledger rows | Skips: OFFLINE + DEGRADED | +`release-earnings` walks both earnings tables in one run. It moves `ConsultantEarnings` and `OrganizationEarnings` rows that are still `PENDING` and whose `holdUntil` has passed to `READY`, which is the status the two batch builders select on, and it does each table in its own Serializable transaction so a serialization conflict on one cannot discard a claim the other has already made. The claim restates `status: PENDING` in its `WHERE`, so a row that a dispute freeze or a refund cascade moved between the read and the update is skipped rather than dragged forward. When the HTTP twin at `/api/cleanup/release-earnings` is called with `?limit=`, that bound applies to each table separately — a run capped at two hundred may release up to two hundred consultant rows and up to two hundred organisation rows — which is the same per-target budgeting the Netlify ticker uses elsewhere. The result reports the two counts separately: `releasedCount` keeps its original meaning of consultant earnings released, and `organizationEarningsReleased` carries the host-organisation figure, so the GitHub Actions outputs and dashboards that already read the first number are not silently re-based. Until #1471 the organisation arm was missing entirely, and because every scheduled entry point imports this one module, a hosting organisation's retained share could never reach a payout batch. + ## Billing and contracts Organisation billing runs on its own cycle engine, and these jobs advance it. They were the newest part of the fleet, and until the wave-5 sweep it showed in the maintenance column: six of the eight were self-contained `jobs/**` entrypoints written after the `abortIfMaintenance()` convention was established, and none of them had adopted it. All eight now call the guard. diff --git a/jobs/earnings/release-earnings.ts b/jobs/earnings/release-earnings.ts index f44984c84..09c715082 100644 --- a/jobs/earnings/release-earnings.ts +++ b/jobs/earnings/release-earnings.ts @@ -28,6 +28,9 @@ function outputToGitHubActions(result: ReleaseResult): void { if (outputFile) { const outputs = [ `released_count=${result.releasedCount}`, + // #1471 — host-org earnings are released by the same run; a separate + // output keeps `released_count` meaning what downstream steps expect. + `org_released_count=${result.organizationEarningsReleased}`, `error_count=${result.errorCount}`, `success=${result.success}`, ].join("\n"); @@ -63,14 +66,17 @@ async function main(): Promise { // Summary console.log(`\n📊 Release Summary:`); - console.log(` ✅ Earnings released: ${result.releasedCount}`); + console.log(` ✅ Consultant earnings released: ${result.releasedCount}`); + console.log( + ` ✅ Organization earnings released: ${result.organizationEarningsReleased}`, + ); console.log(` ❌ Errors: ${result.errorCount}`); // Output to GitHub Actions outputToGitHubActions(result); if (result.success) { - Sentry.logger.info("job:release-earnings finished", { releasedCount: result.releasedCount, errorCount: result.errorCount }); + Sentry.logger.info("job:release-earnings finished", { releasedCount: result.releasedCount, organizationEarningsReleased: result.organizationEarningsReleased, errorCount: result.errorCount }); console.log("🎉 Release earnings job completed successfully"); } else { console.error("❌ Release earnings job completed with errors"); diff --git a/lib/payments/payouts/earnings-service.ts b/lib/payments/payouts/earnings-service.ts index f34e09447..354251b0b 100644 --- a/lib/payments/payouts/earnings-service.ts +++ b/lib/payments/payouts/earnings-service.ts @@ -1130,41 +1130,12 @@ export async function createEarningsFromPayment({ } } -/** - * Release earnings from hold period - * Called by cron job hourly - */ -export async function releaseEarningsFromHold(): Promise { - const now = new Date(); - - // Release consultant earnings - const consultantResult = await prisma.consultantEarnings.updateMany({ - where: { - status: EarningStatus.PENDING, - holdUntil: { lte: now }, - }, - data: { - status: EarningStatus.READY, - }, - }); - - // Release org earnings in parallel - const orgResult = await prisma.organizationEarnings.updateMany({ - where: { - status: EarningStatus.PENDING, - holdUntil: { lte: now }, - }, - data: { - status: EarningStatus.READY, - }, - }); - - const total = consultantResult.count + orgResult.count; - console.log( - `Released ${consultantResult.count} consultant + ${orgResult.count} org earnings from hold`, - ); - return total; -} +// #1471 — `releaseEarningsFromHold` used to live here as a second, unlocked +// implementation that nothing called: every scheduled entry point imports +// `scripts/earnings/release-earnings.ts` instead. It was the only copy that +// released `OrganizationEarnings`, which is why the org arm looked implemented +// while being dead. The behaviour has moved into the script (locked, bounded, +// Serializable) and the dead copy is deleted rather than kept as a trap. /** * Get consultant earnings summary. diff --git a/lib/payments/payouts/index.ts b/lib/payments/payouts/index.ts index ca8e34664..8cf517769 100644 --- a/lib/payments/payouts/index.ts +++ b/lib/payments/payouts/index.ts @@ -54,7 +54,6 @@ export type { OrgProcessingResult } from "./org-payout-service"; export { createEarningsFromPayment, resolvePaymentForEarnings, - releaseEarningsFromHold, getConsultantEarningsSummary, getConsultantEarnings, refundEarnings, diff --git a/lib/payments/payouts/org-payout-service.ts b/lib/payments/payouts/org-payout-service.ts index bd2149024..4d902fd42 100644 --- a/lib/payments/payouts/org-payout-service.ts +++ b/lib/payments/payouts/org-payout-service.ts @@ -100,6 +100,88 @@ export class PayoutValidationError extends Error { } } +/** + * #1470 — raised when an OrganizationPayout's withholding identity + * (`amountPaise + tdsAmountPaise === netPayoutPaise`) does not hold at the + * moment the ORG_PAYOUT journal would be written. + * + * `netPayoutPaise` is the host org's share BEFORE withholding and `amountPaise` + * is what the rail actually transfers, so a payout that breaks the identity has + * no correct posting available: any figure we picked would clear ORG_PAYABLE or + * credit CASH by an amount the money never moved. Throwing from inside the CAS + * transaction rolls the completion (or the reversal) back, which is safe because + * the gateway webhook is at-least-once and the stuck-payout sweep re-drives it. + */ +export class OrgPayoutWithholdingMismatchError extends Error { + readonly code = "ORG_PAYOUT_WITHHOLDING_MISMATCH" as const; + constructor( + readonly payoutId: string, + readonly organizationId: string, + readonly netPayoutPaise: number, + readonly amountPaise: number, + readonly tdsAmountPaise: number, + ) { + super( + `Org payout ${payoutId} withholding identity violated: amountPaise ${amountPaise} + tdsAmountPaise ${tdsAmountPaise} !== netPayoutPaise ${netPayoutPaise}`, + ); + this.name = "OrgPayoutWithholdingMismatchError"; + } +} + +/** + * #1470 — assert the withholding identity before either ORG_PAYOUT posting. + * Kept as one helper so the completion and its reversal cannot drift apart. + */ +function assertOrgPayoutWithholdingIdentity(payout: { + id: string; + organizationId: string; + netPayoutPaise: number; + amountPaise: number; + tdsAmountPaise: number | null; +}): void { + const tds = payout.tdsAmountPaise ?? 0; + if (payout.amountPaise + tds !== payout.netPayoutPaise) { + throw new OrgPayoutWithholdingMismatchError( + payout.id, + payout.organizationId, + payout.netPayoutPaise, + payout.amountPaise, + tds, + ); + } +} + +/** + * #1470 — report a broken withholding identity. Called from the `.catch` of the + * transaction rather than from inside it: `recordSystemError` writes through the + * global Prisma client, and under PG_POOL_MAX=1 a global-client query issued + * while a `$transaction` holds the only connection deadlocks (see the HLD). + */ +async function reportOrgPayoutWithholdingMismatch( + err: OrgPayoutWithholdingMismatchError, + op: string, +): Promise { + const context = { + orgPayoutId: err.payoutId, + organizationId: err.organizationId, + netPayoutPaise: err.netPayoutPaise, + amountPaise: err.amountPaise, + tdsAmountPaise: err.tdsAmountPaise, + }; + await recordSystemError({ + organizationId: err.organizationId, + category: "PAYOUT", + summary: `${err.code} — org payout journal refused: amountPaise + tdsAmountPaise does not equal netPayoutPaise`, + err, + context, + }).catch(() => {}); + reportSentryError(err, { + subsystem: "payments", + op, + extra: context, + }); +} + interface OrgPayoutEligibility { eligible: boolean; readyAmount: number; @@ -1059,7 +1141,10 @@ export async function markOrgPayoutCompleted(payoutId: string): Promise<{ wasNoOp: boolean; status: PayoutStatus; }> { - const result = await prisma.$transaction(async (tx) => { + // #1470 — the promise is held in a local so the `.catch` below is a separate + // statement: chaining it onto `prisma.$transaction(...)` re-indents the whole + // callback and buries the money change in whitespace. + const completion = prisma.$transaction(async (tx) => { const claim = await tx.organizationPayout.updateMany({ where: { id: payoutId, status: "PROCESSING" }, data: { status: "COMPLETED", processedAt: new Date() }, @@ -1089,6 +1174,9 @@ export async function markOrgPayoutCompleted(payoutId: string): Promise<{ id: true, organizationId: true, netPayoutPaise: true, + // #1470 — the CASH leg is sized from what the rail actually sent, which + // is this column, not netPayoutPaise. + amountPaise: true, tdsAmountPaise: true, // #1354 — the rate and section the completion-time TDSRecord files // under, both pinned at batch time (see createOrgPayoutBatch). The @@ -1124,8 +1212,15 @@ export async function markOrgPayoutCompleted(payoutId: string): Promise<{ }); // #771 D1/D5 — double-entry (dual-write): settle the host org's payable. - // Dr ORG_PAYABLE (gross) Cr CASH (paid) Cr TDS_PAYABLE (withheld) + // Dr ORG_PAYABLE (pre-withholding share, netPayoutPaise) + // Cr CASH (what the rail transferred, amountPaise) + // Cr TDS_PAYABLE (withheld, tdsAmountPaise) + // #1470 — this used to debit `netPayoutPaise + tds` and credit CASH + // `netPayoutPaise`. That balances, so the trigger accepted it, but it + // cleared the payable and credited cash by one TDS amount too much on every + // org payout. The identity below is what makes the three legs tie out. const orgTds = payout.tdsAmountPaise ?? 0; + assertOrgPayoutWithholdingIdentity(payout); if (payout.netPayoutPaise > 0) { // #783 — ledger is INR-only; never key accounts by the payout's settlement // currency (would orphan INR paise in a foreign-labelled account). Matches @@ -1137,12 +1232,12 @@ export async function markOrgPayoutCompleted(payoutId: string): Promise<{ organizationId: payout.organizationId, }, direction: "DEBIT", - amountPaise: payout.netPayoutPaise + orgTds, + amountPaise: payout.netPayoutPaise, }, { account: { kind: "CASH" }, direction: "CREDIT", - amountPaise: payout.netPayoutPaise, + amountPaise: payout.amountPaise, }, ]; if (orgTds > 0) { @@ -1193,9 +1288,12 @@ export async function markOrgPayoutCompleted(payoutId: string): Promise<{ const financialYear = getIndianFinancialYear(); const quarter = getIndianFYQuarter(); const { start, end } = getFYDateRange(financialYear); - // The return reports the GROSS amount credited, not the cash that left: - // net + withheld, over every other COMPLETED payout to this org in the FY - // plus this one. + // The return reports the GROSS amount credited, not the cash that left. + // #1470 — `netPayoutPaise` IS that gross figure: it is the org share + // before withholding, and it is the base `computeTdsForPayout` was given. + // Adding `tdsAmountPaise` on top (as this did) counted the withholding + // twice. So: sum of `netPayoutPaise` over every other COMPLETED payout to + // this org in the FY, plus this one's. const priorCompleted = await tx.organizationPayout.aggregate({ where: { organizationId: payout.organizationId, @@ -1203,13 +1301,10 @@ export async function markOrgPayoutCompleted(payoutId: string): Promise<{ processedAt: { gte: start, lt: end }, id: { not: payoutId }, }, - _sum: { netPayoutPaise: true, tdsAmountPaise: true }, + _sum: { netPayoutPaise: true }, }); const cumulativeAmountCredited = - sumPaise(priorCompleted._sum.netPayoutPaise) + - sumPaise(priorCompleted._sum.tdsAmountPaise) + - payout.netPayoutPaise + - orgTds; + sumPaise(priorCompleted._sum.netPayoutPaise) + payout.netPayoutPaise; await recordOrgTDSDeduction({ organizationId: payout.organizationId, @@ -1242,6 +1337,15 @@ export async function markOrgPayoutCompleted(payoutId: string): Promise<{ : null, }; }); + // #1470 — the withholding-identity guard fires INSIDE the transaction so the + // CAS rolls back; the durable report has to happen out here, after the + // connection is free (PG_POOL_MAX=1). + const result = await completion.catch(async (err: unknown) => { + if (err instanceof OrgPayoutWithholdingMismatchError) { + await reportOrgPayoutWithholdingMismatch(err, "markOrgPayoutCompleted"); + } + throw err; + }); if (result.missingTdsRate) { const summary = @@ -1424,7 +1528,7 @@ export async function markOrgPayoutReversed( // claims PROCESSING): the payable stayed cleared, the cash stayed out, the // earnings stayed PAID. We now post a REVERSING ORG_PAYOUT (the exact // inverse of the original), re-open the earnings, and set REVERSED. - const completedResult = await prisma.$transaction(async (tx) => { + const reversalCompletion = prisma.$transaction(async (tx) => { const claim = await tx.organizationPayout.updateMany({ where: { id: payoutId, status: "COMPLETED" }, data: { @@ -1441,6 +1545,9 @@ export async function markOrgPayoutReversed( id: true, organizationId: true, netPayoutPaise: true, + // #1470 — the CASH leg of the original posting was sized from this, so + // the mirror must be too. + amountPaise: true, tdsAmountPaise: true, currency: true, organization: { select: { name: true } }, @@ -1453,12 +1560,17 @@ export async function markOrgPayoutReversed( data: { status: "READY", orgPayoutId: null }, }); - // Reverse the ORG_PAYOUT posting exactly: the original was - // Dr ORG_PAYABLE (net + tds) Cr CASH (net) Cr TDS_PAYABLE (tds) + // Reverse the ORG_PAYOUT posting exactly: the original is + // Dr ORG_PAYABLE (netPayoutPaise) Cr CASH (amountPaise) + // Cr TDS_PAYABLE (tdsAmountPaise) // so the reversal brings the cash back and re-opens the payable. (The TDS // *remittance* to the government is a separate flow; reversing TDS_PAYABLE // here only un-does this payout's accrual, which is correct for a bounce.) + // #1470 — this mirror was written against the old, wrong completion legs, + // which is why a reversal still netted to zero while a payout that stayed + // COMPLETED kept the overstatement. const orgTds = payout.tdsAmountPaise ?? 0; + assertOrgPayoutWithholdingIdentity(payout); if (payout.netPayoutPaise > 0) { // #783 — INR-only ledger; the reversal keys the same INR accounts as the // original posting (which now omits currency) so the pair nets to zero. @@ -1466,7 +1578,7 @@ export async function markOrgPayoutReversed( { account: { kind: "CASH" }, direction: "DEBIT", - amountPaise: payout.netPayoutPaise, + amountPaise: payout.amountPaise, }, { account: { @@ -1474,7 +1586,7 @@ export async function markOrgPayoutReversed( organizationId: payout.organizationId, }, direction: "CREDIT", - amountPaise: payout.netPayoutPaise + orgTds, + amountPaise: payout.netPayoutPaise, }, ]; if (orgTds > 0) { @@ -1529,6 +1641,17 @@ export async function markOrgPayoutReversed( }, }; }); + // #1470 — same shape as markOrgPayoutCompleted: the guard throws inside the + // transaction so the REVERSED claim rolls back, and the durable report runs + // once the single pooled connection is free. + const completedResult = await reversalCompletion.catch( + async (err: unknown) => { + if (err instanceof OrgPayoutWithholdingMismatchError) { + await reportOrgPayoutWithholdingMismatch(err, "markOrgPayoutReversed"); + } + throw err; + }, + ); if (completedResult.claimed) { if (completedResult.notify) { diff --git a/lib/pdf/credit-note-renderer.tsx b/lib/pdf/credit-note-renderer.tsx index a4365ec19..01fac3c74 100644 --- a/lib/pdf/credit-note-renderer.tsx +++ b/lib/pdf/credit-note-renderer.tsx @@ -1,3 +1,4 @@ +/** @jsxImportSource @/lib/pdf/react-runtime */ /** * Credit-note PDF renderer — CGST s.34 `CreditNote` documents (#1230). * @@ -14,7 +15,6 @@ * tax split, and rate of tax. */ -import React from "react"; import { Document, Page, diff --git a/lib/pdf/invoice-renderer.tsx b/lib/pdf/invoice-renderer.tsx index 95401e562..ec5739d44 100644 --- a/lib/pdf/invoice-renderer.tsx +++ b/lib/pdf/invoice-renderer.tsx @@ -1,3 +1,4 @@ +/** @jsxImportSource @/lib/pdf/react-runtime */ /** * Invoice PDF renderer — B2B (`OrganizationInvoice`) PDFs. * @@ -7,20 +8,25 @@ * window. Wired from * `app/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pdf/route.ts`. * - * REACT VERSION + * REACT VERSION (#1468) * ───────────────────────────────────────────────────────────────────────── - * Project React: 18.3.1 (single version in node_modules). + * Two React copies meet in this file and they must be the same one. * `@react-pdf/renderer@4.5.1` → `@react-pdf/reconciler@2.0.0` ships three * reconcilers (reconciler-23 for React ≤18, reconciler-31 for React 19.0/19.1, - * reconciler-33 for React 19.2+) and dispatches by `React.version`. So the - * renderer works whether the route-handler bundle resolves `react` to our - * userland 18.3.1 or Next.js's vendored RSC build — a previous workaround - * that bypassed webpack via `__non_webpack_require__("react")` was - * load-bearing only against an older react-pdf with the single 23-reconciler - * and is no longer needed. Plain JSX is sufficient. + * reconciler-33 for React 19.2+) and dispatches on `React.version`. The + * renderer is externalised, so that dispatch reads the userland React that + * Node resolves — 18.3.1 — and reconciler-23 accepts only elements stamped + * `Symbol.for("react.element")`. Everything else in an App Router route + * handler compiles against Next's vendored React 19, whose elements are + * stamped `Symbol.for("react.transitional.element")`, so plain JSX handed the + * reconciler an object it did not recognise and every statutory PDF answered + * 500 with React error #31 on the deployed build. The `@jsxImportSource` + * pragma above puts element creation back on the reconciler's React; see + * ./react-runtime/jsx-runtime.ts. Note the earlier note in this header — that + * the version dispatch makes either resolution work — was wrong: it makes + * either resolution work only when BOTH sides share it. */ -import React from "react"; import { Document, Page, @@ -82,11 +88,6 @@ function formatDateLong(d: Date | null | undefined): string { }); } -// Suppress unused-import warning for React — it is needed for JSX -// type inference in this file even when no React APIs are referenced -// directly. -void React; - // ============================================================================ // Org (B2B OrganizationInvoice) — types, styles, document // ============================================================================ @@ -237,8 +238,7 @@ const orgStyles = StyleSheet.create({ }); function OrgInvoiceDocument({ data }: { data: OrgInvoicePdfData }) { - const hasTax = - data.igstPaise > 0 || data.cgstPaise > 0 || data.sgstPaise > 0; + const hasTax = data.igstPaise > 0 || data.cgstPaise > 0 || data.sgstPaise > 0; const statusColor: Record = { DRAFT: "#999", ISSUED: "#2563eb", diff --git a/lib/pdf/react-runtime/jsx-dev-runtime.ts b/lib/pdf/react-runtime/jsx-dev-runtime.ts new file mode 100644 index 000000000..c327a962f --- /dev/null +++ b/lib/pdf/react-runtime/jsx-dev-runtime.ts @@ -0,0 +1,15 @@ +/** + * Development twin of ./jsx-runtime — the SWC transform emits `jsxDEV` calls + * against this specifier whenever the build is not a production one. Same + * reason, same resolution. (#1468) + */ +import { nodeRequire } from "./node-require"; + +const runtime = nodeRequire( + "react/jsx-dev-runtime", +) as typeof import("react/jsx-dev-runtime"); + +export const Fragment = runtime.Fragment; +export const jsxDEV = runtime.jsxDEV; + +export type { JSX } from "react/jsx-dev-runtime"; diff --git a/lib/pdf/react-runtime/jsx-runtime.ts b/lib/pdf/react-runtime/jsx-runtime.ts new file mode 100644 index 000000000..aa371f480 --- /dev/null +++ b/lib/pdf/react-runtime/jsx-runtime.ts @@ -0,0 +1,24 @@ +/** + * The JSX runtime the statutory-document components compile against. (#1468) + * + * `@react-pdf/renderer` sits in Next's built-in `serverExternalPackages` list, + * so the deployed function loads it through Node's resolver, and with it + * `@react-pdf/reconciler`, which picks one of three bundled reconcilers by + * reading `React.version`. That lands on this project's userland React. App + * code in the `rsc` layer is compiled against Next's vendored React instead. + * When the two disagree the reconciler does not recognise the elements it is + * handed and the render dies with React error #31. Resolving the runtime the + * same way the reconciler resolves React keeps both sides of that boundary on + * one React. + */ +import { nodeRequire } from "./node-require"; + +const runtime = nodeRequire( + "react/jsx-runtime", +) as typeof import("react/jsx-runtime"); + +export const Fragment = runtime.Fragment; +export const jsx = runtime.jsx; +export const jsxs = runtime.jsxs; + +export type { JSX } from "react/jsx-runtime"; diff --git a/lib/pdf/react-runtime/node-require.ts b/lib/pdf/react-runtime/node-require.ts new file mode 100644 index 000000000..e31e0463a --- /dev/null +++ b/lib/pdf/react-runtime/node-require.ts @@ -0,0 +1,20 @@ +/** + * The `require` that reaches Node's resolver rather than the bundler's. + * + * Webpack rewrites `__non_webpack_require__` to a bare `require` in the + * emitted CommonJS chunk — the same `require` it already uses to pull in the + * packages listed as server externals, so it is Node's own. Outside a webpack + * build (Jest, a `tsx` script) the identifier does not exist and the guard + * falls through to the enclosing module's `require`, which is the same + * resolver again. That is the whole point: one resolver, one React. (#1468) + * + * The fallback deliberately goes through `module.require` rather than a bare + * `require`, which webpack would read as a request it cannot statically + * extract and turn into a critical-dependency warning plus a context module. + */ +declare const __non_webpack_require__: (id: string) => unknown; + +export const nodeRequire: (id: string) => unknown = + typeof __non_webpack_require__ === "function" + ? __non_webpack_require__ + : module.require.bind(module); diff --git a/lib/pdf/statutory-document-frame.tsx b/lib/pdf/statutory-document-frame.tsx index 8e691000b..2dc6a6e69 100644 --- a/lib/pdf/statutory-document-frame.tsx +++ b/lib/pdf/statutory-document-frame.tsx @@ -1,3 +1,4 @@ +/** @jsxImportSource @/lib/pdf/react-runtime */ /** * The shared page furniture for the consumer (B2C) statutory documents. * @@ -17,7 +18,6 @@ import fs from "node:fs"; import path from "node:path"; -import React from "react"; import { View, Text, StyleSheet, Font } from "@react-pdf/renderer"; import type { Currency } from "@prisma/client"; diff --git a/next.config.mjs b/next.config.mjs index 3681b41a6..57069cfe8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -214,15 +214,34 @@ const nextConfig = { // render time through `path.join(process.cwd(), …)` is invisible to it, so // the file would be absent from the deployed function and every Hindi or // Marathi buyer name would render as boxes. Name the routes explicitly. + // + // #1468 — the same blind spot applies to `react/jsx-runtime`. The statutory + // documents create their elements through it deliberately outside the + // bundler (lib/pdf/react-runtime/jsx-runtime.ts), which the tracer cannot + // see, and the only traced import of `react` is the reconciler's, which + // reaches the package root rather than that entrypoint. Ship the package. outputFileTracingIncludes: { - "/api/payments/[paymentId]/invoice/pdf": ["./public/fonts/**"], + "/api/payments/[paymentId]/invoice/pdf": [ + "./public/fonts/**", + "./node_modules/react/**", + ], "/api/payments/[paymentId]/credit-note/[creditNoteId]/pdf": [ "./public/fonts/**", + "./node_modules/react/**", ], + "/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pdf": [ + "./node_modules/react/**", + ], + "/api/organizations/[orgId]/billing-account/credit-notes/[creditNoteId]/pdf": + ["./node_modules/react/**"], }, // Prevent pg (node-postgres) and related packages from being bundled into client-side code - // These are server-only dependencies used by @prisma/adapter-pg + // These are server-only dependencies used by @prisma/adapter-pg. + // + // `@react-pdf/renderer` is also in Next's own built-in external list, so + // listing it here changes nothing — it is external either way, and that is + // what forces lib/pdf to resolve its JSX runtime past the bundler (#1468). serverExternalPackages: [ "pg", "@prisma/adapter-pg", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6d0dc5fb5..2da09e5fa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2013,6 +2013,9 @@ model OrganizationPayout { organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict) + /// #1470 — POST-withholding: the sum the payout rail actually transfers to + /// the host org, i.e. `netPayoutPaise - tdsAmountPaise`. The ORG_PAYOUT + /// journal credits CASH at this figure. amountPaise BigInt currency Currency @default(INR) status PayoutStatus @@ -2024,6 +2027,10 @@ model OrganizationPayout { grossRevenuePaise BigInt platformFeePaise BigInt refundsPaise BigInt @default(0) + /// #1470 — PRE-withholding: the host org's share net of platform fee and + /// refunds, which is also the base TDS is computed on. The ORG_PAYOUT + /// journal debits ORG_PAYABLE at this figure, so + /// `amountPaise + tdsAmountPaise` must always equal it. netPayoutPaise BigInt // India statutory (fields final; cron + derivation stubbed in v1) diff --git a/scripts/earnings/release-earnings.ts b/scripts/earnings/release-earnings.ts index 539bec18c..91850e3d5 100644 --- a/scripts/earnings/release-earnings.ts +++ b/scripts/earnings/release-earnings.ts @@ -3,8 +3,15 @@ /** * Release Earnings Script * - * Releases consultant earnings from hold period to READY status. - * Earnings are held for a period after payment to handle refunds/disputes. + * Releases both consultant AND host-organization earnings from their hold + * period to READY status. Earnings are held for a period after payment so a + * refund or dispute lands before the money is payable. + * + * #1471 — the organization arm used to be missing here, and because every + * scheduled entry point (the GitHub Actions job, the cleanup HTTP twin, the + * admin system-jobs runner) imports THIS module, `OrganizationEarnings` rows + * never left PENDING and a host org's retained share could never be picked up + * by `createOrgPayoutBatch`, which only selects READY rows. * * This module exports functions that can be used by: * - Local development: `npm run scripts:release-earnings` @@ -24,14 +31,26 @@ import { withCronLock } from "@/lib/cron/with-cron-lock"; */ export interface ReleaseResult { success: boolean; + /** Consultant earnings moved PENDING → READY. Unchanged in meaning (#1471). */ releasedCount: number; + /** + * #1471 — host-organization earnings moved PENDING → READY. A separate + * field rather than a widened `releasedCount`, so every existing consumer + * (GitHub Actions outputs, the cleanup summary, the admin runner) keeps + * reporting the number it always reported. + */ + organizationEarningsReleased: number; errorCount: number; errors: string[]; } export interface ReleaseEarningsOptions { /** #1356 — caps the batch for the Netlify ticker; undefined releases the - * whole PENDING/past-hold set, as today. */ + * whole PENDING/past-hold set, as today. + * + * #1471 — the cap applies to EACH table independently, matching the #1390 + * decision for the ticker: a run bounded at 200 may release up to 200 + * consultant rows and up to 200 organization rows. */ limit?: number; } @@ -64,6 +83,7 @@ async function releaseEarningsFromHoldUnlocked( const result: ReleaseResult = { success: false, releasedCount: 0, + organizationEarningsReleased: 0, errorCount: 0, errors: [], }; @@ -121,21 +141,71 @@ async function releaseEarningsFromHoldUnlocked( ); console.log( - `📊 Found ${earningsToRelease.length} earnings ready for release`, + `📊 Found ${earningsToRelease.length} consultant earnings ready for release`, ); + result.releasedCount = releasedCount; - if (earningsToRelease.length === 0) { - console.log("✅ No earnings to release at this time"); - result.success = true; - return result; + for (const earning of earningsToRelease) { + console.log( + `✅ Released consultant earning ${earning.id}: ₹${(earning.consultantSharePaise / 100).toFixed(2)} for ${earning.consultantProfile.user.name || "Unknown"}`, + ); } - result.releasedCount = releasedCount; + // #1471 — the host-organization arm, deliberately a SEPARATE Serializable + // transaction rather than a widened one. The two tables share nothing but + // the predicate, and a serialization conflict on one should not throw away + // a release the other already claimed. The `limit` is applied again here so + // each table gets its own full budget (#1390). + const { orgEarningsToRelease, orgReleasedCount } = + await prisma.$transaction( + async (tx) => { + const rows = await tx.organizationEarnings.findMany({ + where: { + status: EarningStatus.PENDING, + holdUntil: { lte: now }, + }, + select: { + id: true, + orgSharePaise: true, + organization: { select: { name: true } }, + }, + orderBy: { holdUntil: "asc" }, + take: opts.limit, + }); - // Log details for each released earning - for (const earning of earningsToRelease) { + // CAS-in-WHERE: `status: PENDING` is re-stated on the claim so a row + // another writer moved (a dispute freeze, a refund cascade) between + // the read and the update is skipped rather than dragged to READY. + const updated = await tx.organizationEarnings.updateMany({ + where: { + id: { in: rows.map((r) => r.id) }, + status: EarningStatus.PENDING, + }, + data: { + status: EarningStatus.READY, + }, + }); + + return { + orgEarningsToRelease: rows, + orgReleasedCount: updated.count, + }; + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + maxWait: 10_000, + timeout: 15_000, + }, + ); + + console.log( + `📊 Found ${orgEarningsToRelease.length} organization earnings ready for release`, + ); + result.organizationEarningsReleased = orgReleasedCount; + + for (const earning of orgEarningsToRelease) { console.log( - `✅ Released earning ${earning.id}: ₹${(earning.consultantSharePaise / 100).toFixed(2)} for ${earning.consultantProfile.user.name || "Unknown"}`, + `✅ Released organization earning ${earning.id}: ₹${(earning.orgSharePaise / 100).toFixed(2)} for ${earning.organization.name}`, ); } @@ -143,9 +213,15 @@ async function releaseEarningsFromHoldUnlocked( // Summary console.log(`\n📈 Release Summary:`); - console.log(` ✅ Released: ${result.releasedCount} earnings`); + console.log(` ✅ Released: ${result.releasedCount} consultant earnings`); + console.log( + ` 💰 Consultant total: ₹${(earningsToRelease.reduce((sum, e) => sum + e.consultantSharePaise, 0) / 100).toFixed(2)}`, + ); + console.log( + ` ✅ Released: ${result.organizationEarningsReleased} organization earnings`, + ); console.log( - ` 💰 Total amount: ₹${(earningsToRelease.reduce((sum, e) => sum + e.consultantSharePaise, 0) / 100).toFixed(2)}`, + ` 💰 Organization total: ₹${(orgEarningsToRelease.reduce((sum, e) => sum + e.orgSharePaise, 0) / 100).toFixed(2)}`, ); } catch (error) { const errorMessage = diff --git a/scripts/reconcile/reconcile-ledgers.ts b/scripts/reconcile/reconcile-ledgers.ts index 80c4dab7a..3f71e92c2 100644 --- a/scripts/reconcile/reconcile-ledgers.ts +++ b/scripts/reconcile/reconcile-ledgers.ts @@ -43,12 +43,15 @@ * org-license-covered bookings. The hot checkout path log-warns * on mismatch; this invariant is the retroactive detector.) * - * G. For every OrganizationPayout: + * G. For every OrganizationPayout in PENDING / APPROVED / PROCESSING / + * COMPLETED: * sum(OrganizationEarnings.orgSharePaise - .refundedAmountPaise) * for batched earnings === OrganizationPayout.netPayoutPaise * (drift here means the batch claim updated earnings but didn't * match the payout totals — investigate the - * createOrgPayoutBatch tx history) + * createOrgPayoutBatch tx history. #1471: FAILED / REVERSED / + * CANCELLED payouts are skipped because they deliberately detach + * their earnings back to READY.) * * Note: as of A3 (per-collaborator HOST-org settlement) one Payment * can carry N OrganizationEarnings rows — one per (paymentId, orgId) @@ -615,10 +618,26 @@ async function runReconcileLedgersUnlocked( // and writes them to the payout in one go. If anything ever diverges // (manual SQL, partial migration, future code changes) this catches // the drift before the next bank transfer is initiated. + // + // #1471 review — scoped to the statuses where the attachment is EXPECTED to + // hold. A FAILED payout (markOrgPayoutFailedInternal) and a REVERSED one + // (markOrgPayoutReversed) both detach their earnings back to READY with + // `orgPayoutId: null` on purpose, so they legitimately end up with zero + // attached earnings against a retained `netPayoutPaise` — every one of them + // was being reported as drift. CANCELLED is excluded for the same reason. + // APPROVED is included with PENDING/PROCESSING/COMPLETED because the batch is + // still live and its earnings are still claimed. + const ATTACHMENT_EXPECTED_STATUSES = [ + "PENDING", + "APPROVED", + "PROCESSING", + "COMPLETED", + ] as const; const payouts = await prisma.organizationPayout.findMany({ - where: opts.organizationId - ? { organizationId: opts.organizationId } - : undefined, + where: { + status: { in: [...ATTACHMENT_EXPECTED_STATUSES] }, + ...(opts.organizationId ? { organizationId: opts.organizationId } : {}), + }, select: { id: true, organizationId: true, @@ -643,7 +662,7 @@ async function runReconcileLedgersUnlocked( deltaPaise: p.netPayoutPaise - expected, details: { earningsCount: p.earnings.length, - note: "OrganizationPayout.netPayoutPaise diverges from sum(orgShare - refunds) of attached earnings.", + note: "OrganizationPayout.netPayoutPaise diverges from sum(orgShare - refunds) of attached earnings. Only PENDING/APPROVED/PROCESSING/COMPLETED payouts are checked: FAILED, REVERSED and CANCELLED payouts release their earnings back to READY with orgPayoutId cleared, so a zero-earnings total on those is the designed outcome, not drift (#1471).", }, }); }