diff --git a/.claude/skills/booking-doctrine/SKILL.md b/.claude/skills/booking-doctrine/SKILL.md index 4d6e11a40..4cc7bddbb 100644 --- a/.claude/skills/booking-doctrine/SKILL.md +++ b/.claude/skills/booking-doctrine/SKILL.md @@ -50,7 +50,14 @@ edge, and narrowing is safer: automated completion from a Stream webhook passes UNVERIFIED. As of wave 5 (#1322, ADR A12) each helper also appends one `BookingStatusHistory` row in the same transaction, reading the from-status before the CAS, so a lost race logs a stale from-status but never a wrong -state. +state. Wave 6 (#1333) widened that same pre-read to fetch the owning +appointment, so `appointmentId` is stamped without a caller supplying it, and +added `appendCreationHistory` — the one row that is not a transition, written +from the literal `"CREATED"` in the same transaction as the create, because a +booking that has never moved still needs a timeline. The three creation call +sites are `app/api/slots/request-for-approval` and the consultation and +subscription checkout handlers; the capture webhook's legacy creators do not +write it yet. ## 2. Nothing that a Payment points at is ever deleted diff --git a/.claude/skills/razorpay/references/refunds.md b/.claude/skills/razorpay/references/refunds.md index 0912ffe54..2b00dab07 100644 --- a/.claude/skills/razorpay/references/refunds.md +++ b/.claude/skills/razorpay/references/refunds.md @@ -75,11 +75,15 @@ idempotencyKey: reserved.id `createRazorpayRefund` sends the header only when the caller supplies a key. No key is safer than a guessed one. -Two responses to expect. A **409** means a request with the same key is still in flight — -it is retryable, and `postRefund` retries once before giving up to the reconcile cron with -`REFUND_IN_FLIGHT`. The same key with a *different* payload returns `BAD_REQUEST`. The -`receipt` field also acts as a secondary idempotency key ("Duplicate receipt found for -this refund request"). +Two responses to expect, and Razorpay answers both of them with a **409**. When another +request carrying the same key is still in flight, the description reads "still in +progress" and the conflict is retryable: `postRefund` retries once before giving up to the +reconcile cron with `REFUND_IN_FLIGHT`. When the same key is replayed with a *different* +payload, the description reads "Different request with the same idempotency key has +already been processed" and no amount of retrying will change the answer, so `postRefund` +throws it immediately as `REFUND_IDEMPOTENCY_KEY_REUSED` — a key collision is our bug, not +the gateway's. The `receipt` field also acts as a secondary idempotency key ("Duplicate +receipt found for this refund request"). Sources: · · diff --git a/.env.sample b/.env.sample index 1b24f4e61..13c1f52b1 100644 --- a/.env.sample +++ b/.env.sample @@ -60,6 +60,13 @@ NEXT_PUBLIC_RAZORPAY_KEY_ID="" RAZORPAY_SECRET="" # Webhook signing secret from the Razorpay dashboard (Settings > Webhooks) RAZORPAY_WEBHOOK_SECRET="" +# The previous webhook signing secret, kept only for the window in which a +# rotated secret is being rolled out: the route (#1451) accepts a signature +# made with either value, so deliveries already in flight under the old secret +# are still verified instead of being rejected as forgeries. Leave it empty +# outside a rotation, and delete the old value once the dashboard has been +# switched over and no unverified deliveries remain. +RAZORPAY_WEBHOOK_SECRET_PREVIOUS="" # #677 PM-1 — RazorpayX PRODUCTION payout credentials (live disbursements + # payout webhook verification). Falls back to RAZORPAY_KEY_ID/RAZORPAY_SECRET @@ -81,6 +88,18 @@ STRIPE_SECRET_KEY="" NEXT_PUBLIC_STRIPE_KEY="" STRIPE_WEBHOOK_SECRET="" +# #1351 — Stripe is a fenced contingency rail, not a live payment method: +# Razorpay takes every collection and Dodo Payments is the post-MVP +# international gateway. All three below are OPTIONAL and unset means "off". +# STRIPE_ENABLED gates the server (assertGatewayUsable); the NEXT_PUBLIC_ twin +# only gates the checkout button and is inlined at build time, so turning +# Stripe on needs both plus a redeploy. STRIPE_ALLOW_TEST_KEYS_IN_PRODUCTION +# downgrades the sk_test_-under-NODE_ENV=production throw to a loud log for the +# pre-launch window; delete it with the first live key. +STRIPE_ENABLED="" +NEXT_PUBLIC_STRIPE_ENABLED="" +STRIPE_ALLOW_TEST_KEYS_IN_PRODUCTION="" + # Stream API keys for real-time features NEXT_PUBLIC_STREAM_API_KEY="" STREAM_API_KEY="" @@ -151,6 +170,14 @@ ORG_PAYOUT_ENCRYPTION_KEY="" # address or the GSTIN's registered state changes; CA sign-off recommended. SUPPLIER_STATE_CODE="KA" +# GST supplier identity for B2C tax invoices (ADR 26: the platform bills as +# principal). Leave empty until the real GSTIN is issued: mintConsumerInvoice +# then no-ops with a warning and the outward-register job heals the gap later. +PLATFORM_GSTIN="" +# Series prefix for consumer invoice / credit-note numbers (FAM--, +# FAM-CN--). Rule 46 caps the whole number at 16 characters. +PLATFORM_INVOICE_PREFIX="FAM" + # Enterprise — consolidated-invoice rollup cron flag. # When "true", the monthly consolidated-invoice cron rolls up children's # unpaid OrganizationInvoice rows into a single parent-org invoice on the @@ -177,3 +204,11 @@ BETTERSTACK_INGEST_URL="" # are NOT the production payout creds and do NOT flip ENABLE_LIVE_PAYOUTS. RAZORPAYX_SANDBOX_KEY="" RAZORPAYX_SANDBOX_SECRET="" + +# Cron ticker (ADR 27). netlify/functions/cron-tick.mts POSTs the sub-hourly +# money sweeps under app/api/cleanup/* every 5 minutes with this bearer token; +# unset means the ticker logs and exits, and the routes reject every caller. +# Generate with: openssl rand -hex 32. Netlify production context only. +CRON_SECRET="" +# Optional: where the ticker POSTs. Defaults to the deploy URL Netlify sets. +CRON_TICK_BASE_URL="" diff --git a/.github/workflows/advance-program-cycles.yml b/.github/workflows/advance-program-cycles.yml index e2ae3fe7c..91ad9a1fe 100644 --- a/.github/workflows/advance-program-cycles.yml +++ b/.github/workflows/advance-program-cycles.yml @@ -8,6 +8,10 @@ on: - cron: "15 2 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: advance-program-cycles: runs-on: ubuntu-latest diff --git a/.github/workflows/alert-dispute-deadlines.yml b/.github/workflows/alert-dispute-deadlines.yml index e4e1e06e2..9ff091853 100644 --- a/.github/workflows/alert-dispute-deadlines.yml +++ b/.github/workflows/alert-dispute-deadlines.yml @@ -6,6 +6,10 @@ on: - cron: "2 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: alert-dispute-deadlines: runs-on: ubuntu-latest diff --git a/.github/workflows/alert-orphaned-payments.yml b/.github/workflows/alert-orphaned-payments.yml index 6e2b2b86f..703f0a907 100644 --- a/.github/workflows/alert-orphaned-payments.yml +++ b/.github/workflows/alert-orphaned-payments.yml @@ -6,6 +6,10 @@ on: - cron: "30 */6 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: alert-orphaned-payments: runs-on: ubuntu-latest diff --git a/.github/workflows/archive-webhook-events.yml b/.github/workflows/archive-webhook-events.yml index a0485e4a8..23022a3a8 100644 --- a/.github/workflows/archive-webhook-events.yml +++ b/.github/workflows/archive-webhook-events.yml @@ -6,6 +6,10 @@ on: - cron: "25 0 * * 0" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: archive-webhook-events: runs-on: ubuntu-latest diff --git a/.github/workflows/auto-complete-appointments.yml b/.github/workflows/auto-complete-appointments.yml index 5fb808c71..291f30655 100644 --- a/.github/workflows/auto-complete-appointments.yml +++ b/.github/workflows/auto-complete-appointments.yml @@ -9,6 +9,10 @@ on: # cron-runtime-minutes: 6 workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: auto-complete-appointments: runs-on: ubuntu-latest diff --git a/.github/workflows/auto-renew-contracts.yml b/.github/workflows/auto-renew-contracts.yml index a484fc956..294946e28 100644 --- a/.github/workflows/auto-renew-contracts.yml +++ b/.github/workflows/auto-renew-contracts.yml @@ -8,6 +8,10 @@ on: - cron: "30 2 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: auto-renew-contracts: runs-on: ubuntu-latest diff --git a/.github/workflows/cascade-refund-earnings.yml b/.github/workflows/cascade-refund-earnings.yml index b51443968..08731c3dd 100644 --- a/.github/workflows/cascade-refund-earnings.yml +++ b/.github/workflows/cascade-refund-earnings.yml @@ -3,9 +3,14 @@ name: Cascade Refund to Earnings on: schedule: # Run every 15 minutes + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "1-59/15 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: cascade-refund-earnings: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-abandoned-org-top-ups.yml b/.github/workflows/cleanup-abandoned-org-top-ups.yml index e5c6db92d..c3115c9dd 100644 --- a/.github/workflows/cleanup-abandoned-org-top-ups.yml +++ b/.github/workflows/cleanup-abandoned-org-top-ups.yml @@ -7,6 +7,10 @@ on: - cron: "0 2 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: cleanup-abandoned-org-top-ups: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-abandoned-payments.yml b/.github/workflows/cleanup-abandoned-payments.yml index 43a4002fb..4ef139cde 100644 --- a/.github/workflows/cleanup-abandoned-payments.yml +++ b/.github/workflows/cleanup-abandoned-payments.yml @@ -3,9 +3,14 @@ name: Cleanup Abandoned Payments on: schedule: # Run every 15 minutes + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "6-59/15 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: cleanup-abandoned-payments: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-auth-tokens.yml b/.github/workflows/cleanup-auth-tokens.yml index 736e1ca90..2bd1514ca 100644 --- a/.github/workflows/cleanup-auth-tokens.yml +++ b/.github/workflows/cleanup-auth-tokens.yml @@ -6,6 +6,14 @@ on: - cron: "3 0 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: cleanup-auth-tokens: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-empty-folders.yml b/.github/workflows/cleanup-empty-folders.yml index e2addacc2..13daffd47 100644 --- a/.github/workflows/cleanup-empty-folders.yml +++ b/.github/workflows/cleanup-empty-folders.yml @@ -14,6 +14,14 @@ on: - cron: "30 3 * * *" workflow_dispatch: # Allows manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: cleanup: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-invalid-appointments.yml b/.github/workflows/cleanup-invalid-appointments.yml index 9ede3bb5f..d7c22f22a 100644 --- a/.github/workflows/cleanup-invalid-appointments.yml +++ b/.github/workflows/cleanup-invalid-appointments.yml @@ -9,6 +9,14 @@ on: # cron-runtime-minutes: 5 workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: cleanup-invalid-appointments: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-old-stream-recordings.yml b/.github/workflows/cleanup-old-stream-recordings.yml index 6e452fa41..cee292304 100644 --- a/.github/workflows/cleanup-old-stream-recordings.yml +++ b/.github/workflows/cleanup-old-stream-recordings.yml @@ -7,6 +7,14 @@ on: - cron: "0 3 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: cleanup-old-stream-recordings: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-stale-invitations.yml b/.github/workflows/cleanup-stale-invitations.yml index 78fbabb69..bfe2d05a1 100644 --- a/.github/workflows/cleanup-stale-invitations.yml +++ b/.github/workflows/cleanup-stale-invitations.yml @@ -10,6 +10,14 @@ on: - cron: "40 2 * * *" workflow_dispatch: # allow manual trigger from the Actions tab +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: cleanup-stale-invitations: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-stale-pending-consultations.yml b/.github/workflows/cleanup-stale-pending-consultations.yml index ad93485c3..67c2d6f5a 100644 --- a/.github/workflows/cleanup-stale-pending-consultations.yml +++ b/.github/workflows/cleanup-stale-pending-consultations.yml @@ -6,6 +6,10 @@ on: - cron: "37 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: cleanup-stale-pending-consultations: runs-on: ubuntu-latest diff --git a/.github/workflows/cleanup-tentative-slots.yml b/.github/workflows/cleanup-tentative-slots.yml index 5c6d7d671..9c1ebeabe 100644 --- a/.github/workflows/cleanup-tentative-slots.yml +++ b/.github/workflows/cleanup-tentative-slots.yml @@ -9,6 +9,10 @@ on: # cron-runtime-minutes: 5 workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: cleanup-tentative-slots: runs-on: ubuntu-latest diff --git a/.github/workflows/consent-retention-sweeper.yml b/.github/workflows/consent-retention-sweeper.yml index c15d5df6c..6966b9eb9 100644 --- a/.github/workflows/consent-retention-sweeper.yml +++ b/.github/workflows/consent-retention-sweeper.yml @@ -12,6 +12,10 @@ on: - cron: "0 21 * * 0" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: consent-retention-sweeper: runs-on: ubuntu-latest diff --git a/.github/workflows/cron-heartbeat.yml b/.github/workflows/cron-heartbeat.yml index a4508a568..441e8d09c 100644 --- a/.github/workflows/cron-heartbeat.yml +++ b/.github/workflows/cron-heartbeat.yml @@ -17,6 +17,10 @@ on: - cron: "40 4 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # Read-only access to the Actions API; no repo write is needed or wanted. permissions: contents: read diff --git a/.github/workflows/databreach-deadline-alerts.yml b/.github/workflows/databreach-deadline-alerts.yml index e358c6d6b..b0a63d000 100644 --- a/.github/workflows/databreach-deadline-alerts.yml +++ b/.github/workflows/databreach-deadline-alerts.yml @@ -8,6 +8,10 @@ on: - cron: "27 * * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: databreach-deadline-alerts: runs-on: ubuntu-latest diff --git a/.github/workflows/deactivate-expired-discounts.yml b/.github/workflows/deactivate-expired-discounts.yml index 853f49892..a09da097e 100644 --- a/.github/workflows/deactivate-expired-discounts.yml +++ b/.github/workflows/deactivate-expired-discounts.yml @@ -6,6 +6,10 @@ on: - cron: "15 0 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: deactivate-expired-discounts: runs-on: ubuntu-latest diff --git a/.github/workflows/detect-consultant-no-shows.yml b/.github/workflows/detect-consultant-no-shows.yml index a12257f5e..0951490be 100644 --- a/.github/workflows/detect-consultant-no-shows.yml +++ b/.github/workflows/detect-consultant-no-shows.yml @@ -6,6 +6,10 @@ on: - cron: "57 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: detect-consultant-no-shows: runs-on: ubuntu-latest diff --git a/.github/workflows/dispatch-outbound-webhooks.yml b/.github/workflows/dispatch-outbound-webhooks.yml index 3b58d6811..1b13bb397 100644 --- a/.github/workflows/dispatch-outbound-webhooks.yml +++ b/.github/workflows/dispatch-outbound-webhooks.yml @@ -4,9 +4,14 @@ on: schedule: # Every minute. The worker is idempotent — a tick that overlaps an # in-flight tick observes IN_FLIGHT rows and skips them. + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "* * * * *" workflow_dispatch: # Allow manual triggering for debugging +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: dispatch-outbound-webhooks: runs-on: ubuntu-latest diff --git a/.github/workflows/dunning.yml b/.github/workflows/dunning.yml index 52235bbc5..238f35d14 100644 --- a/.github/workflows/dunning.yml +++ b/.github/workflows/dunning.yml @@ -11,6 +11,10 @@ on: - cron: "30 23 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: dunning: runs-on: ubuntu-latest diff --git a/.github/workflows/expire-contracts.yml b/.github/workflows/expire-contracts.yml index e012d2c97..eaff04e4e 100644 --- a/.github/workflows/expire-contracts.yml +++ b/.github/workflows/expire-contracts.yml @@ -8,6 +8,10 @@ on: - cron: "10 3 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: expire-contracts: runs-on: ubuntu-latest diff --git a/.github/workflows/expire-stale-requests.yml b/.github/workflows/expire-stale-requests.yml index f2142ea8c..583c63df5 100644 --- a/.github/workflows/expire-stale-requests.yml +++ b/.github/workflows/expire-stale-requests.yml @@ -9,6 +9,10 @@ on: - cron: "10 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: expire-stale-requests: runs-on: ubuntu-latest diff --git a/.github/workflows/expire-unpaid-trials.yml b/.github/workflows/expire-unpaid-trials.yml index f212e41a4..2942c8d12 100644 --- a/.github/workflows/expire-unpaid-trials.yml +++ b/.github/workflows/expire-unpaid-trials.yml @@ -12,6 +12,10 @@ on: - cron: "40 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: expire-unpaid-trials: runs-on: ubuntu-latest diff --git a/.github/workflows/gst-outward-register-export.yml b/.github/workflows/gst-outward-register-export.yml new file mode 100644 index 000000000..58c02116a --- /dev/null +++ b/.github/workflows/gst-outward-register-export.yml @@ -0,0 +1,104 @@ +name: GST Outward-Supplies Register Export + +on: + schedule: + # 01:40 UTC = 07:10 IST on the 3rd of each month — after the month has + # closed in IST and well before the 11th GSTR-1 deadline, on a minute no + # other workflow starts on. + # cron-runtime-minutes: 4 + - cron: "40 1 3 * *" + workflow_dispatch: + inputs: + period_start: + description: "Period start (YYYY-MM-DD, inclusive). Leave blank for the previous IST calendar month." + required: false + type: string + period_end: + description: "Period end (YYYY-MM-DD, exclusive). Must be set together with period_start." + required: false + type: string + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +# The job reads the repository and nothing else; the credentials it needs are +# the database, Redis and GST ones below, not the Actions token. +permissions: + contents: read + +jobs: + gst-outward-register-export: + runs-on: ubuntu-latest + timeout-minutes: 20 + + env: + # #1066 — jobs run as bare Node processes, so Sentry.init only sees + # what the step env carries. + NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN || secrets.SENTRY_DSN }} + NEXT_PUBLIC_SENTRY_ENVIRONMENT: production + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 cron locks load lib/redis at import — every job entry needs these + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + # Statutory supplier identity. Without PLATFORM_GSTIN the healer mints + # nothing (fail-closed) and the register only reports what already exists. + PLATFORM_GSTIN: ${{ secrets.PLATFORM_GSTIN }} + SUPPLIER_STATE_CODE: ${{ secrets.SUPPLIER_STATE_CODE }} + PLATFORM_INVOICE_PREFIX: ${{ secrets.PLATFORM_INVOICE_PREFIX }} + GST_REGISTER_CSV_OUT: gst-outward-register.csv + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + # The Actions token is never used after checkout, and leaving it in + # .git/config lets any later step — or an uploaded artifact — carry it. + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies without lifecycle scripts + # S6505 — no arbitrary lifecycle scripts during install; the one + # artifact generation we need (Prisma client) is invoked explicitly + # from our pinned devDependency right after. + run: npm ci --ignore-scripts + + - name: Generate Prisma client + run: npx --no-install prisma generate + + - name: Export the outward-supplies register + env: + # inputs.* is read through the step env so the expression is never + # interpolated straight into a shell command (S7630). + GST_REGISTER_PERIOD_START: ${{ inputs.period_start }} + GST_REGISTER_PERIOD_END: ${{ inputs.period_end }} + # S8543/S6505 — run the locally-installed binary directly; no + # on-demand package resolution at execution time. + run: node_modules/.bin/tsx jobs/compliance/gst-outward-register-export.ts + + - name: Upload the register CSV + if: always() + uses: actions/upload-artifact@v4 + with: + name: gst-outward-register + path: gst-outward-register.csv + # The CSV carries GSTINs, document numbers and amounts — no PAN, no + # bank detail, no buyer address. Ninety days covers the filing plus + # one amendment cycle. + retention-days: 90 + if-no-files-found: warn + + - name: Notify on failure + if: failure() + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + # Fallback sink — Slack has never been provisioned, so this is + # what actually pages today. See 07-required-secrets.md. + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + run: bash scripts/ci/notify-ops-failure.sh "gst-outward-register-export" diff --git a/.github/workflows/handle-lost-disputes.yml b/.github/workflows/handle-lost-disputes.yml index 753fa5512..9b240a197 100644 --- a/.github/workflows/handle-lost-disputes.yml +++ b/.github/workflows/handle-lost-disputes.yml @@ -6,6 +6,10 @@ on: - cron: "8 */6 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: handle-lost-disputes: runs-on: ubuntu-latest diff --git a/.github/workflows/handle-stuck-payouts.yml b/.github/workflows/handle-stuck-payouts.yml index f1af4ffd7..df3de264d 100644 --- a/.github/workflows/handle-stuck-payouts.yml +++ b/.github/workflows/handle-stuck-payouts.yml @@ -6,6 +6,10 @@ on: - cron: "52 */4 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: handle-stuck-payouts: runs-on: ubuntu-latest diff --git a/.github/workflows/irp-uploader.yml b/.github/workflows/irp-uploader.yml index eef45f7a3..2b8acc330 100644 --- a/.github/workflows/irp-uploader.yml +++ b/.github/workflows/irp-uploader.yml @@ -8,6 +8,10 @@ on: - cron: "50 2 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: irp-uploader: runs-on: ubuntu-latest diff --git a/.github/workflows/mark-expired-recordings.yml b/.github/workflows/mark-expired-recordings.yml index 507f2882a..9a5febfbe 100644 --- a/.github/workflows/mark-expired-recordings.yml +++ b/.github/workflows/mark-expired-recordings.yml @@ -6,6 +6,10 @@ on: - cron: "20 3 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: mark-expired-recordings: runs-on: ubuntu-latest diff --git a/.github/workflows/msme-payment-alerts.yml b/.github/workflows/msme-payment-alerts.yml index 1fe44c0d8..46f2dfdad 100644 --- a/.github/workflows/msme-payment-alerts.yml +++ b/.github/workflows/msme-payment-alerts.yml @@ -8,6 +8,10 @@ on: - cron: "30 4 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: msme-payment-alerts: runs-on: ubuntu-latest diff --git a/.github/workflows/process-data-exports.yml b/.github/workflows/process-data-exports.yml index 988c29427..5405ed5eb 100644 --- a/.github/workflows/process-data-exports.yml +++ b/.github/workflows/process-data-exports.yml @@ -5,9 +5,14 @@ on: # Every 10 minutes. Exports are async, low volume, but the # requester is waiting on an email so latency budget is single- # digit minutes. + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "9-59/10 * * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: process-data-exports: runs-on: ubuntu-latest diff --git a/.github/workflows/prune-audit-logs.yml b/.github/workflows/prune-audit-logs.yml index f8e6799d3..7a5547bec 100644 --- a/.github/workflows/prune-audit-logs.yml +++ b/.github/workflows/prune-audit-logs.yml @@ -6,6 +6,10 @@ on: - cron: "15 3 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: prune-audit-logs: runs-on: ubuntu-latest diff --git a/.github/workflows/prune-system-events.yml b/.github/workflows/prune-system-events.yml index abd363d60..452629302 100644 --- a/.github/workflows/prune-system-events.yml +++ b/.github/workflows/prune-system-events.yml @@ -6,6 +6,10 @@ on: - cron: "5 3 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: prune-system-events: runs-on: ubuntu-latest diff --git a/.github/workflows/prune-system-job-executions.yml b/.github/workflows/prune-system-job-executions.yml index 80826bd47..cd31fc025 100644 --- a/.github/workflows/prune-system-job-executions.yml +++ b/.github/workflows/prune-system-job-executions.yml @@ -6,6 +6,10 @@ on: - cron: "26 3 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # The job reads the repository and nothing else; the credentials it needs are # the database and Redis ones below, not the Actions token. permissions: diff --git a/.github/workflows/purge-deleted-documents.yml b/.github/workflows/purge-deleted-documents.yml index bb61eda86..81199e1be 100644 --- a/.github/workflows/purge-deleted-documents.yml +++ b/.github/workflows/purge-deleted-documents.yml @@ -7,6 +7,10 @@ on: - cron: "34 3 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + permissions: contents: read diff --git a/.github/workflows/reconcile-disputes.yml b/.github/workflows/reconcile-disputes.yml index 1b96abb2e..39e13f27b 100644 --- a/.github/workflows/reconcile-disputes.yml +++ b/.github/workflows/reconcile-disputes.yml @@ -6,6 +6,10 @@ on: - cron: "28 */6 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: reconcile-disputes: runs-on: ubuntu-latest diff --git a/.github/workflows/reconcile-document-storage.yml b/.github/workflows/reconcile-document-storage.yml index 6e0fe6f8e..2a9b9732f 100644 --- a/.github/workflows/reconcile-document-storage.yml +++ b/.github/workflows/reconcile-document-storage.yml @@ -6,6 +6,10 @@ on: - cron: "35 2 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: reconcile-document-storage: runs-on: ubuntu-latest diff --git a/.github/workflows/reconcile-ledgers.yml b/.github/workflows/reconcile-ledgers.yml index 9639eb9fe..48a588c07 100644 --- a/.github/workflows/reconcile-ledgers.yml +++ b/.github/workflows/reconcile-ledgers.yml @@ -13,6 +13,10 @@ on: - cron: "45 3 * * *" workflow_dispatch: # Allow manual triggering from the Actions UI. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: reconcile-ledgers: runs-on: ubuntu-latest diff --git a/.github/workflows/reconcile-orphaned-confirmations.yml b/.github/workflows/reconcile-orphaned-confirmations.yml index 054d8d0df..213b8a934 100644 --- a/.github/workflows/reconcile-orphaned-confirmations.yml +++ b/.github/workflows/reconcile-orphaned-confirmations.yml @@ -3,9 +3,14 @@ name: Reconcile Orphaned Confirmations on: schedule: # Run every 30 minutes + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "13-59/30 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: reconcile-orphaned-confirmations: runs-on: ubuntu-latest diff --git a/.github/workflows/reconcile-payment-status.yml b/.github/workflows/reconcile-payment-status.yml index a1729ca1e..e8049d06f 100644 --- a/.github/workflows/reconcile-payment-status.yml +++ b/.github/workflows/reconcile-payment-status.yml @@ -3,9 +3,14 @@ name: Reconcile Payment Status on: schedule: # Run every 30 minutes + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "18-59/30 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: reconcile-payment-status: runs-on: ubuntu-latest diff --git a/.github/workflows/reconcile-payout-status.yml b/.github/workflows/reconcile-payout-status.yml index 959759ba8..959c8f8b5 100644 --- a/.github/workflows/reconcile-payout-status.yml +++ b/.github/workflows/reconcile-payout-status.yml @@ -6,6 +6,10 @@ on: - cron: "33 */6 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: reconcile-payout-status: runs-on: ubuntu-latest diff --git a/.github/workflows/reconcile-pending-refunds.yml b/.github/workflows/reconcile-pending-refunds.yml index 410267adc..5b76b49bf 100644 --- a/.github/workflows/reconcile-pending-refunds.yml +++ b/.github/workflows/reconcile-pending-refunds.yml @@ -3,9 +3,14 @@ name: Reconcile Pending Refunds on: schedule: # Run every 15 minutes + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "11-59/15 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: reconcile-refunds: runs-on: ubuntu-latest diff --git a/.github/workflows/reconcile-slot-availability.yml b/.github/workflows/reconcile-slot-availability.yml index e43908a12..8fc68edfa 100644 --- a/.github/workflows/reconcile-slot-availability.yml +++ b/.github/workflows/reconcile-slot-availability.yml @@ -10,6 +10,10 @@ on: # cron-runtime-minutes: 8 workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: reconcile-slot-availability: runs-on: ubuntu-latest diff --git a/.github/workflows/release-pending-trust-earnings.yml b/.github/workflows/release-pending-trust-earnings.yml index d1061dd72..bfd0f6661 100644 --- a/.github/workflows/release-pending-trust-earnings.yml +++ b/.github/workflows/release-pending-trust-earnings.yml @@ -17,6 +17,10 @@ on: - cron: "42 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: release-pending-trust-earnings: runs-on: ubuntu-latest diff --git a/.github/workflows/retry-failed-emails.yml b/.github/workflows/retry-failed-emails.yml index 1e5cb44c1..6733becc3 100644 --- a/.github/workflows/retry-failed-emails.yml +++ b/.github/workflows/retry-failed-emails.yml @@ -8,9 +8,14 @@ name: Retry Failed Emails on: schedule: + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "5-59/15 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: retry-failed-emails: runs-on: ubuntu-latest diff --git a/.github/workflows/retry-moderation-enforcement.yml b/.github/workflows/retry-moderation-enforcement.yml index 86df2b52f..8e28db099 100644 --- a/.github/workflows/retry-moderation-enforcement.yml +++ b/.github/workflows/retry-moderation-enforcement.yml @@ -16,9 +16,18 @@ on: schedule: # Every 30 minutes, on a minute no other job claims. Enforcement that has # already been reported as done should not wait an hour to become true. + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "8-59/30 * * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + permissions: contents: read diff --git a/.github/workflows/send-appointment-reminders.yml b/.github/workflows/send-appointment-reminders.yml index 880178a2d..ae6cd68f3 100644 --- a/.github/workflows/send-appointment-reminders.yml +++ b/.github/workflows/send-appointment-reminders.yml @@ -7,6 +7,14 @@ on: - cron: "47 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: send-appointment-reminders: runs-on: ubuntu-latest diff --git a/.github/workflows/sso-cert-expiry-alert.yml b/.github/workflows/sso-cert-expiry-alert.yml index ac86d160e..8754e7876 100644 --- a/.github/workflows/sso-cert-expiry-alert.yml +++ b/.github/workflows/sso-cert-expiry-alert.yml @@ -10,6 +10,14 @@ on: - cron: "25 3 * * *" workflow_dispatch: # allow manual trigger after rotating a cert +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: sso-cert-expiry-alert: runs-on: ubuntu-latest diff --git a/.github/workflows/stream-sync.yml b/.github/workflows/stream-sync.yml index a25b56f84..8e53a7856 100644 --- a/.github/workflows/stream-sync.yml +++ b/.github/workflows/stream-sync.yml @@ -6,6 +6,14 @@ on: - cron: "40 3 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: sync-stream-users: runs-on: ubuntu-latest diff --git a/.github/workflows/stream-webhook-drift.yml b/.github/workflows/stream-webhook-drift.yml index 29d3a5222..34699106d 100644 --- a/.github/workflows/stream-webhook-drift.yml +++ b/.github/workflows/stream-webhook-drift.yml @@ -36,6 +36,14 @@ on: - ".github/workflows/stream-webhook-drift.yml" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + # Read-only: the check mode makes no Stream write and no repository write. permissions: contents: read diff --git a/.github/workflows/sweep-abandoned-overage-charges.yml b/.github/workflows/sweep-abandoned-overage-charges.yml index faeba0fe6..f23866d0c 100644 --- a/.github/workflows/sweep-abandoned-overage-charges.yml +++ b/.github/workflows/sweep-abandoned-overage-charges.yml @@ -8,6 +8,14 @@ on: - cron: "55 2 * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: sweep-abandoned-overage-charges: runs-on: ubuntu-latest diff --git a/.github/workflows/sweep-orphaned-topup-captures.yml b/.github/workflows/sweep-orphaned-topup-captures.yml index ab24faa02..a01b6f4c6 100644 --- a/.github/workflows/sweep-orphaned-topup-captures.yml +++ b/.github/workflows/sweep-orphaned-topup-captures.yml @@ -5,9 +5,18 @@ name: Sweep Orphaned Top-up Captures on: schedule: + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "23-59/30 * * * *" workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + # A second workflow_dispatch while one is already pending would replace it + # under the default single-pending-run policy; queue: max keeps every + # trigger instead of silently dropping one. + queue: max + jobs: sweep-orphaned-topup-captures: runs-on: ubuntu-latest diff --git a/.github/workflows/sweep-stuck-webhook-events.yml b/.github/workflows/sweep-stuck-webhook-events.yml index 3dae6151d..f20cde12e 100644 --- a/.github/workflows/sweep-stuck-webhook-events.yml +++ b/.github/workflows/sweep-stuck-webhook-events.yml @@ -6,9 +6,14 @@ name: Sweep Stuck Webhook Events on: schedule: + # ADR 22 — this schedule is an upper bound on frequency, not a delivered cadence; ADR 27 — the Netlify ticker (netlify/functions/cron-tick.mts) is what actually holds the cadence for the money sweeps. - cron: "4-59/10 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: sweep-stuck-webhook-events: runs-on: ubuntu-latest diff --git a/.github/workflows/sync-payment-earnings.yml b/.github/workflows/sync-payment-earnings.yml index c2e6e46a3..384f1b087 100644 --- a/.github/workflows/sync-payment-earnings.yml +++ b/.github/workflows/sync-payment-earnings.yml @@ -6,6 +6,10 @@ on: - cron: "22 * * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: sync-payment-earnings: runs-on: ubuntu-latest @@ -23,6 +27,11 @@ jobs: # #476 — fail-closed cron lock: without these the job refuses to run. UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + # #1335 — this healer accrues through the same createEarningsFromPayment + # the webhook uses, so it has to see the same rate-card scope flag. Set on + # Netlify alone, the same booking would settle on the scoped card when the + # webhook caught it and on the org default when the healer did. + RATE_CARD_SCOPED_RESOLUTION: ${{ vars.RATE_CARD_SCOPED_RESOLUTION }} steps: - name: Checkout code diff --git a/.github/workflows/tds-return-draft.yml b/.github/workflows/tds-return-draft.yml new file mode 100644 index 000000000..8d38e8642 --- /dev/null +++ b/.github/workflows/tds-return-draft.yml @@ -0,0 +1,94 @@ +name: TDS quarterly return draft + +# #1354/#1362 — builds the Form 26Q / Form 140 deductee-wise draft for a +# fiscal quarter, prints the MASKED draft to the job log, and writes the +# full-PAN CSV into the private Supabase bucket for the CA. There is +# deliberately NO artifact upload: an Actions artifact is downloadable by +# anyone with repo read access, and this job is the one place a decrypted PAN +# exists outside the database. +# +# cron-runtime-minutes: 3 + +on: + schedule: + # 01:20 UTC = 06:50 IST on the 5th of the month after each fiscal quarter + # closes (Jan/Apr/Jul/Oct), which puts the draft in front of finance before + # the India business day and well inside the 31st-of-the-month filing + # window. Hour 01 UTC carries one other cron, at minute 00. + # + # CR #1354 r1 — with no inputs the job targets the quarter that CLOSED, so + # this trigger date and the exported period agree. Deriving the period from + # "today" made the 5 April run export five days of the new FY. + - cron: "20 1 5 1,4,7,10 *" + workflow_dispatch: + inputs: + financialYear: + description: 'Financial year, e.g. "2026-27" (defaults to the FY of the quarter that closed)' + required: false + type: string + quarter: + description: "Fiscal quarter 1-4 (defaults to the quarter that closed)" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: >- + tds-return-draft-${{ github.event.inputs.financialYear || 'current' }}-${{ + github.event.inputs.quarter || 'current' }} + # A second run would overwrite the same CSV object with the same content; + # cancelling the first mid-upload is the only way to get a truncated file. + cancel-in-progress: false + +jobs: + export: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + - name: Install dependencies without lifecycle scripts + # S6505 — no arbitrary lifecycle scripts during install; the one + # artifact generation we need (Prisma client) is invoked explicitly + # from our pinned devDependency right after. + run: npm ci --ignore-scripts + - name: Generate Prisma client + run: npx --no-install prisma generate + - name: Build the draft and write the return CSV + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + DIRECT_URL: ${{ secrets.DIRECT_URL }} + # #476 cron locks load lib/redis at import — every job entry needs these + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} + # Without this the CSV ships with every PAN cell blank and the draft + # silently understates who was withheld at the no-PAN rate. + PAN_ENCRYPTION_KEY: ${{ secrets.PAN_ENCRYPTION_KEY }} + # lib/supabase-storage-core.ts builds the PUBLIC client at module + # scope and throws without the anon key, even though this job only + # ever uses the service-role client. + NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} + # #1066 — jobs run as bare Node processes, so Sentry.init only sees + # what the step env carries. + NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN || secrets.SENTRY_DSN }} + NEXT_PUBLIC_SENTRY_ENVIRONMENT: production + TDS_RETURN_FY: ${{ github.event.inputs.financialYear }} + TDS_RETURN_QUARTER: ${{ github.event.inputs.quarter }} + # S8543/S6505 — run the locally-installed binary directly; no + # on-demand package resolution at execution time. + run: node_modules/.bin/tsx jobs/compliance/tds-26q-draft-export.ts + - name: Notify on failure + if: failure() + env: + SLACK_OPS_WEBHOOK_URL: ${{ secrets.SLACK_OPS_WEBHOOK_URL }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + run: bash scripts/ci/notify-ops-failure.sh "tds-return-draft" diff --git a/.github/workflows/timeout-member-overages.yml b/.github/workflows/timeout-member-overages.yml index cacf6679c..342e60ff9 100644 --- a/.github/workflows/timeout-member-overages.yml +++ b/.github/workflows/timeout-member-overages.yml @@ -11,6 +11,10 @@ on: - cron: "0 23 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: timeout-member-overages: runs-on: ubuntu-latest diff --git a/.github/workflows/transfer-expiring-recordings.yml b/.github/workflows/transfer-expiring-recordings.yml index 107d90152..da038a326 100644 --- a/.github/workflows/transfer-expiring-recordings.yml +++ b/.github/workflows/transfer-expiring-recordings.yml @@ -6,6 +6,10 @@ on: - cron: "58 */6 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: transfer-expiring-recordings: runs-on: ubuntu-latest diff --git a/.github/workflows/wallet-low-balance.yml b/.github/workflows/wallet-low-balance.yml index f09d00b5f..f63143080 100644 --- a/.github/workflows/wallet-low-balance.yml +++ b/.github/workflows/wallet-low-balance.yml @@ -11,6 +11,10 @@ on: - cron: "45 23 * * *" workflow_dispatch: # Allow manual triggering +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: wallet-low-balance: runs-on: ubuntu-latest diff --git a/.sonarcloud.properties b/.sonarcloud.properties index b5f99f31a..10829ceeb 100644 --- a/.sonarcloud.properties +++ b/.sonarcloud.properties @@ -11,3 +11,11 @@ # following them would break the migration. Excluded for the same reason # prisma/migrations/** already is. sonar.exclusions=**/node_modules/**,.next/**,coverage/**,**/*.d.ts,prisma/migrations/**,prisma/seedFiles/**,prisma/sql/**,public/**,emails/**,docs/**,load-tests/**,testsprite_tests/** +# Test suites repeat mock scaffolding by design (jest.mock is per-file and +# hoisted); duplication there is not a maintainability signal. #1330 already +# excluded __tests__/**,tests/** from CPD in sonar-project.properties for the +# future CI-based scan, but missed this file — the one Automatic Analysis +# actually reads — so every PR that added test coverage kept failing the +# new-code duplication gate on boilerplate the CI-scan config had already +# written off. +sonar.cpd.exclusions=__tests__/**,tests/** diff --git a/__tests__/booking-algorithm/allocation-top-up.test.ts b/__tests__/booking-algorithm/allocation-top-up.test.ts new file mode 100644 index 000000000..6645f7e3c --- /dev/null +++ b/__tests__/booking-algorithm/allocation-top-up.test.ts @@ -0,0 +1,295 @@ +/** + * #1206 — top-up allocation. + * + * A partial allocation confirms N of M sessions and leaves the rest unplaced. + * Re-running the ordinary auto path to recover them deletes every confirmed + * appointment (and the Payment rows that cascade off it) and re-plans from + * scratch, which is why the hourly sweep could never do it. `topUp: true` + * places only the shortfall and touches nothing that exists. + * + * The transaction mock below has no delete members at all: any call into + * `deleteExistingAppointments` throws instead of quietly passing. + */ + +import "./setup"; + +// Mock prisma (relative path required — @/ aliases fail in jest.mock) +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + $transaction: jest.fn(), + consultation: { findUnique: jest.fn() }, + subscription: { findUnique: jest.fn() }, + webinar: { findUnique: jest.fn() }, + class: { findUnique: jest.fn() }, + appointment: { findMany: jest.fn(), findUnique: jest.fn() }, + rescheduleRequest: { findFirst: jest.fn() }, + }, + ALLOCATION_TX_MAX_WAIT_MS: 8000, + ALLOCATION_TX_TIMEOUT_MS: 30000, +})); + +// Mock appointmentlock to avoid the @upstash/redis ESM import under Jest. +jest.mock("../../utils/appointmentlock", () => ({ + lockAutoAllocate: jest + .fn() + .mockResolvedValue({ key: "mock-key", value: "mock-value" }), + unlockAutoAllocate: jest.fn().mockResolvedValue(undefined), + lockConsulteeBooking: jest + .fn() + .mockResolvedValue({ key: "mock-consultee-key", value: "mock-value" }), + unlockConsulteeBooking: jest.fn().mockResolvedValue(undefined), +})); + +// Slot discovery stays REAL — the weekly-cap seeding and the booked-slot set +// are exactly what must keep the fixed sessions out of the search. Only the +// validators are stubbed, as elsewhere in this folder. +const mockValidateFn = jest.fn(); +const mockRevalidateConflictsFn = jest.fn(); +jest.mock("../../utils/slotAllocation/SlotValidationService", () => ({ + ...jest.requireActual("../../utils/slotAllocation/SlotValidationService"), + SlotValidationService: jest.fn().mockImplementation(() => ({ + validate: mockValidateFn, + revalidateConflicts: mockRevalidateConflictsFn, + })), +})); + +import prisma from "@/lib/prisma"; +import { notifyAppointmentBooked } from "@/lib/novu"; +import { SlotAllocationService } from "@/utils/slotAllocation/SlotAllocationService"; +import { ScheduleType, DayOfWeek } from "@prisma/client"; + +/** Mondays 09:00–11:00 UTC — room for one 1-hour session a week, forever. */ +const MONDAY_MORNINGS = { + id: "weekly-monday-9", + startDay: DayOfWeek.MONDAY, + endDay: DayOfWeek.MONDAY, + startTimeUtc: 9 * 60, + endTimeUtc: 11 * 60, + utcOffsetMinutes: 0, +}; + +const mockPrisma = prisma as unknown as { + $transaction: jest.Mock; + subscription: { findUnique: jest.Mock }; + appointment: { findMany: jest.Mock; findUnique: jest.Mock }; +}; + +const notifyBooked = notifyAppointmentBooked as jest.Mock; + +/** One 1-hour session = two 30-minute atoms, both already confirmed. */ +function confirmedSession(id: string, startISO: string) { + const startsAt = new Date(startISO); + const midpoint = new Date(startsAt.getTime() + 30 * 60 * 1000); + return { + id, + organizationId: null, + payment: [], + slotsOfAppointment: [ + { + id: `${id}-slot-1`, + startsAt, + endsAt: midpoint, + isTentative: false, + }, + { + id: `${id}-slot-2`, + startsAt: midpoint, + endsAt: new Date(startsAt.getTime() + 60 * 60 * 1000), + isTentative: false, + }, + ], + }; +} + +// Weeks 1 and 2 of a four-session plan are already booked and paid for. +const WEEK_1 = confirmedSession("apt-week-1", "2025-01-06T09:00:00.000Z"); +const WEEK_2 = confirmedSession("apt-week-2", "2025-01-13T09:00:00.000Z"); +// What the same event looks like once the top-up has run. +const WEEK_3 = confirmedSession("apt-week-3", "2025-01-20T09:00:00.000Z"); +const WEEK_4 = confirmedSession("apt-week-4", "2025-01-27T09:00:00.000Z"); + +function makeSubscription(appointments: ReturnType[]) { + return { + id: "sub-topup", + schedulingPeriodStartsAt: new Date("2025-01-06T00:00:00Z"), + schedulingPeriodEndsAt: new Date("2025-02-28T00:00:00Z"), + subscriptionPlan: { + title: "Weekly coaching", + consultantProfileId: "consultant-profile-1", + durationInMonths: 2, + sessionsPerWeek: 1, + sessionDurationInHours: 1, + totalSessions: 4, + consultantProfile: { + user: { id: "consultant-1", name: "Consultant", timezone: "UTC" }, + scheduleType: ScheduleType.WEEKLY, + slotsOfAvailabilityWeekly: [MONDAY_MORNINGS], + slotsOfAvailabilityCustom: [], + }, + }, + requestedBy: { user: { id: "consultee-1", name: "Consultee" } }, + appointments, + }; +} + +/** + * No `delete`, `deleteMany` or `slotOfAppointment.deleteMany`: the top-up path + * must never reach `deleteExistingAppointments`, and a call here is a + * TypeError rather than a silent pass. + */ +function makeNoDeleteTx() { + return { + subscription: { + findUnique: jest.fn(), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + consultantProfile: { + findFirst: jest.fn().mockResolvedValue({ id: "consultant-profile-1" }), + }, + appointment: { + findMany: jest.fn().mockResolvedValue([]), + create: jest + .fn() + .mockImplementation(({ data }: { data: Record }) => + Promise.resolve({ id: `created-${Math.random()}`, ...data }), + ), + }, + appointmentParticipant: { + createMany: jest.fn().mockResolvedValue({ count: 2 }), + }, + bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + $queryRaw: jest.fn().mockResolvedValue([]), + }; +} + +let mockTx: ReturnType; + +/** Slot start times, in order, of every appointment this run created. */ +function createdSlotStarts(): string[] { + return mockTx.appointment.create.mock.calls.flatMap( + ([args]: [ + { data: { slotsOfAppointment: { create: { startsAt: Date }[] } } }, + ]) => + args.data.slotsOfAppointment.create.map((slot) => + slot.startsAt.toISOString(), + ), + ); +} + +/** Let the fire-and-forget notification promise settle. */ +async function flushNotifications(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.setSystemTime(new Date("2025-01-01T00:00:00Z")); + + mockTx = makeNoDeleteTx(); + mockPrisma.$transaction.mockImplementation( + (callback: (tx: typeof mockTx) => unknown) => callback(mockTx), + ); + mockTx.subscription.findUnique.mockResolvedValue( + makeSubscription([WEEK_1, WEEK_2]), + ); + mockPrisma.subscription.findUnique.mockImplementation(() => + mockTx.subscription.findUnique(), + ); + // One array answers all three reads: the event's own appointments, the + // consultant's occupancy scan and the consultee's. The confirmed sessions + // therefore block their own intervals, which is what a top-up requires. + mockPrisma.appointment.findMany.mockResolvedValue([WEEK_1, WEEK_2]); + + mockValidateFn.mockResolvedValue({ isValid: true, errors: [], warnings: [] }); + mockRevalidateConflictsFn.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [], + }); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe("#1206 top-up allocation", () => { + it("places only the two missing sessions and deletes nothing", async () => { + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-topup", + mode: "auto", + topUp: true, + allowPartial: true, + }); + + expect(result.success).toBe(true); + expect(result.noChange).toBeUndefined(); + // Two new Appointment rows = the two sessions the plan was short. + expect(result.appointments).toHaveLength(2); + // Weeks 1 and 2 are untouched: they are already at the weekly cap and + // their atoms are in the booked set, so the search skipped straight to + // weeks 3 and 4. + expect(createdSlotStarts()).toEqual([ + "2025-01-20T09:00:00.000Z", + "2025-01-20T09:30:00.000Z", + "2025-01-27T09:00:00.000Z", + "2025-01-27T09:30:00.000Z", + ]); + // The plan is whole again, so no partial notice is owed. + expect(result.partial).toBeUndefined(); + }); + + it("returns noChange and notifies nobody once the plan is complete", async () => { + // The state the run above leaves behind: all four sessions confirmed. + const complete = [WEEK_1, WEEK_2, WEEK_3, WEEK_4]; + mockTx.subscription.findUnique.mockResolvedValue( + makeSubscription(complete), + ); + mockPrisma.appointment.findMany.mockResolvedValue(complete); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-topup", + mode: "auto", + topUp: true, + allowPartial: true, + }); + await flushNotifications(); + + expect(result.success).toBe(true); + expect(result.noChange).toBe(true); + // Derived from the shortfall: a whole plan is not partial. + expect(result.partial).toBe(false); + expect(result.placedSessions).toBe(0); + expect(result.requiredSessions).toBe(4); + expect(result.unplacedSessions).toBe(0); + // Nothing was written, and — the point of the suppressor — the consultee + // is not paged by an hourly sweep that changed nothing. + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + expect(notifyBooked).not.toHaveBeenCalled(); + }); + + it("without the flag, the same event still goes for the delete", async () => { + // The contrast that makes the pin above mean something. Same fixture, no + // flag: the ordinary auto path re-plans, which starts by deleting the two + // paid sessions — and this transaction has no delete to give it. + mockTx.appointment.findMany.mockResolvedValue([WEEK_1, WEEK_2]); + + const result = await SlotAllocationService.allocate({ + eventType: "subscription", + eventId: "sub-topup", + mode: "auto", + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/delete/i); + }); +}); diff --git a/__tests__/booking-algorithm/expiry-refunds.test.ts b/__tests__/booking-algorithm/expiry-refunds.test.ts index 396831da2..ae254725b 100644 --- a/__tests__/booking-algorithm/expiry-refunds.test.ts +++ b/__tests__/booking-algorithm/expiry-refunds.test.ts @@ -9,25 +9,34 @@ import "./setup"; const refundBookingPayment = jest.fn(); jest.mock("../../lib/payments/operations/booking-refund", () => ({ __esModule: true, - refundBookingPayment: (...a: unknown[]) => refundBookingPayment(...(a as [never])), + refundBookingPayment: (...a: unknown[]) => + refundBookingPayment(...(a as [never])), })); -jest.mock("../../lib/prisma", () => ({ - __esModule: true, - default: { +jest.mock("../../lib/prisma", () => { + const db: Record = { consultation: { findMany: jest.fn(), + findUnique: jest.fn().mockResolvedValue(null), updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, subscription: { findMany: jest.fn(), + findUnique: jest.fn().mockResolvedValue(null), updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, - slotOfAppointment: { deleteMany: jest.fn() }, + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + updateManyAndReturn: jest.fn().mockResolvedValue([]), + }, + bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, appointment: { findMany: jest.fn() }, $disconnect: jest.fn(), - }, -})); + }; + // The payment-pending arm now expires each request in its own transaction. + db.$transaction = jest.fn(async (fn: (tx: unknown) => unknown) => fn(db)); + return { __esModule: true, default: db }; +}); jest.mock("../../lib/cron/with-cron-lock", () => ({ __esModule: true, @@ -55,11 +64,13 @@ describe("expiry sweep refunds (PR 2c)", () => { consultantProfile: { user: { name: "Consultant", email: "" } }, }, }; - ((prisma.subscription.findMany as unknown) as jest.Mock) - .mockResolvedValueOnce([richRow]) // PENDING cohort + (prisma.subscription.findMany as unknown as jest.Mock) + .mockResolvedValueOnce([richRow]) // PENDING cohort .mockResolvedValueOnce([]); // APPROVED-unallocated cohort: empty - (prisma.subscription.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); - ((prisma.appointment.findMany as unknown) as jest.Mock).mockResolvedValue([ + (prisma.subscription.updateMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + (prisma.appointment.findMany as unknown as jest.Mock).mockResolvedValue([ { id: "apt-1", payment: [ @@ -85,8 +96,8 @@ describe("expiry sweep refunds (PR 2c)", () => { }); it("counts a front-door refusal as a failure without stalling the sweep", async () => { - ((prisma.subscription.findMany as unknown) as jest.Mock) - .mockResolvedValueOnce([ + (prisma.subscription.findMany as unknown as jest.Mock) + .mockResolvedValueOnce([ { id: "sub-1", requestedAt: new Date("2026-01-01"), @@ -97,8 +108,10 @@ describe("expiry sweep refunds (PR 2c)", () => { }, ]) .mockResolvedValueOnce([]); - (prisma.subscription.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); - ((prisma.appointment.findMany as unknown) as jest.Mock).mockResolvedValue([ + (prisma.subscription.updateMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + (prisma.appointment.findMany as unknown as jest.Mock).mockResolvedValue([ { id: "apt-1", payment: [{ id: "pay1", paymentStatus: "SUCCEEDED" }] }, ]); refundBookingPayment.mockRejectedValue( @@ -114,26 +127,35 @@ describe("expiry sweep refunds (PR 2c)", () => { it("drains the immortal APPROVED-unallocated cohort (zero live confirmed slots)", async () => { // First findMany call = PENDING subs (none); second = APPROVED cohort. - ((prisma.subscription.findMany as unknown) as jest.Mock) - .mockResolvedValueOnce([]) + (prisma.subscription.findMany as unknown as jest.Mock) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ id: "sub-immortal" }]); - (prisma.subscription.updateMany as jest.Mock).mockResolvedValue({ count: 1 }); - ((prisma.appointment.findMany as unknown) as jest.Mock).mockResolvedValue([ - { id: "placeholder", payment: [{ id: "pay-paid", paymentStatus: "SUCCEEDED" }] }, + (prisma.subscription.updateMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + (prisma.appointment.findMany as unknown as jest.Mock).mockResolvedValue([ + { + id: "placeholder", + payment: [{ id: "pay-paid", paymentStatus: "SUCCEEDED" }], + }, ]); refundBookingPayment.mockResolvedValue({ status: "SUCCEEDED" }); const result = await expireStaleRequests(); // Cohort filter: APPROVED + stale + zero confirmed slots. - const cohortCall = (prisma.subscription.findMany as jest.Mock).mock.calls[1][0]; + const cohortCall = (prisma.subscription.findMany as jest.Mock).mock + .calls[1][0]; expect(cohortCall.where.status).toBe(AppointmentStatus.APPROVED); expect(JSON.stringify(cohortCall.where.NOT)).toContain("isTentative"); - // The transition guard rides the WHERE. + // Through the CAS helper now (#1423): the from-set rides the WHERE as a + // list, and the cohort's own predicate is repeated at write time. expect(prisma.subscription.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ - status: AppointmentStatus.APPROVED, + id: "sub-immortal", + status: { in: [AppointmentStatus.APPROVED] }, + NOT: expect.anything(), }), data: { status: "EXPIRED" }, }), @@ -141,4 +163,75 @@ describe("expiry sweep refunds (PR 2c)", () => { expect(result.subscriptionsExpired).toBe(1); expect(result.refundsIssued).toBe(1); }); + + /** + * #1423 — the PENDING arm re-ran the 30-day predicate in the write instead + * of naming the rows it had read, so the expired set and the refunded set + * could diverge: a subscription that crossed the cutoff between the two + * statements was expired without a refund and without an audit row, and the + * next run (which reads PENDING only) never saw it again. + */ + it("expires only the rows it read, and every expired row gets history and a refund", async () => { + const readRow = { + id: "sub-read", + requestedAt: new Date("2026-01-01"), + requestedBy: { user: { name: "Buyer", email: "" } }, + subscriptionPlan: { + consultantProfile: { user: { name: "Consultant", email: "" } }, + }, + }; + (prisma.subscription.findMany as unknown as jest.Mock) + .mockResolvedValueOnce([readRow]) // PENDING cohort + .mockResolvedValueOnce([]); // APPROVED-unallocated cohort: empty + // "sub-latecomer" crosses the 30-day line between the read and the write. + // A predicate-shaped write would sweep it up; an id-scoped CAS cannot. + (prisma.subscription.updateMany as jest.Mock).mockImplementation( + async ({ where }: { where: { id?: string } }) => ({ + count: where.id === "sub-read" ? 1 : 0, + }), + ); + (prisma.appointment.findMany as unknown as jest.Mock).mockResolvedValue([ + { + id: "apt-read", + payment: [{ id: "pay-read", paymentStatus: "SUCCEEDED" }], + }, + ]); + refundBookingPayment.mockResolvedValue({ status: "SUCCEEDED" }); + + const result = await expireStaleRequests(); + + // Every terminal write names exactly one id from the read set. + const terminalWrites = ( + prisma.subscription.updateMany as jest.Mock + ).mock.calls + .map(([args]) => args) + .filter((args) => args?.data?.status === AppointmentStatus.EXPIRED); + expect(terminalWrites).toHaveLength(1); + expect(terminalWrites[0].where.id).toBe("sub-read"); + expect(terminalWrites[0].where.status).toEqual({ + in: [AppointmentStatus.PENDING], + }); + + // The CAS helper wrote the audit row the bulk update never did. + expect(prisma.bookingStatusHistory.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + entity: "SUBSCRIPTION", + entityId: "sub-read", + toStatus: AppointmentStatus.EXPIRED, + reason: expect.stringContaining("PENDING"), + }), + }), + ); + + // Refunds are handed the ids the helper transitioned, not the read set. + expect(prisma.appointment.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { subscriptionId: { in: ["sub-read"] } }, + }), + ); + expect(refundBookingPayment).toHaveBeenCalledTimes(1); + expect(result.subscriptionsExpired).toBe(1); + expect(result.refundsIssued).toBe(1); + }); }); diff --git a/__tests__/booking-algorithm/request-hold-hygiene.test.ts b/__tests__/booking-algorithm/request-hold-hygiene.test.ts index d5fd26fe1..dde17724f 100644 --- a/__tests__/booking-algorithm/request-hold-hygiene.test.ts +++ b/__tests__/booking-algorithm/request-hold-hygiene.test.ts @@ -10,16 +10,31 @@ import "./setup"; -jest.mock("../../lib/prisma", () => ({ - __esModule: true, - default: { - consultation: { findMany: jest.fn(), updateMany: jest.fn(), count: jest.fn() }, - subscription: { findMany: jest.fn(), updateMany: jest.fn() }, - slotOfAppointment: { deleteMany: jest.fn() }, +jest.mock("../../lib/prisma", () => { + const db: Record = { + consultation: { + findMany: jest.fn(), + findUnique: jest.fn().mockResolvedValue(null), + updateMany: jest.fn(), + count: jest.fn(), + }, + subscription: { + findMany: jest.fn(), + findUnique: jest.fn().mockResolvedValue(null), + updateMany: jest.fn(), + }, + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + updateManyAndReturn: jest.fn().mockResolvedValue([]), + }, + bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, appointment: { findMany: jest.fn().mockResolvedValue([]) }, $disconnect: jest.fn(), - }, -})); + }; + // The payment-pending arm now expires each request in its own transaction. + db.$transaction = jest.fn(async (fn: (tx: unknown) => unknown) => fn(db)); + return { __esModule: true, default: db }; +}); // B1's sweep now routes refunds through booking-refund, whose module graph // constructs a Stripe client (needs global fetch — absent in this env). @@ -57,7 +72,9 @@ const rfaRoute = require("fs").readFileSync( describe("RFA route lock order", () => { it("takes the consultee lock before the slot atoms", () => { - const consulteeAt = rfaRoute.indexOf("lockConsulteeBooking(session.user.id)"); + const consulteeAt = rfaRoute.indexOf( + "lockConsulteeBooking(session.user.id)", + ); const atomsAt = rfaRoute.indexOf( "lockSlotBooking(consultantProfileId, startsAt, endsAt)", ); @@ -101,7 +118,7 @@ describe("48h PENDING consultation expiry releases pinned slots", () => { }); }); - it("expires stale consultations by id and deletes their tentative slots", async () => { + it("expires stale consultations by id and soft-cancels their tentative slots", async () => { (prisma.consultation.findMany as jest.Mock).mockResolvedValue([ { id: "c1", appointment: { id: "apt-1" } }, { id: "c2", appointment: { id: "apt-2" } }, @@ -109,13 +126,17 @@ describe("48h PENDING consultation expiry releases pinned slots", () => { ]); // PR 2c — the sweep now refunds SUCCEEDED payments of expired rows. (prisma.appointment.findMany as jest.Mock).mockResolvedValue([]); - (refundBookingPayment as jest.Mock).mockResolvedValue({ status: "SUCCEEDED" }); + (refundBookingPayment as jest.Mock).mockResolvedValue({ + status: "SUCCEEDED", + }); (prisma.consultation.updateMany as jest.Mock).mockResolvedValue({ count: 3, }); - (prisma.slotOfAppointment.deleteMany as jest.Mock).mockResolvedValue({ - count: 5, - }); + ( + prisma.slotOfAppointment.updateManyAndReturn as jest.Mock + ).mockResolvedValueOnce( + Array.from({ length: 5 }, (_, i) => ({ id: `slot-${i}` })), + ); const result = await expireStaleRequests(); @@ -123,33 +144,34 @@ describe("48h PENDING consultation expiry releases pinned slots", () => { expect(result.consultationsExpired).toBe(3); expect(result.consultationSlotsReleased).toBe(5); - // The CAS guard rides the WHERE: only rows still PENDING flip. - expect(prisma.consultation.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - id: { in: ["c1", "c2", "c3"] }, - status: "PENDING", + // One transaction per consultation: the CAS guard rides the WHERE (only a + // row still PENDING flips), and the release of its tentative holds commits + // with it, so a failed release can never leave an EXPIRED request holding + // the calendar. Freed by status: the row is CANCELLED and tombstoned, + // never deleted (doctrine rule 2). + for (const id of ["c1", "c2", "c3"]) { + expect(prisma.consultation.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id, status: { in: ["PENDING"] } }), + data: expect.objectContaining({ status: "EXPIRED" }), }), - data: { status: "EXPIRED" }, - }), + ); + } + // c3 is a slot-less placeholder, so only two releases run. + expect(prisma.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledTimes( + 2, ); - - // Only TENTATIVE slots of consultations that are STILL expired at delete - // time are released — the relational guard re-checks status at write - // time, so a request approved between the read and the delete keeps its - // hold (CodeRabbit triage on the approve-race). - expect(prisma.slotOfAppointment.deleteMany).toHaveBeenCalledWith({ - where: { - appointmentId: { in: ["apt-1", "apt-2"] }, - isTentative: true, - appointment: { - consultation: { - id: { in: ["c1", "c2", "c3"] }, - status: "EXPIRED", - }, + expect(prisma.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + appointmentId: "apt-1", + isTentative: true, + deletedAt: null, + completionStatus: { in: ["SCHEDULED", "UNVERIFIED", "RESCHEDULED"] }, }, - }, - }); + data: expect.objectContaining({ completionStatus: "CANCELLED" }), + }), + ); }); it("is a no-op when nothing is stale", async () => { @@ -160,11 +182,16 @@ describe("48h PENDING consultation expiry releases pinned slots", () => { expect(result.consultationsExpired).toBe(0); expect(result.consultationSlotsReleased).toBe(0); // The stale-rescheduled-slot release (PR 2e) may fire independently — - // assert the CONSULTATION-expiry deleteMany was NOT the one that ran. - const calls = (prisma.slotOfAppointment.deleteMany as jest.Mock).mock.calls; - const consultationDelete = calls.find( - (c: any[]) => !c[0]?.where?.completionStatus, + // assert the CONSULTATION-expiry release was NOT the one that ran. Both + // arms now carry a completionStatus from-set, so the consultation arm is + // identified by its appointmentId scope instead. + const calls = (prisma.slotOfAppointment.updateManyAndReturn as jest.Mock) + .mock.calls; + const consultationRelease = calls.find( + ([args]) => + (args as { where?: { appointmentId?: unknown } })?.where + ?.appointmentId !== undefined, ); - expect(consultationDelete).toBeUndefined(); + expect(consultationRelease).toBeUndefined(); }); }); diff --git a/__tests__/booking-algorithm/rescheduleCancel.test.ts b/__tests__/booking-algorithm/rescheduleCancel.test.ts index 65edbbd94..33071b4bf 100644 --- a/__tests__/booking-algorithm/rescheduleCancel.test.ts +++ b/__tests__/booking-algorithm/rescheduleCancel.test.ts @@ -33,7 +33,11 @@ jest.mock("../../lib/prisma", () => ({ // #1003 — group-event cancel reads the attendee roster off the payments so // it can notify them. Default to an empty event. payment: { findMany: jest.fn().mockResolvedValue([]) }, - slotOfAppointment: { findMany: jest.fn(), deleteMany: jest.fn() }, + slotOfAppointment: { + findMany: jest.fn(), + updateManyAndReturn: jest.fn().mockResolvedValue([]), + }, + bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, // #1008 — the cancel/reschedule routes call hasActiveDisputeForAppointment, // which reads prisma.dispute.findFirst. Default to no live dispute. dispute: { findFirst: jest.fn().mockResolvedValue(null) }, @@ -1274,6 +1278,14 @@ describe("Cancel Route Handler - POST", () => { describe("cleanupTentativeSlots", () => { beforeEach(() => { jest.clearAllMocks(); + // #1319 wave 6 — the sweep releases holds inside prisma.$transaction so + // the tombstone and its history rows land together; hand the callback the + // same mocked client so the per-model mocks below keep applying. + (prisma.$transaction as jest.Mock).mockImplementation((arg: unknown) => + typeof arg === "function" + ? (arg as (tx: typeof prisma) => unknown)(prisma) + : Promise.all(arg as Promise[]), + ); }); it("should return success with 0 slots when none are stale", async () => { @@ -1287,7 +1299,7 @@ describe("cleanupTentativeSlots", () => { expect(result.errors).toHaveLength(0); }); - it("should find and delete stale tentative slots", async () => { + it("should find and soft-cancel stale tentative slots", async () => { const staleSlots = [ { id: "slot-1", @@ -1312,9 +1324,9 @@ describe("cleanupTentativeSlots", () => { ]; (prisma.slotOfAppointment as any).findMany.mockResolvedValue(staleSlots); - (prisma.slotOfAppointment as any).deleteMany.mockResolvedValue({ - count: 1, - }); + ( + prisma.slotOfAppointment as unknown as { updateManyAndReturn: jest.Mock } + ).updateManyAndReturn.mockResolvedValue([{ id: "slot-1" }]); const result = await cleanupTentativeSlots(); @@ -1358,9 +1370,13 @@ describe("cleanupTentativeSlots", () => { ]; (prisma.slotOfAppointment as any).findMany.mockResolvedValue(staleSlots); - (prisma.slotOfAppointment as any).deleteMany.mockResolvedValue({ - count: 3, - }); + ( + prisma.slotOfAppointment as unknown as { updateManyAndReturn: jest.Mock } + ).updateManyAndReturn.mockResolvedValue([ + { id: "slot-1" }, + { id: "slot-2" }, + { id: "slot-3" }, + ]); const result = await cleanupTentativeSlots(); @@ -1419,11 +1435,17 @@ describe("cleanupTentativeSlots", () => { ); }); - it("should not call deleteMany when no stale slots found", async () => { + it("should not write when no stale slots found", async () => { (prisma.slotOfAppointment as any).findMany.mockResolvedValue([]); await cleanupTentativeSlots(); - expect((prisma.slotOfAppointment as any).deleteMany).not.toHaveBeenCalled(); + expect( + ( + prisma.slotOfAppointment as unknown as { + updateManyAndReturn: jest.Mock; + } + ).updateManyAndReturn, + ).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/booking-algorithm/slot-completion-transitions.test.ts b/__tests__/booking-algorithm/slot-completion-transitions.test.ts index 4968b5d08..f6b918b02 100644 --- a/__tests__/booking-algorithm/slot-completion-transitions.test.ts +++ b/__tests__/booking-algorithm/slot-completion-transitions.test.ts @@ -26,6 +26,7 @@ type TrialTx = Parameters[0]; function slotTx(count: number, from: string = "SCHEDULED") { const ids = Array.from({ length: count }, (_, i) => ({ id: `slot_${i + 1}`, + appointmentId: "apt_1", })); const updateManyAndReturn = jest.fn().mockResolvedValue(ids); const findMany = jest @@ -77,7 +78,7 @@ describe("SLOT_COMPLETION_ALLOWED_FROM", () => { describe("transitionSlotCompletion", () => { it("bakes the allowed-from set into the WHERE and returns the count", async () => { - const { tx, updateManyAndReturn } = slotTx(2); + const { tx, updateManyAndReturn, create } = slotTx(2); const moved = await transitionSlotCompletion(tx, { where: { appointmentId: "apt_1", deletedAt: null }, to: "CANCELLED", @@ -94,8 +95,22 @@ describe("transitionSlotCompletion", () => { completionStatus: "CANCELLED", deletedAt: new Date("2026-09-02T00:00:00Z"), }, - select: { id: true }, + // #1333 — the owning appointment comes back with each moved row so the + // history row it writes can name it. + select: { id: true, appointmentId: true }, }); + // #1333 — one history row per moved slot, each naming the appointment the + // row came back with, not a null fallback. + expect(create).toHaveBeenCalledTimes(2); + for (const [call] of create.mock.calls) { + expect(call.data).toEqual( + expect.objectContaining({ + entity: "SLOT", + toStatus: "CANCELLED", + appointmentId: "apt_1", + }), + ); + } }); it("fromIn narrows the set", async () => { diff --git a/__tests__/booking/cleanup-tentative-guard.test.ts b/__tests__/booking/cleanup-tentative-guard.test.ts index 09cb6e9fa..a4c500e14 100644 --- a/__tests__/booking/cleanup-tentative-guard.test.ts +++ b/__tests__/booking/cleanup-tentative-guard.test.ts @@ -3,23 +3,31 @@ */ /** - * #829 — the tentative-slot cleanup must never delete a slot that was - * confirmed (or paid) between its scan and its delete. The delete's WHERE - * re-states isTentative + no-SUCCEEDED-payment, so a concurrent capture - * webhook's flip makes the row stop matching (re-evaluated under the row - * lock) instead of being destroyed. + * #829 — the tentative-slot cleanup must never release a slot that was + * confirmed (or paid) between its scan and its write. The CAS WHERE re-states + * isTentative + no-SUCCEEDED-payment, so a concurrent capture webhook's flip + * makes the row stop matching (re-evaluated under the row lock) instead of + * being released. The release itself is a soft cancel, so the row survives. */ -jest.mock("../../lib/prisma", () => ({ - __esModule: true, - default: { +jest.mock("../../lib/prisma", () => { + const client: Record = { slotOfAppointment: { findMany: jest.fn(), - deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + updateManyAndReturn: jest.fn().mockResolvedValue([]), }, + bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, $disconnect: jest.fn(), - }, -})); + }; + // #1319 wave 6 — the release runs inside $transaction so the tombstone and + // its history rows land together; the callback gets the same mocked client. + client.$transaction = jest.fn((arg: unknown) => + typeof arg === "function" + ? (arg as (tx: unknown) => unknown)(client) + : Promise.all(arg as Promise[]), + ); + return { __esModule: true, default: client }; +}); jest.mock("../../lib/cron/with-cron-lock", () => ({ withCronLock: jest.fn((_j: string, _o: unknown, fn: () => unknown) => fn()), CronLockHeldError: class CronLockHeldError extends Error {}, @@ -31,42 +39,74 @@ import prisma from "../../lib/prisma"; import { cleanupTentativeSlots } from "@/scripts/appointments/cleanup-tentative-slots"; const mocked = prisma as unknown as { - slotOfAppointment: { findMany: jest.Mock; deleteMany: jest.Mock }; + slotOfAppointment: { findMany: jest.Mock; updateManyAndReturn: jest.Mock }; +}; + +const STALE_SLOT = { + id: "slot-1", + appointmentId: "appt-1", + completionStatus: "SCHEDULED", + createdAt: new Date("2026-05-01T00:00:00Z"), + updatedAt: new Date("2026-05-01T00:00:00Z"), + startsAt: new Date("2026-05-02T10:00:00Z"), + endsAt: new Date("2026-05-02T11:00:00Z"), + appointment: { payment: [], consultation: null, subscription: null }, }; beforeEach(() => jest.clearAllMocks()); -describe("#829 — cleanup delete re-states the tentative + unpaid guards", () => { - it("carries isTentative + no-SUCCEEDED-payment in the deleteMany WHERE", async () => { - mocked.slotOfAppointment.findMany.mockResolvedValue([ - { - id: "slot-1", - appointmentId: "appt-1", - createdAt: new Date("2026-05-01T00:00:00Z"), - updatedAt: new Date("2026-05-01T00:00:00Z"), - startsAt: new Date("2026-05-02T10:00:00Z"), - endsAt: new Date("2026-05-02T11:00:00Z"), - appointment: { payment: [], consultation: null, subscription: null }, - }, +describe("#829 — cleanup release re-states the tentative + unpaid guards", () => { + it("carries isTentative + no-SUCCEEDED-payment in the CAS WHERE", async () => { + mocked.slotOfAppointment.findMany.mockResolvedValue([STALE_SLOT]); + mocked.slotOfAppointment.updateManyAndReturn.mockResolvedValue([ + { id: "slot-1" }, ]); - mocked.slotOfAppointment.deleteMany.mockResolvedValue({ count: 1 }); - await cleanupTentativeSlots(); + const result = await cleanupTentativeSlots(); - expect(mocked.slotOfAppointment.deleteMany).toHaveBeenCalledWith({ - where: expect.objectContaining({ - id: { in: ["slot-1"] }, - isTentative: true, - appointment: { - payment: { none: { paymentStatus: "SUCCEEDED" } }, - }, + expect(result.slotsReleased).toBe(1); + expect(mocked.slotOfAppointment.updateManyAndReturn).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: { in: ["slot-1"] }, + isTentative: true, + deletedAt: null, + // The parent-status guards ride alongside (wave 6); the money + // predicate is what this pin protects. + appointment: expect.objectContaining({ + payment: { none: { paymentStatus: "SUCCEEDED" } }, + }), + // The from-set is the optimistic lock. UNVERIFIED belongs in it: + // auto-complete stamps a past SCHEDULED slot UNVERIFIED without + // excluding tentative rows, so most 24h-old holds are already + // there. COMPLETED stays out — a session that happened is not a + // stale hold. + completionStatus: { in: ["SCHEDULED", "UNVERIFIED", "RESCHEDULED"] }, + }), + data: expect.objectContaining({ completionStatus: "CANCELLED" }), }), - }); + ); + // Freed by status, with the row left behind for support. + const [{ data }] = + mocked.slotOfAppointment.updateManyAndReturn.mock.calls[0]; + expect(data.deletedAt).toBeInstanceOf(Date); + }); + + it("excludes already-released rows from the cohort read", async () => { + // Without this the sweep re-collects its own soft-cancelled rows every + // run and a backlog fills the per-run cap with dead slots forever. + mocked.slotOfAppointment.findMany.mockResolvedValue([]); + await cleanupTentativeSlots(); + + const [cohortRead] = mocked.slotOfAppointment.findMany.mock.calls[0]; + expect(cohortRead.where).toEqual( + expect.objectContaining({ isTentative: true, deletedAt: null }), + ); }); - it("deletes nothing when the scan finds nothing", async () => { + it("releases nothing when the scan finds nothing", async () => { mocked.slotOfAppointment.findMany.mockResolvedValue([]); await cleanupTentativeSlots(); - expect(mocked.slotOfAppointment.deleteMany).not.toHaveBeenCalled(); + expect(mocked.slotOfAppointment.updateManyAndReturn).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/booking/expiry-sweep-reschedule-race.test.ts b/__tests__/booking/expiry-sweep-reschedule-race.test.ts index 45c150a36..b1c652002 100644 --- a/__tests__/booking/expiry-sweep-reschedule-race.test.ts +++ b/__tests__/booking/expiry-sweep-reschedule-race.test.ts @@ -29,25 +29,31 @@ jest.mock("../../lib/payments/operations/booking-refund", () => ({ refundBookingPayment(...(a as [never])), })); -jest.mock("../../lib/prisma", () => ({ - __esModule: true, - default: { +jest.mock("../../lib/prisma", () => { + const db: Record = { consultation: { findMany: jest.fn(), + findUnique: jest.fn().mockResolvedValue(null), updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, subscription: { findMany: jest.fn(), + findUnique: jest.fn().mockResolvedValue(null), updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, slotOfAppointment: { - deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + findMany: jest.fn().mockResolvedValue([]), updateMany: jest.fn().mockResolvedValue({ count: 0 }), + updateManyAndReturn: jest.fn().mockResolvedValue([]), }, + bookingStatusHistory: { create: jest.fn().mockResolvedValue({}) }, appointment: { findMany: jest.fn() }, $disconnect: jest.fn(), - }, -})); + }; + // The payment-pending arm now expires each request in its own transaction. + db.$transaction = jest.fn(async (fn: (tx: unknown) => unknown) => fn(db)); + return { __esModule: true, default: db }; +}); jest.mock("../../lib/cron/with-cron-lock", () => ({ __esModule: true, @@ -122,7 +128,10 @@ describe("expiry sweep × live reschedule proposals", () => { expect(terminalWrite.where.appointment).toEqual({ rescheduleRequests: { none: openStatusFilter }, }); - expect(terminalWrite.where.status).toBe(AppointmentStatus.PENDING); + // Through the CAS helper now: the from-set rides the WHERE as a list. + expect(terminalWrite.where.status).toEqual({ + in: [AppointmentStatus.PENDING], + }); }); }); @@ -187,7 +196,10 @@ describe("expiry sweep × live reschedule proposals", () => { count: 1, }); (prisma.appointment.findMany as jest.Mock).mockResolvedValue([ - { id: "apt-stale", payment: [{ id: "pay-1", paymentStatus: "SUCCEEDED" }] }, + { + id: "apt-stale", + payment: [{ id: "pay-1", paymentStatus: "SUCCEEDED" }], + }, ]); refundBookingPayment.mockResolvedValue({ status: "SUCCEEDED" }); diff --git a/__tests__/booking/status-history.test.ts b/__tests__/booking/status-history.test.ts new file mode 100644 index 000000000..f6f7b0e5c --- /dev/null +++ b/__tests__/booking/status-history.test.ts @@ -0,0 +1,77 @@ +/** + * @jest-environment node + */ + +/** + * #1333 — the staff timeline resolves a booking's trail by appointment, and + * both halves of that were broken in production. + * + * Every helper accepted `meta.appointmentId` and no caller ever supplied one, + * so `BookingStatusHistory.appointmentId` was NULL on every row and the column + * was dead weight. And creation is not a transition, so a request that had not + * moved yet had no rows at all — the timeline read "nothing has moved on this + * booking yet" for every fresh booking. Both are one row's worth of code and + * both silently regress, so both are pinned here. + */ + +import { + appendCreationHistory, + transitionConsultationRequest, +} from "../../lib/booking/transitions"; + +function consultationTx(before: Record | null) { + const create = jest.fn( + async (_args: { data: Record }) => ({}), + ); + const tx = { + consultation: { + findUnique: jest.fn(async () => before), + updateMany: jest.fn(async () => ({ count: 1 })), + }, + bookingStatusHistory: { create }, + } as never; + return { tx, create }; +} + +describe("BookingStatusHistory carries its appointment (#1333)", () => { + it("resolves the appointment from the pre-image when the caller passes none", async () => { + const { tx, create } = consultationTx({ + status: "PENDING", + appointment: { id: "appt-1" }, + }); + + await transitionConsultationRequest(tx, { + where: { id: "cons-1" }, + to: "APPROVED", + }); + + expect(create.mock.calls[0][0].data).toMatchObject({ + entity: "CONSULTATION", + entityId: "cons-1", + fromStatus: "PENDING", + toStatus: "APPROVED", + appointmentId: "appt-1", + }); + }); + + // "UNKNOWN" is the lost-race sentinel and must not be reused here: an + // operator reading it on a creation row would be told a concurrent writer + // moved something, which is the opposite of what happened. + it("writes the creation row as CREATED, not the UNKNOWN sentinel", async () => { + const { tx, create } = consultationTx(null); + + await appendCreationHistory(tx, "CONSULTATION", "cons-2", "PENDING", { + appointmentId: "appt-2", + actorUserId: "user-1", + }); + + expect(create.mock.calls[0][0].data).toMatchObject({ + entity: "CONSULTATION", + entityId: "cons-2", + fromStatus: "CREATED", + toStatus: "PENDING", + appointmentId: "appt-2", + actorUserId: "user-1", + }); + }); +}); diff --git a/__tests__/compliance/gstr8-draft.test.ts b/__tests__/compliance/gstr8-draft.test.ts index 7bb05dd75..e84d003ee 100644 --- a/__tests__/compliance/gstr8-draft.test.ts +++ b/__tests__/compliance/gstr8-draft.test.ts @@ -5,6 +5,12 @@ /** * #1230 — GSTR-8 draft builder invariants: integer-paise clamping, supplier * annex aggregation, IST period labelling, and the honest-empty warning. + * + * #1370 — and the reporting window those labels describe. Every statutory + * period here is an IST calendar month, so both of its boundaries are IST + * midnights expressed as instants. Computing them from UTC components dropped + * the first five and a half hours of the month and swallowed the same span of + * the next one, which no label would ever have shown. */ import { @@ -12,6 +18,11 @@ import { gstr8PeriodLabel, type Gstr8SourceRow, } from "@/lib/compliance/gstr8"; +import { + previousIstCalendarMonthStart, + nextMonthStart, +} from "@/lib/compliance/ist-period"; +import { buildOutwardRegister } from "@/lib/compliance/gst-outward-register"; const AUG_2026_UTC = new Date("2026-08-01T00:00:00Z"); @@ -29,6 +40,53 @@ describe("gstr8PeriodLabel", () => { }); }); +describe("IST calendar-month boundaries", () => { + // The 3rd-of-the-month run, exporting August. + const NOW = new Date("2026-09-04T00:00:00Z"); + const START = previousIstCalendarMonthStart(NOW); + + it("opens and closes at IST midnight, not UTC midnight", () => { + expect(START.toISOString()).toBe("2026-07-31T18:30:00.000Z"); + expect(nextMonthStart(START).toISOString()).toBe( + "2026-08-31T18:30:00.000Z", + ); + }); + + it("covers the opening hours a UTC-midnight window filed against July", () => { + // 2026-08-01T00:15 IST is 2026-07-31T18:45Z, which sits before the old + // 2026-08-01T00:00Z boundary. + const justAfterMidnightIst = new Date("2026-07-31T18:45:00Z"); + expect(justAfterMidnightIst.getTime()).toBeGreaterThanOrEqual( + START.getTime(), + ); + expect(justAfterMidnightIst.getTime()).toBeLessThan( + nextMonthStart(START).getTime(), + ); + }); + + it("still names the IST month in the GSTR-8 label", () => { + expect(gstr8PeriodLabel(START)).toBe("2026-08"); + }); + + it("labels the register for exactly the IST days it covers", () => { + expect( + buildOutwardRegister([], START, nextMonthStart(START)).periodLabel, + ).toBe("2026-08-01 to 2026-08-31"); + }); + + it("labels an operator's UTC-midnight override honestly too", () => { + // GST_REGISTER_PERIOD_START/END parse to UTC midnights, which is why the + // exclusive end is stepped back a whole day rather than a millisecond. + expect( + buildOutwardRegister( + [], + new Date("2026-08-01T00:00:00Z"), + new Date("2026-09-01T00:00:00Z"), + ).periodLabel, + ).toBe("2026-08-01 to 2026-08-31"); + }); +}); + describe("buildGstr8Draft", () => { it("aggregates totals and the seller-wise annex", () => { const rows: Gstr8SourceRow[] = [ diff --git a/__tests__/compliance/private-finance-bucket.test.ts b/__tests__/compliance/private-finance-bucket.test.ts new file mode 100644 index 000000000..edc250e4e --- /dev/null +++ b/__tests__/compliance/private-finance-bucket.test.ts @@ -0,0 +1,130 @@ +/** + * @jest-environment node + */ + +/** + * #1354 — the `org-invoices` bucket had never been created on the live Supabase + * project, so the quarterly TDS return export died with `Bucket not found` and + * the org invoice PDF route carried the same latent failure. These pins hold + * the three properties the fix depends on: it creates the bucket only when it + * is genuinely missing, it no-ops when the bucket is already there, and every + * upload goes through the check so no writer can reintroduce the outage. + */ + +const mockGetBucket = jest.fn(); +const mockCreateBucket = jest.fn(); +const mockUpload = jest.fn(); + +// Relative, not `@/`: jest.mock does not resolve the tsconfig alias here, so an +// aliased specifier registers against a path the module under test never loads. +jest.mock("../../lib/supabase-storage-core", () => ({ + supabaseAdmin: { + storage: { + getBucket: (...a: unknown[]) => mockGetBucket(...a), + createBucket: (...a: unknown[]) => mockCreateBucket(...a), + from: () => ({ upload: (...a: unknown[]) => mockUpload(...a) }), + }, + }, +})); + +/** The memo lives at module scope, so each case needs a fresh module registry. */ +async function freshModule() { + jest.resetModules(); + return import("../../lib/storage/private-finance-object"); +} + +beforeEach(() => { + mockGetBucket.mockReset(); + mockCreateBucket + .mockReset() + .mockResolvedValue({ data: { name: "org-invoices" } }); + mockUpload.mockReset().mockResolvedValue({ error: null }); +}); + +describe("ensurePrivateFinanceBucket", () => { + it("creates the bucket private, once, when getBucket reports it missing", async () => { + mockGetBucket.mockResolvedValue({ + data: null, + error: { message: "not found" }, + }); + const { ensurePrivateFinanceBucket } = await freshModule(); + + await ensurePrivateFinanceBucket(); + await ensurePrivateFinanceBucket(); + + expect(mockCreateBucket).toHaveBeenCalledTimes(1); + expect(mockCreateBucket).toHaveBeenCalledWith("org-invoices", { + public: false, + fileSizeLimit: 25 * 1024 * 1024, + }); + // Memoized: the second call skips the existence round trip entirely. + expect(mockGetBucket).toHaveBeenCalledTimes(1); + }); + + it("no-ops when the bucket already exists", async () => { + mockGetBucket.mockResolvedValue({ + data: { name: "org-invoices" }, + error: null, + }); + const { ensurePrivateFinanceBucket } = await freshModule(); + + await ensurePrivateFinanceBucket(); + await ensurePrivateFinanceBucket(); + + expect(mockCreateBucket).not.toHaveBeenCalled(); + expect(mockGetBucket).toHaveBeenCalledTimes(1); + }); + + it("treats a lost create race as success, since the bucket now exists", async () => { + mockGetBucket + .mockResolvedValueOnce({ data: null, error: { message: "not found" } }) + .mockResolvedValueOnce({ data: { name: "org-invoices" }, error: null }); + mockCreateBucket.mockResolvedValue({ error: { message: "Duplicate" } }); + const { ensurePrivateFinanceBucket } = await freshModule(); + + await expect(ensurePrivateFinanceBucket()).resolves.toBeUndefined(); + }); + + it("does not memoize a rejection, so a transient failure is retried", async () => { + mockGetBucket.mockResolvedValue({ + data: null, + error: { message: "not found" }, + }); + mockCreateBucket + .mockResolvedValueOnce({ error: { message: "boom" } }) + .mockResolvedValueOnce({ data: { name: "org-invoices" } }); + const { ensurePrivateFinanceBucket } = await freshModule(); + + await expect(ensurePrivateFinanceBucket()).rejects.toThrow( + /Failed to create bucket org-invoices/, + ); + await expect(ensurePrivateFinanceBucket()).resolves.toBeUndefined(); + }); +}); + +describe("uploadPrivateFinanceObject", () => { + it("provisions the bucket before writing", async () => { + const order: string[] = []; + mockGetBucket.mockImplementation(() => { + order.push("getBucket"); + return Promise.resolve({ data: null, error: { message: "not found" } }); + }); + mockCreateBucket.mockImplementation(() => { + order.push("createBucket"); + return Promise.resolve({ data: { name: "org-invoices" } }); + }); + mockUpload.mockImplementation(() => { + order.push("upload"); + return Promise.resolve({ error: null }); + }); + const { uploadPrivateFinanceObject } = await freshModule(); + + await uploadPrivateFinanceObject({ + storagePath: "compliance/tds/2026-27-Q2.csv", + body: Buffer.from("a,b\n"), + contentType: "text/csv", + }); + + expect(order).toEqual(["getBucket", "createBucket", "upload"]); + }); +}); diff --git a/__tests__/compliance/tds-return-draft.test.ts b/__tests__/compliance/tds-return-draft.test.ts index d8a161e77..dc7fef06d 100644 --- a/__tests__/compliance/tds-return-draft.test.ts +++ b/__tests__/compliance/tds-return-draft.test.ts @@ -10,6 +10,7 @@ import { buildTdsReturnDraft, + closedIndianFyQuarterOf, indianFyQuarterOf, type TdsReturnSourceRow, } from "@/lib/compliance/tds-return"; @@ -37,38 +38,80 @@ describe("indianFyQuarterOf", () => { it("a March instant belongs to the PREVIOUS FY label (IST-aware)", () => { // 19:00Z = Apr 1 00:30 IST → NEW fiscal year already. - expect(indianFyQuarterOf(new Date("2026-03-31T19:00:00Z")).financialYear).toBe( - "2026-27", - ); + expect( + indianFyQuarterOf(new Date("2026-03-31T19:00:00Z")).financialYear, + ).toBe("2026-27"); // 18:29:59Z is still Mar 31 23:59 IST → prior-FY Q4; 18:30Z tips over. expect(indianFyQuarterOf(new Date("2026-03-31T18:29:59Z")).quarter).toBe(4); expect(indianFyQuarterOf(new Date("2026-03-31T18:30:01Z")).quarter).toBe(1); // 17:00Z = 22:30 IST on Mar 31 → previous FY. - expect(indianFyQuarterOf(new Date("2026-03-31T17:00:00Z")).financialYear).toBe( - "2025-26", - ); + expect( + indianFyQuarterOf(new Date("2026-03-31T17:00:00Z")).financialYear, + ).toBe("2025-26"); + }); +}); + +describe("closedIndianFyQuarterOf", () => { + it("targets the quarter that ended, not the one containing the run (#1354)", () => { + // The workflow fires 01:20 UTC on the 5th of Jan/Apr/Jul/Oct. + expect(closedIndianFyQuarterOf(new Date("2027-04-05T01:20:00Z"))).toEqual({ + financialYear: "2026-27", + quarter: 4, + }); + expect(closedIndianFyQuarterOf(new Date("2027-01-05T01:20:00Z"))).toEqual({ + financialYear: "2026-27", + quarter: 3, + }); + expect(closedIndianFyQuarterOf(new Date("2026-07-05T01:20:00Z"))).toEqual({ + financialYear: "2026-27", + quarter: 1, + }); + expect(closedIndianFyQuarterOf(new Date("2026-10-05T01:20:00Z"))).toEqual({ + financialYear: "2026-27", + quarter: 2, + }); + // Mid-quarter re-run: still the last CLOSED quarter, never the open one. + expect(closedIndianFyQuarterOf(new Date("2026-05-20T09:00:00Z"))).toEqual({ + financialYear: "2025-26", + quarter: 4, + }); }); }); describe("buildTdsReturnDraft", () => { const base: TdsReturnSourceRow[] = [ { - consultantProfileId: "c1", + deducteeType: "CONSULTANT", + deducteeId: "c1", + deducteeName: "Consultant One", + deducteePanLast4: "1234", + deducteeGstin: null, tdsSection: "194O", + paymentCode: "1005", amountCreditedPaise: 1_000_000, tdsDeductedPaise: 1_000, isReversal: false, }, { - consultantProfileId: "c1", + deducteeType: "CONSULTANT", + deducteeId: "c1", + deducteeName: "Consultant One", + deducteePanLast4: "1234", + deducteeGstin: null, tdsSection: "194O", + paymentCode: "1005", amountCreditedPaise: 500_000, tdsDeductedPaise: -500, isReversal: true, }, { - consultantProfileId: "c2", + deducteeType: "CONSULTANT", + deducteeId: "c2", + deducteeName: "Consultant Two", + deducteePanLast4: "5678", + deducteeGstin: null, tdsSection: null, + paymentCode: null, amountCreditedPaise: 250_000, tdsDeductedPaise: 2_500, isReversal: false, @@ -80,7 +123,7 @@ describe("buildTdsReturnDraft", () => { expect(d.totalAmountCreditedPaise).toBe(1_750_000); // 1000 - 500 + 2500 expect(d.totalTdsDeductedNetPaise).toBe(3_000); - const c1 = d.deductees.find((x) => x.consultantProfileId === "c1"); + const c1 = d.deductees.find((x) => x.deducteeId === "c1"); expect(c1?.tdsDeductedNetPaise).toBe(500); // Unstamped rows land under UNKNOWN with a warning, never silently dropped. expect(d.deductees.find((x) => x.tdsSection === "UNKNOWN")).toBeDefined(); @@ -97,8 +140,39 @@ describe("buildTdsReturnDraft", () => { expect(d.warnings.join(" ")).toMatch(/payout pipeline/i); }); - it("always warns about the org-rail return-artifact gap", () => { - const d = buildTdsReturnDraft([], "2026-27", 3); - expect(d.warnings.join(" ")).toMatch(/OrganizationPayout/); + it("emits an organization deductee row alongside a consultant row (#1354)", () => { + const d = buildTdsReturnDraft( + [ + ...base, + { + deducteeType: "ORGANIZATION", + deducteeId: "org1", + deducteeName: "Acme Advisory Pvt Ltd", + deducteePanLast4: "9012", + deducteeGstin: "27AAAAA0000A1Z5", + tdsSection: "194J", + paymentCode: "1004", + amountCreditedPaise: 4_000_000, + tdsDeductedPaise: 400_000, + isReversal: false, + }, + ], + "2026-27", + 2, + ); + + const consultant = d.deductees.find((x) => x.deducteeId === "c1"); + const org = d.deductees.find((x) => x.deducteeId === "org1"); + expect(consultant?.deducteeType).toBe("CONSULTANT"); + expect(org?.deducteeType).toBe("ORGANIZATION"); + expect(org?.tdsSection).toBe("194J"); + expect(org?.tdsDeductedNetPaise).toBe(400_000); + // 1_750_000 consultant credits + the org's 4_000_000. + expect(d.totalAmountCreditedPaise).toBe(5_750_000); + // 1000 - 500 + 2500 + 400_000. + expect(d.totalTdsDeductedNetPaise).toBe(403_000); + // The org rail files a real return line now, so the standing "no return + // artifact" warning must be gone rather than merely inaccurate. + expect(d.warnings.join(" ")).not.toMatch(/OrganizationPayout/); }); }); diff --git a/__tests__/enterprise/catalog-earnings-attribution.test.ts b/__tests__/enterprise/catalog-earnings-attribution.test.ts index 40bcb4e23..c9b65daaf 100644 --- a/__tests__/enterprise/catalog-earnings-attribution.test.ts +++ b/__tests__/enterprise/catalog-earnings-attribution.test.ts @@ -41,6 +41,9 @@ jest.mock("../../lib/collaborators/service", () => ({ })); jest.mock("../../lib/api/organizations/rate-card", () => ({ resolveEffectiveRateCard: jest.fn(), + // #1335 — settlement destructures this from the same module; a partial mock + // leaves it undefined and every split throws before it resolves a card. + isScopedRateCardResolutionEnabled: () => false, })); jest.mock("../../lib/feature-flags", () => ({ ...jest.requireActual("../../lib/feature-flags"), diff --git a/__tests__/enterprise/collaborator-org-earnings.test.ts b/__tests__/enterprise/collaborator-org-earnings.test.ts index 32129d9e9..84708745b 100644 --- a/__tests__/enterprise/collaborator-org-earnings.test.ts +++ b/__tests__/enterprise/collaborator-org-earnings.test.ts @@ -44,6 +44,9 @@ jest.mock("../../lib/collaborators/service", () => ({ jest.mock("../../lib/api/organizations/rate-card", () => ({ resolveEffectiveRateCard: jest.fn(), + // #1335 — settlement destructures this from the same module; a partial mock + // leaves it undefined and every split throws before it resolves a card. + isScopedRateCardResolutionEnabled: () => false, })); // #812 — this suite verifies the per-collaborator EARNINGS-split logic, not the @@ -100,10 +103,12 @@ jest.mock("../../lib/prisma", () => { }, consultantEarnings: { findFirst: jest.fn().mockResolvedValue(null), - create: jest.fn().mockImplementation(async ({ data }: { data: { id?: string } }) => ({ - id: "earnings-" + Math.random().toString(36).slice(2, 8), - ...data, - })), + create: jest + .fn() + .mockImplementation(async ({ data }: { data: { id?: string } }) => ({ + id: "earnings-" + Math.random().toString(36).slice(2, 8), + ...data, + })), }, consultantProfile: { update: jest.fn().mockResolvedValue({}), @@ -115,28 +120,33 @@ jest.mock("../../lib/prisma", () => { count: jest.fn().mockResolvedValue(1), }, organizationEarnings: { - create: jest.fn().mockImplementation(async ({ data }: { data: CapturedCreate }) => { - const key = `${data.paymentId}::${data.organizationId}`; - if (p2002Targets.has(key)) { - // Simulate Prisma P2002 unique constraint violation. - // We can't import Prisma's real error class without dragging - // the runtime in; throw an object that quacks like one. - const err = new Error("Unique constraint failed") as Error & { - code: string; - clientVersion: string; - meta: Record; - }; - err.code = "P2002"; - err.clientVersion = "test"; - err.meta = { target: ["paymentId", "organizationId"] }; - // Re-tag prototype so `instanceof Prisma.PrismaClientKnownRequestError` matches - const { Prisma } = jest.requireActual("@prisma/client"); - Object.setPrototypeOf(err, Prisma.PrismaClientKnownRequestError.prototype); - throw err; - } - capturedOrgEarnings.push(data); - return { id: "org-earn-" + capturedOrgEarnings.length, ...data }; - }), + create: jest + .fn() + .mockImplementation(async ({ data }: { data: CapturedCreate }) => { + const key = `${data.paymentId}::${data.organizationId}`; + if (p2002Targets.has(key)) { + // Simulate Prisma P2002 unique constraint violation. + // We can't import Prisma's real error class without dragging + // the runtime in; throw an object that quacks like one. + const err = new Error("Unique constraint failed") as Error & { + code: string; + clientVersion: string; + meta: Record; + }; + err.code = "P2002"; + err.clientVersion = "test"; + err.meta = { target: ["paymentId", "organizationId"] }; + // Re-tag prototype so `instanceof Prisma.PrismaClientKnownRequestError` matches + const { Prisma } = jest.requireActual("@prisma/client"); + Object.setPrototypeOf( + err, + Prisma.PrismaClientKnownRequestError.prototype, + ); + throw err; + } + capturedOrgEarnings.push(data); + return { id: "org-earn-" + capturedOrgEarnings.length, ...data }; + }), }, membership: { findFirst: jest.fn(), @@ -155,9 +165,11 @@ jest.mock("../../lib/prisma", () => { return { __esModule: true, default: { - $transaction: jest.fn().mockImplementation(async (fn: (tx: unknown) => Promise) => { - return await fn(mockTx); - }), + $transaction: jest + .fn() + .mockImplementation(async (fn: (tx: unknown) => Promise) => { + return await fn(mockTx); + }), // Expose tx-bound mocks via the default export so tests can // configure findFirst per-case. __mockTx: mockTx, @@ -172,12 +184,16 @@ import { createEarningsFromPayment } from "@/lib/payments/payouts/earnings-servi // `findFirst` is the time-scoped membership lookup inside resolveOrgSplit. // We set it per-test to map consultantProfileId -> { orgId, payoutRecipient }. -const mockedTx = (prisma as unknown as { __mockTx: { - membership: { findFirst: jest.Mock }; - consultantEarnings: { findFirst: jest.Mock; create: jest.Mock }; - organization: { findUnique: jest.Mock }; - organizationEarnings: { create: jest.Mock }; -} }).__mockTx; +const mockedTx = ( + prisma as unknown as { + __mockTx: { + membership: { findFirst: jest.Mock }; + consultantEarnings: { findFirst: jest.Mock; create: jest.Mock }; + organization: { findUnique: jest.Mock }; + organizationEarnings: { create: jest.Mock }; + }; + } +).__mockTx; const mockedCalculateSplit = calculateRevenueSplit as jest.MockedFunction< typeof calculateRevenueSplit @@ -207,7 +223,10 @@ function makePayment(overrides: Partial<{ id: string; amount: number }> = {}) { * consultant (no HOST-org membership). */ function setMembershipMap( - map: Record, + map: Record< + string, + { orgId: string; payoutRecipient?: "SELF" | "ORGANIZATION" } | null + >, ) { mockedTx.membership.findFirst.mockImplementation( async (args: { where: { consultantProfileId: string } }) => { @@ -262,7 +281,10 @@ beforeEach(() => { err.code = "P2002"; err.clientVersion = "test"; err.meta = { target: ["paymentId", "organizationId"] }; - Object.setPrototypeOf(err, Prisma.PrismaClientKnownRequestError.prototype); + Object.setPrototypeOf( + err, + Prisma.PrismaClientKnownRequestError.prototype, + ); throw err; } capturedOrgEarnings.push(data); @@ -285,8 +307,16 @@ describe("A3 (Q3): per-collaborator HOST-org earnings", () => { // the independent collab → owner keeps the rest. mockedCalculateSplit.mockResolvedValue([ { consultantProfileId: PRIMARY_PROFILE, share: 42_500, role: "OWNER" }, - { consultantProfileId: COLLAB_HOST_PROFILE, share: 25_500, role: "CO_HOST" }, - { consultantProfileId: COLLAB_INDEP_PROFILE, share: 17_000, role: "CO_HOST" }, + { + consultantProfileId: COLLAB_HOST_PROFILE, + share: 25_500, + role: "CO_HOST", + }, + { + consultantProfileId: COLLAB_INDEP_PROFILE, + share: 17_000, + role: "CO_HOST", + }, ]); await createEarningsFromPayment({ @@ -323,8 +353,8 @@ describe("A3 (Q3): per-collaborator HOST-org earnings", () => { expect(anotherAgency!.rateCardIdApplied).toBe(`rc-${ORG_ANOTHER}`); // No row for the independent collaborator's profile id was ever passed. - const independentRows = capturedOrgEarnings.filter( - (r) => r.organizationId.includes("indep"), + const independentRows = capturedOrgEarnings.filter((r) => + r.organizationId.includes("indep"), ); expect(independentRows).toHaveLength(0); }); @@ -337,7 +367,11 @@ describe("A3 (Q3): per-collaborator HOST-org earnings", () => { mockedCalculateSplit.mockResolvedValue([ { consultantProfileId: PRIMARY_PROFILE, share: 60_000, role: "OWNER" }, - { consultantProfileId: COLLAB_SAME_ORG_PROFILE, share: 25_000, role: "CO_HOST" }, + { + consultantProfileId: COLLAB_SAME_ORG_PROFILE, + share: 25_000, + role: "CO_HOST", + }, ]); // Pre-arm the P2002 trap on (PAYMENT_ID, ORG_LEARNPRO) to fire on @@ -358,7 +392,10 @@ describe("A3 (Q3): per-collaborator HOST-org earnings", () => { err.code = "P2002"; err.clientVersion = "test"; err.meta = { target: ["paymentId", "organizationId"] }; - Object.setPrototypeOf(err, Prisma.PrismaClientKnownRequestError.prototype); + Object.setPrototypeOf( + err, + Prisma.PrismaClientKnownRequestError.prototype, + ); throw err; } } @@ -396,7 +433,11 @@ describe("A3 (Q3): per-collaborator HOST-org earnings", () => { mockedCalculateSplit.mockResolvedValue([ { consultantProfileId: PRIMARY_PROFILE, share: 70_000, role: "OWNER" }, - { consultantProfileId: COLLAB_HOST_PROFILE, share: 30_000, role: "CO_HOST" }, + { + consultantProfileId: COLLAB_HOST_PROFILE, + share: 30_000, + role: "CO_HOST", + }, ]); await createEarningsFromPayment({ diff --git a/__tests__/enterprise/consent-gates.test.ts b/__tests__/enterprise/consent-gates.test.ts index bcc804147..2d263a918 100644 --- a/__tests__/enterprise/consent-gates.test.ts +++ b/__tests__/enterprise/consent-gates.test.ts @@ -9,16 +9,35 @@ */ const mockFindFirst = jest.fn(); +const mockUpdateMany = jest.fn(); jest.mock("../../lib/prisma", () => ({ __esModule: true, - default: { consentArtifact: { findFirst: (...a: unknown[]) => mockFindFirst(...a) } }, + default: { + consentArtifact: { + findFirst: (...a: unknown[]) => mockFindFirst(...a), + updateMany: (...a: unknown[]) => mockUpdateMany(...a), + }, + }, })); -import { checkConsent } from "@/lib/compliance/dpdp"; -import { PURPOSE_CODES } from "@/lib/compliance/purpose-codes"; +// withdrawConsent fires a SESSION_BOOKING cascade event through a dynamic +// import; the gate under test does not depend on it. +jest.mock("../../lib/enterprise/system-events", () => ({ + __esModule: true, + recordSystemEvent: jest.fn(), +})); + +import { checkConsent, withdrawConsent } from "@/lib/compliance/dpdp"; +import { + PURPOSE_CODES, + purposeCodeAliases, +} from "@/lib/compliance/purpose-codes"; -beforeEach(() => mockFindFirst.mockReset()); +beforeEach(() => { + mockFindFirst.mockReset(); + mockUpdateMany.mockReset(); +}); describe("checkConsent — fail-closed (#701)", () => { it("is true when a live artifact for the purpose exists", async () => { @@ -31,7 +50,8 @@ describe("checkConsent — fail-closed (#701)", () => { // The query must filter withdrawn + expired out and match the purpose code. const where = mockFindFirst.mock.calls[0][0].where; expect(where.userId).toBe("u1"); - expect(where.purposeCodes).toEqual({ has: PURPOSE_CODES.SESSION_BOOKING }); + // #1472 — the canonical code plus its legacy aliases, not an exact match. + expect(where.purposeCodes.hasSome).toContain(PURPOSE_CODES.SESSION_BOOKING); expect(where.withdrawnAt).toBeNull(); expect(where.auditRetainedUntil.gt).toBeInstanceOf(Date); }); @@ -50,3 +70,53 @@ describe("checkConsent — fail-closed (#701)", () => { expect(PURPOSE_CODES.PRIMARY_PROCESSING).toBe("PRIMARY_PROCESSING"); }); }); + +/** + * #1472 — the pre-taxonomy kebab-case codes are still on disk (no backfill: + * pre-MVP reset). A consent record is a legal artifact, so the gate that + * decides whether a consultant can be booked must recognise every code the + * platform ever wrote for that purpose, and a narrow withdrawal must reach the + * same rows the gate reads. + */ +describe("#1472 legacy purpose codes are recognised by the runtime gates", () => { + /** Match an artifact's stored codes against a `hasSome` clause, as PG would. */ + const matches = ( + where: { purposeCodes: { hasSome: string[] } }, + stored: string[], + ) => stored.some((code) => where.purposeCodes.hasSome.includes(code)); + + it("lets a `session-booking` artifact satisfy a SESSION_BOOKING check", async () => { + mockFindFirst.mockResolvedValue({ id: "legacy-artifact" }); + await checkConsent({ + userId: "u1", + purposeCode: PURPOSE_CODES.SESSION_BOOKING, + }); + + const where = mockFindFirst.mock.calls[0][0].where; + expect(matches(where, ["session-booking"])).toBe(true); + // An unrelated legacy code is NOT swept in by the same alias set. + expect(matches(where, ["marketing"])).toBe(false); + }); + + it("withdraws a `session-booking` artifact on a SESSION_BOOKING withdrawal", async () => { + mockUpdateMany.mockResolvedValue({ count: 1 }); + await withdrawConsent({ + userId: "u1", + purposeCode: PURPOSE_CODES.SESSION_BOOKING, + }); + + const where = mockUpdateMany.mock.calls[0][0].where; + expect(matches(where, ["session-booking"])).toBe(true); + expect(matches(where, ["third-party-sharing-with-stream"])).toBe(false); + }); + + it("resolves aliases per purpose, never across purposes", () => { + expect(purposeCodeAliases(PURPOSE_CODES.SESSION_BOOKING)).toEqual([ + "SESSION_BOOKING", + "session-booking", + ]); + expect(purposeCodeAliases(PURPOSE_CODES.MARKETING_COMMS)).not.toContain( + "session-booking", + ); + }); +}); 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..74045a8f3 --- /dev/null +++ b/__tests__/enterprise/org-payout-withholding-postings.test.ts @@ -0,0 +1,288 @@ +/** + * @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", + }), + ); + }); + + it("refuses a negative payout that satisfies the identity arithmetically", async () => { + // #1473 review — -852,516 + 0 === -852,516 passes the equation, and the + // posting site skips the journal for anything not > 0, so without the + // non-negativity guard this row would settle COMPLETED with no ledger entry. + mockedPrisma.organizationPayout.findUniqueOrThrow.mockResolvedValue( + payoutRow({ + netPayoutPaise: -NET_PAYOUT_PAISE, + amountPaise: -NET_PAYOUT_PAISE, + tdsAmountPaise: 0, + }), + ); + + await expect(markOrgPayoutCompleted(PAYOUT_ID)).rejects.toThrow( + OrgPayoutWithholdingMismatchError, + ); + + expect(postLedgerTxn).not.toHaveBeenCalled(); + expect(recordOrgTDSDeduction).not.toHaveBeenCalled(); + }); +}); + +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/overage-settlement-legsum.test.ts b/__tests__/enterprise/overage-settlement-legsum.test.ts index 978f06b86..3752beb17 100644 --- a/__tests__/enterprise/overage-settlement-legsum.test.ts +++ b/__tests__/enterprise/overage-settlement-legsum.test.ts @@ -37,8 +37,16 @@ function makeTx(opts: { surchargeBps?: number | null; priceCap?: number | null; overageBehavior?: "CHARGE_ORG" | "CHARGE_MEMBER"; + /** #1458 — which funding rail wrote the parent's base leg. */ + baseSource?: "INVOICE_ACCRUAL" | "WALLET" | "LICENSE"; }) { - const legs: Leg[] = [{ source: "INVOICE_ACCRUAL", amountPaise: opts.price }]; + const legs: Leg[] = [ + { + source: opts.baseSource ?? "INVOICE_ACCRUAL", + // A licence leg is deliberately zero-value: the contract already paid. + amountPaise: opts.baseSource === "LICENSE" ? 0 : opts.price, + }, + ]; const payment = { amount: opts.price }; const children: { amount: number }[] = []; let childSeq = 0; @@ -168,6 +176,70 @@ describe("recordOverageAtCheckout — CHARGE_ORG leg-sum invariant (#785)", () = }); }); +describe("recordOverageAtCheckout — CHARGE_ORG on the WALLET rail (#1458)", () => { + it("leaves the payment at the wallet debit, adds no leg, and records the overage as collected", async () => { + const walletDebit = 258_326; + const { state, tx } = makeTx({ + price: walletDebit, + cap: 5, + used: 5, + baseSource: "WALLET", + }); + await recordOverageAtCheckout({ + tx: tx as unknown as Tx, + ...callArgs(walletDebit), + }); + + // The wallet already took the whole price at commit, so the marginal is + // collected: no OVERAGE_INVOICE_ACCRUAL leg, no amount bump. + expect(state.legs).toEqual([ + { source: "WALLET", amountPaise: walletDebit }, + ]); + expect(state.payment.amount).toBe(walletDebit); + // The cancellation quote is a percentage of Payment.amount and the refund + // cascade splits it across the legs, so one WALLET leg equal to amount is + // what makes a 100% refund return exactly the debit and not a paisa more. + expect(sum(state.legs)).toBe(state.payment.amount); + expect(tx.paymentLeg.create).not.toHaveBeenCalled(); + expect(tx.payment.update).not.toHaveBeenCalled(); + + expect(tx.overageEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + overageBehavior: "CHARGE_ORG", + chargeStatus: "CHARGED", + paymentId: "pay1", + settledAt: expect.any(Date), + }), + }), + ); + }); + + // A licence is a flat fee settled at contract time, so its leg is ₹0 while + // Payment.amount stays at the full price. Adding an overage leg re-arms the + // leg-sum comparison the licence carve had suppressed, and the booking used + // to die at COMMIT on assert_payment_legs_ok instead of saying why. + it("LICENSE + CHARGE_ORG is refused rather than made additive", async () => { + const { state, tx } = makeTx({ + price: 100_000, + cap: 5, + used: 5, + baseSource: "LICENSE", + }); + + await expect( + recordOverageAtCheckout({ + tx: tx as unknown as Tx, + ...callArgs(100_000), + }), + ).rejects.toMatchObject({ code: "OVERAGE_UNSUPPORTED_FUNDING" }); + + expect(tx.paymentLeg.create).not.toHaveBeenCalled(); + expect(tx.payment.update).not.toHaveBeenCalled(); + expect(state.legs).toEqual([{ source: "LICENSE", amountPaise: 0 }]); + }); +}); + describe("recordOverageAtCheckout — CHARGE_MEMBER parent carve (#785)", () => { it("carves basePaise off the org parent; member child pays the marginal (no double-collect)", async () => { const { state, tx } = makeTx({ diff --git a/__tests__/enterprise/payment-leg-invariant.test.ts b/__tests__/enterprise/payment-leg-invariant.test.ts index 780ceef11..b10b8cb66 100644 --- a/__tests__/enterprise/payment-leg-invariant.test.ts +++ b/__tests__/enterprise/payment-leg-invariant.test.ts @@ -11,11 +11,12 @@ * - assertPaymentLegsSumToAmount — hard-throwing sibling for tests + * reconciliation jobs. * - * The invariant: `sum(legs.amountPaise) === Payment.amount`. LICENSE - * legs intentionally carry zero amount (cost absorbed at contract time) - * and are still part of the sum. Referral-credit flows mix a CARD leg - * (post-credit gateway charge) with a REFERRAL_CREDIT leg (the credit - * value) whose sum equals Payment.amount (post-credit). + * The invariant: `sum(non-reversal, non-REFERRAL_CREDIT legs.amountPaise) + * === Payment.amount`. LICENSE legs intentionally carry zero amount (cost + * absorbed at contract time) and are still part of the sum. REFERRAL_CREDIT + * legs are not: `Payment.amount` is the gateway charge and the credit has + * already been netted out of it, so a credit-funded checkout has its CARD leg + * alone equal to `Payment.amount` (#1347). */ import { @@ -53,12 +54,13 @@ describe("checkPaymentLegsSumToAmount", () => { ).toBeNull(); }); - it("returns null when CARD + REFERRAL_CREDIT legs sum to Payment.amount", () => { - // Post-credit scenario: gateway charged 100 paise, credits covered - // 50, Payment.amount = 150 (pre-credit total). + it("returns null when the CARD leg alone matches a credit-funded Payment.amount", () => { + // #1347 — gateway charged 100 paise, credits covered 50, so + // Payment.amount is the post-credit 100. The REFERRAL_CREDIT leg records + // the platform's 50 but is excluded from the funding sum. expect( checkPaymentLegsSumToAmount({ - paymentAmountPaise: 150, + paymentAmountPaise: 100, legs: [ { source: "CARD", amountPaise: 100 }, { source: "REFERRAL_CREDIT", amountPaise: 50 }, @@ -67,6 +69,22 @@ describe("checkPaymentLegsSumToAmount", () => { ).toBeNull(); }); + it("still reports drift when a REFERRAL_CREDIT leg sits beside a CARD leg that misses the amount", () => { + // Excluding the credit must not blind the check to the funding it does + // cover: the card is short by 20 and that is real drift. + const mismatch = checkPaymentLegsSumToAmount({ + paymentAmountPaise: 100, + legs: [ + { source: "CARD", amountPaise: 80 }, + { source: "REFERRAL_CREDIT", amountPaise: 50 }, + ], + }); + expect(mismatch).not.toBeNull(); + expect(mismatch?.reason).toBe("FUNDING_SUM_DRIFT"); + expect(mismatch?.legSumPaise).toBe(80); + expect(mismatch?.deltaPaise).toBe(-20); + }); + it("returns a mismatch payload when legs sum is less than the amount", () => { const mismatch = checkPaymentLegsSumToAmount({ paymentAmountPaise: 100, @@ -83,7 +101,7 @@ describe("checkPaymentLegsSumToAmount", () => { paymentAmountPaise: 100, legs: [ { source: "CARD", amountPaise: 80 }, - { source: "REFERRAL_CREDIT", amountPaise: 40 }, + { source: "WALLET", amountPaise: 40 }, ], }); expect(mismatch?.deltaPaise).toBe(20); @@ -188,18 +206,19 @@ describe("checkPaymentLegsSumToAmount — zero-value LICENSE exemption", () => { expect(mismatch!.legSumPaise).toBe(250000); }); - it("exempts a license booking whose accrual reversal nets it back to nothing", () => { - // Reversal sources are filtered out before the sum runs, so a refunded - // license booking is still judged on its original legs alone. - expect( - checkPaymentLegsSumToAmount({ - paymentAmountPaise: 500000, - legs: [ - { source: "LICENSE", amountPaise: 0 }, - { source: "INVOICE_ACCRUAL_REVERSAL", amountPaise: -0 }, - ], - }), - ).toBeNull(); + it("rejects a zero accrual reversal on a license booking", () => { + // #1347 — zero is rejected on BOTH sides: the checker and + // `assert_payment_legs_ok` agree a reversal must be strictly negative, so + // a zero one is an orphan counter-entry rather than a benign no-op. The + // sum carve still suppresses the sum comparison; it never reaches here. + const mismatch = checkPaymentLegsSumToAmount({ + paymentAmountPaise: 500000, + legs: [ + { source: "LICENSE", amountPaise: 0 }, + { source: "INVOICE_ACCRUAL_REVERSAL", amountPaise: 0 }, + ], + }); + expect(mismatch?.reason).toBe("REVERSAL_PAIR_VIOLATION"); }); it("does NOT exempt a LICENSE leg that carries a non-zero amount", () => { @@ -237,3 +256,65 @@ describe("checkPaymentLegsSumToAmount — zero-value LICENSE exemption", () => { expect(mismatch!.legSumPaise).toBe(0); }); }); + +/** + * #786 — partial-refund reversal pairs. + * + * A refund appends a negative `*_REVERSAL` sibling rather than mutating the + * original leg, so the originals keep summing to `Payment.amount` and the + * reversal is judged against its own pair: it must be negative and must never + * exceed the originals it reverses. `assert_payment_legs_ok` enforces the same + * two rules in the database, including on a licence-only payment, so the + * checker has to reach them there too — otherwise a leg the checker waved + * through would still be rejected at COMMIT (#1347). + */ +describe("checkPaymentLegsSumToAmount — reversal pairs", () => { + it("accepts a partial reversal that stays inside its original sibling", () => { + expect( + checkPaymentLegsSumToAmount({ + paymentAmountPaise: 500000, + legs: [ + { source: "INVOICE_ACCRUAL", amountPaise: 500000 }, + { source: "INVOICE_ACCRUAL_REVERSAL", amountPaise: -200000 }, + ], + }), + ).toBeNull(); + }); + + it("rejects a partial reversal larger than the accrual it reverses", () => { + const mismatch = checkPaymentLegsSumToAmount({ + paymentAmountPaise: 500000, + legs: [ + { source: "INVOICE_ACCRUAL", amountPaise: 500000 }, + { source: "INVOICE_ACCRUAL_REVERSAL", amountPaise: -600000 }, + ], + }); + expect(mismatch?.reason).toBe("REVERSAL_PAIR_VIOLATION"); + }); + + it("rejects a positive reversal leg", () => { + // A reversal is a counter-entry; a positive one would credit the org twice. + const mismatch = checkPaymentLegsSumToAmount({ + paymentAmountPaise: 500000, + legs: [ + { source: "INVOICE_ACCRUAL", amountPaise: 500000 }, + { source: "INVOICE_ACCRUAL_REVERSAL", amountPaise: 200000 }, + ], + }); + expect(mismatch?.reason).toBe("REVERSAL_PAIR_VIOLATION"); + }); + + it("still checks the pair on a licence-only payment the sum carve exempts", () => { + // The zero-LICENSE carve skips the SUM comparison only. A reversal with no + // original sibling is corrupt whichever way the sum is read, and the + // trigger raises on it, so the checker must agree rather than return early. + const mismatch = checkPaymentLegsSumToAmount({ + paymentAmountPaise: 500000, + legs: [ + { source: "LICENSE", amountPaise: 0 }, + { source: "INVOICE_ACCRUAL_REVERSAL", amountPaise: -200000 }, + ], + }); + expect(mismatch?.reason).toBe("REVERSAL_PAIR_VIOLATION"); + }); +}); diff --git a/__tests__/enterprise/payout-webhook-reconciler.test.ts b/__tests__/enterprise/payout-webhook-reconciler.test.ts index a79e5392a..ce0adc25c 100644 --- a/__tests__/enterprise/payout-webhook-reconciler.test.ts +++ b/__tests__/enterprise/payout-webhook-reconciler.test.ts @@ -283,4 +283,25 @@ describe("handleRazorpayPayoutWebhook — OrganizationPayout reconciliation", () "Beneficiary blocked", ); }); + + // #1451 — `failed` was missing from the consultant status map, so a bank + // failure fell through to the PENDING default and the payout stayed in + // flight with its earnings BATCHED. FAILED is what un-batches them. + it("consultant payout.failed → handlePayoutWebhook with FAILED, not the PENDING default", async () => { + mockedPrisma.organizationPayout.findUnique.mockResolvedValue(null); + + await handleRazorpayPayoutWebhook("payout.failed", { + id: "pout_consultant", + status: "failed", + failure_reason: "Insufficient bank balance", + }); + + expect(mockedHandlePayoutWebhook).toHaveBeenCalledWith( + "RAZORPAY", + "pout_consultant", + "FAILED", + "Insufficient bank balance", + undefined, + ); + }); }); diff --git a/__tests__/enterprise/po-balance-enforcement.test.ts b/__tests__/enterprise/po-balance-enforcement.test.ts index d63193682..7fa47dfd0 100644 --- a/__tests__/enterprise/po-balance-enforcement.test.ts +++ b/__tests__/enterprise/po-balance-enforcement.test.ts @@ -211,6 +211,10 @@ function setupActivePo(orgId = "org-1") { mockedPrisma.purchaseOrder.findUnique.mockResolvedValue({ organizationId: orgId, status: "ACTIVE", + // #1396 — the route now compares the PO's currency against the invoice's + // before it claims any balance, and repeats the comparison in the CAS + // predicate, so the pre-flight read has to carry it. + currency: "INR", }); } @@ -251,6 +255,7 @@ describe("POST /api/organizations/[orgId]/billing-account/invoices — PO balanc id: "po-1", organizationId: "org-1", status: "ACTIVE", + currency: "INR", remainingAmountPaise: { gte: 5000 }, }, data: { remainingAmountPaise: { decrement: 5000 } }, diff --git a/__tests__/enterprise/reachable-paths.test.ts b/__tests__/enterprise/reachable-paths.test.ts index 5741da067..bb98ae3a1 100644 --- a/__tests__/enterprise/reachable-paths.test.ts +++ b/__tests__/enterprise/reachable-paths.test.ts @@ -13,6 +13,7 @@ import { REACHABLE_ORG_FUNDING_PATHS, isReachableOrgFundingPath, + overageBehaviorUnsupportedReason, capabilityOf, } from "@/lib/enterprise/reachable-paths"; @@ -69,6 +70,58 @@ describe("REACHABLE_ORG_FUNDING_PATHS — v0 lockdown matrix", () => { }); }); + // #1458 — the matrix sanctions SPONSOR + WALLET + CREDIT_POOL, but a wallet + // debit takes the whole booking price at commit, so there is nothing left to + // carve back out for a member charge. Checkout could only fail closed after + // the member had picked a slot; the config is what has to be refused. + describe("overageBehaviorUnsupportedReason", () => { + it("refuses CHARGE_MEMBER on a WALLET-funded account, naming #715", () => { + const reason = overageBehaviorUnsupportedReason( + "WALLET", + "CHARGE_MEMBER", + ); + expect(reason).toContain("#715"); + }); + + it("refuses either charging behaviour on a LICENSE-funded account", () => { + // A flat licence moves no money per booking, so nothing carries the + // marginal and the leg-sum guard rejects the extra leg at COMMIT. + expect( + overageBehaviorUnsupportedReason("LICENSE", "CHARGE_ORG"), + ).toContain("licence"); + expect( + overageBehaviorUnsupportedReason("LICENSE", "CHARGE_MEMBER"), + ).toContain("licence"); + expect(overageBehaviorUnsupportedReason("LICENSE", "BLOCK")).toBeNull(); + }); + + it("allows CHARGE_ORG and BLOCK on WALLET, and CHARGE_MEMBER on INVOICE", () => { + expect( + overageBehaviorUnsupportedReason("WALLET", "CHARGE_ORG"), + ).toBeNull(); + expect(overageBehaviorUnsupportedReason("WALLET", "BLOCK")).toBeNull(); + expect( + overageBehaviorUnsupportedReason("INVOICE", "CHARGE_MEMBER"), + ).toBeNull(); + }); + + // A wallet debit collects the booking price, so the plain over-cap amount + // rides along inside it; a surcharge sits on top of that price and nothing + // collects it. Checkout refuses it either way, so the configuration must. + it("refuses a surcharged CHARGE_ORG on WALLET but not the plain one", () => { + expect( + overageBehaviorUnsupportedReason("WALLET", "CHARGE_ORG", 1000), + ).toContain("surcharge"); + expect( + overageBehaviorUnsupportedReason("WALLET", "CHARGE_ORG", 0), + ).toBeNull(); + // The surcharge only matters on the wallet rail — an invoice can carry it. + expect( + overageBehaviorUnsupportedReason("INVOICE", "CHARGE_ORG", 1000), + ).toBeNull(); + }); + }); + describe("capabilityOf", () => { it.each([ [true, false, "SPONSOR"], diff --git a/__tests__/enterprise/tds-org-payout-input.test.ts b/__tests__/enterprise/tds-org-payout-input.test.ts index bd8856560..a115f6d41 100644 --- a/__tests__/enterprise/tds-org-payout-input.test.ts +++ b/__tests__/enterprise/tds-org-payout-input.test.ts @@ -17,9 +17,68 @@ * silently ships `tdsAmountPaise=0` because the old code never called * the TDS helper at all — and the #785 regression where the encrypted-PAN * ciphertext was passed as `panNumber` and wrongly hit the 5% fallback. + * + * The last block leaves the pure-arithmetic surface and drives + * `markOrgPayoutCompleted` itself, because the rate a payout carries is what + * decides whether the completion can file a `TDSRecord` at all (#1354). */ +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 { computeTdsForPayout } from "@/lib/compliance/tds"; +import prisma from "@/lib/prisma"; +import { recordSystemError } from "@/lib/enterprise/system-events"; +import { reportSentryMessage } from "@/lib/observability/report"; +import { recordOrgTDSDeduction } from "@/lib/payments/tax/tds-service"; +import { markOrgPayoutCompleted } from "@/lib/payments/payouts/org-payout-service"; describe("org payout TDS construction", () => { it("Resident host org with valid PAN → 194-O 0.1%", () => { @@ -125,3 +184,77 @@ describe("org payout TDS construction", () => { expect(r.tdsAmountPaise).toBe(9); }); }); + +describe("markOrgPayoutCompleted — withheld TDS with no stored rate (#1354)", () => { + const PAYOUT_ID = "op_legacy_no_rate"; + const ORG_ID = "org-legacy-1"; + + const mockedPrisma = prisma as unknown as { + organizationPayout: { + updateMany: jest.Mock; + findUniqueOrThrow: jest.Mock; + aggregate: jest.Mock; + }; + tDSRecord: { deleteMany: jest.Mock }; + $transaction: jest.Mock; + }; + + 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.organizationPayout.aggregate.mockResolvedValue({ + _sum: { netPayoutPaise: 0 }, + }); + }); + + it("writes no TDSRecord and reports the gap through the system-error recorder", async () => { + // A payout batched before `tdsRateAppliedBps` existed: the withholding is + // real and already on TDS_PAYABLE, but the rate the return must cite is + // not recoverable from it (computeTdsForPayout floors, so gross/tds does + // not invert). + mockedPrisma.organizationPayout.findUniqueOrThrow.mockResolvedValue({ + 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, + currency: "INR", + organization: { name: "Legacy Org" }, + }); + + const result = await markOrgPayoutCompleted(PAYOUT_ID); + + // The completion still stands — cash moved, so it is never rolled back. + expect(result).toEqual({ wasNoOp: false, status: "COMPLETED" }); + expect(recordOrgTDSDeduction).not.toHaveBeenCalled(); + expect(mockedPrisma.tDSRecord.deleteMany).not.toHaveBeenCalled(); + + expect(recordSystemError).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + category: "PAYOUT", + summary: expect.stringContaining("ORG_PAYOUT_TDS_RATE_MISSING"), + context: expect.objectContaining({ + orgPayoutId: PAYOUT_ID, + organizationId: ORG_ID, + tdsAmountPaise: 1_000, + }), + }), + ); + expect(reportSentryMessage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ level: "warning" }), + ); + }); +}); diff --git a/__tests__/enterprise/wallet-null-cached-balance.test.ts b/__tests__/enterprise/wallet-null-cached-balance.test.ts new file mode 100644 index 000000000..3aa6a68df --- /dev/null +++ b/__tests__/enterprise/wallet-null-cached-balance.test.ts @@ -0,0 +1,126 @@ +/** + * @jest-environment node + */ + +/** + * #1459 — `BillingAccount.walletBalance` is nullable, and an INVOICE-funded + * account is created without one. Postgres evaluates `NULL + amount` to `NULL`, + * so `walletCredit`'s increment silently no-opped on exactly those accounts + * while the ledger CREDIT posted: the cache and the journal disagreed forever, + * and only the nightly reconciler noticed. The fake transaction below reproduces + * that NULL arithmetic rather than JavaScript's, which is what makes the pin + * meaningful — an `Int?` column that behaves like zero would have passed before + * the fix too. + */ + +jest.mock("../../lib/prisma", () => ({ __esModule: true, default: {} })); +jest.mock("../../lib/payments/ledger/post", () => ({ + postLedgerTxn: jest.fn().mockResolvedValue(undefined), +})); + +import { postLedgerTxn } from "../../lib/payments/ledger/post"; +import { + WalletInsufficientFundsError, + walletCredit, + walletDebit, +} from "../../lib/api/organizations/wallet"; + +/** A billing account row whose cached balance obeys Postgres NULL arithmetic. */ +function invoiceFundedAccount() { + const row = { + walletBalance: null as number | null, + currency: "INR", + ownerOrgId: "org_1", + }; + return { + row, + tx: { + billingAccount: { + updateMany: jest.fn( + async (args: { + where: { walletBalance?: null | { gte: number } }; + data: { walletBalance: number | { decrement: number } }; + }) => { + const guard = args.where.walletBalance; + // `walletBalance: null` in the WHERE matches only a row that still + // has no cached balance, exactly like the SQL `IS NULL`. + if (guard === null) { + if (row.walletBalance !== null) return { count: 0 }; + row.walletBalance = args.data.walletBalance as number; + return { count: 1 }; + } + // `gte` is the debit's sufficiency guard. SQL comparisons against + // NULL are UNKNOWN rather than true, so it matches nothing on an + // unseeded row — the reason the seed above cannot let a debit + // through that the balance does not cover. + if ( + row.walletBalance === null || + row.walletBalance < (guard as { gte: number }).gte + ) + return { count: 0 }; + row.walletBalance -= ( + args.data.walletBalance as { decrement: number } + ).decrement; + return { count: 1 }; + }, + ), + update: jest.fn( + async (args: { data: { walletBalance: { increment: number } } }) => { + row.walletBalance = + row.walletBalance === null + ? null + : row.walletBalance + args.data.walletBalance.increment; + return { ...row }; + }, + ), + }, + }, + }; +} + +beforeEach(() => jest.clearAllMocks()); + +describe("walletCredit against a NULL cached balance (#1459)", () => { + it("leaves the cache agreeing with the ledger CREDIT it posts", async () => { + const { row, tx } = invoiceFundedAccount(); + + const result = await walletCredit(tx as never, { + billingAccountId: "ba_1", + amountPaise: 123400, + reason: "TOPUP", + providerOrderId: "order_wave1c_001", + providerPaymentId: "pay_wave1c_001", + }); + + expect(row.walletBalance).toBe(123400); + expect(result.balanceAfter).toBe(123400); + + const posted = (postLedgerTxn as jest.Mock).mock.calls[0][1]; + expect(posted.postings).toContainEqual( + expect.objectContaining({ + direction: "CREDIT", + amountPaise: 123400, + account: { kind: "WALLET", organizationId: "org_1" }, + }), + ); + }); +}); + +describe("walletDebit against a NULL cached balance (#1459)", () => { + it("seeds the cache to zero and still refuses the debit", async () => { + const { row, tx } = invoiceFundedAccount(); + + await expect( + walletDebit(tx as never, { + billingAccountId: "ba_1", + amountPaise: 5000, + reason: "BOOKING", + }), + ).rejects.toThrow(WalletInsufficientFundsError); + + // The seed is the fix for the credit path; on the debit path it must not + // become a licence to spend. Zero is a real balance, and the gte guard + // refuses it exactly as it refuses any other insufficient one. + expect(row.walletBalance).toBe(0); + }); +}); diff --git a/__tests__/maintenance/abandoned-payments-reversal.test.ts b/__tests__/maintenance/abandoned-payments-reversal.test.ts index 4e3edd776..3de16d452 100644 --- a/__tests__/maintenance/abandoned-payments-reversal.test.ts +++ b/__tests__/maintenance/abandoned-payments-reversal.test.ts @@ -51,11 +51,16 @@ jest.mock("../../lib/referrals/service", () => ({ jest.mock("../../lib/payments/core/razorpay", () => ({ cancelRazorpayOrder: jest.fn().mockResolvedValue(undefined), })); -jest.mock("stripe", () => ({ +// #1464 — the sweep reaches Stripe only through the fenced core client, so +// mocking the core is what proves the fence: a call to `getStripeClient` is a +// gateway call, and with the fence shut there must not be one. +const stripeClient = { + paymentIntents: { cancel: jest.fn() }, + checkout: { sessions: { expire: jest.fn() } }, +}; +jest.mock("../../lib/payments/core/stripe", () => ({ __esModule: true, - default: class { - paymentIntents = { cancel: jest.fn() }; - }, + getStripeClient: jest.fn(() => stripeClient), })); jest.mock("../../lib/cron/with-cron-lock", () => ({ @@ -68,7 +73,12 @@ jest.mock("../../lib/cron/with-cron-lock", () => ({ import prisma from "../../lib/prisma"; import { reverseCreditsForPayment } from "../../lib/referrals/service"; -import { cleanupAbandonedPayments } from "../../scripts/payments/cleanup-abandoned-payments"; +import { cancelRazorpayOrder } from "../../lib/payments/core/razorpay"; +import { getStripeClient } from "../../lib/payments/core/stripe"; +import { + cancelGatewayIntents, + cleanupAbandonedPayments, +} from "../../scripts/payments/cleanup-abandoned-payments"; import { REQUEST_ALLOWED_FROM } from "../../lib/booking/transitions"; import { IllegalTransitionError } from "../../lib/enterprise/transitions"; @@ -299,3 +309,159 @@ describe("cleanupAbandonedPayments — soft-cancel + credit reversal (#1319)", ( ); }); }); + +/** + * #1459 — the Netlify ticker gives this sweep a six-second budget and the + * cancels used to run one after another, so five stuck payments were enough to + * spend it all and every tick aborted mid-sweep. The cancels now fan out, and + * the ceiling on that fan-out is the only thing keeping the sweep from opening + * an unbounded burst against the gateway. + */ +describe("cleanupAbandonedPayments gateway cancel concurrency (#1459)", () => { + it("never has more than five gateway cancels in flight", async () => { + let inFlight = 0; + let peak = 0; + (cancelRazorpayOrder as jest.Mock).mockImplementation(async () => { + inFlight++; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight--; + }); + + const payments = Array.from({ length: 12 }, (_, i) => ({ + id: `pay_${i}`, + paymentIntent: `order_${i}`, + paymentGateway: "RAZORPAY" as const, + })); + + const failures = await cancelGatewayIntents(payments); + + expect(cancelRazorpayOrder).toHaveBeenCalledTimes(12); + expect(peak).toBeLessThanOrEqual(5); + // Twelve over a ceiling of five is three chunks, so the fan-out is real + // rather than an accidental sequence of one. + expect(peak).toBeGreaterThan(1); + expect(failures.size).toBe(0); + }); + + it("reports a failed cancel against its own payment and lets the rest through", async () => { + (cancelRazorpayOrder as jest.Mock).mockImplementation( + async (intent: string) => { + if (intent === "order_1") throw new Error("gateway refused"); + }, + ); + + const failures = await cancelGatewayIntents([ + { id: "pay_0", paymentIntent: "order_0", paymentGateway: "RAZORPAY" }, + { id: "pay_1", paymentIntent: "order_1", paymentGateway: "RAZORPAY" }, + ]); + + expect([...failures.keys()]).toEqual(["pay_1"]); + expect(failures.get("pay_1")).toBe("gateway refused"); + }); +}); + +/** + * #1464 — a gateway cancel that failed used to skip the PENDING→EXPIRED CAS + * while the credits were still restored and the slot still released, and the + * run reported `success: true`. The payment was then invisible to every later + * sweep, because nothing about it still looked abandoned. + */ +describe("cleanupAbandonedPayments — a failed gateway cancel (#1464)", () => { + const originalStripeEnabled = process.env.STRIPE_ENABLED; + + afterEach(() => { + if (originalStripeEnabled === undefined) delete process.env.STRIPE_ENABLED; + else process.env.STRIPE_ENABLED = originalStripeEnabled; + }); + + it("still expires the payment and returns its credits, and fails the run", async () => { + (cancelRazorpayOrder as jest.Mock).mockRejectedValue( + new Error("gateway refused"), + ); + + const result = await cleanupAbandonedPayments(); + + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { id: "pay_1", paymentStatus: "PENDING" }, + data: { paymentStatus: "EXPIRED" }, + }); + expect(mockReverse).toHaveBeenCalledWith("pay_1", tx); + expect(result.cleanedCount).toBe(1); + // Counted, not just listed: `success` is what the HTTP twin turns into a + // non-2xx, and the listing alone left the run looking healthy. + expect(result.errorCount).toBe(1); + expect(result.success).toBe(false); + expect(result.errors).toEqual([ + expect.stringContaining("Payment cancellation failed for order_abc"), + ]); + }); + + it("makes no gateway call for a Stripe row while the fence is shut", async () => { + delete process.env.STRIPE_ENABLED; + const appointment = abandonedConsultation(); + appointment.payment[0].paymentGateway = "STRIPE"; + db.appointment.findMany.mockResolvedValue([appointment]); + + const result = await cleanupAbandonedPayments(); + + expect(getStripeClient).not.toHaveBeenCalled(); + // Fenced is "nothing to cancel", not a failure: the row still expires. + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { id: "pay_1", paymentStatus: "PENDING" }, + data: { paymentStatus: "EXPIRED" }, + }); + expect(result.errorCount).toBe(0); + expect(result.success).toBe(true); + }); + + /** + * #1461 — `payment_intent_unexpected_state` used to be blanket-suppressed as + * "already gone". On a `processing` intent that is false: Stripe is still + * holding the buyer's money behind a payment this sweep has just marked + * EXPIRED, and the run reported itself healthy. + */ + it("counts an uncancellable but still-live Stripe intent as a failure", async () => { + process.env.STRIPE_ENABLED = "true"; + const appointment = abandonedConsultation(); + appointment.payment[0].paymentGateway = "STRIPE"; + appointment.payment[0].paymentIntent = "pi_live_1"; + db.appointment.findMany.mockResolvedValue([appointment]); + stripeClient.paymentIntents.cancel.mockRejectedValue( + Object.assign(new Error("cannot cancel a processing PaymentIntent"), { + code: "payment_intent_unexpected_state", + payment_intent: { status: "processing" }, + }), + ); + + const result = await cleanupAbandonedPayments(); + + // #1464 still holds: the row expires regardless of the cancel's outcome. + expect(tx.payment.updateMany).toHaveBeenCalledWith({ + where: { id: "pay_1", paymentStatus: "PENDING" }, + data: { paymentStatus: "EXPIRED" }, + }); + expect(result.errorCount).toBe(1); + expect(result.success).toBe(false); + }); + + it("leaves a succeeded intent alone — that one really is nothing to cancel", async () => { + process.env.STRIPE_ENABLED = "true"; + const appointment = abandonedConsultation(); + appointment.payment[0].paymentGateway = "STRIPE"; + appointment.payment[0].paymentIntent = "pi_done_1"; + db.appointment.findMany.mockResolvedValue([appointment]); + stripeClient.paymentIntents.cancel.mockRejectedValue( + Object.assign(new Error("cannot cancel a succeeded PaymentIntent"), { + code: "payment_intent_unexpected_state", + // Nested under `raw`, the other shape the SDK wraps errors in. + raw: { payment_intent: { status: "succeeded" } }, + }), + ); + + const result = await cleanupAbandonedPayments(); + + expect(result.errorCount).toBe(0); + expect(result.success).toBe(true); + }); +}); diff --git a/__tests__/maintenance/cleanup-route.test.ts b/__tests__/maintenance/cleanup-route.test.ts index 2b99a5166..9d46e643b 100644 --- a/__tests__/maintenance/cleanup-route.test.ts +++ b/__tests__/maintenance/cleanup-route.test.ts @@ -47,7 +47,12 @@ jest.mock("../../lib/maintenance-cron", () => ({ import type { NextRequest } from "next/server"; -import { cleanupRoute, statusFor } from "../../lib/cron/cleanup-route"; +import { + cleanupRoute, + InvalidLimitError, + parseLimitParam, + statusFor, +} from "../../lib/cron/cleanup-route"; import { CronLockHeldError } from "../../lib/cron/with-cron-lock"; import { assertNotInMaintenance, @@ -83,6 +88,37 @@ describe("statusFor", () => { }); }); +/** + * #1459 — `reconcile-orphaned-confirmations` kept its own parser that logged a + * malformed `?limit=` and swept the defaults, so a broken caller produced a run + * that looked healthy. Every ticker target now shares this one, which is worth + * pinning directly rather than only through the route's 400 mapping. + */ +describe("parseLimitParam", () => { + const withLimit = (raw: string | null): NextRequest => + ({ + nextUrl: { + searchParams: new URLSearchParams(raw === null ? "" : { limit: raw }), + }, + }) as unknown as NextRequest; + + it("refuses a present-but-invalid limit instead of falling back to unbounded", () => { + // "" is `?limit=`: present, so it is junk rather than the absent default. + for (const raw of ["", "abc", "0", "-5", "2.5"]) { + expect(() => parseLimitParam(withLimit(raw))).toThrow(InvalidLimitError); + } + }); + + it("clamps above the cap and passes a sane value through", () => { + expect(parseLimitParam(withLimit("5000"))).toBe(500); + expect(parseLimitParam(withLimit("50"))).toBe(50); + }); + + it("treats an absent limit as the unbounded GitHub Actions run", () => { + expect(parseLimitParam(withLimit(null))).toBeUndefined(); + }); +}); + describe("cleanupRoute", () => { const OLD_ENV = process.env; @@ -181,6 +217,18 @@ describe("cleanupRoute", () => { expect(guard).toHaveBeenCalledWith("reconcile-pending-refunds"); }); + it("answers 400 INVALID_LIMIT and never runs the job on a bad ?limit=", async () => { + const run = jest.fn(() => { + throw new InvalidLimitError(); + }); + const { POST } = cleanupRoute({ job: "test-job", run }); + + const res = await POST(request()); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "INVALID_LIMIT" }); + }); + it("never returns the exception text to the caller", async () => { const { POST } = cleanupRoute({ job: "test-job", diff --git a/__tests__/maintenance/cron-lock-registry.test.ts b/__tests__/maintenance/cron-lock-registry.test.ts index 7eb2c5993..412f11fa7 100644 --- a/__tests__/maintenance/cron-lock-registry.test.ts +++ b/__tests__/maintenance/cron-lock-registry.test.ts @@ -222,4 +222,21 @@ describe("cron lock registry (#1169)", () => { ); expect(orphaned).toEqual([]); }); + + it("gives every scheduled workflow a queueing concurrency group", () => { + // #1413 — a second, redundant guard alongside withCronLock: an overlap + // should queue behind the in-flight run at the Actions layer too, not + // just at the Redis layer. cancel-in-progress must stay false, since + // killing a mid-flight money job is the one thing worse than a double run. + const missing = registry + .map((r) => r.workflow) + .filter((workflow) => { + const src = read(path.join(WORKFLOW_DIR, workflow)); + if (!src) return true; + const hasGroup = /^concurrency:\s*\n\s*group:\s*\S+/m.test(src); + const hasNoCancel = /cancel-in-progress:\s*false/.test(src); + return !(hasGroup && hasNoCancel); + }); + expect(missing).toEqual([]); + }); }); diff --git a/__tests__/maintenance/reconcile-tentative-clear-race.test.ts b/__tests__/maintenance/reconcile-tentative-clear-race.test.ts new file mode 100644 index 000000000..f64bb1539 --- /dev/null +++ b/__tests__/maintenance/reconcile-tentative-clear-race.test.ts @@ -0,0 +1,115 @@ +/** + * @jest-environment node + */ + +/** + * #1424 — the tentative-clear sweep of `reconcile-slot-availability` reads a + * cohort of tentative slots whose payment succeeded and then stamps them + * confirmed. The write used to be scoped by `id IN (...)` alone, so it did not + * care whether a row was still in the cohort. A partial reschedule releases a + * slot as `isTentative=true` / `completionStatus=RESCHEDULED` while leaving the + * parent APPROVED, which the sweep's parent-status guard does not see; a slot + * that moved that way between the read and the write was stamped confirmed and + * blocked the consultant's calendar for a session nobody would ever deliver. + * ADR 21: a sweep repeats its own predicate in the WHERE it writes with. + */ + +jest.mock("../../lib/prisma", () => { + const db: Record = { + slotOfAppointment: { + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + subscription: { + findMany: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue({}), + }, + class: { + findMany: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue({}), + }, + appointment: { findMany: jest.fn().mockResolvedValue([]) }, + slotOfAvailabilityWeekly: { groupBy: jest.fn().mockResolvedValue([]) }, + slotOfAvailabilityCustom: { groupBy: jest.fn().mockResolvedValue([]) }, + $disconnect: jest.fn(), + }; + return { __esModule: true, default: db }; +}); + +jest.mock("../../lib/cron/with-cron-lock", () => ({ + __esModule: true, + LONG_JOB_TTL_MS: 1, + withCronLock: (_key: string, _opts: unknown, fn: () => unknown) => fn(), +})); + +// The allocator is out of scope here and drags Novu/undici into the graph. +jest.mock("../../utils/slotAllocation/SlotAllocationService", () => ({ + __esModule: true, + SlotAllocationService: { allocate: jest.fn() }, +})); +jest.mock("../../utils/slotAllocation/SlotCalculationService", () => ({ + __esModule: true, + SlotCalculationService: { + getSlotsPerCall: jest.fn().mockReturnValue(1), + calculateRequiredSlots: jest.fn().mockReturnValue(1), + }, +})); + +import prisma from "../../lib/prisma"; +import { reconcileSlotAvailability } from "../../scripts/appointments/reconcile-slot-availability"; +import { SlotCompletionStatus } from "@prisma/client"; + +describe("reconcile-slot-availability × tentative-clear race (#1424)", () => { + it("skips a slot whose completion status changed after the cohort read", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + // Two slots read; one is moved to RESCHEDULED by a partial reschedule + // before the write lands, so the CAS matches only the other. + (prisma.slotOfAppointment.findMany as jest.Mock).mockResolvedValueOnce([ + { + id: "slot-live", + appointmentId: "apt-1", + startsAt: new Date("2026-10-01T10:00:00Z"), + endsAt: new Date("2026-10-01T10:30:00Z"), + }, + { + id: "slot-rescheduled", + appointmentId: "apt-2", + startsAt: new Date("2026-10-01T11:00:00Z"), + endsAt: new Date("2026-10-01T11:30:00Z"), + }, + ]); + (prisma.slotOfAppointment.updateMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + + const result = await reconcileSlotAvailability(); + + const write = (prisma.slotOfAppointment.updateMany as jest.Mock).mock + .calls[0][0]; + expect(write.data).toEqual({ isTentative: false }); + expect(write.where.id).toEqual({ + in: ["slot-live", "slot-rescheduled"], + }); + // The cohort predicate is repeated at write time, so a row that left the + // cohort cannot be stamped confirmed by its id alone. + expect(write.where.isTentative).toBe(true); + expect(write.where.deletedAt).toBeNull(); + expect(write.where.completionStatus.in).not.toContain( + SlotCompletionStatus.RESCHEDULED, + ); + expect(write.where.completionStatus.in).not.toContain( + SlotCompletionStatus.CANCELLED, + ); + expect(write.where.completionStatus.in).toContain( + SlotCompletionStatus.SCHEDULED, + ); + + // Only the row the write actually matched is counted, and the shortfall is + // logged rather than swallowed. + expect(result.tentativeFlagsCleared).toBe(1); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("reconcile_tentative_clear_raced"), + ); + warn.mockRestore(); + }); +}); 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__/payments/appointment-delete-forbidden.test.ts b/__tests__/payments/appointment-delete-forbidden.test.ts index c8f5a6663..a832857f1 100644 --- a/__tests__/payments/appointment-delete-forbidden.test.ts +++ b/__tests__/payments/appointment-delete-forbidden.test.ts @@ -11,6 +11,10 @@ * same shape for another two months. This is a source-text contract: it fails * the moment a `delete`/`deleteMany` on those models is reintroduced, which a * mock-based test cannot promise. + * + * The slot rule is repo-wide rather than sweep-only: four more tentative-hold + * deletes survived outside `scripts/` until they were converted, so the last + * case below scans every source tree against a two-entry allowlist. */ import fs from "fs"; @@ -35,8 +39,55 @@ const FORBIDDEN = [ // A Payment row is the money record this whole file exists to keep. /\bpayment\.delete\(/, /\bpayment\.deleteMany\(/, + // A tentative hold is freed by status, never by DELETE (doctrine rule 2). + /\bslotOfAppointment\.delete\(/, + /\bslotOfAppointment\.deleteMany\(/, ]; +// The global slot rule below scans these trees. `utils/` is included because +// the one sanctioned exception lives there, so the allowlist stays honest +// instead of being decorative. +const SLOT_SCAN_ROOTS = ["scripts", "jobs", "lib", "app", "utils"]; + +/** + * The allocator's re-planning delete is the deliberate exception: it releases + * never-paid tentative rows with `payment: { none: {} }` inside the DELETE's + * own WHERE, so a Payment-bearing appointment is never destroyed. Seed and + * reset scripts wipe a disposable database and are not booking writes. + */ +const SLOT_DELETE_ALLOWLIST = [ + "utils/slotAllocation/SlotAllocationService.ts", + "prisma/", + "scripts/db/", +]; + +// Tolerates `delete (` and bracket access; Prettier normalises the former, +// but the pin should not depend on it. +const SLOT_DELETE = + /\bslotOfAppointment(?:\??\.delete(?:Many)?|\[\s*["']delete(?:Many)?["']\s*\])\s*\(/; +// A file entry (no trailing slash) matches exactly; a directory entry matches +// on a path boundary, so `SlotAllocationService.tsx` is not the allocator. +function isAllowlisted(file: string): boolean { + return SLOT_DELETE_ALLOWLIST.some((ok) => + ok.endsWith("/") ? file.startsWith(ok) : file === ok, + ); +} + +function walkTypescript(dir: string, out: string[] = []): string[] { + for (const entry of fs.readdirSync(path.join(process.cwd(), dir), { + withFileTypes: true, + })) { + const rel = `${dir}/${entry.name}`; + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name.startsWith(".")) continue; + walkTypescript(rel, out); + } else if (/\.tsx?$/.test(entry.name)) { + out.push(rel); + } + } + return out; +} + describe("no sweep hard-deletes a booking row (#1319)", () => { for (const file of SWEEPS) { const source = fs.readFileSync(path.join(process.cwd(), file), "utf8"); @@ -62,6 +113,22 @@ describe("no sweep hard-deletes a booking row (#1319)", () => { } }); + it("no site outside the allocator hard-deletes a slot", () => { + // The named-file case above only covered the three sweeps that had + // already been converted; four more sites kept deleting tentative rows + // for months. This is the general rule, so a new delete site fails here + // the moment it is written rather than when someone re-reads the sweeps. + const offenders = SLOT_SCAN_ROOTS.flatMap((root) => walkTypescript(root)) + .filter((file) => !isAllowlisted(file)) + .filter((file) => + SLOT_DELETE.test( + fs.readFileSync(path.join(process.cwd(), file), "utf8"), + ), + ); + + expect(offenders).toEqual([]); + }); + it("the unscheduled approval-payments route is gone (one semantics: EXPIRED)", () => { expect( fs.existsSync( diff --git a/__tests__/payments/approval-path-correctness.test.ts b/__tests__/payments/approval-path-correctness.test.ts index 5654f0e57..8a3aeaac9 100644 --- a/__tests__/payments/approval-path-correctness.test.ts +++ b/__tests__/payments/approval-path-correctness.test.ts @@ -136,7 +136,12 @@ describe("checkout hardening (#1093 tail + tentative visibility)", () => { const step5 = checkout.indexOf( "// STEP 5: Create tentative appointment + payment record", ); - const window = checkout.slice(step5, step5 + 600); + // #1435 — the window is measured in characters, so the comment block + // between the marker and the call decides whether this passes. Strip the + // comments and assert on the code instead of enlarging it again. + const window = checkout + .slice(step5, step5 + 2000) + .replace(/^\s*\/\/.*$/gm, ""); expect(window).toContain("withSerializableRetry("); }); }); diff --git a/__tests__/payments/cancel-pending-checkout.test.ts b/__tests__/payments/cancel-pending-checkout.test.ts index e92c70008..db96b8d3a 100644 --- a/__tests__/payments/cancel-pending-checkout.test.ts +++ b/__tests__/payments/cancel-pending-checkout.test.ts @@ -19,6 +19,8 @@ interface SlotRow { appointmentId: string; classId: string | null; isTentative: boolean; + completionStatus: string; + deletedAt: Date | null; userIds: string[]; } @@ -46,6 +48,32 @@ function matchesUserSome(slot: SlotRow, where: Row): boolean { return slot.userIds.includes(user.some.id); } +interface SlotWhere { + appointmentId?: string; + appointment?: { classId?: string }; + isTentative?: boolean; + deletedAt?: Date | null; + completionStatus?: { in?: string[] }; +} + +function matchSlots(where: Row): SlotRow[] { + const w = where as SlotWhere; + return state.slots.filter((slot) => { + let match = true; + if (w.appointmentId !== undefined) + match = match && slot.appointmentId === w.appointmentId; + if (w.appointment?.classId !== undefined) + match = match && slot.classId === w.appointment.classId; + if (w.isTentative !== undefined) + match = match && slot.isTentative === w.isTentative; + if (w.deletedAt === null) match = match && slot.deletedAt === null; + if (w.completionStatus?.in !== undefined) + match = match && w.completionStatus.in.includes(slot.completionStatus); + match = match && matchesUserSome(slot, where); + return match; + }); +} + function makeTx() { return { payment: { @@ -115,19 +143,10 @@ function makeTx() { }, slotOfAppointment: { findMany: jest.fn(async ({ where }: any) => - state.slots - .filter((slot) => { - let match = true; - if (where.appointmentId !== undefined) - match = match && slot.appointmentId === where.appointmentId; - if (where.appointment?.classId !== undefined) - match = match && slot.classId === where.appointment.classId; - if (where.isTentative !== undefined) - match = match && slot.isTentative === where.isTentative; - match = match && matchesUserSome(slot, where); - return match; - }) - .map((slot) => ({ id: slot.id })), + matchSlots(where).map((slot) => ({ + id: slot.id, + completionStatus: slot.completionStatus, + })), ), update: jest.fn(async ({ where, data }: any) => { const slot = state.slots.find((s) => s.id === where.id); @@ -138,21 +157,16 @@ function makeTx() { } return { id: slot.id }; }), - deleteMany: jest.fn(async ({ where }: any) => { - const before = state.slots.length; - state.slots = state.slots.filter((slot) => { - let match = true; - if (where.appointmentId !== undefined) - match = match && slot.appointmentId === where.appointmentId; - if (where.appointment?.classId !== undefined) - match = match && slot.classId === where.appointment.classId; - if (where.isTentative !== undefined) - match = match && slot.isTentative === where.isTentative; - match = match && matchesUserSome(slot, where); - return !match; - }); - return { count: before - state.slots.length }; - }), + updateManyAndReturn: jest.fn( + async ({ where, data }: { where: Row; data: Row }) => { + const moved = matchSlots(where); + for (const slot of moved) Object.assign(slot, data); + return moved.map((slot) => ({ + id: slot.id, + appointmentId: slot.appointmentId, + })); + }, + ), }, referralCreditUsage: { findMany: jest.fn(async () => []), @@ -221,6 +235,8 @@ function seedConsultationPayment({ appointmentId: "appt-1", classId: null, isTentative: true, + completionStatus: "SCHEDULED", + deletedAt: null, userIds: ["user-1"], }); } @@ -242,12 +258,29 @@ describe("cancelPendingCheckout — happy path (consultation)", () => { expect(result).toEqual({ ok: true, slotsReleased: 1 }); expect(state.payments.get("pay-1")?.paymentStatus).toBe("EXPIRED"); - expect(state.slots).toHaveLength(0); + // Freed by status, not deleted: the row stays so support can see the + // hold the buyer abandoned (doctrine rule 2). + expect(state.slots).toHaveLength(1); + expect(state.slots[0].completionStatus).toBe("CANCELLED"); + expect(state.slots[0].deletedAt).toBeInstanceOf(Date); const cons = state.consultations.get("cons-1"); expect(cons?.status).toBe("CANCELLED"); expect(cons?.cancellationNotes).toBe("Cancelled by user during checkout"); expect(cons?.cancelledAt).toBeInstanceOf(Date); expect(cancelPaymentIntent).toHaveBeenCalledWith("order_abc", "RAZORPAY"); + // #1333 — the slot history rows name the appointment the rows came back with. + const slotHistory = ( + tx.bookingStatusHistory.create as jest.Mock + ).mock.calls.filter(([call]) => call.data.entity === "SLOT"); + expect(slotHistory.length).toBeGreaterThan(0); + for (const [call] of slotHistory) { + expect(call.data).toEqual( + expect.objectContaining({ + toStatus: "CANCELLED", + appointmentId: "appt-1", + }), + ); + } }); it("skips the gateway cancel for mock payments", async () => { @@ -297,6 +330,8 @@ describe("cancelPendingCheckout — CAS / status guards", () => { expect(result).toEqual({ ok: false, code: "NOT_PENDING" }); expect(state.payments.get("pay-1")?.paymentStatus).toBe("SUCCEEDED"); expect(state.slots).toHaveLength(1); + expect(state.slots[0].completionStatus).toBe("SCHEDULED"); + expect(state.slots[0].deletedAt).toBeNull(); expect(state.consultations.get("cons-1")?.status).toBe( "APPROVED_PENDING_PAYMENT", ); @@ -330,6 +365,7 @@ describe("cancelPendingCheckout — CAS / status guards", () => { expect(result).toEqual({ ok: false, code: "NOT_FOUND" }); expect(state.payments.get("pay-1")?.paymentStatus).toBe("PENDING"); expect(state.slots).toHaveLength(1); + expect(state.slots[0].deletedAt).toBeNull(); }); it("returns NOT_FOUND for a missing payment", async () => { @@ -378,6 +414,8 @@ describe("cancelPendingCheckout — subscription parent", () => { appointmentId: "appt-s", classId: null, isTentative: true, + completionStatus: "SCHEDULED", + deletedAt: null, userIds: ["user-1"], }); @@ -417,7 +455,7 @@ describe("cancelPendingCheckout — subscription parent", () => { }); describe("cancelPendingCheckout — webinar scoping", () => { - it("deletes only the caller's tentative slot on a shared webinar appointment", async () => { + it("releases only the caller's tentative seat on a shared webinar appointment", async () => { state.payments.set("pay-w", { id: "pay-w", userId: "user-1", @@ -437,6 +475,8 @@ describe("cancelPendingCheckout — webinar scoping", () => { appointmentId: "appt-w", classId: null, isTentative: true, + completionStatus: "SCHEDULED", + deletedAt: null, userIds: ["user-1"], }, { @@ -444,6 +484,8 @@ describe("cancelPendingCheckout — webinar scoping", () => { appointmentId: "appt-w", classId: null, isTentative: true, + completionStatus: "SCHEDULED", + deletedAt: null, userIds: ["user-2"], }, { @@ -451,6 +493,8 @@ describe("cancelPendingCheckout — webinar scoping", () => { appointmentId: "appt-w", classId: null, isTentative: false, + completionStatus: "SCHEDULED", + deletedAt: null, userIds: ["user-1"], }, ); @@ -495,6 +539,8 @@ describe("cancelPendingCheckout — class scoping", () => { appointmentId: "appt-c1", classId: "class-1", isTentative: true, + completionStatus: "SCHEDULED", + deletedAt: null, userIds: ["user-1"], }, { @@ -502,6 +548,8 @@ describe("cancelPendingCheckout — class scoping", () => { appointmentId: "appt-c2", classId: "class-1", isTentative: true, + completionStatus: "SCHEDULED", + deletedAt: null, userIds: ["user-1"], }, { @@ -509,6 +557,8 @@ describe("cancelPendingCheckout — class scoping", () => { appointmentId: "appt-c2", classId: "class-1", isTentative: true, + completionStatus: "SCHEDULED", + deletedAt: null, userIds: ["user-2"], }, ); diff --git a/__tests__/payments/capture-amount-parity.test.ts b/__tests__/payments/capture-amount-parity.test.ts index 7c91fc40c..e9e522d19 100644 --- a/__tests__/payments/capture-amount-parity.test.ts +++ b/__tests__/payments/capture-amount-parity.test.ts @@ -17,9 +17,11 @@ */ const captureException = jest.fn(); +const captureMessage = jest.fn(); jest.mock("@sentry/nextjs", () => ({ __esModule: true, captureException: (...a: unknown[]) => captureException(...a), + captureMessage: (...a: unknown[]) => captureMessage(...a), })); const withSerializableRetry = jest.fn(async (fn: () => unknown) => fn()); @@ -28,13 +30,18 @@ jest.mock("../../lib/db/serializable-retry", () => ({ withSerializableRetry: (fn: () => unknown) => withSerializableRetry(fn), })); -const paymentUpdate = jest.fn( - async (_args: { where: unknown; data: { description?: string } }) => ({}), +// #1439 — every in-tx status stamp is now a CAS, so the tx writer is +// `updateMany` and its count decides whether the flow continues. +const paymentUpdateMany = jest.fn( + async (_args: { + where: { paymentStatus?: string }; + data: { description?: string }; + }) => ({ count: 1 }), ); const paymentFindUnique = jest.fn(); const appointmentFindUnique = jest.fn(); const txStub = { - payment: { findUnique: paymentFindUnique, update: paymentUpdate }, + payment: { findUnique: paymentFindUnique, updateMany: paymentUpdateMany }, appointment: { findUnique: appointmentFindUnique }, }; // #990 — the Phase-2 clear-marker write runs on the base client (outside the @@ -85,18 +92,24 @@ jest.mock("../../actions/stream/chat/channel.action", () => ({ jest.mock("../../lib/stream-logger", () => ({ streamLogger: { info: jest.fn(), error: jest.fn() }, })); +const recordSystemError = jest.fn( + async (_args: { context?: Record }) => undefined, +); jest.mock("../../lib/enterprise/system-events", () => ({ - recordSystemError: jest.fn(), + recordSystemError: (...a: unknown[]) => recordSystemError(...(a as [never])), })); +const validateWebhookMetadata = jest.fn(); jest.mock("../../schemas/webhooks/metadata", () => ({ normalizeLegacySlotKeys: (m: unknown) => m, - validateWebhookMetadata: jest.fn(), + validateWebhookMetadata: (...a: unknown[]) => validateWebhookMetadata(...a), })); import { handlePaymentSuccess } from "../../lib/payments/webhooks/handlers"; beforeEach(() => { jest.clearAllMocks(); + paymentUpdateMany.mockImplementation(async () => ({ count: 1 })); + validateWebhookMetadata.mockImplementation(() => undefined); paymentFindUnique.mockResolvedValue({ id: "pay1", paymentIntent: "order1", @@ -126,9 +139,11 @@ describe("#677 / #990 — handlePaymentSuccess capture-amount parity", () => { "Capture amount mismatch", ); - // Phase 1 stamped the REQUIRES_MANUAL_RECOVERY fallback marker (in-tx). - expect(paymentUpdate).toHaveBeenCalledTimes(1); - const update = paymentUpdate.mock.calls[0][0]; + // Phase 1 stamped the REQUIRES_MANUAL_RECOVERY fallback marker (in-tx), + // and #1439 puts the PENDING predicate in the WHERE. + expect(paymentUpdateMany).toHaveBeenCalledTimes(1); + const update = paymentUpdateMany.mock.calls[0][0]; + expect(update.where.paymentStatus).toBe("PENDING"); expect(update.data.description).toContain("REQUIRES_MANUAL_RECOVERY"); // #990 — Phase 2 auto-refunded the wrong-amount capture for this payment. @@ -187,7 +202,7 @@ describe("#677 / #990 — handlePaymentSuccess capture-amount parity", () => { ).catch(() => undefined); // we only assert the guard did not fire expect(captureException).not.toHaveBeenCalled(); - const recoveryWrite = paymentUpdate.mock.calls.find((c) => + const recoveryWrite = paymentUpdateMany.mock.calls.find((c) => String(c[0].data.description ?? "").includes("REQUIRES_MANUAL_RECOVERY"), ); expect(recoveryWrite).toBeUndefined(); @@ -195,3 +210,45 @@ describe("#677 / #990 — handlePaymentSuccess capture-amount parity", () => { expect(appointmentFindUnique).toHaveBeenCalled(); }); }); + +describe("#1439 — a capture landing on a terminal payment never restamps it", () => { + it("leaves an EXPIRED payment EXPIRED when the metadata fails validation", async () => { + // The abandoned-payments sweep expired the row and released its hold; a + // late capture then failed metadata validation. The old bare `update` + // flipped it to SUCCEEDED and the tentative hold leaked forever. + paymentFindUnique.mockResolvedValue({ + id: "pay1", + paymentIntent: "order1", + amount: 10000, + paymentStatus: "EXPIRED", + userId: "u1", + currency: "INR", + appointmentId: "appt1", + user: { email: "buyer@example.com", name: "Buyer", consulteeProfile: {} }, + }); + paymentUpdateMany.mockImplementation(async () => ({ count: 0 })); + validateWebhookMetadata.mockImplementation(() => { + throw new Error("userId: Required"); + }); + + await handlePaymentSuccess("order1", { appointmentType: "CONSULTATION" }); + + // The stamp was attempted as a CAS on PENDING and matched nothing. + expect(paymentUpdateMany).toHaveBeenCalledTimes(1); + expect(paymentUpdateMany.mock.calls[0][0].where).toMatchObject({ + paymentStatus: "PENDING", + }); + // The terminal race is recorded once, as a warning, naming the row. + expect(recordSystemError).toHaveBeenCalledTimes(1); + expect(recordSystemError.mock.calls[0][0].context).toMatchObject({ + paymentId: "pay1", + orderId: "order1", + currentStatus: "EXPIRED", + }); + expect(captureMessage).toHaveBeenCalledTimes(1); + // Nothing downstream ran: no ledger posting, no refund, no marker rewrite. + expect(createEarningsFromPayment).not.toHaveBeenCalled(); + expect(refundPayment).not.toHaveBeenCalled(); + expect(prismaPaymentUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/payments/checkout-needs-gateway.test.ts b/__tests__/payments/checkout-needs-gateway.test.ts new file mode 100644 index 000000000..116451872 --- /dev/null +++ b/__tests__/payments/checkout-needs-gateway.test.ts @@ -0,0 +1,51 @@ +/** + * @jest-environment node + */ + +/** + * #1437 — the org WALLET/INVOICE/LICENSE rail (and zero-amount/mock) + * confirms /api/checkout synchronously with a synthetic id and no gateway + * order, but RazorpayCheckout/StripeCheckout still called razorpay.open() + * on it: a 400 from Razorpay's preferences call surfaced as "Payment + * Failed" over a booking that had already succeeded. + * + * checkoutNeedsGateway is the one place both gateway components now make + * that decision, so pinning it pins the fix for all four checkout pages. + */ + +import { checkoutNeedsGateway } from "@/app/checkout/plans/utils"; + +describe("checkoutNeedsGateway", () => { + it("says no gateway is needed for the org WALLET-rail synchronous-success response", () => { + // Shape handleCheckout returns for isOrgSponsoredPayment (WALLET/ + // INVOICE/LICENSE): success server-side, synthetic org_wallet_ id, no + // client secret — skipPayment is the flag carrying that home. + const walletResponse = { + success: true, + paymentIntent: { + id: "org_wallet_1730000000000_ab12cd34", + client_secret: null, + }, + skipPayment: true, + isMockPayment: false, + isZeroAmountPayment: false, + }; + expect(checkoutNeedsGateway(walletResponse)).toBe(false); + }); + + it("says no gateway is needed for zero-amount and mock responses", () => { + expect(checkoutNeedsGateway({ isZeroAmountPayment: true })).toBe(false); + expect(checkoutNeedsGateway({ skipPayment: true })).toBe(false); + }); + + it("says the gateway is needed for a real pending payment", () => { + const realGatewayResponse = { + success: true, + paymentIntent: { id: "order_abc123", client_secret: "secret" }, + skipPayment: false, + isMockPayment: false, + isZeroAmountPayment: false, + }; + expect(checkoutNeedsGateway(realGatewayResponse)).toBe(true); + }); +}); diff --git a/__tests__/payments/checkout-pool-1-nesting.test.ts b/__tests__/payments/checkout-pool-1-nesting.test.ts new file mode 100644 index 000000000..0602a7072 --- /dev/null +++ b/__tests__/payments/checkout-pool-1-nesting.test.ts @@ -0,0 +1,173 @@ +/** + * @jest-environment node + */ + +/** + * #1421 — checkout must never issue a query on the global Prisma client while + * one of its own interactive transactions is open. + * + * Netlify runs this app with a pg pool of `PG_POOL_MAX=1` and a 3 s connect + * timeout. An interactive `$transaction` checks out that single connection and + * holds it until it commits, so any query sent to the global client in the + * meantime queues for a connection that only the blocked transaction can + * release. The request cannot make progress and pg gives up with "timeout + * exceeded when trying to connect", which is exactly how every consultation + * checkout failed on the deploy preview while sibling read routes on the same + * deploy answered normally. + * + * `validateSlotAvailability` is the site that fired: it is called from inside + * three separate transactions on the plain Razorpay consultation path, and its + * DPDP consent gate used to read on the global client. The mock below models + * the pool faithfully — a global-client call raised while a transaction is + * open throws the same pg error the preview logged — so this test fails + * against the unfixed code and passes once the gate reads through `tx`. + */ + +import type { CheckoutInput } from "@/schemas/checkout"; + +let mockTxDepth = 0; +const mockGlobalTouches: string[] = []; + +const mockTxClient = { + consentArtifact: { + findFirst: jest.fn(async () => ({ id: "consent-artifact-1" })), + }, + slotOfAppointment: { + findFirst: jest.fn(async () => null), + }, + // #1463 — the self-hold lookup is a fourth read on this helper's path, and + // it must ride the transaction client like every other one. + appointment: { + findMany: jest.fn(async (): Promise => []), + }, +}; + +jest.mock("../../lib/prisma", () => { + // Any model reached on the global client answers through this proxy, so the + // test does not have to enumerate the models a future call site might touch. + const globalModel = (model: string) => + new Proxy( + {}, + { + get: (_target, operation: string) => async (): Promise => { + mockGlobalTouches.push(`${model}.${operation}`); + if (mockTxDepth > 0) { + throw new Error("timeout exceeded when trying to connect"); + } + return null; + }, + }, + ); + + const client: Record = { + $transaction: async (fn: (tx: unknown) => unknown) => { + mockTxDepth += 1; + try { + return await fn(mockTxClient); + } finally { + mockTxDepth -= 1; + } + }, + }; + + return { + __esModule: true, + default: new Proxy(client, { + get: (target, prop: string) => + prop in target ? target[prop] : globalModel(prop), + }), + }; +}); + +// Boundary mocks. `lib/payments/operations/checkout` transitively imports the +// auth stack through the payouts barrel, which is ESM-only and cannot be +// required under this Jest transform; the gateway and the Redis lock helpers +// are infrastructure this suite never exercises. Note that +// `lib/compliance/dpdp` is deliberately NOT mocked — its real body is the code +// under test. +jest.mock("../../lib/payments/payouts", () => ({ + __esModule: true, + createEarningsFromPayment: jest.fn(), +})); + +jest.mock("../../lib/payments/index", () => ({ + __esModule: true, + createPaymentIntent: jest.fn(), + cancelPaymentIntent: jest.fn(), +})); + +jest.mock("../../utils/appointmentlock", () => ({ + __esModule: true, + CHECKOUT_WAIT_RETRY_CONFIG: { retryCount: 5 }, + CHECKOUT_LOCK_TTL_MS: {}, + EventFullError: class extends Error {}, + lockSlotBooking: jest.fn(), + unlockSlotBooking: jest.fn(), + lockEventCheckout: jest.fn(), + unlockEventCheckout: jest.fn(), + lockConsulteeBooking: jest.fn(), + unlockConsulteeBooking: jest.fn(), + extendLock: jest.fn(), + extendSlotInterval: jest.fn(), +})); + +import prisma, { type Tx } from "../../lib/prisma"; +import { validateSlotAvailability } from "../../lib/payments/operations/checkout"; + +const HOUR_MS = 60 * 60 * 1000; + +function slotInput(): CheckoutInput { + const startsAt = new Date(Date.now() + 48 * HOUR_MS); + const endsAt = new Date(startsAt.getTime() + HOUR_MS); + return { + appointmentType: "CONSULTATION", + planId: "plan-1", + paymentGateway: "RAZORPAY", + startsAt: startsAt.toISOString(), + endsAt: endsAt.toISOString(), + } as unknown as CheckoutInput; +} + +/** The shape every real caller uses: the helper runs inside an open tx. */ +function validateInsideTransaction(): Promise<{ + selfHoldAppointmentIds: string[]; +}> { + return prisma.$transaction(async (tx) => + validateSlotAvailability( + tx as unknown as Tx, + slotInput(), + "consultee-user", + "expert-user", + ), + ); +} + +describe("#1421 checkout does not starve the single-connection pool", () => { + beforeEach(() => { + mockTxDepth = 0; + mockGlobalTouches.length = 0; + mockTxClient.consentArtifact.findFirst.mockResolvedValue({ + id: "consent-artifact-1", + }); + }); + + it("runs the consent gate on the transaction client, not the global one", async () => { + await expect(validateInsideTransaction()).resolves.toEqual({ + selfHoldAppointmentIds: [], + }); + + expect(mockTxClient.consentArtifact.findFirst).toHaveBeenCalledTimes(1); + expect(mockGlobalTouches).toEqual([]); + }); + + it("still blocks a consultant who withdrew session-delivery consent", async () => { + mockTxClient.consentArtifact.findFirst.mockResolvedValue( + null as unknown as { id: string }, + ); + + await expect(validateInsideTransaction()).rejects.toThrow( + /withdrawn session-delivery consent/, + ); + expect(mockGlobalTouches).toEqual([]); + }); +}); diff --git a/__tests__/payments/checkout-route-business-code-reporting.test.ts b/__tests__/payments/checkout-route-business-code-reporting.test.ts new file mode 100644 index 000000000..2a6471046 --- /dev/null +++ b/__tests__/payments/checkout-route-business-code-reporting.test.ts @@ -0,0 +1,138 @@ +/** + * @jest-environment node + */ + +/** + * #1477 — `POST /api/checkout` captured every error that reached its generic + * tail as a Sentry exception, before it had even been classified. Only the + * refusals with an explicit `instanceof` branch above that line escaped it, so + * the #1458 programme-cap codes and the #1467 entitlement codes answered the + * buyer correctly and still opened an incident on every routine refusal. + * + * The route's collaborators are boundary-mocked: what is under test is which + * report a coded refusal gets on its way out of the catch, not auth, rate + * limiting, tax context or gateway routing. + */ + +jest.mock("../../lib/auth-helpers", () => ({ + requireApiAuth: jest.fn(async () => ({ + session: { user: { id: "user_1" } }, + })), +})); + +jest.mock("../../lib/rate-limit", () => ({ + __esModule: true, + applyRateLimit: jest.fn(async () => null), + checkoutLimiter: { limit: jest.fn() }, +})); + +const handleCheckout = jest.fn(); +jest.mock("../../lib/payments/operations/checkout", () => ({ + handleCheckout: (...args: unknown[]) => handleCheckout(...args), +})); + +jest.mock("../../lib/payments/operations/checkout-replay", () => ({ + replayByIdempotencyKey: jest.fn(async () => null), +})); + +jest.mock("../../lib/payments/tax/checkout-context", () => ({ + resolveCheckoutTaxContext: jest.fn(async () => ({ buyerCountry: "IN" })), +})); + +jest.mock("../../lib/payments/gateway-router", () => ({ + routeGateway: jest.fn(() => ({ gateway: "RAZORPAY", reason: "domestic" })), +})); + +// The schema is a boundary here: the body only has to survive parsing so the +// handler can reach `handleCheckout` and throw. +jest.mock("../../schemas/checkout", () => ({ + checkoutSchema: { parse: (body: Record) => ({ ...body }) }, +})); + +const captureException = jest.fn(); +jest.mock("@sentry/nextjs", () => ({ + captureException: (...args: unknown[]) => captureException(...args), + captureMessage: jest.fn(), +})); + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: {}, +})); + +import { NextRequest } from "next/server"; + +import { POST } from "../../app/api/checkout/route"; + +function checkoutRequest() { + return new NextRequest("https://x.test/api/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ appointmentId: "appt_1", amount: 100 }), + }); +} + +/** The context Sentry was handed, for the single capture the route made. */ +function soleCaptureContext(): { + level?: string; + tags?: Record; +} { + expect(captureException).toHaveBeenCalledTimes(1); + return (captureException.mock.calls[0]?.[1] ?? {}) as { + level?: string; + tags?: Record; + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, "warn").mockImplementation(() => {}); + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("a business-coded refusal leaves POST /api/checkout as an answer", () => { + it("answers PROGRAM_ASSIGNMENT_INACTIVE 409 without an error-level capture", async () => { + handleCheckout.mockRejectedValue( + Object.assign( + new Error("No active programme assignment covers this session type"), + { code: "PROGRAM_ASSIGNMENT_INACTIVE" }, + ), + ); + + const res = await POST(checkoutRequest()); + const body = await res.json(); + + expect(res.status).toBe(409); + expect(body.errorType).toBe("PROGRAM_ASSIGNMENT_INACTIVE_ERROR"); + + // Reported, but as a modelled outcome: `expected` tagged true and the level + // pinned to info. An error-level capture is exactly what paged us. + const context = soleCaptureContext(); + expect(context.level).toBe("info"); + expect(context.tags?.expected).toBe("true"); + }); + + it("still captures an unrecognised failure at Sentry's default level", async () => { + handleCheckout.mockRejectedValue(new Error("connection terminated")); + + const res = await POST(checkoutRequest()); + + expect(res.status).toBe(500); + // Two captures here (the route's own, then logClassifiedError's). Asserted + // rather than assumed, so the loop below cannot pass on an empty list; the + // point of the loop is that neither is downgraded to a modelled outcome. + expect(captureException).toHaveBeenCalledTimes(2); + for (const call of captureException.mock.calls) { + const context = (call[1] ?? {}) as { + level?: string; + tags?: Record; + }; + expect(context.level).toBeUndefined(); + expect(context.tags?.expected).not.toBe("true"); + } + }); +}); diff --git a/__tests__/payments/checkout-self-hold-resume.test.ts b/__tests__/payments/checkout-self-hold-resume.test.ts new file mode 100644 index 000000000..d121024a8 --- /dev/null +++ b/__tests__/payments/checkout-self-hold-resume.test.ts @@ -0,0 +1,247 @@ +/** + * @jest-environment node + */ + +/** + * #1463 — a buyer who closes the gateway modal and clicks Pay again used to be + * blocked by their OWN tentative hold. `validateSlotAvailability` ran before + * the open-order resume (`findReusablePendingOrderPayment`, "Rec C") and threw + * "Time slot is already booked", so the documented same-order resume was + * unreachable and the buyer waited for the hold to expire. + * + * The exclusion has to be exactly as narrow as the resume gate it feeds, which + * is what this pin holds in place: the same buyer, the same plan, the same + * gateway and the exact same window passes; a different buyer on the same slot + * still blocks; the same buyer on an overlapping-but-different window still + * blocks; and (#1465-triage) so does a hold minted on a different gateway, + * which `findReusablePendingOrderPayment` could neither resume nor supersede. + * + * The transaction client below evaluates the two blocking predicates against an + * in-memory hold rather than asserting on query shape, so the self-hold + * exclusion has to actually work for these to pass. Exactness in particular is + * real: a booked window is stored as N contiguous 30-minute atoms, and the + * helper decides coverage from the run's first start and last end. + */ + +// Boundary mocks. `lib/payments/operations/checkout` transitively imports the +// auth stack through the payouts barrel, which is ESM-only under this Jest +// transform; none of it is reached by the availability helper, which is handed +// its own transaction client. +jest.mock("../../lib/prisma", () => ({ __esModule: true, default: {} })); + +jest.mock("../../lib/payments/payouts", () => ({ + __esModule: true, + createEarningsFromPayment: jest.fn(), +})); + +jest.mock("../../lib/payments/index", () => ({ + __esModule: true, + createPaymentIntent: jest.fn(), + cancelPaymentIntent: jest.fn(), +})); + +jest.mock("../../utils/appointmentlock", () => ({ + __esModule: true, + CHECKOUT_WAIT_RETRY_CONFIG: { retryCount: 5 }, + CHECKOUT_LOCK_TTL_MS: {}, + EventFullError: class extends Error {}, + lockSlotBooking: jest.fn(), + unlockSlotBooking: jest.fn(), + lockEventCheckout: jest.fn(), + unlockEventCheckout: jest.fn(), + lockConsulteeBooking: jest.fn(), + unlockConsulteeBooking: jest.fn(), + extendLock: jest.fn(), + extendSlotInterval: jest.fn(), +})); + +jest.mock("../../lib/compliance/dpdp", () => ({ + __esModule: true, + checkConsent: jest.fn(async () => true), + PURPOSE_CODES: { SESSION_BOOKING: "SESSION_BOOKING" }, +})); + +import type { Tx } from "../../lib/prisma"; +import type { CheckoutInput } from "../../schemas/checkout"; +import { validateSlotAvailability } from "../../lib/payments/operations/checkout"; + +const MINUTE_MS = 60 * 1000; +const HOUR_MS = 60 * MINUTE_MS; + +const BUYER = "buyer-user-1"; +const CONSULTANT = "consultant-user-1"; +const PLAN = "plan-1"; + +/** The held window: 48 h out, one hour long, stored as two 30-minute atoms. */ +const WINDOW_START = new Date(Date.now() + 48 * HOUR_MS); +const WINDOW_END = new Date(WINDOW_START.getTime() + HOUR_MS); + +interface HeldSlot { + appointmentId: string; + startsAt: Date; + endsAt: Date; + isTentative: boolean; +} + +/** One live tentative hold, minted by a previous checkout attempt. */ +const HOLD_APPOINTMENT_ID = "appt-hold"; +const heldSlots: HeldSlot[] = [ + { + appointmentId: HOLD_APPOINTMENT_ID, + startsAt: WINDOW_START, + endsAt: new Date(WINDOW_START.getTime() + 30 * MINUTE_MS), + isTentative: true, + }, + { + appointmentId: HOLD_APPOINTMENT_ID, + startsAt: new Date(WINDOW_START.getTime() + 30 * MINUTE_MS), + endsAt: WINDOW_END, + isTentative: true, + }, +]; + +/** The hold's live PENDING payment belongs to the buyer, and to nobody else. */ +const HOLD_OWNER = BUYER; + +/** ...and it was minted on the gateway the resume gate would look for. */ +const HOLD_GATEWAY = "RAZORPAY"; + +/** The `AND` terms of the two blocking slot queries this suite discriminates. */ +interface SlotWhereTerm { + NOT?: SlotWhereTerm; + appointmentId?: { in: string[] }; + startsAt?: { lt: Date }; + endsAt?: { gt: Date }; + isTentative?: boolean; +} + +/** The self-hold lookup's `where`, as far as this suite reads it. */ +interface SelfHoldWhere { + payment?: { + some?: { + userId?: string; + paymentGateway?: string; + organizationId?: string | null; + }; + }; + consultation?: { consultationPlanId?: string }; + slotsOfAppointment?: { some?: { startsAt?: Date } }; +} + +/** + * Evaluate one term of the blocking queries' `AND` array against a held slot. + * Only the terms this suite can discriminate are modelled; the relation terms + * (consultant membership, occupancy, the live-payment join) are true for the + * single fixture hold by construction. + */ +function termMatches(slot: HeldSlot, term: SlotWhereTerm): boolean { + if (term.NOT) return !termMatches(slot, term.NOT); + if (term.appointmentId?.in) { + return term.appointmentId.in.includes(slot.appointmentId); + } + if (term.startsAt?.lt) return slot.startsAt < term.startsAt.lt; + if (term.endsAt?.gt) return slot.endsAt > term.endsAt.gt; + if (term.isTentative !== undefined) { + return slot.isTentative === term.isTentative; + } + return true; +} + +const tx = { + // The self-hold lookup: same buyer, same plan, an atom starting at the + // requested window's start. Exact coverage is decided by the helper itself. + appointment: { + findMany: async ({ where }: { where: SelfHoldWhere }) => { + const wantedStart = where.slotsOfAppointment?.some?.startsAt; + if (where.payment?.some?.userId !== HOLD_OWNER) return []; + // #1465-triage — the resume gate's own scope, and therefore this + // exclusion's: a hold on another gateway or another org is not adoptable. + if (where.payment?.some?.paymentGateway !== HOLD_GATEWAY) return []; + if ((where.payment?.some?.organizationId ?? null) !== null) return []; + if (where.consultation?.consultationPlanId !== PLAN) return []; + if ( + !wantedStart || + !heldSlots.some((s) => s.startsAt.getTime() === wantedStart.getTime()) + ) { + return []; + } + return [ + { + id: HOLD_APPOINTMENT_ID, + slotsOfAppointment: heldSlots.map((s) => ({ + startsAt: s.startsAt, + endsAt: s.endsAt, + })), + }, + ]; + }, + }, + slotOfAppointment: { + findFirst: async ({ where }: { where: { AND: SlotWhereTerm[] } }) => + heldSlots.find((slot) => + where.AND.every((term) => termMatches(slot, term)), + ) ?? null, + }, +} as unknown as Tx; + +function checkoutInput( + startsAt: Date, + endsAt: Date, + paymentGateway: string = HOLD_GATEWAY, +): CheckoutInput { + return { + appointmentType: "CONSULTATION", + planId: PLAN, + paymentGateway, + startsAt: startsAt.toISOString(), + endsAt: endsAt.toISOString(), + } as unknown as CheckoutInput; +} + +describe("#1463 the buyer's own live hold does not block their resume", () => { + it("lets the same buyer, same plan and exact window through to the open-order resume", async () => { + const result = await validateSlotAvailability( + tx, + checkoutInput(WINDOW_START, WINDOW_END), + BUYER, + CONSULTANT, + ); + + expect(result.selfHoldAppointmentIds).toEqual([HOLD_APPOINTMENT_ID]); + }); + + it("still blocks a different buyer on the same slot", async () => { + await expect( + validateSlotAvailability( + tx, + checkoutInput(WINDOW_START, WINDOW_END), + "other-buyer", + CONSULTANT, + ), + ).rejects.toThrow("Time slot is already booked"); + }); + + it("still blocks a hold this request could not resume (other gateway)", async () => { + await expect( + validateSlotAvailability( + tx, + checkoutInput(WINDOW_START, WINDOW_END, "STRIPE"), + BUYER, + CONSULTANT, + ), + ).rejects.toThrow("Time slot is already booked"); + }); + + it("still blocks the same buyer on an overlapping but different window", async () => { + const shifted = new Date(WINDOW_START.getTime() + 30 * MINUTE_MS); + + await expect( + validateSlotAvailability( + tx, + checkoutInput(shifted, new Date(shifted.getTime() + HOUR_MS)), + BUYER, + CONSULTANT, + ), + ).rejects.toThrow("Time slot is already booked"); + }); +}); diff --git a/__tests__/payments/confirmation-single-writer.test.ts b/__tests__/payments/confirmation-single-writer.test.ts index 2105c1c25..133e7720a 100644 --- a/__tests__/payments/confirmation-single-writer.test.ts +++ b/__tests__/payments/confirmation-single-writer.test.ts @@ -58,6 +58,26 @@ jest.mock("../../lib/auth-server", () => ({ getSession: () => getSession(), })); +// #1353 — the route now applies checkoutLimiter per user. In the shared CI +// process the mock-redis store accumulates hits across suites, so these +// success-path POSTs would start answering 429. Boundary-mock it: rate limiting +// is infrastructure, not the single-writer contract under test here. +jest.mock("../../lib/rate-limit", () => ({ + __esModule: true, + applyRateLimit: jest.fn().mockResolvedValue(null), + checkoutLimiter: { limit: jest.fn() }, +})); + +// #1353 — the route records a client-confirmation audit event. It is +// fire-and-forget and best-effort in production; here it would reach the real +// prisma module, which this suite stubs to a handful of models. +const recordSystemEvent = jest.fn().mockResolvedValue(undefined); +jest.mock("../../lib/enterprise/system-events", () => ({ + __esModule: true, + recordSystemEvent: (...args: unknown[]) => recordSystemEvent(...args), + recordSystemError: jest.fn().mockResolvedValue(undefined), +})); + const findUnique = jest.fn(); const updateMany = jest.fn(); jest.mock("../../lib/prisma", () => ({ diff --git a/__tests__/payments/consultation-atom-parity.test.ts b/__tests__/payments/consultation-atom-parity.test.ts index 049f2acd9..e6e7939ba 100644 --- a/__tests__/payments/consultation-atom-parity.test.ts +++ b/__tests__/payments/consultation-atom-parity.test.ts @@ -47,7 +47,8 @@ type SlotAtom = { const webhookAppointmentCreate = jest.fn(); const consultationCreate = jest.fn(); const webhookTx = { - payment: { findUnique: jest.fn(), update: jest.fn() }, + // #1439 — the confirmation stamp is a CAS, so the tx writer is updateMany. + payment: { findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() }, consultation: { create: consultationCreate, updateMany: jest.fn(), @@ -184,6 +185,7 @@ async function runWebhookCreator(): Promise { // `clearMocks` wipes implementations between tests, so every default the // webhook transaction needs is (re)installed here rather than at module scope. webhookTx.payment.update.mockResolvedValue({}); + webhookTx.payment.updateMany.mockResolvedValue({ count: 1 }); webhookTx.appointment.update.mockResolvedValue({}); webhookTx.consultation.updateMany.mockResolvedValue({ count: 1 }); webhookTx.consultation.findUnique.mockResolvedValue({ @@ -246,6 +248,7 @@ async function runWebhookCreator(): Promise { } async function runCheckoutCreator(): Promise { + const historyCreate = jest.fn().mockResolvedValue({}); const checkoutAppointmentCreate = jest .fn() .mockResolvedValue({ id: "appt-2" }); @@ -271,6 +274,8 @@ async function runCheckoutCreator(): Promise { createMany: jest.fn().mockResolvedValue({ count: 2 }), updateMany: jest.fn().mockResolvedValue({ count: 2 }), }, + // #1333 — the handler opens the timeline in the same tx as the create. + bookingStatusHistory: { create: historyCreate }, } as unknown as Tx; await handleConsultationCheckout( @@ -287,6 +292,20 @@ async function runCheckoutCreator(): Promise { ); expect(checkoutAppointmentCreate).toHaveBeenCalledTimes(1); + // #1333 — the opening timeline row is written by the handler itself, in the + // same tx, naming the appointment it just created and the buyer as actor. + expect(historyCreate).toHaveBeenCalledTimes(1); + expect(historyCreate.mock.calls[0][0].data).toEqual( + expect.objectContaining({ + entity: "CONSULTATION", + entityId: "cons-2", + fromStatus: "CREATED", + toStatus: "PENDING", + appointmentId: "appt-2", + actorUserId: CONSULTEE_USER, + organizationId: null, + }), + ); return nestedAtoms(checkoutAppointmentCreate); } diff --git a/__tests__/payments/currency-and-tax-gates.test.ts b/__tests__/payments/currency-and-tax-gates.test.ts index 28be4e73f..e68a144a4 100644 --- a/__tests__/payments/currency-and-tax-gates.test.ts +++ b/__tests__/payments/currency-and-tax-gates.test.ts @@ -3,7 +3,8 @@ */ /** - * Two gates that decide real money, both of which were open. + * The gates that decide real money on a consumer supply, every one of which + * was open. * * 1. INR-only settlement. The platform settles in INR end to end — Razorpay * always settles INR, and the double-entry ledger is INR-denominated (#783). @@ -24,8 +25,51 @@ * domestic sales as exports. */ +import fs from "node:fs"; +import path from "node:path"; + import { detectBuyerCountry } from "../../lib/payments/tax/buyer-country"; -import { validatePlanCurrency } from "../../lib/payments/validation/currency-guards"; +import { + assertInrSettlement, + validatePlanCurrency, +} from "../../lib/payments/validation/currency-guards"; + +// #1396 — the Razorpay SDK is replaced wholesale so `createRazorpayOrder` can be +// called for real and the assertion observed at its true position: ahead of the +// client lookup and ahead of `orders.create`. Asserting that the spy was never +// called is the whole point — a guard placed after the SDK call would still +// throw and would still pass a naive "it throws" test. +const ordersCreate = jest.fn(); +jest.mock("razorpay", () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + orders: { create: (...args: unknown[]) => ordersCreate(...args) }, + })), +})); +import { assertGatewayUsable } from "../../lib/payments/validation/gateway-guards"; +import { + deriveConsumerInvoiceTax, + deriveConsumerCreditNoteAmounts, + mintConsumerInvoice, + resolveSupplierStateCode, + type ConsumerCreditNoteAmounts, +} from "../../lib/payments/billing/consumer-invoice"; +import { getPlatformSupplier } from "../../lib/pdf/supplier"; +import { recordSystemError } from "../../lib/enterprise/system-events"; + +// The mint's fail-closed branch is the only I/O-bearing path pinned here; the +// supplier config, the system-event write and Sentry are all stubbed so the +// assertion is about the decision, not the plumbing. +jest.mock("../../lib/pdf/supplier", () => ({ getPlatformSupplier: jest.fn() })); +jest.mock("../../lib/enterprise/system-events", () => ({ + recordSystemError: jest.fn().mockResolvedValue(undefined), +})); +jest.mock("../../lib/observability/report", () => ({ + reportSentryError: jest.fn(), +})); + +const readRepoFile = (relativePath: string): string => + fs.readFileSync(path.join(process.cwd(), relativePath), "utf8"); describe("buyer country never zero-rates on a browser locale", () => { it("ignores Accept-Language entirely, even when it names a country", () => { @@ -83,13 +127,7 @@ describe("only INR plans can reach a charge", () => { // A behavioural test would need the whole booking graph; this asserts the // call site exists, which is the thing that regressed. Both branches of // calculateAmount (CONSULTATION and SUBSCRIPTION) must be covered. - const src = require("fs").readFileSync( - require("path").join( - process.cwd(), - "lib/payments/operations/approval-payment.ts", - ), - "utf8", - ); + const src = readRepoFile("lib/payments/operations/approval-payment.ts"); const calls = src.match(/validatePlanCurrency\(/g) ?? []; expect(calls.length).toBeGreaterThanOrEqual(2); }); @@ -97,15 +135,334 @@ describe("only INR plans can reach a charge", () => { describe("the planner cannot create an unsettleable plan", () => { it("offers INR only", () => { - const src = require("fs").readFileSync( - require("path").join( - process.cwd(), - "components/planner/components/form-fields/PriceField.tsx", - ), - "utf8", + const src = readRepoFile( + "components/planner/components/form-fields/PriceField.tsx", ); const match = src.match(/const DEFAULT_CURRENCIES = (\[[^\]]*\])/); expect(match).not.toBeNull(); expect(JSON.parse(match![1].replace(/'/g, '"'))).toEqual(["INR"]); }); }); + +describe("settlement is INR at the gateway boundary (#1396)", () => { + it("passes INR through", () => { + expect(() => + assertInrSettlement("INR", "create a test order"), + ).not.toThrow(); + }); + + it("normalises before deciding, so lower case and padding still pass, and hands back the canonical code", () => { + let result: string | undefined; + expect(() => { + result = assertInrSettlement(" inr ", "create a test order"); + }).not.toThrow(); + expect(result).toBe("INR"); + }); + + it.each(["USD", "EUR", "GBP", "AED"])( + "refuses %s with NON_INR_SETTLEMENT", + (ccy) => { + expect(() => assertInrSettlement(ccy, "create a test order")).toThrow( + expect.objectContaining({ code: "NON_INR_SETTLEMENT" }), + ); + }, + ); + + it("refuses a code the platform cannot even represent", () => { + // A currency outside the Prisma enum must fail the same way as a + // representable-but-unsettleable one: both are non-INR settlement attempts. + expect(() => assertInrSettlement("XYZ", "create a test order")).toThrow( + expect.objectContaining({ code: "NON_INR_SETTLEMENT" }), + ); + }); + + it("stops a non-INR createRazorpayOrder before the SDK is called", async () => { + // The reachable repro: a BillingAccount set to USD, whose currency the + // wallet top-up route forwarded verbatim alongside an amount in INR paise. + // Razorpay reads a non-INR amount in the target currency's own subunit, so + // 100000 would have been a $1,000.00 order for an intended ₹1,000 top-up. + process.env.RAZORPAY_KEY_ID ||= "rzp_test_stub"; + process.env.RAZORPAY_SECRET ||= "stub_secret"; + const { createRazorpayOrder } = + await import("../../lib/payments/core/razorpay"); + + await expect( + createRazorpayOrder({ + amount: 100000, + currency: "USD", + metadata: { appointmentType: "CONSULTATION" }, + paymentGateway: "RAZORPAY", + }), + ).rejects.toThrow(expect.objectContaining({ code: "NON_INR_SETTLEMENT" })); + + expect(ordersCreate).not.toHaveBeenCalled(); + }); +}); + +describe("the Stripe rail is fenced unless it is switched on", () => { + // #1351 — Stripe was fully live: every checkout page offered the button and + // routeGateway honoured an explicit STRIPE request unconditionally, on + // sk_test_ keys. It is a contingency rail for an RBI rule change, so the + // flag is the gate. + const original = process.env.STRIPE_ENABLED; + afterEach(() => { + if (original === undefined) delete process.env.STRIPE_ENABLED; + else process.env.STRIPE_ENABLED = original; + }); + + it("rejects STRIPE with the flag unset and accepts it with the flag on", () => { + delete process.env.STRIPE_ENABLED; + expect(() => assertGatewayUsable("STRIPE", "route a checkout")).toThrow( + /STRIPE_ENABLED/, + ); + + process.env.STRIPE_ENABLED = "true"; + expect(() => + assertGatewayUsable("STRIPE", "route a checkout"), + ).not.toThrow(); + + // The fence must not touch the gateway that actually takes money. + expect(() => + assertGatewayUsable("RAZORPAY", "route a checkout"), + ).not.toThrow(); + }); +}); + +/** + * 3. Place of supply for a consumer invoice (#1365). Sec 12(2)(b) IGST Act + * puts a B2C supply at the SUPPLIER's location when the recipient's + * address is not on record — the opposite of the B2B fallback, which + * reports IGST on an unknown buyer state as an audit signal. Getting that + * backwards files the tax under the wrong heads in the wrong state. + */ +describe("B2C place of supply (s.12(2)(b))", () => { + // ₹1,000 + 18% GST, tax-inclusive. + const CHARGED = { totalPaise: 118_000, taxAmountPaise: 18_000 }; + + it("splits CGST and SGST when the buyer is in the supplier's state", () => { + const tax = deriveConsumerInvoiceTax({ + ...CHARGED, + buyerStateCode: "29", + supplierStateCode: "KA", + buyerCountry: "IN", + }); + expect(tax.igstPaise).toBe(0); + expect(tax.cgstPaise + tax.sgstPaise).toBe(CHARGED.taxAmountPaise); + expect(tax.placeOfSupply).toBe("29"); + expect(tax.placeOfSupplySource).toBe("DECLARED_AT_CHECKOUT"); + expect( + tax.taxableValuePaise + tax.cgstPaise + tax.sgstPaise + tax.igstPaise, + ).toBe(tax.totalPaise); + }); + + it("charges IGST only when the buyer is in another state", () => { + const tax = deriveConsumerInvoiceTax({ + ...CHARGED, + buyerStateCode: "27", + supplierStateCode: "KA", + buyerCountry: "IN", + }); + expect(tax.igstPaise).toBe(CHARGED.taxAmountPaise); + expect(tax.cgstPaise).toBe(0); + expect(tax.sgstPaise).toBe(0); + expect(tax.placeOfSupply).toBe("27"); + expect(tax.taxableValuePaise + tax.igstPaise).toBe(tax.totalPaise); + }); + + it("falls back to the supplier's own state when no address is on record", () => { + const tax = deriveConsumerInvoiceTax({ + ...CHARGED, + buyerStateCode: null, + supplierStateCode: "KA", + buyerCountry: "IN", + }); + expect(tax.placeOfSupplySource).toBe("SUPPLIER_DEFAULT_12_2_B"); + expect(tax.igstPaise).toBe(0); + expect(tax.placeOfSupply).toBe("29"); + expect(tax.cgstPaise + tax.sgstPaise).toBe(CHARGED.taxAmountPaise); + expect( + tax.taxableValuePaise + tax.cgstPaise + tax.sgstPaise + tax.igstPaise, + ).toBe(tax.totalPaise); + }); + + it("gives the odd paise of an uneven tax to SGST", () => { + // Every other fixture here charges an even tax, so the floor-CGST rule is + // indistinguishable from a plain halving. This pins the residual: the two + // heads must still sum to the tax the buyer was actually charged, because + // that figure is what settlement credited to GST_PAYABLE. + const tax = deriveConsumerInvoiceTax({ + totalPaise: 118_001, + taxAmountPaise: 18_001, + buyerStateCode: "29", + supplierStateCode: "KA", + buyerCountry: "IN", + }); + expect(tax.cgstPaise).toBe(9_000); + expect(tax.sgstPaise).toBe(9_001); + expect(tax.cgstPaise + tax.sgstPaise).toBe(18_001); + }); +}); + +/** + * 4. The platform's own state (#1365). The first two digits of a GSTIN are the + * state of registration by law, so the GSTIN is authoritative and + * `SUPPLIER_STATE_CODE` is only the fallback. Reading the env var alone put + * IGST on an intra-state supply whenever it was unset, and picking either + * one when they disagree burns a gapless Rule 46 number on a document that + * cannot be corrected in place. + */ +describe("the supplier's own state", () => { + const KARNATAKA_GSTIN = "29AABCU9603R1ZM"; + + it("comes from the GSTIN when the env code is unset, and keeps the supply intra-state", () => { + const { stateCode, mismatch } = resolveSupplierStateCode( + KARNATAKA_GSTIN, + undefined, + ); + expect(mismatch).toBeNull(); + expect(stateCode).toBe("29"); + + const tax = deriveConsumerInvoiceTax({ + totalPaise: 118_000, + taxAmountPaise: 18_000, + buyerStateCode: "29", + supplierStateCode: stateCode, + buyerCountry: "IN", + }); + expect(tax.igstPaise).toBe(0); + expect(tax.cgstPaise + tax.sgstPaise).toBe(18_000); + expect(tax.placeOfSupply).toBe("29"); + }); + + it("falls back to the env code only when the GSTIN carries no state", () => { + expect(resolveSupplierStateCode(null, "KA").stateCode).toBe("29"); + }); + + it("refuses to choose when the two disagree", () => { + const resolved = resolveSupplierStateCode(KARNATAKA_GSTIN, "MH"); + expect(resolved.stateCode).toBeNull(); + expect(resolved.mismatch).toEqual({ fromGstin: "29", fromEnv: "27" }); + }); + + it("mints nothing and records the fault when the two disagree", async () => { + const previousEnv = process.env.SUPPLIER_STATE_CODE; + process.env.SUPPLIER_STATE_CODE = "MH"; + (getPlatformSupplier as jest.Mock).mockReturnValue({ + name: "Familiarise", + gstin: KARNATAKA_GSTIN, + address: "Bengaluru, Karnataka", + }); + const create = jest.fn(); + const tx = { + consumerInvoice: { + findUnique: jest.fn().mockResolvedValue(null), + create, + }, + payment: { + findUnique: jest.fn().mockResolvedValue({ + id: "pay_1", + amount: 118_000, + taxAmount: 18_000, + currency: "INR", + paymentStatus: "SUCCEEDED", + deletedAt: null, + buyerCountry: "IN", + consumerStateCode: "29", + billableToOrgInvoiceId: null, + createdAt: new Date("2026-08-10T06:00:00Z"), + userId: "usr_1", + legs: [], + creditUsages: [], + user: { + id: "usr_1", + name: "A Buyer", + email: "buyer@example.com", + address: null, + city: null, + consulteeProfile: { billingStateCode: "29" }, + }, + }), + }, + } as unknown as Parameters[0]; + + try { + const result = await mintConsumerInvoice(tx, { paymentId: "pay_1" }); + expect(result.consumerInvoiceId).toBeNull(); + expect(create).not.toHaveBeenCalled(); + expect(recordSystemError).toHaveBeenCalledWith( + expect.objectContaining({ + summary: expect.stringMatching(/supplier state is ambiguous/i), + }), + ); + } finally { + process.env.SUPPLIER_STATE_CODE = previousEnv; + } + }); +}); + +/** + * 5. Credit notes (#1370). A partial refund and a later lost chargeback are + * two idempotency keys against one invoice, so a per-note cap let them + * credit past 100% and understate the period's output tax. And flooring + * each head independently left the row short of its own total, which the + * register then flagged as a reconciliation warning. + */ +describe("consumer credit notes", () => { + // ₹590.01 charged, ₹90.01 of it tax — an odd-paise invoice, split by the + // invoice's own floor-CGST rule (CGST 4,500 / SGST 4,501). + const INVOICE = { + invoiceTotalPaise: 59_001, + invoiceCgstPaise: 4_500, + invoiceSgstPaise: 4_501, + invoiceIgstPaise: 0, + }; + + const balances = (a: ConsumerCreditNoteAmounts): number => + a.taxableValuePaise + a.cgstPaise + a.sgstPaise + a.igstPaise; + + function credit(alreadyCreditedPaise: number, requestedPaise: number) { + return deriveConsumerCreditNoteAmounts({ + ...INVOICE, + alreadyCreditedPaise, + requestedPaise, + }); + } + + it("balances a one-third reversal and stays under every invoice head", () => { + const derived = credit(0, 19_667); + expect(derived.outcome).toBe("CREDIT"); + if (derived.outcome !== "CREDIT") return; + expect(derived.amounts).toEqual({ + creditedTotalPaise: 19_667, + taxableValuePaise: 16_667, + cgstPaise: 1_500, + sgstPaise: 1_500, + igstPaise: 0, + }); + expect(balances(derived.amounts)).toBe(19_667); + expect(derived.amounts.cgstPaise).toBeLessThanOrEqual( + INVOICE.invoiceCgstPaise, + ); + expect(derived.amounts.sgstPaise).toBeLessThanOrEqual( + INVOICE.invoiceSgstPaise, + ); + }); + + it("gives the odd credited paise of tax to SGST and the residual to the taxable value", () => { + const derived = credit(0, 19_672); + if (derived.outcome !== "CREDIT") throw new Error("expected a credit"); + expect(derived.amounts.cgstPaise).toBe(1_500); + expect(derived.amounts.sgstPaise).toBe(1_501); + expect(derived.amounts.taxableValuePaise).toBe(16_671); + expect(balances(derived.amounts)).toBe(19_672); + }); + + it("caps the second note at the remainder and refuses the third", () => { + const second = credit(30_000, 40_000); + if (second.outcome !== "CREDIT") throw new Error("expected a credit"); + expect(second.amounts.creditedTotalPaise).toBe(29_001); + expect(balances(second.amounts)).toBe(29_001); + + expect(credit(59_001, 1_000).outcome).toBe("FULLY_CREDITED"); + }); +}); diff --git a/__tests__/payments/dispute-earnings-hardening.test.ts b/__tests__/payments/dispute-earnings-hardening.test.ts index 81d01fc55..60e24125c 100644 --- a/__tests__/payments/dispute-earnings-hardening.test.ts +++ b/__tests__/payments/dispute-earnings-hardening.test.ts @@ -107,6 +107,13 @@ jest.mock("../../lib/payments/operations/refund", () => ({ mintInvoiceRefundCreditNote: jest.fn(), mintRefundCreditNote: jest.fn().mockResolvedValue({ creditNoteId: null }), })); +// #1365 — the chargeback path now mints the B2C credit note beside the org one. +jest.mock("../../lib/payments/billing/consumer-invoice", () => ({ + mintConsumerCreditNote: jest + .fn() + .mockResolvedValue({ consumerCreditNoteId: null }), + mintConsumerInvoice: jest.fn().mockResolvedValue({ consumerInvoiceId: null }), +})); // --------------------------------------------------------------------------- // Row types — exactly the fields the handler reads/writes. Fixtures are these @@ -116,6 +123,8 @@ jest.mock("../../lib/payments/operations/refund", () => ({ interface PaymentRow { id: string; paymentIntent: string; + /** #1353 — the gateway `pay_…` id; the second key the handlers match on. */ + gatewayPaymentId?: string | null; userId: string; amount: number; organizationId: string | null; @@ -221,6 +230,10 @@ function inList(status: EarningsLostWhere["status"], actual: EarningStatus): boo interface TxStub { payment: { findUnique: (args: { where: { paymentIntent?: string; id?: string } }) => Promise; + // #1353 — handleDisputeCreated resolves by either id through an `OR`. + findFirst: (args: { + where: { OR?: Array> }; + }) => Promise; }; dispute: { findUnique: (args: { where: { disputeId: string } }) => Promise<(DisputeRow & { payment: PaymentRow | null }) | null>; @@ -271,6 +284,20 @@ const mockedTransaction = prisma.$transaction as unknown as jest.Mock; function makeTxStub(): TxStub { return { payment: { + findFirst: async ({ where }) => { + const clauses = where.OR ?? []; + return ( + Array.from(store.payments.values()).find((p) => + clauses.some( + (clause) => + (clause.paymentIntent !== undefined && + clause.paymentIntent === p.paymentIntent) || + (clause.gatewayPaymentId !== undefined && + clause.gatewayPaymentId === p.gatewayPaymentId), + ), + ) ?? null + ); + }, findUnique: async ({ where }) => { if (where.paymentIntent) { return ( diff --git a/__tests__/payments/dispute-refund-correctness.test.ts b/__tests__/payments/dispute-refund-correctness.test.ts index 80fafec14..cb1903344 100644 --- a/__tests__/payments/dispute-refund-correctness.test.ts +++ b/__tests__/payments/dispute-refund-correctness.test.ts @@ -87,6 +87,28 @@ const store: { function txStub() { return { payment: { + // #1353 — the handlers resolve by EITHER id now (order id or the gateway + // payment id), so the stub answers the `OR` shape they actually send. + findFirst: jest.fn( + async ({ + where, + }: { + where: { OR?: Array> }; + }) => { + const clauses = where.OR ?? []; + return ( + Array.from(store.payments.values()).find((p) => + clauses.some( + (clause) => + (clause.paymentIntent !== undefined && + clause.paymentIntent === p.paymentIntent) || + (clause.gatewayPaymentId !== undefined && + clause.gatewayPaymentId === p.gatewayPaymentId), + ), + ) ?? null + ); + }, + ), findUnique: jest.fn(async ({ where }: { where: Row }) => { // Lookup is by paymentIntent for the B2C path. if (where.paymentIntent) { diff --git a/__tests__/payments/gateway-fence-classification.test.ts b/__tests__/payments/gateway-fence-classification.test.ts new file mode 100644 index 000000000..6d88ea931 --- /dev/null +++ b/__tests__/payments/gateway-fence-classification.test.ts @@ -0,0 +1,162 @@ +/** + * #1351 — a fenced or stub gateway must reach the caller as a business + * rejection. Before this pin the guard's error matched no message pattern, so + * `POST /api/checkout` with a disabled rail answered 500 UNKNOWN_ERROR and + * Sentry recorded it as an unexpected exception. + */ +import { + classifyError, + ErrorTypes, +} from "@/lib/errors/classification/payment-error-classification"; +import { getErrorToast } from "@/lib/errors/mapping/payment-error-toast-map"; +import { + DisabledGatewayError, + UnsupportedGatewayError, +} from "@/lib/payments/validation/gateway-guards"; +import { DomainVerificationRequiredError } from "@/lib/enterprise/governance"; +import { WalletInsufficientFundsError } from "@/lib/api/organizations/wallet"; + +describe("gateway fence classification", () => { + it("classifies a disabled gateway as a 422 business rejection", () => { + const classified = classifyError( + new DisabledGatewayError("STRIPE", "route a checkout"), + ); + + expect(classified.errorType).toBe(ErrorTypes.GATEWAY_UNAVAILABLE); + expect(classified.isBusinessError).toBe(true); + expect(classified.httpStatus).toBe(422); + }); + + it("classifies a stub gateway the same way", () => { + const classified = classifyError( + new UnsupportedGatewayError("PAYPAL", "issue a refund"), + ); + + expect(classified.errorType).toBe(ErrorTypes.GATEWAY_UNAVAILABLE); + expect(classified.httpStatus).toBe(422); + }); + + it("gives the buyer a payment-method toast, not the env flag", () => { + const toast = getErrorToast(ErrorTypes.GATEWAY_UNAVAILABLE); + + expect(toast.title).toBe("This payment method is not available"); + expect(toast.description).not.toContain("STRIPE_ENABLED"); + }); + + // #1426 — WALLET_FROZEN, CONSENT_REQUIRED and CONSENT_WITHDRAWN are the + // codes checkout.ts already throws (lib/payments/operations/checkout.ts:881, + // :1556, :2538) but BUSINESS_ERROR_CODES only carried GATEWAY_DISABLED and + // UNSUPPORTED_GATEWAY, so these three fell through to the 500 UNKNOWN path. + it.each(["WALLET_FROZEN", "CONSENT_REQUIRED", "CONSENT_WITHDRAWN"])( + "classifies %s as a business rejection with an actionable toast", + (code) => { + const classified = classifyError( + Object.assign(new Error("refused"), { code }), + ); + + expect(classified.isBusinessError).toBe(true); + expect(classified.httpStatus).not.toBe(500); + + const toast = getErrorToast(classified.errorType); + expect(toast.title).not.toBe("Something Went Wrong"); + expect(toast.description).toBeTruthy(); + }, + ); + + // #1407 — invoice funding's verified-domain guard throws its own typed 403, + // and with no BUSINESS_ERROR_CODES row it answered 500 UNKNOWN_ERROR: the + // buyer saw "something went wrong" for a condition their admin can fix. + it("classifies the verified-domain guard as an actionable 403", () => { + const classified = classifyError( + new DomainVerificationRequiredError("INVOICE_FUNDING"), + ); + + expect(classified.errorType).toBe(ErrorTypes.DOMAIN_VERIFICATION_REQUIRED); + expect(classified.isBusinessError).toBe(true); + expect(classified.httpStatus).toBe(403); + + const toast = getErrorToast(classified.errorType); + expect(toast.title).toBe("Domain Verification Required"); + expect(toast.description).toBeTruthy(); + }); + + // #1458 — the overage settlement throws PROGRAM_CAP_EXHAUSTED as a 402 with + // copy the buyer can act on, but the checkout catch rewrote it to "Failed to + // record payment information" and the classifier answered 500 UNKNOWN_ERROR. + it("classifies PROGRAM_CAP_EXHAUSTED as a 402 with its own toast", () => { + const classified = classifyError( + Object.assign(new Error("cycle ceiling reached"), { + httpStatus: 402, + code: "PROGRAM_CAP_EXHAUSTED", + }), + ); + + expect(classified.errorType).toBe(ErrorTypes.PROGRAM_CAP_EXHAUSTED); + expect(classified.isBusinessError).toBe(true); + expect(classified.httpStatus).toBe(402); + + const toast = getErrorToast(classified.errorType); + expect(toast.title).not.toBe("Something Went Wrong"); + expect(toast.description).toContain("programme budget"); + }); + + it("classifies the other checkout-transaction refusals off their codes", () => { + expect( + classifyError( + Object.assign(new Error("session cap"), { + code: "PROGRAM_SESSION_CAP_REACHED", + }), + ).httpStatus, + ).toBe(402); + expect( + classifyError( + Object.assign(new Error("member overage"), { + code: "OVERAGE_CHARGE_MEMBER_UNSUPPORTED", + }), + ).httpStatus, + ).toBe(409); + expect( + classifyError( + Object.assign(new Error("funding"), { + code: "OVERAGE_UNSUPPORTED_FUNDING", + }), + ).httpStatus, + ).toBe(409); + }); + + // #1467 — a lapsed contract and a dunning suspension are org entitlement + // states the member's admin can clear. Both threw bare Errors, so the + // message-only fallback answered 500 UNKNOWN_ERROR on a routine refusal. + it.each([ + ["PROGRAM_ASSIGNMENT_INACTIVE", 409], + ["BILLING_SUSPENDED_DUNNING", 402], + ])("classifies %s as a business rejection with status %i", (code, status) => { + const classified = classifyError( + Object.assign(new Error("refused"), { code }), + ); + + expect(classified.isBusinessError).toBe(true); + expect(classified.httpStatus).toBe(status); + + const toast = getErrorToast(classified.errorType); + expect(toast.title).not.toBe("Something Went Wrong"); + expect(toast.description).toContain("admin"); + }); + + // #1477 — `WalletInsufficientFundsError` carried no code at all, so an org + // that had simply spent its wallet down got 500 "Something Went Wrong" and a + // Sentry incident for a refusal only a top-up can clear. + it("classifies an overdrawn org wallet as a 402 pointing at the billing admin", () => { + const classified = classifyError( + new WalletInsufficientFundsError("ba_1", 250000), + ); + + expect(classified.errorType).toBe(ErrorTypes.WALLET_INSUFFICIENT_FUNDS); + expect(classified.isBusinessError).toBe(true); + expect(classified.httpStatus).toBe(402); + + const toast = getErrorToast(classified.errorType); + expect(toast.title).not.toBe("Something Went Wrong"); + expect(toast.description).toContain("billing admin"); + }); +}); diff --git a/__tests__/payments/gateway-note-limits.test.ts b/__tests__/payments/gateway-note-limits.test.ts new file mode 100644 index 000000000..b20307f68 --- /dev/null +++ b/__tests__/payments/gateway-note-limits.test.ts @@ -0,0 +1,128 @@ +/** + * @jest-environment node + */ + +/** + * #1437 — Razorpay caps an order's `notes` at 15 keys and 256 characters per + * value, and rejects the order outright past either bound. The buyer's booking + * note was forwarded verbatim with no length bound anywhere in the path, so a + * long note did not degrade the payload — it made the order impossible to + * create, which a buyer experiences as being unable to pay at all. + * + * This pin covers both halves of the fix: the schema refuses the note at the + * request boundary with a message the buyer can act on, and the metadata + * builder truncates whatever still reaches it so the gateway call survives + * even if a future caller bypasses the schema. The full note is never lost — + * it is persisted on the Payment and Appointment rows either way. + */ + +// Boundary mocks. `lib/payments/operations/checkout` transitively imports the +// auth stack through the payouts barrel, which is ESM-only under this Jest +// transform; none of it is exercised by a pure metadata builder. +jest.mock("../../lib/prisma", () => ({ __esModule: true, default: {} })); + +jest.mock("../../lib/payments/payouts", () => ({ + __esModule: true, + createEarningsFromPayment: jest.fn(), +})); + +jest.mock("../../lib/payments/index", () => ({ + __esModule: true, + createPaymentIntent: jest.fn(), + cancelPaymentIntent: jest.fn(), +})); + +jest.mock("../../utils/appointmentlock", () => ({ + __esModule: true, + CHECKOUT_WAIT_RETRY_CONFIG: { retryCount: 5 }, + CHECKOUT_LOCK_TTL_MS: {}, + EventFullError: class extends Error {}, + lockSlotBooking: jest.fn(), + unlockSlotBooking: jest.fn(), + lockEventCheckout: jest.fn(), + unlockEventCheckout: jest.fn(), + lockConsulteeBooking: jest.fn(), + unlockConsulteeBooking: jest.fn(), + extendLock: jest.fn(), + extendSlotInterval: jest.fn(), +})); + +import { checkoutSchema, type CheckoutInput } from "../../schemas/checkout"; +import { buildPaymentMetadata } from "../../lib/payments/operations/checkout"; + +const HOUR_MS = 60 * 60 * 1000; +const LONG_NOTE = "a".repeat(300); + +function consultationInput(notes: string): CheckoutInput { + const startsAt = new Date(Date.now() + 48 * HOUR_MS); + const endsAt = new Date(startsAt.getTime() + HOUR_MS); + return { + appointmentType: "CONSULTATION", + planId: "plan-1", + paymentGateway: "RAZORPAY", + startsAt: startsAt.toISOString(), + endsAt: endsAt.toISOString(), + slotOfAvailabilityWeeklyId: "weekly-1", + notes, + } as CheckoutInput; +} + +describe("#1437 gateway note limits", () => { + it("refuses a 300-character booking note at the request boundary", () => { + const parsed = checkoutSchema.safeParse(consultationInput(LONG_NOTE)); + + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain( + "256 characters or fewer", + ); + }); + + it("accepts a note exactly at the limit", () => { + expect( + checkoutSchema.safeParse(consultationInput("a".repeat(256))).success, + ).toBe(true); + }); + + it("truncates the note in the gateway payload and stays under 15 keys", () => { + const metadata = buildPaymentMetadata( + { ...consultationInput(LONG_NOTE), eventId: "event-1" } as CheckoutInput, + "user-1", + { organizationId: "org-1", fundingSource: "WALLET" }, + ); + + expect(metadata.notes).toHaveLength(256); + expect(LONG_NOTE.startsWith(metadata.notes)).toBe(true); + // The org-sponsored event shape is the widest one this builder emits; it + // sat at exactly Razorpay's 15-key ceiling before `discountCode` was cut. + expect(Object.keys(metadata).length).toBeLessThanOrEqual(14); + }); + + /** + * #1462 — the same payload, seen from the webhook's side. A scheduling-period + * subscription has no slot times, and sending them as `""` failed + * `z.string().datetime().optional()` on every capture, stranding the sale as + * REQUIRES_MANUAL_RECOVERY with the buyer already charged. + */ + it("omits every empty optional field instead of sending it as an empty string", () => { + const metadata = buildPaymentMetadata( + { + appointmentType: "SUBSCRIPTION", + planId: "plan-1", + paymentGateway: "RAZORPAY", + schedulingPeriodStartsAt: "2026-09-01T00:00:00.000Z", + schedulingPeriodEndsAt: "2026-12-01T00:00:00.000Z", + } as unknown as CheckoutInput, + "user-1", + ); + + expect(metadata).not.toHaveProperty("startsAt"); + expect(metadata).not.toHaveProperty("endsAt"); + expect(metadata).not.toHaveProperty("slotOfAvailabilityWeeklyId"); + expect(metadata).not.toHaveProperty("slotOfAvailabilityCustomId"); + expect(metadata).not.toHaveProperty("notes"); + expect(Object.values(metadata)).not.toContain(""); + // What the sale actually needs still travels. + expect(metadata.schedulingPeriodStartsAt).toBe("2026-09-01T00:00:00.000Z"); + expect(metadata.schedulingPeriodEndsAt).toBe("2026-12-01T00:00:00.000Z"); + }); +}); diff --git a/__tests__/payments/invoice-rollup-serialization-retry.test.ts b/__tests__/payments/invoice-rollup-serialization-retry.test.ts new file mode 100644 index 000000000..e612e8ffb --- /dev/null +++ b/__tests__/payments/invoice-rollup-serialization-retry.test.ts @@ -0,0 +1,118 @@ +/** + * @jest-environment node + */ + +/** + * #1347 — a serialization abort used to cost an org a whole billing cycle. + * + * The rollup runs Serializable so two concurrent runs can't both issue an + * invoice for the same accruals. The loser aborts with P2034, and the cron + * treated every P2034 as a benign skip: "an overlapping run claimed this org". + * That reading only holds when the rival was a same-org rollup. Postgres also + * aborts on a read-write dependency with an unrelated writer touching Payment + * or OverageEvent, and there the org simply went unbilled until the next + * monthly run, with a console.log as its only trace. + * + * These pin the two halves of the fix: the abort is retried before it is + * believed, and an exhausted retry is reported rather than swallowed. + */ + +const mockTransaction = jest.fn(); +const mockOrgFindUnique = jest.fn(); +const mockPaymentFindMany = jest.fn(); +const mockRecordSystemError = jest.fn(); + +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + $transaction: (...a: unknown[]) => mockTransaction(...a), + organization: { findUnique: (...a: unknown[]) => mockOrgFindUnique(...a) }, + payment: { findMany: (...a: unknown[]) => mockPaymentFindMany(...a) }, + $disconnect: jest.fn(), + }, +})); + +jest.mock("../../lib/enterprise/system-events", () => ({ + recordSystemError: (...a: unknown[]) => mockRecordSystemError(...a), +})); + +jest.mock("../../lib/maintenance-cron", () => ({ + abortIfMaintenance: jest.fn(), +})); + +import { Prisma } from "@prisma/client"; +import { rollupOrgInvoiceAccruals } from "@/lib/payments/billing/invoice-rollup"; +import { settleInvoiceAccruals } from "@/jobs/billing/settle-invoice-accruals"; + +function p2034() { + return new Prisma.PrismaClientKnownRequestError("write conflict", { + code: "P2034", + clientVersion: "test", + }); +} + +function invoice(id: string) { + return { + invoiceId: id, + invoiceNumber: `ACME/26-27/${id}`, + billedPaymentCount: 2, + subtotalPaise: 500000, + totalPaise: 590000, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + delete process.env.ENABLE_CONSOLIDATED_INVOICE; + mockOrgFindUnique.mockResolvedValue({ + id: "org_1", + slug: "acme", + invoiceNumberPrefix: null, + billingAccountId: "ba_1", + dataResidencyRegion: "IN", + paymentTermsDays: 30, + taxInfo: null, + }); +}); + +describe("rollupOrgInvoiceAccruals — serialization retry", () => { + it("retries a P2034 and issues exactly one invoice", async () => { + // The first attempt loses the race; the second commits. + mockTransaction + .mockRejectedValueOnce(p2034()) + .mockResolvedValueOnce(invoice("inv_1")); + + const result = await rollupOrgInvoiceAccruals({ organizationId: "org_1" }); + + expect(result.invoiceId).toBe("inv_1"); + // Two attempts, one committed invoice — a retry must not double-bill. + expect(mockTransaction).toHaveBeenCalledTimes(2); + }); +}); + +describe("settleInvoiceAccruals — exhausted retries", () => { + it("records a system error and still bills the next org", async () => { + mockPaymentFindMany.mockResolvedValue([ + { organizationId: "org_contended" }, + { organizationId: "org_ok" }, + ]); + // withSerializableRetry burns its four attempts on the first org, then the + // second org commits on its first try. + mockTransaction + .mockRejectedValueOnce(p2034()) + .mockRejectedValueOnce(p2034()) + .mockRejectedValueOnce(p2034()) + .mockRejectedValueOnce(p2034()) + .mockResolvedValueOnce(invoice("inv_2")); + + const r = await settleInvoiceAccruals(); + + expect(mockRecordSystemError).toHaveBeenCalledTimes(1); + expect(mockRecordSystemError.mock.calls[0][0]).toMatchObject({ + organizationId: "org_contended", + category: "INVOICE", + }); + // The contended org is skipped, not fatal: the next org is still invoiced. + expect(r.invoicesCreated).toBe(1); + }); +}); diff --git a/__tests__/payments/legacy-capture-tentative-birth.test.ts b/__tests__/payments/legacy-capture-tentative-birth.test.ts index b6df9e8f8..45cf7a9b6 100644 --- a/__tests__/payments/legacy-capture-tentative-birth.test.ts +++ b/__tests__/payments/legacy-capture-tentative-birth.test.ts @@ -46,9 +46,16 @@ const classUpdateMany = jest.fn().mockResolvedValue({ count: 1 }); const participantCreateMany = jest.fn().mockResolvedValue({ count: 1 }); const participantUpdateMany = jest.fn().mockResolvedValue({ count: 1 }); const paymentFindUnique = jest.fn(); -const paymentUpdate = jest.fn().mockResolvedValue({}); +// #1439 — the confirmation stamp is a CAS, so the tx writer is updateMany +// and a count of 1 means this capture won the PENDING row. +const paymentUpdateMany = jest.fn().mockResolvedValue({ count: 1 }); const txStub = { - payment: { findUnique: paymentFindUnique, update: paymentUpdate }, + payment: { + findUnique: paymentFindUnique, + // The appointmentId link is still a plain update — only STATUS rides a CAS. + update: jest.fn().mockResolvedValue({}), + updateMany: paymentUpdateMany, + }, slotOfAppointment: { create: slotCreate, update: slotUpdate, diff --git a/__tests__/payments/license-refund-ledger.test.ts b/__tests__/payments/license-refund-ledger.test.ts index 0f0ab73da..15d8aa4da 100644 --- a/__tests__/payments/license-refund-ledger.test.ts +++ b/__tests__/payments/license-refund-ledger.test.ts @@ -144,6 +144,10 @@ function txStub() { organizationPayout: { update: jest.fn().mockResolvedValue({}) }, organizationInvoice: { findUnique: jest.fn().mockResolvedValue(null) }, creditNote: { findUnique: jest.fn().mockResolvedValue(null) }, + // #1365 — the B2C sibling mint probes both of these and no-ops when the + // payment has no consumer invoice, which is the case in every fixture here. + consumerCreditNote: { findUnique: jest.fn().mockResolvedValue(null) }, + consumerInvoice: { findUnique: jest.fn().mockResolvedValue(null) }, overageEvent: { findFirst: jest.fn().mockResolvedValue(null) }, orgAuditLog: { create: jest.fn().mockResolvedValue({}) }, paymentLeg: { upsert: jest.fn().mockResolvedValue({}) }, diff --git a/__tests__/payments/multi-party-booking-journal.test.ts b/__tests__/payments/multi-party-booking-journal.test.ts index 0d2a67c6b..cb9465cf6 100644 --- a/__tests__/payments/multi-party-booking-journal.test.ts +++ b/__tests__/payments/multi-party-booking-journal.test.ts @@ -38,6 +38,9 @@ jest.mock("../../lib/collaborators/service", () => ({ jest.mock("../../lib/api/organizations/rate-card", () => ({ resolveEffectiveRateCard: jest.fn(), + // #1335 — settlement destructures this from the same module; a partial mock + // leaves it undefined and every split throws before it resolves a card. + isScopedRateCardResolutionEnabled: () => false, })); type CapturedLedgerCreate = { @@ -78,10 +81,12 @@ jest.mock("../../lib/prisma", () => { findUnique: jest.fn().mockResolvedValue(null), create: jest .fn() - .mockImplementation(async ({ data }: { data: CapturedLedgerCreate }) => { - capturedLedgerTxns.push(data); - return { id: "ltxn-" + capturedLedgerTxns.length }; - }), + .mockImplementation( + async ({ data }: { data: CapturedLedgerCreate }) => { + capturedLedgerTxns.push(data); + return { id: "ltxn-" + capturedLedgerTxns.length }; + }, + ), }, ledgerAccount: { upsert: jest @@ -92,6 +97,8 @@ jest.mock("../../lib/prisma", () => { }, ledgerAccountBalance: { upsert: jest.fn().mockResolvedValue({}) }, paymentLeg: { findMany: jest.fn().mockResolvedValue([]) }, + // #1458 — only read when an OVERAGE_INVOICE_ACCRUAL leg funded the payment. + overageEvent: { findFirst: jest.fn().mockResolvedValue(null) }, consultantEarnings: { findFirst: jest.fn().mockResolvedValue(null), create: jest @@ -153,6 +160,8 @@ const mockedTx = ( membership: { findFirst: jest.Mock }; consultantEarnings: { findFirst: jest.Mock; create: jest.Mock }; ledgerTransaction: { findUnique: jest.Mock; create: jest.Mock }; + paymentLeg: { findMany: jest.Mock }; + overageEvent: { findFirst: jest.Mock }; }; } ).__mockTx; @@ -242,6 +251,8 @@ beforeEach(() => { capturedOrgEarnings = []; mockedTx.consultantEarnings.findFirst.mockResolvedValue(null); mockedTx.ledgerTransaction.findUnique.mockResolvedValue(null); + mockedTx.paymentLeg.findMany.mockResolvedValue([]); + mockedTx.overageEvent.findFirst.mockResolvedValue(null); setStandardRateCard(); }); @@ -261,8 +272,16 @@ describe("#773 multi-party booking journal", () => { // fee 2_549, net 21_674, org absorbs the remainder 1_276. mockedCalculateSplit.mockResolvedValue([ { consultantProfileId: PRIMARY_PROFILE, share: 42_501, role: "OWNER" }, - { consultantProfileId: COLLAB_HOST_PROFILE, share: 25_499, role: "CO_HOST" }, - { consultantProfileId: COLLAB_INDEP_PROFILE, share: 17_000, role: "CO_HOST" }, + { + consultantProfileId: COLLAB_HOST_PROFILE, + share: 25_499, + role: "CO_HOST", + }, + { + consultantProfileId: COLLAB_INDEP_PROFILE, + share: 17_000, + role: "CO_HOST", + }, ]); await createEarningsFromPayment({ @@ -300,11 +319,19 @@ describe("#773 multi-party booking journal", () => { ).toBe(42_501); // Hosted collaborator: NET of ORG_ANOTHER's cut. expect( - legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${COLLAB_HOST_PROFILE}|INR`), + legAmount( + txn, + "CREDIT", + `CONSULTANT_PAYABLE|_|${COLLAB_HOST_PROFILE}|INR`, + ), ).toBe(21_674); // Independent collaborator: full share. expect( - legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${COLLAB_INDEP_PROFILE}|INR`), + legAmount( + txn, + "CREDIT", + `CONSULTANT_PAYABLE|_|${COLLAB_INDEP_PROFILE}|INR`, + ), ).toBe(17_000); expect(legAmount(txn, "CREDIT", `ORG_PAYABLE|${ORG_LEARNPRO}|_|INR`)).toBe( 5_000, @@ -352,7 +379,11 @@ describe("#773 multi-party booking journal", () => { // shave the pool); pool = 80_000, split 60_000 owner + 20_000 collab. mockedCalculateSplit.mockResolvedValue([ { consultantProfileId: PRIMARY_PROFILE, share: 60_000, role: "OWNER" }, - { consultantProfileId: COLLAB_INDEP_PROFILE, share: 20_000, role: "CO_HOST" }, + { + consultantProfileId: COLLAB_INDEP_PROFILE, + share: 20_000, + role: "CO_HOST", + }, ]); await createEarningsFromPayment({ @@ -371,7 +402,11 @@ describe("#773 multi-party booking journal", () => { legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${PRIMARY_PROFILE}|INR`), ).toBe(60_000); expect( - legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${COLLAB_INDEP_PROFILE}|INR`), + legAmount( + txn, + "CREDIT", + `CONSULTANT_PAYABLE|_|${COLLAB_INDEP_PROFILE}|INR`, + ), ).toBe(20_000); const legs = legsOf(txn); const debit = legs @@ -391,7 +426,11 @@ describe("#773 multi-party booking journal", () => { }); mockedCalculateSplit.mockResolvedValue([ { consultantProfileId: PRIMARY_PROFILE, share: 59_501, role: "OWNER" }, - { consultantProfileId: COLLAB_HOST_PROFILE, share: 25_499, role: "CO_HOST" }, + { + consultantProfileId: COLLAB_HOST_PROFILE, + share: 25_499, + role: "CO_HOST", + }, ]); // Crash-recovery shape: the journal txn survived but the earnings tx is // being replayed — postLedgerTxn's fast-path must dedupe on the key. @@ -426,3 +465,92 @@ describe("#773 multi-party booking journal", () => { expect(mockedTx.ledgerTransaction.create).not.toHaveBeenCalled(); }); }); + +/** + * #1458 / Sentry FAMILIARISE_WEB-28 — the BOOKING posting's credits are all + * derived from `payment.originalAmount` (+ tax), while its debits are the + * funding legs plus a DISCOUNT plug clamped at >= 0. The posting therefore + * balances only while Σ(funding legs) <= originalAmount + tax; anything that + * pushes a funding leg above the nominal price throws LedgerImbalanceError and + * the booking commits with no journal entry at all. + */ +describe("#1458 org-overage rails keep the booking journal balanced", () => { + it("WALLET + CHARGE_ORG: the wallet leg alone funds the price and the posting balances", async () => { + // The exact #1458 payment: a 258,326-paise wallet debit on a 218,920 + + // 39,406 booking. Before the fix an OVERAGE_INVOICE_ACCRUAL leg of 248,326 + // was added and Payment.amount became 506,652, so debits overshot the + // credits by the marginal and the journal was dropped. + setMembershipMap({ [PRIMARY_PROFILE]: null }); + mockedCalculateSplit.mockResolvedValue([]); + mockedTx.paymentLeg.findMany.mockResolvedValue([ + { source: "WALLET", amountPaise: 258_326 }, + ]); + + await createEarningsFromPayment({ + payment: makePayment({ + amount: 258_326, + originalAmount: 218_920, + taxAmount: 39_406, + }), + appointmentType: "CONSULTATION", + }); + + const txn = capturedLedgerTxns[0]; + const legs = legsOf(txn); + const debit = legs + .filter((l) => l.direction === "DEBIT") + .reduce((s, l) => s + l.paise, 0); + const credit = legs + .filter((l) => l.direction === "CREDIT") + .reduce((s, l) => s + l.paise, 0); + expect(debit).toBe(258_326); + expect(credit).toBe(258_326); + // No leg was added and no amount bumped, so the wallet debit is the whole + // funding side and the platform absorbs nothing as DISCOUNT. + expect(legAmount(txn, "DEBIT", "WALLET|_|_|INR")).toBe(258_326); + expect(legAmount(txn, "DEBIT", "DISCOUNT|_|_|INR")).toBe(0); + expect(mockedTx.overageEvent.findFirst).not.toHaveBeenCalled(); + }); + + it("INVOICE + CHARGE_ORG with a surcharge: the surcharge is credited to PLATFORM_FEE", async () => { + // #785's carve leaves INVOICE_ACCRUAL at 0 and OVERAGE_INVOICE_ACCRUAL at + // base + surcharge, and bumps Payment.amount by the surcharge — which is + // real funding that sits OUTSIDE originalAmount. The surcharge is platform + // revenue for exceeding the cap, so it credits PLATFORM_FEE. + setMembershipMap({ [PRIMARY_PROFILE]: null }); + mockedCalculateSplit.mockResolvedValue([]); + mockedTx.paymentLeg.findMany.mockResolvedValue([ + { source: "INVOICE_ACCRUAL", amountPaise: 0 }, + { source: "OVERAGE_INVOICE_ACCRUAL", amountPaise: 125_000 }, + ]); + mockedTx.overageEvent.findFirst.mockResolvedValue({ + surchargePaise: BigInt(25_000), + }); + + await createEarningsFromPayment({ + payment: makePayment({ + amount: 125_000, + originalAmount: 100_000, + taxAmount: 0, + }), + appointmentType: "CONSULTATION", + }); + + const txn = capturedLedgerTxns[0]; + const legs = legsOf(txn); + const debit = legs + .filter((l) => l.direction === "DEBIT") + .reduce((s, l) => s + l.paise, 0); + const credit = legs + .filter((l) => l.direction === "CREDIT") + .reduce((s, l) => s + l.paise, 0); + expect(debit).toBe(125_000); + expect(credit).toBe(125_000); + // 20% of the nominal 100_000 plus the whole 25_000 surcharge; the + // consultant pool stays on the nominal price alone. + expect(legAmount(txn, "CREDIT", "PLATFORM_FEE|_|_|INR")).toBe(45_000); + expect( + legAmount(txn, "CREDIT", `CONSULTANT_PAYABLE|_|${PRIMARY_PROFILE}|INR`), + ).toBe(80_000); + }); +}); diff --git a/__tests__/payments/phase2-deadlines.test.ts b/__tests__/payments/phase2-deadlines.test.ts new file mode 100644 index 000000000..5af46c733 --- /dev/null +++ b/__tests__/payments/phase2-deadlines.test.ts @@ -0,0 +1,301 @@ +/** + * @jest-environment node + */ + +/** + * #1446 — Phase 2 of `handlePaymentSuccess` runs in `after()`, on a warm + * instance whose single Prisma connection (PG_POOL_MAX=1) is shared with the + * next inbound request. A Novu trigger that hangs used to hold that instance + * for 39 s while the chat-channel step waited for the connection and died at + * the 3 s connect timeout. + * + * Both steps are now bounded, and the notifications are awaited before the + * channel step begins. This pins the two properties that follow from that: a + * hanging channel step cannot outlive its deadline (and leaves the stamp NULL, + * which is the reconcile sweep's queue), and hanging Novu triggers cannot + * delay the channel step past theirs. + */ + +const withSerializableRetry = jest.fn(async (fn: () => unknown) => fn()); +jest.mock("../../lib/db/serializable-retry", () => ({ + __esModule: true, + withSerializableRetry: (fn: () => unknown) => withSerializableRetry(fn), +})); + +jest.mock("@sentry/nextjs", () => ({ + __esModule: true, + captureException: jest.fn(), + captureMessage: jest.fn(), +})); + +const CONSULTANT_USER = "consultant-user-1"; +const CONSULTEE_USER = "user-1"; +const START = new Date("2026-10-01T09:00:00.000Z"); +const END = new Date("2026-10-01T10:00:00.000Z"); + +const consultationCreate = jest.fn(); +const webhookAppointmentCreate = jest.fn(); +const webhookTx = { + payment: { findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() }, + consultation: { + create: consultationCreate, + updateMany: jest.fn(), + findUnique: jest.fn(), + }, + appointment: { + create: webhookAppointmentCreate, + findUnique: jest.fn(), + update: jest.fn(), + }, + slotOfAppointment: { + findMany: jest.fn(), + findFirst: jest.fn(), + updateMany: jest.fn(), + update: jest.fn(), + }, + appointmentParticipant: { + createMany: jest.fn().mockResolvedValue({ count: 2 }), + updateMany: jest.fn().mockResolvedValue({ count: 2 }), + }, +}; + +const baseAppointmentFindUnique = jest.fn(); +const baseSlotFindFirst = jest.fn(); +jest.mock("../../lib/prisma", () => ({ + __esModule: true, + default: { + $transaction: async (fn: (tx: unknown) => unknown) => fn(webhookTx), + // Phase 2's email + earnings reads: null keeps both steps out of the way, + // so the only work left in flight is the pair this file is about. + payment: { + update: jest.fn().mockResolvedValue({}), + findUnique: jest.fn().mockResolvedValue(null), + }, + appointment: { + findUnique: (...a: unknown[]) => baseAppointmentFindUnique(...a), + }, + slotOfAppointment: { + findFirst: (...a: unknown[]) => baseSlotFindFirst(...a), + }, + class: { + findUnique: jest.fn().mockResolvedValue(null), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + webinar: { + findUnique: jest.fn().mockResolvedValue(null), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + }, +})); + +jest.mock("../../lib/payments/operations/refund", () => ({ + __esModule: true, + refundPayment: jest.fn().mockResolvedValue({ id: "rfnd" }), +})); +jest.mock("../../lib/payments/payouts", () => ({ + __esModule: true, + createEarningsFromPayment: jest.fn(), + reverseEarningsForPayment: jest.fn(), +})); +jest.mock("../../lib/email", () => ({ + __esModule: true, + sendPaymentSuccessEmail: jest.fn(), + sendPaymentFailedEmail: jest.fn(), +})); + +const notifyPaymentSuccess = jest.fn(); +const notifyAppointmentBooked = jest.fn(); +jest.mock("../../lib/novu", () => ({ + __esModule: true, + notifyPaymentSuccess: (...a: unknown[]) => notifyPaymentSuccess(...a), + notifyPaymentFailed: jest.fn(), + notifyAppointmentBooked: (...a: unknown[]) => notifyAppointmentBooked(...a), +})); +jest.mock("../../lib/referrals/service", () => ({ + __esModule: true, + processQualifyingAction: jest.fn(), + processConsultantBookingReferral: jest.fn(), +})); + +const ensureChannelsForAppointment = jest.fn(); +jest.mock("../../lib/payments/webhooks/ensure-channels", () => ({ + __esModule: true, + ensureChannelsForAppointment: (...a: unknown[]) => + ensureChannelsForAppointment(...a), +})); + +const streamWarn = jest.fn(); +jest.mock("../../lib/stream-logger", () => ({ + __esModule: true, + streamLogger: { + info: jest.fn(), + warn: (...a: unknown[]) => streamWarn(...a), + error: jest.fn(), + }, +})); +jest.mock("../../lib/enterprise/system-events", () => ({ + __esModule: true, + recordSystemError: () => Promise.resolve(), +})); +jest.mock("../../schemas/webhooks/metadata", () => ({ + __esModule: true, + normalizeLegacySlotKeys: (m: unknown) => m, + validateWebhookMetadata: jest.fn(), +})); +jest.mock("../../lib/events/capacity", () => ({ + __esModule: true, + getWebinarCapacity: jest.fn(), + getClassCapacity: jest.fn(), +})); + +import { handlePaymentSuccess } from "../../lib/payments/webhooks/handlers"; +import { validateWebhookMetadata } from "../../schemas/webhooks/metadata"; + +const METADATA = { + appointmentType: "CONSULTATION", + userId: CONSULTEE_USER, + planId: "plan-1", + startsAt: START.toISOString(), + endsAt: END.toISOString(), +}; + +/** A promise that never settles — the 39 s call, without the wait. */ +function hangs(): Promise { + return new Promise(() => {}); +} + +function primePhase1() { + webhookTx.payment.update.mockResolvedValue({}); + webhookTx.payment.updateMany.mockResolvedValue({ count: 1 }); + webhookTx.appointment.update.mockResolvedValue({}); + webhookTx.consultation.updateMany.mockResolvedValue({ count: 1 }); + webhookTx.consultation.findUnique.mockResolvedValue({ + id: "cons-1", + status: "PENDING", + }); + webhookTx.slotOfAppointment.findMany.mockResolvedValue([]); + webhookTx.slotOfAppointment.findFirst.mockResolvedValue(null); + webhookTx.slotOfAppointment.updateMany.mockResolvedValue({ count: 2 }); + webhookTx.slotOfAppointment.update.mockResolvedValue({}); + webhookTx.payment.findUnique.mockResolvedValue({ + id: "pay1", + paymentIntent: "order1", + amount: 10000, + paymentStatus: "PENDING", + userId: CONSULTEE_USER, + currency: "INR", + appointmentId: null, + user: { + id: CONSULTEE_USER, + email: "b@x.com", + name: "Buyer", + consulteeProfile: { id: "consultee-profile-1" }, + }, + }); + consultationCreate.mockResolvedValue({ + id: "cons-1", + consultationPlan: { + consultantProfileId: "consultant-profile-1", + consultantProfile: { userId: CONSULTANT_USER }, + }, + }); + webhookAppointmentCreate.mockResolvedValue({ + id: "appt-1", + slotsOfAppointment: [{ id: "slot-0" }], + }); + webhookTx.appointment.findUnique.mockResolvedValue({ + id: "appt-1", + consultation: { id: "cons-1" }, + subscription: null, + webinar: null, + class: null, + slotsOfAppointment: [], + }); + // Phase 2's notification read + the session time the template needs. + baseAppointmentFindUnique.mockResolvedValue({ + organizationId: null, + organization: null, + consultation: { + consultationPlan: { + consultantProfile: { user: { id: CONSULTANT_USER, name: "Dr Who" } }, + }, + }, + subscription: null, + webinar: null, + class: null, + }); + baseSlotFindFirst.mockResolvedValue({ startsAt: START }); + (validateWebhookMetadata as jest.Mock).mockReturnValue(METADATA); +} + +/** + * Drive the handler with fake timers so a deadline can be reached without the + * suite actually waiting five seconds, then let the trailing microtasks (the + * fire-and-forget channel IIFE) run. + */ +async function runPastDeadlines() { + jest.useFakeTimers(); + try { + const done = handlePaymentSuccess( + "order1", + METADATA as unknown as Record, + 10000, + ); + // Well past both 5 s deadlines, twice over. + await jest.advanceTimersByTimeAsync(30_000); + await done; + await jest.advanceTimersByTimeAsync(30_000); + } finally { + jest.useRealTimers(); + } +} + +let warnSpy: jest.SpyInstance; + +beforeEach(() => { + jest.clearAllMocks(); + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + primePhase1(); + notifyPaymentSuccess.mockResolvedValue(undefined); + notifyAppointmentBooked.mockResolvedValue(undefined); + ensureChannelsForAppointment.mockResolvedValue({ ensured: true }); +}); + +afterEach(() => { + warnSpy.mockRestore(); +}); + +describe("#1446 — Phase 2 outbound steps are bounded", () => { + it("a channel step that never resolves does not hold the handler, and leaves the stamp NULL", async () => { + ensureChannelsForAppointment.mockReturnValue(hangs()); + + await runPastDeadlines(); + + expect(ensureChannelsForAppointment).toHaveBeenCalledWith("appt-1"); + // The stamp is written inside the step, at its very end, so a step that + // never returns cannot have written it: `chatChannelEnsuredAt` stays NULL + // and the appointment stays in the reconcile sweep's queue. What the + // handler owes is the warning that says so, and its own return. + expect(streamWarn).toHaveBeenCalledWith( + expect.stringContaining("deadline"), + expect.objectContaining({ appointmentId: "appt-1" }), + ); + }); + + it("Novu triggers that hang do not delay the channel step past their deadline", async () => { + notifyPaymentSuccess.mockReturnValue(hangs()); + notifyAppointmentBooked.mockReturnValue(hangs()); + + await runPastDeadlines(); + + // The whole point of #1446: the channel step still ran. Before the fix the + // triggers were unawaited and overlapped it; now they are awaited, and + // their deadline is what lets the step start at all. + expect(notifyPaymentSuccess).toHaveBeenCalled(); + expect(ensureChannelsForAppointment).toHaveBeenCalledWith("appt-1"); + // Awaited, so the step could only have started because the deadline fired. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("novu-trigger"), + ); + }); +}); diff --git a/__tests__/payments/rate-card-scoped-settlement.test.ts b/__tests__/payments/rate-card-scoped-settlement.test.ts new file mode 100644 index 000000000..c73a4ea91 --- /dev/null +++ b/__tests__/payments/rate-card-scoped-settlement.test.ts @@ -0,0 +1,259 @@ +/** + * @jest-environment node + */ + +/** + * A scoped rate card only settles money when the flag says so. + * + * `resolveEffectiveRateCard` has always ranked contract- and plan-scoped cards + * above the org default, but settlement handed it the org alone, so those tiers + * were unreachable: an org could create a plan-scoped card through the POST + * route and watch every booking settle on the org default instead. #1335 + * forwards the booking's scope behind `RATE_CARD_SCOPED_RESOLUTION`, off by + * default, because the flip changes which card pays live money. + * + * The resolver is deliberately NOT mocked here — the point of the pin is the + * whole chain from `createEarningsFromPayment` down to the bps that land on the + * earnings rows. + */ + +const EXPERT = "consultant-expert"; +const ORG = "org-host"; +const OTHER_ORG = "org-someone-else"; +const PAYMENT_ID = "pay-scoped-1"; +const PLAN_ID = "webinar-plan-1"; +const GROSS = 100_000; + +const ORG_DEFAULT_CARD = { + id: "rc-org-default", + platformBps: 1000, + orgBps: 1000, + consultantBps: 8000, +}; +const PLAN_SCOPED_CARD = { + id: "rc-plan-scoped", + platformBps: 2000, + orgBps: 1000, + consultantBps: 7000, +}; +/** Owned by a contract belonging to OTHER_ORG — must never settle ORG's booking. */ +const FOREIGN_CONTRACT_CARD = { + id: "rc-foreign-contract", + platformBps: 5000, + orgBps: 1000, + consultantBps: 4000, +}; + +interface Captured { + [k: string]: unknown; +} +const capturedConsultantEarnings: Captured[] = []; +const capturedOrgEarnings: Captured[] = []; + +jest.mock("../../lib/collaborators/service", () => ({ + calculateRevenueSplit: jest.fn().mockResolvedValue([]), +})); +jest.mock("../../lib/feature-flags", () => ({ + ...jest.requireActual("../../lib/feature-flags"), + ENABLE_HOST_ORGS: true, +})); +jest.mock("../../lib/payments/ledger/post", () => ({ + ...jest.requireActual("../../lib/payments/ledger/post"), + postLedgerTxn: jest + .fn() + .mockResolvedValue({ transactionId: "ltxn-stub", created: true }), +})); + +jest.mock("../../lib/prisma", () => { + const mockTx = { + ledgerAccount: { + findFirst: jest.fn().mockResolvedValue(null), + upsert: jest.fn().mockResolvedValue({ id: "ledger-1" }), + }, + ledgerAccountBalance: { upsert: jest.fn().mockResolvedValue({}) }, + paymentLeg: { findMany: jest.fn().mockResolvedValue([]) }, + consultantEarnings: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest + .fn() + .mockImplementation(async ({ data }: { data: Captured }) => { + capturedConsultantEarnings.push(data); + return { id: "earn-1", ...data }; + }), + }, + consultantProfile: { update: jest.fn().mockResolvedValue({}) }, + organization: { + findUnique: jest.fn().mockResolvedValue({ status: "ACTIVE" }), + }, + organizationInvoice: { count: jest.fn().mockResolvedValue(1) }, + organizationEarnings: { + create: jest + .fn() + .mockImplementation(async ({ data }: { data: Captured }) => { + capturedOrgEarnings.push(data); + return { id: "org-earn-1", ...data }; + }), + }, + membership: { findFirst: jest.fn() }, + webinarPlan: { findUnique: jest.fn() }, + classPlan: { + findUnique: jest.fn().mockResolvedValue({ organizationId: null }), + }, + bookingUtilization: { findUnique: jest.fn().mockResolvedValue(null) }, + rateCard: { findFirst: jest.fn() }, + }; + return { + __esModule: true, + default: { + $transaction: jest + .fn() + .mockImplementation(async (fn: (tx: unknown) => Promise) => + fn(mockTx), + ), + __mockTx: mockTx, + }, + }; +}); + +import prisma from "@/lib/prisma"; +import { createEarningsFromPayment } from "@/lib/payments/payouts/earnings-service"; + +type CardWhere = { + ownerOrgId?: string; + ownerContractId?: string; + planType?: string | null; + planId?: string | null; +}; + +const tx = ( + prisma as unknown as { + __mockTx: { + rateCard: { findFirst: jest.Mock }; + bookingUtilization: { findUnique: jest.Mock }; + membership: { findFirst: jest.Mock }; + webinarPlan: { findUnique: jest.Mock }; + }; + } +).__mockTx; + +function payment() { + return { + id: PAYMENT_ID, + amount: GROSS, + originalAmount: GROSS, + organizationId: null, + billingAccountId: null, + createdAt: new Date("2026-09-03T00:00:00Z"), + appointment: { + consultantProfile: { id: EXPERT }, + webinar: { webinarPlanId: PLAN_ID }, + class: null, + }, + } as unknown as Parameters[0]["payment"]; +} + +async function settle() { + await createEarningsFromPayment({ + payment: payment(), + appointmentType: "WEBINAR", + } as unknown as Parameters[0]); +} + +beforeEach(() => { + capturedConsultantEarnings.length = 0; + capturedOrgEarnings.length = 0; + delete process.env.RATE_CARD_SCOPED_RESOLUTION; + tx.bookingUtilization.findUnique.mockResolvedValue(null); + tx.membership.findFirst.mockResolvedValue({ + id: "mem-1", + rateCardOverrideId: null, + payoutRecipient: "SELF", + organization: { id: ORG }, + }); + tx.webinarPlan.findUnique.mockResolvedValue({ organizationId: ORG }); + tx.rateCard.findFirst.mockImplementation( + async ({ where }: { where: CardWhere }) => { + if (where.ownerContractId) return FOREIGN_CONTRACT_CARD; + if (where.ownerOrgId !== ORG) return null; + if (where.planId === PLAN_ID) return PLAN_SCOPED_CARD; + if (where.planType === null && where.planId === null) + return ORG_DEFAULT_CARD; + return null; + }, + ); +}); + +afterEach(() => { + delete process.env.RATE_CARD_SCOPED_RESOLUTION; +}); + +describe("#1335 — scoped rate cards reach settlement only behind the flag", () => { + it("settles on the plan-scoped card when the flag is on", async () => { + process.env.RATE_CARD_SCOPED_RESOLUTION = "on"; + + await settle(); + + expect(capturedOrgEarnings).toHaveLength(1); + expect(capturedOrgEarnings[0]).toMatchObject({ + rateCardIdApplied: PLAN_SCOPED_CARD.id, + platformBpsApplied: 2000, + consultantBpsApplied: 7000, + platformFeePaise: 20_000, + consultantSharePaise: 70_000, + orgSharePaise: 10_000, + }); + expect(capturedConsultantEarnings[0]).toMatchObject({ + platformFeePaise: 20_000, + consultantSharePaise: 70_000, + }); + }); + + it("settles on the org default card when the flag is off", async () => { + await settle(); + + expect(capturedOrgEarnings).toHaveLength(1); + expect(capturedOrgEarnings[0]).toMatchObject({ + rateCardIdApplied: ORG_DEFAULT_CARD.id, + platformBpsApplied: 1000, + consultantBpsApplied: 8000, + platformFeePaise: 10_000, + consultantSharePaise: 80_000, + orgSharePaise: 10_000, + }); + expect(capturedConsultantEarnings[0]).toMatchObject({ + platformFeePaise: 10_000, + consultantSharePaise: 80_000, + }); + // The scoped tiers must not even be queried while the flag is off. + const wheres = tx.rateCard.findFirst.mock.calls.map( + ([args]: [{ where: CardWhere }]) => args.where, + ); + expect(wheres.every((w: CardWhere) => w.planId === null)).toBe(true); + }); + + it("never forwards a contract owned by another org", async () => { + process.env.RATE_CARD_SCOPED_RESOLUTION = "on"; + // The booking is program-funded, but the sponsoring contract belongs to a + // different tenant. resolveEffectiveRateCard matches ownerContractId without + // re-checking the org, so forwarding it would settle ORG's booking on + // OTHER_ORG's negotiated 50/10/40 split. + tx.bookingUtilization.findUnique.mockResolvedValue({ + programAssignment: { + program: { contract: { id: "contract-1", organizationId: OTHER_ORG } }, + }, + }); + + await settle(); + + const wheres = tx.rateCard.findFirst.mock.calls.map( + ([args]: [{ where: CardWhere }]) => args.where, + ); + expect(wheres.some((w: CardWhere) => w.ownerContractId !== undefined)).toBe( + false, + ); + expect(capturedOrgEarnings[0]).toMatchObject({ + rateCardIdApplied: PLAN_SCOPED_CARD.id, + platformBpsApplied: 2000, + }); + }); +}); diff --git a/__tests__/payments/razorpay-productionization.test.ts b/__tests__/payments/razorpay-productionization.test.ts new file mode 100644 index 000000000..09e5d897b --- /dev/null +++ b/__tests__/payments/razorpay-productionization.test.ts @@ -0,0 +1,182 @@ +/** + * #1377 — the two behavioural changes in the Razorpay productionization pass. + * + * 1. A RazorpayX payout that reaches the terminal `failed` state must map to + * FAILED. It used to fall through to the `default` arm and read as PENDING, + * which left the earnings BATCHED against a payout the bank had refused. + * 2. Rotating `RAZORPAY_WEBHOOK_SECRET` must not drop the deliveries signed + * with the old secret during the cutover, because Razorpay disables a + * webhook that fails for 24 hours and lost events cannot be replayed. + * 3. The `X-Payout-Idempotency` header must stay inside the length RazorpayX + * accepts, or the duplicate guard becomes a 400 on every live payout. + */ +import crypto from "node:crypto"; + +import { + isPayoutEventName, + matchRazorpayWebhookSecret, + resolveRazorpayPaymentSecrets, + verifyRazorpaySignature, +} from "@/app/api/webhooks/razorpay/signature"; +import { + boundPayoutIdempotencyKey, + RazorpayPayoutsService, +} from "@/lib/payments/payouts/razorpay-payouts"; + +const RAW_BODY = JSON.stringify({ + event: "payment.captured", + payload: { payment: { entity: { id: "pay_test" } } }, +}); + +function sign(body: string, secret: string): string { + return crypto.createHmac("sha256", secret).update(body).digest("hex"); +} + +describe("RazorpayX payout status mapping", () => { + const service = new RazorpayPayoutsService({ + keyId: "rzp_test_key", + keySecret: "secret", + accountNumber: "2323230000000000", + }); + + it("maps every terminal RazorpayX status to a terminal internal status", () => { + expect(service.mapPayoutStatus("failed")).toBe("FAILED"); + expect(service.mapPayoutStatus("rejected")).toBe("FAILED"); + expect(service.mapPayoutStatus("reversed")).toBe("FAILED"); + expect(service.mapPayoutStatus("cancelled")).toBe("CANCELLED"); + expect(service.mapPayoutStatus("processed")).toBe("COMPLETED"); + }); + + it("keeps the intermediate statuses non-terminal so the reconciler keeps polling", () => { + expect(service.mapPayoutStatus("queued")).toBe("PENDING"); + expect(service.mapPayoutStatus("pending")).toBe("PENDING"); + expect(service.mapPayoutStatus("processing")).toBe("PROCESSING"); + }); +}); + +// The resolver takes the environment as an argument precisely so these cases +// need no process.env mutation and cannot leak into a sibling suite. +describe("Razorpay webhook secret rotation grace", () => { + it("offers only the current secret when no rotation is in flight", () => { + const secrets = resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: "current_secret", + }); + + expect(secrets).toEqual([{ role: "current", value: "current_secret" }]); + }); + + it("offers nothing at all when the current secret is missing", () => { + // The grace window is an aid to a rotation, never a standalone secret: a + // deployment that has lost the current value must fail loudly rather than + // keep accepting deliveries on the retired one. + expect( + resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret", + }), + ).toEqual([]); + expect( + resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: " ", + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret", + }), + ).toEqual([]); + }); + + it("offers the previous secret second, and never duplicates the current one", () => { + expect( + resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: "new_secret", + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret", + }), + ).toEqual([ + { role: "current", value: "new_secret" }, + { role: "previous", value: "old_secret" }, + ]); + + expect( + resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: "same", + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "same", + }), + ).toEqual([{ role: "current", value: "same" }]); + }); + + it("accepts a delivery signed with either secret and reports which one matched", () => { + const candidates = resolveRazorpayPaymentSecrets({ + RAZORPAY_WEBHOOK_SECRET: "new_secret", + RAZORPAY_WEBHOOK_SECRET_PREVIOUS: "old_secret", + }); + + expect( + matchRazorpayWebhookSecret( + RAW_BODY, + sign(RAW_BODY, "new_secret"), + candidates, + ), + ).toBe("current"); + expect( + matchRazorpayWebhookSecret( + RAW_BODY, + sign(RAW_BODY, "old_secret"), + candidates, + ), + ).toBe("previous"); + expect( + matchRazorpayWebhookSecret( + RAW_BODY, + sign(RAW_BODY, "attacker_secret"), + candidates, + ), + ).toBeNull(); + }); + + it("rejects a malformed signature instead of throwing out of timingSafeEqual", () => { + expect(() => + verifyRazorpaySignature(RAW_BODY, "not-a-hex-digest", "current_secret"), + ).not.toThrow(); + expect( + verifyRazorpaySignature(RAW_BODY, "not-a-hex-digest", "current_secret"), + ).toBe(false); + expect( + verifyRazorpaySignature( + RAW_BODY, + sign(RAW_BODY, "current_secret").slice(0, 63), + "current_secret", + ), + ).toBe(false); + }); + + it("only classifies payout.* bodies as eligible for the RazorpayX secret", () => { + expect( + isPayoutEventName(JSON.stringify({ event: "payout.processed" })), + ).toBe(true); + expect(isPayoutEventName(RAW_BODY)).toBe(false); + expect(isPayoutEventName("{ not json")).toBe(false); + }); +}); + +describe("RazorpayX payout idempotency header", () => { + // Both real key shapes overshoot the gateway's 36-character ceiling, so the + // bound is what stands between a deduplicated retry and a rejected payout. + const orgKey = "payout_11111111-2222-4333-8444-555555555555"; + const consultantKey = + "payout_11111111-2222-4333-8444-555555555555_batch_1756900000000_abcdef12"; + + it("keeps a key the gateway already accepts", () => { + expect(boundPayoutIdempotencyKey("payout_ckv1v0h8n0000abcdefghijkl")).toBe( + "payout_ckv1v0h8n0000abcdefghijkl", + ); + }); + + it("folds an over-long key into the accepted length, deterministically", () => { + for (const key of [orgKey, consultantKey]) { + const bounded = boundPayoutIdempotencyKey(key); + expect(bounded.length).toBeLessThanOrEqual(36); + expect(bounded).toMatch(/^[A-Za-z0-9 _-]+$/); + expect(boundPayoutIdempotencyKey(key)).toBe(bounded); + } + expect(boundPayoutIdempotencyKey(orgKey)).not.toBe( + boundPayoutIdempotencyKey(consultantKey), + ); + }); +}); diff --git a/__tests__/payments/razorpay-refund-idempotency.test.ts b/__tests__/payments/razorpay-refund-idempotency.test.ts index 5bcc5edd4..5c18a9297 100644 --- a/__tests__/payments/razorpay-refund-idempotency.test.ts +++ b/__tests__/payments/razorpay-refund-idempotency.test.ts @@ -29,6 +29,7 @@ jest.mock("razorpay", () => { }); import { createRazorpayRefund } from "@/lib/payments/core/razorpay"; +import { RefundError } from "@/lib/payments/core/types"; const fetchMock = jest.fn(); global.fetch = fetchMock as unknown as typeof fetch; @@ -80,14 +81,22 @@ describe("X-Refund-Idempotency header", () => { ); }); - it("omits the header entirely when no key is supplied", async () => { + it("always sends the header, because the key is no longer optional", async () => { fetchMock.mockResolvedValue(okResponse()); - await createRazorpayRefund({ paymentIntentId: "order_1", amount: 5000 }); + // #1352 — this used to assert the opposite: with no key supplied, no + // header. Optionality was the only way to express a non-idempotent refund, + // and a network-error retry of one credits the customer's card twice. The + // key is required at the type level now, so every refund carries it. + await createRazorpayRefund({ + paymentIntentId: "order_1", + amount: 5000, + idempotencyKey: "clx3k2j9a0000abcd1234efgh", + }); - // Deriving a key from paymentId+amount would make two legitimate partial - // refunds of equal amount collide — no key is safer than a guessed one. - expect(headersOf()).not.toHaveProperty("X-Refund-Idempotency"); + expect(headersOf()["X-Refund-Idempotency"]).toBe( + "clx3k2j9a0000abcd1234efgh", + ); }); it("refuses a key that cannot satisfy Razorpay's >=10 char rule", async () => { @@ -103,22 +112,28 @@ describe("X-Refund-Idempotency header", () => { amount: 5000, idempotencyKey: "short", }), - ).rejects.toThrow(/unusable after sanitization/); + ).rejects.toThrow(/not a valid Razorpay key/); // And crucially: no request was sent, so no money moved. expect(fetchMock).not.toHaveBeenCalled(); }); - it("strips characters Razorpay rejects", async () => { + it("rejects a key containing characters Razorpay does not allow", async () => { fetchMock.mockResolvedValue(okResponse()); - await createRazorpayRefund({ - paymentIntentId: "order_1", - amount: 5000, - idempotencyKey: "refund:abc/def ghi+jkl", - }); + // Sanitizing instead of rejecting is lossy: "refund:abc/def" and + // "refund/abc:def" both reduce to "refundabcdef", so two distinct refunds + // would share one header value and Razorpay would answer the second with + // the first one's result. + await expect( + createRazorpayRefund({ + paymentIntentId: "order_1", + amount: 5000, + idempotencyKey: "refund:abc/def ghi+jkl", + }), + ).rejects.toThrow(/not a valid Razorpay key/); - expect(headersOf()["X-Refund-Idempotency"]).toBe("refundabcdefghijkl"); + expect(fetchMock).not.toHaveBeenCalled(); }); it("reuses the same key across a retry of the same logical refund", async () => { @@ -146,12 +161,12 @@ describe("request shape", () => { it("POSTs to the captured payment with basic auth and omits amount on a full refund", async () => { fetchMock.mockResolvedValue(okResponse()); - await createRazorpayRefund({ paymentIntentId: "order_1" }); + await createRazorpayRefund({ + paymentIntentId: "order_1", + idempotencyKey: "clx3k2j9a0000abcd1234efgh", + }); - const [url, init] = fetchMock.mock.calls[0] as [ - string, - { method: string }, - ]; + const [url, init] = fetchMock.mock.calls[0] as [string, { method: string }]; expect(url).toBe( "https://api.razorpay.com/v1/payments/pay_captured/refund", ); @@ -166,7 +181,11 @@ describe("request shape", () => { it("sends the amount in paise for a partial refund", async () => { fetchMock.mockResolvedValue(okResponse()); - await createRazorpayRefund({ paymentIntentId: "order_1", amount: 5000 }); + await createRazorpayRefund({ + paymentIntentId: "order_1", + amount: 5000, + idempotencyKey: "clx3k2j9a0000abcd1234efgh", + }); expect(bodyOf().amount).toBe(5000); }); @@ -219,12 +238,39 @@ describe("error passthrough", () => { }); await expect( - createRazorpayRefund({ paymentIntentId: "order_1", amount: 999_999 }), + createRazorpayRefund({ + paymentIntentId: "order_1", + amount: 999_999, + idempotencyKey: "clx3k2j9a0000abcd1234efgh", + }), ).rejects.toMatchObject({ code: "BAD_REQUEST_ERROR", message: "The amount is more than the refundable amount", }); }); + + it("classifies an envelope with no error body instead of throwing a TypeError", async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: undefined }), + }); + + // #1353 — the `"error" in error` guard passed on this shape and then read + // `.code` off undefined, so reconcile-refunds died on the TypeError instead + // of recording a classified failure (E2E 2026-09-04). + const thrown = await createRazorpayRefund({ + paymentIntentId: "order_1", + amount: 5000, + idempotencyKey: "clx3k2j9a0000abcd1234efgh", + }).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(RefundError); + expect(thrown).toMatchObject({ + code: "UNKNOWN_ERROR", + message: "Failed to process refund", + }); + }); }); describe("status mapping", () => { @@ -243,6 +289,7 @@ describe("status mapping", () => { const result = await createRazorpayRefund({ paymentIntentId: "order_1", amount: 5000, + idempotencyKey: "clx3k2j9a0000abcd1234efgh", }); expect(result.status).toBe(expected); diff --git a/__tests__/payments/razorpay-refund-target.test.ts b/__tests__/payments/razorpay-refund-target.test.ts index cadfbae1e..387418fb5 100644 --- a/__tests__/payments/razorpay-refund-target.test.ts +++ b/__tests__/payments/razorpay-refund-target.test.ts @@ -98,6 +98,7 @@ describe("PM-12 — createRazorpayRefund targets the captured payment", () => { }); await createRazorpayRefund({ + idempotencyKey: "clx3k2j9a0000abcd1234efgh", paymentIntentId: "order_1", amount: 5000, reason: "requested_by_customer", @@ -121,7 +122,11 @@ describe("PM-12 — createRazorpayRefund targets the captured payment", () => { notes: {}, }); - await createRazorpayRefund({ paymentIntentId: "order_2", amount: 1000 }); + await createRazorpayRefund({ + paymentIntentId: "order_2", + amount: 1000, + idempotencyKey: "clx3k2j9a0000abcd1234efgh", + }); expect(refundedPaymentId()).toBe("pay_only"); }); diff --git a/__tests__/payments/razorpay-webhook-body-cap.test.ts b/__tests__/payments/razorpay-webhook-body-cap.test.ts new file mode 100644 index 000000000..def87cc39 --- /dev/null +++ b/__tests__/payments/razorpay-webhook-body-cap.test.ts @@ -0,0 +1,114 @@ +/** + * @jest-environment node + */ + +/** + * #1459 — the Razorpay webhook route is unauthenticated until the HMAC is + * checked, and checking the HMAC means reading the whole body into a buffer. A + * real Razorpay event is a few kilobytes, so an oversized body is never a + * delivery we owe service to; refusing it before the signature read is what + * keeps a stranger from choosing how much memory the route allocates. The + * refusal has to come first in the handler for that to hold, which is what this + * pins: the request carries a signature header, so every later step would + * otherwise run, and neither the body read nor the verifier is reached. + */ + +jest.mock("@sentry/nextjs", () => ({ + setTag: jest.fn(), + captureException: jest.fn(), + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +jest.mock("../../app/api/webhooks/utils", () => ({ + verifyWebhookSignature: jest.fn(), + logWebhookEvent: jest.fn(), + isDbHealthy: jest.fn(), +})); + +// The route verifies through its own module, not the shared webhook util, so +// this is the mock that proves the HMAC path was skipped. +jest.mock("../../app/api/webhooks/razorpay/signature", () => ({ + isPayoutEventName: jest.fn().mockReturnValue(false), + matchRazorpayWebhookSecret: jest.fn().mockReturnValue("current"), + resolveRazorpayPaymentSecrets: jest.fn().mockReturnValue(["secret"]), + verifyRazorpaySignature: jest.fn().mockReturnValue(true), +})); + +jest.mock("../../app/api/webhooks/razorpay-dispatch", () => ({ + processRazorpayWebhookEvent: jest.fn(), +})); + +jest.mock("../../lib/enterprise/system-events", () => ({ + recordSystemEvent: jest.fn(), +})); + +import type { NextRequest } from "next/server"; + +import { POST } from "../../app/api/webhooks/razorpay/route"; +import { logWebhookEvent } from "../../app/api/webhooks/utils"; +import { + matchRazorpayWebhookSecret, + resolveRazorpayPaymentSecrets, +} from "../../app/api/webhooks/razorpay/signature"; + +function oversizedRequest(bytes: number) { + const headers = new Headers({ + "content-length": String(bytes), + "x-razorpay-signature": "deadbeef", + }); + const text = jest.fn().mockResolvedValue("{}"); + return { req: { headers, text } as unknown as NextRequest, text }; +} + +/** + * A delivery that declares no size at all — the case a header check cannot + * see. `pumped` counts the 64 KB chunks the route actually pulled, which is + * how the test tells "stopped at the cap" apart from "buffered the lot". + */ +function undeclaredRequest(chunkCount: number) { + const counter = { pumped: 0 }; + let remaining = chunkCount; + const body = new ReadableStream({ + pull(controller) { + if (remaining === 0) { + controller.close(); + return; + } + remaining--; + counter.pumped++; + controller.enqueue(new Uint8Array(64 * 1024)); + }, + }); + const headers = new Headers({ "x-razorpay-signature": "deadbeef" }); + return { req: { headers, body } as unknown as NextRequest, counter }; +} + +describe("Razorpay webhook body cap (#1459)", () => { + it("refuses a body over 256 KB with 413, before the signature read", async () => { + const { req, text } = oversizedRequest(512 * 1024); + + const res = await POST(req); + + expect(res.status).toBe(413); + // The refusal precedes the read, so nothing was buffered to be hashed. + expect(text).not.toHaveBeenCalled(); + expect(resolveRazorpayPaymentSecrets).not.toHaveBeenCalled(); + expect(matchRazorpayWebhookSecret).not.toHaveBeenCalled(); + expect(logWebhookEvent).not.toHaveBeenCalled(); + }); + + it("stops reading a body that never declared its size, once past the cap", async () => { + // 32 × 64 KB = 2 MB offered; the cap is 256 KB, so the fifth chunk is the + // one that crosses it and the read must abandon there. A ReadableStream + // pre-pulls one chunk past the reader, hence six rather than five — the + // point is that it is nowhere near the 32 a full buffering would have. + const { req, counter } = undeclaredRequest(32); + + const res = await POST(req); + + expect(res.status).toBe(413); + expect(counter.pumped).toBeLessThanOrEqual(6); + expect(matchRazorpayWebhookSecret).not.toHaveBeenCalled(); + expect(logWebhookEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/payments/reconcile-clawback-dual-write-gap.test.ts b/__tests__/payments/reconcile-clawback-dual-write-gap.test.ts new file mode 100644 index 000000000..d1e17a030 --- /dev/null +++ b/__tests__/payments/reconcile-clawback-dual-write-gap.test.ts @@ -0,0 +1,75 @@ +/** + * @jest-environment node + */ + +/** + * #1408 — the clawback dual-write detector compares AMOUNTS, not presence. + * `OrganizationPayout.clawbackAmountPaise` is a running total, so a payout + * clawed back twice whose second `Dr CASH / Cr ORG_PAYABLE` posting was + * swallowed still carries a `clawback:*` transaction. The old payout-id Set + * read that as clean and the journal quietly under-recorded recovered cash. + * + * The finding builder is pure, so this drives it directly rather than standing + * up a whole reconciler run. + */ + +import { clawbackDualWriteGapFindings } from "../../scripts/reconcile/reconcile-ledgers"; + +const PAYOUT = { + id: "orgpo_1", + organizationId: "org_1", + // Two clawbacks: 30_000 + 20_000 paise. + clawbackAmountPaise: 50_000, +}; + +describe("#1408 — cumulative clawback postings vs the stamped counter", () => { + it("two clawbacks, only the first posted → flagged with the partial delta", () => { + const findings = clawbackDualWriteGapFindings( + [PAYOUT], + new Map([["orgpo_1", 30_000]]), + ); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + kind: "LEDGER_DUAL_WRITE_GAP", + payoutId: "orgpo_1", + organizationId: "org_1", + expectedPaise: 50_000, + actualPaise: 30_000, + deltaPaise: 20_000, + }); + // Presence alone would have cleared this row. + expect(String(findings[0].details?.note)).toContain("exceeds"); + }); + + it("both clawbacks posted → clean", () => { + expect( + clawbackDualWriteGapFindings([PAYOUT], new Map([["orgpo_1", 50_000]])), + ).toEqual([]); + }); + + // Shortfall-only, so a non-positive counter is never a gap. The reconciler + // query already filters `clawbackAmountPaise > 0`; this pins the guard in the + // helper so a future caller with a looser query cannot manufacture findings. + it("a zero or negative stamped counter is never flagged", () => { + for (const clawbackAmountPaise of [0, -1]) { + expect( + clawbackDualWriteGapFindings( + [{ ...PAYOUT, clawbackAmountPaise }], + new Map(), + ), + ).toEqual([]); + } + }); + + it("nothing posted at all → the total gap, same kind", () => { + const findings = clawbackDualWriteGapFindings([PAYOUT], new Map()); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + kind: "LEDGER_DUAL_WRITE_GAP", + actualPaise: 0, + deltaPaise: 50_000, + }); + }); +}); diff --git a/__tests__/payments/reconcile-payout-tds.test.ts b/__tests__/payments/reconcile-payout-tds.test.ts index 8afcbd231..36bbaeaae 100644 --- a/__tests__/payments/reconcile-payout-tds.test.ts +++ b/__tests__/payments/reconcile-payout-tds.test.ts @@ -143,6 +143,32 @@ describe("PM-15 — payout reconcile delegates to handlePayoutWebhook", () => { expect(utr).toBeUndefined(); }); + // #1407 — RazorpayX `failed` (the bank refused a queued payout) had no arm + // here while Stripe's did, so the payout fell through as an unknown status + // and was skipped — which is the exact cohort this sweep exists for. + it("gateway `failed` → delegates FAILED, not skipped as an unknown status", async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "failed", failure_reason: "account closed" }), + }); + + const result = await reconcilePayoutStatus(); + + expect(handlePayoutWebhook).toHaveBeenCalledTimes(1); + const [provider, id, status, reason, utr] = + handlePayoutWebhook.mock.calls[0]; + expect(provider).toBe("RAZORPAY"); + expect(id).toBe("pout_live_1"); + expect(status).toBe("FAILED"); + // A plain `failed` is not the pre-completion reversal, so it carries the + // gateway's own reason without the net-zero note. + expect(reason).toBe("account closed"); + expect(reason).not.toContain("net-zero"); + expect(utr).toBeUndefined(); + expect(result.failedCount).toBe(1); + expect(result.skippedCount).toBe(0); + }); + it("still-processing payout → no delegation (status unchanged)", async () => { (global as unknown as { fetch: jest.Mock }).fetch = jest.fn().mockResolvedValue({ ok: true, diff --git a/__tests__/payments/reconcile-reservation-match.test.ts b/__tests__/payments/reconcile-reservation-match.test.ts index e2fde6a50..10ca9e032 100644 --- a/__tests__/payments/reconcile-reservation-match.test.ts +++ b/__tests__/payments/reconcile-reservation-match.test.ts @@ -264,4 +264,35 @@ describe("reconcilePendingRefunds real-id PENDING polling", () => { expect(result.failedCount).toBe(0); expect(refundTable.update).not.toHaveBeenCalled(); }); + + // #1458 — with STRIPE_ENABLED unset, the Stripe client is never built, so + // getRefund threw for every Stripe row, the error list filled up and the whole + // run reported success:false — the cleanup route answered 500 for what is + // deliberate configuration. + test("a fenced STRIPE refund is skipped and counted, not failed", async () => { + const previous = process.env.STRIPE_ENABLED; + delete process.env.STRIPE_ENABLED; + try { + refundTable.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([ + { + id: "row_stripe", + refundId: "re_real", + status: "PENDING", + amountPaise: 10_000, + createdAt: new Date(Date.now() - 3 * HOUR), + payment: { paymentGateway: "STRIPE" }, + }, + ]); + + const result = await reconcilePendingRefunds(); + + expect(mockGet).not.toHaveBeenCalled(); + expect(result.skippedFenced).toBe(1); + expect(result.success).toBe(true); + expect(result.errors).toEqual([]); + } finally { + if (previous === undefined) delete process.env.STRIPE_ENABLED; + else process.env.STRIPE_ENABLED = previous; + } + }); }); diff --git a/__tests__/payments/refund-operation.test.ts b/__tests__/payments/refund-operation.test.ts index dd0bff374..aa8ba7547 100644 --- a/__tests__/payments/refund-operation.test.ts +++ b/__tests__/payments/refund-operation.test.ts @@ -250,6 +250,10 @@ function txStub() { return inv; }), }, + // #1365 — the B2C credit-note mint probes both and no-ops when the payment + // has no consumer invoice, which is true of every fixture in this suite. + consumerCreditNote: { findUnique: jest.fn().mockResolvedValue(null) }, + consumerInvoice: { findUnique: jest.fn().mockResolvedValue(null) }, orgAuditLog: { create: jest.fn(async ({ data }: any) => { const created = { id: stableUuid(), createdAt: new Date(), ...data }; diff --git a/__tests__/payments/refund-webhook-status-guard.test.ts b/__tests__/payments/refund-webhook-status-guard.test.ts index 7395ee622..e9b6d11a8 100644 --- a/__tests__/payments/refund-webhook-status-guard.test.ts +++ b/__tests__/payments/refund-webhook-status-guard.test.ts @@ -85,6 +85,18 @@ interface RefundRow { paymentId: string; } +/** The one Payment the stubbed database holds, keyed by both of its ids. */ +const PAYMENT_ROW = { + id: "pay_1", + paymentIntent: "order_1", + gatewayPaymentId: "pay_gateway_1", + userId: "user_1", + organizationId: null, + amount: 50_000, + currency: "INR", + billingAccountId: null, +}; + const store: { refunds: RefundRow[] } = { refunds: [] }; function resetStore() { @@ -94,15 +106,19 @@ function resetStore() { function txStub() { return { payment: { - findUnique: jest.fn(async () => ({ - id: "pay_1", - paymentIntent: "order_1", - userId: "user_1", - organizationId: null, - amount: 50_000, - currency: "INR", - billingAccountId: null, - })), + // #1353 — the handler resolves by EITHER id now, so the stub has to be a + // real matcher rather than a constant: a test that returns the same row + // whatever the `where` says cannot tell the two keys apart. + findFirst: jest.fn( + async ({ where }: { where: { OR: Array> } }) => { + const matches = where.OR.some( + (clause) => + clause.paymentIntent === PAYMENT_ROW.paymentIntent || + clause.gatewayPaymentId === PAYMENT_ROW.gatewayPaymentId, + ); + return matches ? PAYMENT_ROW : null; + }, + ), }, creditNote: { findUnique: jest.fn(async () => null), @@ -110,11 +126,15 @@ function txStub() { }, walletTopUp: { findFirst: jest.fn(async () => null) }, organizationInvoice: { findFirst: jest.fn(async () => null) }, - billingAccount: { findFirst: jest.fn(async () => null), findUniqueOrThrow: jest.fn() }, + billingAccount: { + findFirst: jest.fn(async () => null), + findUniqueOrThrow: jest.fn(), + }, orgAuditLog: { create: jest.fn(async () => ({})) }, refund: { - findUnique: jest.fn(async ({ where }: { where: { refundId: string } }) => - store.refunds.find((r) => r.refundId === where.refundId) ?? null, + findUnique: jest.fn( + async ({ where }: { where: { refundId: string } }) => + store.refunds.find((r) => r.refundId === where.refundId) ?? null, ), create: jest.fn(async ({ data }: { data: Omit }) => { const created: RefundRow = { @@ -223,6 +243,37 @@ describe("handleRefundCreated status transition guard", () => { ); }); + // #1353 — the pin for the lookup change. Razorpay's refund webhook carries + // only the `pay_…` payment id, and when the dispatcher's `payments.fetch` + // translation failed it handed that id through as `paymentIntentId`. A lookup + // keyed solely on `Payment.paymentIntent` (which holds the ORDER id) could + // never match it, so the handler deferred and the sweeper re-drove the event + // for up to a week against a payment that had been captured all along. + test("a refund whose paymentIntentId is a pay_ id resolves via gatewayPaymentId", async () => { + store.refunds.push({ + id: "row_1", + refundId: "rfnd_1", + status: "PENDING", + paymentId: "pay_1", + }); + + await handleRefundCreated( + "rfnd_1", + // Not an order id — exactly what the dispatcher passes through when the + // gateway translation is unavailable. + "pay_gateway_1", + 10_000, + "INR", + "processed", + "RAZORPAY", + "pay_gateway_1", + ); + + // Resolved, settled and cascaded — not deferred. + expect(store.refunds[0].status).toBe("SUCCEEDED"); + expect(applyRefundCascade).toHaveBeenCalledTimes(1); + }); + test("same-status redelivery of a PENDING refund is a no-op", async () => { store.refunds.push({ id: "row_1", diff --git a/__tests__/payments/stuck-payouts-tds-reconcile.test.ts b/__tests__/payments/stuck-payouts-tds-reconcile.test.ts index 012ecf782..2e7bded01 100644 --- a/__tests__/payments/stuck-payouts-tds-reconcile.test.ts +++ b/__tests__/payments/stuck-payouts-tds-reconcile.test.ts @@ -54,7 +54,11 @@ let payoutRow: Row; // — a let/const would still be in its TDZ when the factory assigns to it. // eslint-disable-next-line no-var var prismaStub: { - consultantPayout: { findMany: jest.Mock; update: jest.Mock }; + consultantPayout: { + findMany: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + }; consultantEarnings: { updateMany: jest.Mock }; $disconnect: jest.Mock; }; @@ -67,6 +71,9 @@ jest.mock("../../lib/prisma", () => { Object.assign(payoutRow, data); return payoutRow; }), + // #1407 — the retry reset is a CAS updateMany now; the count is what + // each test decides the race produced. + updateMany: jest.fn(async () => ({ count: 1 })), }, consultantEarnings: { updateMany: jest.fn(async () => ({ count: 0 })), @@ -138,6 +145,27 @@ describe("PM-15 — stuck-payout reconcile delegates to handlePayoutWebhook", () ); }); + // #1407 — RazorpayX `failed` (bank refused a queued payout) had no arm here + // while Stripe's did, so the payout fell through as an unknown status and was + // skipped: PROCESSING forever, earnings still linked to money that never left. + it("gateway `failed` → delegates FAILED, not skipped as an unknown status", async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ status: "failed", failure_reason: "account closed" }), + }); + + const result = await handleStuckPayouts(); + + expect(handlePayoutWebhook).toHaveBeenCalledWith( + "RAZORPAY", + "pout_live_1", + "FAILED", + "account closed", + undefined, + ); + expect(result.skippedCount).toBe(0); + }); + it("still-processing payout → no delegation (status unchanged)", async () => { (global as unknown as { fetch: jest.Mock }).fetch = jest.fn().mockResolvedValue({ ok: true, @@ -149,3 +177,47 @@ describe("PM-15 — stuck-payout reconcile delegates to handlePayoutWebhook", () expect(handlePayoutWebhook).not.toHaveBeenCalled(); }); }); + +/** + * #1407 — the retry reset must be a CAS. The cohort is read once and each + * payout then costs a gateway round-trip, so a concurrent process-payouts run + * or a payout webhook can move a row while this job is out. A bare `update` + * stamped it back to APPROVED and the next batch paid it twice. + */ +describe("#1407 — retry reset loses the CAS", () => { + it("count 0 → no second payout is armed and the row is reported skipped", async () => { + // Never reached the gateway, so this is the retry branch. + payoutRow = { ...STUCK_PAYOUT, providerPayoutId: null, retryCount: 0 }; + prismaStub.consultantPayout.updateMany.mockResolvedValueOnce({ count: 0 }); + + const result = await handleStuckPayouts(); + + expect(prismaStub.consultantPayout.updateMany).toHaveBeenCalledWith({ + where: { + id: "po_stuck_1", + status: "PROCESSING", + providerPayoutId: null, + }, + data: { status: "APPROVED", retryCount: { increment: 1 } }, + }); + // Not re-armed for a second disbursement, and no bare write behind the CAS. + expect(result.retriedCount).toBe(0); + expect(payoutRow.status).toBe("PROCESSING"); + expect(prismaStub.consultantPayout.update).not.toHaveBeenCalled(); + expect(result.skippedCount).toBe(1); + }); + + it("count 1 → the winner re-arms once and the increment rides the CAS", async () => { + payoutRow = { ...STUCK_PAYOUT, providerPayoutId: null, retryCount: 0 }; + prismaStub.consultantPayout.updateMany.mockResolvedValueOnce({ count: 1 }); + + const result = await handleStuckPayouts(); + + // Exactly one reset attempt for the one stuck row, and the retryCount bump + // is part of the same guarded write rather than a follow-up update. + expect(prismaStub.consultantPayout.updateMany).toHaveBeenCalledTimes(1); + expect(result.retriedCount).toBe(1); + expect(result.skippedCount).toBe(0); + expect(prismaStub.consultantPayout.update).not.toHaveBeenCalled(); + }); +}); 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/__tests__/schemas/webhook-metadata.test.ts b/__tests__/schemas/webhook-metadata.test.ts index 5f70bf5d7..aea9ce9ec 100644 --- a/__tests__/schemas/webhook-metadata.test.ts +++ b/__tests__/schemas/webhook-metadata.test.ts @@ -65,3 +65,57 @@ describe("validateWebhookMetadata — slot-key rename dual-read", () => { expect(() => validateWebhookMetadata({ ...BASE })).toThrow(); }); }); + +/** + * #1462 — a scheduling-period subscription carries no direct slots, and the + * builder used to send `startsAt`/`endsAt` to the gateway as empty strings. + * `z.string().datetime().optional()` admits an ABSENT key and rejects `""`, so + * every capture for such a sale failed validation and was stamped + * REQUIRES_MANUAL_RECOVERY with the money already taken. The builder no longer + * emits those keys, but Razorpay orders never expire, so orders already minted + * with empty strings keep replaying and the validator has to absorb them. + */ +describe("validateWebhookMetadata — empty-string notes are absent fields", () => { + const SUBSCRIPTION_BASE = { + ...BASE, + appointmentType: "SUBSCRIPTION", + schedulingPeriodStartsAt: "2026-09-01T00:00:00.000Z", + schedulingPeriodEndsAt: "2026-12-01T00:00:00.000Z", + }; + + it("validates an in-flight scheduling-period order carrying startsAt/endsAt as empty strings", () => { + const parsed = validateWebhookMetadata({ + ...SUBSCRIPTION_BASE, + startsAt: "", + endsAt: "", + slotOfAvailabilityWeeklyId: "", + notes: "", + }); + + if (parsed.appointmentType !== "SUBSCRIPTION") { + throw new Error("expected subscription metadata"); + } + expect(parsed.startsAt).toBeUndefined(); + expect(parsed.endsAt).toBeUndefined(); + expect(parsed.notes).toBeUndefined(); + expect(parsed.schedulingPeriodStartsAt).toBe( + SUBSCRIPTION_BASE.schedulingPeriodStartsAt, + ); + }); + + it("does not let an empty legacy key shadow a real slot time", () => { + const parsed = validateWebhookMetadata({ + ...BASE, + slotStartTimeInUTC: NEW_KEYS.startsAt, + slotEndTimeInUTC: NEW_KEYS.endsAt, + startsAt: "", + endsAt: "", + }); + + if (parsed.appointmentType !== "CONSULTATION") { + throw new Error("expected consultation metadata"); + } + expect(parsed.startsAt).toBe(NEW_KEYS.startsAt); + expect(parsed.endsAt).toBe(NEW_KEYS.endsAt); + }); +}); diff --git a/__tests__/security/dm-channel-org-precedence.test.ts b/__tests__/security/dm-channel-org-precedence.test.ts index 3997a628e..897aa7e1e 100644 --- a/__tests__/security/dm-channel-org-precedence.test.ts +++ b/__tests__/security/dm-channel-org-precedence.test.ts @@ -109,7 +109,7 @@ describe("no site re-types the precedence chain", () => { const CONSUMERS: [string, string][] = [ ["creators", "actions/stream/chat/channel.action.ts"], ["reconcile", "actions/stream/chat/event-channel.action.ts"], - ["webhook", "lib/payments/webhooks/handlers.ts"], + ["webhook", "lib/payments/webhooks/ensure-channels.ts"], [ "consultation approval", "app/api/bookings/consultations/[consultationId]/route.ts", @@ -138,7 +138,7 @@ describe("no site re-types the precedence chain", () => { ["creators", "actions/stream/chat/channel.action.ts"], ["reconcile", "actions/stream/chat/event-channel.action.ts"], ["search", "app/api/stream/channels/search-appointments/route.ts"], - ["webhook", "lib/payments/webhooks/handlers.ts"], + ["webhook", "lib/payments/webhooks/ensure-channels.ts"], [ "subscription approval", "app/api/bookings/subscriptions/[subscriptionId]/route.ts", diff --git a/__tests__/stream/trial-dm-consultant-resolution.test.ts b/__tests__/stream/trial-dm-consultant-resolution.test.ts index e1ac4cc3b..63c53b122 100644 --- a/__tests__/stream/trial-dm-consultant-resolution.test.ts +++ b/__tests__/stream/trial-dm-consultant-resolution.test.ts @@ -38,7 +38,7 @@ interface AppointmentShape { } /** - * Lifted from `lib/payments/webhooks/handlers.ts`. A copy, so the test states + * Lifted from `lib/payments/webhooks/ensure-channels.ts`. A copy, so the test states * the contract rather than re-deriving it — the assertion below pins it to the * source so the two cannot drift apart silently. */ @@ -106,8 +106,11 @@ describe("the handler actually does this", () => { const { readFileSync } = require("fs") as typeof import("fs"); // eslint-disable-next-line @typescript-eslint/no-require-imports const { join } = require("path") as typeof import("path"); + // #1356 — the channel block moved out of `handlers.ts` into its own module + // so the reconcile sweep can re-drive it. The pin follows the code; the + // contract it pins is unchanged. const source = readFileSync( - join(process.cwd(), "lib/payments/webhooks/handlers.ts"), + join(process.cwd(), "lib/payments/webhooks/ensure-channels.ts"), "utf8", ); @@ -115,15 +118,17 @@ describe("the handler actually does this", () => { // Without the include, the rung below reads a relation Prisma never // loaded — always undefined, and no type error to say so. expect(source).toContain("trialSession: {"); + // #1446 narrowed the read to a `select` of the ids the step uses, so the + // relation is now loaded with its own `select` rather than `: true`. The + // contract the pin states is unchanged: trialSession is loaded, WITH its + // consultant. expect(source).toMatch( - /trialSession:\s*\{\s*include:\s*\{\s*consultantProfile:\s*true/, + /trialSession:\s*\{\s*(?:include|select):\s*\{\s*consultantProfile:/, ); }); it("has the trial rung in the resolution chain", () => { - expect(source).toContain( - "appointmentForChannel?.trialSession?.consultantProfile", - ); + expect(source).toContain("appointment.trialSession?.consultantProfile"); }); it("uses TrialSession's own consultant, not the plan author's", () => { diff --git a/app/api/admin/compliance/tds-return/route.ts b/app/api/admin/compliance/tds-return/route.ts new file mode 100644 index 000000000..4b6345e1e --- /dev/null +++ b/app/api/admin/compliance/tds-return/route.ts @@ -0,0 +1,99 @@ +/** + * Admin TDS return CSV — the authenticated hop to the quarterly full-PAN file. + * + * #1354/#1362 — the export job writes one CSV per FY+quarter into the PRIVATE + * `org-invoices` bucket because it is the only artifact in the system that + * carries a decrypted PAN. That is also why there is no download proxy and no + * artifact upload anywhere: a full PAN leaves the database exactly once, into + * an object no anonymous URL reaches, and this route is the only door — an + * ADMIN session exchanges the quarter for a short-lived signed URL. + * + * CR #1354 r1 — ADMIN, not merely privileged: `requirePrivilegedAuth` admits + * STAFF, and this object is the same decrypted-PAN class of data that + * `/api/admin/tds?view=form26q` has always gated on ADMIN alone. + */ + +import * as Sentry from "@sentry/nextjs"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAdminAuth } from "@/lib/auth-helpers"; +import { applyRateLimit, moneyOpsLimiter } from "@/lib/rate-limit"; +import { tdsReturnCsvStoragePath } from "@/lib/compliance/tds-return"; +import { + createPrivateFinanceSignedUrl, + privateFinanceObjectExists, +} from "@/lib/storage/private-finance-object"; + +/** Short enough that a leaked URL from a browser history is already dead. */ +const SIGNED_URL_TTL_SECONDS = 10 * 60; + +/** + * CR #1354 r1 — the shape has to be canonical, not merely plausible. + * `Number.parseInt` reads "2foo" as quarter 2, and a bare `\d{4}-\d{2}$` + * accepts "2026-99", so both would name a storage object for a period that + * cannot exist. The refine pins the second half to the FY's closing year. + */ +const QuerySchema = z.object({ + financialYear: z + .string() + .regex(/^\d{4}-\d{2}$/, 'financialYear must look like "2026-27"') + .refine((fy) => { + const startYear = Number.parseInt(fy.slice(0, 4), 10); + return fy.slice(5) === String((startYear + 1) % 100).padStart(2, "0"); + }, 'financialYear must be a consecutive Apr-Mar pair, e.g. "2026-27"'), + quarter: z + .string() + .regex(/^[1-4]$/, "quarter must be 1-4") + .transform((q) => Number.parseInt(q, 10)), +}); + +/** + * GET /api/admin/compliance/tds-return?financialYear=2026-27&quarter=2 + * Redirects to a signed URL for that quarter's return CSV. + */ +export async function GET(req: NextRequest) { + try { + const auth = await requireAdminAuth(); + if (auth.error) return auth.error; + + const limited = await applyRateLimit(moneyOpsLimiter, auth.session.user.id); + if (limited) return limited; + + const { searchParams } = new URL(req.url); + const parsed = QuerySchema.safeParse(Object.fromEntries(searchParams)); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid query", detail: parsed.error.flatten() }, + { status: 400 }, + ); + } + const { financialYear, quarter } = parsed.data; + + const storagePath = tdsReturnCsvStoragePath(financialYear, quarter); + if (!(await privateFinanceObjectExists(storagePath))) { + return NextResponse.json( + { + error: + "No return CSV for that quarter — run the tds-return-draft workflow first.", + }, + { status: 404 }, + ); + } + + const signedUrl = await createPrivateFinanceSignedUrl( + storagePath, + SIGNED_URL_TTL_SECONDS, + ); + return NextResponse.redirect(signedUrl, 302); + } catch (error) { + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "admin" } }, + ); + console.error("Error signing TDS return CSV:", error); + return NextResponse.json( + { error: "Failed to fetch the TDS return CSV" }, + { status: 500 }, + ); + } +} diff --git a/app/api/admin/exchange-rates/route.ts b/app/api/admin/exchange-rates/route.ts index 188d8e975..c2fdc8c0d 100644 --- a/app/api/admin/exchange-rates/route.ts +++ b/app/api/admin/exchange-rates/route.ts @@ -5,6 +5,15 @@ * POST — Force-invalidates the in-memory cache, triggering a fresh fetch on next use * * Useful when FX markets move significantly within the 1-hour cache window. + * + * #1396 — read both verbs as instance-local, not global. The cache they act on + * is a module-level variable in lib/currency.ts, so it belongs to whichever + * serverless instance happened to serve this request. GET therefore reports one + * instance's age, and POST cannot flush production: every other warm instance + * keeps its own copy until that copy expires on its own, and the CDN cache in + * front of /api/currency is untouched by either call. Treat this as a + * development and diagnosis aid. The real staleness bound is MAX_STALE_AGE in + * lib/currency.ts, which refuses to serve anything older than a day. */ import * as Sentry from "@sentry/nextjs"; diff --git a/app/api/admin/maintenance/preflight/route.ts b/app/api/admin/maintenance/preflight/route.ts index f140fdad6..2ade4537d 100644 --- a/app/api/admin/maintenance/preflight/route.ts +++ b/app/api/admin/maintenance/preflight/route.ts @@ -29,6 +29,9 @@ export async function GET() { where: { startsAt: { gte: now, lte: fourHoursFromNow }, isTentative: false, + // A cancelled session is tombstoned, not deleted, so an unfiltered + // count warns the operator about sessions that will never happen. + deletedAt: null, }, }), prisma.consultantPayout.count({ where: { status: "PENDING" } }), diff --git a/app/api/admin/payments/[paymentId]/route.ts b/app/api/admin/payments/[paymentId]/route.ts index 999c6e864..3d782c39c 100644 --- a/app/api/admin/payments/[paymentId]/route.ts +++ b/app/api/admin/payments/[paymentId]/route.ts @@ -2,6 +2,12 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { requirePrivilegedAuth } from "@/lib/auth-helpers"; +import { + ADMIN_DISPUTE_SELECT, + ADMIN_REFUND_SELECT, + CONSUMER_INVOICE_SUMMARY_SELECT, + DISCOUNT_CODE_SUMMARY_SELECT, +} from "@/lib/data/payments-select"; interface RouteParams { params: Promise<{ @@ -33,13 +39,7 @@ export async function GET(req: NextRequest, { params }: RouteParams) { appointmentType: true, }, }, - discountCode: { - select: { - code: true, - discountType: true, - discountValue: true, - }, - }, + discountCode: { select: DISCOUNT_CODE_SUMMARY_SELECT }, // Explicit selects, for two reasons. A bare `include` returns whatever // the model happens to carry, which is how the detail page came to read // `refund.amount` — a name neither model has — and render "₹NaN" from @@ -50,32 +50,17 @@ export async function GET(req: NextRequest, { params }: RouteParams) { // `internalNotes` and `evidence`, and `Refund` holds `metadata` and // `failureReason`; none of it is rendered, and operator notes on a // chargeback are not something to hand out because the page happened - // to over-fetch. + // to over-fetch. The shapes live in lib/data/payments-select.ts so the + // buyer-facing history cannot drift away from this boundary. refunds: { orderBy: { createdAt: "desc" }, - select: { - id: true, - refundId: true, - amountPaise: true, - currency: true, - status: true, - reason: true, - paymentGateway: true, - createdAt: true, - }, + select: ADMIN_REFUND_SELECT, }, disputes: { orderBy: { createdAt: "desc" }, - select: { - id: true, - disputeId: true, - amountPaise: true, - currency: true, - status: true, - reason: true, - createdAt: true, - }, + select: ADMIN_DISPUTE_SELECT, }, + consumerInvoice: { select: CONSUMER_INVOICE_SUMMARY_SELECT }, }, }); diff --git a/app/api/admin/payments/route.ts b/app/api/admin/payments/route.ts index f6a8149bc..3b02c782a 100644 --- a/app/api/admin/payments/route.ts +++ b/app/api/admin/payments/route.ts @@ -70,6 +70,11 @@ export async function GET(req: NextRequest) { appointmentType: true, }, }, + // #1365 — drives the "Invoice" column; an operator answering "where + // is my invoice" should not have to open each payment to find out. + consumerInvoice: { + select: { id: true, invoiceNumber: true, issuedAt: true }, + }, }, }), prisma.payment.count({ where }), 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/admin/tds/route.ts b/app/api/admin/tds/route.ts index 7371924ea..2b80facb3 100644 --- a/app/api/admin/tds/route.ts +++ b/app/api/admin/tds/route.ts @@ -59,7 +59,19 @@ export async function GET(req: NextRequest) { where: { financialYear: fy, reportedInForm26Q: false }, include: { consultantProfile: { - include: { taxInfo: true }, + include: { taxInfo: true, user: { select: { name: true } } }, + }, + // #1354 — org-rail rows share this table, and a filing view that + // resolved only one rail's identity would hand finance a deduction + // with no deductee to file it against. + organization: { + select: { + id: true, + name: true, + taxInfo: { + select: { legalName: true, panEncrypted: true }, + }, + }, }, }, }); @@ -67,7 +79,19 @@ export async function GET(req: NextRequest) { const { decryptPAN } = await import("@/lib/payments/tax/pan-crypto"); const form26qData = records.map((r) => ({ id: r.id, + // CR #1354 r1 — the deductee is a consultant XOR an organisation, so + // the row names which rail it is on rather than leaving the caller to + // infer it from a null id. + deducteeType: r.consultantProfileId ? "CONSULTANT" : "ORGANIZATION", consultantProfileId: r.consultantProfileId, + organizationId: r.organizationId, + // The return needs the name on the PAN; `name` is the editable trade + // name and is only the fallback. + deducteeName: + r.consultantProfile?.user?.name ?? + r.organization?.taxInfo?.legalName ?? + r.organization?.name ?? + null, financialYear: r.financialYear, quarter: r.quarter, tdsDeducted: r.tdsDeducted, @@ -75,9 +99,14 @@ export async function GET(req: NextRequest) { tdsRatePercent: r.tdsRateBps / 100, cumulativeAmountCredited: r.cumulativeAmountCredited, isReversal: r.isReversal, - consultantPAN: r.consultantProfile.taxInfo?.panEncrypted + // #1354 — `consultantProfile` is now nullable because org-rail rows + // share this table, so each rail decrypts from its own tax satellite. + consultantPAN: r.consultantProfile?.taxInfo?.panEncrypted ? decryptPAN(Buffer.from(r.consultantProfile.taxInfo.panEncrypted)) : null, + organizationPAN: r.organization?.taxInfo?.panEncrypted + ? decryptPAN(Buffer.from(r.organization.taxInfo.panEncrypted)) + : null, createdAt: r.createdAt, })); @@ -87,7 +116,10 @@ export async function GET(req: NextRequest) { const summary = await getTDSSummary(fy); return NextResponse.json(summary); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "admin" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "admin" } }, + ); console.error("Admin TDS API error:", error); return NextResponse.json( { error: "Failed to fetch TDS data" }, @@ -143,7 +175,10 @@ export async function POST(req: NextRequest) { recordsUpdated: result.count, }); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "admin" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "admin" } }, + ); console.error("Admin TDS filing error:", error); return NextResponse.json( { error: "Failed to update TDS filing status" }, diff --git a/app/api/bookings/classes/[classId]/allocate/route.ts b/app/api/bookings/classes/[classId]/allocate/route.ts index 54c2feddb..c27e80969 100644 --- a/app/api/bookings/classes/[classId]/allocate/route.ts +++ b/app/api/bookings/classes/[classId]/allocate/route.ts @@ -99,6 +99,9 @@ export async function PATCH( // #1206 — only the consultant (or a privileged caller) may decide to // schedule fewer sessions than the plan sold. allowPartial: body.allowPartial === true && canOverride, + // #1206 — top up the sessions an earlier partial allocation left + // unplaced instead of deleting the confirmed ones and re-planning. + topUp: body.topUp === true && canOverride, }); const duration = Date.now() - startTime; @@ -146,6 +149,9 @@ export async function PATCH( placedSessions: result.placedSessions, requiredSessions: result.requiredSessions, unplacedSessions: result.unplacedSessions, + // #1206 — a top-up that wrote nothing. Lets the caller tell "already + // complete / still no room" from "sessions were added". + noChange: result.noChange, }); } catch (validationError) { const duration = Date.now() - startTime; diff --git a/app/api/bookings/consultations/[consultationId]/allocate/route.ts b/app/api/bookings/consultations/[consultationId]/allocate/route.ts index 9a2e46599..9e3dd4ea2 100644 --- a/app/api/bookings/consultations/[consultationId]/allocate/route.ts +++ b/app/api/bookings/consultations/[consultationId]/allocate/route.ts @@ -100,6 +100,9 @@ export async function PATCH( // #1206 — only the consultant (or a privileged caller) may decide to // schedule fewer sessions than the plan sold. allowPartial: body.allowPartial === true && canOverride, + // #1206 — top up the sessions an earlier partial allocation left + // unplaced instead of deleting the confirmed ones and re-planning. + topUp: body.topUp === true && canOverride, }); const duration = Date.now() - startTime; @@ -147,6 +150,9 @@ export async function PATCH( placedSessions: result.placedSessions, requiredSessions: result.requiredSessions, unplacedSessions: result.unplacedSessions, + // #1206 — a top-up that wrote nothing. Lets the caller tell "already + // complete / still no room" from "sessions were added". + noChange: result.noChange, }); } catch (validationError) { const duration = Date.now() - startTime; diff --git a/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts b/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts index 701d153ea..93a0b8178 100644 --- a/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts +++ b/app/api/bookings/subscriptions/[subscriptionId]/allocate/route.ts @@ -99,6 +99,9 @@ export async function PATCH( // #1206 — only the consultant (or a privileged caller) may decide to // schedule fewer sessions than the plan sold. allowPartial: body.allowPartial === true && canOverride, + // #1206 — top up the sessions an earlier partial allocation left + // unplaced instead of deleting the confirmed ones and re-planning. + topUp: body.topUp === true && canOverride, }); const duration = Date.now() - startTime; @@ -146,6 +149,9 @@ export async function PATCH( placedSessions: result.placedSessions, requiredSessions: result.requiredSessions, unplacedSessions: result.unplacedSessions, + // #1206 — a top-up that wrote nothing. Lets the caller tell "already + // complete / still no room" from "sessions were added". + noChange: result.noChange, }); } catch (validationError) { const duration = Date.now() - startTime; diff --git a/app/api/bookings/webinars/[webinarId]/allocate/route.ts b/app/api/bookings/webinars/[webinarId]/allocate/route.ts index 0389f292a..262117844 100644 --- a/app/api/bookings/webinars/[webinarId]/allocate/route.ts +++ b/app/api/bookings/webinars/[webinarId]/allocate/route.ts @@ -99,6 +99,9 @@ export async function PATCH( // #1206 — only the consultant (or a privileged caller) may decide to // schedule fewer sessions than the plan sold. allowPartial: body.allowPartial === true && canOverride, + // #1206 — top up the sessions an earlier partial allocation left + // unplaced instead of deleting the confirmed ones and re-planning. + topUp: body.topUp === true && canOverride, }); const duration = Date.now() - startTime; @@ -146,6 +149,9 @@ export async function PATCH( placedSessions: result.placedSessions, requiredSessions: result.requiredSessions, unplacedSessions: result.unplacedSessions, + // #1206 — a top-up that wrote nothing. Lets the caller tell "already + // complete / still no room" from "sessions were added". + noChange: result.noChange, }); } catch (validationError) { const duration = Date.now() - startTime; diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts index 829445771..6d2a41476 100644 --- a/app/api/checkout/route.ts +++ b/app/api/checkout/route.ts @@ -4,7 +4,10 @@ import { handleCheckout } from "@/lib/payments/operations/checkout"; import { classifyError, logClassifiedError, + isBusinessErrorCode, + ErrorTypes, } from "@/lib/errors/classification/payment-error-classification"; +import { reportSentryError } from "@/lib/observability/report"; import { NextRequest, NextResponse } from "next/server"; import { requireApiAuth } from "@/lib/auth-helpers"; import { @@ -15,6 +18,8 @@ import { EventFullError, } from "@/utils/appointmentlock"; import { WalletFrozenError } from "@/lib/payments/wallet-freeze"; +import { WalletInsufficientFundsError } from "@/lib/api/organizations/wallet"; +import { DomainVerificationRequiredError } from "@/lib/enterprise/governance"; import { checkoutLimiter, applyRateLimit } from "@/lib/rate-limit"; import { ZodError } from "zod"; import { Prisma } from "@prisma/client"; @@ -80,7 +85,6 @@ export async function POST(req: NextRequest) { event: "checkout_gateway_routed", buyerCountry, gateway: gatewayRouting.gateway, - isIBT: gatewayRouting.isIBT, reason: gatewayRouting.reason, timestamp: new Date().toISOString(), }), @@ -207,6 +211,45 @@ export async function POST(req: NextRequest) { ); } + // #1477 — an overdrawn wallet reaches here with its own code, so the + // classifier below would already answer 402. It gets its own branch anyway + // for the two things that fall-through cannot do: replace a message naming + // the billing account and the paise figure with copy the buyer can act on, + // and skip the unconditional `captureException` under it, which would file + // a routine refusal as an exception. + if (error instanceof WalletInsufficientFundsError) { + // #1477 — a modelled refusal, reported like the other business-coded + // outcomes below so the route's observability stays uniform. + reportSentryError(error, { subsystem: "checkout", expected: true }); + return NextResponse.json( + { + error: + "This organization's wallet does not have enough balance for this booking. Your card was not charged — ask your billing admin to top it up.", + errorType: ErrorTypes.WALLET_INSUFFICIENT_FUNDS, + yourCardWasNotCharged: true, + timestamp: new Date().toISOString(), + }, + { status: error.httpStatus }, + ); + } + + // #1407 — invoice funding asserts a verified org domain + // (lib/payments/operations/checkout.ts:2585) and the guard's typed 403 fell + // through to classifyError, which is message-only and answered 500 + // UNKNOWN_ERROR. Honour the structured status like WalletFrozenError above, + // so the page can say what the admin has to do. + if (error instanceof DomainVerificationRequiredError) { + return NextResponse.json( + { + error: + "Invoice funding requires a verified domain on this organization. Your card was not charged — ask your billing admin to verify the domain, or pay by card instead.", + errorType: "DOMAIN_VERIFICATION_REQUIRED", + timestamp: new Date().toISOString(), + }, + { status: error.httpStatus }, + ); + } + // #1319 — an exhausted serialization retry (P2034 ×4) means the tx never // committed: nothing was charged and a retry will see the sibling's state. // classifyError is message-only and would label it 500. @@ -227,10 +270,20 @@ export async function POST(req: NextRequest) { ); } - Sentry.captureException( - error instanceof Error ? error : new Error(String(error)), - { tags: { subsystem: "checkout" } }, - ); + // #1477 — an error carrying a registered business code is an ANSWER, not a + // fault: the classifier below already resolves it to its own status and + // toast. Capturing it here as an exception is what kept every coded refusal + // without an explicit branch above — the #1458 programme-cap codes, the + // #1467 entitlement codes — paging as a checkout incident. Report it the + // way the modelled refusals inside handleCheckout are reported instead. + if (isBusinessErrorCode((error as { code?: unknown } | null)?.code)) { + reportSentryError(error, { subsystem: "checkout", expected: true }); + } else { + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "checkout" } }, + ); + } const classified = classifyError(error, "Checkout failed"); logClassifiedError("Checkout", classified, error); diff --git a/app/api/checkout/verify-signature/route.ts b/app/api/checkout/verify-signature/route.ts index 6ff2d499c..83b8940e4 100644 --- a/app/api/checkout/verify-signature/route.ts +++ b/app/api/checkout/verify-signature/route.ts @@ -35,6 +35,8 @@ import prisma from "@/lib/prisma"; import { getSession } from "@/lib/auth-server"; import { getRazorpayClient } from "@/lib/payments/core/razorpay"; import { routeCapturedPayment } from "@/app/api/webhooks/razorpay-dispatch"; +import { checkoutLimiter, applyRateLimit } from "@/lib/rate-limit"; +import { recordSystemEvent } from "@/lib/enterprise/system-events"; import { z } from "zod"; const verifySignatureSchema = z.object({ @@ -54,6 +56,14 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + // #1353 — the same 5/min budget `/api/checkout` applies, for the same + // reason: this route makes an outbound `payments.fetch` per call and then + // drives the whole confirmation pipeline, so an unbounded client loop here + // is both a gateway-quota drain and a way to hammer the money path. It was + // the one confirmation entry point with no limit at all. + const rl = await applyRateLimit(checkoutLimiter, session.user.id); + if (rl) return rl; + const body = await req.json(); const { razorpay_order_id, razorpay_payment_id, razorpay_signature } = verifySignatureSchema.parse(body); @@ -206,6 +216,29 @@ export async function POST(req: NextRequest) { // Handlers are idempotent and Serializable, so a concurrent webhook either // loses the SSI race and retries into a no-op or wins and makes this one. after(async () => { + // #1353 3.3 — the audit trail has to distinguish a capture the CLIENT + // confirmed from one the WEBHOOK confirmed. Both run the identical + // pipeline, so afterwards the Payment row looks the same either way, and + // when the two disagree — a confirmation with no matching webhook, or one + // that arrived impossibly fast — there was no record of which door the + // money came through. + // + // Inside `after()` and awaited, not floated next to the response: a + // detached promise on Netlify races the freeze that follows the response, + // so the very confirmations worth auditing — the slow ones — were the + // ones whose row could be dropped. `after()` holds the invocation open, + // and the `.catch` keeps this best-effort, so awaiting costs one insert + // of post-response latency and can never fail a confirmation. + await recordSystemEvent({ + category: "PAYMENT", + message: "client-side payment confirmation", + correlationId: razorpay_order_id, + context: { + paymentId: payment.id, + gatewayPaymentId: razorpay_payment_id, + }, + }).catch(() => {}); + try { await routeCapturedPayment({ orderId: razorpay_order_id, diff --git a/app/api/cleanup/abandoned-payments/route.ts b/app/api/cleanup/abandoned-payments/route.ts index d83e24e5d..c4842dbab 100644 --- a/app/api/cleanup/abandoned-payments/route.ts +++ b/app/api/cleanup/abandoned-payments/route.ts @@ -5,6 +5,11 @@ import { cleanupExpiredApprovalPendingPayments, disconnectDatabase, } from "@/scripts/payments/cleanup-abandoned-payments"; +import { + InvalidLimitError, + parseLimitParam, + statusFor, +} from "@/lib/cron/cleanup-route"; import * as Sentry from "@sentry/nextjs"; import { assertNotInMaintenance, @@ -31,9 +36,16 @@ export async function POST(req: NextRequest) { Sentry.logger.info("cron:cleanup-abandoned-payments started"); - // Run both cleanup tasks - const paymentResult = await cleanupAbandonedPayments(); - const consultationResult = await cleanupExpiredApprovalPendingPayments(); + // Run both cleanup tasks. `limit` is passed to each pass IN FULL, not + // split or subtracted between them: it bounds each pass's own query so a + // single pass fits the ticker's 26s function ceiling, and the unbounded + // GitHub Actions run is the backstop that drains whatever a bounded tick + // leaves behind (ADR 27). + const limit = parseLimitParam(req); + const paymentResult = await cleanupAbandonedPayments({ limit }); + const consultationResult = await cleanupExpiredApprovalPendingPayments({ + limit, + }); await disconnectDatabase(); Sentry.logger.info("cron:cleanup-abandoned-payments finished", { @@ -41,17 +53,28 @@ export async function POST(req: NextRequest) { consultationSuccess: consultationResult.success, }); - return NextResponse.json({ - paymentCleanup: paymentResult, - consultationCleanup: consultationResult, - overallSuccess: paymentResult.success && consultationResult.success, - }); + const overallSuccess = paymentResult.success && consultationResult.success; + return NextResponse.json( + { + paymentCleanup: paymentResult, + consultationCleanup: consultationResult, + overallSuccess, + }, + // #1464 — this twin always answered 200, so a run that reported failures + // in its own body still read as healthy to the ticker and to anything + // watching the status. The shared mapping answers 500 when the sweep + // says it failed, which is what the rest of the cohort already does. + { status: statusFor({ success: overallSuccess }) }, + ); } catch (error) { // #476 — concurrent invocation (schedule overlap / manual re-run) // skips with a 409 instead of double-running. if (error instanceof CronLockHeldError) { return NextResponse.json({ error: error.message }, { status: 409 }); } + if (error instanceof InvalidLimitError) { + return NextResponse.json({ error: "INVALID_LIMIT" }, { status: 400 }); + } if (error instanceof MaintenanceActiveError) { return NextResponse.json( { error: error.message, phase: error.phase }, diff --git a/app/api/cleanup/cascade-refund-earnings/route.ts b/app/api/cleanup/cascade-refund-earnings/route.ts index a88fb048c..2ae71deec 100644 --- a/app/api/cleanup/cascade-refund-earnings/route.ts +++ b/app/api/cleanup/cascade-refund-earnings/route.ts @@ -8,13 +8,13 @@ * Schedule: Every 15 minutes (via GitHub Actions or external cron) */ -import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { cleanupRoute, parseLimitParam } from "@/lib/cron/cleanup-route"; import { cascadeRefundToEarnings } from "@/scripts/refunds/cascade-refund-earnings"; import { cascadeRunFailed } from "@/scripts/refunds/cascade-run-outcome"; export const { GET, POST } = cleanupRoute({ job: "cascade-refund-earnings", - run: () => cascadeRefundToEarnings(), + run: (req) => cascadeRefundToEarnings({ limit: parseLimitParam(req) }), summarize: (r) => ({ totalProcessed: r.totalProcessed, updatedCount: r.updatedCount, diff --git a/app/api/cleanup/dispatch-outbound-webhooks/route.ts b/app/api/cleanup/dispatch-outbound-webhooks/route.ts index 20af8810b..c2c46d29b 100644 --- a/app/api/cleanup/dispatch-outbound-webhooks/route.ts +++ b/app/api/cleanup/dispatch-outbound-webhooks/route.ts @@ -7,7 +7,7 @@ * the bearer gate keeps random callers from running it. */ -import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { cleanupRoute, parseLimitParam } from "@/lib/cron/cleanup-route"; import { dispatchOutboundWebhooks } from "@/scripts/cleanup/dispatch-outbound-webhooks"; export const { GET, POST } = cleanupRoute({ @@ -15,7 +15,7 @@ export const { GET, POST } = cleanupRoute({ // The HTTP route does NOT disconnect — `prisma` is the global singleton // shared with the rest of the Next runtime. We only disconnect in the // standalone job wrapper (jobs/cleanup/*). - run: () => dispatchOutboundWebhooks(), + run: (req) => dispatchOutboundWebhooks({ limit: parseLimitParam(req) }), summarize: (r) => ({ scanned: r.scanned, succeeded: r.succeeded, diff --git a/app/api/cleanup/gst-outward-register-export/route.ts b/app/api/cleanup/gst-outward-register-export/route.ts new file mode 100644 index 000000000..5729f66b5 --- /dev/null +++ b/app/api/cleanup/gst-outward-register-export/route.ts @@ -0,0 +1,32 @@ +/** + * POST /api/cleanup/gst-outward-register-export — #1370 + * + * HTTP twin of the monthly outward-supplies register export, CRON_SECRET-gated + * like every other cleanup route. It exists so the register can be re-run + * without a GitHub Actions dispatch — during a filing week that matters, because + * the healer inside is what mints the tax invoices checkout was allowed to miss. + * + * It shares `runGstOutwardRegisterExport` with the Actions entry point, so it + * takes the same fail-closed `gst-outward-register-export` cron lock; a manual + * call that overlaps the scheduled run answers 409 rather than racing the + * gapless invoice series. The CSV is not written here — there is nowhere to put + * it and nothing to collect it — so this call heals, stamps and reports. + */ + +import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { runGstOutwardRegisterExport } from "@/jobs/compliance/gst-outward-register-export"; + +export const { GET, POST } = cleanupRoute({ + job: "gst-outward-register-export", + run: () => runGstOutwardRegisterExport({ writeCsv: false }), + summarize: (r) => ({ + period: r.period, + mintedByHealer: r.mintedByHealer, + documentCount: r.documentCount, + warnings: r.warnings, + }), + // A register with warnings still succeeded; the warnings are a filer's + // worklist, not a failed run. + status: () => 200, + failureMessage: "Failed to export the GST outward-supplies register", +}); diff --git a/app/api/cleanup/reconcile-disputes/route.ts b/app/api/cleanup/reconcile-disputes/route.ts index 9984b77d8..4e7ddc93e 100644 --- a/app/api/cleanup/reconcile-disputes/route.ts +++ b/app/api/cleanup/reconcile-disputes/route.ts @@ -27,7 +27,10 @@ export const { GET, POST } = cleanupRoute({ reconciledCount: r.reconciledCount, urgentCount: r.urgentCount, razorpayManualReviewCount: r.razorpayManualReviewCount, + skippedFenced: r.skippedFenced, }), - status: () => 200, + // #1459 — no `status` override: a hardcoded 200 reported every failed run as + // healthy, the same masking #1390 removed from the other sweeps. The default + // mapping answers 500 when `success` is false. failureMessage: "Failed to reconcile disputes", }); diff --git a/app/api/cleanup/reconcile-orphaned-confirmations/route.ts b/app/api/cleanup/reconcile-orphaned-confirmations/route.ts new file mode 100644 index 000000000..a03e9cea0 --- /dev/null +++ b/app/api/cleanup/reconcile-orphaned-confirmations/route.ts @@ -0,0 +1,47 @@ +/** + * Orphaned-confirmation reconcile API endpoint. + * + * Thin wrapper around scripts/payments/reconcile-orphaned-confirmations.ts, + * matching the GitHub Actions job in jobs/payments. Two passes run per + * invocation: the #830 re-drive of a confirmation that never landed, and the + * #1356 re-drive of the chat channel a capture failed to create. + * + * `?limit=` exists because a Netlify ticker calls this route on a short + * schedule and cannot spend a function's whole budget on one sweep. It bounds + * both passes, so a small value means "take a small bite of each backlog" + * rather than starving one of them. It can only lower the channel pass: that + * pass keeps its own ceiling in appointments and in buyer-level Stream calls, + * because one appointment can carry hundreds of buyers. #1391 + */ + +import { + cleanupRoute, + parseLimitParam, + statusFor, +} from "@/lib/cron/cleanup-route"; +import { reconcileOrphanedConfirmations } from "@/scripts/payments/reconcile-orphaned-confirmations"; + +export const { GET, POST } = cleanupRoute({ + job: "reconcile-orphaned-confirmations", + run: (req) => { + // #1459 — this route kept a private parser that swallowed a malformed + // `?limit=` and swept the default batch instead. Every other ticker target + // uses the shared one, which answers 400 INVALID_LIMIT on junk and clamps + // at the cap, so a broken caller is visible rather than silently unbounded. + const limit = parseLimitParam(req); + return reconcileOrphanedConfirmations(limit === undefined ? {} : { limit }); + }, + summarize: (r) => ({ + scanned: r.scanned, + confirmed: r.confirmed, + stillBlocked: r.stillBlocked, + channelsEnsured: r.channelsEnsured, + channelsFailed: r.channelsFailed, + channelBuyerOps: r.channelBuyerOps, + channelsDeferred: r.channelsDeferred, + }), + // A channel this run could not create is a buyer with no conversation, which + // an operator has to see; the next run retries it, so it is not a failure. + status: (r) => statusFor(r, r.channelsFailed > 0), + failureMessage: "Failed to reconcile orphaned confirmations", +}); diff --git a/app/api/cleanup/reconcile-payment-status/route.ts b/app/api/cleanup/reconcile-payment-status/route.ts index 30c909c2b..2ba3a13bd 100644 --- a/app/api/cleanup/reconcile-payment-status/route.ts +++ b/app/api/cleanup/reconcile-payment-status/route.ts @@ -7,12 +7,16 @@ * Schedule: Every 30 minutes (via GitHub Actions or external cron) */ -import { cleanupRoute, statusFor } from "@/lib/cron/cleanup-route"; +import { + cleanupRoute, + parseLimitParam, + statusFor, +} from "@/lib/cron/cleanup-route"; import { reconcilePaymentStatus } from "@/scripts/payments/reconcile-payment-status"; export const { GET, POST } = cleanupRoute({ job: "reconcile-payment-status", - run: () => reconcilePaymentStatus(), + run: (req) => reconcilePaymentStatus({ limit: parseLimitParam(req) }), summarize: (r) => ({ totalProcessed: r.totalProcessed, reconciledCount: r.reconciledCount, diff --git a/app/api/cleanup/reconcile-refunds/route.ts b/app/api/cleanup/reconcile-refunds/route.ts index 7d657b5dd..0b5a113c3 100644 --- a/app/api/cleanup/reconcile-refunds/route.ts +++ b/app/api/cleanup/reconcile-refunds/route.ts @@ -7,7 +7,11 @@ * Schedule: Every 15 minutes (via GitHub Actions or external cron) */ -import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { + cleanupRoute, + parseLimitParam, + statusFor, +} from "@/lib/cron/cleanup-route"; import { reconcilePendingRefunds } from "@/scripts/refunds/reconcile-pending-refunds"; export const { GET, POST } = cleanupRoute({ @@ -15,13 +19,20 @@ export const { GET, POST } = cleanupRoute({ // DEGRADED branch on FINANCIAL_JOB_NAMES membership, and "reconcile-refunds" // is not a member, so this financial job would have run through DEGRADED. job: "reconcile-pending-refunds", - run: () => reconcilePendingRefunds(), + run: (req) => reconcilePendingRefunds({ limit: parseLimitParam(req) }), summarize: (r) => ({ totalProcessed: r.totalProcessed, reconciledCount: r.reconciledCount, failedCount: r.failedCount, skippedCount: r.skippedCount, + skippedFenced: r.skippedFenced, }), - status: () => 200, + // #1458 — a fenced-gateway skip is a healthy run with something an operator + // should know about: PENDING refunds exist on a rail this deployment does not + // poll. 207 says exactly that, where the old behaviour was a 500 because every + // fenced row threw and landed in `errors`. + status: (r) => statusFor(r, r.skippedFenced > 0), + // #1390 review — the constant 200 masked a caught job error (success:false) + // as healthy; the default statusFor already reads result.success. failureMessage: "Failed to reconcile refunds", }); diff --git a/app/api/cleanup/reconcile-slot-availability/route.ts b/app/api/cleanup/reconcile-slot-availability/route.ts index 924e7f56b..655cdf17c 100644 --- a/app/api/cleanup/reconcile-slot-availability/route.ts +++ b/app/api/cleanup/reconcile-slot-availability/route.ts @@ -16,6 +16,9 @@ export const { GET, POST } = cleanupRoute({ summarize: (r) => ({ tentativeFlagsCleared: r.tentativeFlagsCleared, doubleBookingsDetected: r.doubleBookingsDetected, + // #1206 — sessions the top-up pass recovered for partially-scheduled plans. + topUpsPlaced: r.topUps.placed, + topUpSessionsPlaced: r.topUps.sessionsPlaced, }), // 207 when double bookings were detected and the run itself was clean. status: (r) => statusFor(r, r.doubleBookingsDetected > 0), diff --git a/app/api/cleanup/release-earnings/route.ts b/app/api/cleanup/release-earnings/route.ts index 088559307..feafe22e7 100644 --- a/app/api/cleanup/release-earnings/route.ts +++ b/app/api/cleanup/release-earnings/route.ts @@ -7,16 +7,20 @@ * Schedule: Hourly (via GitHub Actions or external cron) */ -import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { cleanupRoute, parseLimitParam } from "@/lib/cron/cleanup-route"; import { releaseEarningsFromHold } from "@/scripts/earnings/release-earnings"; export const { GET, POST } = cleanupRoute({ job: "release-earnings", - run: () => releaseEarningsFromHold(), + 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, }), - status: () => 200, + // #1390 review — the constant 200 masked a caught job error (success:false) + // as healthy; the default statusFor already reads result.success. failureMessage: "Failed to release earnings", }); diff --git a/app/api/cleanup/settle-invoice-accruals/route.ts b/app/api/cleanup/settle-invoice-accruals/route.ts new file mode 100644 index 000000000..8556f62b9 --- /dev/null +++ b/app/api/cleanup/settle-invoice-accruals/route.ts @@ -0,0 +1,31 @@ +/** + * POST /api/cleanup/settle-invoice-accruals — #1407 + * + * HTTP twin of the monthly accrual rollup, CRON_SECRET-gated like every other + * cleanup route. Every other job under `jobs/**` already has one; this one did + * not, so the only way to re-run the job that turns INVOICE_ACCRUAL legs into + * an actual OrganizationInvoice was a GitHub Actions dispatch — during a + * billing cycle that matters, because an org that misses the rollup is simply + * not billed until the next month. + * + * It shares `runSettleInvoiceAccruals` with the Actions entry point, so it + * takes the same fail-closed `settle-invoice-accruals` cron lock: a manual call + * that overlaps the scheduled run answers 409 rather than racing it into a + * second invoice for the same accruals. + */ + +import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { runSettleInvoiceAccruals } from "@/jobs/billing/settle-invoice-accruals"; + +export const { GET, POST } = cleanupRoute({ + job: "settle-invoice-accruals", + run: () => runSettleInvoiceAccruals(), + summarize: (r) => ({ + orgsProcessed: r.orgsProcessed, + invoicesCreated: r.invoicesCreated, + }), + // A run that found nothing to bill is a healthy run, and the flag being off + // (ENABLE_CONSOLIDATED_INVOICE="false") reports zeroes rather than failing. + status: () => 200, + failureMessage: "Failed to settle invoice accruals", +}); diff --git a/app/api/cleanup/sweep-orphaned-topup-captures/route.ts b/app/api/cleanup/sweep-orphaned-topup-captures/route.ts index 8cfd473bc..10253f5b9 100644 --- a/app/api/cleanup/sweep-orphaned-topup-captures/route.ts +++ b/app/api/cleanup/sweep-orphaned-topup-captures/route.ts @@ -2,12 +2,12 @@ * Captured-but-uncredited wallet top-up reconciler API endpoint (#785, task #23). * Thin CRON_SECRET-gated wrapper around the reconciler. Runs every ~30 minutes. */ -import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { cleanupRoute, parseLimitParam } from "@/lib/cron/cleanup-route"; import { sweepOrphanedTopupCaptures } from "@/scripts/cleanup/sweep-orphaned-topup-captures"; export const { GET, POST } = cleanupRoute({ job: "sweep-orphaned-topup-captures", - run: () => sweepOrphanedTopupCaptures(), + run: (req) => sweepOrphanedTopupCaptures({ limit: parseLimitParam(req) }), summarize: (r) => ({ scanned: r.scanned, recredited: r.recredited, diff --git a/app/api/cleanup/sweep-stuck-webhook-events/route.ts b/app/api/cleanup/sweep-stuck-webhook-events/route.ts index 2101d03e9..d954e8d7b 100644 --- a/app/api/cleanup/sweep-stuck-webhook-events/route.ts +++ b/app/api/cleanup/sweep-stuck-webhook-events/route.ts @@ -6,12 +6,12 @@ * * Schedule: every ~10 minutes (CRON_SECRET-gated, like the other cleanup jobs). */ -import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { cleanupRoute, parseLimitParam } from "@/lib/cron/cleanup-route"; import { sweepStuckWebhookEvents } from "@/scripts/cleanup/sweep-stuck-webhook-events"; export const { GET, POST } = cleanupRoute({ job: "sweep-stuck-webhook-events", - run: () => sweepStuckWebhookEvents(), + run: (req) => sweepStuckWebhookEvents({ limit: parseLimitParam(req) }), summarize: (r) => ({ scanned: r.scanned, recovered: r.recovered, diff --git a/app/api/cleanup/sync-payment-earnings/route.ts b/app/api/cleanup/sync-payment-earnings/route.ts index f25e0bcb7..83f7649d8 100644 --- a/app/api/cleanup/sync-payment-earnings/route.ts +++ b/app/api/cleanup/sync-payment-earnings/route.ts @@ -8,18 +8,19 @@ * Schedule: Hourly (via GitHub Actions or external cron) */ -import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { cleanupRoute, parseLimitParam } from "@/lib/cron/cleanup-route"; import { syncPaymentEarnings } from "@/scripts/earnings/sync-payment-earnings"; export const { GET, POST } = cleanupRoute({ job: "sync-payment-earnings", - run: () => syncPaymentEarnings(), + run: (req) => syncPaymentEarnings({ limit: parseLimitParam(req) }), summarize: (r) => ({ totalProcessed: r.totalProcessed, createdCount: r.createdCount, skippedCount: r.skippedCount, errorCount: r.errorCount, }), - status: () => 200, + // #1390 review — the constant 200 masked errorCount>0 runs as healthy; the + // default statusFor already reads result.success. failureMessage: "Failed to sync payment earnings", }); diff --git a/app/api/cleanup/tds-return-draft/route.ts b/app/api/cleanup/tds-return-draft/route.ts new file mode 100644 index 000000000..6d606ec82 --- /dev/null +++ b/app/api/cleanup/tds-return-draft/route.ts @@ -0,0 +1,35 @@ +/** + * POST /api/cleanup/tds-return-draft — #1407 + * + * HTTP twin of the quarterly Form 26Q draft export, CRON_SECRET-gated like + * every other cleanup route. It runs the same core with the same defaults — + * the quarter that CLOSED, not the one containing today — so a filing week + * re-run does not need a GitHub Actions dispatch. The full-PAN CSV still goes + * to the private finance bucket and the masked draft to the log; there is no + * Actions artifact on this path, so the response carries the storage path + * rather than the file. + * + * It shares `runTdsReturnDraftExport` with the Actions entry point, so it takes + * the same `tds-26q-draft-export` cron lock (fail-open — the draft is a + * read-only export, harmless to repeat). + */ + +import { cleanupRoute } from "@/lib/cron/cleanup-route"; +import { runTdsReturnDraftExport } from "@/jobs/compliance/tds-26q-draft-export"; + +export const { GET, POST } = cleanupRoute({ + job: "tds-26q-draft-export", + run: () => runTdsReturnDraftExport(), + summarize: (r) => ({ + financialYear: r.financialYear, + quarter: r.quarter, + deducteeCount: r.deducteeCount, + alreadyReported: r.alreadyReported, + warnings: r.warnings.length, + storagePath: r.storagePath, + }), + // Warnings are the filer's worklist, not a failed run — same posture as the + // GST outward register twin. + status: () => 200, + failureMessage: "Failed to export the TDS return draft", +}); diff --git a/app/api/currency/route.ts b/app/api/currency/route.ts index f6c40dee5..30570fbc5 100644 --- a/app/api/currency/route.ts +++ b/app/api/currency/route.ts @@ -1,39 +1,101 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; -import { getExchangeRates, CURRENCY_SYMBOLS } from "@/lib/currency"; +import { z } from "zod"; +import { getExchangeRates } from "@/lib/currency"; +import { + SUPPORTED_CURRENCIES, + SUPPORTED_CURRENCY_CODES, +} from "@/lib/currency-codes"; +import { applyRateLimit, currencyLimiter, getClientIp } from "@/lib/rate-limit"; + +// #1396 — this replaces the `CURRENCY_SYMBOLS` Proxy in lib/currency.ts, which +// claimed to hold every ISO 4217 code by answering `has` with `true` and +// deriving the symbol through Intl on every property read. The route only ever +// needs a symbol for a currency the navbar can actually select, so a plain map +// over that same list says what is true and an unknown code falls back to the +// code itself, which is what Intl would have produced anyway. +const CURRENCY_SYMBOL_BY_CODE: Record = Object.fromEntries( + SUPPORTED_CURRENCIES.map((c) => [c.code, c.symbol]), +); + +function symbolFor(code: string): string { + return CURRENCY_SYMBOL_BY_CODE[code] ?? code; +} + +// #1396 — the response is a public, per-currency mid-market rate that is +// identical for every caller, and the upstream provider refreshes it once a +// day. Serving it from the CDN for an hour, and allowing a day-old copy to be +// served while a new one is fetched, is what actually protects the provider +// quota: the module-level cache in lib/currency.ts is per serverless instance +// and gives no cross-instance hit rate at all. +const CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400"; + +// #1414 — `to` arrives from localStorage by way of useCurrency, so it is +// attacker-controlled. The provider answers with roughly 160 codes, and reading +// `rates[to]` straight off that object returned a real rate for currencies the +// navbar never offers and `symbolFor` has no symbol for. Allowlisting here is +// the same list the switcher renders and the checkout schema accepts, so the +// three cannot drift; an unsupported code 400s without the raw value being +// echoed back. +const querySchema = z.object({ + to: z.enum(SUPPORTED_CURRENCY_CODES).default("INR"), +}); export async function GET(request: NextRequest) { + const limited = await applyRateLimit(currencyLimiter, getClientIp(request)); + if (limited) return limited; + try { - const searchParams = request.nextUrl.searchParams; - const to = searchParams.get("to") || "INR"; + const parsed = querySchema.safeParse({ + to: request.nextUrl.searchParams.get("to") || undefined, + }); + if (!parsed.success) { + return NextResponse.json( + { error: "Unsupported currency" }, + { status: 400 }, + ); + } + const { to } = parsed.data; // If target is INR, no conversion needed if (to === "INR") { - return NextResponse.json({ - rate: 1, - currency: "INR", - symbol: CURRENCY_SYMBOLS["INR"], - }); + return NextResponse.json( + { rate: 1, currency: "INR", symbol: symbolFor("INR") }, + { headers: { "Cache-Control": CACHE_CONTROL } }, + ); } const rates = await getExchangeRates(); const rate = rates[to]; + // Allowlisted but absent upstream: the provider dropped a code we offer. + // Treated as a provider failure, not a client error, so useCurrency + // degrades to honest INR rather than showing a converted figure. if (rate === undefined) { return NextResponse.json( - { error: `Unsupported currency: ${to}` }, - { status: 400 }, + { error: "Failed to fetch exchange rates" }, + { status: 500 }, ); } - return NextResponse.json({ - rate, - currency: to, - symbol: CURRENCY_SYMBOLS[to] || to, - }); + return NextResponse.json( + { + rate, + currency: to, + symbol: symbolFor(to), + }, + { headers: { "Cache-Control": CACHE_CONTROL } }, + ); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "currency" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "currency" } }, + ); console.error("Currency API error:", error); + // #1396 — reaching here now also covers "the provider is down and the + // cached rates are older than a day". Answering 500 is deliberate: the + // client query exhausts its retries, `rate` stays null, and useCurrency + // renders honest INR rather than a stale conversion presented as current. return NextResponse.json( { error: "Failed to fetch exchange rates" }, { status: 500 }, diff --git a/app/api/dashboard/consultee/[consulteeId]/payments/route.ts b/app/api/dashboard/consultee/[consulteeId]/payments/route.ts index a7a629c59..1ebd4681a 100644 --- a/app/api/dashboard/consultee/[consulteeId]/payments/route.ts +++ b/app/api/dashboard/consultee/[consulteeId]/payments/route.ts @@ -7,6 +7,11 @@ import { forbiddenResponse, } from "@/lib/auth-helpers"; import { resolveOrgScope, scopeOrgId } from "@/lib/api/scope/parse"; +import { + CONSUMER_INVOICE_SUMMARY_SELECT, + DISCOUNT_CODE_SUMMARY_SELECT, + REFUND_SUMMARY_SELECT, +} from "@/lib/data/payments-select"; export async function GET( request: Request, @@ -78,8 +83,9 @@ export async function GET( ? { organizationId: scopedOrgId } : {}; - // Per-Payment invoices stay out of this response since the v0 lockdown - // (#768) — v1.1 re-introduces a per-Payment invoice flow. + // #1365 — the per-Payment tax invoice is back. The v0 lockdown (#768) took + // it out; the platform bills as principal supplier, so a consumer charged + // 18% GST is owed the document and needs to be able to find it here. const [payments, credits, creditAgg, creditUsages] = await Promise.all([ // All payments for this user, scoped to the selected org context prisma.payment.findMany({ @@ -110,26 +116,17 @@ export async function GET( }, }, }, - discountCode: { - select: { - code: true, - discountType: true, - discountValue: true, - }, - }, - // #776 — refund visibility. Without this a cancelled-with-refund - // booking reads "SUCCEEDED" in the payment history forever. + discountCode: { select: DISCOUNT_CODE_SUMMARY_SELECT }, + // Column shapes are shared with the admin payment route so the + // privacy boundary is defined once (lib/data/payments-select.ts). + // The soft-delete filter is buyer-side only: an operator still needs + // to see a withdrawn refund row, a buyer does not. refunds: { where: { deletedAt: null }, - select: { - id: true, - amountPaise: true, - status: true, - reason: true, - createdAt: true, - }, + select: REFUND_SUMMARY_SELECT, orderBy: { createdAt: "desc" }, }, + consumerInvoice: { select: CONSUMER_INVOICE_SUMMARY_SELECT }, }, orderBy: { createdAt: "desc" }, // Per-user history; bound the payload (mirrors the main consultee @@ -229,6 +226,7 @@ export async function GET( refunds, refundedPaise, displayStatus, + consumerInvoice: p.consumerInvoice, receiptUrl: p.receiptUrl, expiresAt: p.expiresAt, createdAt: p.createdAt, diff --git a/app/api/dev/mock-webhook/route.ts b/app/api/dev/mock-webhook/route.ts index aa3647d93..ffd6b580b 100644 --- a/app/api/dev/mock-webhook/route.ts +++ b/app/api/dev/mock-webhook/route.ts @@ -25,7 +25,7 @@ import prisma from "@/lib/prisma"; import { handlePaymentSuccess } from "@/lib/payments/webhooks/handlers"; import { refundEarnings } from "@/lib/payments/payouts/earnings-service"; import { handlePayoutWebhook } from "@/lib/payments/payouts/payout-service"; -import { PaymentGateway, PaymentStatus, EarningStatus } from "@prisma/client"; +import { PaymentGateway, EarningStatus } from "@prisma/client"; // ============================================ // Types @@ -63,12 +63,16 @@ interface MockWebhookResponse { * production deployment into an unauthenticated "confirm any payment" endpoint. * That disjunct is gone. The gate is now build-time posture only: a production * build cannot be opened up by configuration. + * + * The `VERCEL_ENV === "preview"` disjunct went the same way, for the same + * reason: it was a second runtime toggle contradicting the sentence above, and + * on a preview built against the one shared Supabase project it would have + * meant an unauthenticated "confirm any payment" endpoint over real rows. It + * 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" || - process.env.VERCEL_ENV === "preview" - ); + return process.env.NODE_ENV === "development"; } // ============================================ @@ -158,7 +162,10 @@ async function handleMockPaymentCaptured( const metadata: Record = { appointmentId: payment.appointmentId || "", appointmentType: payment.appointment?.appointmentType || "CONSULTATION", - consulteeId: payment.userId || "", + // #1439 — the schema's key is `userId`; under `consulteeId` every replay + // failed validation and took the manual-recovery branch instead of + // confirming the booking. + userId: payment.userId || "", consultantId: getConsultantProfileId(), }; diff --git a/app/api/organizations/[orgId]/billing-account/invoices/route.ts b/app/api/organizations/[orgId]/billing-account/invoices/route.ts index 9008e3c28..765208c25 100644 --- a/app/api/organizations/[orgId]/billing-account/invoices/route.ts +++ b/app/api/organizations/[orgId]/billing-account/invoices/route.ts @@ -146,7 +146,7 @@ export async function POST( if (body.purchaseOrderId) { const po = await prisma.purchaseOrder.findUnique({ where: { id: body.purchaseOrderId }, - select: { organizationId: true, status: true }, + select: { organizationId: true, status: true, currency: true }, }); if (!po || po.organizationId !== orgId) { return NextResponse.json( @@ -160,6 +160,20 @@ export async function POST( { status: 409 }, ); } + // #1396 — the claim below decrements `remainingAmountPaise` by this + // invoice's INR total. Nothing compared the two currencies, so an invoice + // could spend a PO denominated in something else, paise-for-paise. Both + // sides are INR today (the writers are narrowed to INR); this refuses the + // combination rather than assuming it stays that way. + if (po.currency !== body.displayCurrency) { + return NextResponse.json( + { + error: `PurchaseOrder is denominated in ${po.currency}; this invoice is in ${body.displayCurrency}. A PO can only be drawn down by an invoice in its own currency.`, + code: "PO_CURRENCY_MISMATCH", + }, + { status: 409 }, + ); + } } if (body.contractId) { const contract = await prisma.contract.findUnique({ @@ -233,6 +247,9 @@ export async function POST( id: body.purchaseOrderId, organizationId: orgId, status: "ACTIVE", + // #1396 — repeated in the CAS predicate so the currency check above + // cannot be raced by a PATCH between the read and the claim. + currency: body.displayCurrency, remainingAmountPaise: { gte: gst.totalPaise }, }, data: { remainingAmountPaise: { decrement: gst.totalPaise } }, diff --git a/app/api/organizations/[orgId]/billing-account/purchase-orders/route.ts b/app/api/organizations/[orgId]/billing-account/purchase-orders/route.ts index 4daae1a10..6ea0ded68 100644 --- a/app/api/organizations/[orgId]/billing-account/purchase-orders/route.ts +++ b/app/api/organizations/[orgId]/billing-account/purchase-orders/route.ts @@ -25,7 +25,12 @@ import { requireOrgAccess } from "@/lib/auth-helpers"; import { requireOrgBillingAdminOrOwner } from "@/lib/auth/billing-admin-gate"; import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; -const CurrencySchema = z.enum(["INR", "USD", "EUR", "GBP"]); +// #1396 — the `Currency` enum stays on the column (ADR 15 keeps the type), but +// this API refuses to write anything except INR. Nothing downstream compares a +// PO's currency against what is drawn from it: the invoice route decrements +// `remainingAmountPaise` by an INR total, and the dashboard rollup sums +// remainders across currencies. A USD PO was therefore spent in rupees. +const CurrencySchema = z.literal("INR"); const PoStatusSchema = z.enum(["ACTIVE", "CLOSED", "CANCELLED"]); const CreateBodySchema = z.object({ diff --git a/app/api/organizations/[orgId]/billing-account/route.ts b/app/api/organizations/[orgId]/billing-account/route.ts index 64f7762c0..6c9a9b26f 100644 --- a/app/api/organizations/[orgId]/billing-account/route.ts +++ b/app/api/organizations/[orgId]/billing-account/route.ts @@ -38,7 +38,12 @@ const FundingSourceSchema = z.enum([ "INVOICE", ]); -const CurrencySchema = z.enum(["INR", "USD", "EUR", "GBP"]); +// #1396 — the `Currency` enum stays on the column (ADR 15 keeps the type), but +// this API refuses to write anything except INR. `BillingAccount.currency` is +// forwarded verbatim into `createRazorpayOrder` by the wallet top-up route, and +// every amount the platform stores is INR paise, so a USD account priced a +// ₹1,000 top-up as a $1,000 order. +const CurrencySchema = z.literal("INR"); // #777 §C — wallet minimum-balance + auto-top-up config. NOTIFY-ONLY floor for // now (cron emails finance below the minimum); the mandate charge lands later. diff --git a/app/api/organizations/[orgId]/billing/route.ts b/app/api/organizations/[orgId]/billing/route.ts index e5a68384a..77430ee7a 100644 --- a/app/api/organizations/[orgId]/billing/route.ts +++ b/app/api/organizations/[orgId]/billing/route.ts @@ -8,6 +8,10 @@ * * Shape (consumed by `BillingPageClient.fetchBilling`): * { + * walletFrozen: boolean, + * walletFrozenReason: string | null, + * dunningSuspended: boolean, + * dunningSuspendedInvoiceNumber: string | null, * fundingSource: FundingSource | null, * monthToDate: { gross: number, paymentCount: number }, * outstanding: { amount: number, invoiceCount: number }, @@ -18,12 +22,20 @@ * `pendingCharges` is non-null only for INVOICE-funded orgs (where * Payment rows accrue with `billableToOrgInvoiceId = null` until the * monthly cron rolls them into an OrganizationInvoice). + * + * #1427/#1430 — `walletFrozen` and `dunningSuspended` were previously only + * ever read inside checkout's own block predicates, so an org hit a wall at + * the payment sheet with no earlier warning. They ride along here instead + * of a separate endpoint because the page already fetches this route on + * every billing-tab load. */ import { NextResponse, type NextRequest } from "next/server"; import prisma from "@/lib/prisma"; import { requireOrgAccess } from "@/lib/auth-helpers"; import { sumPaise } from "@/lib/payments/utils/money"; +import { isWalletFrozen } from "@/lib/payments/wallet-freeze"; +import { ENABLE_DUNNING_SUSPEND } from "@/lib/feature-flags"; export async function GET( _req: NextRequest, @@ -118,7 +130,34 @@ export async function GET( select: { paymentTermsDays: true }, }); + // #1427/#1430 — resolve the two silent-block states next to the account + // read the page already makes, so the client never has to special-case a + // failed checkout to learn about them. Freeze lives in SystemEvent (#837); + // dunning lives on the oldest OVERDUE invoice's stamp (#812, flag-gated). + const [walletFrozen, suspendingInvoice] = await Promise.all([ + billingAccount + ? isWalletFrozen(prisma, billingAccount.id) + : Promise.resolve(false), + ENABLE_DUNNING_SUSPEND + ? prisma.organizationInvoice.findFirst({ + where: { + organizationId: orgId, + status: "OVERDUE", + dunningSuspendedAt: { not: null }, + }, + select: { invoiceNumber: true }, + orderBy: { dueDate: "asc" }, + }) + : Promise.resolve(null), + ]); + return NextResponse.json({ + walletFrozen, + walletFrozenReason: walletFrozen + ? "Wallet spend is paused pending a balance-reconciliation review." + : null, + dunningSuspended: suspendingInvoice !== null, + dunningSuspendedInvoiceNumber: suspendingInvoice?.invoiceNumber ?? null, fundingSource: billingAccount?.fundingSource ?? null, // null = unlimited (#777 §B credit-limit visibility). creditLimitPaise: billingAccount?.creditLimit ?? null, diff --git a/app/api/organizations/[orgId]/checkout/consent-preview/route.ts b/app/api/organizations/[orgId]/checkout/consent-preview/route.ts new file mode 100644 index 000000000..1c02ee4a7 --- /dev/null +++ b/app/api/organizations/[orgId]/checkout/consent-preview/route.ts @@ -0,0 +1,28 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { requireOrgAccess } from "@/lib/auth-helpers"; +import { checkConsent } from "@/lib/compliance/dpdp"; +import { PURPOSE_CODES } from "@/lib/compliance/purpose-codes"; + +/** + * #1430 — read-only consent pre-flight for the org-funded booking surface. + * `handleCheckout` (lib/payments/operations/checkout.ts) already fails + * closed on a missing SESSION_BOOKING consent artifact for the caller; this + * route lets the payer selector warn about that BEFORE the member picks + * "Bill to org" and hits the wall at pay time. No transaction is open here, + * so the default `prisma` client `checkConsent` falls back to is fine. + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ orgId: string }> }, +) { + const { orgId } = await params; + const access = await requireOrgAccess(orgId, { canSponsor: true }); + if (access.error) return access.error; + + const hasConsent = await checkConsent({ + userId: access.session.user.id, + purposeCode: PURPOSE_CODES.SESSION_BOOKING, + }); + + return NextResponse.json({ hasConsent }); +} diff --git a/app/api/organizations/[orgId]/programs/[programId]/route.ts b/app/api/organizations/[orgId]/programs/[programId]/route.ts index 38fabe727..09ca584b4 100644 --- a/app/api/organizations/[orgId]/programs/[programId]/route.ts +++ b/app/api/organizations/[orgId]/programs/[programId]/route.ts @@ -16,6 +16,7 @@ import { requireOrgAccess } from "@/lib/auth-helpers"; import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; import { transitionProgram } from "@/lib/enterprise/transitions"; import { getProgramLockState } from "@/lib/enterprise/config-lock"; +import { overageBehaviorUnsupportedReason } from "@/lib/enterprise/reachable-paths"; import { withSerializableRetry } from "@/lib/db/serializable-retry"; const ProgramStatusSchema = z.enum([ @@ -154,7 +155,15 @@ async function applyProgramPatch( const touchesMoney = MONEY_FIELDS.some((f) => body[f] !== undefined); const current = await tx.program.findFirst({ where: { id: programId, contract: { organizationId: orgId } }, - include: { licensedSeatConfig: true, creditPoolConfig: true }, + include: { + licensedSeatConfig: true, + creditPoolConfig: true, + // #1458 — the funding source decides which overage behaviours can + // actually be collected, so the merged-config check below needs it. + contract: { + select: { billingAccount: { select: { fundingSource: true } } }, + }, + }, }); if (!current) { throw Object.assign(new Error("Program not found"), { @@ -235,6 +244,17 @@ async function applyProgramPatch( "overageSurchargeBps has no effect with overageBehavior=BLOCK — remove it or pick CHARGE_MEMBER/CHARGE_ORG.", ); } + // #1458 — same funding-source rule the create route applies, re-checked on + // the merged config so a patch cannot assemble a combination the create + // route would have refused. + const overageReason = overageBehaviorUnsupportedReason( + current.contract.billingAccount?.fundingSource ?? null, + merged.overageBehavior, + // #1458 — merged, so a patch that adds a surcharge to an already-saved + // wallet CHARGE_ORG programme is refused as readily as one that sets both. + merged.overageSurchargeBps, + ); + if (overageReason) fail(overageReason); } // #777 §B — archiving guard: an archived program is skipped by the cycle diff --git a/app/api/organizations/[orgId]/programs/route.ts b/app/api/organizations/[orgId]/programs/route.ts index 524110c3e..afac0219c 100644 --- a/app/api/organizations/[orgId]/programs/route.ts +++ b/app/api/organizations/[orgId]/programs/route.ts @@ -16,6 +16,7 @@ import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; import { capabilityOf, isReachableOrgFundingPath, + overageBehaviorUnsupportedReason, } from "@/lib/enterprise/reachable-paths"; import { sumPaise } from "@/lib/payments/utils/money"; @@ -315,6 +316,29 @@ export async function POST( ); } + // #1458 — the matrix above sanctions the funding shape but says nothing about + // what happens past the cap. CHARGE_MEMBER on a wallet-funded contract only + // failed at checkout, inside the booking transaction, so the refusal landed on + // a member who had already picked a slot. Refuse it here instead. + const overageConfig = + body.type === "LICENSED_SEAT" + ? body.licensedSeatConfig + : body.creditPoolConfig; + const overageReason = overageBehaviorUnsupportedReason( + fundingSource, + overageConfig.overageBehavior, + // #1458 — the surcharge is part of the rule, not a separate knob: CHARGE_ORG + // is collectable on a wallet debit only while the marginal stays inside the + // price that debit took. + overageConfig.overageSurchargeBps, + ); + if (overageReason) { + return NextResponse.json( + { error: overageReason, code: "INVALID_OVERAGE_CONFIG" }, + { status: 400 }, + ); + } + // #751 — two ACTIVE programs on the same contract with intersecting // coveredPlanTypes make checkout's program resolution ambiguous (the // booking lands on whichever resolves first) and can double-entitle a diff --git a/app/api/organizations/[orgId]/rate-cards/route.ts b/app/api/organizations/[orgId]/rate-cards/route.ts index 97846f196..5083ec0cf 100644 --- a/app/api/organizations/[orgId]/rate-cards/route.ts +++ b/app/api/organizations/[orgId]/rate-cards/route.ts @@ -11,6 +11,10 @@ * now()`. This preserves the historical split each earning was settled * against — see `OrganizationEarnings.platformBpsApplied`. * + * #1335 — a card scoped to a contract, planType or planId is only selected at + * settlement when `RATE_CARD_SCOPED_RESOLUTION=on`; off (the default), the org + * default card settles instead. Creation is unaffected either way. + * * Query params on GET: * scope=current|all (default current — live cards only) * planType=CONSULTATION|CLASS|WEBINAR|SUBSCRIPTION @@ -21,7 +25,9 @@ import * as Sentry from "@sentry/nextjs"; import { NextResponse, type NextRequest } from "next/server"; import { z } from "zod"; +import { Prisma } from "@prisma/client"; import prisma from "@/lib/prisma"; +import { withSerializableRetry } from "@/lib/db/serializable-retry"; import { requireOrgAccess } from "@/lib/auth-helpers"; // Why: rate card creation/edit is a finance-team mutation; downgrade // from OWNER-only so BILLING_ADMIN can configure splits without escalation. @@ -140,73 +146,105 @@ export async function POST( const body = parsed.data; try { - const card = await prisma.$transaction(async (tx) => { - // Cross-org check: contract must belong to this org when scoping - // the card to a contract. - if (body.contractId) { - const contract = await tx.contract.findFirst({ - where: { id: body.contractId, organizationId: orgId }, - select: { id: true }, - }); - if (!contract) { - throw Object.assign( - new Error("Contract not found for this organization"), - { httpStatus: 404 }, - ); - } - } - - const created = await bumpRateCard(tx, { - scope: body.contractId - ? { ownerContractId: body.contractId } - : { ownerOrgId: orgId }, - planType: body.planType ?? null, - planId: body.planId ?? null, - next: { - platformBps: body.platformBps, - orgBps: body.orgBps, - consultantBps: body.consultantBps, - }, - minGrossPaise: body.minGrossPaise, - maxGrossPaise: body.maxGrossPaise, - effectiveAt: body.effectiveAt, - reason: body.reason, - }); - - await tx.orgAuditLog.create({ - data: { - organizationId: orgId, - actorMembershipId: access.member.id, - category: "PROGRAM", - action: AUDIT_ACTIONS.PROGRAM.RATE_CARD_BUMPED, - description: body.contractId - ? `Rate card bumped for contract ${body.contractId}` - : `Org-default rate card bumped`, - details: { - rateCardId: created.id, - contractId: body.contractId ?? null, + // #1405 — bumpRateCard is read-then-write (findEffective → close the open + // card → insert the replacement). Under the default isolation two + // concurrent bumps on one scope each read "no card open yet" and each + // insert with `effectiveTo = null`, after which `findEffective` picks + // between the two open windows non-deterministically. Serializable makes + // that interleaving abort, and withSerializableRetry re-runs the loser + // the way checkout does rather than surfacing P2034 to the caller. + const card = await withSerializableRetry(() => + prisma.$transaction( + async (tx) => { + // Cross-org check: contract must belong to this org when scoping + // the card to a contract. + if (body.contractId) { + const contract = await tx.contract.findFirst({ + where: { id: body.contractId, organizationId: orgId }, + select: { id: true }, + }); + if (!contract) { + throw Object.assign( + new Error("Contract not found for this organization"), + { httpStatus: 404 }, + ); + } + } + + const created = await bumpRateCard(tx, { + scope: body.contractId + ? { ownerContractId: body.contractId } + : { ownerOrgId: orgId }, planType: body.planType ?? null, planId: body.planId ?? null, - platformBps: body.platformBps, - orgBps: body.orgBps, - consultantBps: body.consultantBps, - effectiveFrom: created.effectiveFrom, - reason: body.reason ?? null, - }, + next: { + platformBps: body.platformBps, + orgBps: body.orgBps, + consultantBps: body.consultantBps, + }, + minGrossPaise: body.minGrossPaise, + maxGrossPaise: body.maxGrossPaise, + effectiveAt: body.effectiveAt, + reason: body.reason, + }); + + await tx.orgAuditLog.create({ + data: { + organizationId: orgId, + actorMembershipId: access.member.id, + category: "PROGRAM", + action: AUDIT_ACTIONS.PROGRAM.RATE_CARD_BUMPED, + description: body.contractId + ? `Rate card bumped for contract ${body.contractId}` + : `Org-default rate card bumped`, + details: { + rateCardId: created.id, + contractId: body.contractId ?? null, + planType: body.planType ?? null, + planId: body.planId ?? null, + platformBps: body.platformBps, + orgBps: body.orgBps, + consultantBps: body.consultantBps, + effectiveFrom: created.effectiveFrom, + reason: body.reason ?? null, + }, + }, + }); + + return created; }, - }); - - return created; - }); + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + ); return NextResponse.json({ rateCard: card }, { status: 201 }); } catch (err) { if (err instanceof Error && "httpStatus" in err) { - const status = - typeof err.httpStatus === "number" ? err.httpStatus : 500; + const status = typeof err.httpStatus === "number" ? err.httpStatus : 500; return NextResponse.json({ error: err.message }, { status }); } - Sentry.captureException(err instanceof Error ? err : new Error(String(err)), { tags: { subsystem: "enterprise" } }); + // #1405 — the `rate_card_one_open_window` partial unique is the structural + // half of the fix: it refuses a second open window on the same scope even + // if Serializable is unavailable or the write arrives from somewhere else. + // A caller that loses that race is not a server fault, so answer 409 and + // let them re-read the current card and retry. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2002" + ) { + return NextResponse.json( + { + error: + "This rate-card scope already has an open window; re-read the current card and retry", + code: "RATE_CARD_OPEN_WINDOW_CONFLICT", + }, + { status: 409 }, + ); + } + Sentry.captureException( + err instanceof Error ? err : new Error(String(err)), + { tags: { subsystem: "enterprise" } }, + ); throw err; } } diff --git a/app/api/organizations/route.ts b/app/api/organizations/route.ts index 6dcbb8ca9..fbe511ece 100644 --- a/app/api/organizations/route.ts +++ b/app/api/organizations/route.ts @@ -41,7 +41,12 @@ const FundingSourceSchema = z.enum([ "WALLET", "INVOICE", ]); -const CurrencySchema = z.enum(["INR", "USD", "EUR", "GBP"]); +// #1396 — the `Currency` enum stays on the column (ADR 15 keeps the type), but +// this API refuses to write anything except INR. `BillingAccount.currency` is +// forwarded verbatim into `createRazorpayOrder` by the wallet top-up route, and +// every amount the platform stores is INR paise, so a USD account priced a +// ₹1,000 top-up as a $1,000 order. +const CurrencySchema = z.literal("INR"); const DataRegionSchema = z.enum(["IN", "US", "EU"]); const SizeBucketSchema = z.enum([ "SMALL_1_50", diff --git a/app/api/payments/[paymentId]/credit-note/[creditNoteId]/pdf/route.ts b/app/api/payments/[paymentId]/credit-note/[creditNoteId]/pdf/route.ts new file mode 100644 index 000000000..f91f87795 --- /dev/null +++ b/app/api/payments/[paymentId]/credit-note/[creditNoteId]/pdf/route.ts @@ -0,0 +1,113 @@ +/** + * GET /api/payments/[paymentId]/credit-note/[creditNoteId]/pdf — #1365 + * + * The buyer's copy of the s.34 credit note that reverses their tax invoice + * after a refund or a lost chargeback. It shares `serveConsumerPdf` with the + * invoice route, so the two documents are served under identical auth, rate + * limiting, supplier-gate and caching rules by construction. + * + * The credit note is nested under the payment so it cannot be enumerated by id + * alone — the note must belong to that payment's invoice. + */ + +import { type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { + renderConsumerCreditNotePdf, + type ConsumerCreditNotePdfData, +} from "@/lib/pdf/credit-note-renderer"; +import { serveConsumerPdf } from "@/lib/pdf/serve-consumer-pdf"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ paymentId: string; creditNoteId: string }> }, +) { + const { paymentId, creditNoteId } = await params; + + return serveConsumerPdf({ + event: "consumer_credit_note_pdf_render_failed", + logContext: { paymentId, creditNoteId }, + failureMessage: "Failed to generate the credit note PDF", + load: async () => { + const creditNote = await prisma.consumerCreditNote.findFirst({ + where: { id: creditNoteId, consumerInvoice: { paymentId } }, + select: { + id: true, + creditNoteNumber: true, + issuedAt: true, + reason: true, + taxableValuePaise: true, + cgstPaise: true, + sgstPaise: true, + igstPaise: true, + totalPaise: true, + pdfStoragePath: true, + pdfGeneratedAt: true, + consumerInvoice: { + select: { + userId: true, + invoiceNumber: true, + issuedAt: true, + currency: true, + sacCode: true, + taxRateBps: true, + placeOfSupply: true, + supplierName: true, + supplierGstin: true, + supplierAddress: true, + supplierStateCode: true, + buyerName: true, + buyerEmail: true, + buyerAddress: true, + buyerStateCode: true, + }, + }, + }, + }); + return creditNote + ? { ...creditNote, ownerUserId: creditNote.consumerInvoice.userId } + : null; + }, + render: (creditNote) => { + const invoice = creditNote.consumerInvoice; + const data: ConsumerCreditNotePdfData = { + creditNoteNumber: creditNote.creditNoteNumber, + issuedAt: creditNote.issuedAt, + reason: creditNote.reason, + currency: invoice.currency, + taxableValuePaise: creditNote.taxableValuePaise, + cgstPaise: creditNote.cgstPaise, + sgstPaise: creditNote.sgstPaise, + igstPaise: creditNote.igstPaise, + totalPaise: creditNote.totalPaise, + originalInvoiceNumber: invoice.invoiceNumber, + originalInvoiceDate: invoice.issuedAt, + placeOfSupply: invoice.placeOfSupply, + sacCode: invoice.sacCode, + // The rate comes off the ORIGINAL invoice, never a current constant: a + // note issued after a rate change still reverses the rate that was + // charged. + taxRateBps: invoice.taxRateBps, + supplier: { + name: invoice.supplierName, + gstin: invoice.supplierGstin, + address: invoice.supplierAddress, + stateCode: invoice.supplierStateCode, + }, + buyer: { + name: invoice.buyerName, + email: invoice.buyerEmail, + address: invoice.buyerAddress, + stateCode: invoice.buyerStateCode, + }, + }; + return renderConsumerCreditNotePdf(data); + }, + stamp: async ({ id, pdfStoragePath, pdfGeneratedAt }) => { + await prisma.consumerCreditNote.update({ + where: { id }, + data: { pdfStoragePath, pdfGeneratedAt }, + }); + }, + }); +} diff --git a/app/api/payments/[paymentId]/invoice/pdf/route.ts b/app/api/payments/[paymentId]/invoice/pdf/route.ts new file mode 100644 index 000000000..404d7fdc5 --- /dev/null +++ b/app/api/payments/[paymentId]/invoice/pdf/route.ts @@ -0,0 +1,106 @@ +/** + * GET /api/payments/[paymentId]/invoice/pdf — #1365 + * + * The buyer's copy of their B2C tax invoice. Auth, rate limiting, the + * fail-closed supplier gate, the 24-hour render cache and the redirect to a + * signed URL all live in `serveConsumerPdf`, which the credit-note route + * shares; this file only says which row to load, how to render it, and where + * to stamp the cache. + * + * Access is the payment's own buyer, or an ADMIN/STAFF operator handling a + * support request. There is no org membership to lean on here, so ownership is + * the whole rule. + */ + +import { type NextRequest } from "next/server"; +import prisma from "@/lib/prisma"; +import { + renderConsumerInvoicePdf, + type ConsumerInvoicePdfData, +} from "@/lib/pdf/invoice-renderer"; +import { serveConsumerPdf } from "@/lib/pdf/serve-consumer-pdf"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ paymentId: string }> }, +) { + const { paymentId } = await params; + + return serveConsumerPdf({ + event: "consumer_invoice_pdf_render_failed", + logContext: { paymentId }, + failureMessage: "Failed to generate the tax invoice PDF", + load: async () => { + const invoice = await prisma.consumerInvoice.findUnique({ + where: { paymentId }, + select: { + id: true, + userId: true, + invoiceNumber: true, + issuedAt: true, + supplyDate: true, + currency: true, + sacCode: true, + taxRateBps: true, + taxableValuePaise: true, + cgstPaise: true, + sgstPaise: true, + igstPaise: true, + totalPaise: true, + placeOfSupply: true, + placeOfSupplySource: true, + supplierName: true, + supplierGstin: true, + supplierAddress: true, + supplierStateCode: true, + buyerName: true, + buyerEmail: true, + buyerAddress: true, + buyerStateCode: true, + pdfStoragePath: true, + pdfGeneratedAt: true, + }, + }); + return invoice ? { ...invoice, ownerUserId: invoice.userId } : null; + }, + render: (invoice) => { + // Rendered from the row's own stored snapshot, not from live supplier or + // buyer records — a tax invoice must keep saying what it said on the day + // it was issued. + const data: ConsumerInvoicePdfData = { + invoiceNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt, + supplyDate: invoice.supplyDate, + currency: invoice.currency, + sacCode: invoice.sacCode, + taxRateBps: invoice.taxRateBps, + taxableValuePaise: invoice.taxableValuePaise, + cgstPaise: invoice.cgstPaise, + sgstPaise: invoice.sgstPaise, + igstPaise: invoice.igstPaise, + totalPaise: invoice.totalPaise, + placeOfSupply: invoice.placeOfSupply, + placeOfSupplySource: invoice.placeOfSupplySource, + supplier: { + name: invoice.supplierName, + gstin: invoice.supplierGstin, + address: invoice.supplierAddress, + stateCode: invoice.supplierStateCode, + }, + buyer: { + name: invoice.buyerName, + email: invoice.buyerEmail, + address: invoice.buyerAddress, + stateCode: invoice.buyerStateCode, + }, + }; + return renderConsumerInvoicePdf(data); + }, + stamp: async ({ id, pdfStoragePath, pdfGeneratedAt }) => { + await prisma.consumerInvoice.update({ + where: { id }, + data: { pdfStoragePath, pdfGeneratedAt }, + }); + }, + }); +} diff --git a/app/api/slots/request-for-approval/route.ts b/app/api/slots/request-for-approval/route.ts index 7f15f3e67..07b10edc6 100644 --- a/app/api/slots/request-for-approval/route.ts +++ b/app/api/slots/request-for-approval/route.ts @@ -11,11 +11,10 @@ import { } from "@/utils/appointmentlock"; import { SlotLockError } from "@/utils/errors/SlotLockError"; import { SlotValidationService } from "@/utils/slotAllocation/SlotValidationService"; -import { - notifyNewBookingRequest, -} from "@/lib/novu"; +import { notifyNewBookingRequest } from "@/lib/novu"; import { notificationScope } from "@/lib/novu/workflows"; import { scopedHref } from "@/lib/novu/resolve-href"; +import { appendCreationHistory } from "@/lib/booking/transitions"; import { RequestForApprovalSchema } from "@/schemas/slots"; import { requestApprovalLimiter, applyRateLimit } from "@/lib/rate-limit"; import { ensureConsulteeProfile } from "@/lib/profiles/ensure-consultee-profile"; @@ -155,9 +154,8 @@ export async function POST(req: NextRequest) { // without it the same user could race this route against their own // checkout on a DIFFERENT consultant and double-book themselves — the // GiST guard is consultant-keyed and cannot see it. - let consulteeLock: Awaited< - ReturnType - > | null = null; + let consulteeLock: Awaited> | null = + null; let lock; try { @@ -190,226 +188,251 @@ export async function POST(req: NextRequest) { // Use default 60s TTL (15s was too short for slow database operations) lock = await lockSlotBooking(consultantProfileId, startsAt, endsAt); - console.log( - JSON.stringify({ - event: "slot_booking_lock_acquired", - consultant: consultantProfileId, - slot: startsAt, - user: session.user.id, - timestamp: new Date().toISOString(), - }), - ); + console.log( + JSON.stringify({ + event: "slot_booking_lock_acquired", + consultant: consultantProfileId, + slot: startsAt, + user: session.user.id, + timestamp: new Date().toISOString(), + }), + ); - // Generate 30-minute slot chunks from startTime to endTime. - // SlotOfAppointment records are always 30 minutes each — consistent with - // manual and auto allocation paths in SlotAllocationService. - const SLOT_DURATION_MS = 30 * 60 * 1000; - const slotChunkStarts: Date[] = []; - let current = new Date(startTime); - while (current < endTime) { - slotChunkStarts.push(new Date(current)); - current = new Date(current.getTime() + SLOT_DURATION_MS); - } - if (slotChunkStarts.length === 0) { - return NextResponse.json( - { error: "Invalid slot: start time must be before end time" }, - { status: 400 }, + // Generate 30-minute slot chunks from startTime to endTime. + // SlotOfAppointment records are always 30 minutes each — consistent with + // manual and auto allocation paths in SlotAllocationService. + const SLOT_DURATION_MS = 30 * 60 * 1000; + const slotChunkStarts: Date[] = []; + let current = new Date(startTime); + while (current < endTime) { + slotChunkStarts.push(new Date(current)); + current = new Date(current.getTime() + SLOT_DURATION_MS); + } + if (slotChunkStarts.length === 0) { + return NextResponse.json( + { error: "Invalid slot: start time must be before end time" }, + { status: 400 }, + ); + } + + // RE-VALIDATE inside lock: Ensure ALL 30-min chunks are still available + // This is the critical missing piece - prevents double-booking even after lock + const validationService = new SlotValidationService(prisma); + const validation = await validationService.checkSlotAvailability( + slotChunkStarts, + consultationPlan.consultantProfile.user.id, ); - } - // RE-VALIDATE inside lock: Ensure ALL 30-min chunks are still available - // This is the critical missing piece - prevents double-booking even after lock - const validationService = new SlotValidationService(prisma); - const validation = await validationService.checkSlotAvailability( - slotChunkStarts, - consultationPlan.consultantProfile.user.id, - ); + if (!validation.isValid) { + console.log( + JSON.stringify({ + event: "slot_booking_validation_failed", + consultant: consultantProfileId, + slot: startsAt, + user: session.user.id, + errors: validation.errors, + timestamp: new Date().toISOString(), + }), + ); + + return NextResponse.json( + { + error: "Slot no longer available", + details: validation.errors, + }, + { status: 409 }, + ); + } - if (!validation.isValid) { console.log( JSON.stringify({ - event: "slot_booking_validation_failed", + event: "slot_booking_validation_passed", consultant: consultantProfileId, slot: startsAt, user: session.user.id, - errors: validation.errors, timestamp: new Date().toISOString(), }), ); - return NextResponse.json( - { - error: "Slot no longer available", - details: validation.errors, + // CRITICAL SECTION: Create consultation (protected by lock AND validated) + // Create one SlotOfAppointment per 30-min chunk — consistent with + // SlotAllocationService which also uses 30-min granularity. + const slotChunksToCreate = slotChunkStarts.map((chunkStart) => ({ + startsAt: chunkStart, + endsAt: new Date(chunkStart.getTime() + SLOT_DURATION_MS), + isTentative: true, // Mark as tentative since it's pending approval + // #440 — the overlap-guard column must be set at CREATE time even on + // tentative rows: approval/webhook confirm flips isTentative via + // updateMany, so whatever is on the row rides into confirmed state. + consultantProfileId, + user: { + connect: [ + { id: session.user.id }, // Consultee + { id: consultationPlan.consultantProfile.user.id }, // Consultant + ], }, - { status: 409 }, - ); - } - - console.log( - JSON.stringify({ - event: "slot_booking_validation_passed", - consultant: consultantProfileId, - slot: startsAt, - user: session.user.id, - timestamp: new Date().toISOString(), - }), - ); + })); - // CRITICAL SECTION: Create consultation (protected by lock AND validated) - // Create one SlotOfAppointment per 30-min chunk — consistent with - // SlotAllocationService which also uses 30-min granularity. - const slotChunksToCreate = slotChunkStarts.map((chunkStart) => ({ - startsAt: chunkStart, - endsAt: new Date(chunkStart.getTime() + SLOT_DURATION_MS), - isTentative: true, // Mark as tentative since it's pending approval - // #440 — the overlap-guard column must be set at CREATE time even on - // tentative rows: approval/webhook confirm flips isTentative via - // updateMany, so whatever is on the row rides into confirmed state. - consultantProfileId, - user: { - connect: [ - { id: session.user.id }, // Consultee - { id: consultationPlan.consultantProfile.user.id }, // Consultant - ], - }, - })); - - const consultation = await prisma.consultation.create({ - data: { - consultationPlanId: consultationPlanId, - requestedById: consulteeProfile.id, - status: AppointmentStatus.PENDING, - requestNotes: requestNotes, - appointment: { - create: { - appointmentType: "CONSULTATION", - // #1166 ORG-9 — org attribution rides the appointment from the - // moment the request exists. - organizationId: organizationId ?? null, - slotsOfAppointment: { - create: slotChunksToCreate, + // #1333 — the request and its opening timeline row commit together, so + // a booking that exists is never one the staff timeline has nothing to + // say about. The nested create was already atomic on its own; the + // transaction is what extends that atomicity to the audit row. The + // budget sits well inside the 60 s slot lock held above. + const consultation = await prisma.$transaction( + async (tx) => { + const created = await tx.consultation.create({ + data: { + consultationPlanId: consultationPlanId, + requestedById: consulteeProfile.id, + status: AppointmentStatus.PENDING, + requestNotes: requestNotes, + appointment: { + create: { + appointmentType: "CONSULTATION", + // #1166 ORG-9 — org attribution rides the appointment from + // the moment the request exists. + organizationId: organizationId ?? null, + slotsOfAppointment: { + create: slotChunksToCreate, + }, + }, + }, }, - }, - }, - }, - include: { - consultationPlan: { - include: { - consultantProfile: { - include: { - user: true, + include: { + consultationPlan: { + include: { + consultantProfile: { + include: { + user: true, + }, + }, + }, + }, + requestedBy: { + include: { + user: true, + }, + }, + appointment: { + include: { + slotsOfAppointment: true, + }, }, }, - }, - }, - requestedBy: { - include: { - user: true, - }, - }, - appointment: { - include: { - slotsOfAppointment: true, - }, + }); + await appendCreationHistory( + tx, + "CONSULTATION", + created.id, + AppointmentStatus.PENDING, + { + appointmentId: created.appointment?.id ?? null, + actorUserId: session.user.id, + organizationId: created.appointment?.organizationId ?? null, + }, + ); + return created; }, - }, - }); - - console.log( - JSON.stringify({ - event: "slot_booking_success", - consultationId: consultation.id, - consultant: consultantProfileId, - slot: startsAt, - user: session.user.id, - timestamp: new Date().toISOString(), - }), - ); + { maxWait: 10_000, timeout: 15_000 }, + ); - // Fire-and-forget: notify consultant of new booking request. - // - // ADR 23 — the link used to hardcode the personal Requests page even for - // an org-hosted plan, where the request is not listed: the personal scope - // pins organizationId: null. Single recipient with a known side, so this - // resolves to a precise route rather than the /dashboard bounce. - const requestOrgId = consultation.appointment?.organizationId ?? null; - void notifyNewBookingRequest( - consultation.consultationPlan.consultantProfile.user.id, - { - ...notificationScope(requestOrgId), - consulteeName: consultation.requestedBy.user.name || "A consultee", - planTitle: consultation.consultationPlan.title, - appointmentType: "CONSULTATION", - requestedDateTime: startTime.toISOString(), - dashboardUrl: scopedHref({ - organizationId: requestOrgId, - surface: "requests", - personal: { - kind: "consultant", - profileId: consultation.consultationPlan.consultantProfile.id, - }, + console.log( + JSON.stringify({ + event: "slot_booking_success", + consultationId: consultation.id, + consultant: consultantProfileId, + slot: startsAt, + user: session.user.id, + timestamp: new Date().toISOString(), }), - }, - ); - - return NextResponse.json( - { - message: "Request for approval submitted successfully", - data: consultation, - }, - { status: 201 }, - ); - } catch (lockError) { - console.error( - JSON.stringify({ - event: "slot_booking_error", - consultant: consultantProfileId, - slot: startsAt, - user: session.user.id, - error: - lockError instanceof Error ? lockError.message : "Unknown error", - timestamp: new Date().toISOString(), - }), - ); + ); - // #1169 PR 1 — Redis-down fails closed with a structured 503; without - // this branch the outage fell through to the generic 500 below. - if (lockError instanceof BookingLockUnavailableError) { - return NextResponse.json( - { error: lockError.message }, - { status: lockError.httpStatus }, + // Fire-and-forget: notify consultant of new booking request. + // + // ADR 23 — the link used to hardcode the personal Requests page even for + // an org-hosted plan, where the request is not listed: the personal scope + // pins organizationId: null. Single recipient with a known side, so this + // resolves to a precise route rather than the /dashboard bounce. + const requestOrgId = consultation.appointment?.organizationId ?? null; + void notifyNewBookingRequest( + consultation.consultationPlan.consultantProfile.user.id, + { + ...notificationScope(requestOrgId), + consulteeName: consultation.requestedBy.user.name || "A consultee", + planTitle: consultation.consultationPlan.title, + appointmentType: "CONSULTATION", + requestedDateTime: startTime.toISOString(), + dashboardUrl: scopedHref({ + organizationId: requestOrgId, + surface: "requests", + personal: { + kind: "consultant", + profileId: consultation.consultationPlan.consultantProfile.id, + }, + }), + }, ); - } - // Check if error is lock acquisition failure (type-safe) - if (lockError instanceof SlotLockError) { return NextResponse.json( { - error: lockError.message, - retryAfter: lockError.retryAfterSeconds, + message: "Request for approval submitted successfully", + data: consultation, }, - { status: 409 }, // 409 Conflict + { status: 201 }, ); - } - - Sentry.captureException(lockError instanceof Error ? lockError : new Error(String(lockError)), { tags: { subsystem: "scheduling" } }); - throw lockError; // Re-throw other errors for general error handler - } finally { - // ALWAYS release lock (even on error) - if (lock) { - await unlockSlotBooking(lock); - console.log( + } catch (lockError) { + console.error( JSON.stringify({ - event: "slot_booking_lock_released", + event: "slot_booking_error", consultant: consultantProfileId, slot: startsAt, user: session.user.id, + error: + lockError instanceof Error ? lockError.message : "Unknown error", timestamp: new Date().toISOString(), }), ); + + // #1169 PR 1 — Redis-down fails closed with a structured 503; without + // this branch the outage fell through to the generic 500 below. + if (lockError instanceof BookingLockUnavailableError) { + return NextResponse.json( + { error: lockError.message }, + { status: lockError.httpStatus }, + ); + } + + // Check if error is lock acquisition failure (type-safe) + if (lockError instanceof SlotLockError) { + return NextResponse.json( + { + error: lockError.message, + retryAfter: lockError.retryAfterSeconds, + }, + { status: 409 }, // 409 Conflict + ); + } + + Sentry.captureException( + lockError instanceof Error ? lockError : new Error(String(lockError)), + { tags: { subsystem: "scheduling" } }, + ); + throw lockError; // Re-throw other errors for general error handler + } finally { + // ALWAYS release lock (even on error) + if (lock) { + await unlockSlotBooking(lock); + console.log( + JSON.stringify({ + event: "slot_booking_lock_released", + consultant: consultantProfileId, + slot: startsAt, + user: session.user.id, + timestamp: new Date().toISOString(), + }), + ); + } } - } } finally { // Release the consultee arm last (reverse acquisition order). if (consulteeLock) { @@ -442,7 +465,10 @@ export async function POST(req: NextRequest) { } console.error("Error creating approval request:", error); - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "scheduling" } }); + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { tags: { subsystem: "scheduling" } }, + ); return NextResponse.json( { error: "An error occurred while creating the approval request" }, { status: 500 }, diff --git a/app/api/webhooks/razorpay-dispatch.ts b/app/api/webhooks/razorpay-dispatch.ts index 4e96a0065..fb1a5c888 100644 --- a/app/api/webhooks/razorpay-dispatch.ts +++ b/app/api/webhooks/razorpay-dispatch.ts @@ -37,6 +37,7 @@ import { type RazorpayWebhookEnvelope, } from "@/schemas/webhooks/razorpay"; import { getRazorpayClient } from "@/lib/payments/core/razorpay"; +import prisma from "@/lib/prisma"; import { z } from "zod"; // Strict inner-entity schemas used to narrow optional envelope fields at the @@ -129,7 +130,10 @@ export async function routeCapturedPayment(params: { await handleRecordingPurchaseSuccess(orderId, gatewayPaymentId); return; } - await handlePaymentSuccess(orderId, notes, amountPaise); + // #1353 — the B2C pipeline persists the `pay_…` id on the Payment row it is + // already the single writer of, so later refund and dispute webhooks (which + // carry only that id) can find the row without a live gateway lookup. + await handlePaymentSuccess(orderId, notes, amountPaise, gatewayPaymentId); } /** @@ -221,9 +225,23 @@ export async function processRazorpayWebhookEvent( ); let paymentIntentId = refundEvent.payment_id; + // #1353 — ask our own database first. The capture pipeline persists the + // `pay_…` id on the Payment row, so the order id this refund needs is + // almost always one indexed read away; the gateway call below is now a + // fallback for pre-#1353 rows and for captures that never ran through + // the pipeline, not the only path. That matters because when the API + // call failed we used to continue with the `pay_…` id, which nothing + // downstream could match — the refund deferred for up to a week. + const knownPayment = await prisma.payment.findFirst({ + where: { gatewayPaymentId: refundEvent.payment_id }, + select: { paymentIntent: true }, + }); + const razorpayClient = knownPayment ? null : getRazorpayClient(); + if (knownPayment) { + paymentIntentId = knownPayment.paymentIntent; + } // Only the refund family resolves payment_id → order_id via the SDK; // other event branches must not construct a client. - const razorpayClient = getRazorpayClient(); if (razorpayClient) { try { const rzpPayment = await razorpayClient.payments.fetch( @@ -274,7 +292,16 @@ export async function processRazorpayWebhookEvent( ); let failedPaymentIntentId = failedRefundEvent.payment_id; - const razorpayClient = getRazorpayClient(); + // #1353 — same order as the created/processed branch: our own row + // first, the gateway API only when we have never seen this capture. + const knownFailedPayment = await prisma.payment.findFirst({ + where: { gatewayPaymentId: failedRefundEvent.payment_id }, + select: { paymentIntent: true }, + }); + const razorpayClient = knownFailedPayment ? null : getRazorpayClient(); + if (knownFailedPayment) { + failedPaymentIntentId = knownFailedPayment.paymentIntent; + } if (razorpayClient) { try { const rzpPayment = await razorpayClient.payments.fetch( @@ -439,7 +466,20 @@ export async function processRazorpayWebhookEvent( } finally { // #813/#812 — on a defer, leave the row processed=false/error=null so the // stuck-event sweeper re-drives it once the awaited payment lands. - if (!deferred) { + if (deferred) { + // #1356 6.2 — that "leave it alone" is deliberately indistinguishable + // from "crashed before recording anything", which is exactly why a + // permanently-deferring event stayed invisible until the 168h give-up cap + // fired. Counting the deferrals is the only mark this path leaves, and it + // is what the sweeper alerts on. updateMany, not update, so a row that + // was archived between dispatch and here cannot throw inside a `finally`. + await prisma.webhookEvent + .updateMany({ + where: { eventId }, + data: { deferCount: { increment: 1 } }, + }) + .catch(() => {}); + } else { await markWebhookEventProcessed(eventId, processingError); } } diff --git a/app/api/webhooks/razorpay/route.ts b/app/api/webhooks/razorpay/route.ts index ca89d7837..62c6a2380 100644 --- a/app/api/webhooks/razorpay/route.ts +++ b/app/api/webhooks/razorpay/route.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import { after } from "next/server"; import crypto from "node:crypto"; -import { verifyWebhookSignature, logWebhookEvent, isDbHealthy } from "../utils"; +import { logWebhookEvent, isDbHealthy } from "../utils"; import { recordSystemEvent } from "@/lib/enterprise/system-events"; import { razorpayWebhookEnvelopeSchema, @@ -12,11 +12,88 @@ import { // stuck-webhook sweeper (jobs/cleanup/sweep-stuck-webhook-events) can replay // crashed events through the exact same handler routing. import { processRazorpayWebhookEvent } from "../razorpay-dispatch"; +import { + isPayoutEventName, + matchRazorpayWebhookSecret, + resolveRazorpayPaymentSecrets, + verifyRazorpaySignature, +} from "./signature"; + +// #1377 — signature verification needs `node:crypto`, which the edge runtime +// does not provide. Node is already the App Router default for route handlers; +// pinning it here means a future project-wide default flip cannot silently +// break every inbound payment confirmation. +export const runtime = "nodejs"; + +/** + * #1459 — a Razorpay event payload is a few kilobytes; the largest we have seen + * is well under a hundredth of this. Anything bigger is not a delivery we have + * to serve, and reading it into a buffer to HMAC it is work an unauthenticated + * caller gets to make us do. The refusal is the first thing the handler does, + * so an oversized body never reaches the signature read and never writes a + * webhook-inbox row. + */ +const MAX_WEBHOOK_BODY_BYTES = 256 * 1024; + +/** + * #1459 — Content-Length is optional and set by the caller, so the header check + * alone is a cap only a well-behaved sender honours: omit it, or send chunked, + * and `req.text()` would buffer whatever arrives. Counting the bytes as they + * stream in and abandoning the read the moment the cap is passed is what makes + * the limit hold against the caller it was written for. The whole body is + * decoded in one pass at the end, because a multi-byte character split across + * two chunks must not be decoded twice — the HMAC covers these exact bytes. + * + * @returns The raw body, or `null` when the request exceeded the cap. + */ +async function readBodyWithinCap(req: NextRequest): Promise { + const stream = req.body; + // No stream means there is no body to bound; `text()` yields "" and the + // signature check below rejects it. + if (!stream) return req.text(); + + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_WEBHOOK_BODY_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return new TextDecoder().decode(Buffer.concat(chunks)); +} export async function POST(req: NextRequest) { - const secret = process.env.RAZORPAY_WEBHOOK_SECRET; + // Content-Length is what a refusal can be based on before a single byte is + // read, so an honest oversized delivery costs us nothing at all. A caller + // that omits or understates it is caught by readBodyWithinCap instead. + const declaredBytes = Number(req.headers.get("content-length")); + if ( + Number.isFinite(declaredBytes) && + declaredBytes > MAX_WEBHOOK_BODY_BYTES + ) { + console.warn( + `Rejected oversized Razorpay webhook body: ${declaredBytes} bytes`, + ); + return NextResponse.json({ error: "Payload too large" }, { status: 413 }); + } + Sentry.setTag("subsystem", "payments"); - if (!secret) { + + // #1377 — the payment-side secrets, current first and (only during a + // rotation) the previous one. See resolveRazorpayPaymentSecrets for why the + // grace window exists: a hard cutover loses events permanently. + const paymentSecrets = resolveRazorpayPaymentSecrets(); + if (paymentSecrets.length === 0) { console.error("RAZORPAY_WEBHOOK_SECRET not configured"); return NextResponse.json( { error: "Webhook secret not configured" }, @@ -29,74 +106,67 @@ export async function POST(req: NextRequest) { // is configured, re-verify with it (for payout.* events). const razorpayXSecret = process.env.RAZORPAYX_WEBHOOK_SECRET; - const { isValid, body } = await verifyWebhookSignature( - req, - secret, - "razorpay", - ); + const signature = req.headers.get("x-razorpay-signature"); + // The HMAC covers the RAW bytes. Read them once here and hand the same + // string to every verification attempt — parsing and re-serialising would + // reorder keys and break the digest. + const body = signature ? await readBodyWithinCap(req) : ""; + if (body === null) { + console.warn( + `Rejected oversized Razorpay webhook body: over ${MAX_WEBHOOK_BODY_BYTES} bytes`, + ); + return NextResponse.json({ error: "Payload too large" }, { status: 413 }); + } + + const matchedRole = signature + ? matchRazorpayWebhookSecret(body, signature, paymentSecrets) + : null; - if (!isValid) { + if (matchedRole === "previous") { + // The rotation grace is meant to be short. Every delivery that only the + // OLD secret can verify is reported so a variable left behind after the + // cutover shows up in the operations timeline instead of quietly + // extending the window forever. + await recordSystemEvent({ + category: "WEBHOOK", + severity: "WARN", + message: + "Razorpay webhook verified with RAZORPAY_WEBHOOK_SECRET_PREVIOUS — rotation grace still in use", + context: { provider: "razorpay" }, + }); + } + + if (!matchedRole) { // M2 FIX: Only allow RazorpayX secret fallback for payout.* events. - // Parse the body to check event type before re-verifying — this prevents - // non-payout events from being accepted with the RazorpayX secret. - let isPossiblyPayoutEvent = false; - try { - const parsed = JSON.parse(body); - isPossiblyPayoutEvent = - typeof parsed.event === "string" && parsed.event.startsWith("payout."); - } catch { - // Can't parse — not a valid webhook, reject - } + // Read the event name from the (still unverified) body first — this + // prevents non-payout events from being accepted with the RazorpayX + // secret, and can only ever narrow what we accept. + const isPossiblyPayoutEvent = signature ? isPayoutEventName(body) : false; - if ( + const razorpayXAccepted = isPossiblyPayoutEvent && - razorpayXSecret && - razorpayXSecret !== secret - ) { - const signature = req.headers.get("x-razorpay-signature"); - if (signature) { - const crypto = await import("crypto"); - const expectedSig = crypto - .createHmac("sha256", razorpayXSecret) - .update(body) - .digest("hex"); - const sigBuf = Buffer.from(signature, "hex"); - const expectedBuf = Buffer.from(expectedSig, "hex"); - const isRazorpayXValid = - sigBuf.length === expectedBuf.length && - crypto.timingSafeEqual(sigBuf, expectedBuf); - - if (!isRazorpayXValid) { - // #776 §K — repeated HMAC failures are a tamper/misconfig signal. - await recordSystemEvent({ - category: "WEBHOOK", - severity: "WARN", - message: - "Razorpay webhook HMAC verification failed (RazorpayX secret)", - context: { provider: "razorpayx", event: "payout.*" }, - }); - return NextResponse.json( - { error: "Invalid signature" }, - { status: 400 }, - ); - } - // RazorpayX signature valid for payout event — continue processing - } else { - return NextResponse.json( - { error: "Invalid signature" }, - { status: 400 }, - ); - } - } else { + !!signature && + !!razorpayXSecret && + !paymentSecrets.some( + (candidate) => candidate.value === razorpayXSecret, + ) && + verifyRazorpaySignature(body, signature, razorpayXSecret); + + if (!razorpayXAccepted) { // #776 §K — repeated HMAC failures are a tamper/misconfig signal. await recordSystemEvent({ category: "WEBHOOK", severity: "WARN", - message: "Razorpay webhook HMAC verification failed", - context: { provider: "razorpay" }, + message: isPossiblyPayoutEvent + ? "Razorpay webhook HMAC verification failed (RazorpayX secret)" + : "Razorpay webhook HMAC verification failed", + context: isPossiblyPayoutEvent + ? { provider: "razorpayx", event: "payout.*" } + : { provider: "razorpay" }, }); return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); } + // RazorpayX signature valid for payout event — continue processing } // DB health check — return 503 if DB is unreachable so Razorpay retries @@ -171,7 +241,7 @@ export async function POST(req: NextRequest) { eventId, eventType, event.payload, - req.headers.get("x-razorpay-signature") || undefined, + signature || undefined, ); if (!isNew) { diff --git a/app/api/webhooks/razorpay/signature.ts b/app/api/webhooks/razorpay/signature.ts new file mode 100644 index 000000000..f0bfbc011 --- /dev/null +++ b/app/api/webhooks/razorpay/signature.ts @@ -0,0 +1,135 @@ +import crypto from "node:crypto"; + +/** + * Which configured secret verified an inbound Razorpay webhook. + * + * `previous` exists only during a secret rotation. `razorpayx` is the separate + * RazorpayX (payouts) product secret, which is a different value again and is + * only ever consulted for `payout.*` events. + */ +export type RazorpayWebhookSecretRole = "current" | "previous" | "razorpayx"; + +export interface RazorpayWebhookSecretCandidate { + role: RazorpayWebhookSecretRole; + value: string; +} + +// Razorpay signs with HMAC-SHA256 and sends the digest hex-encoded, so a +// well-formed `x-razorpay-signature` is always 64 characters. The length +// pre-check is not decoration: `timingSafeEqual` THROWS on a length mismatch, +// so without it an attacker-controlled header turns a rejected signature into +// an unhandled 500. +const HMAC_SHA256_HEX_LENGTH = 64; + +/** Constant-time HMAC-SHA256 check of the RAW body against one secret. */ +export function verifyRazorpaySignature( + rawBody: string, + signature: string, + secret: string, +): boolean { + if (signature.length !== HMAC_SHA256_HEX_LENGTH) { + return false; + } + const expected = crypto + .createHmac("sha256", secret) + .update(rawBody) + .digest("hex"); + const signatureBuffer = Buffer.from(signature, "hex"); + const expectedBuffer = Buffer.from(expected, "hex"); + if (signatureBuffer.length !== expectedBuffer.length) { + return false; + } + return crypto.timingSafeEqual(signatureBuffer, expectedBuffer); +} + +/** + * The payment-side secrets a delivery may legitimately be signed with, in the + * order they should be tried. + * + * #1377 — rotating `RAZORPAY_WEBHOOK_SECRET` is otherwise a hard cutover, and + * the two sides cannot swap atomically: the operator saves the new secret in + * the Razorpay dashboard, and every event Razorpay signs between that click + * and the platform finishing its redeploy is rejected with a 400. Razorpay + * treats any non-2xx as a delivery failure, retries with exponential backoff + * for 24 hours and then DISABLES the webhook, and a disabled webhook loses + * events permanently because there is no self-serve replay. So a routine + * hygiene action could silently take payment confirmation offline. + * + * `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` closes that gap the same way ADR 09 + * closes it for our OUTBOUND webhooks: both secrets are honoured across the + * cutover, and the old one is retired afterwards. The window here is + * operational rather than timestamped — the variable IS the window — so every + * delivery that actually lands on the previous secret is reported by the + * caller, and a variable left behind after the rotation is loud rather than + * silent. + * + * An unset, blank or duplicated previous secret contributes no candidate, so + * the normal steady state is a single-secret check. A missing CURRENT secret + * contributes none at all: the grace window is an aid to a rotation, not a + * secret in its own right, so a deployment that has lost + * `RAZORPAY_WEBHOOK_SECRET` must fail loudly on the route's 500 rather than + * quietly keep accepting deliveries on a value the operator has retired. + */ +export function resolveRazorpayPaymentSecrets( + env: Readonly> = process.env, +): RazorpayWebhookSecretCandidate[] { + const current = env.RAZORPAY_WEBHOOK_SECRET?.trim(); + const previous = env.RAZORPAY_WEBHOOK_SECRET_PREVIOUS?.trim(); + + const candidates: RazorpayWebhookSecretCandidate[] = []; + if (!current) { + return candidates; + } + candidates.push({ role: "current", value: current }); + if (previous && previous !== current) { + candidates.push({ role: "previous", value: previous }); + } + return candidates; +} + +/** + * Try each candidate in order and report which one matched, or null. + * + * Trying several secrets does not widen the trust boundary: each check is the + * same full HMAC over the same raw body, so a forged signature still has to + * match a secret the platform holds. What it widens is the SET of secrets the + * platform holds, which is exactly why `resolveRazorpayPaymentSecrets` only + * ever returns more than one while a rotation is in flight. + */ +export function matchRazorpayWebhookSecret( + rawBody: string, + signature: string, + candidates: readonly RazorpayWebhookSecretCandidate[], +): RazorpayWebhookSecretRole | null { + for (const candidate of candidates) { + if (verifyRazorpaySignature(rawBody, signature, candidate.value)) { + return candidate.role; + } + } + return null; +} + +/** + * True when the parsed body names a RazorpayX payout event. + * + * The RazorpayX secret is only ever tried for these, and the ordering — main + * secrets first, X secret only on a `payout.*` name — is the whole safety + * property: a non-payout event can never be accepted by the X secret, so the + * fallback cannot be used to smuggle a forged `payment.captured` through. + * The name is read from an as-yet-UNVERIFIED body, which is safe precisely + * because it can only ever narrow what we are willing to accept. + */ +export function isPayoutEventName(rawBody: string): boolean { + try { + const parsed: unknown = JSON.parse(rawBody); + return ( + typeof parsed === "object" && + parsed !== null && + typeof (parsed as { event?: unknown }).event === "string" && + (parsed as { event: string }).event.startsWith("payout.") + ); + } catch { + // Unparseable body — not a webhook we can classify, so no fallback. + return false; + } +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index d1293d88c..b96bba255 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -168,6 +168,17 @@ export async function POST(req: NextRequest) { latestRefund.currency.toUpperCase(), latestRefund.status, "STRIPE", + // 7th arg — the provider payment id the org-level branches key + // on (WalletTopUp / OrganizationInvoice.providerPaymentId); only + // the B2C Payment lookup uses `payment_intent`. `ch_<…>` is the + // Stripe analogue of the `pay_<…>` razorpay-dispatch passes here. + // Org billing mints Razorpay orders today, so nothing matches on + // this rail yet; what it changes now is the not-found case, which + // stopped silently ACKing (#813/#812 calls that permanent death) + // and now 5xxs so Stripe re-delivers. + typeof latestRefund.charge === "string" + ? latestRefund.charge + : (latestRefund.charge?.id ?? refundEvent.id), ); } break; diff --git a/app/api/webhooks/utils.ts b/app/api/webhooks/utils.ts index 6563bee0d..cfb5a130a 100644 --- a/app/api/webhooks/utils.ts +++ b/app/api/webhooks/utils.ts @@ -35,6 +35,7 @@ import { mintInvoiceRefundCreditNote, mintRefundCreditNote, } from "@/lib/payments/operations/refund"; +import { mintConsumerCreditNote } from "@/lib/payments/billing/consumer-invoice"; import { applyReversal } from "@/lib/payments/operations/reversal-engine"; import { recordTdsReversal } from "@/lib/payments/tax/tds-service"; import { AUDIT_ACTIONS } from "@/lib/enterprise/audit-actions"; @@ -621,9 +622,30 @@ export async function handleRefundCreated( return await withSerializableRetry(() => prisma.$transaction( async (tx) => { - // Find the payment (B2C appointment path) - const payment = await tx.payment.findUnique({ - where: { paymentIntent: paymentIntentId }, + // Find the payment (B2C appointment path). + // + // #1353 — match on EITHER id. A refund webhook carries only the gateway's + // `pay_…` payment id, so the dispatcher had to translate it into our order + // id with a live `payments.fetch`; when that call failed it passed the + // `pay_…` id through unchanged and a lookup keyed solely on `paymentIntent` + // could never match it. The refund then deferred and was re-driven for up + // to a week against a payment that had been captured all along. Now the id + // the webhook actually carries is itself a key. + // + // Deliberately NOT filtered on `deletedAt: null`: the lookup this replaced + // was a `findUnique` on `paymentIntent`, which reached soft-deleted rows + // too. A Payment soft-deleted after capture still owes its refund event a + // hearing — excluding it would defer the webhook and give up on it after + // 168h, which is a money outcome, not a tidier query. + const payment = await tx.payment.findFirst({ + where: { + OR: [ + { paymentIntent: paymentIntentId }, + ...(providerPaymentId + ? [{ gatewayPaymentId: providerPaymentId }] + : []), + ], + }, }); if (!payment) { @@ -967,8 +989,16 @@ export async function handleRefundCreated( // error=true which the sweeper skips (it only re-drives error=null) — both // are permanent death on Razorpay (no redelivery after a 200). Instead // DEFER: on Razorpay the dispatcher skips the mark and the sweeper re-drives - // until the payment lands (or the terminal age cap gives up). Stripe retries - // natively on a 5xx and doesn't read this return, so keep throwing there. + // until the payment lands (or the terminal age cap gives up). + // + // Stripe keeps throwing, and the asymmetry is deliberate rather than + // leftover: sweep-stuck-webhook-events.ts selects + // `provider: { in: ["razorpay", "stream"] }`, so a deferred Stripe event + // has NO actor — it would sit processed=false/error=null forever after a + // 200 told Stripe to stop retrying. The throw returns 5xx, and Stripe's + // native retry schedule (~3 days) is the re-drive. Extracting a Stripe + // dispatch and adding it to the sweep is the precondition for unifying + // these two branches. const deferReason = `refund-before-capture: payment not yet recorded for refund ${refundId} (paymentIntent=${paymentIntentId}, providerPaymentId=${providerPaymentId})`; if (gateway === "RAZORPAY") { return new DeferSignal(deferReason); @@ -1223,11 +1253,26 @@ export async function handleDisputeCreated( return await withSerializableRetry(() => prisma.$transaction( async (tx) => { - const payment = resolvedPaymentIntent - ? await tx.payment.findUnique({ - where: { paymentIntent: resolvedPaymentIntent }, - }) - : null; + // #1353 — either id resolves the disputed payment: the order id when + // the gateway lookup above succeeded, or `chargeId` (the `pay_…` id the + // webhook itself carried) against the column the capture pipeline now + // persists. The second key is what keeps a dispute linkable when that + // gateway fetch fails — until now such a failure meant no link at all, + // a CRITICAL_DISPUTE_UNLINKED page, and disputed earnings left payable + // until the six-hourly reconcile cron noticed. As on the refund path, + // no `deletedAt` filter: this replaced a `findUnique` that reached + // soft-deleted rows, and a chargeback against one still has to be + // recorded and still has to hold the earnings. + const payment = await tx.payment.findFirst({ + where: { + OR: [ + ...(resolvedPaymentIntent + ? [{ paymentIntent: resolvedPaymentIntent }] + : []), + { gatewayPaymentId: chargeId }, + ], + }, + }); if (!payment) { console.warn(`Payment not found for dispute: ${disputeId}`); @@ -1659,6 +1704,17 @@ export async function handleDisputeUpdated( reason: `chargeback lost (dispute ${disputeId})`, }); + // #1365 — the B2C sibling. A personal buyer's tax invoice is reversed + // by its own s.34 credit note on the platform series; idempotent on + // ConsumerCreditNote.disputeId, and a no-op when no consumer invoice + // was ever issued for the payment. + await mintConsumerCreditNote(tx, { + paymentId: dispute.paymentId, + disputeId: dispute.id, + amountPaise: dispute.amountPaise, + reason: `chargeback lost (dispute ${disputeId})`, + }); + // #738-B — TCS u/s 52 parity: if collection ever stamped this payment // (flag-gated, schema-live), the chargeback must net it out of the // next GSTR-8. Inert while gstTcsCollectedPaise stays null. @@ -2081,6 +2137,12 @@ export async function handleRazorpayPayoutWebhook( processed: "COMPLETED", reversed: "FAILED", rejected: "FAILED", + // #1451 — RazorpayX answers a bank-level failure with `failed`, and the missing + // entry fell through to the `|| "PENDING"` default: a `payout.failed` + // delivery left the consultant payout in flight and its earnings BATCHED + // forever, because the un-batch back to READY only runs on the FAILED + // branch of handlePayoutWebhook. The Stripe twin below already maps it. + failed: "FAILED", cancelled: "CANCELLED", }; diff --git a/app/checkout/checkout-success/page.tsx b/app/checkout/checkout-success/page.tsx index f2b765c08..c42c38908 100644 --- a/app/checkout/checkout-success/page.tsx +++ b/app/checkout/checkout-success/page.tsx @@ -1,12 +1,12 @@ "use client"; -import * as Sentry from "@sentry/nextjs"; import { useEffect, useState, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { CheckoutResultSkeleton } from "@/app/checkout/CheckoutSkeletons"; import { CheckCircle, Clock, Calendar, ArrowRight } from "lucide-react"; +import { reportPaymentsError } from "@/app/checkout/plans/utils"; interface PaymentDetails { paymentIntent: string; appointmentType: string; @@ -77,10 +77,7 @@ function CheckoutSuccessContent() { return; } } catch (error) { - Sentry.captureException( - error instanceof Error ? error : new Error(String(error)), - { tags: { subsystem: "payments" } }, - ); + reportPaymentsError(error); console.error("Payment verification error:", error); } diff --git a/app/checkout/components/BillingStateSelect.tsx b/app/checkout/components/BillingStateSelect.tsx new file mode 100644 index 000000000..734510484 --- /dev/null +++ b/app/checkout/components/BillingStateSelect.tsx @@ -0,0 +1,123 @@ +"use client"; + +/** + * Billing-state picker for the GST place of supply (#1365). + * + * Deliberately optional and off the critical path: under s.12(2)(b) of the + * IGST Act a B2C supply with no address of the recipient on record is supplied + * at the SUPPLIER's own location, so leaving this blank produces a correct + * intra-state tax invoice rather than an incomplete one. It exists so a buyer + * in another state gets an IGST invoice their accountant will accept, not so + * that checkout can demand an answer before taking money. + * + * Submits the 2-digit NUMERIC code, which is what the GST portal, the invoice + * and `lib/compliance/gst.ts` all compare on. Once a buyer picks a state, + * checkout remembers it on their consultee profile and this field arrives + * pre-filled next time. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { GST_STATE_OPTIONS } from "@/lib/compliance/state-codes"; + +export interface BillingStateSelectProps { + /** The 2-digit numeric state code, or null when nothing has been chosen. */ + value: string | null; + onChange: (stateCode: string | null) => void; + disabled?: boolean; +} + +/** Sentinel for the "no answer" option. Radix Select forbids an empty-string + * item value, and null is what the API wants for the statutory default. */ +const NOT_SPECIFIED = "__unspecified__"; + +export function BillingStateSelect({ + value, + onChange, + disabled, +}: Readonly) { + return ( +
+ + +

+ Optional. We use this only to decide the place of supply on your tax + invoice. If you leave it blank, the invoice is issued at our own + location, as the GST rules provide. +

+
+ ); +} + +export interface UseBillingStateResult { + /** The 2-digit numeric code, or null for the statutory default. */ + value: string | null; + onChange: (stateCode: string | null) => void; + /** + * Spread straight into a checkout POST body. Omits the key entirely when no + * state is on record, because the API treats an absent field and an explicit + * null identically and `undefined` is what the shared `createCheckoutData` + * helper expects for "not answered". + */ + bodyField: { consumerStateCode?: string }; +} + +/** + * Owns the billing-state answer for one checkout page: the local value, the + * pre-fill from the buyer's remembered profile state, and the body field the + * page sends. + * + * Every checkout page needs exactly this, and had grown its own copy of it. + * The pre-fill needs the latch: the checkout context resolves after first + * paint, so without one, a buyer who answers before it lands would have their + * answer overwritten by the stored value a moment later. + * + * @param initial the remembered state from the checkout context, if any. + */ +export function useBillingState( + initial?: string | null, +): UseBillingStateResult { + const [value, setValue] = useState(null); + const [answered, setAnswered] = useState(false); + + useEffect(() => { + if (answered) return; + setValue(initial ?? null); + }, [initial, answered]); + + const onChange = useCallback((stateCode: string | null) => { + setAnswered(true); + setValue(stateCode); + }, []); + + const bodyField = useMemo( + () => (value ? { consumerStateCode: value } : {}), + [value], + ); + + return { value, onChange, bodyField }; +} diff --git a/app/checkout/components/FxEstimateNote.tsx b/app/checkout/components/FxEstimateNote.tsx new file mode 100644 index 000000000..cbb406af8 --- /dev/null +++ b/app/checkout/components/FxEstimateNote.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useMemo } from "react"; +import { useSession } from "@/lib/auth-client"; +import { useCurrency } from "@/hooks/useCurrency"; +import { RATE_PROVIDER_NAME, RATE_PROVIDER_URL } from "@/lib/currency-codes"; +import { formatCurrencyAmount } from "@/utils/formatting"; + +/** + * The disclosure sentence for one (currency, total, funding source) triple. + * + * #1414 — extracted from the component body as a chain of early returns. As a + * nested ternary it tripped the quality gate twice, and the zero-total case + * below had nowhere to go: referral credits that cover a booking in full skip + * the gateway entirely, so the default sentence promised a gateway charge that + * never happens. + */ +function estimateLead( + currency: string, + totalPaise: number, + fundingSource: string | null, +) { + const inr = formatCurrencyAmount(totalPaise, "INR"); + + if (totalPaise <= 0) { + return ( + <> + Estimated in {currency}. Nothing is payable for this booking, so no + gateway payment is required. + + ); + } + + if (fundingSource === "WALLET") { + return ( + <> + Estimated in {currency}. Your organisation’s wallet will be + debited {inr} in INR; no card is charged. + + ); + } + + if (fundingSource === "INVOICE") { + return ( + <> + Estimated in {currency}. {inr} in INR will be billed to your + organisation’s invoice account; no card is charged. + + ); + } + + if (fundingSource === "LICENSE") { + return ( + <> + Estimated in {currency}. The session value is {inr} in INR and is + covered by your enterprise licence. + + ); + } + + return ( + <> + Estimated in {currency}. You will be charged {inr} in INR by the payment + gateway; your card issuer’s rate applies. + + ); +} + +/** + * #1396 — every order-summary line on the four checkout pages, the Total + * included, is rendered through `useCurrency().formatPrice`, which multiplies + * INR paise by a live rate and stamps a foreign symbol on the result. The + * Razorpay modal then opens with the server's INR amount and the confirmation + * email is written in INR, so a buyer who had selected USD read "$59.38", was + * charged ₹5,000.00, and was emailed "₹5,000.00" — with the mid-market-versus- + * card-network spread and the gateway's markup, typically two to four percent, + * unaccounted for anywhere on the page. + * + * This component is the disclosure. It renders only while the figures above it + * really are an estimate, names the INR amount that will actually be taken, and + * carries the attribution that the rate provider's licence requires wherever + * its rates are displayed. + * + * It exists as one shared component rather than four copies so the four + * checkout pages each add a single line, which also keeps the duplication ratio + * on new code inside the quality gate. + */ +export function FxEstimateNote({ + totalPaise, + organizationId, +}: { + totalPaise: number; + /** + * #1414 — the selected org, when the buyer is booking against one. Only + * PERSONAL funding (and no org at all) reaches a payment gateway; WALLET + * debits the credit pool, INVOICE defers to NET-X billing and LICENSE + * charges nothing, so naming a gateway charge in those flows is false. + */ + organizationId?: string | null; +}) { + const { currency, isEstimate } = useCurrency(); + const { data: session } = useSession(); + const fundingSource = useMemo(() => { + if (!organizationId) return null; + const memberships = session?.user?.organizationMemberships ?? []; + return ( + memberships.find((m) => m.organizationId === organizationId) + ?.fundingSource ?? null + ); + }, [organizationId, session?.user?.organizationMemberships]); + + if (!isEstimate) return null; + + return ( +

+ {estimateLead(currency, totalPaise, fundingSource)} Rates by{" "} + + {RATE_PROVIDER_NAME} + + . +

+ ); +} diff --git a/app/checkout/components/OrgPayerSelector.tsx b/app/checkout/components/OrgPayerSelector.tsx index 5a0f3320c..075463ba6 100644 --- a/app/checkout/components/OrgPayerSelector.tsx +++ b/app/checkout/components/OrgPayerSelector.tsx @@ -3,7 +3,7 @@ import { useSession } from "@/lib/auth-client"; import { useQuery } from "@tanstack/react-query"; import Image from "next/image"; -import { Building2, CreditCard, AlertTriangle, Ban } from "lucide-react"; +import { Building2, CreditCard, AlertTriangle, Ban, Info } from "lucide-react"; import type { CoveredPlanType } from "@prisma/client"; interface OveragePreview { @@ -87,6 +87,44 @@ function OverageWarning({ return null; } +/** + * #1430 — non-blocking consent pre-flight. `handleCheckout` already fails + * closed on a missing SESSION_BOOKING consent artifact for the booking + * member, so this is a heads-up, not a gate: the server stays the + * authoritative check, this just stops the surprise at pay time. + */ +function ConsentPreflightNotice({ + organizationId, +}: { + organizationId: string; +}) { + const { data } = useQuery<{ hasConsent: boolean }>({ + queryKey: ["checkout-consent-preview", organizationId], + queryFn: async () => { + const res = await fetch( + `/api/organizations/${organizationId}/checkout/consent-preview`, + ); + if (!res.ok) throw new Error("consent preview failed"); + return res.json(); + }, + staleTime: 30_000, + retry: false, + }); + + if (!data || data.hasConsent) return null; + + return ( +
+ + + You have not granted session-booking consent yet. Your organization + cannot book or pay for a session on your behalf until you grant it in + your privacy settings. + +
+ ); +} + /** * Payer selector for checkout pages. Shows "Pay personally" vs "Bill to * [org name]" when the user has org memberships. Self-hides for users @@ -126,7 +164,9 @@ export function OrgPayerSelector({ return (
-

Who is paying?

+

+ Who is paying? +

{/* Personal payment option */}
- {[ - { - name: "Stripe", - description: "Card payments (international)", - gateway: "STRIPE" as const, - isActive: true, - }, - { - name: "Razorpay", - description: "UPI, cards & bank transfer", - gateway: "RAZORPAY" as const, - isActive: true, - }, - ].map((gateway) => ( + {paymentGateways.map((gateway) => ( @@ -830,6 +828,7 @@ export default function ClassCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, })} onPaymentSuccess={razorpayHandlers.onPaymentSuccess} onPaymentError={razorpayHandlers.onPaymentError} @@ -848,6 +847,7 @@ export default function ClassCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, })} onPaymentSuccess={stripeHandlers.onPaymentSuccess} onPaymentError={stripeHandlers.onPaymentError} diff --git a/app/checkout/plans/consultation/[planId]/page.tsx b/app/checkout/plans/consultation/[planId]/page.tsx index a0ce47016..253098a1a 100644 --- a/app/checkout/plans/consultation/[planId]/page.tsx +++ b/app/checkout/plans/consultation/[planId]/page.tsx @@ -1,6 +1,5 @@ "use client"; -import * as Sentry from "@sentry/nextjs"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -25,6 +24,11 @@ import { } from "@/lib/payments/constants"; import type { AppliedDiscount } from "@/types/checkout"; import { OrgPayerSelector } from "@/app/checkout/components/OrgPayerSelector"; +import { FxEstimateNote } from "@/app/checkout/components/FxEstimateNote"; +import { + BillingStateSelect, + useBillingState, +} from "@/app/checkout/components/BillingStateSelect"; import { useSession } from "@/lib/auth-client"; import { ConsultantProfile, @@ -36,13 +40,15 @@ import { CompanyLogo } from "@/components/ui/company-logo"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import RazorpayCheckout from "../../../components/RazorpayCheckout"; import StripeCheckout from "../../../components/StripeCheckout"; -import { createHandleApiError } from "../../utils"; +import { createHandleApiError, paymentGateways } from "../../utils"; import { calculatePricing, formatPercentage } from "../../math"; import { useCurrency } from "@/hooks/useCurrency"; import { useCheckoutTaxContext } from "../../useCheckoutTaxContext"; -import { mintClientIdempotencyKey, +import { + mintClientIdempotencyKey, busyRetryToast, fetchCheckoutWithBusyRetry, + reportPaymentsError, } from "@/app/checkout/plans/utils"; // price arrives as number: extended client + JSON serialization (#780) @@ -72,6 +78,23 @@ type PageProps = { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; +// #1414 — lifted out of handleCheckout, which SonarCloud measured at +// cognitive complexity 17 against a ceiling of 15. This branch reads which of +// the three gatewayless confirmations happened; it needs nothing from the +// component's scope. +function gatewaylessConfirmationText(data: { + isZeroAmountPayment?: boolean; + isMockPayment?: boolean; +}): string { + if (data.isZeroAmountPayment) { + return "Payment completed via referral credits. Your consultation has been confirmed."; + } + if (data.isMockPayment) { + return "Mock payment processed. Your consultation has been confirmed. Check your dashboard for details."; + } + return "Your consultation has been confirmed. Check your dashboard for details."; +} + export default function ConsultationCheckoutPage({ params, searchParams, @@ -102,6 +125,9 @@ export default function ConsultationCheckoutPage({ const [isApplyingDiscount, setIsApplyingDiscount] = useState(false); const [discountError, setDiscountError] = useState(null); const [useReferralCredits, setUseReferralCredits] = useState(false); + // #1365 — GST place of supply. Blank is the statutory s.12(2)(b) default, so + // this never blocks checkout. + const billingState = useBillingState(checkoutTaxContext.billingStateCode); const [selectedOrganizationId, setSelectedOrganizationId] = useState< string | null >(null); @@ -188,7 +214,7 @@ export default function ConsultationCheckoutPage({ ); } } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "payments" } }); + reportPaymentsError(error); console.error("Error fetching referral credits:", error); } finally { setIsLoadingCredits(false); @@ -269,7 +295,10 @@ export default function ConsultationCheckoutPage({ ); const handleCheckout = useCallback( - async (gateway: SupportedCheckoutGateway, isMockPayment: boolean = false) => { + async ( + gateway: SupportedCheckoutGateway, + isMockPayment: boolean = false, + ) => { // Block checkout during maintenance mode if (isMaintenanceBlocked) { toast({ @@ -317,6 +346,7 @@ export default function ConsultationCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, }); // Make single API call - backend decides dev vs prod flow @@ -356,11 +386,7 @@ export default function ConsultationCheckoutPage({ ) { toast({ title: "✅ Consultation Booked Successfully!", - description: data.isZeroAmountPayment - ? "Payment completed via referral credits. Your consultation has been confirmed." - : data.isMockPayment - ? "Mock payment processed. Your consultation has been confirmed. Check your dashboard for details." - : "Your consultation has been confirmed. Check your dashboard for details.", + description: gatewaylessConfirmationText(data), variant: "default", }); @@ -371,7 +397,7 @@ export default function ConsultationCheckoutPage({ } catch (error) { // Only fires for unexpected errors (network failure, JSON parse error, etc.) // API errors are handled above with handleApiError() + return - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "payments" } }); + reportPaymentsError(error); toast({ title: "Checkout Failed", description: @@ -394,6 +420,7 @@ export default function ConsultationCheckoutPage({ appliedDiscount, useReferralCredits, selectedOrganizationId, + billingState.bodyField, validatedSearchParams, currency, handleApiError, @@ -443,7 +470,7 @@ export default function ConsultationCheckoutPage({ const reviewsData = await fetchReviews(data.data.consultantProfile.id); setReviews(reviewsData); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "payments" } }); + reportPaymentsError(error); console.error("[Checkout] Error fetching event data:", error); setError( error instanceof Error @@ -627,14 +654,15 @@ export default function ConsultationCheckoutPage({
Date
{validatedSearchParams - ? new Date( - validatedSearchParams.startsAt, - ).toLocaleDateString(undefined, { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - }) + ? new Date(validatedSearchParams.startsAt).toLocaleDateString( + undefined, + { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }, + ) : "—"}
@@ -684,6 +712,11 @@ export default function ConsultationCheckoutPage({ }} /> + +
Discount Codes
@@ -829,6 +862,12 @@ export default function ConsultationCheckoutPage({ : formatPrice(pricing.total)}
+ {!isLicenseCovered && ( + + )} {isLicenseCovered && (

Session value {formatPrice(pricing.total)} — covered by @@ -845,23 +884,12 @@ export default function ConsultationCheckoutPage({ Select your preferred payment method - {[ - { - name: "Stripe", - description: "Card payments (international)", - gateway: "STRIPE" as const, - isActive: true, - }, - { - name: "Razorpay", - description: "UPI, cards & bank transfer", - gateway: "RAZORPAY" as const, - isActive: true, - }, - ].map((gateway) => ( + {paymentGateways.map((gateway) => ( - {gateway.name} + + {gateway.name} +

@@ -885,10 +913,8 @@ export default function ConsultationCheckoutPage({ appointmentType: "CONSULTATION", planId: resolvedParams.planId, paymentGateway: "RAZORPAY", - startsAt: - validatedSearchParams.startsAt, - endsAt: - validatedSearchParams.endsAt, + startsAt: validatedSearchParams.startsAt, + endsAt: validatedSearchParams.endsAt, slotOfAvailabilityWeeklyId: validatedSearchParams.slotOfAvailabilityWeeklyId, slotOfAvailabilityCustomId: @@ -900,6 +926,7 @@ export default function ConsultationCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, })} onPaymentSuccess={(response: { razorpay_payment_id?: string; @@ -934,10 +961,8 @@ export default function ConsultationCheckoutPage({ appointmentType: "CONSULTATION", planId: resolvedParams.planId, paymentGateway: "STRIPE", - startsAt: - validatedSearchParams.startsAt, - endsAt: - validatedSearchParams.endsAt, + startsAt: validatedSearchParams.startsAt, + endsAt: validatedSearchParams.endsAt, slotOfAvailabilityWeeklyId: validatedSearchParams.slotOfAvailabilityWeeklyId, slotOfAvailabilityCustomId: @@ -949,6 +974,7 @@ export default function ConsultationCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, })} onPaymentSuccess={(response: { message?: string; diff --git a/app/checkout/plans/subscription/[planId]/page.tsx b/app/checkout/plans/subscription/[planId]/page.tsx index 3bfeb9aaa..9c01aaba6 100644 --- a/app/checkout/plans/subscription/[planId]/page.tsx +++ b/app/checkout/plans/subscription/[planId]/page.tsx @@ -1,6 +1,5 @@ "use client"; -import * as Sentry from "@sentry/nextjs"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -21,6 +20,11 @@ import { } from "@/schemas/checkout"; import type { AppliedDiscount } from "@/types/checkout"; import { OrgPayerSelector } from "@/app/checkout/components/OrgPayerSelector"; +import { FxEstimateNote } from "@/app/checkout/components/FxEstimateNote"; +import { + BillingStateSelect, + useBillingState, +} from "@/app/checkout/components/BillingStateSelect"; import { ConsultantProfile, ConsultantReview, @@ -35,13 +39,16 @@ import { createHandleApiError, createRazorpayCheckoutHandlers, createStripeCheckoutHandlers, + paymentGateways, } from "../../utils"; import { calculatePricing, formatPercentage } from "../../math"; import { useCurrency } from "@/hooks/useCurrency"; import { useCheckoutTaxContext } from "../../useCheckoutTaxContext"; -import { mintClientIdempotencyKey, +import { + mintClientIdempotencyKey, busyRetryToast, fetchCheckoutWithBusyRetry, + reportPaymentsError, } from "@/app/checkout/plans/utils"; // price arrives as number: extended client + JSON serialization (#780) @@ -98,6 +105,9 @@ export default function SubscriptionCheckoutPage({ const [isApplyingDiscount, setIsApplyingDiscount] = useState(false); const [discountError, setDiscountError] = useState(null); const [useReferralCredits, setUseReferralCredits] = useState(false); + // #1365 — GST place of supply. Blank is the statutory s.12(2)(b) default, so + // this never blocks checkout. + const billingState = useBillingState(checkoutTaxContext.billingStateCode); const [selectedOrganizationId, setSelectedOrganizationId] = useState< string | null >(null); @@ -202,7 +212,7 @@ export default function SubscriptionCheckoutPage({ ); } } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "payments" } }); + reportPaymentsError(error); console.error("Error fetching referral credits:", error); } finally { setIsLoadingCredits(false); @@ -236,7 +246,10 @@ export default function SubscriptionCheckoutPage({ ); const handleCheckout = useCallback( - async (gateway: SupportedCheckoutGateway, isMockPayment: boolean = false) => { + async ( + gateway: SupportedCheckoutGateway, + isMockPayment: boolean = false, + ) => { // Block checkout during maintenance mode if (isMaintenanceBlocked) { toast({ @@ -301,6 +314,7 @@ export default function SubscriptionCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, }); // Make API call - backend decides dev vs prod flow @@ -351,7 +365,7 @@ export default function SubscriptionCheckoutPage({ handleApiError({ error: data.error, errorType: data.errorType }); } } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "payments" } }); + reportPaymentsError(error); console.error("Checkout error:", error); if (error instanceof Error) { toast({ @@ -375,6 +389,7 @@ export default function SubscriptionCheckoutPage({ appliedDiscount, useReferralCredits, selectedOrganizationId, + billingState.bodyField, effectiveSearchParams, currency, handleApiError, @@ -405,7 +420,7 @@ export default function SubscriptionCheckoutPage({ const reviewsData = await fetchReviews(data.data.consultantProfile.id); setReviews(reviewsData); } catch (error) { - Sentry.captureException(error instanceof Error ? error : new Error(String(error)), { tags: { subsystem: "payments" } }); + reportPaymentsError(error); console.error("Error fetching plan data:", error); setError( error instanceof Error @@ -657,6 +672,11 @@ export default function SubscriptionCheckoutPage({ }} /> + +
Discount Codes
@@ -787,7 +807,9 @@ export default function SubscriptionCheckoutPage({ (planData?.data?.sessionDurationInHours || 1)}{" "} hours) -
  • {planData?.data?.sessionsPerWeek || 1} sessions per week
  • +
  • + {planData?.data?.sessionsPerWeek || 1} sessions per week +
  • {planData?.data?.sessionDurationInHours || 1} hour sessions @@ -831,6 +853,10 @@ export default function SubscriptionCheckoutPage({
    Total
    {formatPrice(pricing.total)}
  • +
    @@ -841,23 +867,12 @@ export default function SubscriptionCheckoutPage({ Select your preferred payment method
    - {[ - { - name: "Stripe", - description: "Card payments (international)", - gateway: "STRIPE" as const, - isActive: true, - }, - { - name: "Razorpay", - description: "UPI, cards & bank transfer", - gateway: "RAZORPAY" as const, - isActive: true, - }, - ].map((gateway) => ( + {paymentGateways.map((gateway) => ( - {gateway.name} + + {gateway.name} +
    @@ -892,6 +907,7 @@ export default function SubscriptionCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, })} onPaymentSuccess={razorpayHandlers.onPaymentSuccess} onPaymentError={razorpayHandlers.onPaymentError} @@ -915,6 +931,7 @@ export default function SubscriptionCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, })} onPaymentSuccess={stripeHandlers.onPaymentSuccess} onPaymentError={stripeHandlers.onPaymentError} diff --git a/app/checkout/plans/useCheckoutTaxContext.ts b/app/checkout/plans/useCheckoutTaxContext.ts index 9429ac70f..e734aa568 100644 --- a/app/checkout/plans/useCheckoutTaxContext.ts +++ b/app/checkout/plans/useCheckoutTaxContext.ts @@ -7,12 +7,15 @@ type CheckoutTaxContext = { isInternational: boolean; /** Server-decided (#1230): international AND a valid platform LUT exists. */ exportZeroRated: boolean; + /** #1365 — remembered GST billing state, used to pre-fill the picker. */ + billingStateCode: string | null; }; const DEFAULT_CONTEXT: CheckoutTaxContext = { buyerCountry: "IN", isInternational: false, exportZeroRated: false, + billingStateCode: null, }; export function useCheckoutTaxContext() { diff --git a/app/checkout/plans/utils.ts b/app/checkout/plans/utils.ts index e02994943..d7e287fb7 100644 --- a/app/checkout/plans/utils.ts +++ b/app/checkout/plans/utils.ts @@ -1,11 +1,24 @@ "use client"; +import * as Sentry from "@sentry/nextjs"; import { useToast } from "@/hooks/use-toast"; import { getErrorToast } from "@/lib/errors/mapping/payment-error-toast-map"; import { ErrorTypes } from "@/lib/errors/classification/payment-error-classification"; import { CheckoutInput, checkoutResponseSchema } from "@/schemas/checkout"; import { PaymentGateway } from "@prisma/client"; +// #1396 — every checkout page and both gateway components caught an +// unexpected error the same way; centralising it removed the repeated +// three-line block flagged as duplication rather than leaving the copies. +export function reportPaymentsError(error: unknown): void { + Sentry.captureException( + error instanceof Error ? error : new Error(String(error)), + { + tags: { subsystem: "payments" }, + }, + ); +} + export function loadScript(src: string): Promise { return new Promise((resolve, reject) => { const script = document.createElement("script"); @@ -40,7 +53,6 @@ export function createHandleApiError( }; } - // #828 — one key per logical checkout attempt (stable across double-clicks // and network retries within a mount; a fresh mount = a fresh attempt). The // server CASes on Payment.clientIdempotencyKey and replays the original @@ -81,7 +93,10 @@ export async function makeCheckoutRequest( * commits or releases within seconds — yet they used to dead-end as terminal * error toasts while the buyer watched a hot slot slip away. */ -const BUSY_ERROR_TYPES = new Set(["EVENT_CHECKOUT_BUSY", "CONSULTEE_BOOKING_BUSY"]); +const BUSY_ERROR_TYPES = new Set([ + "EVENT_CHECKOUT_BUSY", + "CONSULTEE_BOOKING_BUSY", +]); /** Never wait longer than this server-advised pause (function-ceiling friendly). */ const MAX_BUSY_WAIT_SECONDS = 20; @@ -114,11 +129,18 @@ export async function fetchCheckoutWithBusyRetry( return response; } const retryAfter = Number(body?.retryAfter); - if (!body?.errorType || !BUSY_ERROR_TYPES.has(body.errorType) || !Number.isFinite(retryAfter)) { + if ( + !body?.errorType || + !BUSY_ERROR_TYPES.has(body.errorType) || + !Number.isFinite(retryAfter) + ) { return response; } - const waitSeconds = Math.min(Math.max(1, Math.round(retryAfter)), MAX_BUSY_WAIT_SECONDS); + const waitSeconds = Math.min( + Math.max(1, Math.round(retryAfter)), + MAX_BUSY_WAIT_SECONDS, + ); notifyBusy(waitSeconds); await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); @@ -127,16 +149,16 @@ export async function fetchCheckoutWithBusyRetry( } /** Shared copy for the during-wait notice so all four surfaces sound alike. */ -export function busyRetryToast( - waitSeconds: number, -): { title: string; description: string } { +export function busyRetryToast(waitSeconds: number): { + title: string; + description: string; +} { return { title: "Almost got it — someone is one step ahead", description: `Your card has not been charged. Retrying automatically in ${waitSeconds}s…`, }; } - // Common success handling logic for different appointment types export function createHandleCheckoutSuccess( toast: ReturnType["toast"], @@ -243,17 +265,41 @@ export async function handleUnifiedCheckout( } } -// Gateway configuration for UI rendering +// #1437 — WALLET/INVOICE/LICENSE org funding, zero-amount (credits) and mock +// payments all confirm synchronously server-side with a synthetic id and no +// gateway order/client secret. Opening Razorpay/Stripe on that id 400s and +// shows a false "Payment Failed" alert over a booking that already +// succeeded, so every gateway component must check this before opening. +export function checkoutNeedsGateway(data: { + skipPayment?: boolean; + isZeroAmountPayment?: boolean; + [key: string]: unknown; +}): boolean { + return !(data.skipPayment || data.isZeroAmountPayment); +} + +// Gateway configuration for UI rendering. The four checkout pages each carried +// their own copy of this array with `isActive: true` hardcoded on both entries, +// so the fence had to be applied in four places to hold. One list now. +// +// #1351 — Stripe is a contingency rail kept in the tree in case RBI rules +// change, not a live payment method: without NEXT_PUBLIC_STRIPE_ENABLED=true +// the card renders the disabled "Coming Soon" button and no StripeCheckout +// mounts. The server-side fence (STRIPE_ENABLED, assertGatewayUsable) is the +// one that actually protects money; this only keeps the UI honest, because a +// NEXT_PUBLIC_ value is inlined into the client bundle and a buyer can edit it. export const paymentGateways = [ { name: "Stripe", description: "Card payments (international)", gateway: "STRIPE" as const, + isActive: process.env.NEXT_PUBLIC_STRIPE_ENABLED === "true", }, { name: "Razorpay", description: "UPI, cards & bank transfer", gateway: "RAZORPAY" as const, + isActive: true, }, ]; @@ -270,7 +316,12 @@ export function createStripeCheckoutHandlers( }); window.location.href = "/checkout/checkout-success"; }, - onPaymentError: (error: { message?: string; code?: string; errorType?: string; error?: string }) => { + onPaymentError: (error: { + message?: string; + code?: string; + errorType?: string; + error?: string; + }) => { // Booking-conflict errors from /api/checkout (slot taken/relinquished, // event expired) carry our own errorType — route them through the precise // toast map instead of the gateway card-decline heuristics. diff --git a/app/checkout/plans/webinar/[planId]/page.tsx b/app/checkout/plans/webinar/[planId]/page.tsx index a5a51855e..a83815dc2 100644 --- a/app/checkout/plans/webinar/[planId]/page.tsx +++ b/app/checkout/plans/webinar/[planId]/page.tsx @@ -1,6 +1,5 @@ "use client"; -import * as Sentry from "@sentry/nextjs"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -28,6 +27,8 @@ import { createRazorpayCheckoutHandlers, createStripeCheckoutHandlers, handleUnifiedCheckout, + paymentGateways, + reportPaymentsError, } from "../../utils"; import { calculatePricing, formatPercentage } from "../../math"; import { getWebinarCapacity } from "@/lib/events/capacity"; @@ -35,6 +36,11 @@ import { useCurrency } from "@/hooks/useCurrency"; import { useCheckoutTaxContext } from "../../useCheckoutTaxContext"; import type { AppliedDiscount } from "@/types/checkout"; import { OrgPayerSelector } from "@/app/checkout/components/OrgPayerSelector"; +import { FxEstimateNote } from "@/app/checkout/components/FxEstimateNote"; +import { + BillingStateSelect, + useBillingState, +} from "@/app/checkout/components/BillingStateSelect"; import type { Appointment, @@ -119,6 +125,9 @@ export default function WebinarCheckoutPage({ const [isApplyingDiscount, setIsApplyingDiscount] = useState(false); const [discountError, setDiscountError] = useState(null); const [useReferralCredits, setUseReferralCredits] = useState(false); + // #1365 — GST place of supply. Blank is the statutory s.12(2)(b) default, so + // this never blocks checkout. + const billingState = useBillingState(checkoutTaxContext.billingStateCode); const [selectedOrganizationId, setSelectedOrganizationId] = useState< string | null >(null); @@ -194,10 +203,7 @@ export default function WebinarCheckoutPage({ ); } } catch (error) { - Sentry.captureException( - error instanceof Error ? error : new Error(String(error)), - { tags: { subsystem: "payments" } }, - ); + reportPaymentsError(error); console.error("Error fetching referral credits:", error); } finally { setIsLoadingCredits(false); @@ -290,6 +296,7 @@ export default function WebinarCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, }); // Handle unified checkout flow using the utility @@ -301,10 +308,7 @@ export default function WebinarCheckoutPage({ isMockPayment, ); } catch (error) { - Sentry.captureException( - error instanceof Error ? error : new Error(String(error)), - { tags: { subsystem: "payments" } }, - ); + reportPaymentsError(error); console.error("Checkout error:", error); if (error instanceof Error) { // Provide more informative error messages based on the error type @@ -356,6 +360,7 @@ export default function WebinarCheckoutPage({ appliedDiscount, useReferralCredits, selectedOrganizationId, + billingState.bodyField, validatedSearchParams, currency, ], @@ -415,11 +420,7 @@ export default function WebinarCheckoutPage({ } catch { return true; } - }, [ - resolvedParams.planId, - validatedSearchParams?.eventId, - toast, - ]); + }, [resolvedParams.planId, validatedSearchParams?.eventId, toast]); useEffect(() => { async function fetchPlanData() { @@ -446,10 +447,7 @@ export default function WebinarCheckoutPage({ ); _setReviews(reviewsData); } catch (error) { - Sentry.captureException( - error instanceof Error ? error : new Error(String(error)), - { tags: { subsystem: "payments" } }, - ); + reportPaymentsError(error); console.error("Error fetching plan data:", error); setError( error instanceof Error @@ -725,6 +723,11 @@ export default function WebinarCheckoutPage({ }} /> + +
    Discount Codes
    @@ -879,6 +882,10 @@ export default function WebinarCheckoutPage({
    Total
    {formatPrice(pricing.total)}
    +
    @@ -889,20 +896,7 @@ export default function WebinarCheckoutPage({ Select your preferred payment method
    - {[ - { - name: "Stripe", - description: "Card payments (international)", - gateway: "STRIPE" as const, - isActive: true, - }, - { - name: "Razorpay", - description: "UPI, cards & bank transfer", - gateway: "RAZORPAY" as const, - isActive: true, - }, - ].map((gateway) => ( + {paymentGateways.map((gateway) => ( @@ -942,6 +936,7 @@ export default function WebinarCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, })} onPaymentSuccess={razorpayHandlers.onPaymentSuccess} onPaymentError={razorpayHandlers.onPaymentError} @@ -962,6 +957,7 @@ export default function WebinarCheckoutPage({ ? false : useReferralCredits, organizationId: selectedOrganizationId ?? undefined, + ...billingState.bodyField, })} onPaymentSuccess={stripeHandlers.onPaymentSuccess} onPaymentError={stripeHandlers.onPaymentError} diff --git a/app/dashboard/consultee/[consulteeId]/(features)/payments/PaymentsTab.tsx b/app/dashboard/consultee/[consulteeId]/(features)/payments/PaymentsTab.tsx index a64f595fc..d7fd14835 100644 --- a/app/dashboard/consultee/[consulteeId]/(features)/payments/PaymentsTab.tsx +++ b/app/dashboard/consultee/[consulteeId]/(features)/payments/PaymentsTab.tsx @@ -63,6 +63,12 @@ interface PaymentItem { refundedPaise: number; /** Server-derived: REFUNDED | PARTIALLY_REFUNDED | PaymentStatus. */ displayStatus: string; + /** #1365 — the statutory tax invoice, when one was issued for this payment. */ + consumerInvoice: { + id: string; + invoiceNumber: string; + issuedAt: string; + } | null; receiptUrl: string | null; expiresAt: string | null; createdAt: string; @@ -203,8 +209,45 @@ function getExpiryInfo(payment: PaymentItem): { }; } +/** + * #1365 — the buyer's own tax invoice for a payment. Defined at module scope + * rather than inside the tab so it is not re-created on every render (S6478). + * An empty cell means the booking was org-sponsored and is invoiced to the + * organization instead, which is the correct answer rather than a missing + * document. + */ +function renderInvoiceCell(payment: PaymentItem) { + if (!payment.consumerInvoice) { + return ; + } + return ( + + {/* Explicit separator: JSX strips the newline between a text node and + the element after it, so the words would otherwise run together. */} + Download{" "} + + {payment.consumerInvoice.invoiceNumber} + + + ); +} + export function PaymentsTab({ data }: { data: PaymentsData | undefined }) { const { formatPrice } = useCurrency(); + + // #1396 — `formatPrice` assumes INR paise and applies the viewer's FX rate, + // so a payment already denominated in another currency was converted a second + // time and relabelled, while its refunds and the per-currency total right + // below were rendered unconverted. The three disagreed on the same row. This + // is the guard `PendingPaymentsWidget` already uses: only INR amounts go + // through the converter, everything else renders in its own currency. + const formatPaymentAmount = (paise: number, currency: string | null | undefined) => + currency && currency.toUpperCase() !== "INR" + ? formatAmountInCurrency(paise, currency) + : formatPrice(paise); const { data: session } = useSession(); // Resolve a payment's `organizationId` to a displayable org name for // the "Sponsored · " badge — same convention as the appointments @@ -302,11 +345,12 @@ export function PaymentsTab({ data }: { data: PaymentsData | undefined }) { cell: (payment) => ( - {formatPrice(payment.amount)} + {formatPaymentAmount(payment.amount, payment.currency)} {payment.taxAmount && payment.taxAmount > 0 && ( - incl. {formatPrice(payment.taxAmount ?? 0)} GST + incl.{" "} + {formatPaymentAmount(payment.taxAmount ?? 0, payment.currency)} GST )} {payment.discount && ( @@ -323,7 +367,7 @@ export function PaymentsTab({ data }: { data: PaymentsData | undefined }) { {" — "} {payment.discount.type === "PERCENTAGE" ? `${payment.discount.value}% off` - : `${formatPrice(payment.discount.value)} off`} + : `${formatPaymentAmount(payment.discount.value, payment.currency)} off`}

    @@ -390,6 +434,11 @@ export function PaymentsTab({ data }: { data: PaymentsData | undefined }) { ); }, }, + { + key: "invoice", + header: "Tax invoice", + cell: renderInvoiceCell, + }, { key: "expires", header: "Expires", diff --git a/app/dashboard/organization/[orgId]/billing/BillingPageClient.tsx b/app/dashboard/organization/[orgId]/billing/BillingPageClient.tsx index 90a23b994..41ad72d02 100644 --- a/app/dashboard/organization/[orgId]/billing/BillingPageClient.tsx +++ b/app/dashboard/organization/[orgId]/billing/BillingPageClient.tsx @@ -60,6 +60,7 @@ import type { OrgReceivablesPayload, } from "@/lib/data/org-receivables"; import { WalletTab } from "./WalletTab"; +import { BillingBlockBanner } from "@/components/billing/BillingBlockBanner"; // --------------------------------------------------------------------------- // Zod schemas — narrow API responses at the network boundary so the rest @@ -67,6 +68,12 @@ import { WalletTab } from "./WalletTab"; // --------------------------------------------------------------------------- const billingSummarySchema = z.object({ + // #1427/#1430 — resolved server-side next to the account read; see + // app/api/organizations/[orgId]/billing/route.ts. + walletFrozen: z.boolean().default(false), + walletFrozenReason: z.string().nullable().default(null), + dunningSuspended: z.boolean().default(false), + dunningSuspendedInvoiceNumber: z.string().nullable().default(null), fundingSource: z .enum(["PERSONAL", "WALLET", "INVOICE", "LICENSE"]) .nullable(), @@ -345,12 +352,23 @@ export function BillingPageClient({ staleTime: 60_000, }); const orgStatus = orgDetails.data?.organization.status; + // #1427/#1430 — a frozen wallet or a dunning-suspended org blocks the same + // pay/top-up affordances as an unverified org; fold both into the one + // gate rather than leaving the buttons live until checkout rejects them. + const walletFrozen = summary.data?.walletFrozen ?? false; + const dunningSuspended = summary.data?.dunningSuspended ?? false; const moneyMoveBlocked = - orgStatus === "PENDING_VERIFICATION" || orgStatus === "SUSPENDED"; - const moneyMoveReason = - orgStatus === "SUSPENDED" - ? "Organization suspended" - : "Verify your organization to move money"; + orgStatus === "PENDING_VERIFICATION" || + orgStatus === "SUSPENDED" || + walletFrozen || + dunningSuspended; + const moneyMoveReason = walletFrozen + ? (summary.data?.walletFrozenReason ?? "Wallet spend is frozen") + : dunningSuspended + ? "Bookings are paused until the overdue invoice is settled" + : orgStatus === "SUSPENDED" + ? "Organization suspended" + : "Verify your organization to move money"; const generateMutation = useMutation({ mutationFn: () => generateInvoice(orgId), @@ -826,9 +844,19 @@ export function BillingPageClient({

    )} + {/* #1427/#1430 — the freeze/dunning states get the shared + banner (with the support link); org-verification status + keeps its own inline reason below. */} + + {/* #779 §B: org can't move money until ACTIVE; surface the reason inline so the disabled Pay/Top-up affordances aren't silent. */} - {moneyMoveBlocked && ( + {moneyMoveBlocked && !walletFrozen && !dunningSuspended && (

    diff --git a/app/dashboard/organization/[orgId]/home/FinanceLeadViewCard.tsx b/app/dashboard/organization/[orgId]/home/FinanceLeadViewCard.tsx index cb076acc2..d6c3efa45 100644 --- a/app/dashboard/organization/[orgId]/home/FinanceLeadViewCard.tsx +++ b/app/dashboard/organization/[orgId]/home/FinanceLeadViewCard.tsx @@ -20,6 +20,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { CreditCard, Wallet, @@ -39,6 +40,7 @@ import { StatCard } from "@/components/dashboard/StatCard"; import { Button } from "@/components/ui/button"; import { DashboardGrid } from "@/components/dashboard/PageScaffold"; import { formatCurrencyAmount } from "@/utils/formatting"; +import { BillingBlockBanner } from "@/components/billing/BillingBlockBanner"; /** * Shape mirrors the live `/api/organizations/[orgId]/analytics` payload @@ -72,6 +74,23 @@ export function FinanceLeadViewCard({ orgId, data }: FinanceLeadViewProps) { const billingHref = `/dashboard/organization/${orgId}/billing`; const pastDueCount = data.invoices?.pastDueCount ?? 0; + // #1427/#1430 — same query key as BillingPageClient's `fetchBilling` + // (["org-billing", orgId]), so the two share react-query's cache instead + // of double-fetching when a finance lead lands here first. The route is + // a cheap DB-side aggregate, not the heavier analytics payload above. + const billingBlock = useQuery({ + queryKey: ["org-billing", orgId], + queryFn: async () => { + const res = await fetch(`/api/organizations/${orgId}/billing`); + if (!res.ok) throw new Error("Failed to load billing status"); + return (await res.json()) as { + walletFrozen: boolean; + walletFrozenReason: string | null; + dunningSuspended: boolean; + }; + }, + }); + const stats: Array<{ label: string; value: string; @@ -97,9 +116,7 @@ export function FinanceLeadViewCard({ orgId, data }: FinanceLeadViewProps) { value: data.wallet ? formatCurrencyAmount(data.wallet.balancePaise, currency) : "—", - subtitle: data.wallet - ? "Available to debit" - : "No wallet configured", + subtitle: data.wallet ? "Available to debit" : "No wallet configured", icon: Wallet, href: `/dashboard/organization/${orgId}/billing?tab=wallet`, cta: "Top up wallet", @@ -124,14 +141,23 @@ export function FinanceLeadViewCard({ orgId, data }: FinanceLeadViewProps) { return ( <> + {billingBlock.data && ( + + )} + Finance overview - Everything you can act on as the finance lead — invoices to - chase, payouts to approve, wallet headroom, PO burndown. - Member and SSO surfaces are intentionally hidden; coordinate - with your OWNER for those. + Everything you can act on as the finance lead — invoices to chase, + payouts to approve, wallet headroom, PO burndown. Member and SSO + surfaces are intentionally hidden; coordinate with your OWNER for + those. @@ -158,9 +184,7 @@ export function FinanceLeadViewCard({ orgId, data }: FinanceLeadViewProps) { variant={ isOutstanding && pastDueCount > 0 ? "danger" : "default" } - onClick={ - deepLinks ? () => router.push(stat.href) : undefined - } + onClick={deepLinks ? () => router.push(stat.href) : undefined} /> {/* Past-due needs more than a number — give the finance lead a one-click jump to the invoices they have to chase. */} @@ -200,13 +224,17 @@ export function FinanceLeadViewCard({ orgId, data }: FinanceLeadViewProps) { ))}

    + {/* #1396 — this is the headline price of a plan the server will + charge, so it renders in the plan's own currency, exactly as + the trial price above it does for the reason given at #1167. + `formatPrice` took INR paise and applied the viewer's FX rate, + which relabelled the plan and disagreed with the trial line + two elements away. */} - {formatPrice(option.price)} + {formatCurrencyAmount(option.price, option.priceCurrency || "INR")} / month
    diff --git a/bugs/compliance/b2c-vs-b2b-gaps.md b/bugs/compliance/b2c-vs-b2b-gaps.md index 6da6eb71a..6c69a4f34 100644 --- a/bugs/compliance/b2c-vs-b2b-gaps.md +++ b/bugs/compliance/b2c-vs-b2b-gaps.md @@ -1,5 +1,7 @@ # B2C vs B2B Compliance Gaps +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 9 claims, 3 are still true today, 3 have been addressed since this dossier was written, and 3 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Enterprise rail: org tax info, invoices, credit notes, MSME alerts, org payout TDS, audit export. Marketplace rail: consultant verification (manual), PAN encryption, payout tax fields — but filing automation and TCS lag. Shipping checklist grades MUST vs DEFER in `docs/compliance/15-india-compliance-shipping-checklist.md`. @@ -8,20 +10,20 @@ Enterprise rail: org tax info, invoices, credit notes, MSME alerts, org payout T Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| Dual TDS engines (#778 planned consolidation) | ❌ STALE — consultant path is already 194-O via `computeTdsForPayout`; `tds-service.ts` is FY-helper/audit only | -| No GSTR-8 aggregator job | 🟡 LEGIT-DEFERRED | -| Refund tax adjustment wiring gaps | ❌ OVERSTATED — TdsAdjustment (via reversal) and GstTcsAdjustment are both wired from `refund.ts`; only monthly `GstTcsBatch` collection deferred | -| Seller disclosure (name/address/GSTIN on public profiles) incomplete | 🟡 LEGIT-DEFERRED | -| RBI PA Path C needs legal confirmation | 🎯 legal/CA gate, not code | -| Form 26Q/140 automation missing | 🔵 TRACKED #737 (code cites #737; audit said #738) | +| Claim (short) | Verdict | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Dual TDS engines (#778 planned consolidation) | ❌ STALE — consultant path is already 194-O via `computeTdsForPayout`; `tds-service.ts` is FY-helper/audit only | +| No GSTR-8 aggregator job | 🟡 LEGIT-DEFERRED | +| Refund tax adjustment wiring gaps | ❌ OVERSTATED — TdsAdjustment (via reversal) and GstTcsAdjustment are both wired from `refund.ts`; only monthly `GstTcsBatch` collection deferred | +| Seller disclosure (name/address/GSTIN on public profiles) incomplete | 🟡 LEGIT-DEFERRED | +| RBI PA Path C needs legal confirmation | 🎯 legal/CA gate, not code | +| Form 26Q/140 automation missing | 🔵 TRACKED #737 (code cites #737; audit said #738) | ## Known gaps / bugs -- Dual TDS engines (#778 planned consolidation). +- Dual TDS engines (#778 planned consolidation). The 2026-09-03 verdict pass marked this stale: the consultant path already runs 194-O via `computeTdsForPayout`, so `tds-service.ts` is FY-helper/audit-only, not a live second engine. - No GSTR-8 aggregator job. -- Refund tax adjustment wiring gaps. +- Refund tax adjustment wiring gaps. The 2026-09-03 verdict pass marked this overstated: `TdsAdjustment` (reversal) and `GstTcsAdjustment` are both wired from `refund.ts`; only the monthly `GstTcsBatch` collection is deferred. - Seller disclosure (legal name, address, GSTIN on public profiles) incomplete for e-commerce rules. - RBI PA Path C needs legal confirmation (finance pack). - Form 26Q/140 automation missing before quarterly deadlines. @@ -33,31 +35,34 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **Block B2C payouts until 194-O unified + TCS plan?** - - A) Hard block - - B) Soft warn + CA escrow - - C) Proceed with documented risk acceptance +1. **Block B2C payouts until 194-O unified + TCS plan?** + - A) Hard block + - B) Soft warn + CA escrow + - C) Proceed with documented risk acceptance + +**Recommendation: A.** Unify TDS before B2C payouts — wrong 194-O path is not acceptable risk at scale. -**Recommendation: A.** Unify TDS before B2C payouts — wrong 194-O path is not acceptable risk at scale. -- Not B: Soft warn still pays out on the wrong engine. +- Not B: Soft warn still pays out on the wrong engine. - Not C: Documented risk acceptance does not fix GSTN notices to consultants. -2. **Public seller disclosure minimum on consultant profile?** - - A) Legal name + address + GSTIN if registered - - B) Display name only - - C) Disclosure only at checkout +2. **Public seller disclosure minimum on consultant profile?** + - A) Legal name + address + GSTIN if registered + - B) Display name only + - C) Disclosure only at checkout -**Recommendation: A.** E-commerce seller disclosure belongs on the public profile, not only at payment. -- Not B: Display name alone fails consumer disclosure expectations. +**Recommendation: A.** E-commerce seller disclosure belongs on the public profile, not only at payment. + +- Not B: Display name alone fails consumer disclosure expectations. - Not C: Checkout-only disclosure is easy to miss and weak for trust/browse. -3. **Who files quarterly TDS returns?** - - A) In-house automation - - B) CA retainer - - C) Hybrid export + CA upload +3. **Who files quarterly TDS returns?** + - A) In-house automation + - B) CA retainer + - C) Hybrid export + CA upload + +**Recommendation: C.** Hybrid export + CA upload is the realistic interim until Form 26Q automation is trustworthy. -**Recommendation: C.** Hybrid export + CA upload is the realistic interim until Form 26Q automation is trustworthy. -- Not A: Full in-house automation is not ready before quarterly deadlines. +- Not A: Full in-house automation is not ready before quarterly deadlines. - Not B: CA-only without clean exports recreates spreadsheet chaos. ## High concurrency / multi-device diff --git a/bugs/cross-cutting/deadlocks-and-inconsistencies.md b/bugs/cross-cutting/deadlocks-and-inconsistencies.md index 0bf29fe71..47847a014 100644 --- a/bugs/cross-cutting/deadlocks-and-inconsistencies.md +++ b/bugs/cross-cutting/deadlocks-and-inconsistencies.md @@ -1,5 +1,7 @@ # Cross-Cutting — Deadlocks & Inconsistencies +> **Verdict pass 2026-09-03/04.** Every money-related claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 8 claims, 4 are still true today, 3 have been addressed since this dossier was written, and 1 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Familiarise avoids classic DB deadlocks with documented Redis lock ordering (consultant/event → consultee → slot) and sorted ledger account updates. Residual pain is less “DB deadlock” and more **distributed inconsistency windows**, **doc/code drift**, and **asymmetric fail modes**. @@ -8,21 +10,21 @@ Familiarise avoids classic DB deadlocks with documented Redis lock ordering (con Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| Payment Phase-2 side effects outside confirm tx (skew healed by crons) | 🔵 by-design (ACK-before-complete + sweeper) | -| Sorted ledger-account locking / deadlock avoidance | 🔵 by-design | -| Consent "stub" comments vs live fail-closed checks | ✅ FIXED-BY #989 (checkConsent docstring corrected) | -| `revokeSession` comment-only vs real mechanism | ✅ FIXED-BY #985 (comment corrected; true revoke follow-up #725) | -| Payment critical-bugs task file vs fixed code (doc drift) | ✅ resolved (task file superseded; code fixed) | -| Subscription status vs per-session slot after partial reschedule (#448) | ✅ FIXED-BY #988 | -| Rating denormalization vs review rows | ✅ FIXED-BY #987 | -| Wallet cache vs ledger journal drift | ✅ FIXED-BY #990 (freeze + page on reconcile `ok=false`) | -| Stream call exists before MeetingSession row (orphan window) | 🟡 LEGIT-DEFERRED | -| BetterAuth `Member` vs `Membership` dual source | 🟡 LEGIT-DEFERRED (large) | -| SCIM/docs vs live implementation drift | 🟡 doc drift (SCIM implemented; docs say parked) | -| Display currency vs INR settlement dual truth | 🔵 TRACKED #783 | -| Novu vs Resend delivery split brain | 🟡 LEGIT-DEFERRED | +| Claim (short) | Verdict | +| ----------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Payment Phase-2 side effects outside confirm tx (skew healed by crons) | 🔵 by-design (ACK-before-complete + sweeper) | +| Sorted ledger-account locking / deadlock avoidance | 🔵 by-design | +| Consent "stub" comments vs live fail-closed checks | ✅ FIXED-BY #989 (checkConsent docstring corrected) | +| `revokeSession` comment-only vs real mechanism | ✅ FIXED-BY #985 (comment corrected; true revoke follow-up #725) | +| Payment critical-bugs task file vs fixed code (doc drift) | ✅ resolved (task file superseded; code fixed) | +| Subscription status vs per-session slot after partial reschedule (#448) | ✅ FIXED-BY #988 | +| Rating denormalization vs review rows | ✅ FIXED-BY #987 | +| Wallet cache vs ledger journal drift | ✅ FIXED-BY #990 (freeze + page on reconcile `ok=false`) | +| Stream call exists before MeetingSession row (orphan window) | 🟡 LEGIT-DEFERRED | +| BetterAuth `Member` vs `Membership` dual source | 🟡 LEGIT-DEFERRED (large) | +| SCIM/docs vs live implementation drift | 🟡 doc drift (SCIM implemented; docs say parked) | +| Display currency vs INR settlement dual truth | 🔵 TRACKED #783 | +| Novu vs Resend delivery split brain | 🟡 LEGIT-DEFERRED | ## Known gaps / bugs @@ -42,7 +44,7 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai - Subscription status vs per-session slot state after partial reschedule (#448). - Rating denormalization vs review rows. - Novu vs Resend delivery split brain. -- SCIM/docs vs live implementation; payment critical-bugs task file vs fixed code; consent “stub” comments vs live checks. +- SCIM/docs vs live implementation drift (doc drift only — SCIM is implemented). Payment critical-bugs task file vs fixed code and consent “stub” comments vs live checks are both resolved per the verdict table above. ### Dual sources of truth @@ -61,52 +63,51 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **How to govern new distributed locks?** - - A) ADR + checklist in PR template - - B) Central lock registry module only - - C) Prefer DB constraints over new Redis locks +1. **How to govern new distributed locks?** + - A) ADR + checklist in PR template + - B) Central lock registry module only + - C) Prefer DB constraints over new Redis locks - **Recommendation: A.** Require an ADR plus PR-template checklist for every new distributed lock so order and fail-open/closed stay reviewable. - - Not B: a registry without process still drifts when someone adds a lock ad hoc - - Not C: DB constraints cannot cover Redis slot and appointment intent races + **Recommendation: A.** Require an ADR plus PR-template checklist for every new distributed lock so order and fail-open/closed stay reviewable. + - Not B: a registry without process still drifts when someone adds a lock ad hoc + - Not C: DB constraints cannot cover Redis slot and appointment intent races -2. **Accept eventual consistency for Phase-2 side effects?** - - A) Yes + status page + SLA - - B) Move earnings inside confirm txn - - C) Outbox pattern with visible pending +2. **Accept eventual consistency for Phase-2 side effects?** + - A) Yes + status page + SLA + - B) Move earnings inside confirm txn + - C) Outbox pattern with visible pending > 🎯 Locked: Phase-2 stays by-design ACK-before-complete with a sweeper/cron backstop; not moved into the confirm txn (B) and no new outbox this wave. + **Historical recommendation (2026-07-12): C.** Use an outbox (or explicit pending status) for Phase-2 earnings/notifications so users see “processing” instead of silent skew. This was superseded by the 2026-09-03 Locked decision above, which keeps ACK-before-complete with no new outbox this wave. + - Not A: a status page alone still leaves confirm→side-effect gaps invisible in-product + - Not B: stuffing earnings into confirm lengthens ACK windows and timeouts - **Recommendation: C.** Use an outbox (or explicit pending status) for Phase-2 earnings/notifications so users see “processing” instead of silent skew. - - Not A: a status page alone still leaves confirm→side-effect gaps invisible in-product - - Not B: stuffing earnings into confirm lengthens ACK windows and timeouts - -3. **Doc drift process?** - - A) Docs CI check against flags/paths - - B) Quarterly audit only - - C) Delete stale task files when fixed +3. **Doc drift process?** + - A) Docs CI check against flags/paths + - B) Quarterly audit only + - C) Delete stale task files when fixed - **Recommendation: C.** Delete stale task docs when bugs are fixed so ops never follows a runbook that disagrees with code. - - Not A: full docs CI is heavier process than Familiarise needs right now - - Not B: quarterly-only lets wrong payment/security docs linger for months + **Recommendation: C.** Delete stale task docs when bugs are fixed so ops never follows a runbook that disagrees with code. + - Not A: full docs CI is heavier process than Familiarise needs right now + - Not B: quarterly-only lets wrong payment/security docs linger for months -4. **Unify dual truth pairs?** - - A) Hard deprecate BetterAuth Member fields in app logic - - B) Keep bridge forever - - C) Generate Membership from Member only +4. **Unify dual truth pairs?** + - A) Hard deprecate BetterAuth Member fields in app logic + - B) Keep bridge forever + - C) Generate Membership from Member only - **Recommendation: A.** Hard-deprecate BetterAuth `Member` fields in app logic and lean on `Membership` — dual truth is how role bugs keep returning. - - Not B: keeping the bridge forever preserves every UI misuse of the wrong role model - - Not C: generating Membership from Member only still couples product auth to BetterAuth’s shape + **Recommendation: A.** Hard-deprecate BetterAuth `Member` fields in app logic and lean on `Membership` — dual truth is how role bugs keep returning. + - Not B: keeping the bridge forever preserves every UI misuse of the wrong role model + - Not C: generating Membership from Member only still couples product auth to BetterAuth’s shape ## High concurrency / multi-device -Under spike, inconsistency windows lengthen (sweeper lag, pool wait, Stream breaker open). Multi-device users observe *different slices* of the window and conclude the system is random. +Under spike, inconsistency windows lengthen (sweeper lag, pool wait, Stream breaker open). Multi-device users observe _different slices_ of the window and conclude the system is random. ## Suggested directions -1. Outbox or explicit “pending side effects” for payment Phase-2. -2. PR template: lock order + idempotency key + fail-open/closed choice. -3. Monthly “doc drift” pass on flags, SCIM, payment bug register, compliance stubs. +1. Outbox or explicit “pending side effects” for payment Phase-2. +2. PR template: lock order + idempotency key + fail-open/closed choice. +3. Monthly “doc drift” pass on flags, SCIM, payment bug register, compliance stubs. 4. Prefer one user-visible status model for “money vs booking vs meeting” alignment. diff --git a/bugs/enterprise/compliance-kyb-gst-tds.md b/bugs/enterprise/compliance-kyb-gst-tds.md index 3b29e751c..3988642f7 100644 --- a/bugs/enterprise/compliance-kyb-gst-tds.md +++ b/bugs/enterprise/compliance-kyb-gst-tds.md @@ -1,5 +1,7 @@ # Enterprise Compliance — KYB, GST, TDS, MSME, IRP +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 12 claims, 7 are still true today, 4 have been addressed since this dossier was written, and 1 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Enterprise buyers expect audit-ready invoices, TDS on org payouts, MSME timelines, and eventually IRN. Schema and helpers are deep ([`lib/compliance/`](../../lib/compliance/), org tax models, invoice counters). Several **gates are UI/docs only** — money APIs still move without KYB/domain hard checks. That is incompatible with “enterprise respect.” @@ -8,30 +10,30 @@ Enterprise buyers expect audit-ready invoices, TDS on org payouts, MSME timeline Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| K-01 KYB not a hard gate on INVOICE | 🔵/✅ partial via #991 (domain gate) | -| K-02 `assertVerifiedDomainOrThrow` unwired | ✅ FIXED-BY #991 (INVOICE now hard-requires verified domain) | -| K-03 IRP uploader gated / stub | 🔵 TRACKED #713 | -| K-04 `requireActive` inconsistent across money surfaces | 🟡 LEGIT-DEFERRED | -| K-05 MSME §16 interest not accrued | 🟡 LEGIT-DEFERRED | -| K-06 `TdsAdjustment` / Form 26Q export schema-only | 🔵 TRACKED #737 (audit cited #738 — misattribution) | -| K-07 Dual TDS engines vs B2C deprecated 194J path | ❌ STALE (194-O already live via `computeTdsForPayout`) | -| K-08 Invoice GST not per-line; credit-note length | 🟡 LEGIT-DEFERRED | -| Refund tax cascade incomplete (implied) | ❌ OVERSTATED (`TdsAdjustment` + `GstTcsAdjustment` both wired) | +| Claim (short) | Verdict | +| ------------------------------------------------------- | --------------------------------------------------------------- | +| K-01 KYB not a hard gate on INVOICE | 🔵/✅ partial via #991 (domain gate) | +| K-02 `assertVerifiedDomainOrThrow` unwired | ✅ FIXED-BY #991 (INVOICE now hard-requires verified domain) | +| K-03 IRP uploader gated / stub | 🔵 TRACKED #713 | +| K-04 `requireActive` inconsistent across money surfaces | 🟡 LEGIT-DEFERRED | +| K-05 MSME §16 interest not accrued | 🟡 LEGIT-DEFERRED | +| K-06 `TdsAdjustment` / Form 26Q export schema-only | 🔵 TRACKED #737 (audit cited #738 — misattribution) | +| K-07 Dual TDS engines vs B2C deprecated 194J path | ❌ STALE (194-O already live via `computeTdsForPayout`) | +| K-08 Invoice GST not per-line; credit-note length | 🟡 LEGIT-DEFERRED | +| Refund tax cascade incomplete (implied) | ❌ OVERSTATED (`TdsAdjustment` + `GstTcsAdjustment` both wired) | ## Known gaps / bugs -| ID | Severity | Issue | -|----|----------|-------| -| K-01 | **P0** | `OrgKybVerification.kybVerifiedAt` / Sumsub fields — **not a hard gate** on INVOICE booking, invoice issue, or funding-source switch | -| K-02 | **P1** | `assertVerifiedDomainOrThrow` for INVOICE_FUNDING **defined but unwired** on critical routes | -| K-03 | **P1** | IRP uploader gated / stub without ClearTax — B2B ITC buyers may require IRN | -| K-04 | **P1** | Manual invoice POST / rollup paths weak on `requireActive` vs top-up which requires ACTIVE | -| K-05 | **P2** | MSME `mustPayByDate` alerts live; §16 interest not accrued; deadline semantics soft | -| K-06 | **P2** | `TdsAdjustment` / Form 26Q export largely schema-only | -| K-07 | **P2** | Dual TDS engines vs B2C deprecated path — keep B2B on `lib/compliance/tds.ts` only | -| K-08 | **P2** | Invoice GST not always per-line; credit-note length limits | +| ID | Severity | Issue | +| ---- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| K-01 | **P0** | `OrgKybVerification.kybVerifiedAt` / Sumsub fields — **not a hard gate** on INVOICE booking, invoice issue, or funding-source switch | +| K-02 | ✅ fixed | `assertVerifiedDomainOrThrow` for INVOICE_FUNDING — the 2026-09-03 verdict pass confirmed FIXED-BY #991: INVOICE now hard-requires a verified domain. | +| K-03 | **P1** | IRP uploader gated / stub without ClearTax — B2B ITC buyers may require IRN | +| K-04 | **P1** | Manual invoice POST / rollup paths weak on `requireActive` vs top-up which requires ACTIVE | +| K-05 | **P2** | MSME `mustPayByDate` alerts live; §16 interest not accrued; deadline semantics soft | +| K-06 | **P2** | `TdsAdjustment` / Form 26Q export largely schema-only | +| K-07 | ❌ stale | Dual TDS engines vs B2C deprecated path — the 2026-09-03 verdict pass marked this stale: 194-O is already live via `computeTdsForPayout`. | +| K-08 | **P2** | Invoice GST not always per-line; credit-note length limits | Working well: GST derive helper, sequential invoice numbers, org payout TDS 194-O compute, MSME alert cron, credit note mint on refund. @@ -44,31 +46,34 @@ Working well: GST derive helper, sequential invoice numbers, org payout TDS 194- ## Questions (handled?) -1. **Hard-gate INVOICE on KYB + verified domain?** - - A) Yes on funding switch, checkout, invoice issue - - B) Soft checklist forever - - C) Cap-only for unverified +1. **Hard-gate INVOICE on KYB + verified domain?** + - A) Yes on funding switch, checkout, invoice issue + - B) Soft checklist forever + - C) Cap-only for unverified + +**Recommendation: A.** Enterprise INVOICE without KYB/domain is a fraud and reputation hole. -**Recommendation: A.** Enterprise INVOICE without KYB/domain is a fraud and reputation hole. -- Not B: Checklists do not stop API clients. +- Not B: Checklists do not stop API clients. - Not C: Cap is blast-radius control, not identity assurance. -2. **IRP before enterprise invoice GA?** - - A) Enable for orgs above AATO / all B2B - - B) PDF-only until first audit ask - - C) Per-customer ClearTax allowlist +2. **IRP before enterprise invoice GA?** + - A) Enable for orgs above AATO / all B2B + - B) PDF-only until first audit ask + - C) Per-customer ClearTax allowlist -**Recommendation: C then A.** Allowlist design partners who need IRN; expand when ClearTax prod-ready. -- Not B if selling to GST-registered enterprises that demand IRN now. +**Recommendation: C then A.** Allowlist design partners who need IRN; expand when ClearTax prod-ready. + +- Not B if selling to GST-registered enterprises that demand IRN now. - A immediately only if ops ready. -3. **MSME payment SLA productization?** - - A) Prioritize MSME payouts in batch + surface mustPayByDate in UI - - B) Alerts-only (current-ish) - - C) Accrue statutory interest in ledger +3. **MSME payment SLA productization?** + - A) Prioritize MSME payouts in batch + surface mustPayByDate in UI + - B) Alerts-only (current-ish) + - C) Accrue statutory interest in ledger + +**Recommendation: A.** UI + batch priority first; interest accrual (C) after legal sign-off. -**Recommendation: A.** UI + batch priority first; interest accrual (C) after legal sign-off. -- Not B alone at host-agency scale. +- Not B alone at host-agency scale. - C without legal template is premature. ## High concurrency / multi-device / spikes @@ -77,6 +82,6 @@ Invoice numbering must stay gapless under concurrent rollups (Serializable prese ## Suggested directions -1. Wire domain + KYB asserts on INVOICE paths. -2. Align `requireActive` across money surfaces. +1. Wire domain + KYB asserts on INVOICE paths. +2. Align `requireActive` across money surfaces. 3. ClearTax allowlist for IRP; keep TDS on single engine for org payouts. diff --git a/bugs/enterprise/concurrency-deadlocks-spikes.md b/bugs/enterprise/concurrency-deadlocks-spikes.md index 810190596..3f5d8c0e2 100644 --- a/bugs/enterprise/concurrency-deadlocks-spikes.md +++ b/bugs/enterprise/concurrency-deadlocks-spikes.md @@ -1,5 +1,7 @@ # Enterprise Concurrency, Deadlocks & Traffic Spikes +> **Verdict pass 2026-09-03/04.** Every money-related claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 5 claims, 3 are still true today, 2 have been addressed since this dossier was written, and 0 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Enterprise concurrency is **Postgres-native** by ADR 13: conditional UPDATEs, unique constraints, sorted ledger account locks. Serializable appears on checkout, some governance, and crons — not on every point mutation. Redis locks protect booking/checkout and cron exclusion. Documented intent: [`docs/enterprise/30-programs-and-lifecycle/01-concurrency-and-idempotency.md`](../../docs/enterprise/30-programs-and-lifecycle/01-concurrency-and-idempotency.md). Chaos go/no-go covers B2C-heavy races; **enterprise 14c (seats, invoice void, wallet top-up replay) is staged, not blocking**. @@ -8,42 +10,42 @@ Enterprise concurrency is **Postgres-native** by ADR 13: conditional UPDATEs, un Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| X-01 chaos 14c unstaged | 🟡 LEGIT-DEFERRED (staging chaos gate remains) | -| X-02 SSO PATCH last-write-wins (has `version`) | ✅ FIXED-BY #985 (`expectedVersion` CAS) | -| X-03 SCIM bypasses unverified seat governance | ✅ FIXED-BY #985 | -| X-04 `revokeSession` on member removal comment-only | ✅ FIXED-BY #985 (comment corrected to real bump; TRUE revoke blocked — BetterAuth admin plugin not installed → follow-up #725) | -| X-05 no contractual seat ceiling at assign | 🟡 LEGIT-DEFERRED | -| X-06 JIT SSO auto-join may not bump `sessionGeneration` | 🟡 LEGIT-DEFERRED | -| X-07 long Serializable checkout + pool pressure (#368) | 🔵 TRACKED #368 (CLOSED — pooler fix landed) | -| X-08 CREDIT_POOL lacks reserve-hold-TTL | 🟡 LEGIT-DEFERRED | +| Claim (short) | Verdict | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| X-01 chaos 14c unstaged | 🟡 LEGIT-DEFERRED (staging chaos gate remains) | +| X-02 SSO PATCH last-write-wins (has `version`) | ✅ FIXED-BY #985 (`expectedVersion` CAS) | +| X-03 SCIM bypasses unverified seat governance | ✅ FIXED-BY #985 | +| X-04 `revokeSession` on member removal comment-only | ✅ FIXED-BY #985 (comment corrected to real bump; TRUE revoke blocked — BetterAuth admin plugin not installed → follow-up #725) | +| X-05 no contractual seat ceiling at assign | 🟡 LEGIT-DEFERRED | +| X-06 JIT SSO auto-join may not bump `sessionGeneration` | 🟡 LEGIT-DEFERRED | +| X-07 long Serializable checkout + pool pressure (#368) | 🔵 TRACKED #368 (CLOSED — pooler fix landed) | +| X-08 CREDIT_POOL lacks reserve-hold-TTL | 🟡 LEGIT-DEFERRED | ## What works -| Path | Pattern | -|------|---------| -| Wallet debit | `updateMany WHERE walletBalance >= amount` | -| Engagement / credit cap BLOCK | Guarded `updateMany` on used/consumed | -| Assignment claim | `createMany skipDuplicates` + unique (program, membership, period) | -| Invite accept | Atomic pending→accepted claim + P2002 retry | -| Org capability flip | `expectedVersion` CAS + Serializable wind-down | -| Ledger postings | Sorted `accountId` balance updates | -| Invoice void vs pay | Status CAS | -| Org payout batch | Redis org lock + idempotency key | +| Path | Pattern | +| ----------------------------- | ------------------------------------------------------------------ | +| Wallet debit | `updateMany WHERE walletBalance >= amount` | +| Engagement / credit cap BLOCK | Guarded `updateMany` on used/consumed | +| Assignment claim | `createMany skipDuplicates` + unique (program, membership, period) | +| Invite accept | Atomic pending→accepted claim + P2002 retry | +| Org capability flip | `expectedVersion` CAS + Serializable wind-down | +| Ledger postings | Sorted `accountId` balance updates | +| Invoice void vs pay | Status CAS | +| Org payout batch | Redis org lock + idempotency key | ## Known gaps / bugs -| ID | Severity | Issue | -|----|----------|-------| -| X-01 | **P1** | Chaos **14c unstaged** — N+1 seat assign, invoice generate-vs-void, wallet top-up replay not in go/no-go suite | -| X-02 | **P1** | SSO settings have `version` column but PATCH is **last-write-wins** (multi-admin) | -| X-03 | **P1** | SCIM provisioning **bypasses** unverified 5-seat invite governance | -| X-04 | **P1** | Docs claim `revokeSession` on member removal — **not implemented**; 5-min cookie membership lag | -| X-05 | **P2** | No contractual seat ceiling at assign — counter honest, commercial cap absent | -| X-06 | **P2** | JIT SSO auto-join may not bump `sessionGeneration` — sidebar lag | -| X-07 | **P2** | Long Serializable checkout + Redis event lock → timeouts under spike (pool #368), looks like deadlock | -| X-08 | **P2** | CREDIT_POOL lacks reserve-hold-TTL pattern used in industry credit engines — fine now, fragile at massive parallel enroll | +| ID | Severity | Issue | +| ---- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| X-01 | **P1** | Chaos **14c unstaged** — N+1 seat assign, invoice generate-vs-void, wallet top-up replay not in go/no-go suite | +| X-02 | ✅ fixed | SSO settings PATCH — the 2026-09-03 verdict pass confirmed FIXED-BY #985: PATCH now uses `expectedVersion` CAS. | +| X-03 | ✅ fixed | SCIM provisioning vs unverified 5-seat invite governance — the 2026-09-03 verdict pass confirmed FIXED-BY #985. | +| X-04 | ✅ fixed | `revokeSession` on member removal — the 2026-09-03 verdict pass confirmed FIXED-BY #985 (comment corrected to the real session-generation bump; true revoke is blocked on the BetterAuth admin plugin, follow-up #725). | +| X-05 | **P2** | No contractual seat ceiling at assign — counter honest, commercial cap absent | +| X-06 | **P2** | JIT SSO auto-join may not bump `sessionGeneration` — sidebar lag | +| X-07 | **P2** | Long Serializable checkout + Redis event lock → timeouts under spike (pool #368), looks like deadlock | +| X-08 | **P2** | CREDIT_POOL lacks reserve-hold-TTL pattern used in industry credit engines — fine now, fragile at massive parallel enroll | ## Unhappy paths & multi-device psychology @@ -62,31 +64,34 @@ True AB-BA DB deadlocks are mitigated by sorted ledger locks. Residual “deadlo ## Questions (handled?) -1. **Stage chaos 14c before enterprise GTM?** - - A) Yes — hard gate - - B) Service-level tests enough - - C) Only after first design partner +1. **Stage chaos 14c before enterprise GTM?** + - A) Yes — hard gate + - B) Service-level tests enough + - C) Only after first design partner + +**Recommendation: A.** Enterprise money paths deserve the same go/no-go rigor as booking races. -**Recommendation: A.** Enterprise money paths deserve the same go/no-go rigor as booking races. -- Not B: Unit CAS ≠ API storm behavior. +- Not B: Unit CAS ≠ API storm behavior. - Not C: First partner should not be the load test. -2. **SSO settings CAS?** - - A) Wire `expectedVersion` like org PATCH - - B) Accept last-write-wins - - C) Single-admin lockout for SSO edits +2. **SSO settings CAS?** + - A) Wire `expectedVersion` like org PATCH + - B) Accept last-write-wins + - C) Single-admin lockout for SSO edits -**Recommendation: A.** Security settings must not silently clobber across devices. -- Not B: EnforceSSO flip is too dangerous for LWW. +**Recommendation: A.** Security settings must not silently clobber across devices. + +- Not B: EnforceSSO flip is too dangerous for LWW. - Not C: Overkill if version CAS works. -3. **Seat ceiling enforcement?** - - A) Enforce purchased seats at assignment - - B) Bill whatever counter says (current) - - C) Soft warn only +3. **Seat ceiling enforcement?** + - A) Enforce purchased seats at assignment + - B) Bill whatever counter says (current) + - C) Soft warn only + +**Recommendation: A.** Fail closed on revenue seats under concurrency (industry standard). -**Recommendation: A.** Fail closed on revenue seats under concurrency (industry standard). -- Not B: Over-assign then dispute is predictable gaming. +- Not B: Over-assign then dispute is predictable gaming. - Not C: Warns don’t stop scripts. ## High concurrency / multi-device / spikes @@ -95,7 +100,7 @@ Millions of users: bottleneck is **Postgres pool + Serializable checkout**, not ## Suggested directions -1. Implement and green-bar chaos 14c. -2. SSO version CAS; SCIM seat governance parity. -3. Implement or delete `revokeSession` docs. +1. Implement and green-bar chaos 14c. +2. SSO version CAS; SCIM seat governance parity. +3. Implement or delete `revokeSession` docs. 4. Plan CREDIT_POOL reserve-hold if enroll becomes long-running. diff --git a/bugs/enterprise/money-checkout-wallet-invoice.md b/bugs/enterprise/money-checkout-wallet-invoice.md index 6e0e0071c..4189aa689 100644 --- a/bugs/enterprise/money-checkout-wallet-invoice.md +++ b/bugs/enterprise/money-checkout-wallet-invoice.md @@ -1,5 +1,7 @@ # Enterprise Money — Checkout, Wallet, Invoice, License +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 9 claims, 2 are still true today, 7 have been addressed since this dossier was written, and 0 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Org-funded checkout ([`lib/payments/operations/checkout.ts`](../../lib/payments/operations/checkout.ts)) resolves membership + program, then funds via WALLET (conditional debit), INVOICE (accrual leg + credit-limit recheck inside Serializable tx), or LICENSE (zero-amount leg + utilization). Ledger `BOOKING` posts usually via `createEarningsFromPayment` **after** checkout commit (try/catch + cron heal). Enterprise trust requires books to match what finance sees in wallet and invoices. @@ -8,25 +10,25 @@ Org-funded checkout ([`lib/payments/operations/checkout.ts`](../../lib/payments/ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| C-01 checkout↔ledger non-atomic → `WALLET_BALANCE_DRIFT` / missing earnings | ✅ FIXED-BY #994 | -| C-02 payment leg sum mismatch warn-only | 🟡 LEGIT-DEFERRED (nightly reconcile backstops) | -| C-03 INVOICE booking for PENDING_VERIFICATION without hard KYB | ✅ partial via #991 (domain gate) | -| C-04 CHARGE_MEMBER on non-INVOICE parent fail-closed (#715) | 🔵 TRACKED #715 | -| C-05 auto-top-up notify-only (#777) | 🔵 TRACKED #777 | -| C-06 dunning suspend behind flag | 🔵 TRACKED #779 | +| Claim (short) | Verdict | +| --------------------------------------------------------------------------- | ----------------------------------------------- | +| C-01 checkout↔ledger non-atomic → `WALLET_BALANCE_DRIFT` / missing earnings | ✅ FIXED-BY #994 | +| C-02 payment leg sum mismatch warn-only | 🟡 LEGIT-DEFERRED (nightly reconcile backstops) | +| C-03 INVOICE booking for PENDING_VERIFICATION without hard KYB | ✅ partial via #991 (domain gate) | +| C-04 CHARGE_MEMBER on non-INVOICE parent fail-closed (#715) | 🔵 TRACKED #715 | +| C-05 auto-top-up notify-only (#777) | 🔵 TRACKED #777 | +| C-06 dunning suspend behind flag | 🔵 TRACKED #779 | ## Known gaps / bugs -| ID | Severity | Issue | -|----|----------|-------| -| C-01 | **P0** | Wallet/legs commit in checkout tx; earnings + `BOOKING` journal in a later swallowed try/catch → transient `WALLET_BALANCE_DRIFT` / missing earnings | -| C-02 | **P1** | Payment leg sum mismatch is **warn-only** at checkout (nightly reconcile catches) | -| C-03 | **P1** | INVOICE booking allowed for `PENDING_VERIFICATION` under ₹50k-ish governance cap without hard KYB | -| C-04 | **P2** | CHARGE_MEMBER on non-INVOICE parent fail-closed (#715) — correct but misconfig hard-fails | -| C-05 | **P2** | Auto-top-up schema present; cron is **notify-only** (#777) | -| C-06 | **P2** | Dunning reminders live; booking suspend behind `ENABLE_DUNNING_SUSPEND` (off) | +| ID | Severity | Issue | +| ---- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| C-01 | ✅ fixed | Wallet/legs vs earnings + `BOOKING` journal — the 2026-09-03 verdict pass confirmed FIXED-BY #994; checkout and the journal are no longer split across a swallowed try/catch. | +| C-02 | **P1** | Payment leg sum mismatch is **warn-only** at checkout (nightly reconcile catches) | +| C-03 | **P1** | INVOICE booking allowed for `PENDING_VERIFICATION` under ₹50k-ish governance cap without hard KYB | +| C-04 | **P2** | CHARGE_MEMBER on non-INVOICE parent fail-closed (#715) — correct but misconfig hard-fails | +| C-05 | **P2** | Auto-top-up schema present; cron is **notify-only** (#777) | +| C-06 | **P2** | Dunning reminders live; booking suspend behind `ENABLE_DUNNING_SUSPEND` (off) | Working well: wallet `updateMany WHERE balance >= amount`; in-tx INVOICE exposure re-check; LICENSE metering without money legs; Redis + Serializable for slots/capacity. @@ -39,31 +41,34 @@ Working well: wallet `updateMany WHERE balance >= amount`; in-tx INVOICE exposur ## Questions (handled?) -1. **Org-sponsored checkout + earnings + ledger atomicity?** - - A) Single Serializable tx for org paths - - B) Compensating wallet credit if earnings fail - - C) Keep try/catch + nightly heal +1. **Org-sponsored checkout + earnings + ledger atomicity?** + - A) Single Serializable tx for org paths + - B) Compensating wallet credit if earnings fail + - C) Keep try/catch + nightly heal + +**Recommendation: A (or B as interim).** Enterprise cannot respect “eventual books”; prefer one tx, or immediate compensating credit + alert. -**Recommendation: A (or B as interim).** Enterprise cannot respect “eventual books”; prefer one tx, or immediate compensating credit + alert. -- Not C: Cron heal is fine for B2C noise; fatal for design-partner CFOs. +- Not C: Cron heal is fine for B2C noise; fatal for design-partner CFOs. - B acceptable short-term if A is large; never silent swallow alone. -2. **Leg sum mismatch — throw or warn?** - - A) Hard throw / 500 abort checkout - - B) Warn + ship (current) - - C) Quarantine payment to manual review state +2. **Leg sum mismatch — throw or warn?** + - A) Hard throw / 500 abort checkout + - B) Warn + ship (current) + - C) Quarantine payment to manual review state -**Recommendation: A.** Bad legs must not reach SUCCEEDED — overnight reconcile is too late for trust. -- Not B: Warn-only ships corrupt money shapes. +**Recommendation: A.** Bad legs must not reach SUCCEEDED — overnight reconcile is too late for trust. + +- Not B: Warn-only ships corrupt money shapes. - Not C: Quarantine is heavier than fail-closed at write time. -3. **INVOICE for PENDING_VERIFICATION orgs?** - - A) Block until ACTIVE + KYB/domain - - B) Keep ₹50k pilot cap - - C) Cap + park all payables (see payouts file) +3. **INVOICE for PENDING_VERIFICATION orgs?** + - A) Block until ACTIVE + KYB/domain + - B) Keep ₹50k pilot cap + - C) Cap + park all payables (see payouts file) + +**Recommendation: C near-term, A for GA.** Cap alone still creates platform liability if consultants accrue; park payables or block. -**Recommendation: C near-term, A for GA.** Cap alone still creates platform liability if consultants accrue; park payables or block. -- Not B alone: Cap limits blast radius but still burns trust and cash. +- Not B alone: Cap limits blast radius but still burns trust and cash. - A alone without payable parking still needed for GA. ## High concurrency / multi-device / spikes @@ -72,6 +77,6 @@ Month-end + cohort enroll: many Serializable checkouts + wallet row contention ## Suggested directions -1. Unify org checkout money write with ledger/earnings. -2. Throw on leg sum mismatch. +1. Unify org checkout money write with ledger/earnings. +2. Throw on leg sum mismatch. 3. Pair with KYB + PENDING_TRUST sponsor-scope fixes (sibling files). diff --git a/bugs/enterprise/money-payouts-earnings-trust.md b/bugs/enterprise/money-payouts-earnings-trust.md index 07be00441..4e4c45f69 100644 --- a/bugs/enterprise/money-payouts-earnings-trust.md +++ b/bugs/enterprise/money-payouts-earnings-trust.md @@ -1,5 +1,7 @@ # Enterprise Money — Payouts, Earnings & Trust Parking +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 10 claims, 2 are still true today, 8 have been addressed since this dossier was written, and 0 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Successful org/host flows create `OrganizationEarnings` + `ConsultantEarnings`, hold, then batch into `OrganizationPayout` / `ConsultantPayout`. `PENDING_TRUST` was meant to park payables when **unverified INVOICE sponsors** ghost. Live disbursement requires `ENABLE_LIVE_PAYOUTS`. Host split requires `ENABLE_HOST_ORGS`. This file is about **whether enterprise money-out tells the truth**. @@ -10,27 +12,27 @@ Key: [`lib/payments/payouts/earnings-service.ts`](../../lib/payments/payouts/ear Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| E-01 PENDING_TRUST scopes host not sponsoring org | ✅ FIXED-BY #991 | -| E-02 consultant earnings for ghost INVOICE not parked | ✅ FIXED-BY #991 | -| E-03 earnings flip PAID at batch creation | ✅ FIXED-BY #993 (BATCHED status) | -| E-04 LIVE_PAYOUTS off → batches PAID without UTR | ✅ FIXED-BY #993 | -| E-05 post-COMPLETED clawback manual; org-side TDS reversal | 🟡 LEGIT-DEFERRED | -| E-06 no RazorpayX balance pre-check before batch | 🟡 LEGIT-DEFERRED | -| E-07 poller vs webhook reverse-status edges | 🟡 LEGIT-DEFERRED | +| Claim (short) | Verdict | +| ---------------------------------------------------------- | --------------------------------- | +| E-01 PENDING_TRUST scopes host not sponsoring org | ✅ FIXED-BY #991 | +| E-02 consultant earnings for ghost INVOICE not parked | ✅ FIXED-BY #991 | +| E-03 earnings flip PAID at batch creation | ✅ FIXED-BY #993 (BATCHED status) | +| E-04 LIVE_PAYOUTS off → batches PAID without UTR | ✅ FIXED-BY #993 | +| E-05 post-COMPLETED clawback manual; org-side TDS reversal | 🟡 LEGIT-DEFERRED | +| E-06 no RazorpayX balance pre-check before batch | 🟡 LEGIT-DEFERRED | +| E-07 poller vs webhook reverse-status edges | 🟡 LEGIT-DEFERRED | ## Known gaps / bugs -| ID | Severity | Issue | -|----|----------|-------| -| E-01 | **P0** | `PENDING_TRUST` scopes **host** `orgSplit.organizationId`, not sponsoring `payment.organizationId` — ghost INVOICE sponsor + ACTIVE host still accrues host share | -| E-02 | **P0** | Marketplace / consultant earnings for unverified INVOICE org bookings are **not parked** — platform can owe experts for unpaid sponsor invoices | -| E-03 | **P1** | Earnings flipped to **PAID when batch is created**, before gateway wire / COMPLETED | -| E-04 | **P1** | `ENABLE_LIVE_PAYOUTS` off → batches exist, no UTR — trust erosion if UI says paid | -| E-05 | **P2** | Org clawback after COMPLETED payout is manual; TDS org-side reversal incomplete | -| E-06 | **P2** | No RazorpayX balance pre-check before batch | -| E-07 | **P2** | Poller vs webhook reverse-status edge cases on payouts | +| ID | Severity | Issue | +| ---- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| E-01 | ✅ fixed | `PENDING_TRUST` scoping — the 2026-09-03 verdict pass confirmed FIXED-BY #991: it now scopes the sponsoring `payment.organizationId`, not the host. | +| E-02 | ✅ fixed | Marketplace / consultant earnings for unverified INVOICE org bookings — the 2026-09-03 verdict pass confirmed FIXED-BY #991: these now park. | +| E-03 | ✅ fixed | Earnings-flip timing — the 2026-09-03 verdict pass confirmed FIXED-BY #993: earnings go BATCHED at batch creation and only PAID after the gateway wire completes. | +| E-04 | ✅ fixed | `ENABLE_LIVE_PAYOUTS` off + no UTR — the 2026-09-03 verdict pass confirmed FIXED-BY #993, tied to the same BATCHED-status change as E-03. | +| E-05 | **P2** | Org clawback after COMPLETED payout is manual; TDS org-side reversal incomplete | +| E-06 | **P2** | No RazorpayX balance pre-check before batch | +| E-07 | **P2** | Poller vs webhook reverse-status edge cases on payouts | ## Unhappy paths & multi-device psychology @@ -41,35 +43,38 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **Fix PENDING_TRUST scope before any INVOICE sponsor GA?** - - A) Park consultant + correct sponsor org id; or block checkout until ACTIVE/KYB - - B) Keep current host-scoped park - - C) Rely on ₹50k cap only +1. **Fix PENDING_TRUST scope before any INVOICE sponsor GA?** + - A) Park consultant + correct sponsor org id; or block checkout until ACTIVE/KYB + - B) Keep current host-scoped park + - C) Rely on ₹50k cap only + +**Recommendation: A.** Mis-scoped park is an existential balance-sheet bug for enterprise respect. -**Recommendation: A.** Mis-scoped park is an existential balance-sheet bug for enterprise respect. -- Not B: Current behavior does not match ADR intent. +- Not B: Current behavior does not match ADR intent. - Not C: Cap limits size, not wrong payables. -2. **When to mark earnings PAID?** - - A) Only on payout COMPLETED + UTR - - B) At batch creation (current) - - C) Intermediate status `BATCHED` then `PAID` +2. **When to mark earnings PAID?** + - A) Only on payout COMPLETED + UTR + - B) At batch creation (current) + - C) Intermediate status `BATCHED` then `PAID` > 🎯 Locked: BATCHED status (rec C, shipped #993) — earnings go BATCHED at batch creation and only PAID after the gateway wire completes. -**Recommendation: C (or A).** Introduce `BATCHED`/`IN_FLIGHT` so UI never lies; `PAID` only after wire. -- Not B: “Paid” before cash is how enterprise trust dies. +**Recommendation: C (or A).** Introduce `BATCHED`/`IN_FLIGHT` so UI never lies; `PAID` only after wire. + +- Not B: “Paid” before cash is how enterprise trust dies. - A alone may be enough if batch UI uses non-PAID labels. -3. **Live payouts go-live coupling?** - - A) With HOST flag + Path C CA memo + sandbox UTR - - B) Enable anytime for consultant-only - - C) Stay manual bank forever +3. **Live payouts go-live coupling?** + - A) With HOST flag + Path C CA memo + sandbox UTR + - B) Enable anytime for consultant-only + - C) Stay manual bank forever > 🎯 Locked: sponsor-first — the live-payouts flip couples with the host flag as one go-live program after the sponsor rail ships. -**Recommendation: A.** One runbook: host split + live payouts + CA Path C + UTR proof. -- Not B: Consultant-only still needs trust parking + TDS correctness. +**Recommendation: A.** One runbook: host split + live payouts + CA Path C + UTR proof. + +- Not B: Consultant-only still needs trust parking + TDS correctness. - Not C: Manual does not scale past design partners. ## High concurrency / multi-device / spikes @@ -78,6 +83,6 @@ Batch creation uses Redis org lock + Serializable + idempotency keys — solid u ## Suggested directions -1. Re-implement PENDING_TRUST against `payment.organizationId` + park `ConsultantEarnings`. -2. Rename/split PAID vs BATCHED. +1. Re-implement PENDING_TRUST against `payment.organizationId` + park `ConsultantEarnings`. +2. Rename/split PAID vs BATCHED. 3. Dual-flag go-live runbook before host agency sales. diff --git a/bugs/enterprise/money-refunds-disputes-overage.md b/bugs/enterprise/money-refunds-disputes-overage.md index 5c52ae0cc..85552d407 100644 --- a/bugs/enterprise/money-refunds-disputes-overage.md +++ b/bugs/enterprise/money-refunds-disputes-overage.md @@ -1,5 +1,7 @@ # Enterprise Money — Refunds, Disputes & Overage +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 10 claims, 4 are still true today, 5 have been addressed since this dossier was written, and 1 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Canonical cascade: [`lib/payments/operations/refund.ts`](../../lib/payments/operations/refund.ts) (`applyRefundCascade`) — Serializable, `cascadedAt` claim, reverse legs (wallet credit, accrual reversals, LICENSE utilization), earnings clawback, credit notes, best-effort `REFUND` ledger. Disputes: hold earnings → LOST applies org chargeback (`Dr WALLET` / receivable) + credit note. Overage: PENDING/ACCRUED can reverse on refund; **CHARGED overage (#716) is an explicit gap**. @@ -8,27 +10,27 @@ Canonical cascade: [`lib/payments/operations/refund.ts`](../../lib/payments/oper Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| R-01 #716 CHARGED overage lacks auto credit-note / return | 🟡 LEGIT-DEFERRED (#716) | -| R-02 ledger reversal on refund best-effort | 🟡 LEGIT-DEFERRED (append-only legs + cron heal) | -| R-03 org chargeback can drive wallet negative | 🟡 LEGIT-DEFERRED (dunning recovery) | -| R-04 credit-note serial length / prefix edges | 🟡 LEGIT-DEFERRED | -| R-05 Razorpay dispute reconciler manual-heavy | 🟡 LEGIT-DEFERRED | -| R-06 post-COMPLETED payout clawback manual | 🟡 LEGIT-DEFERRED (post-payout netting not in this wave) | -| R-07 docs claim multi-leg refund incomplete | ❌ STALE/OVERSTATED (cascade + tax adjustment rows already wired — don't chase ghosts) | +| Claim (short) | Verdict | +| --------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| R-01 #716 CHARGED overage lacks auto credit-note / return | 🟡 LEGIT-DEFERRED (#716) | +| R-02 ledger reversal on refund best-effort | 🟡 LEGIT-DEFERRED (append-only legs + cron heal) | +| R-03 org chargeback can drive wallet negative | 🟡 LEGIT-DEFERRED (dunning recovery) | +| R-04 credit-note serial length / prefix edges | 🟡 LEGIT-DEFERRED | +| R-05 Razorpay dispute reconciler manual-heavy | 🟡 LEGIT-DEFERRED | +| R-06 post-COMPLETED payout clawback manual | 🟡 LEGIT-DEFERRED (post-payout netting not in this wave) | +| R-07 docs claim multi-leg refund incomplete | ❌ STALE/OVERSTATED (cascade + tax adjustment rows already wired — don't chase ghosts) | ## Known gaps / bugs -| ID | Severity | Issue | -|----|----------|-------| -| R-01 | **P1** | #716 — CHARGED `OVERAGE_INVOICE_ACCRUAL` / member side-charge lacks automated credit-note + money return | -| R-02 | **P1** | Ledger reversal on refund can be best-effort — books lag customer money | -| R-03 | **P1** | Org chargeback can drive wallet negative — dunning must recover | -| R-04 | **P2** | Credit note serial length / prefix edge cases for long org codes | -| R-05 | **P2** | Razorpay dispute list/reconciler still manual-heavy | -| R-06 | **P2** | Post-COMPLETED org payout clawback is manual (no auto net-next-batch) | -| R-07 | **P2** | Readiness docs may still claim multi-leg refund incomplete — **code largely fixed**; don’t chase ghosts | +| ID | Severity | Issue | +| ---- | -------- | -------------------------------------------------------------------------------------------------------- | +| R-01 | **P1** | #716 — CHARGED `OVERAGE_INVOICE_ACCRUAL` / member side-charge lacks automated credit-note + money return | +| R-02 | **P1** | Ledger reversal on refund can be best-effort — books lag customer money | +| R-03 | **P1** | Org chargeback can drive wallet negative — dunning must recover | +| R-04 | **P2** | Credit note serial length / prefix edge cases for long org codes | +| R-05 | **P2** | Razorpay dispute list/reconciler still manual-heavy | +| R-06 | **P2** | Post-COMPLETED org payout clawback is manual (no auto net-next-batch) | +| R-07 | **P2** | Readiness docs may still claim multi-leg refund incomplete — **code largely fixed**; don’t chase ghosts | Working well: refund vs chargeback Serializable pairing (#785); append-only reversal legs (#786); idempotent credit notes on `refundId`. @@ -41,31 +43,34 @@ Working well: refund vs chargeback Serializable pairing (#785); append-only reve ## Questions (handled?) -1. **#716 CHARGED overage on refund — priority before enterprise GA?** - - A) Hard gate — implement credit note + reverse/chargeback policy - - B) Manual finance runbook only - - C) Forbid CHARGE_* overage until fixed +1. **#716 CHARGED overage on refund — priority before enterprise GA?** + - A) Hard gate — implement credit note + reverse/chargeback policy + - B) Manual finance runbook only + - C) Forbid CHARGE\_\* overage until fixed + +**Recommendation: A (or C interim).** Un-reversed CHARGED overage destroys AP trust; implement or disable CHARGE paths until done. -**Recommendation: A (or C interim).** Un-reversed CHARGED overage destroys AP trust; implement or disable CHARGE paths until done. -- Not B: Manual-only fails at cohort scale. +- Not B: Manual-only fails at cohort scale. - C valid freeze if eng capacity tight before A ships. -2. **Org chargeback recovery?** - - A) Auto-dunning + optional suspend - - B) Immediate negative wallet + invoice - - C) Platform absorbs +2. **Org chargeback recovery?** + - A) Auto-dunning + optional suspend + - B) Immediate negative wallet + invoice + - C) Platform absorbs -**Recommendation: A.** Negative wallet is a signal; recover via dunning/suspend policy, not silent absorption. -- Not B alone without dunning UX. +**Recommendation: A.** Negative wallet is a signal; recover via dunning/suspend policy, not silent absorption. + +- Not B alone without dunning UX. - Not C: Teaches sponsors disputes are free. -3. **Post-payout clawback?** - - A) Net against next OrganizationPayout - - B) Manual collection forever - - C) Hold future host bookings until cleared +3. **Post-payout clawback?** + - A) Net against next OrganizationPayout + - B) Manual collection forever + - C) Hold future host bookings until cleared + +**Recommendation: A.** Auto-net next batch; escalate to C only for chronic offenders. -**Recommendation: A.** Auto-net next batch; escalate to C only for chronic offenders. -- Not B: Ops debt grows with every refund-after-payout. +- Not B: Ops debt grows with every refund-after-payout. - C too harsh as default. ## High concurrency / multi-device / spikes @@ -74,6 +79,6 @@ Refund × dispute × webhook storms are among the best-tested money races. Resid ## Suggested directions -1. Ship #716 with tests for INVOICE + CHARGE_MEMBER parents. -2. Enable dunning suspend when AR policy ready. +1. Ship #716 with tests for INVOICE + CHARGE_MEMBER parents. +2. Enable dunning suspend when AR policy ready. 3. Clawback auto-net in org payout batch creator. diff --git a/bugs/finances/00-overview.md b/bugs/finances/00-overview.md index fcb3b8eed..a0a582a8e 100644 --- a/bugs/finances/00-overview.md +++ b/bugs/finances/00-overview.md @@ -1,5 +1,7 @@ # Finances — Overview +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 10 claims, 4 are still true today, 4 have been addressed since this dossier was written, and 2 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Familiarise’s money stack is Razorpay-primary (Stripe legacy/fallback), with integer paise amounts, a double-entry ledger (`LedgerTransaction` / `LedgerEntry`), and enterprise funding paths (wallet, invoice accrual, license). Consumer checkout creates tentative bookings; webhooks confirm payment and slots. Consultant/org payouts run through RazorpayX (Path C: PG → operating account → FAA), gated by `ENABLE_LIVE_PAYOUTS`. @@ -10,25 +12,25 @@ Canonical engineering: `docs/payments/`, `docs/enterprise/10-money-and-ledger/`, Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| Live payout disbursement flag-gated off | 🔵 by-design gate (`ENABLE_LIVE_PAYOUTS`); not a bug | -| INR-only ledger, FX fields cosmetic | 🔵 TRACKED #783 (multi-currency deferred) | -| Phase-2 side effects outside confirm tx | 🔵 by-design ACK-before-complete; sweeper backstop | -| Amount mismatch marks recovery, no auto-refund | ✅ FIXED-BY #990 (auto-refund + Sentry, manual-recovery fallback) | -| Dual TDS engines, B2C consultant on 194J (P0) | ❌ STALE — consultant withholding is already 194-O via `computeTdsForPayout` (payout-service.ts:592-607); the P0 does not exist in current code | -| Lemon Squeezy / XFlow `NOT_IMPLEMENTED` + routes | ✅ FIXED-BY #984 (removed; Stripe kept; DODO_PAYMENTS enum added post-MVP) | -| Day-pass doc-only, no Prisma model | ✅ FIXED-BY #984 (doc mentions removed) | +| Claim (short) | Verdict | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Live payout disbursement flag-gated off | 🔵 by-design gate (`ENABLE_LIVE_PAYOUTS`); not a bug | +| INR-only ledger, FX fields cosmetic | 🔵 TRACKED #783 (multi-currency deferred) | +| Phase-2 side effects outside confirm tx | 🔵 by-design ACK-before-complete; sweeper backstop | +| Amount mismatch marks recovery, no auto-refund | ✅ FIXED-BY #990 (auto-refund + Sentry, manual-recovery fallback) | +| Dual TDS engines, B2C consultant on 194J (P0) | ❌ STALE — consultant withholding is already 194-O via `computeTdsForPayout` (payout-service.ts:592-607); the P0 does not exist in current code | +| Lemon Squeezy / XFlow `NOT_IMPLEMENTED` + routes | ✅ FIXED-BY #984 (removed; Stripe kept; DODO_PAYMENTS enum added post-MVP) | +| Day-pass doc-only, no Prisma model | ✅ FIXED-BY #984 (doc mentions removed) | ## Known gaps / bugs - Live payout disbursement is feature-flagged off by default — earnings accrue without real money movement until ops flips the flag. - Ledger and plan pricing are **INR settlement only**; display FX fields exist for international buyers but are audit cosmetics (#783). - Phase-2 webhook side effects (earnings, notifications) sit outside the confirmation transaction — temporary inconsistency until crons heal. -- Amount mismatch on capture marks recovery flags but does not auto-refund. -- Dual TDS engines: B2B uses `lib/compliance/tds.ts` (194-O); B2C consultant path still touches deprecated `lib/payments/tax/tds-service.ts` (194J rates) — **P0** if B2C payouts go live. -- Lemon Squeezy / XFlow checkout throw `NOT_IMPLEMENTED`; webhook routes still exist. -- Day-pass product appears in Razorpay skill docs only — no Prisma model. +- Amount mismatch on capture — the 2026-09-03 verdict pass confirmed this FIXED-BY #990 (auto-refund + Sentry paging, with manual-recovery as the fallback). +- Dual TDS engines — the 2026-09-03 verdict pass marked this stale: consultant withholding is already 194-O via `computeTdsForPayout` (payout-service.ts:592-607); the B2C-on-194J path described here does not exist in current code. +- Lemon Squeezy / XFlow checkout — the 2026-09-03 verdict pass confirmed this FIXED-BY #984 (removed; Stripe kept as the live rail; Dodo Payments added post-MVP). +- Day-pass product doc-only — the 2026-09-03 verdict pass confirmed this FIXED-BY #984 (the doc mentions were removed). ## Unhappy paths & user psychology @@ -39,34 +41,37 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **Is Path C (operating account + RazorpayX) signed off by a CA under RBI PA Directions 2025?** - - A) Written CA/RBI memo before first live payout - - B) Ship design partners with escrow-like hold language only - - C) Move to Razorpay Route sub-merchants per consultant +1. **Is Path C (operating account + RazorpayX) signed off by a CA under RBI PA Directions 2025?** + - A) Written CA/RBI memo before first live payout + - B) Ship design partners with escrow-like hold language only + - C) Move to Razorpay Route sub-merchants per consultant **Recommendation: A.** Money movement under Path C needs a written CA/RBI memo before the first live payout so Familiarise does not invent escrow language or redesign onto Route. + - Not B: Hold copy without a memo still leaves RBI PA exposure once real UTRs flow. - Not C: Route sub-merchants abandon the intentional Path C architecture and delay go-live. > 🎯 Locked: rec A stands — this is a legal/CA sign-off gate, not a code change. -2. **What is the customer SLA when payment succeeds but booking confirmation lags (async webhook gap)?** - - A) Confirm within N minutes via sweeper + status page - - B) Poll client until SUCCEEDED or timeout with auto-refund - - C) Accept ops tickets; document “eventual confirmation” +2. **What is the customer SLA when payment succeeds but booking confirmation lags (async webhook gap)?** + - A) Confirm within N minutes via sweeper + status page + - B) Poll client until SUCCEEDED or timeout with auto-refund + - C) Accept ops tickets; document “eventual confirmation” **Recommendation: A.** Sweeper + status page matches the existing ACK-before-complete webhook design and keeps paid users informed without premature refunds. + - Not B: Client-timeout auto-refunds can claw back legitimate slow confirms and fight the sweeper. - Not C: Ops-ticket-only acceptance erodes trust when payment already succeeded. > 🎯 Locked: rec A — the sweeper + status-page design is already the shipped behaviour. -3. **Who owns finance reconciliation when `LedgerReconciliationReport.ok=false`?** - - A) On-call eng pages nightly - - B) Finance ops dashboard with weekly review - - C) Auto-open support ticket per finding +3. **Who owns finance reconciliation when `LedgerReconciliationReport.ok=false`?** + - A) On-call eng pages nightly + - B) Finance ops dashboard with weekly review + - C) Auto-open support ticket per finding **Recommendation: A.** Ledger `ok=false` is a money-correctness P0 and should page engineering the night it appears, not wait for a weekly ops glance. + - Not B: Weekly review is too slow when wallet/cache drift can compound under load. - Not C: Support tickets do not fix journal/cache imbalance and create noise without owners. @@ -74,9 +79,9 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## High concurrency / multi-device -Checkout uses `clientIdempotencyKey`, Redis locks, Serializable transactions, and webhook `eventId` dedup. Same user on two devices double-tapping pay should replay via unique key; two *different* users racing a 1:1 slot may both pay — confirmation guard blocks the loser (see booking pack). Mobile: **web checkout only** today — no native Razorpay SDK. +Checkout uses `clientIdempotencyKey`, Redis locks, Serializable transactions, and webhook `eventId` dedup. Same user on two devices double-tapping pay should replay via unique key; two _different_ users racing a 1:1 slot may both pay — confirmation guard blocks the loser (see booking pack). Mobile: **web checkout only** today — no native Razorpay SDK. ## Suggested directions -1. Treat finances go-live as a checklist: live payouts sandbox UTR proof, Path C memo, TDS engine unification, IRP decision, amount-mismatch runbook. +1. Treat finances go-live as a checklist: live payouts sandbox UTR proof, Path C memo, TDS engine unification, IRP decision, amount-mismatch runbook. 2. Read sibling files in this folder before changing money code. diff --git a/bugs/finances/checkout-webhooks-idempotency.md b/bugs/finances/checkout-webhooks-idempotency.md index d9a65eb17..a4e0b63fd 100644 --- a/bugs/finances/checkout-webhooks-idempotency.md +++ b/bugs/finances/checkout-webhooks-idempotency.md @@ -1,5 +1,7 @@ # Checkout, Webhooks & Idempotency +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 8 claims, 3 are still true today, 4 have been addressed since this dossier was written, and 1 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context `POST /api/checkout` → `handleCheckout()` creates a PENDING `Payment` plus tentative slots under Redis + Serializable guards. Gateway order/session is minted; user pays via Razorpay.js (or Stripe). Razorpay webhooks verify HMAC, dedup via `WebhookEvent`, return 200 fast, and process in `after()`. Success confirms slots; failure deletes tentatives. Client HMAC verify exists as defense-in-depth only — webhook remains authoritative. @@ -10,57 +12,60 @@ Key paths: `lib/payments/operations/checkout.ts`, `app/api/webhooks/razorpay/`, Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| `after()` ACK-before-complete gap | 🔵 by-design + `sweep-stuck-webhook-events` cron backstop | -| Refund-before-capture `DeferSignal` race | 🔵 handled (#855) | -| Amount parity failure leaves funds captured, booking blocked | ✅ FIXED-BY #990 (auto-refund + Sentry) | -| Stripe sync vs Razorpay async asymmetry | 🎯 Stripe KEPT by decision; asymmetry accepted (ADR recommended removal, user retains Stripe) | -| `allocationIdempotencyKey` schema-only | ✅ FIXED-BY #988 (#837, wired via Idempotency-Key header) | +| Claim (short) | Verdict | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| `after()` ACK-before-complete gap | 🔵 by-design + `sweep-stuck-webhook-events` cron backstop | +| Refund-before-capture `DeferSignal` race | 🔵 handled (#855) | +| Amount parity failure leaves funds captured, booking blocked | ✅ FIXED-BY #990 (auto-refund + Sentry) | +| Stripe sync vs Razorpay async asymmetry | 🎯 Stripe KEPT by decision; asymmetry accepted (ADR recommended removal, user retains Stripe) | +| `allocationIdempotencyKey` schema-only | ✅ FIXED-BY #988 (#837, wired via Idempotency-Key header) | ## Known gaps / bugs - Async `after()` processing means ACK-before-complete; stuck events rely on `sweep-stuck-webhook-events` cron (minutes-level gap). - Refund-before-capture race uses `DeferSignal` — correct but easy to mis-ops if sweeper cadence slips. -- Amount parity failure can leave funds captured while booking blocked (`REQUIRES_MANUAL_RECOVERY`). -- Stripe path processes sync (no `after()`) — asymmetric timeout/retry behavior vs Razorpay. -- `allocationIdempotencyKey` on appointments is schema-only (#837) — allocate double-submit not fully covered by payment idempotency. +- Amount parity failure leaving funds captured while booking is blocked — the 2026-09-03 verdict pass confirmed this FIXED-BY #990 (auto-refund + Sentry paging). +- Stripe path processes sync (no `after()`) — asymmetric timeout/retry behavior vs Razorpay; kept by decision, the asymmetry is accepted rather than removed. +- `allocationIdempotencyKey` on appointments — the 2026-09-03 verdict pass confirmed this FIXED-BY #988 (#837): it is now wired via the `Idempotency-Key` header on the allocate routes, not schema-only. ## Unhappy paths & user psychology -- Double-click / back-button / second tab: user thinks first pay failed and tries again — mitigated by `clientIdempotencyKey` if the client remints carefully; remounting checkout can mint a *new* key. +- Double-click / back-button / second tab: user thinks first pay failed and tries again — mitigated by `clientIdempotencyKey` if the client remints carefully; remounting checkout can mint a _new_ key. - Payment app switches to UPI on phone while checkout started on desktop — browser session may expire while Razorpay still captures. - User closes modal after bank OTP; webhook still succeeds — they see no UI confirmation until refresh. - Org wallet checkout: balance looks enough on screen A; screen B spends wallet first; screen A fails mid-flow with confusing error. ## Questions (handled?) -1. **After amount mismatch (gateway ≠ Payment.amount), auto-refund or manual confirm?** - - A) Always auto-refund + Sentry P0 - - B) Manual ops with 24h SLA (current leaning) - - C) Partial capture / adjust booking price only with admin approval +1. **After amount mismatch (gateway ≠ Payment.amount), auto-refund or manual confirm?** + - A) Always auto-refund + Sentry P0 + - B) Manual ops with 24h SLA (current leaning) + - C) Partial capture / adjust booking price only with admin approval **Recommendation: A.** Auto-refund amount mismatches and page Sentry P0 — captured funds with a blocked booking must not wait on a 24h ops SLA. + - Not B: Manual recovery leaves consultees charged while `REQUIRES_MANUAL_RECOVERY` sits. - Not C: Partial capture / price adjust invents a second money path instead of returning the wrong capture. > 🎯 Locked: rec A — #990 ships auto-refund + Sentry P0 on amount mismatch, with manual recovery only as fallback. -2. **Should checkout remount reuse the same `clientIdempotencyKey` for a given cart fingerprint?** - - A) Persist key in sessionStorage keyed by plan+slots - - B) Mint fresh each mount; rely on paymentIntent uniqueness - - C) Server-side “open PENDING payment for this user+plan” reuse +2. **Should checkout remount reuse the same `clientIdempotencyKey` for a given cart fingerprint?** + - A) Persist key in sessionStorage keyed by plan+slots + - B) Mint fresh each mount; rely on paymentIntent uniqueness + - C) Server-side “open PENDING payment for this user+plan” reuse **Recommendation: C.** Reuse an open PENDING payment server-side for the same user+plan so remounts, tabs, and WebViews cannot mint parallel charges. + - Not A: sessionStorage helps one browser only and fails across phone/desktop checkout. - Not B: Fresh keys on every mount are exactly how double-pay unhappy paths start. -3. **Is asymmetric Razorpay-async vs Stripe-sync acceptable long-term?** - - A) Unify both on async + sweeper - - B) Delete Stripe per gateway evaluation - - C) Keep Stripe sync for test-only +3. **Is asymmetric Razorpay-async vs Stripe-sync acceptable long-term?** + - A) Unify both on async + sweeper + - B) Delete Stripe per gateway evaluation + - C) Keep Stripe sync for test-only **Recommendation: B.** Delete unused Stripe per the gateway evaluation so Razorpay-async + sweeper is the only production money path. + - Not A: Unifying Stripe onto async invests in a dual-rail we intend to exit. - Not C: Test-only Stripe still leaves asymmetric timeout/retry behavior in the codebase. diff --git a/bugs/finances/high-concurrency-and-spikes.md b/bugs/finances/high-concurrency-and-spikes.md index 5364ccfd9..4b07b93df 100644 --- a/bugs/finances/high-concurrency-and-spikes.md +++ b/bugs/finances/high-concurrency-and-spikes.md @@ -1,5 +1,7 @@ # High Concurrency & Traffic Spikes +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 9 claims, 4 are still true today, 5 have been addressed since this dossier was written, and 0 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Money paths already assume concurrency: Redis checkout locks, Serializable isolation, wallet conditional updates, webhook dedup, payout idempotency keys, ledger sorted locks. GitHub Actions crons backstop orphans, stuck webhooks, earnings sync, refund cascade, and ledger drift. Race tests exist under `tests/typescript/race-conditions/` (webhook storms, cancel-vs-webhook). @@ -8,12 +10,12 @@ Money paths already assume concurrency: Redis checkout locks, Serializable isola Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| Prisma pool exhaustion under long Serializable txs | 🔵 TRACKED #368 (CLOSED — pooler fix landed) | -| Event checkout fail-closed on Redis outage | 🔵 by-design (seat safety over conversion) | -| Consultation path leans on DB GiST if Redis fails | 🔵 handled (#440 exclusion constraint live) | -| Cron jitter delays sweeper/cleanup | 🔵 TRACKED #866 (cron→QStash plan) | +| Claim (short) | Verdict | +| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Prisma pool exhaustion under long Serializable txs | 🔵 TRACKED #368 (CLOSED — pooler fix landed) | +| Event checkout fail-closed on Redis outage | 🔵 by-design (seat safety over conversion) | +| Consultation path leans on DB GiST if Redis fails | 🔵 handled (#440 exclusion constraint live) | +| Cron jitter delays sweeper/cleanup | 🔵 TRACKED #866 (cron→QStash plan) | | Hot consultant auto-allocate lock serializes consultant | ✅ FIXED-BY #988 (#860 sharded locks; auto-allocate keeps consultant key by design, GiST backstop) | ## Known gaps / bugs @@ -22,7 +24,7 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai - Event checkout fail-closed on Redis outage (503) — correct for seats, harsh for conversion at peak. - Consultation path relies more on DB GiST if Redis fails — asymmetric. - Cron jitter (Actions) means tentative cleanup / sweeper delay under load. -- Hot consultant auto-allocate lock serializes an entire consultant — bottleneck during flash sales. +- Hot consultant auto-allocate lock serializing an entire consultant — the 2026-09-03 verdict pass confirmed this FIXED-BY #988 (#860 sharded locks); auto-allocate keeps the consultant key by design, with the GiST constraint as a backstop. ## Unhappy paths & user psychology @@ -32,32 +34,35 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **Load-test target for checkout QPS and webhook burst before marketing spikes?** - - A) Formal k6/Gatling gate in CI monthly - - B) One-off pre-launch test only - - C) Rely on race unit suite +1. **Load-test target for checkout QPS and webhook burst before marketing spikes?** + - A) Formal k6/Gatling gate in CI monthly + - B) One-off pre-launch test only + - C) Rely on race unit suite **Recommendation: A.** Formal checkout/webhook load gates catch pool exhaustion (#368) and lock bottlenecks before marketing spikes create real chargebacks. + - Not B: A single pre-launch run goes stale as checkout and cron paths change. - Not C: Race unit tests prove correctness under contention, not QPS or Prisma pool headroom. -2. **Redis down during peak — fail closed everywhere or degrade 1:1 to DB-only?** - - A) Fail closed all paid checkout - - B) Events fail closed; 1:1 continue on GiST - - C) Queue checkout intents for later processing +2. **Redis down during peak — fail closed everywhere or degrade 1:1 to DB-only?** + - A) Fail closed all paid checkout + - B) Events fail closed; 1:1 continue on GiST + - C) Queue checkout intents for later processing **Recommendation: B.** Keep event capacity fail-closed on Redis outage while 1:1 can rely on GiST as the confirmed-slot backstop. + - Not A: Blocking all paid checkout when GiST still protects 1:1 over-punishes consultations during a Redis blip. - Not C: Queued intents defer money state and create worse “paid later / seat gone” psychology at peak. > 🎯 Locked: rec B matches shipped behaviour — events fail closed on Redis, 1:1 continues on the GiST backstop. -3. **Move booking/money crons from Actions to a real queue (#866)?** - - A) Inngest/BullMQ near-term - - B) Keep Actions until scale pain - - C) Hybrid: critical sweepers on always-on worker +3. **Move booking/money crons from Actions to a real queue (#866)?** + - A) Inngest/BullMQ near-term + - B) Keep Actions until scale pain + - C) Hybrid: critical sweepers on always-on worker **Recommendation: C.** Put money-critical sweepers (webhooks, refunds, tentative cleanup) on an always-on worker while leaving lower-urgency jobs on Actions. + - Not A: A full near-term queue migration is speculative ops redesign ahead of fixing known refund/idempotency bugs. - Not B: Actions jitter already delays confirmation/refund healing under load — waiting for “scale pain” risks paid users. diff --git a/bugs/finances/incomplete-gated-stubs.md b/bugs/finances/incomplete-gated-stubs.md index c772542a0..674f84161 100644 --- a/bugs/finances/incomplete-gated-stubs.md +++ b/bugs/finances/incomplete-gated-stubs.md @@ -1,5 +1,7 @@ # Incomplete, Gated & Stubbed Finance Paths +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 13 claims, 6 are still true today, 5 have been addressed since this dossier was written, and 2 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Much of the finance surface is schema-complete and code-complete but **gated**, **stubbed**, or **doc-only**. Shipping without knowing which gates are intentional creates false confidence. @@ -8,38 +10,38 @@ Much of the finance surface is schema-complete and code-complete but **gated**, Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. The gate/stub table below maps as follows: -| Claim (short) | Verdict | -|---|---| -| `ENABLE_LIVE_PAYOUTS` off | 🔵 by-design gate | -| `ENABLE_IRP_UPLOADER` off | 🔵 TRACKED #713 (ClearTax) | -| Lemon Squeezy / XFlow dead routes | ✅ FIXED-BY #984 (removed) | -| Multi-currency ledger not built | 🔵 TRACKED #783 | -| Section 195 / non-resident blocked | 🟡 LEGIT-DEFERRED (accurate guard, by-design) | -| Overage #715 paths partial | 🔵 TRACKED #715 (CLOSED) | -| GST TCS collection schema-only | 🟡 LEGIT-DEFERRED (GSTR-8 batching deferred) | -| Day-pass product doc-only | ✅ FIXED-BY #984 (mentions removed) | -| Paid trial checkout "partial schema" | ❌ OVERSTATED — `trialPriceInPaise` is wired through checkout | -| Org Stripe Connect deferred | 🟡 LEGIT-DEFERRED | +| Claim (short) | Verdict | +| ----------------------------------------------- | --------------------------------------------------------------- | +| `ENABLE_LIVE_PAYOUTS` off | 🔵 by-design gate | +| `ENABLE_IRP_UPLOADER` off | 🔵 TRACKED #713 (ClearTax) | +| Lemon Squeezy / XFlow dead routes | ✅ FIXED-BY #984 (removed) | +| Multi-currency ledger not built | 🔵 TRACKED #783 | +| Section 195 / non-resident blocked | 🟡 LEGIT-DEFERRED (accurate guard, by-design) | +| Overage #715 paths partial | 🔵 TRACKED #715 (CLOSED) | +| GST TCS collection schema-only | 🟡 LEGIT-DEFERRED (GSTR-8 batching deferred) | +| Day-pass product doc-only | ✅ FIXED-BY #984 (mentions removed) | +| Paid trial checkout "partial schema" | ❌ OVERSTATED — `trialPriceInPaise` is wired through checkout | +| Org Stripe Connect deferred | 🟡 LEGIT-DEFERRED | | Payment cancellation helper warn-only → orphans | ❌ OVERSTATED — `reconcile-orphaned-confirmations` backstops it | -| Export tax evidence (FIRC/LUT) TODO | 🟡 LEGIT-DEFERRED | -| Dec 2025 P0 checkout task reads "Awaiting Fix" | ✅ code fixes landed; the task file was stale doc drift | +| Export tax evidence (FIRC/LUT) TODO | 🟡 LEGIT-DEFERRED | +| Dec 2025 P0 checkout task reads "Awaiting Fix" | ✅ code fixes landed; the task file was stale doc drift | ## Known gaps / bugs -| Item | State | Risk if ignored | -|------|--------|-----------------| -| `ENABLE_LIVE_PAYOUTS` | Off by default | Consultants unpaid | -| `ENABLE_IRP_UPLOADER` | Off / needs ClearTax | Non-compliant e-invoice at scale | -| Lemon Squeezy / XFlow | `NOT_IMPLEMENTED` + webhook routes | Dead routes / secret surface | -| Multi-currency ledger #783 | Deferred | Blocks true intl settlement | -| Section 195 / non-resident | Blocked | Intl consultants cannot cash out | -| Overage #715 paths | Partial | Some refunds/reversals refuse | -| GST TCS collection | Schema only | GSTR-8 gap | -| Day pass product | Skills/docs only | Product confusion | -| Paid trial checkout | Partial schema | Trial→pay funnel broken | -| Org Stripe Connect | Deferred | Dual-rail complexity | -| Payment cancellation helper | Warn-only for unknown IDs | Orphan intents | -| Export tax evidence (FIRC/LUT) | TODO in tax-engine | Audit weakness | +| Item | State | Risk if ignored | +| ------------------------------ | --------------------------------------------------------------- | -------------------------------- | +| `ENABLE_LIVE_PAYOUTS` | Off by default | Consultants unpaid | +| `ENABLE_IRP_UPLOADER` | Off / needs ClearTax | Non-compliant e-invoice at scale | +| Lemon Squeezy / XFlow | ✅ FIXED-BY #984 (hard-removed) | n/a | +| Multi-currency ledger #783 | Deferred | Blocks true intl settlement | +| Section 195 / non-resident | Blocked | Intl consultants cannot cash out | +| Overage #715 paths | Partial | Some refunds/reversals refuse | +| GST TCS collection | Schema only | GSTR-8 gap | +| Day pass product | ✅ FIXED-BY #984 (doc mentions removed) | n/a | +| Paid trial checkout | ❌ overstated — `trialPriceInPaise` is wired through checkout | n/a | +| Org Stripe Connect | Deferred | Dual-rail complexity | +| Payment cancellation helper | ❌ overstated — `reconcile-orphaned-confirmations` backstops it | n/a | +| Export tax evidence (FIRC/LUT) | TODO in tax-engine | Audit weakness | Dec 2025 P0 checkout bugs appear fixed in code — the tracking file `tasks/payment-workflow-critical-bugs.md` was retired with the `tasks/` folder (commit e9471aea), closing that drift. @@ -51,32 +53,35 @@ Dec 2025 P0 checkout bugs appear fixed in code — the tracking file `tasks/paym ## Questions (handled?) -1. **Delete vs quarantine Stripe/Lemon/XFlow code?** - - A) Delete unused gateways per Mar 2026 evaluation - - B) Quarantine behind `DEPRECATED_GATEWAYS` - - C) Keep Stripe test-only +1. **Delete vs quarantine Stripe/Lemon/XFlow code?** + - A) Delete unused gateways per Mar 2026 evaluation + - B) Quarantine behind `DEPRECATED_GATEWAYS` + - C) Keep Stripe test-only + +**Historical recommendation (2026-07-12): B.** Quarantine unused gateways behind a deprecation flag to shrink secret/webhook surface without a risky big-bang delete of shared types. -**Recommendation: B.** Quarantine unused gateways behind a deprecation flag to shrink secret/webhook surface without a risky big-bang delete of shared types. - Not A: Hard delete can break residual imports and webhook routes before the evaluation cleanup is complete. - Not C: Leaving Stripe “test-only” still keeps dual-rail complexity and asymmetric sync behavior in prod codepaths. -> 🎯 Locked: Lemon/XFlow were hard-removed (#984); Stripe is KEPT as a live rail (not quarantined); Dodo Payments is the sanctioned post-MVP second gateway. +> 🎯 Locked: this superseded the recommendation above — Lemon/XFlow were hard-removed (#984), not quarantined; Stripe is KEPT as a live rail; Dodo Payments is the sanctioned post-MVP second gateway. -2. **Single source of truth for “finance ready for prod” checklist?** - - A) This bugs pack + shipping checklist sign-off slots - - B) Notion/Linear only - - C) Feature-flag dashboard as checklist +2. **Single source of truth for “finance ready for prod” checklist?** + - A) This bugs pack + shipping checklist sign-off slots + - B) Notion/Linear only + - C) Feature-flag dashboard as checklist **Recommendation: A.** Keep the go-live checklist next to the audited gaps in-repo so eng and finance sign the same artifacts. + - Not B: Notion/Linear drift from code (as already seen on payment-workflow task status). - Not C: Flags show what is on, not whether Path C, TDS, and IRP are actually signed off. -3. **Are day passes on the roadmap or should skill/docs mentions be removed?** - - A) Build schema + grant path - - B) Remove mentions to reduce confusion - - C) Keep as future marketing only +3. **Are day passes on the roadmap or should skill/docs mentions be removed?** + - A) Build schema + grant path + - B) Remove mentions to reduce confusion + - C) Keep as future marketing only **Recommendation: B.** Day passes are doc-only with no Prisma model — remove mentions so sales and eng stop treating them as shippable. + - Not A: Building a new product surface before payouts/TDS/refund P0s is growth ahead of money safety. - Not C: “Future marketing only” still creates false confidence and support confusion. diff --git a/bugs/finances/multi-currency-and-ledger.md b/bugs/finances/multi-currency-and-ledger.md index 5404dd6a4..76267b68a 100644 --- a/bugs/finances/multi-currency-and-ledger.md +++ b/bugs/finances/multi-currency-and-ledger.md @@ -1,5 +1,7 @@ # Multi-Currency & Ledger +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 7 claims, 3 are still true today, 4 have been addressed since this dossier was written, and 0 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Money is modeled as integer paise with double-entry postings via `postLedgerTxn()`: deterministic ledger account IDs, sorted balance-cache updates (deadlock avoidance), COMMIT-time balance triggers, nightly `reconcile-ledgers`. Chart includes CASH, WALLET, PLATFORM_FEE, CONSULTANT_PAYABLE, ORG_PAYABLE/RECEIVABLE, TDS/GST payables, etc. Currency enum includes INR/USD/EUR/GBP, but settlement and ledger postings are **INR-only** today. International buyers may see `displayCurrencyAtCheckout` + `exchangeRateAtCheckout` as audit labels while Razorpay IBT still settles INR. @@ -10,12 +12,12 @@ Key paths: `lib/payments/ledger/post.ts`, `lib/payments/validation/currency-guar Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| True multi-currency ledger not built | 🔵 TRACKED #783 | +| Claim (short) | Verdict | +| --------------------------------------------------- | ------------------------------------------------------------------------------------- | +| True multi-currency ledger not built | 🔵 TRACKED #783 | | Wallet cache drift possible until nightly reconcile | ✅ FIXED-BY #990 (freeze + page when reconcile `ok=false`; nightly reconcile remains) | -| Display FX not used in refund math | 🟡 by-design (gateway settles INR) | -| Stripe/legacy paths add mental load | 🎯 Stripe KEPT by decision | +| Display FX not used in refund math | 🟡 by-design (gateway settles INR) | +| Stripe/legacy paths add mental load | 🎯 Stripe KEPT by decision | ## Known gaps / bugs @@ -32,34 +34,37 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **Is INR settlement + local FX acceptable until meaningful US/EU consultant supply?** - - A) Yes — document clearly in checkout UI - - B) No — block non-INR buyers until #783 - - C) Dual books: display FX wallet separate from INR ledger +1. **Is INR settlement + local FX acceptable until meaningful US/EU consultant supply?** + - A) Yes — document clearly in checkout UI + - B) No — block non-INR buyers until #783 + - C) Dual books: display FX wallet separate from INR ledger **Recommendation: A.** Stay INR settlement with clear checkout copy until real US/EU supply justifies #783 — matches the India-first money model already in code. + - Not B: Blocking all non-INR buyers cuts international consultees without fixing consultant payout reality. - Not C: Dual display-FX books before a true multi-currency ledger invites reconcile bugs and support disputes. > 🎯 Locked: rec A — INR settlement retained; true multi-currency stays tracked under #783. -2. **On wallet drift detection, auto-heal from journal or page humans?** - - A) Auto-correct cache from ledger balance - - B) Freeze wallet + ops alert - - C) Log only until N paise threshold +2. **On wallet drift detection, auto-heal from journal or page humans?** + - A) Auto-correct cache from ledger balance + - B) Freeze wallet + ops alert + - C) Log only until N paise threshold **Recommendation: B.** Freeze the wallet and page ops when cache drifts from the journal so further bookings cannot spend a wrong balance. + - Not A: Silent auto-heal can mask a posting bug and keep moving bad money. - Not C: Log-only lets orgs keep booking against a lying denormalized balance. > 🎯 Locked: rec B — #990 freezes the wallet and pages Sentry (P0) on reconcile `ok=false`; no silent auto-heal. -3. **Should refunds always return gateway-settled INR, ignoring display currency?** - - A) Yes (gateway truth) - - B) Attempt display-currency refund where gateway supports - - C) Credit wallet in INR equivalent only +3. **Should refunds always return gateway-settled INR, ignoring display currency?** + - A) Yes (gateway truth) + - B) Attempt display-currency refund where gateway supports + - C) Credit wallet in INR equivalent only **Recommendation: A.** Refund the gateway-settled INR amount — that is what Razorpay captured and what the ledger posted. + - Not B: Display-currency refunds fight IBT settlement and create amount-mismatch recovery loops. - Not C: Wallet credit instead of gateway refund leaves the card/UPI customer unpaid. diff --git a/bugs/finances/payouts-and-earnings.md b/bugs/finances/payouts-and-earnings.md index ec533012c..09c815cc6 100644 --- a/bugs/finances/payouts-and-earnings.md +++ b/bugs/finances/payouts-and-earnings.md @@ -1,5 +1,7 @@ # Payouts & Earnings +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 9 claims, 5 are still true today, 4 have been addressed since this dossier was written, and 0 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Successful payments create `ConsultantEarnings` / `OrganizationEarnings` (platform fee share, hold period, refund clawback fields). Weekly crons batch READY earnings into `ConsultantPayout` / `OrganizationPayout` with unique idempotency keys, then submit via RazorpayX or Stripe Connect when `ENABLE_LIVE_PAYOUTS=true`. TDS (194-O) and MSME `mustPayByDate` attach at payout time. Path C intentionally avoids Route sub-merchant splits. @@ -10,14 +12,14 @@ Key paths: `lib/payments/payouts/`, jobs under `.github/workflows/*payout*`. Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| Live gateway submission gated → PROCESSING/PENDING freeze | 🔵 by-design gate + CAS terminal guards | -| Non-resident consultants blocked (Sec 195) | 🟡 LEGIT-DEFERRED (accurate guard) | -| Org clawback after COMPLETED payout is manual | 🟡 LEGIT-DEFERRED (post-payout netting not in this wave) | -| GST TCS fields exist, collection deferred (cites #780) | 🟡 LEGIT-DEFERRED — note #780 is misattributed (it is the BigInt money migration, not GST TCS) | -| Form 26Q / TRACES schema-only (cites #738) | 🔵 TRACKED #737 — code cites #737, audit said #738 | -| INVOICE earnings park in `PENDING_TRUST` "forever" | ✅/🔵 #991 rescopes the park to the sponsor and extends the #687 release valve to consultant rows; dunning-suspend is 🔵 #779 | +| Claim (short) | Verdict | +| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Live gateway submission gated → PROCESSING/PENDING freeze | 🔵 by-design gate + CAS terminal guards | +| Non-resident consultants blocked (Sec 195) | 🟡 LEGIT-DEFERRED (accurate guard) | +| Org clawback after COMPLETED payout is manual | 🟡 LEGIT-DEFERRED (post-payout netting not in this wave) | +| GST TCS fields exist, collection deferred (cites #780) | 🟡 LEGIT-DEFERRED — note #780 is misattributed (it is the BigInt money migration, not GST TCS) | +| Form 26Q / TRACES schema-only (cites #738) | 🔵 TRACKED #737 — code cites #737, audit said #738 | +| INVOICE earnings park in `PENDING_TRUST` "forever" | ✅/🔵 #991 rescopes the park to the sponsor and extends the #687 release valve to consultant rows; dunning-suspend is 🔵 #779 | ## Known gaps / bugs @@ -37,34 +39,37 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **Go-live plan for `ENABLE_LIVE_PAYOUTS`?** - - A) Sandbox UTR reconcile → limited cohort → full prod with kill switch - - B) Keep manual bank transfers until GMV threshold - - C) Switch architecture to Route splits before enabling FAA +1. **Go-live plan for `ENABLE_LIVE_PAYOUTS`?** + - A) Sandbox UTR reconcile → limited cohort → full prod with kill switch + - B) Keep manual bank transfers until GMV threshold + - C) Switch architecture to Route splits before enabling FAA **Recommendation: A.** Prove sandbox UTRs, then design-partner cohort, then full prod with a kill switch — the only safe way to flip `ENABLE_LIVE_PAYOUTS`. + - Not B: Manual bank transfers do not exercise idempotent RazorpayX batching and hide production failure modes. - Not C: Redesigning onto Route before FAA delays payouts and abandons Path C without a CA-driven reason. > 🎯 Locked: rec A stands; batch earnings now move to a BATCHED status (#993) so nothing reads PAID before a UTR exists, and the flag stays the go-live gate. -2. **What happens when INVOICE org never pays — force clawback, write-off, or suspend booking?** - - A) Auto-suspend org after dunning stage 3 (`ENABLE_DUNNING_SUSPEND`) - - B) Earnings stay PENDING_TRUST indefinitely (ops review) - - C) Platform absorbs and invoices org legally +2. **What happens when INVOICE org never pays — force clawback, write-off, or suspend booking?** + - A) Auto-suspend org after dunning stage 3 (`ENABLE_DUNNING_SUSPEND`) + - B) Earnings stay PENDING_TRUST indefinitely (ops review) + - C) Platform absorbs and invoices org legally **Recommendation: A.** After dunning stage 3, auto-suspend the org so unpaid invoice funding cannot keep creating `PENDING_TRUST` earnings forever. + - Not B: Indefinite PENDING_TRUST strands consultants and never forces org payment. - Not C: Platform absorption turns Familiarise into the bad-debt party for unpaid B2B bookings. > 🎯 Locked: rec A direction — #991 rescopes the park to the sponsor (not the consultant) and dunning→suspend is tracked under #779. -3. **Non-resident / Section 195 timeline before international consultants?** - - A) Block non-resident until Form 15CA/CB live - - B) Manual CA process outside product - - C) Only allow INR-resident consultants at launch +3. **Non-resident / Section 195 timeline before international consultants?** + - A) Block non-resident until Form 15CA/CB live + - B) Manual CA process outside product + - C) Only allow INR-resident consultants at launch **Recommendation: C.** Launch with INR-resident consultants only until Section 195 / Form 15CA/CB is productized — India settlement first. + - Not A: “Block until 15CA/CB” still invites half-built intl onboarding UI and support exceptions. - Not B: Manual CA outside product does not scale and will be bypassed under sales pressure. diff --git a/bugs/finances/refunds-and-disputes.md b/bugs/finances/refunds-and-disputes.md index ec6ae0c5a..b7f8d9891 100644 --- a/bugs/finances/refunds-and-disputes.md +++ b/bugs/finances/refunds-and-disputes.md @@ -1,5 +1,7 @@ # Refunds & Disputes +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 8 claims, 2 are still true today, 5 have been addressed since this dossier was written, and 1 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context Canonical refund path is `refundPayment()` + `applyRefundCascade()` in a Serializable transaction: Refund row, reverse payment legs (wallet, invoice accrual, referral credit), reverse booking utilization, proportional earnings clawback, ledger `REFUND` posting, GST credit note minting, TDS reversal, stamp `cascadedAt`. Disputes use a legal transition guard (`dispute-status.ts`); LOST triggers earnings reversal and chargeback accounting. Razorpay disputes are webhook-only (no list API). @@ -10,20 +12,20 @@ Key paths: `lib/payments/operations/refund.ts`, `docs/payments/refunds-disputes/ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| Tax adjustment rows incompletely wired from refund | ❌ OVERSTATED — `recordTdsReversal` (refund.ts:593) and `gstTcsAdjustment.create` (refund.ts:723) are both wired; only the `GstTcsBatch` monthly collection is deferred | -| Overage credit-back refuses non-invoice reversals | 🔵 TRACKED #715 | -| Org payout COMPLETED → clawback manual | 🟡 LEGIT-DEFERRED | -| Double-booking loser holds SUCCEEDED, manual refund | ✅ FIXED-BY #990 | -| Chargeback evidence SLA timers in admin UI | 🟡 UNVERIFIED (alert cron exists; the UI timer was not confirmed) | +| Claim (short) | Verdict | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tax adjustment rows incompletely wired from refund | ❌ OVERSTATED — `recordTdsReversal` (refund.ts:593) and `gstTcsAdjustment.create` (refund.ts:723) are both wired; only the `GstTcsBatch` monthly collection is deferred | +| Overage credit-back refuses non-invoice reversals | 🔵 TRACKED #715 | +| Org payout COMPLETED → clawback manual | 🟡 LEGIT-DEFERRED | +| Double-booking loser holds SUCCEEDED, manual refund | ✅ FIXED-BY #990 | +| Chargeback evidence SLA timers in admin UI | 🟡 UNVERIFIED (alert cron exists; the UI timer was not confirmed) | ## Known gaps / bugs -- Cascade is strong, but some tax adjustment rows (`TdsAdjustment` / `GstTcsAdjustment`) are documented as incompletely wired from refund in compliance shipping checklist. +- Cascade is strong; the tax adjustment rows (`TdsAdjustment` / `GstTcsAdjustment`) were documented elsewhere as incompletely wired from refund, but the 2026-09-03 verdict pass found this overstated — both are wired from `refund.ts` (`recordTdsReversal` at refund.ts:593, `gstTcsAdjustment.create` at refund.ts:723); only the monthly `GstTcsBatch` collection is deferred. - Overage member credit-back (#715) refuses some non-invoice reversals — certain refunds may block. - Org payout already COMPLETED → clawback is not auto-recovered from consultant/org bank. -- Double-booking loser may hold a SUCCEEDED payment with tentative slot — refund may be ops-manual (#830). +- Double-booking loser holding a SUCCEEDED payment with a tentative slot — the 2026-09-03 verdict pass confirmed this FIXED-BY #990. - Chargeback evidence SLAs (documented ~7 days) may not be operationalized in admin UI timers. ## Unhappy paths & user psychology @@ -35,32 +37,35 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **Should consultant no-show auto-trigger full refund?** - - A) Auto from MeetingAttendance / UNVERIFIED after SLA - - B) Support-only with scripted playbook - - C) Partial credit note + mandatory reschedule +1. **Should consultant no-show auto-trigger full refund?** + - A) Auto from MeetingAttendance / UNVERIFIED after SLA + - B) Support-only with scripted playbook + - C) Partial credit note + mandatory reschedule **Recommendation: A.** Policy already promises full refund on consultant no-show — automate from MeetingAttendance / UNVERIFIED after SLA (#471) instead of hoping support catches it. + - Not B: Support-only leaves paid consultees waiting and contradicts published refund copy. - Not C: Partial credit plus forced reschedule underpays the customer relative to the promised full refund. > 🎯 Locked: rec A — no-show refunds are automated in #992 (#471) for consultations; subscriptions remain a deferred TODO#471. -2. **Refund vs dispute race — which wins as product policy?** - - A) Dispute freezes refund UI; finance owns - - B) Refund completes; dispute maps to already-refunded - - C) Always escalate to human before either terminal state +2. **Refund vs dispute race — which wins as product policy?** + - A) Dispute freezes refund UI; finance owns + - B) Refund completes; dispute maps to already-refunded + - C) Always escalate to human before either terminal state **Recommendation: A.** Freeze in-product refunds when a dispute opens so finance owns one recovery path and two devices cannot fight each other. + - Not B: Completing refunds while a dispute is open risks double recovery and confused chargeback evidence. - Not C: Human-gating every terminal state recreates ticket latency on an already Serializable money path. -3. **Post-payout clawback — auto debit next earnings or legal invoice?** - - A) Net against next ConsultantPayout batch - - B) Manual collection only (current v1 leaning) - - C) Hold future bookings until clawback cleared +3. **Post-payout clawback — auto debit next earnings or legal invoice?** + - A) Net against next ConsultantPayout batch + - B) Manual collection only (current v1 leaning) + - C) Hold future bookings until clawback cleared **Recommendation: A.** Net clawbacks against the next payout batch so refunded sessions do not permanently strand platform receivables after COMPLETED payouts. + - Not B: Manual collection does not scale and leaves `clawbackAmount` growing with no recovery path. - Not C: Holding future bookings punishes consultees and consultants for a finance recovery problem. diff --git a/bugs/finances/tax-gst-tds-invoicing.md b/bugs/finances/tax-gst-tds-invoicing.md index 0c3308690..efe77890f 100644 --- a/bugs/finances/tax-gst-tds-invoicing.md +++ b/bugs/finances/tax-gst-tds-invoicing.md @@ -1,5 +1,7 @@ # Tax, GST, TDS & Invoicing +> **Verdict pass 2026-09-03/04.** Every money claim in this file was re-checked against `dev@e1766fa2d` and the live database as part of the 2026-09-03 finance-subsystem verification. Of 11 claims, 8 are still true today, 1 have been addressed since this dossier was written, and 2 are stale. See [`docs/payments/audits/2026-09-03-finance-verdicts.md`](../../docs/payments/audits/2026-09-03-finance-verdicts.md) for the per-item disposition. + ## Context B2B enterprise tax is relatively mature: GST breakdown (`lib/compliance/gst.ts`), org invoices with sequential numbering, credit notes, MSME 43B(h) dates, TDS derivation for org payouts, IRP uploader gated by `ENABLE_IRP_UPLOADER`. B2C marketplace tax is weaker: wrong/legacy TDS path risk, GST TCS Sec 52 schema-only, place-of-supply state not captured at consumer checkout, legal docs still have placeholders elsewhere. @@ -10,21 +12,21 @@ Key paths: `lib/compliance/`, `lib/payments/tax/`, `docs/compliance/15-india-com Triaged 2026-07-12 against real code (3 verifier agents cross-checked every claim); fix wave PRs #981–#994 shipped. This dossier's claims map as follows: -| Claim (short) | Verdict | -|---|---| -| B2C consultant TDS still on 194J (P0) | ❌ STALE — 194-O is live via `computeTdsForPayout` (payout-service.ts:592-607) | -| GST TCS / GSTR-8 batching not wired (`GstTcsBatch`) | 🟡 LEGIT-DEFERRED | -| Refund tax cascade incomplete | ❌ OVERSTATED — `TdsAdjustment` (reversal) and `GstTcsAdjustment` are both wired from `refund.ts` | -| Form 15CA/CB stub returns nulls | 🟡 LEGIT-DEFERRED (explicit stub in `form15.ts`) | -| IRN filing gated (ClearTax) | 🔵 TRACKED #713 | -| HSN defaults static | 🟡 LEGIT-DEFERRED | -| Consultant GSTIN format-only, no registry check | 🟡 LEGIT-DEFERRED | +| Claim (short) | Verdict | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| B2C consultant TDS still on 194J (P0) | ❌ STALE — 194-O is live via `computeTdsForPayout` (payout-service.ts:592-607) | +| GST TCS / GSTR-8 batching not wired (`GstTcsBatch`) | 🟡 LEGIT-DEFERRED | +| Refund tax cascade incomplete | ❌ OVERSTATED — `TdsAdjustment` (reversal) and `GstTcsAdjustment` are both wired from `refund.ts` | +| Form 15CA/CB stub returns nulls | 🟡 LEGIT-DEFERRED (explicit stub in `form15.ts`) | +| IRN filing gated (ClearTax) | 🔵 TRACKED #713 | +| HSN defaults static | 🟡 LEGIT-DEFERRED | +| Consultant GSTIN format-only, no registry check | 🟡 LEGIT-DEFERRED | ## Known gaps / bugs -- **P0:** B2C consultant TDS may still use deprecated 194J@10% path vs required 194-O@0.1% for e-commerce operator style flows. +- B2C consultant TDS on the deprecated 194J@10% path — the 2026-09-03 verdict pass marked this stale: 194-O is already live via `computeTdsForPayout` (payout-service.ts:592-607). - GST TCS / GSTR-8 batching not wired (`GstTcsBatch` schema-only). -- Refund tax cascade incomplete for some adjustment models. +- Refund tax cascade incomplete for some adjustment models — the 2026-09-03 verdict pass marked this overstated: `TdsAdjustment` and `GstTcsAdjustment` are both wired from `refund.ts`. - Form 15CA/CB stub returns nulls — cross-border payouts blocked/deferred. - IRN filing gated; ClearTax credentials required. - HSN defaults static; webinar/class may need different codes. @@ -39,34 +41,37 @@ Triaged 2026-07-12 against real code (3 verifier agents cross-checked every clai ## Questions (handled?) -1. **Consolidate TDS engines before any B2C live payout?** - - A) Hard cutover to `lib/compliance/tds.ts` only, CA-signed rates - - B) Keep dual until B2C GMV threshold - - C) Outsource all TDS calc to CA spreadsheet +1. **Consolidate TDS engines before any B2C live payout?** + - A) Hard cutover to `lib/compliance/tds.ts` only, CA-signed rates + - B) Keep dual until B2C GMV threshold + - C) Outsource all TDS calc to CA spreadsheet **Recommendation: A.** Wrong 194J vs 194-O rates on live B2C payouts is a P0 expert-trust and compliance failure — cut over to one CA-signed engine first. + - Not B: Dual engines until a GMV threshold guarantees some consultants are under-withheld or over-withheld in prod. - Not C: Spreadsheet TDS cannot stay consistent with ledger clawbacks and concurrent payout batches. > 🎯 Locked: the premise is ❌ STALE — the consultant payout path already withholds at 194-O via `computeTdsForPayout`, so no cutover is required. -2. **When does GSTR-8 / TCS become launch-blocking?** - - A) Before first B2C payout - - B) After N consultants registered - - C) Defer with CA retainer filing manually +2. **When does GSTR-8 / TCS become launch-blocking?** + - A) Before first B2C payout + - B) After N consultants registered + - C) Defer with CA retainer filing manually **Recommendation: C.** After TDS unification, design-partner GSTR-8 can be filed via CA retainer until collection volume justifies productized TCS batches. + - Not A: Blocking every B2C payout on schema-only `GstTcsBatch` stalls Path C after the higher-priority TDS fix. - Not B: Consultant headcount is a weak proxy for TCS liability and still leaves early GMV unfiled. > 🎯 Locked: rec C — GST TCS / GSTR-8 batching is LEGIT-DEFERRED; file via CA retainer until volume justifies productised TCS. -3. **IRP — enable for all orgs or only above AATO?** - - A) Flag on for everyone with ClearTax - - B) PENDING IRN below threshold - - C) PDF-only until first audit +3. **IRP — enable for all orgs or only above AATO?** + - A) Flag on for everyone with ClearTax + - B) PENDING IRN below threshold + - C) PDF-only until first audit **Recommendation: B.** Keep IRN pending below AATO and enable ClearTax IRP where the threshold actually requires it. + - Not A: Forcing IRP for every org adds ClearTax cost/ops before legal necessity. - Not C: PDF-only past the threshold is non-compliant once e-invoicing applies. diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 1f9837e8a..b0ef57efa 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -44,6 +44,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { useCurrency, SUPPORTED_CURRENCIES } from "@/hooks/useCurrency"; +import { RATE_PROVIDER_NAME, RATE_PROVIDER_URL } from "@/lib/currency-codes"; import { resolveAuthView, useRememberedAuth } from "@/hooks/useRememberedAuth"; import { hasDarkHero, isChromeHidden } from "@/lib/navigation/public-chrome"; import { useAnnouncementBar } from "@/providers/AnnouncementBarProvider"; @@ -369,13 +370,7 @@ function DesktopDropdownPanel({ : undefined } > -
    +
    {group.columns.map((column) => (
    {isMega && ( @@ -533,7 +528,7 @@ const Navbar = () => { const isAuthedView = authView.mode === "authed"; const [isOpen, setIsOpen] = useState(false); const [isScrolled, setIsScrolled] = useState(false); - const { currency, symbol, setCurrency } = useCurrency(); + const { currency, symbol, setCurrency, isEstimate } = useCurrency(); const { isVisible: isAnnouncementVisible } = useAnnouncementBar(); // Route lists live in lib/navigation/public-chrome.ts — they were duplicated @@ -681,6 +676,27 @@ const Navbar = () => { + {/* #1396 — ExchangeRate-API's Open Access licence requires this + attribution wherever its rates are shown, and the switcher is + where a visitor turns those rates on. Rendered only while the + prices really are converted: `isEstimate` is false for INR and + false during the no-rate degrade, when nothing on the page is + the provider's work. */} + {isEstimate && ( + + Rates by {RATE_PROVIDER_NAME} + + )} + {authView.mode === "unknown" ? (
    @@ -736,206 +752,217 @@ const Navbar = () => { {/* Mobile Drawer — CSS transitions only; framer-motion was pulled into - every public page via the root navbar for enter/exit polish. */} + every public page via the root navbar for enter/exit polish. + #1414 — both entrances are motion-safe: gated; with reduced motion + requested they render in their final position, unanimated. */} {isOpen && ( - <> - {/* Backdrop — native button so keyboard/AT get a real interactive + <> + {/* Backdrop — native button so keyboard/AT get a real interactive control (Sonar typescript:S6848 / S1082). */} - +
    - {/* Drawer */} + {/* Navigation — Accordion Sections */}
    - {/* Drawer Header */} -
    -
    - Familiarise Logo -
    - -
    + Dashboard + + )} - {/* Navigation — Accordion Sections */} -
    - {isAuthedView && ( - + {NAV_GROUPS.map((group) => ( + - Dashboard - - )} - - - {NAV_GROUPS.map((group) => ( - - - {group.label} - - -
    - {group.columns.map((column) => ( -
    - {/* Column headings only earn their space when + + {group.label} + + +
    + {group.columns.map((column) => ( +
    + {/* Column headings only earn their space when there's more than one column to separate. */} - {group.columns.length > 1 && ( -

    - {column.heading} -

    - )} - {column.items.map((item) => ( - - - {item.label} - {item.disabled && ( - - Soon - - )} - - - {item.description} - - - ))} -
    - ))} - - {/* Category chips on mobile */} - {group.categoryChips && - group.categoryChips.length > 0 && ( -
    -

    - By Category -

    -
    - {group.categoryChips.map((chip) => ( - - {chip.label} - - ))} -
    -
    + {group.columns.length > 1 && ( +

    + {column.heading} +

    )} -
    -
    - - ))} - + {column.items.map((item) => ( + + + {item.label} + {item.disabled && ( + + Soon + + )} + + + {item.description} + + + ))} +
    + ))} + + {/* Category chips on mobile */} + {group.categoryChips && + group.categoryChips.length > 0 && ( +
    +

    + By Category +

    +
    + {group.categoryChips.map((chip) => ( + + {chip.label} + + ))} +
    +
    + )} +
    +
    +
    + ))} +
    - {/* Pricing — flat link */} - - Pricing - + {/* Pricing — flat link */} + + Pricing + - {/* Mobile Currency Selector */} -
    - - Currency - -
    - {SUPPORTED_CURRENCIES.map((c) => ( - - ))} -
    + {/* Mobile Currency Selector */} +
    + + Currency + +
    + {SUPPORTED_CURRENCIES.map((c) => ( + + ))}
    + {/* Same licence term as the desktop switcher above. */} + {isEstimate && ( + + Rates by {RATE_PROVIDER_NAME} + + )}
    +
    - {/* User Section */} -
    - {authView.mode === "unknown" ? ( + {/* User Section */} +
    + {authView.mode === "unknown" ? ( +
    + + +
    + ) : isAuthedView ? ( +
    - - -
    - ) : isAuthedView ? ( -
    -
    - - - - {authView.name?.charAt(0) ?? "U"} - - - - {authView.name} - -
    - + + + + {authView.name?.charAt(0) ?? "U"} + + + + {authView.name} +
    - ) : ( - /* Mirrors the desktop bar: no marketing CTAs, sign in only. */ - )} -
    +
    + ) : ( + /* Mirrors the desktop bar: no marketing CTAs, sign in only. */ + + )}
    - - )} +
    + + )} ); }; diff --git a/components/appointments/HeldSlotBadge.tsx b/components/appointments/HeldSlotBadge.tsx new file mode 100644 index 000000000..cb04e2bf5 --- /dev/null +++ b/components/appointments/HeldSlotBadge.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { format } from "date-fns"; +import { useHoldCountdown } from "@/hooks/useHoldCountdown"; +import { cn } from "@/utils/tailwind"; + +interface HeldSlotBadgeProps { + /** Payment.expiresAt for the pending payment holding this slot. */ + deadline: Date | null; + className?: string; +} + +/** + * #1428 — a tentative slot with no visible deadline reads as "the platform + * lost my booking." This names the state and ticks down against it via the + * shared `useHoldCountdown` hook, so SessionTimeline's held row and any + * future held-slot surface agree on when a hold has actually lapsed. + */ +export function HeldSlotBadge({ deadline, className }: HeldSlotBadgeProps) { + const { minutesLeft, isExpired } = useHoldCountdown(deadline); + + if (!deadline) { + return ( + + Held + + ); + } + + if (isExpired) { + return ( + + Hold expired + + ); + } + + return ( + + Held until {format(deadline, "h:mm a")} ·{" "} + {/* minutesLeft floors, so 0 is the live final minute, not a lapsed + hold — "0m left" next to an active CTA reads as broken. */} + {minutesLeft === 0 ? "under a minute left" : `${minutesLeft}m left`} + + ); +} diff --git a/components/appointments/SessionTimeline.tsx b/components/appointments/SessionTimeline.tsx index 4a2d7252c..1cc2f3b18 100644 --- a/components/appointments/SessionTimeline.tsx +++ b/components/appointments/SessionTimeline.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { format } from "date-fns"; -import { Video, Loader2, ChevronDown } from "lucide-react"; +import { Video, Loader2, ChevronDown, CreditCard } from "lucide-react"; import { cn } from "@/utils/tailwind"; import { CONSULTEE_JOIN_WINDOW_MS, @@ -12,6 +12,7 @@ import { } from "@/lib/appointments/slots"; import type { SessionVM } from "@/lib/appointments/view-model"; import { CountdownBadge } from "./CountdownBadge"; +import { HeldSlotBadge } from "./HeldSlotBadge"; /** * Per-session timeline for multi-session (and one-off) appointments. @@ -40,6 +41,17 @@ interface SessionTimelineProps { * Defaults to true so multi-session plans show the full list. */ defaultExpanded?: boolean; + /** + * #1428 — opt-in: render tentative (held-pending-payment) sessions instead + * of silently dropping them. Off by default so AppointmentSheet and + * RequestSlotAllocationTab, which never learned a hold deadline, keep + * their existing tentative-is-invisible behaviour. + */ + showHeld?: boolean; + /** Payment.expiresAt for the hold backing the tentative session(s). */ + holdDeadline?: Date | null; + /** Absent ⇒ held rows show "awaiting payment" with no action (consultant view). */ + onCompletePayment?: () => void; } interface SessionGroup { @@ -136,6 +148,9 @@ export function SessionTimeline({ joinWindowMs = CONSULTEE_JOIN_WINDOW_MS, className, defaultExpanded = true, + showHeld = false, + holdDeadline = null, + onCompletePayment, }: SessionTimelineProps) { const [expanded, setExpanded] = useState(defaultExpanded); useEffect(() => { @@ -146,7 +161,16 @@ export function SessionTimeline({ () => sessions.filter((s) => !s.isTentative), [sessions], ); + // #1428 — tentative sessions used to be dropped outright here, so a + // consultee holding reserved slots with a live payment deadline saw + // nothing at all. Kept as its own list (never mixed into `groups`) so the + // held row's countdown/CTA styling doesn't leak into the confirmed rules. + const tentative = useMemo( + () => (showHeld ? sessions.filter((s) => s.isTentative) : []), + [sessions, showHeld], + ); const groups = useMemo(() => toSessionGroups(nonTentative), [nonTentative]); + const heldGroups = useMemo(() => toSessionGroups(tentative), [tentative]); const showExpand = groups.length > 1; const groupStatuses = useMemo( @@ -178,7 +202,7 @@ export function SessionTimeline({ return upcoming ?? groups[groups.length - 1]; }, [groups, groupStatuses]); - if (groups.length === 0) return null; + if (groups.length === 0 && heldGroups.length === 0) return null; const visibleGroups = showExpand && !expanded && focusGroup ? [focusGroup] : groups; @@ -195,6 +219,54 @@ export function SessionTimeline({ return (
    + {heldGroups.map((group) => ( +
    . + > + + ⏳ + + +
    + + {format(group.startTime, "MMM d")} + + + {format(group.startTime, "h:mm a")} + {" - "} + {format(group.endTime, "h:mm a")} + +
    + +
    + + {onCompletePayment ? ( + + ) : ( + + Awaiting payment + + )} +
    +
    + ))} + {showExpand && !expanded && (

    {collapsedCaption} diff --git a/components/appointments/detail/AppointmentDetailClient.tsx b/components/appointments/detail/AppointmentDetailClient.tsx index a67e94fd4..1d6d68564 100644 --- a/components/appointments/detail/AppointmentDetailClient.tsx +++ b/components/appointments/detail/AppointmentDetailClient.tsx @@ -34,6 +34,7 @@ import { type PaymentDisplayStatus, } from "@/lib/labels/session-labels"; import { useSession } from "@/lib/auth-client"; +import { useHoldCountdown } from "@/hooks/useHoldCountdown"; import { formatCurrencyAmount } from "@/utils/formatting"; import { CountdownBadge } from "../CountdownBadge"; import { KIND_LABEL } from "../AppointmentRow"; @@ -47,6 +48,35 @@ import { SessionReviewCard } from "@/components/reviews/SessionReviewCard"; const PARTICIPANTS_PREVIEW = 5; +/** + * #1428 — the single answer to "what may the payer do about this tentative + * hold right now", so the timeline row and the payment card cannot drift. + * + * Past `Payment.expiresAt` the hold is already DEAD for availability + * (`buildDeadHoldFilter`, utils/slotAllocation/occupancyPolicy.ts counts a + * PENDING payment with a lapsed window as free), so another buyer can take + * the slot before any sweep runs. Checkout also refuses to resume a stale + * order (`findReusablePendingOrderPayment` matches only `expiresAt > now`) + * and mints a fresh one instead. Paying the old link would therefore capture + * onto a released slot and land in the #1439 terminal-race refund — so the + * lapsed state offers a new checkout, not the dead "Pay now". + */ +type TentativeHoldCta = "PAY" | "REBOOK" | "NONE"; + +function tentativeHoldCta(args: { + isConsultee: boolean; + holdDeadline: Date | null; + holdExpired: boolean; + pendingPaymentUrl: string | null; +}): TentativeHoldCta { + // The consultant sees held slots read-only; only the payer gets an action. + if (!args.isConsultee) return "NONE"; + // No deadline at all is not a lapse — useHoldCountdown reports a null + // deadline as expired, which would otherwise mis-read as "released". + if (args.holdDeadline !== null && args.holdExpired) return "REBOOK"; + return args.pendingPaymentUrl ? "PAY" : "NONE"; +} + function initials(name: string): string { return name .split(/\s+/) @@ -56,13 +86,7 @@ function initials(name: string): string { .join(""); } -function Section({ - title, - children, -}: { - title: string; - children: ReactNode; -}) { +function Section({ title, children }: { title: string; children: ReactNode }) { return (

    @@ -133,6 +157,24 @@ export function AppointmentDetailClient({ const mapped = detail ? mapAppointmentDetail(detail, role) : null; useSetBreadcrumbLabel(mapped?.vm.title); + const payments = detail?.appointment.payment ?? []; + // #1428 — the tentative-hold deadline: the soonest still-pending payment + // guarding a held slot on this booking. Derived ABOVE the loading/error + // returns because useHoldCountdown below it is a hook and may not sit + // behind a conditional return. + const holdDeadline = + payments + .filter((p) => p.paymentStatus === "PENDING" && p.expiresAt) + .map((p) => new Date(p.expiresAt!)) + .sort((a, b) => a.getTime() - b.getTime())[0] ?? null; + const { isExpired: holdExpired } = useHoldCountdown(holdDeadline); + const tentativeCta = tentativeHoldCta({ + isConsultee: role === "consultee", + holdDeadline, + holdExpired, + pendingPaymentUrl: mapped?.vm.pendingPaymentUrl ?? null, + }); + if (isLoading && !detail) { return (

    @@ -168,7 +210,6 @@ export function AppointmentDetailClient({ .overflowItems(vm) .filter((item) => item.key !== "reschedule-proposal"); const badge = eventUnionStatusBadge(vm.status); - const payments = detail.appointment.payment ?? []; const orgName = detail.appointment.organization?.name ?? resolveSponsoringOrgName( @@ -192,6 +233,12 @@ export function AppointmentDetailClient({ ? vm.sessions.find((s) => s.startsAt.getTime() === vm.nextAt?.getTime()) : undefined; const hasConfirmedSessions = vm.sessions.some((s) => !s.isTentative); + const hasTentativeSessions = vm.sessions.some((s) => s.isTentative); + const openPendingPayment = () => { + if (vm.pendingPaymentUrl && /^https?:\/\//.test(vm.pendingPaymentUrl)) { + window.open(vm.pendingPaymentUrl, "_blank", "noopener,noreferrer"); + } + }; // #1163 — the read narrows to open statuses and takes one, so [0] is THE // live proposal; the card is the answer surface "Awaiting schedule // confirmation" never offered. @@ -347,7 +394,7 @@ export function AppointmentDetailClient({ )}
    - {hasConfirmedSessions ? ( + {hasConfirmedSessions || hasTentativeSessions ? ( action.onClick!() : undefined } + showHeld + holdDeadline={holdDeadline} + // #1428 — consultee sees the CTA while the window is live; + // the consultant, and anyone once the hold has lapsed, sees + // the same held row read-only ("awaiting payment"). + onCompletePayment={ + tentativeCta === "PAY" ? openPendingPayment : undefined + } /> ) : (

    - {vm.sessions.length > 0 - ? "Awaiting schedule confirmation." - : "No sessions scheduled yet."} + No sessions scheduled yet.

    )}
    @@ -405,24 +458,60 @@ export function AppointmentDetailClient({ This booking is sponsored by {orgName}.

    )} - {vm.needsActionReason === "PAY_NOW" && - vm.pendingPaymentUrl && ( - + {/* #1428 — TENTATIVE (held pending payment) reaches this + branch too now, gated by the same `tentativeHoldCta` + the timeline row uses; PAY_NOW's existing (role-agnostic) + behaviour is unchanged. */} + {((vm.needsActionReason === "PAY_NOW" && + vm.pendingPaymentUrl) || + (vm.needsActionReason === "TENTATIVE" && + tentativeCta === "PAY")) && ( + + )} + {vm.needsActionReason === "TENTATIVE" && + tentativeCta === "REBOOK" && ( +
    +

    + The payment window for this hold closed, so the slot + is released unless you book it again. +

    + {/* Back to the consultant's profile rather than a + deep link to the old slot: that time may already + be taken, and the picker is where a live one is + chosen. */} + +
    )}
    )} diff --git a/components/billing/BillingBlockBanner.tsx b/components/billing/BillingBlockBanner.tsx new file mode 100644 index 000000000..2da4df6cf --- /dev/null +++ b/components/billing/BillingBlockBanner.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; +import { Lock } from "lucide-react"; + +interface BillingBlockBannerProps { + walletFrozen: boolean; + walletFrozenReason?: string | null; + dunningSuspended: boolean; + /** Support deep-link — kept a prop rather than hardcoded so callers on + * different surfaces (billing page vs. org home) can each point at + * whatever support entry point they already render. */ + supportHref: string; +} + +/** + * #1427/#1430 — one banner for both silent-block states so the two never + * drift into slightly different copy/styling (Sonar's 3% new-code + * duplication gate would also just fail a second near-identical component). + * Freeze takes priority when both are somehow true at once — it is the more + * severe state (ops has to reconcile a balance, not just collect a payment). + */ +export function BillingBlockBanner({ + walletFrozen, + walletFrozenReason, + dunningSuspended, + supportHref, +}: BillingBlockBannerProps) { + if (!walletFrozen && !dunningSuspended) return null; + + const message = walletFrozen + ? (walletFrozenReason ?? + "Wallet spend is paused pending a balance-reconciliation review.") + : "Bookings are paused until the overdue invoice is settled."; + + return ( +
    + +

    + {message}{" "} + + Contact support + {" "} + for help resolving this. +

    +
    + ); +} diff --git a/components/dashboard/shared/PaymentDetailPage.tsx b/components/dashboard/shared/PaymentDetailPage.tsx index 822d5a205..31bcc031f 100644 --- a/components/dashboard/shared/PaymentDetailPage.tsx +++ b/components/dashboard/shared/PaymentDetailPage.tsx @@ -18,6 +18,19 @@ import type { // read-only: operators can see refund history that the system wrote from // automated paths (gateway-originated refunds, dispute resolutions). +/** + * Dispute states with a verdict behind them. Everything else is a proceeding + * still in motion, and the gateway can advance it at any moment without the + * operator doing anything — which is what the poll below is for. + */ +const TERMINAL_DISPUTE_STATUSES = new Set([ + "WON", + "LOST", + "CHARGE_REFUNDED", + "CLOSED", + "WARNING_CLOSED", +]); + async function fetchPaymentDetails(paymentId: string): Promise { const response = await fetch(`/api/admin/payments/${paymentId}`); if (!response.ok) { @@ -47,6 +60,18 @@ export function PaymentDetailPage({ queryKey: ["admin-payment", resolvedParams.paymentId], queryFn: () => fetchPaymentDetails(resolvedParams.paymentId), staleTime: 30 * 1000, + // #1352 — a live dispute moves on the gateway's clock, not ours: the + // webhook advances the row while the operator is sitting on this page + // deciding whether to refund, and a 30-second stale window with no refetch + // meant they could act on a status the platform had already superseded. + // Poll only while a verdict is still outstanding; a resolved dispute never + // changes again, so it goes back to costing nothing. + refetchInterval: (query) => + query.state.data?.disputes?.some( + (dispute) => !TERMINAL_DISPUTE_STATUSES.has(dispute.status), + ) + ? 15 * 1000 + : false, }); if (error) { @@ -172,6 +197,26 @@ export function PaymentDetailPage({

    )} + {/* #1365 — the statutory B2C tax invoice. Absent for org-funded + payments, which are invoiced to the organization instead. */} +
    + + {payment.consumerInvoice ? ( +
    + + {payment.consumerInvoice.invoiceNumber} + + + Download + +
    + ) : ( +

    Not issued

    + )} +
    diff --git a/components/dashboard/shared/PaymentsPage.tsx b/components/dashboard/shared/PaymentsPage.tsx index b160c2a6b..5408443ed 100644 --- a/components/dashboard/shared/PaymentsPage.tsx +++ b/components/dashboard/shared/PaymentsPage.tsx @@ -60,6 +60,26 @@ export interface PaymentsPageProps { basePath: string; } +/** + * #1365 — the B2C tax invoice for a payment. Defined at module scope, not + * inside the page, so it is never re-created on a render (S6478). An empty + * cell means the payment was org-funded and is invoiced to the organization + * instead, which is the correct answer rather than a gap to chase. + */ +function renderInvoiceCell(payment: Payment) { + if (!payment.consumerInvoice) { + return ; + } + return ( + + {payment.consumerInvoice.invoiceNumber} + + ); +} + export function PaymentsPage({ basePath }: PaymentsPageProps) { const [page, setPage] = useState(1); const [status, setStatus] = useState(); @@ -157,6 +177,11 @@ export function PaymentsPage({ basePath }: PaymentsPageProps) { ), }, + { + key: "invoice", + header: "Invoice", + cell: renderInvoiceCell, + }, { key: "date", header: "Date", diff --git a/components/dashboard/shared/RefundsPage.tsx b/components/dashboard/shared/RefundsPage.tsx index 23dcd4173..b571716d8 100644 --- a/components/dashboard/shared/RefundsPage.tsx +++ b/components/dashboard/shared/RefundsPage.tsx @@ -2,10 +2,10 @@ import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; -// `formatCurrencyAmount`, not `formatCurrencyFromMajorUnit`: refunds carry -// paise. The old call had both halves wrong — a field the payload has never -// contained, passed to the rupees formatter — so it rendered ₹NaN rather than -// a number that was merely 100× too large. +// `formatCurrencyAmount`, not the old major-unit formatter (deleted in #1396): +// refunds carry paise. The old call had both halves wrong — a field the payload +// has never contained, passed to the rupees formatter — so it rendered ₹NaN +// rather than a number that was merely 100× too large. import { formatCurrencyAmount } from "@/utils/formatting"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; diff --git a/docs/booking/00-architecture-decisions.md b/docs/booking/00-architecture-decisions.md index a5a8d2c5b..092213a22 100644 --- a/docs/booking/00-architecture-decisions.md +++ b/docs/booking/00-architecture-decisions.md @@ -112,4 +112,6 @@ A `version` column with compare-and-set on write exists on four enterprise model Every guarded transition in `lib/booking/transitions.ts` appends one row to `BookingStatusHistory` in the same transaction as the state change, carrying the entity kind, the entity id, the from and to statuses, and optional actor, reason and organisation attribution. This is the audit trail that reschedule and cancel disputes needed and that support and staff views read. It is written by the helpers rather than by a database trigger because the helpers already receive the transaction and the actor, and because the seven lifecycles share three different status enums, which is why `fromStatus` and `toStatus` are strings and `entityId` is polymorphic. +Two amendments landed in wave 6 (#1333), both about making the trail resolvable. The first is that the pre-image read below also fetches the row's owning appointment, so `appointmentId` is stamped without the caller supplying it; before that amendment the column existed and no caller ever filled it, which left the timeline resolving everything through the polymorphic entity keys. It stays null in the two cases where there is genuinely no single appointment to name — a subscription or class that owns several live appointments, and a trial that has not been scheduled yet — so the entity arms of the reader's query remain load-bearing rather than transitional. The second is that creation now writes a row of its own through `appendCreationHistory`, in the same transaction as the create, because creation is not a transition and a freshly created booking therefore had no history at all. Its from-status is the literal `"CREATED"` rather than the `"UNKNOWN"` sentinel described below, which would have told an operator that a race occurred. + The from-status is read with a `findUnique` immediately before the compare-and-set update, because an `updateMany` cannot return the row's previous value. A second legal transition committing between that read and the update can therefore log a stale from-status. That imprecision is accepted: it can only affect an append-only audit row, never the state change itself, and the alternative of narrowing the compare-and-set to the observed value would have turned legitimate concurrent moves into spurious conflicts across roughly forty call sites. There is no outbox or dispatcher on this table; notifications keep their existing paths, and a drainer is only worth adding once the platform has a queue that is not the throttled GitHub Actions cron fleet. diff --git a/docs/booking/05-troubleshooting-and-changelog.md b/docs/booking/05-troubleshooting-and-changelog.md index 98c0851f3..1bd03feb5 100644 --- a/docs/booking/05-troubleshooting-and-changelog.md +++ b/docs/booking/05-troubleshooting-and-changelog.md @@ -87,6 +87,9 @@ flowchart TD ## Changelog: 2026-09-03 — wave 6 +Wave 6 finishes the doctrine cleanups that waves 1-5 started. Each PR appends its own subsection here. +Wave 6 picks up the follow-ups that wave 5 left marked in the code. Each PR appends its own subsection here. + ### PR 0 — the gateway loads at call time, and test keys on production need an explicit opt-out (`fix/gateway-lazy-import-and-test-key-optout`) - **Cancel, cancel preview, reject and checkout returned a bare 500 page on production.** The post-release verification on 2026-09-03 found every one of them dying before its handler ran. The Netlify function logs named the cause: the production context carries a Razorpay TEST key, and the #1219 guard in `lib/payments/core/razorpay.ts` throws at module load when it sees one under the production posture. That guard has shipped in every release since 2026-08-26, so checkout, gateway refunds and Razorpay webhooks had been dead on production for a week, hidden behind the signup outage (#1298). Wave 5 widened the blast radius: the cancel preview route started importing `booking-refund`, which imports `refund.ts`, which imported the gateway barrel as a value, so a request that needed no gateway at all still evaluated the core and tripped the guard. @@ -94,6 +97,48 @@ flowchart TD - **A documented opt-out for the pre-launch window.** With `RAZORPAY_ALLOW_TEST_KEYS_IN_PRODUCTION=true` the guard logs a loud error and lets the module boot, because the production site legitimately runs on test keys while signup is closed and checkout is exercised with test cards. The variable is declared in the required-secrets reference with the rule that it is deleted, and LIVE keys set, before signup opens. The owner chose this posture on 2026-09-03 over switching to live keys immediately. - Two pins in the existing guard suite: the opt-out boots with the warning, and the refund modules load under the production posture with a test key while the core still throws. +### PR A — top-up allocation places only the missing sessions (`feat/allocation-top-up-mode`, #1206) + +- **`autoAllocate` gains a `topUp` mode that never deletes anything.** A partial allocation confirms some of a plan's sessions and leaves the rest unplaced, and until now the only way to recover them was to re-run the ordinary auto path, which deletes every confirmed appointment and re-plans the whole event from scratch. That is correct for a reschedule and destructive for an event whose earlier sessions are already booked and paid, so the recovery was left to the consultant. With `topUp: true` the allocator skips `deleteExistingAppointments` entirely, treats every confirmed appointment as fixed, and asks the search only for the plan's total minus the sessions that already exist. +- **The fixed sessions keep blocking the search and keep counting toward the caps.** A top-up excludes no appointment from the occupancy scan, so the confirmed sessions' intervals stay in the booked set, and their weeks and days seed the per-week and per-day counters exactly as the validator will re-count them. That is the whole difference between a top-up and a re-plan expressed in one list: the ordinary path excludes the event's own appointments because it is about to delete them. +- **The "already fully scheduled" guard answers instead of throwing.** For a top-up, an event that is already complete, and an event whose consultant still has no room, both return a success carrying `noChange: true` rather than a 409 or a `SLOT_SHORTAGE`. A sweep that runs every hour over every incomplete event cannot treat "nothing to do" as an error. A top-up asked for _without_ `allowPartial` still gets the typed `SLOT_SHORTAGE` back, so the consultant's dialog can keep offering to place what fits. +- **Nothing is notified on a run that changed nothing.** `allocate()` suppresses the allocation-time notice when `noChange` is set, which is the precondition the deferred sweep was waiting on: without it the consultee would be told about their booking once an hour, forever, until their consultant happened to publish more availability. When at least one session is placed, the existing complete or partial notice fires once, and its counts are reported against the plan rather than against the run, so a consultee reads "three of four scheduled" rather than "one of four". +- **The hourly `reconcile-slot-availability` job now runs the pass the code had a comment where.** The cohort is recurring events — APPROVED subscriptions and SCHEDULED or IN_PROGRESS classes — whose scheduling window is still open, which carry at least one confirmed session, which carry no tentative slot (a reschedule or a live checkout hold must not be topped up underneath), and whose confirmed session count is short of what the plan requires. Each candidate is attempted only when its consultant's `SlotOfAvailabilityWeekly` or `SlotOfAvailabilityCustom` rows have been touched since the event's own `updatedAt`, because re-searching an unchanged calendar can only reach the same answer. No column was added for that timestamp: a successful top-up re-stamps the request or the class through its transition helper, so `updatedAt` already means "when this event was last attempted". +- **The pass is bounded so the fleet's heaviest cron does not grow a second unbounded tail.** The candidate read is capped, at most twenty-five events are attempted per run, and the loop stops after a minute of wall clock; whatever is skipped is picked up the following hour, ordered so that the consultants who most recently opened up time are served first. One event's failure never ends the sweep, and top-up failures are counted and logged rather than folded into the job's error list, because a calendar with no room is an ordinary outcome and a red cron trains people to ignore it. +- **Review round: a top-up that cannot apply is refused, and the sweep rotates.** `topUp` on a single-session event, on an event with tentative rows, or on an event with nothing confirmed is now a 400 `VALIDATION_ERROR` instead of a silent downgrade to the ordinary delete-and-re-plan path; a whole plan reports `partial: false`. The sweep excludes soft-deleted appointments, reads only the ids of confirmed appointments (the count is the session count), scans in `updatedAt` order across up to three pages, and bumps the event's `updatedAt` on every outcome, including no-change and failure, so the same rows no longer head the list every hour. +- **The four allocate routes accept `topUp` in their shared Zod input** and honour it only for the event's consultant or a privileged caller, on the same footing as `allowPartial` — a consultee may not decide how someone else's calendar is re-planned. The success responses now carry `noChange` so a caller can tell "sessions were added" from "already complete, or still no room". + +### PR B — the last tentative-slot hard deletes become soft cancels (`fix/tentative-slots-soft-cancel`) + +- **Review round: every slot-status write and its history rows share a transaction, and the sweep's guards ride the write.** The three sweeps and the auto-complete pass now run `transitionSlotCompletion` inside `prisma.$transaction`, so a slot can never be moved without the audit row that says why. `cleanup-tentative-slots` repeats its cohort's parent-status predicates in the CAS WHERE, so a request that went back under review, or an event that went live, between the scan and the write keeps its hold. The forbidden-delete pin tolerates whitespace and bracket access and matches allowlisted files exactly, and the cancel-vs-webhook chaos scenario asserts the tombstone state after a cancel win. +- **Review round 2: sweep transactions are bounded.** The helper writes one history row per moved slot, so a whole cohort in one transaction (up to 5,000 rows for the tentative cleanup) would outlive Prisma's default timeout and roll back to zero. `lib/booking/slot-release.ts` now moves cohorts in chunks of 200, each in its own 30-second transaction; the stale-RESCHEDULED release and the three auto-complete passes read a bounded, oldest-first cohort first. The PENDING-consultation expiry commits the EXPIRED transition and the release of its holds in one transaction per consultation, so a failed release can never leave an expired request holding the calendar. +- **The four remaining tentative-hold deletes now free the slot by status.** Doctrine rule 2 says a slot is released by its `completionStatus`, never by a `DELETE`, but four sites still called `slotOfAppointment.deleteMany` on tentative rows: the PENDING-consultation expiry and the stale-RESCHEDULED release in `scripts/appointments/expire-stale-requests.ts`, the stale-hold sweep in `scripts/appointments/cleanup-tentative-slots.ts`, the direct-booking arm of `releaseSlots` in `lib/payments/operations/cancel-pending.ts`, and the failed-payment cleanup in `lib/payments/webhooks/handlers.ts`. All five call sites now route through `transitionSlotCompletion` to `CANCELLED` with a `deletedAt` tombstone, so the hold is released for availability while the row survives for support, disputes and refunds. Every guard that made each site correct — `isTentative: true`, the no-SUCCEEDED-payment predicate, the re-checked parent status — is carried into the compare-and-set WHERE clause verbatim, so a booking that was approved or captured between the cohort read and the write still matches zero rows. The allocator's own re-planning delete in `utils/slotAllocation/SlotAllocationService.ts` remains the one sanctioned exception, because it releases never-paid rows under a `payment: { none: {} }` guard. +- **The stale-RESCHEDULED sweep moves its status scope out of the WHERE and into the from-set.** `transitionSlotCompletion` spreads the caller's WHERE and then overwrites `completionStatus` with its own allowed-from list, so leaving `completionStatus: "RESCHEDULED"` in the WHERE would have silently widened that sweep to SCHEDULED rows it must never touch. The scope is now expressed as `fromIn: ["RESCHEDULED"]`, which the helper bakes into the same statement. +- **Every follow-up read that counts remaining slots now filters the tombstones out.** A soft-cancelled row stays in the table, so the failed-payment cleanup's `remainingSlots` count and the tentative sweep's own cohort read would otherwise never reach zero again: the EXPIRED transition that the count gates would stop firing, and the sweep would refill its per-run cap with its own dead rows on every pass. The orphaned-confirmation reconciler's "still blocked" check is filtered for the same reason, so a successful re-drive is no longer reported as a conflict. Occupancy needed no change, because `buildOccupiedAppointmentFilter`, the grid route and `SlotValidationService` already require `deletedAt: null` on both the slot and its appointment. +- **The lapsed pay-link arm of the expiry sweep stops writing status bare.** `expirePaymentPendingRequests` moved `APPROVED_PENDING_PAYMENT` to `EXPIRED` with a bulk `updateMany` that carried neither the compare-and-set from-set nor the money predicate, which made it the standing counter-example to doctrine rule 1 rather than an instance of it. Because `reconcile-payment-status` flips a recovered capture to SUCCEEDED without touching the request, a booking the buyer had paid for could be expired in the window between the scan and the write, and no `BookingStatusHistory` row was written to show it had happened. Each request now moves through `transitionConsultationRequest` or `transitionSubscriptionRequest` in its own transaction with `fromIn: ["APPROVED_PENDING_PAYMENT"]` and the unpaid predicate repeated in the WHERE, so a raced capture matches zero rows and is counted as skipped instead of expired. +- **Auto-complete's slot-level pass stops stamping holds it should not reach.** The three statements in `completeIndividualSlots` wrote `completionStatus` with a bare `updateMany`, which made them the last uncovered writers of that column and gave them no audit row. Worse, the WHERE clause filtered on nothing but the status and the end time, so an unpaid tentative hold on a slot whose time had passed was marked UNVERIFIED an hour later, and a slot that a sweep had already released was re-stamped from its tombstone. Marking a hold UNVERIFIED is not cosmetic: it moves the row out of the from-set that the release sweeps reach through, so the hold survives every subsequent pass and keeps blocking the consultant's calendar. All three passes now go through `transitionSlotCompletion` with `fromIn: ["SCHEDULED"]`, which is narrower than the map's default so an hourly fallback can never lift a slot a human parked for review, and both `isTentative: false` and `deletedAt: null` ride the same compare-and-set WHERE. The job's counts and log line are unchanged. +- **Two smaller corrections ride along.** The maintenance preflight check counted upcoming sessions without excluding tombstones, so it warned the operator about cancelled sessions that were never going to happen. The lapsed pay-link arm gains a `MAX_REQUESTS_PER_RUN` cap of 500 per cohort, oldest first, matching the cap the tentative sweep already carries: now that each request expires in its own transaction rather than one bulk statement, an unbounded backlog would exhaust the function before it finished, and a capped run logs a warning so the next scheduled run is known to be continuing. +- **The forbidden-delete pin is now a repo-wide rule rather than a list of named files.** `__tests__/payments/appointment-delete-forbidden.test.ts` previously asserted that three specific sweeps were free of `slotOfAppointment.delete`, which is exactly why four other sites kept the shape for months. It now walks `scripts/`, `jobs/`, `lib/`, `app/` and `utils/` — 1,563 source files — against an allowlist of exactly the allocator plus seed and reset scripts under `prisma/` and `scripts/db/`, so a new delete site fails the moment it is written. + +### PR C — every status history row carries its appointment id, and creation is a row + +- **The audit column was dead.** `BookingStatusHistory.appointmentId` has existed since wave 5 and `appendHistory` has always been willing to write it, but the value only ever arrived when a caller volunteered one, and no caller ever did. Every row in the table therefore carried NULL, and the staff timeline resolved a booking's trail entirely through the polymorphic `{ entity, entityId }` arms of its OR. Each of the seven helpers already reads a pre-image inside its transaction to learn the from-status, so the appointment id now rides that same read and is stamped automatically. A caller that knows better still wins, because the resolution only fills in what the caller left out. +- **Two entities deliberately keep writing NULL.** A subscription and a class own `Appointment[]` rather than a single appointment, so their pre-image selects two live appointments and stamps the id only when exactly one came back. A multi-appointment aggregate has no single appointment to name, and guessing one would point the trail at an arbitrary member. A trial that has not been placed yet is the other case: its `appointmentId` is genuinely null until acceptance schedules the session. Both fall back to the entity arms, which is why those arms are load-bearing rather than a transitional fallback. +- **The slot sweep takes the id from the rows it moved.** `transitionSlotCompletion` sweeps a full `WhereInput`, so its callers pass either one appointment id or an `in` list, and reading the id out of the caller's WHERE would have to branch on which. The `updateManyAndReturn` that already tells the helper which slots moved now returns each row's `appointmentId` alongside its id, so the stamp is exact in both shapes and needs no branch. +- **Creation is now a history row.** A freshly created request had never transitioned, so it had no rows at all and the staff timeline answered "nothing has moved on this booking yet" for every new booking on production. A new `appendCreationHistory` helper writes one opening row in the same transaction as the create, from the literal `"CREATED"` to whatever status the row was born in. The literal matters: `appendHistory` renders a missing from-status as `"UNKNOWN"`, which on this surface means a concurrent writer moved the row between the pre-read and the update, and creation is emphatically not that. +- **Three creation paths write it.** The direct-checkout consultation and subscription handlers already run inside the checkout transaction and simply append after the appointment exists. The request-for-approval route did not have a transaction at all — its nested create was atomic on its own — so the create and the audit row are now wrapped in one, budgeted well inside the sixty-second slot lock the route already holds. The capture webhook's own creators in `lib/payments/webhooks/handlers.ts` are deliberately out of scope for this PR. They are the legacy fallback that builds a booking when the payment carries no appointment because checkout never made one, so a booking born that way still opens with no creation row and its timeline starts at its first real transition. + +### PR D — scoped rate cards reach settlement, behind a flag that defaults off (`feat/rate-card-scoped-settlement`) + +- **A rate card scoped to a contract or a plan could be created and never chosen.** `resolveEffectiveRateCard` ranks eight tiers, from the per-expert membership override down to the hardcoded 10/10/80, but its only settlement caller passed just the org, the membership override and the effective instant. Tiers 2 through 6 — contract-scoped and plan-scoped cards — were therefore unreachable, so an org that configured a negotiated split through `POST /organizations/{orgId}/rate-cards` watched every booking settle on the org default instead, with no error anywhere to say so. `resolveOrgSplit` (`lib/payments/payouts/earnings-service.ts`) now forwards the booking's `contractId`, `planType` and `planId` as well. +- **The forwarding is gated on `RATE_CARD_SCOPED_RESOLUTION`, which is on only when the value is exactly `on`.** Flipping it changes which card pays live money: any scoped card an org created while the tiers were dark would begin settling at a different split the moment it became selectable, so an org has to be able to audit its cards first and flip second. The gate is `isScopedRateCardResolutionEnabled()` in `lib/api/organizations/rate-card.ts`, read from `process.env` per call rather than at module load so the flip is a deployment variable rather than a value baked into whichever bundle imported the module first. With the flag off the resolver call is the pre-#1335 one verbatim, and the scoped tiers are not even queried. +- **The effective instant is unchanged.** Settlement still resolves at `payment.createdAt`, because a hold can be days long and a retroactive rate bump must not rewrite what a consultant was owed for work already delivered. +- **The contract is forwarded only when it belongs to the settling org.** The one link a settling payment has to a `Contract` is the org-funded chain `BookingUtilization` → `ProgramAssignment` → `Program` → `Contract`, and `Contract.organizationId` is the **sponsoring** org while `resolveOrgSplit` resolves the expert's **host** org. Because a contract-scoped card is created with the contract checked against its own org, and the resolver then matches `ownerContractId` without re-asserting the org, forwarding a sponsor's contract unguarded would settle one tenant's booking on another tenant's negotiated split. Contract scope is consequently reachable only where the sponsor and the host are the same organization, which is the HYBRID case the tier was designed for. Marketplace and self-funded bookings have no contract at all, and a subscription meters its utilization at slot-allocation time, so it has none yet at settlement; both resolve null and fall through to org scope. +- **Consultation and subscription bookings resolve at plan-type granularity, not plan granularity.** `planType` is derived from the settlement's own `AppointmentType` through an exhaustive record, so all four kinds reach tiers 4 and 5, but only webinar and class carry a plan id into settlement, so only they reach tiers 2 and 3. A card scoped to a specific consultation or subscription plan is still never selected; closing that means widening the payment projection at the three `createEarningsFromPayment` call sites. +- **The collaborator leg forwards no scope under either flag state.** ADR 18 makes collaborations org-blind — a collaborator on someone else's org-owned plan is not that org's expert — so the seller's contract and plan must not select a card owned by the collaborator's own org. +- **`sync-payment-earnings` reads the flag too.** The earnings healer accrues through the same `createEarningsFromPayment` the capture webhook uses, so the workflow now carries `RATE_CARD_SCOPED_RESOLUTION` in its environment. Set on Netlify alone, the same booking would have settled on the scoped card when the webhook caught it and on the org default when the healer did. +- **Pin:** `__tests__/payments/rate-card-scoped-settlement.test.ts` drives the real resolver end to end and asserts the bps that land on the earnings rows — the plan-scoped card's split with the flag on, the org default's with it off, and no contract query at all when the sponsoring contract belongs to another org. + ## Changelog: 2026-09-02 — wave 5 The wave-5 train (#1319) reconciles the original booking and maintenance audit briefs against everything that shipped in waves 1–4 and closes the residuals that survived. Each PR appends its own bullets here. @@ -177,7 +222,7 @@ Part of #1319. This PR makes an abandoned checkout release its slot by definitio **The pay-link mint is its own guarded atom.** The approval-payment mint used a private `lock:approval_payment:` key through the single-shot `lib/redis` `acquireLock`, where a breaker-open Redis and a held lock were indistinguishable. Review of this PR caught that the approval routes mint while they still hold `consultation-approval:` / `subscription-approval:`, so folding the mint into those keys would have made every approval contend with itself. The mint now takes `approval-payment-mint::` through the guarded path, keyed by the validated `appointmentType`, nested under the approval lock (order: approval → mint). The approval routes also re-grant their lock at the top of every Serializable attempt (`renewApprovalLock`); a lapsed grant is a 409 `APPROVAL_LOCK_LOST`, never a concurrent second mint. The two crud-with-plan delete routes and the org payout writer retry once, not three times, so their per-attempt budgets stay under the function ceiling and the 60-second payout grant. -**A dead approval intent is re-minted in place, never handed back or duplicated.** The reuse branch of `createApprovalPaymentIntent` treated every PENDING payment as live, so a row the hold rule already calls dead — marked EXPIRED, or still PENDING past its own `expiresAt` — was returned as the checkout URL. That link points at a slot `buildDeadHoldFilter` has just released, so the payer either pays for a slot somebody else now holds or pays into an order the sweep is about to void. Minting a second row is not the way out either, because `Payment` is unique on `[userId, appointmentId]` and the insert dies on P2002. The mint now creates a fresh gateway order and writes it back into the same Payment row, which keeps its id, its owner and its appointment while taking the new intent, PENDING status, a new forty-eight-hour window and the figures the gateway was actually asked for; the CARD leg is re-pointed with it so `sum(legs) = amount` still holds. When the sweep has already moved the request on — a REJECTED consultation or subscription, a trial that is no longer AWAITING_PAYMENT — there is nothing left to re-mint against, so the mint raises `ApprovalWindowLapsedError` and both approval routes answer 409 telling the consultee to submit the request again. A FAILED row is untouched by this rule: a gateway rejection is retried from scratch. +**A dead approval intent is re-minted in place, never handed back or duplicated.** The reuse branch of `createApprovalPaymentIntent` treated every PENDING payment as live, so a row the hold rule already calls dead — marked EXPIRED, or still PENDING past its own `expiresAt` — was returned as the checkout URL. That link points at a slot `buildDeadHoldFilter` has just released, so the payer either pays for a slot somebody else now holds or pays into an order the sweep is about to void. Minting a second row is not the way out either, because `Payment` is unique on `[userId, appointmentId]` and the insert dies on P2002. The mint now creates a fresh gateway order and writes it back into the same Payment row, which keeps its id, its owner and its appointment while taking the new intent, PENDING status, a new forty-eight-hour window and the figures the gateway was actually asked for; the CARD leg is re-pointed with it so the funding legs still sum to `amount`. When the sweep has already moved the request on — a REJECTED consultation or subscription, a trial that is no longer AWAITING_PAYMENT — there is nothing left to re-mint against, so the mint raises `ApprovalWindowLapsedError` and both approval routes answer 409 telling the consultee to submit the request again. A FAILED row is untouched by this rule: a gateway rejection is retried from scratch. **Checkout renews its slot grant on every retry attempt.** One renewal before the Serializable loop could not cover four 25-second attempts against a 60-second CONSULTATION grant, so the lock lapsed mid-payment on a retried checkout. The grant is now renewed at the top of every `withSerializableRetry` attempt with a TTL sized to one attempt plus slack (the larger of the type's TTL and 35 seconds). Lost ownership aborts the attempt with the existing "already in progress" error, which the route maps to 409. An exhausted P2034 retry budget in `app/api/checkout/route.ts` is a 409 `SERIALIZATION_CONFLICT` that tells the customer the card was not charged. diff --git a/docs/booking/06-dependency-graphs.md b/docs/booking/06-dependency-graphs.md index 7231a3cfd..2fddf3d84 100644 --- a/docs/booking/06-dependency-graphs.md +++ b/docs/booking/06-dependency-graphs.md @@ -304,7 +304,7 @@ erDiagram ClassEvent ||--o{ Appointment : "M appointments" Appointment ||--|{ SlotOfAppointment : "N slots per session" - Appointment ||--o{ BookingStatusHistory : "one row per CAS transition" + Appointment ||--o{ BookingStatusHistory : "one row per creation and CAS transition" SlotOfAppointment ||--o| MeetingSession : "video call" ``` diff --git a/docs/booking/README.md b/docs/booking/README.md index f9577ca1c..d90f013f5 100644 --- a/docs/booking/README.md +++ b/docs/booking/README.md @@ -39,7 +39,7 @@ graph TD ## Reading the audit trail -Every guarded status transition appends one `BookingStatusHistory` row inside the same transaction as the state change, and the reschedule proposals raised against a booking are kept as `RescheduleRequest` rows. Those two tables together are the booking's audit trail, and the way to read them is `getBookingTimeline` in [`lib/data/booking-history.ts`](../../lib/data/booking-history.ts), which merges both sources into a single newest-first list of status edges, actors and reasons. It resolves the trail through the polymorphic `entityId` column rather than through the nullable `appointmentId`, because no writer populates the latter today, and that is what makes the slot and reschedule rows visible. The surface over it is `GET /api/staff/appointments/[appointmentId]/timeline`, which renders in the operator appointment detail modal on the staff and admin appointments pages. Reading it requires ADMIN or STAFF: ADR 20 gives organization roles no per-session drill-in, so the read model's scope parameter accepts only the privileged `all` kind and refuses anything else. +Every guarded status transition appends one `BookingStatusHistory` row inside the same transaction as the state change, creation appends one more from the literal `"CREATED"` so a booking that has not moved yet still has a timeline, and the reschedule proposals raised against a booking are kept as `RescheduleRequest` rows. Those two tables together are the booking's audit trail, and the way to read them is `getBookingTimeline` in [`lib/data/booking-history.ts`](../../lib/data/booking-history.ts), which merges both sources into a single newest-first list of status edges, actors and reasons. It resolves the trail through both the nullable `appointmentId`, which the transition helpers now fill from each row's own pre-image, and the polymorphic `entityId` column, which is what still makes the rows visible where no single appointment can be named — a subscription or class owning several live appointments, a trial not yet scheduled, and every row written before #1333. The surface over it is `GET /api/staff/appointments/[appointmentId]/timeline`, which renders in the operator appointment detail modal on the staff and admin appointments pages. Reading it requires ADMIN or STAFF: ADR 20 gives organization roles no per-session drill-in, so the read model's scope parameter accepts only the privileged `all` kind and refuses anything else. ## Source Code Map diff --git a/docs/compliance/01-tds-overview.md b/docs/compliance/01-tds-overview.md index 3116ccc6f..b7ebd4371 100644 --- a/docs/compliance/01-tds-overview.md +++ b/docs/compliance/01-tds-overview.md @@ -1,6 +1,6 @@ # 01 — TDS overview (sections, rates, thresholds) -> **Status:** 🔴 production bug on B2C side (wrong section + rate). B2B side is closer to correct but still needs wiring. 🔴 **NEW (2026-06-05): the Income-tax Act, 2025 took effect 1-Apr-2026 — the old §194O/194J/194C/195 section *numbers* no longer exist for returns on transactions on/after that date. See "Income-tax Act 2025 renumbering" below; code still emits the old labels.** +> **Status:** 🔴 production bug on B2C side (wrong section + rate). B2B side is closer to correct but still needs wiring. 🔴 **NEW (2026-06-05): the Income-tax Act, 2025 took effect 1-Apr-2026 — the old §194O/194J/194C/195 section _numbers_ no longer exist for returns on transactions on/after that date. See "Income-tax Act 2025 renumbering" below; code still emits the old labels.** > **Audience:** anyone working on payouts (`lib/payments/payouts/`), tax helpers (`lib/payments/tax/`, `lib/compliance/`). > **Last reviewed:** 2026-06-05 (regulatory facts web-verified as of 2026-06-05; prior review 2026-05-02) > **Linked issues:** [#737](https://github.com/Practitionist/games/familiarise_web/issues/737), [#738](https://github.com/Practitionist/familiarise_web/issues/738) (Item F). @@ -11,43 +11,44 @@ Tax Deducted at Source — the income-tax withholding that a payer (us) takes of Section numbers below are given as **§1961-Act, now §2025-Act / payment-code** (see the renumbering note immediately after the table). Rates and thresholds are **unchanged** by the 2025 Act — only the citation/form taxonomy changed. -| Section (1961 → 2025 Act) | Applies to | Rate (FY 2026-27) | Threshold | No-PAN fallback | -|---|---|---|---|---| -| **194O → §393(1) Table Sl.8(v), code 1035** | E-commerce operator pays e-commerce participant | **0.10%** (cut from 1% w.e.f. 1 Oct 2024 by Finance (No. 2) Act 2024) | ₹5,00,000 / FY for resident *individuals/HUF* with valid PAN/Aadhaar; **no threshold** for partnerships / companies / LLPs / non-residents | **5%** (special carve-out for 194O — not the usual 20% under old 206AA, now §397(2)) | -| **194J → §393(1) Table Sl.6(iii)** | Professional / technical services billed directly | **10% professional** (codes 1027/1028) / **2% technical** (code 1026) — *the rates have been distinct since FY 2020-21; the 2025 Act gives them separate payment codes* | **₹50,000 / FY** (raised from ₹30,000 w.e.f. FY 2026-27; computed *per payment-type* — professional, technical, royalty each have their own ₹50K) | 20% | -| **194C → §393(1) Table Sl.6(i)** | Contract works / vendor services | 1% (individual/HUF, code 1023) or 2% (others, code 1024) | ₹30,000 single / ₹1,00,000 aggregate | 20% | -| **195 → §393(2) Table Sl.17, code 1057** | Any payment to a non-resident | 20% (or DTAA rate if Form 10F + TRC + lower-rate cert produced) | None | DTAA cap or 20% | +| Section (1961 → 2025 Act) | Applies to | Rate (FY 2026-27) | Threshold | No-PAN fallback | +| ------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| **194O → §393(1) Table Sl.8(v), code 1035** | E-commerce operator pays e-commerce participant | **0.10%** (cut from 1% w.e.f. 1 Oct 2024 by Finance (No. 2) Act 2024) | ₹5,00,000 / FY for resident _individuals/HUF_ with valid PAN/Aadhaar; **no threshold** for partnerships / companies / LLPs / non-residents | **5%** (special carve-out for 194O — not the usual 20% under old 206AA, now §397(2)) | +| **194J → §393(1) Table Sl.6(iii)** | Professional / technical services billed directly | **10% professional** (codes 1027/1028) / **2% technical** (code 1026) — _the rates have been distinct since FY 2020-21; the 2025 Act gives them separate payment codes_ | **₹50,000 / FY** (raised from ₹30,000 w.e.f. FY 2026-27; computed _per payment-type_ — professional, technical, royalty each have their own ₹50K) | 20% | +| **194C → §393(1) Table Sl.6(i)** | Contract works / vendor services | 1% (individual/HUF, code 1023) or 2% (others, code 1024) | ₹30,000 single / ₹1,00,000 aggregate | 20% | +| **195 → §393(2) Table Sl.17, code 1057** | Any payment to a non-resident | 20% (or DTAA rate if Form 10F + TRC + lower-rate cert produced) | None | DTAA cap or 20% | **Removed and gone (do not implement):** + - Section 206C(1H) — TCS on sale of goods > ₹50L. Omitted by Finance Act 2025 w.e.f. 1 Apr 2025. ✅ verified 2026-06-05. - Section 206AB / 206CCA — higher TDS / TCS for non-filers. Omitted w.e.f. 1 Apr 2025. ✅ verified 2026-06-05. ### Income-tax Act 2025 renumbering — verified as of 2026-06-05 -The **Income-tax Act, 2025 (Act 30 of 2025)** received Presidential assent 21 Aug 2025 and **came into force 1 Apr 2026** — operationalised by the Income-tax Rules, 2026 (G.S.R. 198(E), 20 Mar 2026); the 1961 Act stood repealed 31 Mar 2026. **Every TDS provision outside salary is consolidated into a single Section 393** (salary TDS → §392; TCS → §394; higher-rate-for-no-PAN, old 206AA/206CC → **§397(2)**, retaining the 20% rate). The old alphanumeric section numbers (194O, 194J, 194C, 195, 206AA…) **cease to exist as filing citations** for any transaction on/after 1 Apr 2026 and are replaced by **numeric payment codes (the 10xx series — publishers cite 1001–1067)** keyed to table serials inside §393. (Exact code endpoints differ across early concordances; treat any single code below as the *publisher-asserted* mapping pending the final CBDT challan/RPU schema — see follow-up note.) +The **Income-tax Act, 2025 (Act 30 of 2025)** received Presidential assent 21 Aug 2025 and **came into force 1 Apr 2026** — operationalised by the Income-tax Rules, 2026 (G.S.R. 198(E), 20 Mar 2026); the 1961 Act stood repealed 31 Mar 2026. **Every TDS provision outside salary is consolidated into a single Section 393** (salary TDS → §392; TCS → §394; higher-rate-for-no-PAN, old 206AA/206CC → **§397(2)**, retaining the 20% rate). The old alphanumeric section numbers (194O, 194J, 194C, 195, 206AA…) **cease to exist as filing citations** for any transaction on/after 1 Apr 2026 and are replaced by **numeric payment codes (the 10xx series — publishers cite 1001–1067)** keyed to table serials inside §393. (Exact code endpoints differ across early concordances; treat any single code below as the _publisher-asserted_ mapping pending the final CBDT challan/RPU schema — see follow-up note.) Verified old→new mapping (sources: Finpracto / Tax2win / Jurishour concordances, Mar–May 2026): -| 1961 Act | 2025 Act | Payment code | Rate | -|---|---|---|---| -| 194O | §393(1) Table Sl.8(v) | **1035** | 0.10% | -| 194J (technical) | §393(1) Table Sl.6(iii) | **1026** | 2% | -| 194J (professional / director) | §393(1) Table Sl.6(iii) | **1027 / 1028** | 10% | -| 194C (individual/HUF) | §393(1) Table Sl.6(i) | **1023** | 1% | -| 194C (others) | §393(1) Table Sl.6(i) | **1024** | 2% | -| 195 | §393(2) Table Sl.17 | **1057** | rates-in-force / DTAA | -| 206AA / 206CC (no-PAN) | **§397(2)** | — | 20% (retained) | +| 1961 Act | 2025 Act | Payment code | Rate | +| ------------------------------ | ----------------------- | --------------- | --------------------- | +| 194O | §393(1) Table Sl.8(v) | **1035** | 0.10% | +| 194J (technical) | §393(1) Table Sl.6(iii) | **1026** | 2% | +| 194J (professional / director) | §393(1) Table Sl.6(iii) | **1027 / 1028** | 10% | +| 194C (individual/HUF) | §393(1) Table Sl.6(i) | **1023** | 1% | +| 194C (others) | §393(1) Table Sl.6(i) | **1024** | 2% | +| 195 | §393(2) Table Sl.17 | **1057** | rates-in-force / DTAA | +| 206AA / 206CC (no-PAN) | **§397(2)** | — | 20% (retained) | **Filing impact (verified):** for Q4 FY 2025-26 (up to 31 Mar 2026) returns still use the old section numbers + old form names. For Tax Year 2026-27 onward, a return filed with an old section number (e.g. "194O") **triggers a system-level validation error at upload**. Form names also change — see [doc 04](./04-tds-quarterly-filings.md) (26Q → Form 140, 27Q → Form 144, 16A → Form 131). -🟡 **Code-vs-law divergence (verified 2026-06-05):** the code still stores and emits the **old labels** — `lib/compliance/tds.ts` `TDS_SECTION_DEFAULTS` keys (`"194O"`, `"194J"`, `"194C"`), `TDSRecord.tdsSection`, and `OrganizationPayout.tdsSectionApplied` all carry `"194O"`/`"194J"`/`"194C"`. These are correct for *internal classification* but **must be translated to §393 payment codes before any return upload for FY 2026-27** or the FVU/portal upload will reject. This is a filing-export concern, not a withholding-math concern (the *rates* are unchanged). Tracked as an engineering follow-up; the FVU generator (doc 04) is the right place to map label → code. +🟡 **Code-vs-law divergence (verified 2026-06-05):** the code still stores and emits the **old labels** — `lib/compliance/tds.ts` `TDS_SECTION_DEFAULTS` keys (`"194O"`, `"194J"`, `"194C"`), `TDSRecord.tdsSection`, and `OrganizationPayout.tdsSectionApplied` all carry `"194O"`/`"194J"`/`"194C"`. These are correct for _internal classification_ but **must be translated to §393 payment codes before any return upload for FY 2026-27** or the FVU/portal upload will reject. This is a filing-export concern, not a withholding-math concern (the _rates_ are unchanged). Tracked as an engineering follow-up; the FVU generator (doc 04) is the right place to map label → code. ## When it applies ### B2C (consumer marketplace) - A consumer pays the platform via card. Platform is the **e-commerce operator** under Sec 194O Explanation(a). Consultant is the **e-commerce participant** under Explanation(b). -- The right section is **194O at 0.10%**. Threshold of ₹5L applies only to *resident individuals/HUF* with valid PAN/Aadhaar. For everyone else, withhold from rupee 1. +- The right section is **194O at 0.10%**. Threshold of ₹5L applies only to _resident individuals/HUF_ with valid PAN/Aadhaar. For everyone else, withhold from rupee 1. - For non-resident consultants, **194O does not apply** — pivot to **Sec 195 + DTAA**. ### B2B (org-sponsored) @@ -65,31 +66,38 @@ Verified old→new mapping (sources: Finpracto / Tax2win / Jurishour concordance Two TDS files, two contracts, partial overlap (re-verified against code 2026-06-05): -| File | Section | Rate | Threshold | No-PAN | Notes | -|---|---|---|---|---|---| -| `lib/payments/tax/tds-service.ts` (consultant path, **`@deprecated`**) | 194J | 10% | ₹50,000 | 20% | Header still says "Section 194J flat 10%". Slated for deprecation in favour of the canonical lib once CA signs off on 194-O precedence for consultant payouts (#778 §E). Still live as the conservative default. | -| `lib/compliance/tds.ts` (canonical / org path) | 194O default ✅ | **0.10% ✅** (`"194O": 0.001`) | none for orgs ✅ | **5% ✅** (`NO_PAN_RATE_194O = 0.05`) | **Fixed since the original audit** (#771 P0-1 / #737 / #738) — rate is now 0.001, and 194-O carries its own 5% no-PAN rate distinct from the 206AA/§397(2) 20%. 206AA fallback, §197 cert, and DTAA lookup all implemented. | +| File | Section | Rate | Threshold | No-PAN | Notes | +| ---------------------------------------------------------------------- | --------------- | ------------------------------ | ---------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lib/payments/tax/tds-service.ts` (consultant path, **`@deprecated`**) | 194J | 10% | ₹50,000 | 20% | Header still says "Section 194J flat 10%". Slated for deprecation in favour of the canonical lib once CA signs off on 194-O precedence for consultant payouts (#778 §E). Still live as the conservative default. | +| `lib/compliance/tds.ts` (canonical / org path) | 194O default ✅ | **0.10% ✅** (`"194O": 0.001`) | none for orgs ✅ | **5% ✅** (`NO_PAN_RATE_194O = 0.05`) | **Fixed since the original audit** (#771 P0-1 / #737 / #738) — rate is now 0.001, and 194-O carries its own 5% no-PAN rate distinct from the 206AA/§397(2) 20%. 206AA fallback, §197 cert, and DTAA lookup all implemented. | 🟡 **Two residual code-vs-law nuances (verified 2026-06-05), both in `lib/compliance/tds.ts`:** + - `TDS_SECTION_DEFAULTS["194J"] = 0.1` is a **flat 10%**. Current law (and the 1961 Act since FY 2020-21) distinguishes **technical services at 2%** (code 1026) from **professional/director fees at 10%** (codes 1027/1028). The flat-10% path over-withholds on technical-service consultants. Low blast radius today because 194-O is the platform default, but the residual 194J override path is wrong for technical services. - Section **labels** (`"194O"`, `"194J"`, `"194C"`) are emitted as-is into `TDSRecord.tdsSection` / `OrganizationPayout.tdsSectionApplied`; these need label→§393-payment-code translation before any FY 2026-27 return upload (see renumbering note above). -The deprecated `tds-service.ts` consultant path historically *skipped* deduction for non-residents (should pivot to §195/§393(2) + DTAA, not skip). The canonical `lib/compliance/tds.ts` handles NON_RESIDENT via the DTAA-lookup branch; the gap is only on the deprecated path, which is why consolidation onto the canonical lib (#778 §E) is the fix. +The deprecated `tds-service.ts` consultant path historically _skipped_ deduction for non-residents (should pivot to §195/§393(2) + DTAA, not skip). The canonical `lib/compliance/tds.ts` handles NON_RESIDENT via the DTAA-lookup branch; the gap is only on the deprecated path, which is why consolidation onto the canonical lib (#778 §E) is the fix. + +### Both rails now write `TDSRecord` (#1354) + +`TDSRecord` was a consultant-only table, which meant that host-organisation withholding was computed, deducted from the disbursement and posted to `TDS_PAYABLE` without ever producing a filing row. Organisation payouts now write the same audit row that consultant payouts do, at the moment the payout reaches `COMPLETED` and never earlier, so the quarterly draft covers every deduction the platform actually made. + +The table carries both rails at once. `consultantProfileId` and `organizationId` are each nullable and exactly one is set on any row, which `tds_record_deductee_xor` enforces in the database; a second constraint, `tds_record_payout_rail_matches`, prevents a row from citing the payout of the rail it does not belong to. The org rail has its own unique key over `(organizationId, financialYear, quarter, orgPayoutId, isReversal)` rather than sharing the consultant one, because Postgres treats NULLs as distinct and a shared key would silently dedupe nothing. `TdsAdjustment` is widened the same way, so a reversal on either rail produces the revised-statement line the return generator exports. ## Gap Re-verified against code 2026-06-05. Several rows from the original audit are now **fixed** (struck) because the canonical lib was corrected under #771/#737/#738; the live gaps are the consultant-path consolidation, the 194J split, and the §393 code mapping. -| Gap | Where | Severity | -|---|---|---| -| ~~Wrong rate (1% → 0.10%)~~ **FIXED** — `"194O": 0.001` | `lib/compliance/tds.ts` | ✅ | -| ~~Wrong no-PAN fallback (20% → 5% for 194O)~~ **FIXED** — `NO_PAN_RATE_194O = 0.05` | `lib/compliance/tds.ts` | ✅ | -| Consultant path still 194J/₹50K (deprecated, not yet consolidated onto canonical lib) | `lib/payments/tax/tds-service.ts` | 🔴 (blocked on CA signoff, #778 §E) | -| 194J modelled as flat 10% — no technical-2% (code 1026) vs professional-10% (1027/1028) split | `lib/compliance/tds.ts` `TDS_SECTION_DEFAULTS` | 🟡 | -| Section labels not mapped to §393 payment codes for FY 2026-27 return upload | `lib/compliance/tds.ts`, `TDSRecord.tdsSection`, FVU export (doc 04) | 🔴 (filing-blocking from 1-Apr-2026) | -| No threshold differentiation by entity type on the deprecated path (companies/LLPs get no threshold under 194-O) | `tds-service.ts` | 🟠 (canonical lib treats org payouts as no-threshold; `TaxEntityType` enum now exists in schema) | -| Non-resident path *skips* deduction on the **deprecated** path (should pivot to §195/§393(2)+DTAA) | `tds-service.ts` | 🔴 | -| ~~`ConsultantProfile.taxEntityType` field doesn't exist~~ **ADDED** — `enum TaxEntityType` now in schema (#778 §D) | `prisma/schema.prisma` | ✅ | +| Gap | Where | Severity | +| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| ~~Wrong rate (1% → 0.10%)~~ **FIXED** — `"194O": 0.001` | `lib/compliance/tds.ts` | ✅ | +| ~~Wrong no-PAN fallback (20% → 5% for 194O)~~ **FIXED** — `NO_PAN_RATE_194O = 0.05` | `lib/compliance/tds.ts` | ✅ | +| Consultant path still 194J/₹50K (deprecated, not yet consolidated onto canonical lib) | `lib/payments/tax/tds-service.ts` | 🔴 (blocked on CA signoff, #778 §E) | +| 194J modelled as flat 10% — no technical-2% (code 1026) vs professional-10% (1027/1028) split | `lib/compliance/tds.ts` `TDS_SECTION_DEFAULTS` | 🟡 | +| Section labels not mapped to §393 payment codes for FY 2026-27 return upload | `lib/compliance/tds.ts`, `TDSRecord.tdsSection`, FVU export (doc 04) | 🔴 (filing-blocking from 1-Apr-2026) | +| No threshold differentiation by entity type on the deprecated path (companies/LLPs get no threshold under 194-O) | `tds-service.ts` | 🟠 (canonical lib treats org payouts as no-threshold; `TaxEntityType` enum now exists in schema) | +| Non-resident path _skips_ deduction on the **deprecated** path (should pivot to §195/§393(2)+DTAA) | `tds-service.ts` | 🔴 | +| ~~`ConsultantProfile.taxEntityType` field doesn't exist~~ **ADDED** — `enum TaxEntityType` now in schema (#778 §D) | `prisma/schema.prisma` | ✅ | ## Required @@ -113,13 +121,13 @@ In commit order: ## References -- [Income-tax Act 2025 in force 1-Apr-2026 — press release (incometaxindia.gov.in)](https://www.incometaxindia.gov.in/documents/d/guest/press-release-income-tax-act-2025-comes-into-force-from-01-april-2026-pdf) — *verified 2026-06-05* +- [Income-tax Act 2025 in force 1-Apr-2026 — press release (incometaxindia.gov.in)](https://www.incometaxindia.gov.in/documents/d/guest/press-release-income-tax-act-2025-comes-into-force-from-01-april-2026-pdf) — _verified 2026-06-05_ - [Income-tax Act 2025 effective 1-Apr-2026 (PIB PRID 2221416)](https://www.pib.gov.in/PressReleasePage.aspx?PRID=2221416®=3&lang=1) -- [TDS/TCS section mapping 1961 → 2025 Act (Finpracto)](https://www.finpracto.com/tds-tcs-section-mapping-from-the-1961-act-to-the-2025-act-your-complete-reference-guide/) — *194O→§393(1) Sl.8(v) code 1035; 194J→Sl.6(iii) 1026/1027/1028; 195→§393(2) Sl.17 code 1057* +- [TDS/TCS section mapping 1961 → 2025 Act (Finpracto)](https://www.finpracto.com/tds-tcs-section-mapping-from-the-1961-act-to-the-2025-act-your-complete-reference-guide/) — _194O→§393(1) Sl.8(v) code 1035; 194J→Sl.6(iii) 1026/1027/1028; 195→§393(2) Sl.17 code 1057_ - [Old vs new TDS section mapping FY 2026-27 (Jurishour)](https://www.jurishour.in/columns/old-new-tds-sections-mapping-income-tax-act-2025/) - [206AA/206CC merged into §397(2) (TDSMan, May 2026)](https://blog.tdsman.com/2026/05/higher-rate-of-tds-for-non-furnishing-of-pan-section-3972-206aa-206cc/) -- [Section 194O 0.10% rate + ₹5L threshold (TDSMan)](https://blog.tdsman.com/2025/09/section-194o-tds-on-payments-by-e-commerce-operators-to-participants/) — *0.10% confirmed unchanged for FY 2026-27* -- [TDS Rate Chart FY 2026-27 (TaxGarden)](https://taxgarden.in/blog/tds-rate-chart-2026-to-2027) — *194O 0.1%; 194J 10% prof / 2% technical; §393 payment codes* -- [§194J threshold raised ₹30K → ₹50K w.e.f. FY 2026-27 (Tax2win)](https://tax2win.in/guide/section-194j-under-income-tax-act) — *verified 2026-06-05; ₹50K is per payment-type* -- [Income-tax Rules 2026 G.S.R. 198(E) — new forms 26Q→140 / 27Q→144 / 16A→131 (TDSMan, Mar 2026)](https://blog.tdsman.com/2026/03/new-tds-tcs-forms-it-act-2025-mapping-with-old-forms/) — *verified 2026-06-05* +- [Section 194O 0.10% rate + ₹5L threshold (TDSMan)](https://blog.tdsman.com/2025/09/section-194o-tds-on-payments-by-e-commerce-operators-to-participants/) — _0.10% confirmed unchanged for FY 2026-27_ +- [TDS Rate Chart FY 2026-27 (TaxGarden)](https://taxgarden.in/blog/tds-rate-chart-2026-to-2027) — _194O 0.1%; 194J 10% prof / 2% technical; §393 payment codes_ +- [§194J threshold raised ₹30K → ₹50K w.e.f. FY 2026-27 (Tax2win)](https://tax2win.in/guide/section-194j-under-income-tax-act) — _verified 2026-06-05; ₹50K is per payment-type_ +- [Income-tax Rules 2026 G.S.R. 198(E) — new forms 26Q→140 / 27Q→144 / 16A→131 (TDSMan, Mar 2026)](https://blog.tdsman.com/2026/03/new-tds-tcs-forms-it-act-2025-mapping-with-old-forms/) — _verified 2026-06-05_ - See also: [02-gst-overview.md](./02-gst-overview.md) (GST TCS Sec 52 is the GST analogue), [04-tds-quarterly-filings.md](./04-tds-quarterly-filings.md) (Form 26Q→140 / 27Q→144), [07-cross-border-flows.md](./07-cross-border-flows.md) (Sec 195 → §393(2)). diff --git a/docs/compliance/02-gst-overview.md b/docs/compliance/02-gst-overview.md index 6b717f1cc..f120375d6 100644 --- a/docs/compliance/02-gst-overview.md +++ b/docs/compliance/02-gst-overview.md @@ -5,19 +5,23 @@ > **Last reviewed:** 2026-06-05 (regulatory facts web-verified as of 2026-06-05; prior review 2026-05-02) > **Linked issues:** [#737 §2,§5,§11](https://github.com/Practitionist/familiarise_web/issues/737), [#738 §A,§F](https://github.com/Practitionist/familiarise_web/issues/738). +## Model decision (2026-09-03) + +The platform bills as **Principal supplier for GST**, decided in [ADR 26](../enterprise/70-design-decisions/26-gst-principal-model.md) and superseding the facilitator framing this document was originally written around. GST stays at 18% on the full discounted price exactly as today, and the platform will issue its own numbered **B2C tax invoice** for every consumer supply (`ConsumerInvoice`) rather than relying on a consultant-issued invoice; this is the design intent recorded in ADR 26, not yet shipped — the model lands with PR-E (`feat/finance-b2c-tax-invoice`, in flight). Place of supply for a consumer defaults to the **supplier's home state under Section 12(2)(b) of the IGST Act** whenever no buyer address is on record, which makes the fallback supply intra-state (CGST + SGST) — the opposite of the B2B derivation's IGST fallback, which stays as an audit signal for org invoices only. **GST-TCS under Section 52 does not apply under this model and is not collected**; the dormant schema (`Payment.gstTcsCollectedPaise`, `GstTcsBatch`, the GSTR-8 draft builder) stays in the tree, correctly annotated at 0.5%, and is only wired if a chartered accountant overturns this decision. See ADR 26 for the full CA question list, including whether a platform-funded referral credit should reduce the taxable value under Section 15(3)(a). + ## What it is GST has four interlocking obligations for an e-commerce operator (us): 1. **GST registration (Sec 24(x))** — mandatory for the platform regardless of turnover. -2. **Tax invoice (Rule 46)** — must be issued for every taxable supply; specific format + HSN/SAC + place-of-supply rules. *(Rate context, verified 2026-06-05: the GST 2.0 rationalization — 56th GST Council, 3-Sep-2025, effective 22-Sep-2025 — collapsed the four-slab structure into two main slabs **5% + 18%** (plus 40% sin/luxury); the 12% and 28% slabs were removed. **Professional / consulting / commercial-training services stay at 18%** — so the CGST 9% + SGST 9% / IGST 18% derivation below is unchanged.)* -3. **TCS Section 52** — the platform must collect **0.5% (0.25% CGST + 0.25% SGST, or 0.5% IGST)** on the **net taxable value** of supplies of registered consultants and file **GSTR-8** monthly. *(Halved from 1% by Notification 15/2024-Central Tax + the parallel IGST/UTGST notifications, w.e.f. 10 Jul 2024 — verified 2026-06-05.)* -4. **E-invoicing (Notif 10/2023)** — mandatory for any registered person with aggregate annual turnover (AATO) ≥ ₹5 cr (threshold unchanged as of 2026-06-05); voluntary B2C pilot launched Sep 2024. *(Separate 30-day IRP-reporting cut-off applies at AATO ≥ ₹10 cr since 1-Apr-2025.)* +2. **Tax invoice (Rule 46)** — must be issued for every taxable supply; specific format + HSN/SAC + place-of-supply rules. _(Rate context, verified 2026-06-05: the GST 2.0 rationalization — 56th GST Council, 3-Sep-2025, effective 22-Sep-2025 — collapsed the four-slab structure into two main slabs **5% + 18%** (plus 40% sin/luxury); the 12% and 28% slabs were removed. **Professional / consulting / commercial-training services stay at 18%** — so the CGST 9% + SGST 9% / IGST 18% derivation below is unchanged.)_ +3. **TCS Section 52 (facilitator-model reference only, not a current obligation)** — under the e-commerce-operator/facilitator framing, the platform would collect **0.5% (0.25% CGST + 0.25% SGST, or 0.5% IGST)** on the **net taxable value** of supplies of registered consultants and file **GSTR-8** monthly. Under the principal-supplier model this document now uses (ADR 26, #1360), the platform is the supplier of record, so Section 52 TCS does not apply and is not collected; this obligation is CA-gated and only becomes live if a chartered accountant overturns the principal-supplier decision. _(Rate context if reversed: halved from 1% by Notification 15/2024-Central Tax + the parallel IGST/UTGST notifications, w.e.f. 10 Jul 2024 — verified 2026-06-05.)_ +4. **E-invoicing (Notif 10/2023)** — mandatory for any registered person with aggregate annual turnover (AATO) ≥ ₹5 cr (threshold unchanged as of 2026-06-05); voluntary B2C pilot launched Sep 2024. _(Separate 30-day IRP-reporting cut-off applies at AATO ≥ ₹10 cr since 1-Apr-2025.)_ Plus the orthogonal obligations: - **Place of supply** (IGST Sec 12 / 13) — determines IGST vs CGST+SGST. -- **HSN/SAC codes** — mandatory on invoice; B2C reporting in GSTR-1 Table 12 is optional below ₹5 cr AATO. ⚠️ **SAC correction (verified 2026-06-05): `999293` is *commercial training & coaching* (an education code under group 9992), NOT consulting. Management consulting is `998311`. All of 998311 / 999293 / 999294 / 999299 carry 18% GST, so the *rate* is unaffected — but the doc's old "999293 (consulting)" labelling and the code's 999293 catch-all are a classification (ITC-trail) inaccuracy, not a tax-amount error.** +- **HSN/SAC codes** — mandatory on invoice; B2C reporting in GSTR-1 Table 12 is optional below ₹5 cr AATO. ⚠️ **SAC correction (verified 2026-06-05): `999293` is _commercial training & coaching_ (an education code under group 9992), NOT consulting. Management consulting is `998311`. All of 998311 / 999293 / 999294 / 999299 carry 18% GST, so the _rate_ is unaffected — but the doc's old "999293 (consulting)" labelling and the code's 999293 catch-all are a classification (ITC-trail) inaccuracy, not a tax-amount error.** - **LUT (Letter of Undertaking)** — for zero-rated exports without IGST payment. - **Reverse charge (RCM)** — for imports of services and notified categories. - **GST credit note (Sec 34)** — required on refund / cancellation / discount post-invoice. See [doc 05](./05-refund-and-chargeback-tax-adjustments.md). @@ -44,22 +48,22 @@ Plus the orthogonal obligations: ## Current code -| File | What it does | State | -|---|---|---| -| `lib/compliance/gst.ts:68–128` | `deriveGstBreakdown` — zero-rated export, intra-state CGST 9%+SGST 9%, inter-state IGST 18%, HSN defaulting | ✅ live | -| `lib/compliance/irp.ts` | `generateIrn` — env-gated ClearTax connector | ✅ live | -| `jobs/compliance/irp-uploader.ts` | Daily cron — eligible invoices → `generateIrn` → persist IRN | ✅ wired (Round 2, daily 02:30 UTC) | -| `lib/pdf/invoice-renderer.tsx` | `ConsumerInvoiceDocument` + `OrganizationInvoiceDocument` PDF | ✅ live | -| `OrganizationInvoice` (schema) | igstPaise / cgstPaise / sgstPaise / placeOfSupply / lutNumber / irn / ackNumber / signedQrPayload / irpStatus / retry telemetry | ✅ schema-final | -| `Invoice` (schema, B2C) | Has HSN / GST split fields | ✅ schema-final | -| `Payment.consumerStateCode` | **Missing** | 🔴 | -| `Payment.gstTcsCollectedPaise` + `ConsultantEarnings.gstTcsAccruedPaise` | **Missing** | 🔴 | -| GSTR-8 monthly export | **Missing** | 🔴 | -| GSTIN registry verification (live API) | Format-only (`isValidGstin`) | 🔴 | -| Reverse charge routing | Schema field exists; no routing | 🔴 | -| LUT enforcement | Schema field exists; no enforcement | 🔴 | -| Credit note on refund | **Missing** entirely | 🔴 | -| HSN selection per appointment type | Static default (999293 catch-all) in PDF — should be 998311 consulting / 999293 training | 🟡 | +| File | What it does | State | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `lib/compliance/gst.ts:68–128` | `deriveGstBreakdown` — zero-rated export, intra-state CGST 9%+SGST 9%, inter-state IGST 18%, HSN defaulting | ✅ live | +| `lib/compliance/irp.ts` | `generateIrn` — env-gated ClearTax connector | ✅ live | +| `jobs/compliance/irp-uploader.ts` | Daily cron — eligible invoices → `generateIrn` → persist IRN | ✅ wired (Round 2, daily 02:30 UTC) | +| `lib/pdf/invoice-renderer.tsx` | `ConsumerInvoiceDocument` + `OrganizationInvoiceDocument` PDF | ✅ live | +| `OrganizationInvoice` (schema) | igstPaise / cgstPaise / sgstPaise / placeOfSupply / lutNumber / irn / ackNumber / signedQrPayload / irpStatus / retry telemetry | ✅ schema-final | +| `Invoice` (schema, B2C) | Has HSN / GST split fields | ✅ schema-final | +| `Payment.consumerStateCode` | **Missing** | 🔴 | +| `Payment.gstTcsCollectedPaise` + `ConsultantEarnings.gstTcsAccruedPaise` | **Missing** | 🔴 | +| GSTR-8 monthly export | **Missing** | 🔴 | +| GSTIN registry verification (live API) | Format-only (`isValidGstin`) | 🔴 | +| Reverse charge routing | Schema field exists; no routing | 🔴 | +| LUT enforcement | Schema field exists; no enforcement | 🔴 | +| Credit note on refund | **Missing** entirely | 🔴 | +| HSN selection per appointment type | Static default (999293 catch-all) in PDF — should be 998311 consulting / 999293 training | 🟡 | ## Gap @@ -67,7 +71,7 @@ Plus the orthogonal obligations: 2. **Place-of-supply state capture missing on B2C checkout** (CBIC Notification 02/2023-IT mandates it). 3. **GST credit notes on refunds missing** (handled in [doc 05](./05-refund-and-chargeback-tax-adjustments.md)). 4. **GSTIN live registry verification missing** — only format check today. -5. **HSN selection static** — should pick **998311** (management consulting, group 9983) for CONSULTATION; **999293** (commercial training & coaching, group 9992) for WEBINAR / CLASS / SUBSCRIPTION on educational content. *(See header SAC correction — 999293 is training, NOT consulting; both 18%, so this is a classification/ITC-trail fix, not a rate fix.)* +5. **HSN selection static** — should pick **998311** (management consulting, group 9983) for CONSULTATION; **999293** (commercial training & coaching, group 9992) for WEBINAR / CLASS / SUBSCRIPTION on educational content. _(See header SAC correction — 999293 is training, NOT consulting; both 18%, so this is a classification/ITC-trail fix, not a rate fix.)_ 6. **LUT enforcement** — invoice generator doesn't gate on `lutNumber` for non-resident purchases. 7. **RCM routing** — schema field present; no logic. @@ -75,13 +79,13 @@ Plus the orthogonal obligations: This is the largest discrete gap. What it requires: -| Item | Detail | -|---|---| -| **Rate** | **0.5% total — 0.25% CGST + 0.25% SGST (intra-state) or 0.5% IGST (inter-state)**. Halved from 1% by Notif 15/2024-CT (+ parallel IGST 02/2024-IT / UTGST) w.e.f. 10-Jul-2024 (verified 2026-06-05). **No TCS rate constant is wired anywhere in `lib/` or `jobs/` (verified 2026-06-05 — `GstTcsBatch` stores `netSupplyPaise` / `tcsCollectedPaise` only, with no rate literal or stale "1%" comment); collection is stubbed pending CA signoff, so there is no incorrect *computation* in production.** When collection is wired, hardcode 0.5%, not 1%. | -| **Base** | Net taxable value of supplies through ECO = gross supplies − returns/refunds (Sec 52(3) + Rule 67(1)) | -| **Frequency** | Monthly. **GSTR-8 due 10th of following month.** | -| **Liability** | Platform deposits to govt; consultant claims credit in GSTR-2B. | -| **Penalty** | Equal to TCS not collected (Sec 122(1)(viii)) + 18% p.a. interest (Sec 50(3)) | +| Item | Detail | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Rate** | **0.5% total — 0.25% CGST + 0.25% SGST (intra-state) or 0.5% IGST (inter-state)**. Halved from 1% by Notif 15/2024-CT (+ parallel IGST 02/2024-IT / UTGST) w.e.f. 10-Jul-2024 (verified 2026-06-05). **No TCS rate constant is wired anywhere in `lib/` or `jobs/` (verified 2026-06-05 — `GstTcsBatch` stores `netSupplyPaise` / `tcsCollectedPaise` only, with no rate literal or stale "1%" comment); collection is stubbed pending CA signoff, so there is no incorrect _computation_ in production.** When collection is wired, hardcode 0.5%, not 1%. | +| **Base** | Net taxable value of supplies through ECO = gross supplies − returns/refunds (Sec 52(3) + Rule 67(1)) | +| **Frequency** | Monthly. **GSTR-8 due 10th of following month.** | +| **Liability** | Platform deposits to govt; consultant claims credit in GSTR-2B. | +| **Penalty** | Equal to TCS not collected (Sec 122(1)(viii)) + 18% p.a. interest (Sec 50(3)) | Implementation: @@ -115,17 +119,16 @@ Phased — TCS first because it has a deadline (monthly): - A B2C purchase by a Karnataka consumer of a Karnataka-registered consultant: invoice shows CGST 9% + SGST 9%, place of supply = KA. - Same purchase by a Tamil Nadu consumer: invoice shows IGST 18%, place of supply = TN. - Same purchase by a US consumer: invoice shows zero GST, "Zero-rated export under IGST Sec 16", LUT number on the invoice. -- A purchase by any consumer of a GST-registered consultant emits `Payment.gstTcsCollectedPaise` = **0.5%** of net (0.25% CGST + 0.25% SGST intra-state, or 0.5% IGST inter-state). -- Monthly cron writes a `GstTcsBatch` per consultant; GSTR-8 export passes GSTN sandbox validation. +- **CA-gated, not applicable today:** if a chartered accountant overturns the principal-supplier model (ADR 26), a purchase by any consumer of a GST-registered consultant would emit `Payment.gstTcsCollectedPaise` = **0.5%** of net (0.25% CGST + 0.25% SGST intra-state, or 0.5% IGST inter-state), and a monthly cron would write a `GstTcsBatch` per consultant whose GSTR-8 export passes GSTN sandbox validation. Under the current principal-supplier model neither field is populated and no GSTR-8 is filed. - A refund post-invoice issues a GST credit note (see doc 05). ## Don't build -| Don't build | Reason | -|---|---| -| Internal IRP integration | Use a licensed GSP connector (ClearTax / IRIS / Masters India). Already integrated. | -| B2C IRN today | Voluntary pilot only. Wait for mandatory rollout. | -| Self-managed GSTN portal session | GSP partners provide stable APIs; don't reverse the portal. | +| Don't build | Reason | +| -------------------------------- | ----------------------------------------------------------------------------------- | +| Internal IRP integration | Use a licensed GSP connector (ClearTax / IRIS / Masters India). Already integrated. | +| B2C IRN today | Voluntary pilot only. Wait for mandatory rollout. | +| Self-managed GSTN portal session | GSP partners provide stable APIs; don't reverse the portal. | ## References @@ -133,9 +136,9 @@ Phased — TCS first because it has a deadline (monthly): - [GSTR-8 (ClearTax)](https://cleartax.in/s/gstr-8) - [HSN/SAC requirement clarification (A2Z Taxcorp)](https://a2ztaxcorp.net/cbic-issued-clarification-on-gstns-tweet-hsn-code-requirement-in-gstr-1-mandatory-for-b2b-optional-for-b2c-below-%E2%82%B95-crore-turnover/) - [Place of supply for online services (VJM Global)](https://www.vjmglobal.com/blog/clarification-on-place-supply-online-services-supplied-by-suppliers-services-to-unregistered-recipients) -- [GST Sec 9(5) — when ECO is deemed supplier (ClearTax)](https://cleartax.in/s/gst-on-notified-services-ecommerce-operators-95) — *not applicable to consulting/education; we're a facilitator, not deemed supplier* -- [GST 2.0 two-slab rationalization (5% + 18%), effective 22-Sep-2025 — PIB](https://static.pib.gov.in/WriteReadData/specificdocs/documents/2025/sep/doc202594628401.pdf) — *verified 2026-06-05; professional services remain 18%* -- [SAC 998311 = management consulting; 999293 = commercial training & coaching, both 18% (ClearTax SAC 9983)](https://cleartax.in/s/other-professional-services-gst-rates-sac-code-9983) — *verified 2026-06-05* -- [GST TCS §52 halved 1% → 0.5% by Notif 15/2024-CT, w.e.f. 10-Jul-2024 (GST Safar)](https://gstsafar.com/tcs-rate-for-e-commerce-operator/) — *verified 2026-06-05* -- [E-invoice AATO ≥ ₹5 cr unchanged; 30-day IRP reporting at ₹10 cr since 1-Apr-2025 (Tally)](https://tallysolutions.com/accounting/e-invoicing-rules-in-india/) — *verified 2026-06-05* +- [GST Sec 9(5) — when ECO is deemed supplier (ClearTax)](https://cleartax.in/s/gst-on-notified-services-ecommerce-operators-95) — _not applicable to consulting/education; we're a facilitator, not deemed supplier_ +- [GST 2.0 two-slab rationalization (5% + 18%), effective 22-Sep-2025 — PIB](https://static.pib.gov.in/WriteReadData/specificdocs/documents/2025/sep/doc202594628401.pdf) — _verified 2026-06-05; professional services remain 18%_ +- [SAC 998311 = management consulting; 999293 = commercial training & coaching, both 18% (ClearTax SAC 9983)](https://cleartax.in/s/other-professional-services-gst-rates-sac-code-9983) — _verified 2026-06-05_ +- [GST TCS §52 halved 1% → 0.5% by Notif 15/2024-CT, w.e.f. 10-Jul-2024 (GST Safar)](https://gstsafar.com/tcs-rate-for-e-commerce-operator/) — _verified 2026-06-05_ +- [E-invoice AATO ≥ ₹5 cr unchanged; 30-day IRP reporting at ₹10 cr since 1-Apr-2025 (Tally)](https://tallysolutions.com/accounting/e-invoicing-rules-in-india/) — _verified 2026-06-05_ - See also: [05](./05-refund-and-chargeback-tax-adjustments.md) (credit notes), [07](./07-cross-border-flows.md) (LUT, RCM, IGST Sec 16). diff --git a/docs/compliance/04-tds-quarterly-filings.md b/docs/compliance/04-tds-quarterly-filings.md index 404c7d2a3..247283df3 100644 --- a/docs/compliance/04-tds-quarterly-filings.md +++ b/docs/compliance/04-tds-quarterly-filings.md @@ -1,6 +1,6 @@ # 04 — Form 26Q / 27Q / 16A — quarterly TDS returns + consultant certificates -> **Status:** schema-tracking is real (`TDSRecord.reportedInForm26Q` flag); FVU export + filing automation + Form 16A generation all missing. 🔴 **NEW (2026-06-05): the Income-tax Act, 2025 + Income-tax Rules, 2026 renamed every TDS form w.e.f. 1-Apr-2026 — 26Q→Form 140, 27Q→Form 144, 16A→Form 131. The `reportedInForm26Q` flag name is now a legacy label (the *concept* is unchanged; the form it maps to is Form 140 for FY 2026-27+).** +> **Status:** schema-tracking is real (`TDSRecord.reportedInForm26Q` flag); FVU export + filing automation + Form 16A generation all missing. 🔴 **NEW (2026-06-05): the Income-tax Act, 2025 + Income-tax Rules, 2026 renamed every TDS form w.e.f. 1-Apr-2026 — 26Q→Form 140, 27Q→Form 144, 16A→Form 131. The `reportedInForm26Q` flag name is now a legacy label (the _concept_ is unchanged; the form it maps to is Form 140 for FY 2026-27+).** > **Audience:** payout pipeline + admin tax-ops dashboard. > **Last reviewed:** 2026-06-05 (regulatory facts web-verified as of 2026-06-05; prior review 2026-05-02) > **Linked issues:** [#737 §6](https://github.com/Practitionist/familiarise_web/issues/737), [#738 Phase 2 PR 2.2](https://github.com/Practitionist/familiarise_web/issues/738). @@ -9,39 +9,40 @@ Three related deliverables to the income-tax department after every quarter's TDS deductions. **Form names changed under the Income-tax Act, 2025 (Income-tax Rules, 2026, G.S.R. 198(E)) for any return covering a period on/after 1-Apr-2026** — verified 2026-06-05: -| Deliverable | Form (1961 Act → 2025 Act) | Frequency | Audience | -|---|---|---|---| -| Quarterly TDS return (resident payees) | **26Q → Form 140** | Quarterly | Income Tax Dept (TRACES / NSDL) | -| Quarterly TDS return (non-resident payees) | **27Q → Form 144** | Quarterly | Income Tax Dept | -| Consultant TDS certificate (income other than salary, §195(4) wording) | **16A → Form 131** | Issued within 15 days of return due date | The consultant | +| Deliverable | Form (1961 Act → 2025 Act) | Frequency | Audience | +| ---------------------------------------------------------------------- | -------------------------- | ---------------------------------------- | ------------------------------- | +| Quarterly TDS return (resident payees) | **26Q → Form 140** | Quarterly | Income Tax Dept (TRACES / NSDL) | +| Quarterly TDS return (non-resident payees) | **27Q → Form 144** | Quarterly | Income Tax Dept | +| Consultant TDS certificate (income other than salary, §195(4) wording) | **16A → Form 131** | Issued within 15 days of return due date | The consultant | **Filing transition (verified):** Q4 FY 2025-26 (period up to 31-Mar-2026) returns are still filed under the **old** form names (26Q/27Q/16A) + old section numbers. From the Q1 FY 2026-27 return onward (Apr–Jun 2026, due ~31-Jul-2026), use the **new** form numbers (140/144/131) + §393 payment codes (the 10xx series — see [doc 01](./01-tds-overview.md)). Section codes inside the FVU change from `194O`/`194J`/`194C` strings to the numeric §393 payment codes. Related renumbering: 24Q→138, 16→130, 26AS→168, 15CA→145, 15CB→146. **Cadence (unchanged for FY 2025-26 and FY 2026-27 — due dates were not altered by the 2025 Act; verified 2026-06-05):** -| Quarter | Period | Return due | -|---|---|---| -| Q1 | Apr–Jun | 31 Jul | -| Q2 | Jul–Sep | 31 Oct | -| Q3 | Oct–Dec | 31 Jan | -| Q4 | Jan–Mar | **31 May** | +| Quarter | Period | Return due | +| ------- | ------- | ---------- | +| Q1 | Apr–Jun | 31 Jul | +| Q2 | Jul–Sep | 31 Oct | +| Q3 | Oct–Dec | 31 Jan | +| Q4 | Jan–Mar | **31 May** | **TDS deposit (separate from return):** by **7th of following month**; March deductions by 30 Apr. **Threshold note (verified 2026-06-05):** the §194J professional/technical threshold rose ₹30,000 → **₹50,000/FY** from FY 2026-27 (per payment-type); §194O stays at ₹5,00,000/FY for resident individuals/HUF. See [doc 01](./01-tds-overview.md). **Penalties (section numbers shown 1961-Act → 2025-Act equivalent; the ₹/day mechanics are unchanged):** + - Late filing: ₹200/day under **Sec 234E** (→ §427 of the 2025 Act), capped at the TDS amount. - Non-filing > 1 year: ₹10,000–₹1,00,000 under **Sec 271H** (→ penalty provisions consolidated in the 2025 Act). - Wrong PAN / mis-quoted: separate Sec 271H penalty. -🟡 *The 271H/234E → 2025-Act mappings are penalty-provision equivalents; cite the 1961-Act numbers for any return/period up to 31-Mar-2026, and verify the exact 2025-Act penalty section before quoting it in a notice. The ₹-amounts are unchanged.* +🟡 _The 271H/234E → 2025-Act mappings are penalty-provision equivalents; cite the 1961-Act numbers for any return/period up to 31-Mar-2026, and verify the exact 2025-Act penalty section before quoting it in a notice. The ₹-amounts are unchanged._ ## When it applies ### B2B (org-sponsored) -- **Applies** for org → consultant payouts where TDS was withheld. `OrganizationPayout.tdsAmountPaise` is the source of truth. +- **Applies** for org → consultant payouts where TDS was withheld. `OrganizationPayout.tdsAmountPaise` records what was withheld from the disbursement, and since #1354 the payout also writes a `TDSRecord` when it completes, so the return is built from `TDSRecord` on both rails rather than from the payout tables. - Resident consultant → 26Q. Non-resident → 27Q. - Form 16A is issued by the **deductor** (the platform) to the consultant, even though the org "paid" via its wallet/invoice. The platform is the legal deductor because we run the payout. @@ -56,16 +57,35 @@ Three related deliverables to the income-tax department after every quarter's TD ## Current code -| File | What it does | State | -|---|---|---| -| `TDSRecord` (schema) | Per-payout TDS row with FY, quarter, cumulative, deducted, rate, `reportedInForm26Q` flag | ✅ schema-final | -| `lib/payments/tax/tds-service.ts:268, 298–312` | `getUnreportedRecordsForQuarter`, `markAsReported` | ✅ basic CRUD | -| `lib/payments/tax/pan-crypto.ts:53` | "Only needed for Form 26Q admin filing" comment — PAN encryption helper | ✅ | -| FVU file generator | **Missing** | 🔴 | -| 27Q file generator (non-resident) | **Missing** | 🔴 | -| Form 16A PDF | **Missing** | 🔴 | -| Quarterly cron / dashboard | **Missing** | 🔴 | -| TRACES / NSDL e-filing integration | **Missing** | 🔴 | +| File | What it does | State | +| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| `TDSRecord` (schema) | Per-payout TDS row with FY, quarter, cumulative, deducted, rate, `reportedInForm26Q` flag | ✅ schema-final | +| `lib/payments/tax/tds-service.ts:268, 298–312` | `getUnreportedRecordsForQuarter`, `markAsReported` | ✅ basic CRUD | +| `lib/payments/tax/pan-crypto.ts:53` | "Only needed for Form 26Q admin filing" comment — PAN encryption helper | ✅ | +| FVU file generator | **Missing** | 🔴 | +| 27Q file generator (non-resident) | **Missing** | 🔴 | +| Form 16A PDF | **Missing** | 🔴 | +| `jobs/compliance/tds-26q-draft-export.ts` | Quarterly draft builder: aggregates the quarter's `TDSRecord` rows on both rails, prints the masked draft, writes the full-PAN CSV to storage | ✅ | +| `.github/workflows/tds-return-draft.yml` | Runs that job at 01:20 UTC on the 5th of January, April, July and October, and on manual dispatch | ✅ | +| `app/api/admin/compliance/tds-return/route.ts` | ADMIN-only hop that exchanges an FY and quarter for a short-lived signed URL to the CSV | ✅ | +| Quarterly dashboard | **Missing** | 🔴 | +| TRACES / NSDL e-filing integration | **Missing** | 🔴 | + +## Running the quarterly draft + +The draft is produced by the **TDS quarterly return draft** workflow (`.github/workflows/tds-return-draft.yml`). It runs on its own schedule at 01:20 UTC on the fifth of January, April, July and October, which is 06:50 IST on the fifth day after each fiscal quarter closes, and it can also be dispatched by hand from the Actions tab. A manual dispatch takes two optional inputs, `financialYear` in the `2026-27` form and `quarter` as a digit from 1 to 4; leaving both blank builds the quarter that closed, which is what the scheduled run does and what filing means, so a run on the fifth of April builds the January-to-March quarter of the financial year that has just ended rather than the five days of the new one. To look at a quarter that is still open you have to name it explicitly. A malformed input fails the job rather than emitting a mislabelled compliance artifact. + +The job produces two things. The first is a **masked** draft, printed as JSON in the workflow log and safe to read, copy and quote: it carries each deductee's type, name, section, §393 payment code, credited amount and net TDS, together with the last four characters of their PAN and never more. The second is the **full-PAN CSV** the chartered accountant actually imports, written to the private Supabase bucket `org-invoices` at `compliance/tds/-Q.csv`. The job logs only that path. There is deliberately no Actions artifact upload, because an artifact is downloadable by anyone with repository read access and this file is the one place a decrypted PAN exists outside the database. + +That bucket provisions itself. Until September 2026 the `org-invoices` bucket had never actually been created on the live Supabase project, so the first end-to-end run of this job failed with `Bucket not found`, and the organization invoice PDF route shared the same latent fault because both write through the same helper. The upload path now calls `ensurePrivateFinanceBucket` in `lib/storage/private-finance-object.ts` before every write. That function asks Supabase for the bucket and creates it as a private bucket with a 25MB per-object limit only when it is genuinely absent, then remembers the answer for the rest of the process. No operator has to create anything by hand, and re-running the job against a fresh project is safe. Note that the separate `invoices` bucket visible in the Supabase dashboard is an unrelated March 2026 leftover that no current code path reads or writes, and it should not be confused with this one. + +To fetch the CSV, an admin calls `GET /api/admin/compliance/tds-return?financialYear=2026-27&quarter=2`. The route is ADMIN-only rather than merely privileged, because the object it hands out carries decrypted PANs and that is the same bar `/api/admin/tds?view=form26q` has always applied. Both query parameters are validated as a canonical April-to-March pair and a single digit from 1 to 4, so a malformed period is answered with a 400 rather than a redirect to some other quarter's file. The route answers 404 when no CSV exists for that quarter, which means the workflow has not been run for it yet, and otherwise redirects to a signed URL that expires after ten minutes. That short window is intentional: a URL left in a browser history or a chat message is dead long before anyone else finds it. + +The CSV has one row per deductee and section, with the columns `deductee_type`, `pan`, `deductee_name`, `section`, `payment_code`, `amount_credited_paise`, `tds_deducted_paise`, `quarter`, `financial_year` and `is_reversal`. A deductee whose withholding was reversed during the quarter gets a second row with `is_reversal` set to true, a zero credited amount and the negative reversal figure, because the portal treats a reversal as an adjustment against a previously reported credit rather than as a new credit of its own. Values are escaped against spreadsheet formula injection, but a cell that is a plain number is left alone, so the negative figures import as numbers rather than as text. + +**The FVU step is still a human one.** The CSV is an input to the official NSDL Return Preparation Utility, not a replacement for it: nothing in this pipeline generates or validates an FVU file, and nothing files anything with TRACES. The workflow also deliberately does not stamp `reportedInForm26Q`, so re-running it for the same quarter is safe and idempotent, and the flag continues to mean "a human filed this" rather than "a job exported this". + +Two warnings on the draft deserve attention before filing. A deductee with no PAN on file has to be withheld at the punitive rate under §397(2), so the draft names how many lines are affected and the withheld amount should be checked before the return goes out. From FY 2026-27 the draft also flags any section with no §393 payment code, which means no effective-dated `TdsRate` row covers that section as at the quarter end. ## Gap @@ -102,17 +122,17 @@ In commit order: ## Don't build -| Don't build | Reason | -|---|---| -| Internal NSDL portal scraper | TRACES requires DSC + 2FA; portal scraping breaks regularly. Use a GSP. | +| Don't build | Reason | +| -------------------------------- | ------------------------------------------------------------------------------------- | +| Internal NSDL portal scraper | TRACES requires DSC + 2FA; portal scraping breaks regularly. Use a GSP. | | Custom 16A template font-by-font | Standard PDF template ships with the income-tax dept's RPU. Use the canonical layout. | ## References - [Form 26Q (ClearTax)](https://cleartax.in/s/tds-return-non-salary) -- [TDS return due dates (SAG Infotech)](https://blog.saginfotech.com/due-date-filing-tds-tcs-return) — *quarterly due dates unchanged for FY 2026-27; verified 2026-06-05* -- [New TDS/TCS forms under IT Act 2025 — 26Q→140, 27Q→144, 16A→131 (TDSMan, Mar 2026)](https://blog.tdsman.com/2026/03/new-tds-tcs-forms-it-act-2025-mapping-with-old-forms/) — *verified 2026-06-05* -- [12 key tax forms changing from 1-Apr-2026 (Business Today)](https://www.businesstoday.in/personal-finance/tax/story/new-income-tax-act-2025-explained-12-key-tax-forms-changing-from-april-1-2026-530661-2026-05-10) — *verified 2026-06-05* +- [TDS return due dates (SAG Infotech)](https://blog.saginfotech.com/due-date-filing-tds-tcs-return) — _quarterly due dates unchanged for FY 2026-27; verified 2026-06-05_ +- [New TDS/TCS forms under IT Act 2025 — 26Q→140, 27Q→144, 16A→131 (TDSMan, Mar 2026)](https://blog.tdsman.com/2026/03/new-tds-tcs-forms-it-act-2025-mapping-with-old-forms/) — _verified 2026-06-05_ +- [12 key tax forms changing from 1-Apr-2026 (Business Today)](https://www.businesstoday.in/personal-finance/tax/story/new-income-tax-act-2025-explained-12-key-tax-forms-changing-from-april-1-2026-530661-2026-05-10) — _verified 2026-06-05_ - [Income-tax e-filing portal](https://www.incometax.gov.in/iec/foportal/) - [NSDL TRACES](https://contents.tdscpc.gov.in/) - See also: [01](./01-tds-overview.md) (sections + rates + §393 codes), [07](./07-cross-border-flows.md) (27Q→144 for non-residents), [05](./05-refund-and-chargeback-tax-adjustments.md) (refund-of-quarter-already-filed handling). diff --git a/docs/compliance/10-rbi-pa-and-payment-architecture.md b/docs/compliance/10-rbi-pa-and-payment-architecture.md index 2a8a28874..ba49cdb8f 100644 --- a/docs/compliance/10-rbi-pa-and-payment-architecture.md +++ b/docs/compliance/10-rbi-pa-and-payment-architecture.md @@ -7,7 +7,7 @@ ## What it is -The **Reserve Bank of India (Regulation of Payment Aggregators) Directions, 2025** — reference **RBI/DPSS/2025-26/141**, dated **15 September 2025**. It consolidates and **repeals** the earlier PA guidelines (DPSS PA/PG guidelines of 17 Mar 2020 + 31 Mar 2021) and the PA-CB circular (31 Oct 2023), and for the first time formally categorises three PA types: **PA-Online (PA-O)**, **PA-Physical (PA-P)**, and **PA-Cross-Border (PA-CB)**. *(Title + reference number verified 2026-06-05 against rbi.org.in.)* +The **Reserve Bank of India (Regulation of Payment Aggregators) Directions, 2025** — reference **RBI/DPSS/2025-26/141**, dated **15 September 2025**. It consolidates and **repeals** the earlier PA guidelines (DPSS PA/PG guidelines of 17 Mar 2020 + 31 Mar 2021) and the PA-CB circular (31 Oct 2023), and for the first time formally categorises three PA types: **PA-Online (PA-O)**, **PA-Physical (PA-P)**, and **PA-Cross-Border (PA-CB)**. _(Title + reference number verified 2026-06-05 against rbi.org.in.)_ The most-cited operative constraint for marketplaces: @@ -17,15 +17,15 @@ This affects every marketplace that today receives consumer payments and pays ou **Two paths the direction permits:** -| Path | Mechanism | Onboarding burden | Settlement timing | -|---|---|---|---| -| **A — PA Sub-Merchant** (Razorpay Route) | Each consultant is a fully-KYC'd sub-merchant under Razorpay's PA license. Razorpay handles split settlement at payment time. | High — per-consultant V-CIP, PAN, Aadhaar, bank proof. | Tn+1 to consultant. | -| **B — Escrow Account** | Marketplace (as an authorised PA) maintains the RBI-mandated escrow with a Scheduled Commercial Bank. Funds enter escrow; debits restricted to merchant payouts, refunds, commission per the Directions. | Low at consultant level; high at platform level (requires PA authorisation: ₹15 cr net worth + escrow governance). | Contractual, within the Directions' settlement norms. | +| Path | Mechanism | Onboarding burden | Settlement timing | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | +| **A — PA Sub-Merchant** (Razorpay Route) | Each consultant is a fully-KYC'd sub-merchant under Razorpay's PA license. Razorpay handles split settlement at payment time. | High — per-consultant V-CIP, PAN, Aadhaar, bank proof. | Tn+1 to consultant. | +| **B — Escrow Account** | Marketplace (as an authorised PA) maintains the RBI-mandated escrow with a Scheduled Commercial Bank. Funds enter escrow; debits restricted to merchant payouts, refunds, commission per the Directions. | Low at consultant level; high at platform level (requires PA authorisation: ₹15 cr net worth + escrow governance). | Contractual, within the Directions' settlement norms. | **A third de facto path the direction does NOT explicitly forbid:** -| Path | Mechanism | Notes | -|---|---|---| +| Path | Mechanism | Notes | +| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | **C — Operating account + separate licensed payout** | Consumer → PA → platform's operating account (platform IS the merchant). Platform separately uses a licensed FAA (e.g. RazorpayX Bulk Payouts) to pay consultants from operating funds. | Two separate RBI-licensed flows. Consultant is NOT settled by the PA — they're paid by us via a different licensed product. | ## What architecture does Practitionist actually use? @@ -39,9 +39,10 @@ Consumer → Razorpay PG (PA license) → Practitionist operating account (we ar ``` **This is Path C.** Specifically: + - We are NOT using Razorpay Route — `razorpayContactId` + `razorpayFundAccountId` are RazorpayX Bulk Payouts identifiers, NOT Route sub-merchant IDs. - We are NOT using a nodal account — funds land in the platform's operating account. -- We make a separate, licensed payout via RazorpayX (which has its own RBI authorisation as a Full-fledged Money Changers / Authorised Payment System Operator). +- We make a separate, licensed payout via RazorpayX. Razorpay holds RBI payment-aggregator authorisation — final online PA authorisation was reported in December 2023, cross-border PA authorisation followed in December 2025, and the offline PA-P licence followed in January 2026 — and RazorpayX payouts are executed from the platform's own current account through Razorpay's partner banks, not from PA escrow. ## Why Path C is likely permitted @@ -57,28 +58,28 @@ This is the same architecture used by every B2B SaaS marketplace, every freelanc Even on Path C, the direction imposes: -| # | Requirement | Status | -|---|---|---| -| 1 | **Marketplace declaration** to Razorpay | Done at PA onboarding; sign annual self-declaration | -| 2 | **Refund SLA** to consumers (RBI-prescribed timelines) | Implementation pending — see [doc 09](./09-consumer-protection-and-grievance.md) | -| 3 | **Prohibited categories monitoring** | Active — Razorpay flags + we add platform-level ToS | -| 4 | **Data localisation** of payment data | Already enforced — RBI "Storage of Payment System Data" directive, 6 Apr 2018; Razorpay infra is India-based | -| 5 | **PCI-DSS** — never store card numbers / CVV / etc. | Already compliant — we use Razorpay tokens | -| 6 | **Chargeback handling** within 7-day evidence window | Implementation pending — see [doc 09](./09-consumer-protection-and-grievance.md) | -| 7 | **PA-CB approval** for cross-border collections | Razorpay holds it; we enable cross-border settings — see [doc 07](./07-cross-border-flows.md) | +| # | Requirement | Status | +| --- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| 1 | **Marketplace declaration** to Razorpay | Done at PA onboarding; sign annual self-declaration | +| 2 | **Refund SLA** to consumers (RBI-prescribed timelines) | Implementation pending — see [doc 09](./09-consumer-protection-and-grievance.md) | +| 3 | **Prohibited categories monitoring** | Active — Razorpay flags + we add platform-level ToS | +| 4 | **Data localisation** of payment data | Already enforced — RBI "Storage of Payment System Data" directive, 6 Apr 2018; Razorpay infra is India-based | +| 5 | **PCI-DSS** — never store card numbers / CVV / etc. | Already compliant — we use Razorpay tokens | +| 6 | **Chargeback handling** within 7-day evidence window | Implementation pending — see [doc 09](./09-consumer-protection-and-grievance.md) | +| 7 | **PA-CB approval** for cross-border collections | Razorpay holds it; we enable cross-border settings — see [doc 07](./07-cross-border-flows.md) | ## Wallet auto-top-up and the e-mandate framework -`OrgBillingAccount.autoTopUpMandateId` (a "gateway recurring-payment token") lets the wallet auto-recharge by `autoTopUpAmountPaise` when the balance drops below `minBalancePaise`. Recurring debits against a stored mandate are governed by the **RBI Digital Payments — E-mandate Framework, 2026** (issued **21 April 2026**, effective immediately), which consolidates all prior e-mandate / UPI-AutoPay circulars across cards, PPIs, and UPI. *(Verified 2026-06-05.)* +`OrgBillingAccount.autoTopUpMandateId` (a "gateway recurring-payment token") lets the wallet auto-recharge by `autoTopUpAmountPaise` when the balance drops below `minBalancePaise`. Recurring debits against a stored mandate are governed by the **RBI Digital Payments — E-mandate Framework, 2026** (issued **21 April 2026**, effective immediately), which consolidates all prior e-mandate / UPI-AutoPay circulars across cards, PPIs, and UPI. _(Verified 2026-06-05.)_ Operative limits for the auto-top-up mandate: -| # | Rule | Effect on auto-top-up | -|---|---|---| -| 1 | Mandate **registration** requires Additional Factor of Authentication (AFA) — a one-time auth when the org sets up the mandate. | The first mandate setup must go through full AFA at the gateway. | -| 2 | Subsequent recurring debits run **without AFA up to ₹15,000 per transaction**; debits above that need AFA per transaction. | Keep `autoTopUpAmountPaise` ≤ ₹15,000 (1,500,000 paise) to stay in the no-AFA lane; larger top-ups will prompt per-debit AFA and can fail silently if unattended. | -| 3 | **Pre-debit notification** to the payer **≥ 24h** before each debit, with opt-out. | The gateway issues this; ensure the mandate is registered with a reachable contact so notifications land. | -| 4 | ₹1 lakh per-transaction no-AFA ceiling applies only to insurance / mutual-fund / credit-card-bill categories — **not** wallet top-ups. | Do not assume the ₹1 lakh ceiling for wallet recharges; the ₹15,000 cap governs. | +| # | Rule | Effect on auto-top-up | +| --- | -------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Mandate **registration** requires Additional Factor of Authentication (AFA) — a one-time auth when the org sets up the mandate. | The first mandate setup must go through full AFA at the gateway. | +| 2 | Subsequent recurring debits run **without AFA up to ₹15,000 per transaction**; debits above that need AFA per transaction. | Keep `autoTopUpAmountPaise` ≤ ₹15,000 (1,500,000 paise) to stay in the no-AFA lane; larger top-ups will prompt per-debit AFA and can fail silently if unattended. | +| 3 | **Pre-debit notification** to the payer **≥ 24h** before each debit, with opt-out. | The gateway issues this; ensure the mandate is registered with a reachable contact so notifications land. | +| 4 | ₹1 lakh per-transaction no-AFA ceiling applies only to insurance / mutual-fund / credit-card-bill categories — **not** wallet top-ups. | Do not assume the ₹1 lakh ceiling for wallet recharges; the ₹15,000 cap governs. | This is a forward-looking note: the auto-top-up cron exists in schema, but live mandate registration must respect these limits at the gateway integration layer. @@ -98,23 +99,32 @@ This is a forward-looking note: the auto-top-up cron exists in schema, but live ## Current code -| Item | What it does | State | -|---|---|---| -| `lib/payments/payouts/razorpay-payouts.ts` | RazorpayX Bulk Payouts API client | ✅ live | -| `lib/payments/payouts/stripe-connect.ts` | Stripe Connect for cross-border consultant payouts | ✅ live | -| `lib/payments/payouts/payout-service.ts` (B2C) | Consultant payout pipeline | ✅ live | -| `lib/payments/payouts/org-payout-service.ts` (B2B) | Org payout pipeline | ✅ live | -| Razorpay PG checkout | ✅ live | | -| Architecture memo | **Missing** — there's no doc explicitly stating "we are on Path C and here's why" | 🟡 | +| Item | What it does | State | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------- | +| `lib/payments/payouts/razorpay-payouts.ts` | RazorpayX Bulk Payouts API client | ✅ live | +| `lib/payments/payouts/stripe-connect.ts` | Stripe Connect for cross-border consultant payouts | ✅ live | +| `lib/payments/payouts/payout-service.ts` (B2C) | Consultant payout pipeline | ✅ live | +| `lib/payments/payouts/org-payout-service.ts` (B2B) | Org payout pipeline | ✅ live | +| Razorpay PG checkout | ✅ live | | +| Architecture memo | **Written (2026-09-03)** — this document is the memo stating "we are on Path C and here's why" | ✅ | ## Gap -| Gap | Severity | -|---|---| -| No memo at `docs/payments/` documenting the architecture vs the Sep 2025 direction | 🟡 | -| No CA / RBI-compliance opinion validating Path C for our specific facts | 🟡 | -| No annual Razorpay marketplace self-declaration captured + filed | 🟢 (assumed Razorpay does this; verify) | -| No prohibited-categories monitoring at platform-ToS level (Razorpay flags only) | 🟢 | +| Gap | Severity | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No memo at `docs/payments/` documenting the architecture vs the Sep 2025 direction | ✅ **Written (2026-09-03)** — this document (`docs/compliance/10-rbi-pa-and-payment-architecture.md`), together with `docs/enterprise/70-design-decisions/26-gst-principal-model.md` and `docs/payments/audits/2026-09-03-finance-verdicts.md`, is the architecture memo this row asked for; the fact-specific risk paragraph in §A above stays the operative text. | +| No CA / RBI-compliance opinion validating Path C for our specific facts | 🟡 still open — see the CA action list below | +| No annual Razorpay marketplace self-declaration captured + filed | 🟢 (assumed Razorpay does this; verify) | +| No prohibited-categories monitoring at platform-ToS level (Razorpay flags only) | 🟢 | + +### CA action list (2026-09-03) + +The chartered accountant engagement this section calls for now has a concrete question list, gathered from the 2026-09-03 finance audit rather than left as an open-ended "review the memo" ask. + +1. Confirm Path C (Razorpay PG as the merchant of record, RazorpayX payouts as a separately licensed rail) is permitted for a consulting marketplace under the RBI PA Directions 2025, as this document's §B already asks. +2. Answer the five questions in [ADR 26](./../enterprise/70-design-decisions/26-gst-principal-model.md#questions-for-the-chartered-accountant): whether Principal-for-GST paired with 194-O-operator-for-income-tax holds together, how a platform-funded referral credit should be treated for taxable value, the correct SAC code, whether the Section 12(2)(b) supplier-state default is acceptable, and whether Section 52 TCS registration is required at all under this model. +3. Weigh in on #1388, the specific code-path question about referral credits being applied after tax at checkout (`deriveCheckoutAmount`), which nothing in the code changes until it is answered. +4. Confirm which authorisation RazorpayX payouts operate under and whether any additional registration is needed for platform-initiated payouts to consultants. ## Required @@ -131,6 +141,7 @@ Add `docs/payments/06-pa-master-direction-architecture.md` (or similar): ### B. CA / legal opinion (PR 2 — out-of-band) Engage a CA + an RBI-specialised counsel: + - Review the architecture memo. - Confirm Path C is permitted for our facts. - Document the opinion with their UDIN / signature. @@ -165,18 +176,18 @@ The migration cost (per-consultant V-CIP for Path A, or nodal-account governance ## Don't build -| Don't build | Reason | -|---|---| -| Razorpay Route migration | Path A burden is high and not required given Path C is permitted. Wait for legal opinion. | -| Self-custodied escrow | Requires ₹15 cr net worth + RBI approval. Not viable. | -| Direct nodal-account integration with an SPD bank | Path B governance is heavy; only if a specific feature demands it. | +| Don't build | Reason | +| ------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Razorpay Route migration | Path A burden is high and not required given Path C is permitted. Wait for legal opinion. | +| Self-custodied escrow | Requires ₹15 cr net worth + RBI approval. Not viable. | +| Direct nodal-account integration with an SPD bank | Path B governance is heavy; only if a specific feature demands it. | ## References -- [RBI (Regulation of Payment Aggregators) Directions, 2025 — RBI/DPSS/2025-26/141, 15 Sep 2025 (RBI master-directions page)](https://www.rbi.org.in/Scripts/BS_ViewMasDirections.aspx?id=12896) *(title + ref no. verified 2026-06-05)* +- [RBI (Regulation of Payment Aggregators) Directions, 2025 — RBI/DPSS/2025-26/141, 15 Sep 2025 (RBI master-directions page)](https://www.rbi.org.in/Scripts/BS_ViewMasDirections.aspx?id=12896) _(title + ref no. verified 2026-06-05)_ - [RBI PA Directions 2025 — full text (FIDC mirror)](https://www.fidcindia.org.in/wp-content/uploads/2025/09/RBI-PAYMENT-AGGREGATORS-DIRECTIONS-15-09-25.pdf) - [PA Directions 2025 analysis (Khaitan & Co)](https://www.khaitanco.com/sites/default/files/2025-10/ERGO%20-%20PA%20Master%20Directions%20-%203%20Oct%202025_0.pdf) -- [RBI Digital Payments — E-mandate Framework, 2026 (issued 21 Apr 2026) — coverage](https://www.businesstoday.in/personal-finance/news/story/rbi-caps-recurring-payments-at-rs15000-without-otp-under-new-e-mandate-framework-526759-2026-04-21) *(₹15,000 no-AFA cap verified 2026-06-05; replace with the RBI primary circular URL when indexed)* +- [RBI Digital Payments — E-mandate Framework, 2026 (issued 21 Apr 2026) — coverage](https://www.businesstoday.in/personal-finance/news/story/rbi-caps-recurring-payments-at-rs15000-without-otp-under-new-e-mandate-framework-526759-2026-04-21) _(₹15,000 no-AFA cap verified 2026-06-05; replace with the RBI primary circular URL when indexed)_ - [Razorpay Payment Gateway Compliance 2026](https://razorpay.com/blog/payment-gateway-compliance/) - [Razorpay KYC Onboarding Guide 2026](https://razorpay.com/blog/payment-gateway-kyc-onboarding-india) - See also: [07](./07-cross-border-flows.md) (PA-CB), [09](./09-consumer-protection-and-grievance.md) (refund SLA, chargeback handling). diff --git a/docs/compliance/15-india-compliance-shipping-checklist.md b/docs/compliance/15-india-compliance-shipping-checklist.md index 0151a99c4..ac60c8234 100644 --- a/docs/compliance/15-india-compliance-shipping-checklist.md +++ b/docs/compliance/15-india-compliance-shipping-checklist.md @@ -10,75 +10,76 @@ the design-partner merge, **WHEN-ASKED** by the first enterprise buyer, or **DEFER** indefinitely. Compliance is not over-engineered — every MUST item is the actual law of the land in 2026. -> **2026-06-05 regulatory deltas to mind (verified):** 194-O is **0.1%**, not 1% (§1.1 corrected). GST TCS §52 is **0.5%** since 10-Jul-2024. From **1-Apr-2026** the Income-tax Act, 2025 renames TDS forms (26Q→140, 27Q→144, 16A→131) and replaces section numbers with §393 payment codes — a **filing-export** concern, the withholding *math* is unchanged. GST 2.0 (22-Sep-2025) → two slabs 5%/18%; consulting stays 18%. MSME Udyam thresholds revised 1-Apr-2025 (Micro ₹2.5cr/₹10cr · Small ₹25cr/₹100cr · Medium ₹125cr/₹500cr). +> **2026-06-05 regulatory deltas to mind (verified):** 194-O is **0.1%**, not 1% (§1.1 corrected). GST TCS §52 is **0.5%** since 10-Jul-2024. From **1-Apr-2026** the Income-tax Act, 2025 renames TDS forms (26Q→140, 27Q→144, 16A→131) and replaces section numbers with §393 payment codes — a **filing-export** concern, the withholding _math_ is unchanged. GST 2.0 (22-Sep-2025) → two slabs 5%/18%; consulting stays 18%. MSME Udyam thresholds revised 1-Apr-2025 (Micro ₹2.5cr/₹10cr · Small ₹25cr/₹100cr · Medium ₹125cr/₹500cr). --- ## §1 — MUST ship in this PR (legally mandatory before design-partner merge) -| # | Item | Status | File:line evidence | -|---|---|---|---| -| 1.1 | **TDS withholding wired into org payouts** — Section 194-O default **0.1%** (`"194O": 0.001`, cut from 1% w.e.f. 1-Oct-2024 — verified 2026-06-05; **the old "1%" here was wrong**), **194-O no-PAN fallback 5%** (`NO_PAN_RATE_194O`) / 206AA 20% for 194J/194C. `tdsAmountPaise` deducted from gross before gateway dispatch; `tdsSectionApplied`, `dtaaRateApplied` persisted on `OrganizationPayout`. 🟡 *Section labels are still 1961-Act strings (`194O`/`194J`/`194C`); must map to §393 payment codes before any FY 2026-27 return upload (see [doc 01](./01-tds-overview.md)).* | ✅ This PR | `lib/payments/payouts/org-payout-service.ts` (createOrgPayoutBatch tx); `lib/compliance/tds.ts` (canonical lib) | -| 1.2 | **MSME 43B(h) deadline tracking on org payouts** — `mustPayByDate` derived from `Organization.msmeStatus` + `msmeWrittenAgreementOnFile`, not a hardcoded `NONE` stub. | ✅ This PR | `lib/payments/payouts/org-payout-service.ts`; `lib/compliance/msme.ts`; schema `Organization.msmeStatus`/`msmeWrittenAgreementOnFile` | -| 1.3 | **DPDP consent stamping at signup + Stream gate** — essential-purpose `ConsentArtifact` written in the BetterAuth `user.create.after` hook; `STREAM_DATA_PROCESSING` gate refuses Stream upsert when consent absent. | ✅ This PR | `lib/auth.ts` (user.create.after hook); `actions/stream/chat/user.action.ts` (`checkConsent` gate, single + batch) | -| 1.4 | **GST place-of-supply state code is env-driven** — `SUPPLIER_STATE_CODE` env replaces hardcoded `"KA"`. Place-of-supply rules pick CGST+SGST vs IGST correctly when business address changes. | ✅ This PR | `.env.sample`; `jobs/billing/generate-subscription-invoices.ts`; `app/api/organizations/[orgId]/billing-account/invoices/route.ts` | -| 1.5 | **Per-org sequential invoice numbering (CGST Rule 46)** — atomic counter table `org_invoice_counters`, `INSERT ON CONFLICT DO UPDATE RETURNING`, format `--`, unbroken sequence per (org, fiscal year). | ✅ This PR | `lib/payments/billing/invoice-numbering.ts`; schema `OrgInvoiceCounter`, `OrganizationInvoice.fiscalYear`, `@@unique([organizationId, invoiceNumber])` | -| 1.6 | **IRP / IRN upload cron live + flag/creds-gated** — daily 02:30 UTC; behind `ENABLE_IRP_UPLOADER` (verified in `lib/feature-flags.ts`) + ClearTax creds; scoped to `OrganizationInvoice` `irpStatus=PENDING`, `issuedAt within 30d` (CBIC cut-off); sub-₹5cr orgs accept `FAILED`/`PENDING` until they cross AATO ≥ ₹5 cr (threshold unchanged 2026-06-05). | ✅ Already shipped (Round 2) | `jobs/compliance/irp-uploader.ts`; `.github/workflows/irp-uploader.yml`; `lib/compliance/irp.ts`; `lib/feature-flags.ts:ENABLE_IRP_UPLOADER` | -| 1.7 | **DPDP 72-hour breach alert cron** — hourly sweep + Resend / structured-log fallback for `DataBreach WHERE reportedAt IS NULL`. | ✅ Already shipped (Round 2) | `jobs/compliance/databreach-deadline-alerts.ts`; `.github/workflows/databreach-deadline-alerts.yml` | -| 1.8 | **GST derivation (CGST/SGST/IGST)** — zero-rated export, intra-state CGST 9% + SGST 9%, inter-state IGST 18%; live in app. | ✅ Already shipped (Round 2) | `lib/compliance/gst.ts:deriveGstBreakdown` | -| 1.9 | **MSME payment-alert cron** — daily 04:30 UTC; alerts on overdue MSME payouts. | ✅ Already shipped (Round 2) | `jobs/compliance/msme-payment-alerts.ts`; `.github/workflows/msme-payment-alerts.yml` | -| 1.10 | **Contract expiry cron** — daily 03:00 UTC; ACTIVE → EXPIRED on `effectiveTo` cross. | ✅ Already shipped | `jobs/compliance/contract-expiry.ts`; `.github/workflows/expire-contracts.yml` | +| # | Item | Status | File:line evidence | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1.1 | **TDS withholding wired into org payouts** — Section 194-O default **0.1%** (`"194O": 0.001`, cut from 1% w.e.f. 1-Oct-2024 — verified 2026-06-05; **the old "1%" here was wrong**), **194-O no-PAN fallback 5%** (`NO_PAN_RATE_194O`) / 206AA 20% for 194J/194C. `tdsAmountPaise` deducted from gross before gateway dispatch; `tdsSectionApplied`, `dtaaRateApplied` persisted on `OrganizationPayout`. 🟡 _Section labels are still 1961-Act strings (`194O`/`194J`/`194C`); must map to §393 payment codes before any FY 2026-27 return upload (see [doc 01](./01-tds-overview.md))._ | ✅ This PR | `lib/payments/payouts/org-payout-service.ts` (createOrgPayoutBatch tx); `lib/compliance/tds.ts` (canonical lib) | +| 1.2 | **MSME 43B(h) deadline tracking on org payouts** — `mustPayByDate` derived from `Organization.msmeStatus` + `msmeWrittenAgreementOnFile`, not a hardcoded `NONE` stub. | ✅ This PR | `lib/payments/payouts/org-payout-service.ts`; `lib/compliance/msme.ts`; schema `Organization.msmeStatus`/`msmeWrittenAgreementOnFile` | +| 1.3 | **DPDP consent stamping at signup + Stream gate** — essential-purpose `ConsentArtifact` written in the BetterAuth `user.create.after` hook; `STREAM_DATA_PROCESSING` gate refuses Stream upsert when consent absent. | ✅ This PR | `lib/auth.ts` (user.create.after hook); `actions/stream/chat/user.action.ts` (`checkConsent` gate, single + batch) | +| 1.4 | **GST place-of-supply state code is env-driven** — `SUPPLIER_STATE_CODE` env replaces hardcoded `"KA"`. Place-of-supply rules pick CGST+SGST vs IGST correctly when business address changes. | ✅ This PR | `.env.sample`; `jobs/billing/generate-subscription-invoices.ts`; `app/api/organizations/[orgId]/billing-account/invoices/route.ts` | +| 1.5 | **Per-org sequential invoice numbering (CGST Rule 46)** — atomic counter table `org_invoice_counters`, `INSERT ON CONFLICT DO UPDATE RETURNING`, format `--`, unbroken sequence per (org, fiscal year). | ✅ This PR | `lib/payments/billing/invoice-numbering.ts`; schema `OrgInvoiceCounter`, `OrganizationInvoice.fiscalYear`, `@@unique([organizationId, invoiceNumber])` | +| 1.6 | **IRP / IRN upload cron live + flag/creds-gated** — daily 02:30 UTC; behind `ENABLE_IRP_UPLOADER` (verified in `lib/feature-flags.ts`) + ClearTax creds; scoped to `OrganizationInvoice` `irpStatus=PENDING`, `issuedAt within 30d` (CBIC cut-off); sub-₹5cr orgs accept `FAILED`/`PENDING` until they cross AATO ≥ ₹5 cr (threshold unchanged 2026-06-05). | ✅ Already shipped (Round 2) | `jobs/compliance/irp-uploader.ts`; `.github/workflows/irp-uploader.yml`; `lib/compliance/irp.ts`; `lib/feature-flags.ts:ENABLE_IRP_UPLOADER` | +| 1.7 | **DPDP 72-hour breach alert cron** — hourly sweep + Resend / structured-log fallback for `DataBreach WHERE reportedAt IS NULL`. | ✅ Already shipped (Round 2) | `jobs/compliance/databreach-deadline-alerts.ts`; `.github/workflows/databreach-deadline-alerts.yml` | +| 1.8 | **GST derivation (CGST/SGST/IGST)** — zero-rated export, intra-state CGST 9% + SGST 9%, inter-state IGST 18%; live in app. | ✅ Already shipped (Round 2) | `lib/compliance/gst.ts:deriveGstBreakdown` | +| 1.9 | **MSME payment-alert cron** — daily 04:30 UTC; alerts on overdue MSME payouts. | ✅ Already shipped (Round 2) | `jobs/compliance/msme-payment-alerts.ts`; `.github/workflows/msme-payment-alerts.yml` | +| 1.10 | **Contract expiry cron** — daily 03:00 UTC; ACTIVE → EXPIRED on `effectiveTo` cross. | ✅ Already shipped | `jobs/compliance/contract-expiry.ts`; `.github/workflows/expire-contracts.yml` | ### Tax-adjustment infrastructure shipped in v2 (#776/#778) — schema landed, wiring tracked in §2 -| # | Item | Status | File:line evidence | -|---|---|---|---| -| 1.11 | **`CreditNote` model w/ per-org gapless numbering (CGST Rule 53)** — `creditNoteNumber` + `fiscalYear`, `@@unique([organizationId, creditNoteNumber])`, `refundId @unique` (idempotent refund-driven minting), Sec 34 FK to `OrganizationInvoice`. Verified present 2026-06-05. | ✅ Schema landed (v2) | `prisma/schema.prisma model CreditNote`; `lib/payments/billing/credit-note-numbering.ts` (`-CN--`) | -| 1.12 | **`TdsAdjustment` model** — signed `amountPaise`, `financialYear`/`quarter`, `reportedInForm26Q`; for refund/chargeback TDS reversal as a negative line in the next 26Q→Form 140. Verified present 2026-06-05. | ✅ Schema landed (v2) | `prisma/schema.prisma model TdsAdjustment` | -| 1.13 | **`GstTcsBatch` + `GstTcsAdjustment` models** — monthly GSTR-8 batch (`netSupplyPaise`/`tcsCollectedPaise`, `@@unique([financialYear, month])`) + signed-`amountPaise` reversal. **No rate constant wired** (collection stubbed pending CA signoff — when wired, use **0.5%** not 1%). Verified 2026-06-05. | ✅ Schema landed (v2); collection deferred | `prisma/schema.prisma model GstTcsBatch / GstTcsAdjustment` | +| # | Item | Status | File:line evidence | +| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| 1.11 | **`CreditNote` model w/ per-org gapless numbering (CGST Rule 53)** — `creditNoteNumber` + `fiscalYear`, `@@unique([organizationId, creditNoteNumber])`, `refundId @unique` (idempotent refund-driven minting), Sec 34 FK to `OrganizationInvoice`. Verified present 2026-06-05. | ✅ Schema landed (v2) | `prisma/schema.prisma model CreditNote`; `lib/payments/billing/credit-note-numbering.ts` (`-CN--`) | +| 1.12 | **`TdsAdjustment` model** — signed `amountPaise`, `financialYear`/`quarter`, `reportedInForm26Q`; for refund/chargeback TDS reversal as a negative line in the next Form 140 (formerly 26Q). Verified present 2026-06-05. | ✅ Schema landed (v2) | `prisma/schema.prisma model TdsAdjustment` | +| 1.13 | **`GstTcsBatch` + `GstTcsAdjustment` models** — monthly GSTR-8 batch (`netSupplyPaise`/`tcsCollectedPaise`, `@@unique([financialYear, month])`) + signed-`amountPaise` reversal. **No rate constant wired** (collection stubbed pending CA signoff — when wired, use **0.5%** not 1%). Verified 2026-06-05. 🟡 **Update (2026-09-03):** [ADR 26](../enterprise/70-design-decisions/26-gst-principal-model.md) locked the platform in as **Principal supplier for GST**, under which Section 52 TCS collection does not apply at all — these models stay dormant by design, not by omission, and #1360 is relabelled `compliance`/`launch: post-mvp` and gated on the CA overturning that decision. | ✅ Schema landed (v2); collection deferred, CA-gated | `prisma/schema.prisma model GstTcsBatch / GstTcsAdjustment` | -🟡 **Wiring gap (engineering follow-up, tracked in [doc 05](./05-refund-and-chargeback-tax-adjustments.md)):** these models exist but `lib/payments/operations/refund.ts` does **not** yet write adjustment rows on refund/chargeback. Until wired, every refund still distorts the next 26Q→140 / GSTR-8 period. +🟡 **Wiring gap (engineering follow-up, tracked in [doc 05](./05-refund-and-chargeback-tax-adjustments.md)):** these models exist but `lib/payments/operations/refund.ts` does **not** yet write adjustment rows on refund/chargeback. This gap only matters on the facilitator-model path, where Section 52 TCS is actually collected; under the current principal-supplier model (ADR 26) no TCS is collected, so no refund distorts a GSTR-8 period. The TDS half of this sentence still applies unconditionally: every refund still distorts the next Form 140 return until `TdsAdjustment` rows are wired. --- ## §2 — WHEN-ASKED — next 2–4 weeks (post-merge follow-ups) -| # | Item | Why deferred | Ship when | -|---|---|---|---| -| 2.1 | **Quarterly TDS returns (Form 26Q→140 / 27Q→144)** — generate the FVU with consultant-wise reconciliation; **must emit §393 numeric payment codes (not `194x` labels) for FY 2026-27** + fold in `TdsAdjustment` negative lines. | Filing-season runway is Jul (Q1 FY 2026-27 return due ~31-Jul-2026) — not blocking design-partner merge. | Before the Q1 FY 2026-27 return (~31 Jul). CA-assisted automation. | -| 2.2 | **DPDP DSAR export endpoints** — user data export + erasure (right-to-correction, right-to-erasure under §11). | Stub paths exist (`/api/users/[id]/data-export` is a placeholder). | When first user requests under DPDP §11 or after first enterprise buyer's DPA audit. | -| 2.3 | **In-app consent withdrawal UI** — settings page surfacing `ConsentArtifact` purpose codes, allowing revoke. | API already exists at `/api/organizations/[orgId]/consent`; only UI is missing. | Before public DPDP rules effective-date enforcement (rules-stage now; phased rollout 2025-26). | -| 2.4 | **GSTIN registry verify** — replace 15-char regex with live GSTN portal lookup. | Audit-trail only; no immediate filing impact. | After first invoice dispute. | -| 2.5 | **Place-of-supply state capture in B2C checkout** — needed for B2C tax math parity (B2C in separate PR). | Tracked in parallel B2C compliance PR. | When B2C compliance PR opens. | -| 2.6 | **HSN selection logic** — 999293 vs 999299 split based on service type (currently 999293 catch-all). | CA hasn't flagged a real classification dispute. | After GST audit by CA, ideally before first ITC dispute. | -| 2.7 | **Invoice-fraud mitigation** — session-level immutability guards on INVOICE-funded orgs; soft-delete audit. | Manual ops sufficient pre-design-partner. | Before multi-tenant self-serve. | +| # | Item | Why deferred | Ship when | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | +| 2.1 | **Quarterly TDS returns (Form 140, formerly 26Q / Form 144, formerly 27Q)** — generate the FVU with consultant-wise reconciliation; **must emit §393 numeric payment codes (not `194x` labels) for FY 2026-27** + fold in `TdsAdjustment` negative lines. 🟡 **Update (2026-09-04):** the 31-Jul-2026 Q1 FY 2026-27 due date has passed. No TDS was deducted in that quarter — the `TDSRecord` table has zero rows — so no Form 140 statement was due for Q1. The first live quarter for this item is whichever quarter contains the platform's first completed payout. | Filing-season runway continues into the quarter of the first live payout — not blocking design-partner merge. | Before the Form 140 return for the quarter of the first completed payout. CA-assisted automation. | +| 2.2 | **DPDP DSAR export endpoints** — user data export + erasure (right-to-correction, right-to-erasure under §11). | Stub paths exist (`/api/users/[id]/data-export` is a placeholder). | When first user requests under DPDP §11 or after first enterprise buyer's DPA audit. | +| 2.3 | **In-app consent withdrawal UI** — settings page surfacing `ConsentArtifact` purpose codes, allowing revoke. | API already exists at `/api/organizations/[orgId]/consent`; only UI is missing. | Before public DPDP rules effective-date enforcement (rules-stage now; phased rollout 2025-26). | +| 2.4 | **GSTIN registry verify** — replace 15-char regex with live GSTN portal lookup. | Audit-trail only; no immediate filing impact. | After first invoice dispute. | +| 2.5 | **Place-of-supply state capture in B2C checkout** — needed for B2C tax math parity. 🟡 **Delivered by PR-E (2026-09-03, `feat/finance-b2c-tax-invoice`, in flight):** a `BillingStateSelect` on every checkout page writes `Payment.consumerStateCode`, and `deriveConsumerInvoiceTax` falls back to the supplier's state under Section 12(2)(b) when the buyer declines to declare one. | Carried by the B2C statutory-invoice PR. | Land with PR-E. | +| 2.6 | **SAC/HSN selection logic** — the consumer invoice model defaults to SAC **999293** (`prisma/schema.prisma`, `ConsumerInvoice.sacCode`), with **998311** as the alternative classification pending the CA's answer (#1369); both codes carry 18% GST, so this is a classification (ITC-trail) question, not a rate question. | CA hasn't flagged a real classification dispute. | After GST audit by CA, ideally before first ITC dispute. | +| 2.7 | **Invoice-fraud mitigation** — session-level immutability guards on INVOICE-funded orgs; soft-delete audit. | Manual ops sufficient pre-design-partner. | Before multi-tenant self-serve. | +| 2.8 | **Outward-supplies register export** — a monthly CSV of every `ConsumerInvoice`/`ConsumerCreditNote` and `OrganizationInvoice`/`CreditNote` row, with place of supply and tax heads, for the CA to file GSTR-1 and GSTR-3B from. Carried by `lib/compliance/gst-outward-register.ts` and the `gst-outward-register-export` job in the same B2C statutory-invoice PR. | Replaces the in-app GSTR JSON builders #1361 originally asked for; the register is the platform's own outward-supply return under the Principal model. | Land with PR-E. | --- ## §3 — DEFER indefinitely (no current requirement) -| # | Item | Justification | -|---|---|---| -| 3.1 | **FEMA Form 15CA / 15CB** | No non-resident consultants on platform yet. Fields exist in `OrganizationPayout` schema; populate manually when first cross-border payout ships. | -| 3.2 | **FIRC + RBI PA-CB compliance** | Same — domestic-only today. | -| 3.3 | **SOC 2 Type II certification** | First enterprise buyer's procurement will ask; 6-month effort. Hold until concrete ask. | -| 3.4 | **ISO 27001 prep** | Less common ask in India B2B. Defer past SOC 2. | -| 3.5 | **Programs v2 runtime (PROJECT, RETAINER, AOR, EOR)** | Enum values reserved; API returns 400 `PROGRAM_TYPE_NOT_AVAILABLE`. Wait for design-partner demand. | -| 3.6 | **Multi-currency on BillingAccount / Invoice / Payout** | INR-only today. Open separate epic with ExchangeRateSnapshot model when first cross-border B2B buyer signs. | -| 3.7 | **Custom RBAC (CustomRole + Permission tables)** | Fixed `MemberRole` enum sufficient for design partners. | -| 3.8 | **SCIM 2.0 provisioning** | `/api/.../scim/*` placeholder returns 501. Wait for SCIM-aware IdP-demanding buyer. | -| 3.9 | **Consumer Protection Grievance Officer UI** | Minimal compliance: contact page + email intake suffices for MVP under 30-day resolution rule. | +| # | Item | Justification | +| --- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 3.1 | **FEMA Form 15CA / 15CB** | No non-resident consultants on platform yet. Fields exist in `OrganizationPayout` schema; populate manually when first cross-border payout ships. | +| 3.2 | **FIRC + RBI PA-CB compliance** | Same — domestic-only today. | +| 3.3 | **SOC 2 Type II certification** | First enterprise buyer's procurement will ask; 6-month effort. Hold until concrete ask. | +| 3.4 | **ISO 27001 prep** | Less common ask in India B2B. Defer past SOC 2. | +| 3.5 | **Programs v2 runtime (PROJECT, RETAINER, AOR, EOR)** | Enum values reserved; API returns 400 `PROGRAM_TYPE_NOT_AVAILABLE`. Wait for design-partner demand. | +| 3.6 | **Multi-currency on BillingAccount / Invoice / Payout** | INR-only today. Open separate epic with ExchangeRateSnapshot model when first cross-border B2B buyer signs. | +| 3.7 | **Custom RBAC (CustomRole + Permission tables)** | Fixed `MemberRole` enum sufficient for design partners. | +| 3.8 | **SCIM 2.0 provisioning** | ✅ Shipped (2026-09-03) — no longer deferred. `lib/scim/` implements Users CRUD, bearer-token authentication, group mapping and deprovisioning behind `app/scim/v2/`, and `ScimToken.expiresAt` is enforced on every request. | +| 3.9 | **Consumer Protection Grievance Officer UI** | Minimal compliance: contact page + email intake suffices for MVP under 30-day resolution rule. | --- ## §4 — Sign-off slots -| Reviewer | Item set | Sign-off (date / initials) | -|---|---|---| -| Finance lead | §1.1 (TDS), §1.2 (MSME), §1.4 (GST PoS), §1.5 (numbering) | _____________ | -| DPDP officer | §1.3 (consent), §1.7 (breach cron) | _____________ | -| CA (chartered accountant) | §1.1, §1.5, §1.6 (IRP), §1.8 (GST split) | _____________ | -| Engineering lead | All §1 items + Phase 0 schema-DB sync | _____________ | +| Reviewer | Item set | Sign-off (date / initials) | +| ------------------------- | --------------------------------------------------------- | -------------------------- | +| Finance lead | §1.1 (TDS), §1.2 (MSME), §1.4 (GST PoS), §1.5 (numbering) | **\*\***\_**\*\*** | +| DPDP officer | §1.3 (consent), §1.7 (breach cron) | **\*\***\_**\*\*** | +| CA (chartered accountant) | §1.1, §1.5, §1.6 (IRP), §1.8 (GST split) | **\*\***\_**\*\*** | +| Engineering lead | All §1 items + Phase 0 schema-DB sync | **\*\***\_**\*\*** | --- 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..9324511fd 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,11 +157,13 @@ 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). The same assertion also refuses a payout whose figures are negative, because the equation alone accepts one (minus one lakh plus nothing does equal minus one lakh) and the posting is skipped for anything that is not greater than zero, which would let such a row settle with no journal at all. `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/04-wallet-and-topups.md b/docs/enterprise/10-money-and-ledger/04-wallet-and-topups.md index dd06c2e34..e4f33c160 100644 --- a/docs/enterprise/10-money-and-ledger/04-wallet-and-topups.md +++ b/docs/enterprise/10-money-and-ledger/04-wallet-and-topups.md @@ -28,6 +28,8 @@ A wallet is not a single model — it is a `WalletTopUp` lifecycle record paired The `walletBalance` cache exists for exactly one reason: a conditional SQL `UPDATE … WHERE walletBalance >= amount` is the cheapest correct overdraft guard under concurrency. We can't run that guard against a derived sum, so we keep a cache and let the reconcile cron prove it never drifts. See [Concurrency & idempotency](../30-programs-and-lifecycle/01-concurrency-and-idempotency.md). +The column is nullable, and an account that has never been credited carries `NULL` rather than zero, which is a real distinction in Postgres because `NULL + amount` is `NULL` and not the new balance. Both `walletCredit` and `walletDebit` therefore write a zero over a `NULL` in the same transaction before they touch the arithmetic, and they read the resulting balance back off the mutated row instead of coercing a `NULL` to zero (#1459). Giving the column a non-null default so the seeding step becomes unnecessary is a schema change, and it belongs to the pre-MVP database reset rather than to any migration written today. + --- ## 2. WalletTopUp lifecycle @@ -138,6 +140,8 @@ WHERE "id" = :id If `rowsAffected === 0`, the balance was insufficient and it throws `WalletInsufficientFundsError`. Because the predicate and the decrement are one atomic statement, two concurrent bookings can never both drain the same last rupee. +That refusal carries the stable code `WALLET_INSUFFICIENT_FUNDS` and `httpStatus` 402 (#1477), so checkout rethrows it unchanged, `POST /api/checkout` answers 402 with copy telling the buyer to ask their billing admin for a top-up, and Sentry records it as an expected outcome rather than a payment fault. It reports only the requested amount, never the available balance: the guard refuses without reading the row, and re-reading it would add a query inside the checkout transaction that `PG_POOL_MAX=1` would serialise behind everything else. + > **Important:** `walletDebit` only moves the **cache**. It does **not** post a journal leg. The accounting leg `Dr WALLET` is posted later from the settlement layer (`createEarningsFromPayment`), where the full fee/payable/GST split is known — that single balanced `booking:` transaction is also the authoritative wallet-history record. See [Booking → earnings](05-booking-to-earnings.md) and [Payment legs](09-payment-legs.md). `walletCredit` is the mirror: it bumps the cache for any reason, but **only posts a journal txn when `reason === "TOPUP"`**. Refund credits post their WALLET leg from the refund layer (next section), not here — this keeps each cash event owning exactly one posting. diff --git a/docs/enterprise/10-money-and-ledger/05-booking-to-earnings.md b/docs/enterprise/10-money-and-ledger/05-booking-to-earnings.md index 54981ec08..c04686692 100644 --- a/docs/enterprise/10-money-and-ledger/05-booking-to-earnings.md +++ b/docs/enterprise/10-money-and-ledger/05-booking-to-earnings.md @@ -61,6 +61,8 @@ Two scoping columns let a card live at the **org** level (default across contrac **Time-scoped, never updated.** A rate change closes the old card (`effectiveTo = now()`) and inserts a new one (`effectiveFrom = now()`) in one transaction (`bumpRateCard()`, `lib/api/organizations/rate-card.ts`). Yesterday's booking still resolves the card where `effectiveFrom <= booking.createdAt < effectiveTo`. +**One open window per scope, guarded twice (#1405).** The bump is a read-then-write: it looks for the currently effective card, closes it, and inserts the replacement. Under the default isolation level two administrators bumping the same scope at the same moment could each read "nothing is open here" and each insert a row with `effectiveTo = NULL`, after which `resolveEffectiveRateCard()` had two equally valid candidates and picked between them non-deterministically — two bookings a second apart could settle on different splits. `POST /api/organizations/{orgId}/rate-cards` now runs that transaction at the `Serializable` isolation level and wraps it in `withSerializableRetry()`, so the losing writer aborts and re-runs against the winner's state instead of surfacing a serialization error to the caller. Behind that, the partial unique index `rate_card_one_open_window` in `prisma/sql/check-constraints.sql` makes the invariant structural: it is declared `NULLS NOT DISTINCT` over the four scope columns, because three of them are nullable and Postgres would otherwise treat every NULL as distinct and exempt exactly the rows that need covering (an expression index over `COALESCE` was rejected: the enum-to-text cast is not immutable). A write that still collides comes back as HTTP 409 with the code `RATE_CARD_OPEN_WINDOW_CONFLICT`, which asks the caller to re-read the current card and retry rather than reporting a server fault. + **Resolution order** (`resolveEffectiveRateCard()`, most-specific → least): 1. `Membership.rateCardOverride` (per-expert). @@ -72,7 +74,21 @@ Two scoping columns let a card live at the **org** level (default across contrac 7. Org-scoped default. 8. Hardcoded `DEFAULT_RATE_CARD` = **10% / 10% / 80%** (platform / org / expert); `rateCardId = null` is the sentinel for "defaults used". -**Only tiers 1, 7 and 8 are reachable today.** `resolveOrgSplit()` (`lib/payments/payouts/earnings-service.ts`) is the resolver's only production caller, and it passes just `orgId`, `membershipOverrideId` and `at`. A settling booking can therefore land only on the per-expert override, the org-scoped default, or the hardcoded fallback, even though it has already resolved the plan that would select tiers 2 through 5. The rate-card POST handler will happily create a contract-scoped or `planId`-scoped card, so such a card can exist and never be chosen. Forwarding the booking's scope would change which card settles live money, so it is tracked as an open question under #1319 rather than treated as a documentation gap. +**Which tiers a booking can reach depends on one flag.** `resolveOrgSplit()` (`lib/payments/payouts/earnings-service.ts`) is the resolver's only production caller, and until #1335 it passed just `orgId`, `membershipOverrideId` and `at`. A settling booking could therefore land only on tiers 1, 7 and 8 — the per-expert override, the org-scoped default, or the hardcoded fallback — even though it had already resolved the plan that selects tiers 2 through 5. The rate-card POST handler will happily create a contract-scoped or `planId`-scoped card, so such a card could exist and never be chosen. + +`RATE_CARD_SCOPED_RESOLUTION` closes that gap, and it is **off unless the value is exactly `on`**. The default is off because the flip changes which card settles live money: any scoped card an org created while the tiers were unreachable would begin paying a different split the moment it becomes selectable, so an org must be able to audit its cards first and flip second. The gate is `isScopedRateCardResolutionEnabled()` in `lib/api/organizations/rate-card.ts`; off, `resolveOrgSplit()` makes the pre-#1335 call verbatim. + +With the flag on, settlement forwards three more fields, all derived inside the same transaction that writes the earnings rows: + +| Field | Where it comes from | When it is null | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `planType` | The settlement's own `AppointmentType`, mapped through the exhaustive `RATE_CARD_PLAN_TYPE` record so an enum rename fails the build. | Never, on the primary leg. | +| `planId` | The plan already resolved for the org-ownership lookup. | Consultation and subscription bookings, whose plan ids are not carried in the settlement payload. Those still resolve at `planType` granularity (tiers 4 and 5). | +| `contractId` | `BookingUtilization` (unique on `paymentId`) → `ProgramAssignment` → `Program` → `Contract`, which is the only link a settling payment has to a contract. | Marketplace and self-funded bookings, which have no contract; subscription bookings, which meter utilization at slot-allocation time and so have none yet at settlement; and any contract that does not belong to the settling org. | + +That last exclusion is a tenancy guard rather than a nicety. A contract-scoped card is created under `POST /organizations/{orgId}/rate-cards`, which checks the contract against that org, so `ownerContractId` alone identifies the owner — and `resolveEffectiveRateCard()` matches `ownerContractId` without re-asserting the org. Since `Contract.organizationId` is the **sponsoring** org while `resolveOrgSplit()` resolves the expert's **host** org, forwarding the contract unguarded would let one tenant's booking settle on another tenant's negotiated split. Contract scope is therefore reachable only where the sponsor and the host are the same organization, which is the HYBRID case the tier was designed for. + +The collaborator leg passes no scope at all under either flag state. ADR 18 makes collaborations org-blind, so the seller's contract and plan must not select a card owned by the collaborator's own org. **The bps invariant:** `platformBps + orgBps + consultantBps === 10000` on every row — enforced at the creation site (`bumpRateCard()` + the rate-card POST handler), not yet a Postgres CHECK (follow-up). @@ -187,10 +203,19 @@ On a real over-cap checkout, `recordOverageAtCheckout` (`lib/payments/billing/ov - **Circuit breaker.** `maxOveragePerCyclePaise` is a per-cycle overage ceiling. If `cycleOverageSoFarPaise + marginal` would breach it, the mapper returns `decision: BLOCK, chargeTo: null` **regardless of `overageBehavior`** — the recorder throws `PROGRAM_CAP_EXHAUSTED` (HTTP 402), the same shape as a `BLOCK`-behavior refusal but with a distinct code so the dashboard can say "cycle ceiling" vs "per-member allocation". An unknown/missing `overageBehavior` also **fails safe to `BLOCK`**. - **`CHARGE_MEMBER`** → a parent-linked **PENDING side-`Payment`** for the marginal (gateway _not_ called inside the tx; the order is minted lazily when the member opens the resume-checkout surface) + an `OverageEvent(PENDING)`. To avoid double-collecting `basePaise`, checkout carves it out of the org-funded parent's `INVOICE_ACCRUAL` leg (fail-closed: a non-invoice-funded parent has no credit-back path yet, #715, so it aborts rather than double-charge). Member is notified (`notifyOrgProgramOverageDue`) with a pay deep link. The webhook later posts the `OVERAGE_MEMBER` org-relief leg ([§4.8 of ledger & postings](03-ledger-and-postings.md)). -- **`CHARGE_ORG`** → carve `basePaise` out of the base `INVOICE_ACCRUAL` leg and write the marginal as a distinct **`OVERAGE_INVOICE_ACCRUAL`** leg (the distinct source dodges the `@@unique([paymentId, source])` clash) + an `OverageEvent(PENDING)`. The cycle-close rollup turns it into an `InvoiceLineItem` and walks the event `PENDING → ACCRUED → CHARGED` ([invoicing](08-invoicing.md)). +- **`CHARGE_ORG` on the INVOICE rail** → carve `basePaise` out of the base `INVOICE_ACCRUAL` leg and write the marginal as a distinct **`OVERAGE_INVOICE_ACCRUAL`** leg (the distinct source dodges the `@@unique([paymentId, source])` clash) + an `OverageEvent(PENDING)`. The cycle-close rollup turns it into an `InvoiceLineItem` and walks the event `PENDING → ACCRUED → CHARGED` ([invoicing](08-invoicing.md)). +- **`CHARGE_ORG` on the WALLET rail (#1458)** → nothing is billed, because the wallet debit taken when the booking committed is the whole nominal price and therefore already contains the over-cap pass-through. The recorder writes **no** leg and does **not** touch `Payment.amount`; it records an `OverageEvent` that is born `CHARGED` with `settledAt` stamped and `paymentId` pointing at the booking payment whose `WALLET` leg collected it. That event carries no `invoiceLineItemId`, so the reconciler's link invariant accepts either link as proof of collection. Anything else on this rail fails closed with a business error rather than inflating the payment: a positive `overageSurchargeBps` is a markup the wallet debit never took, and an org-sponsored payment carrying none of the `WALLET` / `INVOICE_ACCRUAL` / `LICENSE` funding legs means the funding seam itself has drifted. +- **`CHARGE_ORG` on the LICENSE rail is refused (#1458).** A licence is a flat fee settled at contract time, so a licence-funded booking moves no money per booking: its funding leg is deliberately ₹0 while `Payment.amount` stays at the full price, and the leg-sum guard excuses that only while the licence leg is the payment's _only_ funding leg. Adding an overage leg re-arms the comparison, so `assert_payment_legs_ok` raised at COMMIT and the booking died with an opaque database error. There is no per-booking rail to collect the marginal on, so `overageBehaviorUnsupportedReason` refuses any charging behaviour on a licence-funded account and checkout keeps the fail-closed backstop. +- **`CHARGE_MEMBER` is not available on a WALLET account (#715, guarded in #1458).** Collecting from the member requires carving the over-cap portion back out of the parent, which on the wallet rail would mean crediting the wallet mid-transaction — a path that has never been built. `overageBehaviorUnsupportedReason` (`lib/enterprise/reachable-paths.ts`) refuses the combination when the programme is created or patched, so an operator cannot save a configuration whose only outcome is a refused booking. Checkout keeps its fail-closed throw for programmes configured before that guard existed, now carrying the code `OVERAGE_CHARGE_MEMBER_UNSUPPORTED` and an HTTP 409. The `chargeStatus` state machine itself is a single guarded transition (`transitionOverage`, `overage-transitions.ts`); the overage-event lifecycle table of states (`PENDING/ACCRUED/CHARGED/BLOCKED/REVERSED/FAILED`) is in [funding & programs](../00-foundations/03-funding-and-programs.md) / [programs](../30-programs-and-lifecycle/02-programs.md). +Because a wallet-funded overage never adds to `Payment.amount`, a cancellation of such a booking refunds exactly what the wallet was debited. The refund cascade splits the refund across the payment's legs, and the single `WALLET` leg equals `Payment.amount`, so a full refund credits the wallet back to the balance it held before the booking. The same cascade reverses the `CHARGED` event, because the money it represented has just been returned and the programme's per-cycle ceiling has to be released with it. + +An overage also has to keep the booking journal balanced, and that is a tighter constraint than the leg-sum identity. Every credit in the BOOKING posting is derived from `Payment.originalAmount` plus `taxAmount` — the nominal price — while the debits are the funding legs plus a `DISCOUNT` plug clamped at zero or above. The posting therefore balances only while the funding legs sum to no more than the nominal gross. On the wallet rail that now holds by construction. On the invoice rail it does not: the base carve keeps `basePaise` inside the price, but `marginal = base + surcharge` raises both the accrual leg and `Payment.amount` by the surcharge, which is real funding sitting outside the nominal price. The posting therefore credits that surcharge to `PLATFORM_FEE`, because an over-cap surcharge is a markup the platform charges the organisation for exceeding its own cap and not consultant income — the consultant is paid out of `originalAmount`. Without that credit the posting was short by exactly `surchargePaise`, threw `LedgerImbalanceError`, and the booking committed with no journal entry at all (Sentry `FAMILIARISE_WEB-28`). + +The refusals checkout can raise from inside its transaction all carry a machine-readable code, and the catch around that transaction rethrows any error whose code is registered in `BUSINESS_ERROR_CODES` instead of rewriting it. `PROGRAM_CAP_EXHAUSTED` therefore reaches the buyer as the HTTP 402 it was thrown as, with a toast that names the admin action, rather than as the 500 "Something Went Wrong" it used to collapse into. + --- ## 7. Design decisions & trade-offs 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 c4da6a0c7..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). @@ -80,17 +84,17 @@ Crucially, a payout that reaches `processed` is **not** guaranteed to be final. The table below maps each gateway state onto our `PayoutStatus` as `mapPayoutStatus` and the reconcilers actually do it, and flags where the mapping is lossy or inconsistent. Note that `mapPayoutStatus` (the gateway-poll path) is separate from the `payout.reversed` **webhook** path: the webhook handlers now drive the dedicated `REVERSED` status (§3), but the poller's mapping below is unchanged. -| RazorpayX state | Our `PayoutStatus` | Faithful? | Note | -|---|---|---|---| -| `queued` | `PROCESSING` (crons) / `PENDING` (`mapPayoutStatus`) | inconsistent | two code paths map it differently; we have no `QUEUED` state | -| `pending` | `PENDING` (`mapPayoutStatus`) / `PROCESSING` (crons) | inconsistent | approval-workflow only; we do not use it | -| `scheduled` | unmapped | gap | falls through to default; we never schedule | -| `processing` | `PROCESSING` | yes | the normal in-flight state | -| `processed` | `COMPLETED` | yes | event carries the UTR; persisted to `gatewayUtr` before the `COMPLETED` flip | -| `reversed` | `FAILED` (poll path) / `REVERSED` (webhook path) | lossy in the poller only | `mapPayoutStatus` still collapses a polled `reversed` to `FAILED`; the `payout.reversed` webhook handler now stamps the dedicated `REVERSED` status and posts the inverse journal (§3, #812) | -| `rejected` | `FAILED` | lossy | approval/deadline reject indistinguishable from bank failure | -| `cancelled` | `CANCELLED` | yes | manual cancel of a queued/scheduled payout | -| `failed` | `FAILED` | yes | Current-Account partner-bank rejection | +| RazorpayX state | Our `PayoutStatus` | Faithful? | Note | +| --------------- | ---------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `queued` | `PROCESSING` (crons) / `PENDING` (`mapPayoutStatus`) | inconsistent | two code paths map it differently; we have no `QUEUED` state | +| `pending` | `PENDING` (`mapPayoutStatus`) / `PROCESSING` (crons) | inconsistent | approval-workflow only; we do not use it | +| `scheduled` | unmapped | gap | falls through to default; we never schedule | +| `processing` | `PROCESSING` | yes | the normal in-flight state | +| `processed` | `COMPLETED` | yes | event carries the UTR; persisted to `gatewayUtr` before the `COMPLETED` flip | +| `reversed` | `FAILED` (poll path) / `REVERSED` (webhook path) | lossy in the poller only | `mapPayoutStatus` still collapses a polled `reversed` to `FAILED`; the `payout.reversed` webhook handler now stamps the dedicated `REVERSED` status and posts the inverse journal (§3, #812) | +| `rejected` | `FAILED` | lossy | approval/deadline reject indistinguishable from bank failure | +| `cancelled` | `CANCELLED` | yes | manual cancel of a queued/scheduled payout | +| `failed` | `FAILED` | yes | Current-Account partner-bank rejection | The UTR (Unique Transaction Reference) is the bank-rail receipt a host org uses to trace funds with its bank. It is **null** while a payout is `processing` and only becomes available once the beneficiary bank confirms the credit — immediately for IMPS/UPI, within roughly ninety seconds for NEFT. Our pipeline extracts it from the `payout.processed` payload (the entity carries `utr` by then) and persists it to `gatewayUtr` before flipping the row to `COMPLETED`, so the notification and audit log always hold the canonical bank reference. IMPS and UPI payouts are near-instant (a typical lifecycle of about 180 seconds); if a payout is still `processing` after that window it is most likely in NPCI's "deemed success" state and may take up to **T+3** working days to resolve. NEFT and RTGS run only during bank working hours and not on the second and fourth Saturdays, Sundays, or RBI holidays. `determinePayoutMode` auto-selects the rail by amount and account type: UPI for a VPA fund account, IMPS for a bank transfer up to ₹5,00,000, and NEFT above that; RTGS (minimum ₹2,00,000) is available but not auto-selected. @@ -149,6 +153,10 @@ TDS is computed by `computeTdsForPayout` (`lib/compliance/tds.ts`). The default > **Refund-driven TDS reversal now wired via `TDSRecord` (#813), richer model still pending.** When a payout is refunded after TDS has been withheld and deposited, the deductor does not chase a cash refund in the ordinary case: under CBDT Circular 2/2011 (carried forward by the 2025 Act) excess TDS discovered within the same financial year is adjusted against the deductor's liability in a later quarter, surfacing as a reduced or negative line in the next quarterly statement (Form 140 / 144). The refund cascade now implements exactly this: `recordTdsReversal` (`lib/payments/tax/tds-service.ts`) writes a negative `isReversal` `TDSRecord` capped at the original withholding, copying the original's FY/quarter when it is unfiled and stamping the current IST-reckoned quarter when it is already filed (the adjust-against-future-liability convention). A correction return for an already-filed quarter, and an excess discovered after the financial year closes (which must route to the Form 26B refund claim), both remain manual operator actions. 🟡 **Still pending:** the richer `TdsAdjustment` model and the FVU export that would emit these as machine-generated negative lines; this policy is provisional pending CA sign-off. +**Where the withholding becomes a filing row (#1354).** Until this change the org rail computed TDS, stamped it on `OrganizationPayout`, and stopped there, so a real statutory deduction never reached the quarterly return. `markOrgPayoutCompleted` now writes a `TDSRecord` on the org rail exactly as `reconcile-payout-status` does on the consultant rail, and it writes it at `COMPLETED` and nowhere else. That timing is the point: a batch that is built and then fails never withheld anything, so a record created at batch time would put money on the return that the government was never paid. The record is deleted and rewritten on each completion, because a payout can be `FAILED`, released back to `READY`, re-batched and completed again. `markOrgPayoutReversed` handles the mirror case: when a bank returns a completed payout, `recordOrgTdsReversal` writes a negative `TDSRecord` plus a `TdsAdjustment` that nets the withholding out of the quarter, capped against any reversal already booked so a redelivered webhook cannot reverse twice. + +Both rails share one `TDSRecord` table, and a row belongs to exactly one of them. `consultantProfileId` and `organizationId` are both nullable, and two CHECK constraints in `prisma/sql/check-constraints.sql` do the work that `NOT NULL` used to do: `tds_record_deductee_xor` requires exactly one deductee, and `tds_record_payout_rail_matches` stops a row on one rail from citing the other rail's payout. Each rail also carries its own unique key, because a single key spanning both would dedupe nothing — Postgres treats NULLs as distinct, so an org row whose consultant columns are all null never conflicts with itself. + Authoritative: `docs/compliance/01-tds-overview.md`. ### 4.2 MSME §15 payment window @@ -159,7 +167,7 @@ The cost of missing the window is twofold. Commercially, MSMED §16 imposes **co > 🟡 **Gap — deadline keys off invoice date, not acceptance (no issue filed yet).** MSMED §15 runs the clock from acceptance or deemed acceptance (deemed acceptance being delivery plus fifteen days absent a written objection), but `computeMsmePaymentDeadline` derives `mustPayByDate` from invoice date. For rendered consulting services the two effectively coincide, so invoice date is a defensible conservative proxy, but it is a proxy and not the statutory trigger. -> 🟡 **Gap — §16 interest is neither documented nor accrued (no issue filed yet).** Nothing in the code models the §16 three-times-RBI-bank-rate monthly-compounding interest on a missed deadline; the cron only *alerts*. That is acceptable for v1 — the alert is meant to prevent the breach — but finance should understand that a missed `mustPayByDate` carries a real statutory interest cost we do not currently compute. +> 🟡 **Gap — §16 interest is neither documented nor accrued (no issue filed yet).** Nothing in the code models the §16 three-times-RBI-bank-rate monthly-compounding interest on a missed deadline; the cron only _alerts_. That is acceptable for v1 — the alert is meant to prevent the breach — but finance should understand that a missed `mustPayByDate` carries a real statutory interest cost we do not currently compute. > 🟥 **Divergence vs `docs/compliance/03-msme-43b-h.md`.** That doc states 43B(h) "carries forward unchanged into the Income-tax Act, 2025 … under equivalent clause numbering." The mechanics are unchanged, but the **clause number changed**: under the 2025 Act old §43B becomes **Section 37** and the MSME limb §43B(h) becomes **Section 37(2)(g)** ("any sum payable … to a micro or small enterprise beyond the time limit specified in section 15 of the MSMED Act, 2006"), with the ITR-due-date relief again expressly excluding that clause (corroborated by [TaxGuru's §37 explainer](https://taxguru.in/income-tax/section-37-income-tax-act-2025-earlier-section-43b-income-tax-act-1961.html)). The compliance doc's "equivalent clause numbering" phrasing is stale. (This doc does not edit docs/compliance.) The revised Udyam thresholds in force since 1 April 2025 (S.O. 1364(E)) are MICRO ≤ ₹2.5 cr investment / ≤ ₹10 cr turnover, SMALL ≤ ₹25 cr / ≤ ₹100 cr, and MEDIUM ≤ ₹125 cr / ≤ ₹500 cr, applied as a composite test; the disallowance reaches only MICRO and SMALL suppliers, which is why `computeMsmePaymentDeadline` routes MEDIUM and NONE to ordinary terms. @@ -199,7 +207,7 @@ A note on the consultant rail, since this doc is host-side: the consultant pipel ## 7. Design decisions & trade-offs -**Periodic batch with an `idempotencyKey` dedup, not a per-earning payout.** Each `READY` earning *could* fire its own transfer the moment its hold elapses, but that would mean N gateway calls, N TDS computations, and N rows per org per period — and TDS section logic wants the *aggregate* pool, not per-earning amounts. So the cron rolls a period's `READY` earnings into **one** `OrganizationPayout`, computes TDS once on the pool, and submits one transfer. The dedup is `OrganizationPayout.idempotencyKey @unique`: when two overlapping cron workers race the same period, the loser's insert hits `P2002` and the service falls through to return the winner's already-created row (`org-payout-service.ts`), so a retry or a replica cannot double-pay. The cost is up to a period of settlement latency; the benefit is one auditable payout per period and no N-way gateway fan-out. +**Periodic batch with an `idempotencyKey` dedup, not a per-earning payout.** Each `READY` earning _could_ fire its own transfer the moment its hold elapses, but that would mean N gateway calls, N TDS computations, and N rows per org per period — and TDS section logic wants the _aggregate_ pool, not per-earning amounts. So the cron rolls a period's `READY` earnings into **one** `OrganizationPayout`, computes TDS once on the pool, and submits one transfer. The dedup is `OrganizationPayout.idempotencyKey @unique`: when two overlapping cron workers race the same period, the loser's insert hits `P2002` and the service falls through to return the winner's already-created row (`org-payout-service.ts`), so a retry or a replica cannot double-pay. The cost is up to a period of settlement latency; the benefit is one auditable payout per period and no N-way gateway fan-out. **The flag freezes submission, not the pipeline.** With `ENABLE_LIVE_PAYOUTS` off, batching, TDS/MSME, the status machine, and the eventual ledger posting all run; only the gateway call is held, and the row is deliberately left at `PENDING` rather than claimed to `PROCESSING`. That keeps the whole path exercised in staging and seed data and makes go-live a flag flip rather than a code path that has never run. The cost is rows sitting at `PENDING` that an operator must read as "held," not "stuck" — hence the explicit "pending platform enablement" UI copy. The rejected alternative, short-circuiting the whole pipeline behind the flag, would mean the first real payout runs untested code. @@ -209,13 +217,14 @@ The mock-Redis case is the sharper one, and it is why the check rejects mock out ### What this design survived -**A false-FAILed payout the gateway had already accepted → double disbursement (`8a924d41`, #785 task #24).** The processing catch block marked a payout `PROCESSING → FAILED` and unlinked its earnings (back to `READY`) on *any* throw — including a DB write that threw *after* RazorpayX had already accepted the transfer. The released earnings would then re-batch under a **fresh `idempotencyKey` the gateway will not dedupe**, paying the same money twice. The fix hoists `providerPayoutId` above the `try` so the catch can tell a **pre-gateway** failure (no provider id yet — safe to FAIL and release) from a **post-gateway** one (provider id set — money already sent): the latter persists the gateway id, leaves the row `PROCESSING` with earnings *linked* (so the batcher's `payoutId: null` filter cannot re-batch them), and lets the stuck-payout handler reconcile it against the gateway. The flag was off, so no production money was double-paid — this was caught end-to-end before go-live. +**A false-FAILed payout the gateway had already accepted → double disbursement (`8a924d41`, #785 task #24).** The processing catch block marked a payout `PROCESSING → FAILED` and unlinked its earnings (back to `READY`) on _any_ throw — including a DB write that threw _after_ RazorpayX had already accepted the transfer. The released earnings would then re-batch under a **fresh `idempotencyKey` the gateway will not dedupe**, paying the same money twice. The fix hoists `providerPayoutId` above the `try` so the catch can tell a **pre-gateway** failure (no provider id yet — safe to FAIL and release) from a **post-gateway** one (provider id set — money already sent): the latter persists the gateway id, leaves the row `PROCESSING` with earnings _linked_ (so the batcher's `payoutId: null` filter cannot re-batch them), and lets the stuck-payout handler reconcile it against the gateway. The flag was off, so no production money was double-paid — this was caught end-to-end before go-live. -**TDS over-withholding 50× because the PAN was encrypted (`7f7e7d12`, #785).** Both payout services passed `OrganizationTaxInfo.panEncrypted` *ciphertext* straight into `computeTdsForPayout` as `panNumber`. The ciphertext failed `isValidPan`'s `[A-Z]{5}[0-9]{4}[A-Z]` regex, so the engine took the **194-O no-PAN fallback (5%)** instead of the with-PAN **0.1%** — withholding fifty times too much from every host-org payout that had a PAN safely on file. The fix added `panOnFile: boolean` to `TdsConsultantInput`: callers now pass `panNumber: null, panOnFile: !!taxInfo.panEncrypted`, so "a PAN exists" is signalled without trying to format-check ciphertext that cannot be validated until decrypt-at-filing-time. This is why §1.1's with-PAN figure is ₹40, not ₹2,000. +**TDS over-withholding 50× because the PAN was encrypted (`7f7e7d12`, #785).** Both payout services passed `OrganizationTaxInfo.panEncrypted` _ciphertext_ straight into `computeTdsForPayout` as `panNumber`. The ciphertext failed `isValidPan`'s `[A-Z]{5}[0-9]{4}[A-Z]` regex, so the engine took the **194-O no-PAN fallback (5%)** instead of the with-PAN **0.1%** — withholding fifty times too much from every host-org payout that had a PAN safely on file. The fix added `panOnFile: boolean` to `TdsConsultantInput`: callers now pass `panNumber: null, panOnFile: !!taxInfo.panEncrypted`, so "a PAN exists" is signalled without trying to format-check ciphertext that cannot be validated until decrypt-at-filing-time. This is why §1.1's with-PAN figure is ₹40, not ₹2,000. --- ### Related docs + - [Earnings lifecycle](06-earnings-lifecycle.md) — the `EarningStatus` machine, holds, and refund decrements this pipeline consumes. - [Booking → earnings](05-booking-to-earnings.md) — the rate-card bps snapshot that feeds the earnings row. - [Ledger & postings](03-ledger-and-postings.md) — the `ORG_PAYOUT` / `PAYOUT` transactions in full. diff --git a/docs/enterprise/10-money-and-ledger/08-invoicing.md b/docs/enterprise/10-money-and-ledger/08-invoicing.md index 57d8db093..73869bcd5 100644 --- a/docs/enterprise/10-money-and-ledger/08-invoicing.md +++ b/docs/enterprise/10-money-and-ledger/08-invoicing.md @@ -265,6 +265,16 @@ The status-level invoice transition is the coarse view (`PAID → REFUNDED` for --- +## 8b. Consumer invoices (B2C) — #1365 + +Everything above concerns `OrganizationInvoice`, which is the document a sponsoring organization receives. A personal buyer paying by card receives a different document on a different series, and that path is documented separately in [B2C tax invoices and credit notes](../../payments/07-b2c-tax-invoice.md). + +The two families are deliberately separate models rather than one model with nullable columns. `OrganizationInvoice` requires an organization, a billing account and a due date, and those columns are load-bearing for dunning and for the IRP e-invoice payload; a consumer invoice has none of them, is paid before it is issued, and runs on one platform-wide gapless series instead of one series per organization. Collapsing them would make every one of those columns optional and would quietly weaken the B2B guarantees this page describes. + +What the two families share is the register. `jobs/compliance/gst-outward-register-export.ts` reads both invoice models and both credit-note models for a period and emits a single outward-supplies CSV for GSTR-1, stamping `gstr1ExportedAt` on the rows it reported so a re-run never re-stamps them. + +--- + ## 9. Overage roll-up into a line item (#715 / #775) A `CHARGE_ORG` program overage isn't billed instantly — its marginal accrues as an `OVERAGE_INVOICE_ACCRUAL` `PaymentLeg` at checkout ([booking → earnings §6.3](05-booking-to-earnings.md)) and is **rolled into the cycle's invoice** alongside the base bookings. `rollupOrgInvoiceAccruals` (`lib/payments/billing/invoice-rollup.ts`, driven by `jobs/billing/settle-invoice-accruals.ts`) gathers each org's unbilled `SUCCEEDED` payments carrying **either** accrual source, sums **both** leg sources into the line `unitPricePaise` (the base + the overage), and emits one `InvoiceLineItem` per booking. The accrual read, invoice create, and stamp all run inside a single **Serializable** transaction (#813), so two overlapping rollup runs can no longer both issue an invoice for the same accrual set — the loser aborts with a P2034 serialization error, which the job treats as a benign skip. The monthly cadence and the workflow's `concurrency` group are the outer belt; the in-transaction read is the suspenders. diff --git a/docs/enterprise/10-money-and-ledger/09-payment-legs.md b/docs/enterprise/10-money-and-ledger/09-payment-legs.md index 663a48617..0af5c61b8 100644 --- a/docs/enterprise/10-money-and-ledger/09-payment-legs.md +++ b/docs/enterprise/10-money-and-ledger/09-payment-legs.md @@ -3,14 +3,14 @@ title: Payment legs & stackable funding band: 10-money-and-ledger audience: sde2 status: live -last-reviewed: 2026-06-05 +last-reviewed: 2026-09-03 --- # Payment legs & stackable funding **What this covers:** how one booking can be funded by several sources at once (`PaymentLeg`), and how each leg source maps to a **debit in the `BOOKING` ledger posting**. This is the funding side of [booking → earnings](05-booking-to-earnings.md). -> A single checkout can stack funding: a wallet covers most of the price, a referral credit chips in, the learner's card picks up the rest. `PaymentLeg` models it — one `Payment`, N legs whose amounts sum to `Payment.amount`. Those same legs become the **debit side** of the booking journal transaction. +> A single checkout can stack funding: a wallet covers most of the price, a referral credit chips in, the learner's card picks up the rest. `PaymentLeg` models it — one `Payment` and N legs, of which every funding leg except the platform-issued referral credit sums to `Payment.amount`. Those same legs become the **debit side** of the booking journal transaction. --- @@ -83,7 +83,7 @@ flowchart LR ## 3. Invariants -1. **Sum identity.** `sum(non-reversal PaymentLeg.amountPaise) === Payment.amount` (LICENSE is 0). Since #786 the funding legs are append-only: a refund never mutates the original leg, it nets through a negative `*_REVERSAL` sibling, so the original legs always still sum to `Payment.amount`. Each reversal leg must be negative and may never exceed its original sibling in magnitude. Enforced at checkout; the reconciler's `PAYMENT_LEG_SUM_MISMATCH` (now pair-aware, with a `FUNDING_SUM_DRIFT` vs `REVERSAL_PAIR_VIOLATION` reason) is the retroactive detector ([ledger integrity](13-ledger-integrity.md)). +1. **Sum identity.** `sum(non-reversal, non-REFERRAL_CREDIT PaymentLeg.amountPaise) === Payment.amount` (LICENSE is 0 and stays in the sum). `Payment.amount` is the amount charged to the gateway, which the schema defines as the figure left after discounts and tax and **after** referral credits have been deducted. A `REFERRAL_CREDIT` leg therefore records value that has already been taken out of `amount`, and adding it back into the sum would demand the same credit twice, so it is excluded (#1347). The credit leg is still written, because it is the `PLATFORM_PROMO` debit in the booking journal (§2); it simply does not participate in the funding identity. A payment whose only funding legs are zero-value `LICENSE` legs is exempt from the comparison altogether, because the licence is absorbed at contract time and the leg is deliberately ₹0 while `Payment.amount` stays at the full list price, and the constraint trigger carries that same carve so it can never reject at `COMMIT` a checkout the checker waves through. That exemption removes the sum comparison and nothing else: both the checker and the trigger still apply the reversal-pair rules below to such a payment, because a reversal leg that exceeds the original it reverses is corrupt under either reading of the sum. Since #786 the funding legs are append-only: a refund never mutates the original leg, it nets through a negative `*_REVERSAL` sibling, so the original legs always still sum to `Payment.amount`. Each reversal leg must be negative and may never exceed its original sibling in magnitude. Enforced at checkout, and made uncommittable by the `payment_legs_sum_to_amount` constraint trigger in `prisma/sql/payment-legs-triggers.sql`; the reconciler's `PAYMENT_LEG_SUM_MISMATCH` (now pair-aware, with a `FUNDING_SUM_DRIFT` vs `REVERSAL_PAIR_VIOLATION` reason) is the retroactive detector ([ledger integrity](13-ledger-integrity.md)). 2. **Leg count ≥ 1.** A `SUCCEEDED` payment with zero legs is a data bug. 3. **Source uniqueness per payment.** `@@unique([paymentId, source])` — a duplicate-source leg fails on insert (`P2002`) rather than corrupting the sum. Reversal legs respect the same rule: there is at most one reversal sibling per source, and subsequent partial refunds net into it. If split-billing across sub-orgs becomes real, drop this and add a `legGroupId` (tracked follow-up). 4. **`sourceRef` for reversal.** Populated for `REFERRAL_CREDIT` (→ `ReferralCredit.id`) and where a gateway/accrual ref exists. `WALLET` legs no longer reference a per-row wallet log (`WalletEntry` was removed in #772) — the authoritative wallet movement is the booking journal's `Dr WALLET(org)` leg; the cache decrement is `walletDebit()`. `LICENSE`/`CARD` may omit `sourceRef`. @@ -101,25 +101,27 @@ price: 500,000 - referral covers: 50,000 → PaymentLeg(REFERRAL_CREDIT, amountPaise=50000) - card covers: 150,000 → PaymentLeg(CARD, amountPaise=150000) -sum (excl. LICENSE=0): 200,000 == Payment.amount: 200,000 +Payment.amount (what the gateway is charged): 150,000 +funding sum (LICENSE 0 + CARD 150,000): 150,000 == Payment.amount ``` -The resulting booking posting debits `PLATFORM_PROMO` 50,000 + `CASH` 150,000 against the fee/payable/GST credits. +`Payment.amount` is 150,000 rather than 200,000 because the ₹500 of referral credit was deducted before the order was minted, so the learner's card was only ever asked for the remaining ₹1,500. The `REFERRAL_CREDIT` leg still carries its 50,000, but the sum identity in §3 skips it, which is exactly what stops the credit being demanded a second time. The resulting booking posting debits `PLATFORM_PROMO` 50,000 + `CASH` 150,000 against the fee/payable/GST credits. ### 4.1 A wallet + referral + card stack The canonical three-source stack the mental model opens with. A learner books a ₹3,000 (300,000 paise) session sponsored by a **WALLET-funded org** (`fundingSource = WALLET`) that has ₹2,000 of wallet balance left; the learner also holds ₹200 of referral credit; the card covers the rest. Checkout allocates **in priority order** — entitlement/wallet first, then platform credits, card last: ``` -price: 300,000 - - wallet covers: 200,000 → PaymentLeg(WALLET, amountPaise=200000) - - referral covers: 20,000 → PaymentLeg(REFERRAL_CREDIT, amountPaise=20000) - - card covers: 80,000 → PaymentLeg(CARD, amountPaise=80000) +price: 300,000 + - wallet covers: 200,000 → PaymentLeg(WALLET, amountPaise=200000) + - referral covers: 20,000 → PaymentLeg(REFERRAL_CREDIT, amountPaise=20000) + - card covers: 80,000 → PaymentLeg(CARD, amountPaise=80000) -sum: 300,000 == Payment.amount: 300,000 +Payment.amount (post-credit): 280,000 +funding sum (WALLET 200,000 + CARD 80,000): 280,000 == Payment.amount ``` -The `walletDebit()` overdraft guard ([wallet & top-ups §4](04-wallet-and-topups.md)) atomically tests-and-decrements the ₹2,000 → ₹0 cache in the same tx the `WALLET` leg is written. The booking posting then debits `WALLET(org) 200,000` + `PLATFORM_PROMO 20,000` (the referral credit the platform eats) + `CASH 80,000`, balanced against the fee/payable/GST credits ([booking → earnings §3](05-booking-to-earnings.md#3-the-booking-posting)). Three sources, three legs, one `Payment` — and the journal's debit side is exactly those three legs summed by source ([ledger & postings §4.2](03-ledger-and-postings.md#42-booking--booking-bookingpaymentid)). Note `Payment.amount` here is the **full** 300,000 because none of these legs is excluded from it — unlike a pure referral-funded booking, where the credit is netted out of `amount` and the `DISCOUNT` plug picks up the gap (the trap behind the [booking → earnings §7 war story](05-booking-to-earnings.md#7-design-decisions--trade-offs)). +The `walletDebit()` overdraft guard ([wallet & top-ups §4](04-wallet-and-topups.md)) atomically tests-and-decrements the ₹2,000 → ₹0 cache in the same tx the `WALLET` leg is written. The booking posting then debits `WALLET(org) 200,000` + `PLATFORM_PROMO 20,000` (the referral credit the platform eats) + `CASH 80,000`, balanced against the fee/payable/GST credits ([booking → earnings §3](05-booking-to-earnings.md#3-the-booking-posting)). Three sources, three legs, one `Payment` — and the journal's debit side is exactly those three legs summed by source ([ledger & postings §4.2](03-ledger-and-postings.md#42-booking--booking-bookingpaymentid)). Note that `Payment.amount` here is 280,000 and not the full 300,000, because the ₹200 of referral credit was netted out of it before the order was minted. That is the same asymmetry the `DISCOUNT` plug exists to absorb: the plug is computed from the sum of the funding-leg **debits**, which does include `PLATFORM_PROMO`, so the credit is counted exactly once on the journal side and zero times on the `Payment.amount` side (the trap behind the [booking → earnings §7 war story](05-booking-to-earnings.md#7-design-decisions--trade-offs)). --- @@ -144,6 +146,8 @@ Refunds are a `Refund` row (not an inverse leg) plus a `REFUND` journal transact - **The `CHARGE_ORG` overage that double-billed via an extra leg (`7f7e7d12`, #785 C3).** When an org-charged overage was recorded at checkout, the code **added** an `OVERAGE_INVOICE_ACCRUAL` leg for the marginal *on top of* the base `INVOICE_ACCRUAL` leg — but the base leg already covered the over-cap pass-through (`basePaise`). Because `rollupOrgInvoiceAccruals` sums **both** leg sources into the invoice ([invoicing §9](08-invoicing.md#9-overage-roll-up-into-a-line-item-715--775)), the org was billed `basePaise` **twice**, and `sum(PaymentLeg.amountPaise)` no longer equalled `Payment.amount` — tripping the reconciler's `PAYMENT_LEG_SUM_MISMATCH` ([ledger integrity](13-ledger-integrity.md)). The fix **carves** `basePaise` *out* of the base `INVOICE_ACCRUAL` leg and writes only the genuinely-additional surcharge as the overage leg, so the two legs sum to the price exactly. The symmetric `CHARGE_MEMBER` carve (so `basePaise` isn't collected on both the org's parent leg and the member's side-charge) shipped in the same commit — latent, since no `CHARGE_MEMBER` program is configured yet. This is the invariant in §3 rule 1 doing its job: the leg-sum identity is what made a silent double-bill a *loud* reconcile finding. +- **The two definitions of `Payment.amount` that could not both be true (2026-09-03, #1347).** The schema has always described `Payment.amount` as the final amount charged to the gateway, taken after discounts and tax and **after** referral credits are deducted, and checkout writes a `CARD` leg equal to exactly that figure. The referral consumption helper then writes a positive `REFERRAL_CREDIT` leg for the credit it just applied, so the legs on a credit-funded booking added up to `amount` *plus* the credit. Rule 1 of §3, the checkout sweep, and the `payment_legs_sum_to_amount` constraint trigger all read the identity as a plain sum over every non-reversal leg, so the trigger — which is deferred to `COMMIT` and is live on the database — raised `check_violation` and rolled back the entire checkout transaction for any booking that spent referral credit. The two readings of `amount` were irreconcilable: either the field meant the pre-credit price, in which case the gateway was being asked for the wrong number, or the credit leg did not belong in the sum. The resolution keeps the field's long-standing meaning and narrows the identity instead, so the funding sum now excludes `REFERRAL_CREDIT` in the checker, in the trigger, and in this document. The credit leg is untouched and still posts as the `PLATFORM_PROMO` debit; the `DISCOUNT` plug in `earnings-service.ts` already based itself on the sum of funding-leg debits including `PLATFORM_PROMO`, so the journal side needed no change at all. + --- ### Related docs diff --git a/docs/enterprise/10-money-and-ledger/12-payment-webhooks.md b/docs/enterprise/10-money-and-ledger/12-payment-webhooks.md index 0a6121990..b1f38568f 100644 --- a/docs/enterprise/10-money-and-ledger/12-payment-webhooks.md +++ b/docs/enterprise/10-money-and-ledger/12-payment-webhooks.md @@ -128,11 +128,25 @@ A `Payment` is created in `PENDING` when checkout creates the gateway order. A s --- +## 4a. The gateway payment id, and why a refund used to need a network call + +Razorpay's `Payment.paymentIntent` column holds the **order** id (`order_…`), because that is what checkout creates and what the capture events name. Refund and dispute webhooks, however, identify their subject by the **payment** id (`pay_…`), and until #1353 nothing on our side stored one. The dispatcher therefore had to translate: for every `refund.*` event it called `payments.fetch(payment_id)` purely to read back the `order_id`. When that call failed — a gateway blip, an expired credential, a cold instance timing out — the dispatcher passed the `pay_…` id through unchanged, `handleRefundCreated` looked it up against `paymentIntent`, found nothing, and returned a `DeferSignal`. The event then sat unprocessed while the stuck-event sweeper re-drove it for up to seven days against a payment that had been captured all along. + +`Payment.gatewayPaymentId` closes that. The confirmation pipeline persists the `pay_…` id at capture, in the same Phase 1 update that flips the row to `SUCCEEDED` — ADR 21 already makes that the single writer of the payment's capture truth, and the gateway id is part of that truth, so no second writer is introduced. The column is unique, so two Payment rows can never claim the same gateway capture. + +Downstream, `handleRefundCreated` and `handleDisputeCreated` resolve their payment with one `findFirst` whose `OR` accepts **either** id, and the dispatcher now reads our own row first and calls `payments.fetch` only when the database has never seen that capture. The gateway call is a fallback for rows written before this change, not the only path. For disputes the benefit is larger still: a failed lookup used to mean the dispute could not be linked to a payment at all, which raised a `CRITICAL_DISPUTE_UNLINKED` page and left disputed earnings payable until the six-hourly reconcile cron noticed. + +## 4b. State-as-outbox for the post-capture legs + +Some of what a capture owes the buyer cannot happen inside the confirmation transaction. Creating their Stream chat channel is outbound network work, so it runs after the commit and is deliberately not awaited. That made it invisible when it failed: a crash or a Stream outage in that window left a confirmed, paid booking with no conversation, and the only trace was a Sentry event. + +Rather than introduce a queue table, the row that already records the work carries the completion stamp. `Appointment.chatChannelEnsuredAt` is written only once the channel calls have actually returned, which turns "confirmed, paid, and still `NULL`" into an exact query for the work that was lost. `reconcile-orphaned-confirmations` runs that query as its second pass and calls back into the same `ensureChannelsForAppointment` the live path uses. `WebhookEvent.deferCount` is the same idea one layer up: a deferral leaves the row looking exactly like a crash, so counting the deferrals is the only mark that path leaves behind, and it is what the sweeper alerts on. This is the pattern ADR 27 describes in full. + ## 5. Monitoring and archival Three concerns keep the inbound pipeline observable and bounded: replay of crashed events, archival of old rows, and alerting on verification failures. -The **stuck-event sweeper** (`sweep-stuck-webhook-events`, §1) is the primary recovery mechanism — it re-drives `processed=false, error=null` events that an `after()` crash left behind, which would otherwise become the highest-blast-radius zombies (PAID money with an ISSUED invoice, uncredited top-ups, unpersisted chargebacks). The sweeper also drains the deliberate before-capture deferrals (a `refund.created`/`refund.processed` whose payment is not yet captured), giving each one up to a 7-day `giveUpAfterHours` window before it is terminally capped so an unknown payment cannot churn indefinitely (#813). Its sister, the **archive cron** (`archive-webhook-events`, weekly), deletes processed events older than 30 days and failed/errored events older than 90 days, keeping the table lean while retaining failures long enough to debug. **Alerting** rides on the row state: a handler error is stamped on `WebhookEvent.error` (surfaced by the sweeper's `stillFailing` count), and a signature-verification failure is recorded as a `WEBHOOK` warning via `recordSystemEvent`, since repeated HMAC failures indicate tampering or a secret misconfiguration. The org-relevant operator surfaces are absorbed from `docs/payments/webhooks/01-monitoring.md`. +The **stuck-event sweeper** (`sweep-stuck-webhook-events`, §1) is the primary recovery mechanism — it re-drives `processed=false, error=null` events that an `after()` crash left behind, which would otherwise become the highest-blast-radius zombies (PAID money with an ISSUED invoice, uncredited top-ups, unpersisted chargebacks). The sweeper also drains the deliberate before-capture deferrals (a `refund.created`/`refund.processed` whose payment is not yet captured), giving each one up to a 7-day `giveUpAfterHours` window before it is terminally capped so an unknown payment cannot churn indefinitely (#813). Its sister, the **archive cron** (`archive-webhook-events`, weekly), deletes processed events older than 30 days and failed/errored events older than 90 days, keeping the table lean while retaining failures long enough to debug. **Alerting** rides on the row state: a handler error is stamped on `WebhookEvent.error` (surfaced by the sweeper's `stillFailing` count), and a signature-verification failure is recorded as a `WEBHOOK` warning via `recordSystemEvent`, since repeated HMAC failures indicate tampering or a secret misconfiguration. Since #1356 a deferral is alertable too. Each time a handler answers with a `DeferSignal` the dispatcher increments `WebhookEvent.deferCount`, and the sweeper raises one Sentry warning per run naming every event that has either deferred five times or been unprocessed for more than an hour. That closes the gap the seven-day give-up cap left: the cap is the point at which we abandon an event, not a point at which anybody is told about it. The org-relevant operator surfaces are absorbed from `docs/payments/webhooks/01-monitoring.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 a65995c82..e062d12a0 100644 --- a/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md +++ b/docs/enterprise/10-money-and-ledger/13-ledger-integrity.md @@ -49,12 +49,13 @@ flowchart TD | `OVERAGE_CHARGESTATUS_INTEGRITY` | per `OverageEvent` (link/state) | CHARGE_MEMBER pending/failed/charged ⇒ has a side-`Payment`; CHARGE_ORG accrued/charged ⇒ has an `InvoiceLineItem`; any `CHARGED` ⇒ `settledAt` set | the `transitionOverage` state machine was bypassed or a write half-completed | | `LEDGER_ACCOUNT_NON_INR` | per `LedgerAccount` | `currency == INR` for every account (#783) | a posting keyed an INR-paise amount by a display currency — would break receivable/payable clearing | | `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(PaymentLeg.amountPaise) == Payment.amount` (LICENSE legs are 0) | a leg writer (checkout / wallet / referral / overage) emitted the wrong amount | +| `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 | +| `LEDGER_DUAL_WRITE_GAP` | per `OrganizationPayout` with `clawbackAmountPaise > 0` | a `clawback:*` `LedgerTransaction` exists against that payout (#1408) | the payout claims recovered cash the journal never saw: `reversePayoutClawback` posts the reversal best-effort inside a `try`/`catch`, and the two other writers of `clawbackAmountPaise` (`refund.ts`, `booking-refund.ts`) post nothing at all | **Grouped by what each check protects** — the flat list above is alphabetical; this is the pipeline as a defender, from "is the journal itself sound" through "do the caches match" to "who gets paged": diff --git a/docs/enterprise/30-programs-and-lifecycle/06-feature-flags-and-rollout.md b/docs/enterprise/30-programs-and-lifecycle/06-feature-flags-and-rollout.md index 091973cb2..f160dfea9 100644 --- a/docs/enterprise/30-programs-and-lifecycle/06-feature-flags-and-rollout.md +++ b/docs/enterprise/30-programs-and-lifecycle/06-feature-flags-and-rollout.md @@ -16,19 +16,20 @@ Flags are read from `process.env` at module load, so setting one requires a rede Six flags are exported from the module, and each one is the literal expression `process.env.X === "true"`, so an absent or empty value means off. The table below gives each flag's default, purpose, and the surfaces it dark-fails when off. -| Flag | Default | Purpose | Gated surfaces when OFF | -| ------------------------------ | ------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ENABLE_HOST_ORGS` | off | Hosting orgs (agencies hosting experts, 3-way split). | `POST /organizations` rejects `canHost=true` with 400 `HOST_ORGS_GATED`; `POST …/members` rejects `role=EXPERT`; the org-create wizard hides the host capability (server flag threaded to the wizard); the public org directory shows a "coming soon" state; the host routes stay gated; `/experts` + `/payouts` nav hidden; `earnings-service` takes the sponsor-only split. | -| `ENABLE_LIVE_PAYOUTS` | off | Live payout **disbursement** gate (#776 §B). | The whole pipeline runs (batches, ledger, TDS, status machine) but gateway submission is held; org/consultant payouts sit `PROCESSING`, surfaced honestly as "pending platform enablement", **never** as a failure. Server-only — the home action-center + payout surfaces read it server-side and pass the boolean down. | -| `ENABLE_DUNNING_SUSPEND` | off | Dunning cascade that suspends an org with an overdue invoice. | `lib/payments/operations/checkout.ts` skips the suspend gate, so an org with an overdue invoice keeps booking. Decide before self-serve tenants (#812/#779). | -| `ENABLE_TDS_194O_GROSS` | off | Section 194-O withholding computed on the GROSS sale amount, with the three-limb ₹5,00,000 exemption. | The legacy base (consultant share net of platform commission) and the 194J ₹50,000 threshold stay in force. Flipping this changes real withholding, so it needs written CA sign-off first — see #1132. | -| `TDS_ENGINE` | `LEGACY` | Withholding engine selector (`LEGACY` \| `194O`). Read inline at payout time; flipping to `194O` also requires `ENABLE_TDS_194O_GROSS` + CA sign-off (#738). | Non-module env gate read in `lib/payments/payouts/payout-service.ts`. | -| `ENABLE_CONSOLIDATED_INVOICE` | off | Monthly invoice-accrual settlement cron (`settle-invoice-accruals`). | Absent means off — accrual rows accumulate until finance enables it. Documented here because it gates real money movement despite living outside `feature-flags.ts`. | -| `DPDP_SWEEPER_DELETE` | absent | Consent-retention sweeper delete mode. | Absent = report-only. Intentional default; see required-secrets doc. | +| Flag | Default | Purpose | Gated surfaces when OFF | +| ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ENABLE_HOST_ORGS` | off | Hosting orgs (agencies hosting experts, 3-way split). | `POST /organizations` rejects `canHost=true` with 400 `HOST_ORGS_GATED`; `POST …/members` rejects `role=EXPERT`; the org-create wizard hides the host capability (server flag threaded to the wizard); the public org directory shows a "coming soon" state; the host routes stay gated; `/experts` + `/payouts` nav hidden; `earnings-service` takes the sponsor-only split. | +| `ENABLE_LIVE_PAYOUTS` | off | Live payout **disbursement** gate (#776 §B). | The whole pipeline runs (batches, ledger, TDS, status machine) but gateway submission is held; org/consultant payouts sit `PROCESSING`, surfaced honestly as "pending platform enablement", **never** as a failure. Server-only — the home action-center + payout surfaces read it server-side and pass the boolean down. | +| `ENABLE_DUNNING_SUSPEND` | off | Dunning cascade that suspends an org with an overdue invoice. | `lib/payments/operations/checkout.ts` skips the suspend gate, so an org with an overdue invoice keeps booking. Decide before self-serve tenants (#812/#779). | +| `ENABLE_TDS_194O_GROSS` | off | Section 194-O withholding computed on the GROSS sale amount, with the three-limb ₹5,00,000 exemption. | The legacy base (consultant share net of platform commission) and the 194J ₹50,000 threshold stay in force. Flipping this changes real withholding, so it needs written CA sign-off first — see #1132. | +| `TDS_ENGINE` | `LEGACY` | Withholding engine selector (`LEGACY` \| `194O`). Read inline at payout time; flipping to `194O` also requires `ENABLE_TDS_194O_GROSS` + CA sign-off (#738). | Non-module env gate read in `lib/payments/payouts/payout-service.ts`. | +| `ENABLE_CONSOLIDATED_INVOICE` | off | Monthly invoice-accrual settlement cron (`settle-invoice-accruals`). | Absent means off — accrual rows accumulate until finance enables it. Documented here because it gates real money movement despite living outside `feature-flags.ts`. | +| `DPDP_SWEEPER_DELETE` | absent | Consent-retention sweeper delete mode. | Absent = report-only. Intentional default; see required-secrets doc. | +| `RATE_CARD_SCOPED_RESOLUTION` | off | Lets settlement forward the booking's contract and plan scope to the rate-card resolver (#1335). On only when the value is exactly `on`. | Off, `resolveOrgSplit()` passes org scope alone, so a contract- or plan-scoped `RateCard` can exist and never be selected — only the per-expert override, the org default and the hardcoded 10/10/80 are reachable. Read per call by `isScopedRateCardResolutionEnabled()` in `lib/api/organizations/rate-card.ts`; flipping it changes which card settles live money, so audit the org's existing scoped cards first. | > **Other non-module gates** live in `.github/workflows/*.yml` (`ENABLE_IRP_UPLOADER`, `ENABLE_STRIPE_PAYOUTS`) and are tracked in `50-operations/07-required-secrets.md`. -| `ENABLE_TDS_ADMIN_VIEW` | off | Admin TDS dashboard + Form 26Q filing surfaces. | `app/api/admin/tds/route.ts` returns 404 (hides from discovery). TDS data is still captured continuously by the payout pipeline; only the _filing workflow_ (mark-as-filed, decrypted-PAN view) is gated. | -| `ENABLE_BETTERSTACK_TELEMETRY` | off | Better Stack Telemetry log sink for operational events (#776 §K). | `recordSystemEvent`/`recordSystemError` always write the `SystemEvent` table (source of truth); the flag (plus `BETTERSTACK_SOURCE_TOKEN` + `BETTERSTACK_INGEST_URL`) only adds the fire-and-forget side-channel that ships those events so a stuck payout / failed reconcile / HMAC failure can page someone. Never on the critical path. | +> | `ENABLE_TDS_ADMIN_VIEW` | off | Admin TDS dashboard + Form 26Q filing surfaces. | `app/api/admin/tds/route.ts` returns 404 (hides from discovery). TDS data is still captured continuously by the payout pipeline; only the _filing workflow_ (mark-as-filed, decrypted-PAN view) is gated. | +> | `ENABLE_BETTERSTACK_TELEMETRY` | off | Better Stack Telemetry log sink for operational events (#776 §K). | `recordSystemEvent`/`recordSystemError` always write the `SystemEvent` table (source of truth); the flag (plus `BETTERSTACK_SOURCE_TOKEN` + `BETTERSTACK_INGEST_URL`) only adds the fire-and-forget side-channel that ships those events so a stuck payout / failed reconcile / HMAC failure can page someone. Never on the critical path. | `ENABLE_HOST_ORGS` is the broadest of the six — flipping it off doesn't just hide a page, it changes the **split math** and dark-fails a whole capability. diff --git a/docs/enterprise/50-operations/01-api-reference.md b/docs/enterprise/50-operations/01-api-reference.md index 79363d002..64ddd2f1c 100644 --- a/docs/enterprise/50-operations/01-api-reference.md +++ b/docs/enterprise/50-operations/01-api-reference.md @@ -41,7 +41,7 @@ flowchart LR ``` > **How to read this table.** Each row is one `path × verb`. -> **Min role** is the *floor* — `requireOrgAccess(orgId, minRole)` (or +> **Min role** is the _floor_ — `requireOrgAccess(orgId, minRole)` (or > `requireOrgOwner`) from `lib/auth-helpers.ts`; higher ranks and > platform admins always pass. 🔒 rows gate on > `requireOrgBillingAdminOrOwner` (`lib/auth/billing-admin-gate.ts`, @@ -54,14 +54,14 @@ flowchart LR > (`lib/enterprise/audit-actions.ts`). Every enterprise-layer HTTP endpoint, exhaustively. Roles are the -*minimum* required role — higher-rank roles and platform admins +_minimum_ required role — higher-rank roles and platform admins always pass. Audit actions are the string literals emitted by the route on success; rows land in `OrgAuditLog` with the category shown in parentheses. Constants live in `lib/enterprise/audit-actions.ts`. > **Billing surfaces are governed by `requireOrgBillingAdminOrOwner`** > (`lib/auth/billing-admin-gate.ts`) — an **OWNER ∨ BILLING_ADMIN** -> disjunction. MAINTAINER is *deliberately excluded* from money +> disjunction. MAINTAINER is _deliberately excluded_ from money > surfaces even though it outranks BILLING_ADMIN on the org ladder. > Rows gated this way are marked 🔒 **OWNER / BILLING_ADMIN** below. @@ -73,156 +73,156 @@ the bottom._ These are the unscoped routes that operate above any single organization — the org switcher feed, org creation, the public directory, and invite acceptance. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations` | `GET` | authenticated | List the caller's orgs (switcher feed) | — | -| `/api/organizations` | `POST` | authenticated | Create org + BillingAccount + OWNER Membership. Also upserts an `OrgWorkspaceProfile` for the creator (so they become "an operator of at least one org") and returns `orgWorkspaceProfileId` on the response. | `MEMBER_ADDED` (MEMBER) | -| `/api/organizations/public` | `GET` | public | Public org directory (no session). Filtered to `isPublic = true`. | — | +| Path | Verb | Min role | Purpose | Audit actions | +| --------------------------------------- | ------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | +| `/api/organizations` | `GET` | authenticated | List the caller's orgs (switcher feed) | — | +| `/api/organizations` | `POST` | authenticated | Create org + BillingAccount + OWNER Membership. Also upserts an `OrgWorkspaceProfile` for the creator (so they become "an operator of at least one org") and returns `orgWorkspaceProfileId` on the response. | `MEMBER_ADDED` (MEMBER) | +| `/api/organizations/public` | `GET` | public | Public org directory (no session). Filtered to `isPublic = true`. | — | | `/api/organizations/invitations/accept` | `POST` | authenticated | Accept an invite via token. Side-effects: LEARNER invites lazily upsert a `ConsulteeProfile` (via `ensureConsulteeProfile`); EXPERT invites upsert a placeholder `ConsultantProfile` (`Domain "General"`, `scheduleType = WEEKLY`, `verificationStatus = PENDING_VERIFICATION`) if the user doesn't already have one. | `INVITE_ACCEPTED` (MEMBER) | ## Org record These routes read and mutate the organization record itself, including branding assets and the platform-admin verification state machine. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]` | `GET` | LEARNER | Full merged org record + capabilities + counts | — | -| `/api/organizations/[orgId]` | `PATCH` | active member 🔓 **field-level RBAC** | Branding + policy + capability flips. The gate is `requireOrgAccess` (any active member) followed by a **field-level allowlist**: OWNER may touch every field; MAINTAINER is limited to `MAINTAINER_FIELDS` (name, description, industry, website, sizeBucket, logo, bannerImage, primaryColor, secondaryColor); BILLING_ADMIN is limited to `BILLING_ADMIN_FIELDS` (billingEmail, paymentTermsDays). Any out-of-remit field → `403 FIELD_RBAC_FORBIDDEN` naming the offending fields. Capability guard: can't disable both `canSponsor`/`canHost`; can't disable `canSponsor` while wallet balance > 0. | `SETTINGS_CHANGED` (SETTINGS) | -| `/api/organizations/[orgId]` | `DELETE` | OWNER | Hard-delete (refused `409` if any contracts/invoices/POs/earnings exist — admins DEACTIVATE instead). No audit row: the org and its `OrgAuditLog` rows are deleted in the same tx, so there's nothing to write to. | — | -| `/api/organizations/[orgId]/branding/[asset]` | `POST` | OWNER | Upload logo or banner image (multipart `file`; `asset` = `logo` \| `banner`) | `SETTINGS_CHANGED` (SETTINGS) | -| `/api/organizations/[orgId]/branding/[asset]` | `DELETE` | OWNER | Remove logo or banner image (`asset` = `logo` \| `banner`) | `SETTINGS_CHANGED` (SETTINGS) | -| `/api/organizations/[orgId]/settings` | `GET` | LEARNER | Thin settings projection | — | -| `/api/admin/organizations/[orgId]/verify` | `POST` | platform ADMIN | One route handles `VERIFY / REJECT / SUSPEND / REACTIVATE / DEACTIVATE` (action upper-cased, defaults to `VERIFY`). `REJECT` requires a `reason` and keeps the org `PENDING_VERIFICATION` (stamps `verificationReason` + `verificationRejectedAt` for the resubmit loop). | `VERIFIED` / `VERIFICATION_REJECTED` / `SUSPENDED` / `REACTIVATED` / `DEACTIVATED` (SYSTEM) | +| Path | Verb | Min role | Purpose | Audit actions | +| --------------------------------------------- | -------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `/api/organizations/[orgId]` | `GET` | LEARNER | Full merged org record + capabilities + counts | — | +| `/api/organizations/[orgId]` | `PATCH` | active member 🔓 **field-level RBAC** | Branding + policy + capability flips. The gate is `requireOrgAccess` (any active member) followed by a **field-level allowlist**: OWNER may touch every field; MAINTAINER is limited to `MAINTAINER_FIELDS` (name, description, industry, website, sizeBucket, logo, bannerImage, primaryColor, secondaryColor); BILLING_ADMIN is limited to `BILLING_ADMIN_FIELDS` (billingEmail, paymentTermsDays). Any out-of-remit field → `403 FIELD_RBAC_FORBIDDEN` naming the offending fields. Capability guard: can't disable both `canSponsor`/`canHost`; can't disable `canSponsor` while wallet balance > 0. | `SETTINGS_CHANGED` (SETTINGS) | +| `/api/organizations/[orgId]` | `DELETE` | OWNER | Hard-delete (refused `409` if any contracts/invoices/POs/earnings exist — admins DEACTIVATE instead). No audit row: the org and its `OrgAuditLog` rows are deleted in the same tx, so there's nothing to write to. | — | +| `/api/organizations/[orgId]/branding/[asset]` | `POST` | OWNER | Upload logo or banner image (multipart `file`; `asset` = `logo` \| `banner`) | `SETTINGS_CHANGED` (SETTINGS) | +| `/api/organizations/[orgId]/branding/[asset]` | `DELETE` | OWNER | Remove logo or banner image (`asset` = `logo` \| `banner`) | `SETTINGS_CHANGED` (SETTINGS) | +| `/api/organizations/[orgId]/settings` | `GET` | LEARNER | Thin settings projection | — | +| `/api/admin/organizations/[orgId]/verify` | `POST` | platform ADMIN | One route handles `VERIFY / REJECT / SUSPEND / REACTIVATE / DEACTIVATE` (action upper-cased, defaults to `VERIFY`). `REJECT` requires a `reason` and keeps the org `PENDING_VERIFICATION` (stamps `verificationReason` + `verificationRejectedAt` for the resubmit loop). | `VERIFIED` / `VERIFICATION_REJECTED` / `SUSPENDED` / `REACTIVATED` / `DEACTIVATED` (SYSTEM) | ## Members and invitations These routes manage membership records and the invitation lifecycle; the anti-lockout guard and the LEARNER↔EXPERT transition guard both live here. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/members` | `GET` | active member | Paginated list; `?role=` / `?status=` / `?q=` | — | -| `/api/organizations/[orgId]/members` | `POST` | MAINTAINER | Direct add (idempotent on userId). If the userId matches a `REMOVED` membership the row is reactivated, with the same `LEARNER ↔ EXPERT` transition guard applied (`409 ROLE_TRANSITION_BLOCKED` on violation). | `MEMBER_ADDED` / `MEMBER_REACTIVATED` (MEMBER) | -| `/api/organizations/[orgId]/members/bulk` | `GET` `POST` `PUT` `PATCH` `DELETE` | — | **Deterministic `405` stub** (`BULK_REMOVAL_NOT_SUPPORTED`). Bulk member ops are intentionally unsupported in v1 so nothing bypasses the last-OWNER anti-lockout guard; use the single-member PATCH instead. See `deletion-policy`. | — | -| `/api/organizations/[orgId]/members/[memberId]` | `GET` | active member | Full member record | — | -| `/api/organizations/[orgId]/members/[memberId]` | `PATCH` | MAINTAINER | Role / status / departmentLabel / payoutRecipient. Returns `409 ROLE_TRANSITION_BLOCKED` if the caller tries to flip LEARNER ↔ EXPERT (`lib/enterprise/role-transitions.ts`); last-OWNER anti-lockout guard runs in a Serializable tx. | `ROLE_CHANGE` / `STATUS_CHANGE` (MEMBER) | -| `/api/organizations/[orgId]/members/[memberId]` | `DELETE` | MAINTAINER | Set `status = REMOVED` | `MEMBER_REMOVED` (MEMBER) | -| `/api/organizations/[orgId]/invitations` | `GET` | MAINTAINER | Pending + accepted + revoked invites | — | -| `/api/organizations/[orgId]/invitations` | `POST` | MAINTAINER | Send (or resend) invite | `INVITE_SENT` / `INVITE_RESENT` (MEMBER) | -| `/api/organizations/[orgId]/invitations/[invitationId]` | `GET` | MAINTAINER | Invite detail | — | -| `/api/organizations/[orgId]/invitations/[invitationId]` | `DELETE` | MAINTAINER | Revoke | `INVITE_REVOKED` (MEMBER) | +| Path | Verb | Min role | Purpose | Audit actions | +| ------------------------------------------------------- | ----------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| `/api/organizations/[orgId]/members` | `GET` | active member | Paginated list; `?role=` / `?status=` / `?q=` | — | +| `/api/organizations/[orgId]/members` | `POST` | MAINTAINER | Direct add (idempotent on userId). If the userId matches a `REMOVED` membership the row is reactivated, with the same `LEARNER ↔ EXPERT` transition guard applied (`409 ROLE_TRANSITION_BLOCKED` on violation). | `MEMBER_ADDED` / `MEMBER_REACTIVATED` (MEMBER) | +| `/api/organizations/[orgId]/members/bulk` | `GET` `POST` `PUT` `PATCH` `DELETE` | — | **Deterministic `405` stub** (`BULK_REMOVAL_NOT_SUPPORTED`). Bulk member ops are intentionally unsupported in v1 so nothing bypasses the last-OWNER anti-lockout guard; use the single-member PATCH instead. See `deletion-policy`. | — | +| `/api/organizations/[orgId]/members/[memberId]` | `GET` | active member | Full member record | — | +| `/api/organizations/[orgId]/members/[memberId]` | `PATCH` | MAINTAINER | Role / status / departmentLabel / payoutRecipient. Returns `409 ROLE_TRANSITION_BLOCKED` if the caller tries to flip LEARNER ↔ EXPERT (`lib/enterprise/role-transitions.ts`); last-OWNER anti-lockout guard runs in a Serializable tx. | `ROLE_CHANGE` / `STATUS_CHANGE` (MEMBER) | +| `/api/organizations/[orgId]/members/[memberId]` | `DELETE` | MAINTAINER | Set `status = REMOVED` | `MEMBER_REMOVED` (MEMBER) | +| `/api/organizations/[orgId]/invitations` | `GET` | MAINTAINER | Pending + accepted + revoked invites | — | +| `/api/organizations/[orgId]/invitations` | `POST` | MAINTAINER | Send (or resend) invite | `INVITE_SENT` / `INVITE_RESENT` (MEMBER) | +| `/api/organizations/[orgId]/invitations/[invitationId]` | `GET` | MAINTAINER | Invite detail | — | +| `/api/organizations/[orgId]/invitations/[invitationId]` | `DELETE` | MAINTAINER | Revoke | `INVITE_REVOKED` (MEMBER) | ## Contracts These routes cover the contract lifecycle — creation, term edits, the lock state that freezes terms once a contract is in use, and the supersede flow that replaces a contract immutably. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/contracts` | `GET` | MAINTAINER | List with `?status=` filter | — | -| `/api/organizations/[orgId]/contracts` | `POST` | OWNER | Create DRAFT/ACTIVE contract. May atomically mint a flat-fee `BillingSubscription` when the BillingAccount is `fundingSource=LICENSE` (`licenseFeePaise` + `licenseCycle`). `requireActive` — a contract can't bind an unverified org. | `CONTRACT_CREATED` (CONTRACT) | -| `/api/organizations/[orgId]/contracts/[contractId]` | `GET` | MAINTAINER | Contract detail + programs; response carries a derived `locked` flag (term-field lock state). | — | -| `/api/organizations/[orgId]/contracts/[contractId]` | `PATCH` | OWNER | Status transitions + terms. **Term fields** (`effectiveFrom`, `effectiveTo`, `paymentTermsDays`) lock once the contract is signed/billing (`getContractLockState`) → `409 CONTRACT_TERMS_LOCKED`; `autoRenew` stays editable. **`status=TERMINATED`** from ACTIVE is guarded: `409` while any live assignment is in its current cycle, or `409 CONTRACT_HAS_OUTSTANDING_INVOICES` while ISSUED/OVERDUE invoices exist; once allowed, an in-tx cascade flips programs ACTIVE→EXPIRED and their ACTIVE assignments→CLOSED. | `CONTRACT_SIGNED` / `CONTRACT_TERMINATED` / `CONTRACT_EXPIRED` (CONTRACT) | -| `/api/organizations/[orgId]/contracts/[contractId]` | `DELETE` | OWNER | DRAFT-only hard delete (refused if programs attached) | `CONTRACT_TERMINATED` (CONTRACT) | -| `/api/organizations/[orgId]/contracts/[contractId]/supersede` | `POST` | OWNER | #779 §A — contracts are immutable in use; supersede mints a successor with new terms, re-points programs, and retires the old row. `reason` accepts only `AMENDMENT` (cuts over now, old→TERMINATED) or `RENEWAL` (chains off old `effectiveTo`, old→EXPIRED). Invoices keep their old `contractId`; `supersededByContractId @unique` is the double-run backstop. `409` if the contract isn't ACTIVE or is already superseded. | `CONTRACT_SUPERSEDED` (CONTRACT) | +| Path | Verb | Min role | Purpose | Audit actions | +| ------------------------------------------------------------- | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| `/api/organizations/[orgId]/contracts` | `GET` | MAINTAINER | List with `?status=` filter | — | +| `/api/organizations/[orgId]/contracts` | `POST` | OWNER | Create DRAFT/ACTIVE contract. May atomically mint a flat-fee `BillingSubscription` when the BillingAccount is `fundingSource=LICENSE` (`licenseFeePaise` + `licenseCycle`). `requireActive` — a contract can't bind an unverified org. | `CONTRACT_CREATED` (CONTRACT) | +| `/api/organizations/[orgId]/contracts/[contractId]` | `GET` | MAINTAINER | Contract detail + programs; response carries a derived `locked` flag (term-field lock state). | — | +| `/api/organizations/[orgId]/contracts/[contractId]` | `PATCH` | OWNER | Status transitions + terms. **Term fields** (`effectiveFrom`, `effectiveTo`, `paymentTermsDays`) lock once the contract is signed/billing (`getContractLockState`) → `409 CONTRACT_TERMS_LOCKED`; `autoRenew` stays editable. **`status=TERMINATED`** from ACTIVE is guarded: `409` while any live assignment is in its current cycle, or `409 CONTRACT_HAS_OUTSTANDING_INVOICES` while ISSUED/OVERDUE invoices exist; once allowed, an in-tx cascade flips programs ACTIVE→EXPIRED and their ACTIVE assignments→CLOSED. | `CONTRACT_SIGNED` / `CONTRACT_TERMINATED` / `CONTRACT_EXPIRED` (CONTRACT) | +| `/api/organizations/[orgId]/contracts/[contractId]` | `DELETE` | OWNER | DRAFT-only hard delete (refused if programs attached) | `CONTRACT_TERMINATED` (CONTRACT) | +| `/api/organizations/[orgId]/contracts/[contractId]/supersede` | `POST` | OWNER | #779 §A — contracts are immutable in use; supersede mints a successor with new terms, re-points programs, and retires the old row. `reason` accepts only `AMENDMENT` (cuts over now, old→TERMINATED) or `RENEWAL` (chains off old `effectiveTo`, old→EXPIRED). Invoices keep their old `contractId`; `supersededByContractId @unique` is the double-run backstop. `409` if the contract isn't ACTIVE or is already superseded. | `CONTRACT_SUPERSEDED` (CONTRACT) | ## Programs and assignments These routes manage programs and their per-member assignments, including the money-config lock that freezes pricing fields once a program is live. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/programs` | `GET` | active member | List programs for org (canSponsor) | — | -| `/api/organizations/[orgId]/programs` | `POST` | MAINTAINER | Create LICENSED_SEAT or CREDIT_POOL program | `PROGRAM_CREATED` (PROGRAM) | -| `/api/organizations/[orgId]/programs/[programId]` | `GET` | active member | Program detail; response carries a derived `locked` flag (money-config lock state). | — | -| `/api/organizations/[orgId]/programs/[programId]` | `PATCH` | MAINTAINER | Safe-field edits (`name`, `status`, `allowedCategories`) always allowed. **Money fields** (`coveredPlanTypes`, `ratePerSeatPaise`, `coveredEngagementsPerCycle`, `creditBudgetPerCycle`, `overageBehavior`, `overageSurchargeBps`, `priceCapPerEngagementPaise`, `maxOveragePerCyclePaise`) freeze once the program is in use (`getProgramLockState`) → `409 PROGRAM_CONFIG_LOCKED`. **`archived=true`** stamps `archivedAt`, guarded `409 PROGRAM_HAS_ACTIVE_ASSIGNMENTS` if live allocations exist. | `PROGRAM_PAUSED` / `PROGRAM_ARCHIVED` (PROGRAM) | -| `/api/organizations/[orgId]/programs/[programId]` | `DELETE` | MAINTAINER | DRAFT/no-assignment hard delete (Serializable; refused if assignments or historical utilizations exist) | `PROGRAM_DELETED` (PROGRAM) | -| `/api/organizations/[orgId]/programs/[programId]/assignments` | `GET` | active member | List assignments | — | -| `/api/organizations/[orgId]/programs/[programId]/assignments` | `POST` | MAINTAINER | Upsert assignment for a membership | `PROGRAM_ASSIGNED` (PROGRAM) | -| `/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]` | `GET` | active member | Assignment detail + utilizations | — | -| `/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]` | `PATCH` | MAINTAINER | Period / engagementsUsed reconciliation; `status` flip emits unassign | `PROGRAM_ASSIGNMENT_UPDATED` / `PROGRAM_UNASSIGNED` (PROGRAM) | -| `/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]` | `DELETE` | MAINTAINER | Remove assignment | `PROGRAM_UNASSIGNED` (PROGRAM) | +| Path | Verb | Min role | Purpose | Audit actions | +| ---------------------------------------------------------------------------- | -------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `/api/organizations/[orgId]/programs` | `GET` | active member | List programs for org (canSponsor) | — | +| `/api/organizations/[orgId]/programs` | `POST` | MAINTAINER | Create LICENSED_SEAT or CREDIT_POOL program | `PROGRAM_CREATED` (PROGRAM) | +| `/api/organizations/[orgId]/programs/[programId]` | `GET` | active member | Program detail; response carries a derived `locked` flag (money-config lock state). | — | +| `/api/organizations/[orgId]/programs/[programId]` | `PATCH` | MAINTAINER | Safe-field edits (`name`, `status`, `allowedCategories`) always allowed. **Money fields** (`coveredPlanTypes`, `ratePerSeatPaise`, `coveredEngagementsPerCycle`, `creditBudgetPerCycle`, `overageBehavior`, `overageSurchargeBps`, `priceCapPerEngagementPaise`, `maxOveragePerCyclePaise`) freeze once the program is in use (`getProgramLockState`) → `409 PROGRAM_CONFIG_LOCKED`. **`archived=true`** stamps `archivedAt`, guarded `409 PROGRAM_HAS_ACTIVE_ASSIGNMENTS` if live allocations exist. | `PROGRAM_PAUSED` / `PROGRAM_ARCHIVED` (PROGRAM) | +| `/api/organizations/[orgId]/programs/[programId]` | `DELETE` | MAINTAINER | DRAFT/no-assignment hard delete (Serializable; refused if assignments or historical utilizations exist) | `PROGRAM_DELETED` (PROGRAM) | +| `/api/organizations/[orgId]/programs/[programId]/assignments` | `GET` | active member | List assignments | — | +| `/api/organizations/[orgId]/programs/[programId]/assignments` | `POST` | MAINTAINER | Upsert assignment for a membership | `PROGRAM_ASSIGNED` (PROGRAM) | +| `/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]` | `GET` | active member | Assignment detail + utilizations | — | +| `/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]` | `PATCH` | MAINTAINER | Period / engagementsUsed reconciliation; `status` flip emits unassign | `PROGRAM_ASSIGNMENT_UPDATED` / `PROGRAM_UNASSIGNED` (PROGRAM) | +| `/api/organizations/[orgId]/programs/[programId]/assignments/[assignmentId]` | `DELETE` | MAINTAINER | Remove assignment | `PROGRAM_UNASSIGNED` (PROGRAM) | ## Billing account These routes expose the billing account, wallet balance, and top-up flow; the money-mutating ones gate on `requireOrgBillingAdminOrOwner` and are marked 🔒. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/billing` | `GET` | MANAGER | Aggregated billing snapshot for the unified Billing dashboard (month-to-date gross, outstanding, pending charges, paymentTermsDays). DB-side `aggregate({ _sum })`, O(1). | — | -| `/api/organizations/[orgId]/billing-account` | `GET` | MANAGER | Account summary + balance + creditLimit | — | -| `/api/organizations/[orgId]/billing-account` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Change billingEmail / fundingSource (guarded) | `SETTINGS_CHANGED` (SETTINGS) | -| `/api/organizations/[orgId]/billing-account/wallet` | `GET` | MANAGER | Wallet balance + history (the org's WALLET-account `LedgerEntry` rows) | — | -| `/api/organizations/[orgId]/billing-account/wallet/top-ups` | `GET` | MANAGER | List top-ups (pending + confirmed) | — | -| `/api/organizations/[orgId]/billing-account/wallet/top-ups` | `POST` | 🔒 OWNER / BILLING_ADMIN | Mint Razorpay order + a PENDING `WalletTopUp` (keyed by `providerOrderId @unique`); the webhook (`notes.type=wallet_topup`) confirms it idempotently and posts the `TOPUP` txn (`Dr CASH / Cr WALLET`) | `WALLET_TOPUP` (WALLET) — `WALLET_TOPUP_CONFIRMED` is emitted by the webhook | -| `/api/organizations/[orgId]/billing-account/wallet/top-ups/[topUpId]` | `GET` | MANAGER | Top-up detail; `topUpId` is the `WalletTopUp` id | — | +| Path | Verb | Min role | Purpose | Audit actions | +| --------------------------------------------------------------------- | ------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| `/api/organizations/[orgId]/billing` | `GET` | MANAGER | Aggregated billing snapshot for the unified Billing dashboard (month-to-date gross, outstanding, pending charges, paymentTermsDays). DB-side `aggregate({ _sum })`, O(1). | — | +| `/api/organizations/[orgId]/billing-account` | `GET` | MANAGER | Account summary + balance + creditLimit | — | +| `/api/organizations/[orgId]/billing-account` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Change billingEmail / fundingSource (guarded) | `SETTINGS_CHANGED` (SETTINGS) | +| `/api/organizations/[orgId]/billing-account/wallet` | `GET` | MANAGER | Wallet balance + history (the org's WALLET-account `LedgerEntry` rows) | — | +| `/api/organizations/[orgId]/billing-account/wallet/top-ups` | `GET` | MANAGER | List top-ups (pending + confirmed) | — | +| `/api/organizations/[orgId]/billing-account/wallet/top-ups` | `POST` | 🔒 OWNER / BILLING_ADMIN | Mint Razorpay order + a PENDING `WalletTopUp` (keyed by `providerOrderId @unique`); the webhook (`notes.type=wallet_topup`) confirms it idempotently and posts the `TOPUP` txn (`Dr CASH / Cr WALLET`) | `WALLET_TOPUP` (WALLET) — `WALLET_TOPUP_CONFIRMED` is emitted by the webhook | +| `/api/organizations/[orgId]/billing-account/wallet/top-ups/[topUpId]` | `GET` | MANAGER | Top-up detail; `topUpId` is the `WalletTopUp` id | — | ## Invoices and purchase orders These routes cover manual invoices, invoice-payment initiation, PDF rendering, and purchase orders; the webhook completes the ISSUED→PAID flip out of band. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/billing-account/invoices` | `GET` | MANAGER | List with `?status=` filter | — | -| `/api/organizations/[orgId]/billing-account/invoices` | `POST` | 🔒 OWNER / BILLING_ADMIN | Manual invoice. Dashboard composer defaults `dueDate` to NET-60 and posts `issueImmediately: true` so the row lands as `ISSUED` in a single call. | `INVOICE_GENERATED` (INVOICE) | -| `/api/organizations/[orgId]/billing-account/invoices/[invoiceId]` | `GET` | MANAGER | Invoice detail | — | -| `/api/organizations/[orgId]/billing-account/invoices/[invoiceId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Status transitions (DRAFT→ISSUED, DRAFT→CANCELLED, ISSUED/OVERDUE→VOID) | `INVOICE_ISSUED` / `INVOICE_CANCELLED` / `INVOICE_VOIDED` (INVOICE) | -| `/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pay` | `POST` | 🔒 OWNER / BILLING_ADMIN | Mint Razorpay order for the invoice; the webhook (`notes.type=invoice_payment`) flips ISSUED→PAID and posts the `INVOICE_PAID` txn (`Dr CASH / Cr ORG_RECEIVABLE`) | `INVOICE_PAYMENT_INITIATED` (INVOICE) — `INVOICE_PAID` is emitted by the webhook | -| `/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pdf` | `GET` | MANAGER | Rendered invoice PDF | — | -| `/api/organizations/[orgId]/billing-account/purchase-orders` | `GET` | MANAGER | List POs | — | -| `/api/organizations/[orgId]/billing-account/purchase-orders` | `POST` | 🔒 OWNER / BILLING_ADMIN | Create PO | `PURCHASE_ORDER_CREATED` (INVOICE) | -| `/api/organizations/[orgId]/billing-account/purchase-orders/[poId]` | `GET` | MANAGER | PO detail + linked invoices | — | -| `/api/organizations/[orgId]/billing-account/purchase-orders/[poId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Update PO metadata | — | -| `/api/organizations/[orgId]/billing-account/purchase-orders/[poId]` | `DELETE` | 🔒 OWNER / BILLING_ADMIN | Cancel PO (if unused) | — | +| Path | Verb | Min role | Purpose | Audit actions | +| --------------------------------------------------------------------- | -------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| `/api/organizations/[orgId]/billing-account/invoices` | `GET` | MANAGER | List with `?status=` filter | — | +| `/api/organizations/[orgId]/billing-account/invoices` | `POST` | 🔒 OWNER / BILLING_ADMIN | Manual invoice. Dashboard composer defaults `dueDate` to NET-60 and posts `issueImmediately: true` so the row lands as `ISSUED` in a single call. | `INVOICE_GENERATED` (INVOICE) | +| `/api/organizations/[orgId]/billing-account/invoices/[invoiceId]` | `GET` | MANAGER | Invoice detail | — | +| `/api/organizations/[orgId]/billing-account/invoices/[invoiceId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Status transitions (DRAFT→ISSUED, DRAFT→CANCELLED, ISSUED/OVERDUE→VOID) | `INVOICE_ISSUED` / `INVOICE_CANCELLED` / `INVOICE_VOIDED` (INVOICE) | +| `/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pay` | `POST` | 🔒 OWNER / BILLING_ADMIN | Mint Razorpay order for the invoice; the webhook (`notes.type=invoice_payment`) flips ISSUED→PAID and posts the `INVOICE_PAID` txn (`Dr CASH / Cr ORG_RECEIVABLE`) | `INVOICE_PAYMENT_INITIATED` (INVOICE) — `INVOICE_PAID` is emitted by the webhook | +| `/api/organizations/[orgId]/billing-account/invoices/[invoiceId]/pdf` | `GET` | MANAGER | Rendered invoice PDF | — | +| `/api/organizations/[orgId]/billing-account/purchase-orders` | `GET` | MANAGER | List POs | — | +| `/api/organizations/[orgId]/billing-account/purchase-orders` | `POST` | 🔒 OWNER / BILLING_ADMIN | Create PO | `PURCHASE_ORDER_CREATED` (INVOICE) | +| `/api/organizations/[orgId]/billing-account/purchase-orders/[poId]` | `GET` | MANAGER | PO detail + linked invoices | — | +| `/api/organizations/[orgId]/billing-account/purchase-orders/[poId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Update PO metadata | — | +| `/api/organizations/[orgId]/billing-account/purchase-orders/[poId]` | `DELETE` | 🔒 OWNER / BILLING_ADMIN | Cancel PO (if unused) | — | ## Rate cards, earnings, payouts (host side) These are the host-side money routes for organizations that earn — rate cards, the payout account, and the earnings-to-payout rollup; most mutations gate on `requireOrgBillingAdminOrOwner`. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/rate-cards` | `GET` | active member (canHost) | Effective cards + history. Read widened from MANAGER so an EXPERT can confirm their commission split. | — | -| `/api/organizations/[orgId]/rate-cards` | `POST` | 🔒 OWNER / BILLING_ADMIN (canHost) | Create / bump a card (atomic two-step) | `RATE_CARD_BUMPED` (PROGRAM) | -| `/api/organizations/[orgId]/rate-cards/[cardId]` | `GET` | MANAGER (canHost) | Card detail | — | -| `/api/organizations/[orgId]/rate-cards/[cardId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN (canHost) | Close (set `effectiveTo`) | `RATE_CARD_BUMPED` (PROGRAM) | -| `/api/organizations/[orgId]/payout-account` | `GET` | MANAGER | Account summary + last4 | — | -| `/api/organizations/[orgId]/payout-account` | `PUT` | OWNER | Replace account (encrypted at rest) | `SETTINGS_CHANGED` (SETTINGS) | -| `/api/organizations/[orgId]/earnings` | `GET` | MANAGER | List OrganizationEarnings rows | — | -| `/api/organizations/[orgId]/payouts` | `GET` | MANAGER | List OrganizationPayouts | — | -| `/api/organizations/[orgId]/payouts` | `POST` | 🔒 OWNER / BILLING_ADMIN | Roll up earnings → PayoutCycle | `PAYOUT_INITIATED` (PAYOUT) | -| `/api/organizations/[orgId]/payouts/[payoutId]` | `GET` | MANAGER | Payout detail + earnings list | — | -| `/api/organizations/[orgId]/payouts/[payoutId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Flip to PROCESSED / CANCELLED | `PAYOUT_INITIATED` / `PAYOUT_CANCELLED` (PAYOUT) | +| Path | Verb | Min role | Purpose | Audit actions | +| ------------------------------------------------ | ------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `/api/organizations/[orgId]/rate-cards` | `GET` | active member (canHost) | Effective cards + history. Read widened from MANAGER so an EXPERT can confirm their commission split. | — | +| `/api/organizations/[orgId]/rate-cards` | `POST` | 🔒 OWNER / BILLING_ADMIN (canHost) | Create / bump a card (atomic two-step). A card scoped to a `contractId`, `planType` or `planId` is only selected at settlement when `RATE_CARD_SCOPED_RESOLUTION=on` (#1335). With the flag off, settlement forwards only the org id, the membership override and the payment time: a valid membership override wins, then the org default card, and with neither the built-in default (10% platform, 10% org, 80% consultant) applies. | `RATE_CARD_BUMPED` (PROGRAM) | +| `/api/organizations/[orgId]/rate-cards/[cardId]` | `GET` | MANAGER (canHost) | Card detail | — | +| `/api/organizations/[orgId]/rate-cards/[cardId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN (canHost) | Close (set `effectiveTo`) | `RATE_CARD_BUMPED` (PROGRAM) | +| `/api/organizations/[orgId]/payout-account` | `GET` | MANAGER | Account summary + last4 | — | +| `/api/organizations/[orgId]/payout-account` | `PUT` | OWNER | Replace account (encrypted at rest) | `SETTINGS_CHANGED` (SETTINGS) | +| `/api/organizations/[orgId]/earnings` | `GET` | MANAGER | List OrganizationEarnings rows | — | +| `/api/organizations/[orgId]/payouts` | `GET` | MANAGER | List OrganizationPayouts | — | +| `/api/organizations/[orgId]/payouts` | `POST` | 🔒 OWNER / BILLING_ADMIN | Roll up earnings → PayoutCycle | `PAYOUT_INITIATED` (PAYOUT) | +| `/api/organizations/[orgId]/payouts/[payoutId]` | `GET` | MANAGER | Payout detail + earnings list | — | +| `/api/organizations/[orgId]/payouts/[payoutId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Flip to PROCESSED / CANCELLED | `PAYOUT_INITIATED` / `PAYOUT_CANCELLED` (PAYOUT) | ## Reimbursements, disputes, documents (read surfaces) These are read-only roster endpoints — reimbursements, disputes, documents, trials, appointments, and recordings — all gated at MANAGER and none of them emit audit rows. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/reimbursements` | `GET` | MANAGER | Reimbursement roster | — | -| `/api/organizations/[orgId]/reimbursements/export` | `GET` | MANAGER | CSV export of reimbursements | — | -| `/api/organizations/[orgId]/disputes` | `GET` | MANAGER | Dispute roster (org-scoped) | — | -| `/api/organizations/[orgId]/documents` | `GET` | MANAGER | Org document list | — | -| `/api/organizations/[orgId]/trials` | `GET` | MANAGER | Trial roster | — | -| `/api/organizations/[orgId]/appointments` | `GET` | MANAGER | Org appointment feed | — | -| `/api/organizations/[orgId]/recordings` | `GET` | MANAGER | Stream recording roster | — | +| Path | Verb | Min role | Purpose | Audit actions | +| -------------------------------------------------- | ----- | -------- | ---------------------------- | ------------- | +| `/api/organizations/[orgId]/reimbursements` | `GET` | MANAGER | Reimbursement roster | — | +| `/api/organizations/[orgId]/reimbursements/export` | `GET` | MANAGER | CSV export of reimbursements | — | +| `/api/organizations/[orgId]/disputes` | `GET` | MANAGER | Dispute roster (org-scoped) | — | +| `/api/organizations/[orgId]/documents` | `GET` | MANAGER | Org document list | — | +| `/api/organizations/[orgId]/trials` | `GET` | MANAGER | Trial roster | — | +| `/api/organizations/[orgId]/appointments` | `GET` | MANAGER | Org appointment feed | — | +| `/api/organizations/[orgId]/recordings` | `GET` | MANAGER | Stream recording roster | — | ## SSO and domains These routes configure SSO providers, the break-glass escape hatch, and domain claims; the OWNER-only gates reflect that these are IdP-trust roots. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/sso` | `GET` | MANAGER | Settings read | — | -| `/api/organizations/[orgId]/sso` | `PATCH` | OWNER | allowedEmailDomains / enforceSSO / defaultRoleForAutoJoin | `SSO_ENABLED` / `SSO_DISABLED` / `SETTINGS_CHANGED` (SETTINGS) | -| `/api/organizations/[orgId]/sso/providers` | `GET` | MANAGER | List providers | — | -| `/api/organizations/[orgId]/sso/providers` | `POST` | OWNER | Add SAML/OIDC provider | `SSO_ENABLED` (SETTINGS) | -| `/api/organizations/[orgId]/sso/providers/[providerId]` | `GET` | MANAGER | Provider detail. Response includes a derived `providerType` of `SAML` or `OIDC`, inferred from whether `samlConfig` or `oidcConfig` is populated on the row. | — | -| `/api/organizations/[orgId]/sso/providers/[providerId]` | `DELETE` | OWNER | Remove provider | `SSO_DISABLED` (SETTINGS) | -| `/api/organizations/[orgId]/sso/break-glass` | `POST` | OWNER | #779 §E — open a time-boxed IdP-outage escape hatch (password login re-allowed while `breakGlassUntil > now`). `hours` 1–72 (default 4), `reason` required (min 5 chars). `404` if SSO isn't enforced. Who/why lives in the audit row, not on columns. | `SETTINGS_CHANGED` (SETTINGS) | -| `/api/organizations/[orgId]/sso/break-glass` | `DELETE` | OWNER | Close the window early (clears `breakGlassUntil`). `404` if SSO isn't enforced. | `SETTINGS_CHANGED` (SETTINGS) | -| `/api/organizations/[orgId]/domain-claims` | `GET` | MANAGER | List claims | — | -| `/api/organizations/[orgId]/domain-claims` | `POST` | OWNER | Claim domain | `DOMAIN_CLAIMED` (SETTINGS) | -| `/api/organizations/[orgId]/domain-claims/[domain]` | `DELETE` | OWNER | Release claim | `DOMAIN_RELEASED` (SETTINGS) | -| `/api/organizations/[orgId]/domain-claims/[domain]/verify` | `POST` | OWNER | Verify the DNS TXT record at `_familiarise-verify.` matches the claim's `verificationToken`; flips `verifiedAt` NULL→now() and unlocks domain-based SSO auto-join. | `DOMAIN_VERIFIED` (SETTINGS) | +| Path | Verb | Min role | Purpose | Audit actions | +| ---------------------------------------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- | +| `/api/organizations/[orgId]/sso` | `GET` | MANAGER | Settings read | — | +| `/api/organizations/[orgId]/sso` | `PATCH` | OWNER | allowedEmailDomains / enforceSSO / defaultRoleForAutoJoin | `SSO_ENABLED` / `SSO_DISABLED` / `SETTINGS_CHANGED` (SETTINGS) | +| `/api/organizations/[orgId]/sso/providers` | `GET` | MANAGER | List providers | — | +| `/api/organizations/[orgId]/sso/providers` | `POST` | OWNER | Add SAML/OIDC provider | `SSO_ENABLED` (SETTINGS) | +| `/api/organizations/[orgId]/sso/providers/[providerId]` | `GET` | MANAGER | Provider detail. Response includes a derived `providerType` of `SAML` or `OIDC`, inferred from whether `samlConfig` or `oidcConfig` is populated on the row. | — | +| `/api/organizations/[orgId]/sso/providers/[providerId]` | `DELETE` | OWNER | Remove provider | `SSO_DISABLED` (SETTINGS) | +| `/api/organizations/[orgId]/sso/break-glass` | `POST` | OWNER | #779 §E — open a time-boxed IdP-outage escape hatch (password login re-allowed while `breakGlassUntil > now`). `hours` 1–72 (default 4), `reason` required (min 5 chars). `404` if SSO isn't enforced. Who/why lives in the audit row, not on columns. | `SETTINGS_CHANGED` (SETTINGS) | +| `/api/organizations/[orgId]/sso/break-glass` | `DELETE` | OWNER | Close the window early (clears `breakGlassUntil`). `404` if SSO isn't enforced. | `SETTINGS_CHANGED` (SETTINGS) | +| `/api/organizations/[orgId]/domain-claims` | `GET` | MANAGER | List claims | — | +| `/api/organizations/[orgId]/domain-claims` | `POST` | OWNER | Claim domain | `DOMAIN_CLAIMED` (SETTINGS) | +| `/api/organizations/[orgId]/domain-claims/[domain]` | `DELETE` | OWNER | Release claim | `DOMAIN_RELEASED` (SETTINGS) | +| `/api/organizations/[orgId]/domain-claims/[domain]/verify` | `POST` | OWNER | Verify the DNS TXT record at `_familiarise-verify.` matches the claim's `verificationToken`; flips `verifiedAt` NULL→now() and unlocks domain-based SSO auto-join. | `DOMAIN_VERIFIED` (SETTINGS) | ## SCIM provisioning @@ -230,14 +230,14 @@ SCIM **runtime** (the IdP-facing `/scim/v2/...` push surface) lives outside this table; these are the org-admin **configuration** routes. All are OWNER-only — provisioning tokens and group mappings are IdP-trust roots. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/scim/tokens` | `GET` | OWNER | List provisioning tokens (secrets masked) | — | -| `/api/organizations/[orgId]/scim/tokens` | `POST` | OWNER | Mint a SCIM bearer token (returned once) | `SCIM_TOKEN_CREATED` (SYSTEM) | -| `/api/organizations/[orgId]/scim/tokens/[tokenId]` | `DELETE` | OWNER | Revoke a token | `SCIM_TOKEN_REVOKED` (SYSTEM) | -| `/api/organizations/[orgId]/scim/group-mappings` | `GET` | OWNER | List IdP-group → role mappings | — | -| `/api/organizations/[orgId]/scim/group-mappings` | `POST` | OWNER | Map an IdP group to a `MemberRole` | `SCIM_GROUP_MAPPED` (SYSTEM) | -| `/api/organizations/[orgId]/scim/group-mappings/[mappingId]` | `DELETE` | OWNER | Remove a group mapping | `SCIM_GROUP_UNMAPPED` (SYSTEM) | +| Path | Verb | Min role | Purpose | Audit actions | +| ------------------------------------------------------------ | -------- | -------- | ----------------------------------------- | ------------------------------ | +| `/api/organizations/[orgId]/scim/tokens` | `GET` | OWNER | List provisioning tokens (secrets masked) | — | +| `/api/organizations/[orgId]/scim/tokens` | `POST` | OWNER | Mint a SCIM bearer token (returned once) | `SCIM_TOKEN_CREATED` (SYSTEM) | +| `/api/organizations/[orgId]/scim/tokens/[tokenId]` | `DELETE` | OWNER | Revoke a token | `SCIM_TOKEN_REVOKED` (SYSTEM) | +| `/api/organizations/[orgId]/scim/group-mappings` | `GET` | OWNER | List IdP-group → role mappings | — | +| `/api/organizations/[orgId]/scim/group-mappings` | `POST` | OWNER | Map an IdP group to a `MemberRole` | `SCIM_GROUP_MAPPED` (SYSTEM) | +| `/api/organizations/[orgId]/scim/group-mappings/[mappingId]` | `DELETE` | OWNER | Remove a group mapping | `SCIM_GROUP_UNMAPPED` (SYSTEM) | ## Outbound webhooks @@ -245,51 +245,51 @@ OWNER-only — provisioning tokens and group mappings are IdP-trust roots. **secret rotation and endpoint deletion are OWNER-only** (highest-trust operations from the integrator's POV). Read surfaces are MANAGER. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/webhooks` | `GET` | MANAGER | List endpoints | — | -| `/api/organizations/[orgId]/webhooks` | `POST` | 🔒 OWNER / BILLING_ADMIN | Create endpoint (returns secret once) | `WEBHOOK_ENDPOINT_CREATED` (WEBHOOK) | -| `/api/organizations/[orgId]/webhooks/[endpointId]` | `GET` | MANAGER | Endpoint detail | — | -| `/api/organizations/[orgId]/webhooks/[endpointId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Update / pause / resume | `WEBHOOK_ENDPOINT_PAUSED` / `WEBHOOK_ENDPOINT_RESUMED` / `WEBHOOK_ENDPOINT_UPDATED` (WEBHOOK) | -| `/api/organizations/[orgId]/webhooks/[endpointId]` | `DELETE` | OWNER | Remove endpoint | `WEBHOOK_ENDPOINT_DELETED` (WEBHOOK) | -| `/api/organizations/[orgId]/webhooks/[endpointId]/rotate-secret` | `POST` | OWNER | Mint a fresh 32-byte secret (returned once); stashes the prior secret + stamps `secretRotatedAt` so the worker dual-signs for a 24h grace window. Rate-limited (`orgWebhookLimiter`). BILLING_ADMIN can pause/disable but **not** rotate. | `WEBHOOK_SECRET_ROTATED` (WEBHOOK) | -| `/api/organizations/[orgId]/webhooks/[endpointId]/deliveries` | `GET` | MANAGER | Delivery attempt history | — | -| `/api/organizations/[orgId]/webhooks/[endpointId]/deliveries/[deliveryId]/redeliver` | `POST` | 🔒 OWNER / BILLING_ADMIN | Re-enqueue a single delivery | `WEBHOOK_DELIVERY_REDELIVERED` (WEBHOOK) | +| Path | Verb | Min role | Purpose | Audit actions | +| ------------------------------------------------------------------------------------ | -------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `/api/organizations/[orgId]/webhooks` | `GET` | MANAGER | List endpoints | — | +| `/api/organizations/[orgId]/webhooks` | `POST` | 🔒 OWNER / BILLING_ADMIN | Create endpoint (returns secret once) | `WEBHOOK_ENDPOINT_CREATED` (WEBHOOK) | +| `/api/organizations/[orgId]/webhooks/[endpointId]` | `GET` | MANAGER | Endpoint detail | — | +| `/api/organizations/[orgId]/webhooks/[endpointId]` | `PATCH` | 🔒 OWNER / BILLING_ADMIN | Update / pause / resume | `WEBHOOK_ENDPOINT_PAUSED` / `WEBHOOK_ENDPOINT_RESUMED` / `WEBHOOK_ENDPOINT_UPDATED` (WEBHOOK) | +| `/api/organizations/[orgId]/webhooks/[endpointId]` | `DELETE` | OWNER | Remove endpoint | `WEBHOOK_ENDPOINT_DELETED` (WEBHOOK) | +| `/api/organizations/[orgId]/webhooks/[endpointId]/rotate-secret` | `POST` | OWNER | Mint a fresh 32-byte secret (returned once); stashes the prior secret + stamps `secretRotatedAt` so the worker dual-signs for a 24h grace window. Rate-limited (`orgWebhookLimiter`). BILLING_ADMIN can pause/disable but **not** rotate. | `WEBHOOK_SECRET_ROTATED` (WEBHOOK) | +| `/api/organizations/[orgId]/webhooks/[endpointId]/deliveries` | `GET` | MANAGER | Delivery attempt history | — | +| `/api/organizations/[orgId]/webhooks/[endpointId]/deliveries/[deliveryId]/redeliver` | `POST` | 🔒 OWNER / BILLING_ADMIN | Re-enqueue a single delivery | `WEBHOOK_DELIVERY_REDELIVERED` (WEBHOOK) | ## Compliance: verification, consent, data exports Routes in this group implement DPDP §11 (data access bundles), §12 (consent grant/withdrawal), and the self-serve verification resubmit loop; the minimum role column reflects that financial PII in export bundles demands the same governance floor as billing mutations. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/verification/resubmit` | `POST` | MAINTAINER | #779 §A — self-serve resubmit after an admin REJECT. Bumps `verificationSubmittedAt`, clears `verificationReason` + `verificationRejectedAt`. `409 NOTHING_TO_RESUBMIT` unless the org is `PENDING_VERIFICATION` with a non-null rejection stamp. | `VERIFICATION_RESUBMITTED` (SYSTEM) | -| `/api/organizations/[orgId]/consent` | `GET` | MANAGER | ConsentArtifact roster (org-scoped via member relation); `?userId=` / `?active=` / `?limit=` | — | -| `/api/organizations/[orgId]/consent` | `POST` | MANAGER | Record a grant. `userId` is validated as a non-empty string (1–128 chars) because `User.id` is a `cuid()`, not a UUID. Writes a SHA-256-hashed `ConsentArtifact`. | `CONSENT_GRANTED` (CONSENT) | -| `/api/organizations/[orgId]/consent` | `DELETE` | MANAGER | Withdraw (DPDP §12) — stamps `withdrawnAt` on the user's active artifacts. `?userId=` required; `?purposeCode=` scopes the withdrawal (omit to withdraw all). Withdrawal is irreversible; a re-grant is a fresh artifact. | `CONSENT_WITHDRAWN` (CONSENT) | -| `/api/organizations/[orgId]/data-exports` | `GET` | 🔒 OWNER / BILLING_ADMIN | List export jobs (last 30 days). Rows are `OrgDataExportJob`. | — | -| `/api/organizations/[orgId]/data-exports` | `POST` | 🔒 OWNER / BILLING_ADMIN | Request a DPDP §11 export bundle (async worker). **Rate-limited 1/24h** (`orgDataExportLimiter`); bundles carry financial PII, hence the billing-governance floor. Returns `202`. | `DATA_EXPORT_REQUESTED` (SYSTEM) | -| `/api/organizations/[orgId]/data-exports/[exportId]/download` | `GET` | 🔒 OWNER / BILLING_ADMIN | Signed-URL download when `status=READY` and unexpired. `409 EXPORT_NOT_READY`, `410 EXPORT_EXPIRED`. | `DATA_EXPORT_DOWNLOADED` (SYSTEM) | +| Path | Verb | Min role | Purpose | Audit actions | +| ------------------------------------------------------------- | -------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `/api/organizations/[orgId]/verification/resubmit` | `POST` | MAINTAINER | #779 §A — self-serve resubmit after an admin REJECT. Bumps `verificationSubmittedAt`, clears `verificationReason` + `verificationRejectedAt`. `409 NOTHING_TO_RESUBMIT` unless the org is `PENDING_VERIFICATION` with a non-null rejection stamp. | `VERIFICATION_RESUBMITTED` (SYSTEM) | +| `/api/organizations/[orgId]/consent` | `GET` | MANAGER | ConsentArtifact roster (org-scoped via member relation); `?userId=` / `?active=` / `?limit=` | — | +| `/api/organizations/[orgId]/consent` | `POST` | MANAGER | Record a grant. `userId` is validated as a non-empty string (1–128 chars) because `User.id` is a `cuid()`, not a UUID. Writes a SHA-256-hashed `ConsentArtifact`. | `CONSENT_GRANTED` (CONSENT) | +| `/api/organizations/[orgId]/consent` | `DELETE` | MANAGER | Withdraw (DPDP §12) — stamps `withdrawnAt` on the user's active artifacts. `?userId=` required; `?purposeCode=` scopes the withdrawal (omit to withdraw all). Withdrawal is irreversible; a re-grant is a fresh artifact. | `CONSENT_WITHDRAWN` (CONSENT) | +| `/api/organizations/[orgId]/data-exports` | `GET` | 🔒 OWNER / BILLING_ADMIN | List export jobs (last 30 days). Rows are `OrgDataExportJob`. | — | +| `/api/organizations/[orgId]/data-exports` | `POST` | 🔒 OWNER / BILLING_ADMIN | Request a DPDP §11 export bundle (async worker). **Rate-limited 1/24h** (`orgDataExportLimiter`); bundles carry financial PII, hence the billing-governance floor. Returns `202`. | `DATA_EXPORT_REQUESTED` (SYSTEM) | +| `/api/organizations/[orgId]/data-exports/[exportId]/download` | `GET` | 🔒 OWNER / BILLING_ADMIN | Signed-URL download when `status=READY` and unexpired. `409 EXPORT_NOT_READY`, `410 EXPORT_EXPIRED`. | `DATA_EXPORT_DOWNLOADED` (SYSTEM) | ## Checkout, analytics, activity, audit These routes cover the advisory overage preview, analytics rollups, and the two audit-log read surfaces (the narrower `/activity` feed and the wider `/audit` read plus its auditable CSV export). -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/checkout/overage-preview` | `GET` | active member | #777 §C — advisory pre-checkout overage preview ("will booking this plan breach my cap, and what does it cost?"). Price is read **server-side** from the plan list price (`?planType=` / `?planId=` / `?sessions=`); a safe over-estimate vs the authoritative checkout charge. | — | -| `/api/organizations/[orgId]/analytics` | `GET` | MANAGER | Rollups (bookings, revenue, earnings, wallet burn) | — | -| `/api/organizations/[orgId]/activity` | `GET` | MANAGER | OrgAuditLog feed (filterable by `?category=` / `?action=` / date) | — | -| `/api/organizations/[orgId]/audit` | `GET` | active member | Audit-log read (paginated; `?actions[]=` / `?category=` / date). Wider read than `/activity`. | — | -| `/api/organizations/[orgId]/audit/export` | `GET` | MAINTAINER | CSV export of the audit trail (`?actions[]=` / date). The export is itself auditable. | `AUDIT_LOG_EXPORTED` (SETTINGS) | +| Path | Verb | Min role | Purpose | Audit actions | +| ----------------------------------------------------- | ----- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| `/api/organizations/[orgId]/checkout/overage-preview` | `GET` | active member | #777 §C — advisory pre-checkout overage preview ("will booking this plan breach my cap, and what does it cost?"). Price is read **server-side** from the plan list price (`?planType=` / `?planId=` / `?sessions=`); a safe over-estimate vs the authoritative checkout charge. | — | +| `/api/organizations/[orgId]/analytics` | `GET` | MANAGER | Rollups (bookings, revenue, earnings, wallet burn) | — | +| `/api/organizations/[orgId]/activity` | `GET` | MANAGER | OrgAuditLog feed (filterable by `?category=` / `?action=` / date) | — | +| `/api/organizations/[orgId]/audit` | `GET` | active member | Audit-log read (paginated; `?actions[]=` / `?category=` / date). Wider read than `/activity`. | — | +| `/api/organizations/[orgId]/audit/export` | `GET` | MAINTAINER | CSV export of the audit trail (`?actions[]=` / date). The export is itself auditable. | `AUDIT_LOG_EXPORTED` (SETTINGS) | ## Stream (chat / video) These two routes expose the org's Stream chat and video metadata; the call/recording export is auditable because it is a compliance pull. -| Path | Verb | Min role | Purpose | Audit actions | -|------|------|----------|---------|----------------| -| `/api/organizations/[orgId]/stream/channels` | `GET` | MANAGER | Stream chat channel roster (metadata only; message bodies are never fetched, per ADR 20) | `STREAM_CHANNELS_EXPORTED` (SYSTEM) | -| `/api/organizations/[orgId]/stream/calls` | `GET` | MANAGER | Call/recording metadata export (compliance pull) | `STREAM_CALLS_EXPORTED` (SYSTEM) | +| Path | Verb | Min role | Purpose | Audit actions | +| -------------------------------------------- | ----- | -------- | ---------------------------------------------------------------------------------------- | ----------------------------------- | +| `/api/organizations/[orgId]/stream/channels` | `GET` | MANAGER | Stream chat channel roster (metadata only; message bodies are never fetched, per ADR 20) | `STREAM_CHANNELS_EXPORTED` (SYSTEM) | +| `/api/organizations/[orgId]/stream/calls` | `GET` | MANAGER | Call/recording metadata export (compliance pull) | `STREAM_CALLS_EXPORTED` (SYSTEM) | ## Retired audit actions diff --git a/docs/enterprise/50-operations/07-required-secrets.md b/docs/enterprise/50-operations/07-required-secrets.md index 5de3957f6..e6d342b21 100644 --- a/docs/enterprise/50-operations/07-required-secrets.md +++ b/docs/enterprise/50-operations/07-required-secrets.md @@ -64,6 +64,7 @@ real value. | `SUPABASE_SERVICE_ROLE_KEY` | 4 workflows | Storage and admin operations in document and recording jobs. | | `NEXT_PUBLIC_SUPABASE_URL` | 6 workflows | Supabase project URL. | | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | 4 workflows | The public Supabase key. It is required by every job that reaches `lib/supabase.ts`, including the ones that only ever use the admin client, because that module constructs the public client first and throws at module scope when the key is unset. Until #1270 no workflow referenced it at all, so `mark-expired-recordings`, `cleanup-old-stream-recordings`, `transfer-expiring-recordings` and `purge-deleted-documents` all died during import on every run they have ever had. The secret itself has been provisioned since 2025-12-09; only the workflow references were missing. | +| `PAN_ENCRYPTION_KEY` | `tds-return-draft` | Hex AES-256-GCM key that decrypts `ConsultantTaxInfo.panEncrypted` and `OrganizationTaxInfo.panEncrypted`. Only the quarterly TDS return export needs it, and only to write the full PAN into the private return CSV. Without it every PAN cell in that CSV is blank and the CA cannot file the quarter; the job still succeeds, so the failure is silent. | | `RESEND_API_KEY` | 4 workflows | Transactional email delivery. | | `STREAM_API_KEY`, `STREAM_API_SECRET`, `NEXT_PUBLIC_STREAM_API_KEY` | Stream jobs | Stream.io video and chat administration. | | `SENTRY_DSN` | all 56 scheduled workflows + `cron-heartbeat` | Fallback alert sink in `scripts/ci/notify-ops-failure.sh`. Because `SLACK_OPS_WEBHOOK_URL` has never been provisioned, this is currently the _only_ channel by which a money-cron _failure_ reaches anyone. It is also the value each scheduled workflow falls back to when exporting `NEXT_PUBLIC_SENTRY_DSN` (see the next row). | @@ -85,12 +86,14 @@ real value. Every row here is currently silently degraded in production. These should be provisioned before launch, in roughly this order. -| Secret | Consumers | What is broken without it | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SLACK_OPS_WEBHOOK_URL` | all 56 scheduled workflows | No money-cron failure is paged anywhere. `scripts/ci/notify-ops-failure.sh` now falls back to Sentry when `SENTRY_DSN` is present, so this is no longer a total blackout, but Slack remains the intended primary channel. | -| `NOVU_SECRET_KEY` | `dunning`, `generate-subscription-invoices`, `wallet-low-balance`, `timeout-member-overages`, `detect-consultant-no-shows`, `send-appointment-reminders`, `transfer-expiring-recordings` | Money notifications never send. Customers are not told that an invoice is overdue, that a wallet is low, or that an overage was charged, while the underlying money state changes anyway. | -| `NEXT_PUBLIC_APP_URL` | `databreach-deadline-alerts`, `detect-consultant-no-shows`, `msme-payment-alerts`, `send-appointment-reminders` | Links inside outbound emails are built against an empty origin, so recipients receive broken URLs. | -| `MSME_ALERT_EMAIL` | `msme-payment-alerts` | The MSME 45-day payment-deadline alert has no recipient, so a statutory deadline under Section 43B(h) can pass unnoticed. | +| Secret | Consumers | What is broken without it | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SLACK_OPS_WEBHOOK_URL` | all 56 scheduled workflows | No money-cron failure is paged anywhere. `scripts/ci/notify-ops-failure.sh` now falls back to Sentry when `SENTRY_DSN` is present, so this is no longer a total blackout, but Slack remains the intended primary channel. | +| `NOVU_SECRET_KEY` | `dunning`, `generate-subscription-invoices`, `wallet-low-balance`, `timeout-member-overages`, `detect-consultant-no-shows`, `send-appointment-reminders`, `transfer-expiring-recordings` | Money notifications never send. Customers are not told that an invoice is overdue, that a wallet is low, or that an overage was charged, while the underlying money state changes anyway. | +| `NEXT_PUBLIC_APP_URL` | `databreach-deadline-alerts`, `detect-consultant-no-shows`, `msme-payment-alerts`, `send-appointment-reminders` | Links inside outbound emails are built against an empty origin, so recipients receive broken URLs. | +| `MSME_ALERT_EMAIL` | `msme-payment-alerts` | The MSME 45-day payment-deadline alert has no recipient, so a statutory deadline under Section 43B(h) can pass unnoticed. | +| `PLATFORM_GSTIN` | `gst-outward-register-export`, the invoice and credit-note download routes | The platform cannot identify itself as the supplier, so it issues no tax invoice at all. `getPlatformSupplier()` fails closed by design, which means every consumer download returns a 503 and the monthly register healer mints nothing. | +| `SUPPLIER_STATE_CODE` | `gst-outward-register-export`, the checkout tax engine | A fallback only: consumer invoicing reads the supplier's state from the first two digits of `PLATFORM_GSTIN` and uses this variable only when the GSTIN carries none. Setting it to a state the GSTIN contradicts stops consumer invoicing altogether, because the mint fails closed rather than guess. | ### Missing — live payouts @@ -120,6 +123,7 @@ that turning the flag on does not become an archaeology exercise. | `LOAD_TEST_EMAIL`, `LOAD_TEST_PASSWORD` | `load-test` | The manual k6 load test cannot authenticate. The workflow is `workflow_dispatch`-only, so this never affects scheduled runs. | | `PLATFORM_LUT_NUMBER`, `PLATFORM_LUT_VALID_TILL` | `lib/compliance/lut.ts` (checkout + invoicing tax gates) | Absent or expired means international supplies are charged 18% IGST instead of zero-rated (#1230). That is the fail-closed default and is legally safe — it just costs foreign customers more until finance files Form RFD-11 for the current FY and sets both values. `VALID_TILL` is the inclusive ISO date, normally the FY end (`YYYY-03-31`). | | `SENTRY_AUTH_TOKEN` (GSTR-8 workflow) | `gstr8-draft-export` | The job still runs and prints the draft, but its empty-period warning never reaches Sentry — an unattended silent zero. | +| `PLATFORM_INVOICE_PREFIX` | `gst-outward-register-export` | The consumer invoice and credit-note series falls back to the `FAM` prefix, so documents are still issued on a gapless statutory series. Set this only if finance wants a different series name; it is a naming preference, not a launch blocker. | ## The second surface: Netlify runtime environment @@ -144,13 +148,20 @@ it. The consequence is narrow but worth stating plainly: gateway webhooks cannot be exercised on a preview, so refund, dispute and payout events can only be tested against production or locally. -| Variable | Read by | What is broken where it is missing | -| ---------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RAZORPAY_ALLOW_TEST_KEYS_IN_PRODUCTION` | `lib/payments/core/razorpay.ts` at module load | Optional pre-launch opt-out. When it is exactly `true`, a Razorpay TEST key under the production posture logs a loud error instead of throwing, so checkout, refunds and cancellation previews boot against test mode. Delete it and set LIVE keys before signup opens. | -| `RAZORPAY_WEBHOOK_SECRET` | `app/api/webhooks/razorpay/route.ts` | Every Razorpay webhook is rejected with a 500 before signature verification. Confirmed missing on Deploy Preview; **production has not been confirmed either way and should be**, because no live payment has ever exercised it. | -| `RAZORPAYX_WEBHOOK_SECRET` | same route, payout-signature fallback | Payout webhooks signed with the X secret fail verification. Not yet provisioned anywhere; live payouts remain gated. | -| `STRIPE_WEBHOOK_SECRET` | `app/api/webhooks/stripe/route.ts` | Same failure shape on the Stripe route. | -| `STREAM_WEBHOOK_SECRET` | `app/api/stream/webhooks/route.ts` | Same failure shape on the Stream route. | +| Variable | Read by | What is broken where it is missing | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CRON_SECRET` | `netlify/functions/cron-tick.mts`, plus every `app/api/cleanup/*` route it calls | The ticker (ADR 27, #1356) cannot authenticate to any `/api/cleanup/*` route, so it returns 500 without ever POSTing a target, and the ten money sweeps it drives fall back to GitHub Actions' measured ~100-minute cadence (ADR 22). This is the same secret already required for the GitHub Actions manifest above; it must additionally be set in the **Netlify production context**, which is a separate store this row exists to call out. | +| `CRON_TICK_BASE_URL` | `netlify/functions/cron-tick.mts` | Optional. Netlify sets `URL` to the site's primary deploy URL in every context, and the ticker POSTs against that by default. Set this only to override where the ticker sends its requests — for example, running `netlify dev` locally against a `next dev` instance on a different port. | +| `RAZORPAY_ALLOW_TEST_KEYS_IN_PRODUCTION` | `lib/payments/core/razorpay.ts` at module load | Optional pre-launch opt-out. When it is exactly `true`, a Razorpay TEST key under the production posture logs a loud error instead of throwing, so checkout, refunds and cancellation previews boot against test mode. Delete it and set LIVE keys before signup opens. | +| `RAZORPAY_WEBHOOK_SECRET` | `app/api/webhooks/razorpay/route.ts` | Every Razorpay webhook is rejected with a 500 before signature verification. Confirmed missing on Deploy Preview; **production has not been confirmed either way and should be**, because no live payment has ever exercised it. | +| `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` | `app/api/webhooks/razorpay/route.ts`, alongside the current secret (#1451) | Optional, and empty is the correct steady state. It holds the previous webhook signing secret for the duration of a rotation, during which the route accepts a signature made with either value; without it, every delivery that Razorpay signed before the dashboard finished switching over is rejected as a forgery and the money event behind it is lost. Set it to the outgoing secret immediately before rotating, and delete it once the dashboard shows the new secret and no unverified deliveries remain. | +| `RAZORPAYX_WEBHOOK_SECRET` | same route, payout-signature fallback | Payout webhooks signed with the X secret fail verification. Not yet provisioned anywhere; live payouts remain gated. | +| `STRIPE_WEBHOOK_SECRET` | `app/api/webhooks/stripe/route.ts` | Same failure shape on the Stripe route. | +| `STRIPE_ENABLED` | `assertGatewayUsable` in `lib/payments/validation/gateway-guards.ts`, read at call time | Optional, and absent is the intended default (#1351). Stripe is a contingency rail rather than a live payment method, so without the exact value `true` any attempt to route a checkout to Stripe or to mint a Stripe payment intent throws a `DisabledGatewayError`. Refunds of payments already taken on Stripe stay outside the fence and keep working, and so do mock payments: `createPaymentIntent` returns a mock intent before it reaches the guard, so a deployment running with `ENABLE_MOCK_PAYMENTS=true` can still exercise a Stripe-labelled Mock Pay flow with the fence closed. | +| `NEXT_PUBLIC_STRIPE_ENABLED` | `paymentGateways` in `app/checkout/plans/utils.ts`, inlined into the client bundle at build time | Optional, and absent is the intended default (#1351). The Stripe card on the four checkout pages renders a disabled "Coming Soon" button and no `StripeCheckout` mounts. Because the value is inlined at build time, turning Stripe on requires a redeploy and not just an environment change, and because it ships to the browser it is not a security control on its own. | +| `STRIPE_ALLOW_TEST_KEYS_IN_PRODUCTION` | `initializeStripeClient` in `lib/payments/core/stripe.ts`, when the client is lazily constructed | Optional pre-launch opt-out, the Stripe twin of `RAZORPAY_ALLOW_TEST_KEYS_IN_PRODUCTION`. When it is exactly `true`, a test-mode secret key under the production posture logs a loud error instead of throwing `STRIPE_TEST_KEY_IN_PRODUCTION`. The guard treats both prefixes as test mode, the standard `sk_test_…` and the restricted `rk_test_…` that Stripe recommends for server-side use. Delete it and set a LIVE key before Stripe is ever enabled for customers. | +| `STREAM_WEBHOOK_SECRET` | `app/api/stream/webhooks/route.ts` | Same failure shape on the Stream route. | +| `RATE_CARD_SCOPED_RESOLUTION` | `lib/api/organizations/rate-card.ts` (`isScopedRateCardResolutionEnabled`), consumed by `resolveOrgSplit` in `lib/payments/payouts/earnings-service.ts`; also read by `.github/workflows/sync-payment-earnings.yml` as the GitHub repository variable `vars.RATE_CARD_SCOPED_RESOLUTION` | Absent means off, which is the intended default (#1335). Off, settlement forwards only org scope, so a contract- or plan-scoped `RateCard` an org has created can never be selected and its bookings settle on the org default instead. To enable, set the exact value `on` in BOTH stores: the Netlify environment (the capture webhook) and the GitHub repository variable (the earnings healer). Any other value, including `true`, leaves it off. Not a credential; it is listed because the two surfaces read the same money code, and a value present in only one store would settle the same booking two different ways depending on which surface accrued it. | Confirming production is a two-minute check and it has never been done, so it is worth doing before the first live payment rather than after: open the Netlify diff --git a/docs/enterprise/70-design-decisions/00-README.md b/docs/enterprise/70-design-decisions/00-README.md index dd5258f47..2491862ee 100644 --- a/docs/enterprise/70-design-decisions/00-README.md +++ b/docs/enterprise/70-design-decisions/00-README.md @@ -8,7 +8,7 @@ last-reviewed: 2026-06-15 # Design decisions (ADRs) — band index -This band collects the architecture decision records for the enterprise layer. Where the other bands document *what* the system does and *how* it does it, each ADR here records *why* one design was chosen over its alternatives, at the moment the choice was made. Read these before proposing a structural change: most "why don't we just…" questions are answered by an ADR, and a change that reverses one should say so explicitly in its PR description. +This band collects the architecture decision records for the enterprise layer. Where the other bands document _what_ the system does and _how_ it does it, each ADR here records _why_ one design was chosen over its alternatives, at the moment the choice was made. Read these before proposing a structural change: most "why don't we just…" questions are answered by an ADR, and a change that reverses one should say so explicitly in its PR description. ## Format @@ -21,32 +21,35 @@ Every ADR follows the same four-part shape, written in full sentences: ## Index -All twenty-five ADRs below are written and live (#793 wrote the first twelve; #872 added 15–17; #971 added 18; the dashboard consolidation added 19–20; the money-productionization pass added 21–22; #1051 added 23; the offering content model added 24; and #705 added 25); this index is the authoritative list. Each row links to its record. - -| # | ADR | Decision in one line | -|---|---|---| -| 01 | [Double-entry journal over three logs](01-double-entry-over-three-logs.md) | One balanced `LedgerTransaction`/`LedgerEntry` journal replaced `FundingLedgerEntry`, `WalletEntry`, and `SettlementLedgerEntry` (#772). | -| 02 | [Integer paise and basis points](02-integer-paise-and-basis-points.md) | All money is integer paise and all splits are integer basis points, so no float ever touches a balance. | -| 03 | [Deterministic ledger-account IDs](03-deterministic-ledger-account-ids.md) | Ledger accounts use deterministic composite IDs (kind|org|consultant|currency) instead of UUIDs (#783). | -| 04 | [Batch payouts over streaming](04-batch-payouts-over-streaming.md) | Earnings settle in periodic idempotent batches rather than per-earning transfers. | -| 05 | [GitHub Actions crons](05-github-actions-crons.md) | Scheduled jobs run as GitHub Actions invoking `npx tsx jobs/**` directly (with `CRON_SECRET`-gated routes as a manual fallback) rather than Netlify scheduled functions. | -| 06 | [Typed Membership over BetterAuth Member](06-typed-membership-over-betterauth-member.md) | Every permission gate reads the typed `Membership` row, never BetterAuth's own member table. | -| 07 | [Upstash rate limiting](07-upstash-rate-limiting.md) | BetterAuth's built-in limiter stays off; Upstash sliding windows gate the sensitive routes. | -| 08 | [Gapless invoice counters](08-gapless-invoice-counters.md) | Invoice and credit-note numbers come from per-org, per-fiscal-year atomic counters to satisfy CGST Rules 46/53. | -| 09 | [Webhook secret-rotation grace](09-webhook-rotation-grace.md) | Outbound webhook secret rotation dual-signs for 24 hours so receivers can cut over without a hard break. | -| 10 | [Session-generation clock](10-session-generation-clock.md) | Role changes bump `User.sessionGeneration` to force a membership refetch instead of revoking sessions. | -| 11 | [Live-payout submission freeze](11-live-payout-submission-freeze.md) | `ENABLE_LIVE_PAYOUTS` freezes only the gateway submission step; the whole pipeline upstream of it runs for real. | -| 12 | [PENDING_TRUST earnings parking](12-pending-trust-earnings-parking.md) | Earnings for unverified INVOICE-funded orgs park in `PENDING_TRUST` until the org verifies or pays, closing the ghost-org fraud hole (#687). | -| 13 | [Postgres-native concurrency](13-postgres-native-concurrency.md) | State transitions are guarded by CAS WHERE clauses, Serializable retries, version columns, and Redis cron locks — no Kafka, RabbitMQ, Temporal, or Inngest at this stage. | -| 14 | [Async and queue posture](14-async-queue-posture.md) | Background work stays queue-less for launch (GH Actions crons + `after()` + sweeper re-drives); Upstash QStash is the pre-approved escalation, gated on two named telemetry triggers. | -| 15 | [Currency as enum with display fields](15-currency-as-enum-with-display-fields.md) | Settlement currency stays the `Currency` enum; gateway and buyer codes live in free-text display fields, and the ledger is keyed INR-only (#783). | -| 16 | [Slot freshness without realtime](16-slot-freshness-without-realtime.md) | Slot freshness comes from server-authoritative 409 conflicts plus focused refetch and invalidate-on-mutation, not Supabase Realtime. | -| 17 | [Timezone pinned to IST for launch](17-timezone-pinned-to-ist-for-launch.md) | The platform pins to IST and removes the speculative DST materialization layer; the full IANA-TZID implementation is deferred to #872. | -| 18 | [Open B2B/B2C boundary](18-open-b2b-b2c-boundary.md) | Sponsors fund any marketplace consultant and collaborations stay org-blind by design; `ProgramConsultantAllowlist` and `Membership.exclusiveEngagement` began as schema stubs and have both been enforced at checkout since 2026-07-11. | -| 19 | [Personal-vs-org dashboard split](19-personal-vs-org-dashboard-split.md) | Dashboards split by the org-ness of the underlying session, plan or payment — views split, instruments do not; a nav entry must be a distinct destination, so scope variants become on-page toggles and filters become tabs; admin and staff keep two URL trees over one implementation, with access decided by a permission matrix rather than by which tree you landed in. | -| 20 | [Org visibility into member sessions](20-org-visibility-into-member-sessions.md) | An organization sees that a session happened — member, counterpart, plan title, time, status, cost — and never what happened in it; notes, feedback, recordings, document contents and chat stay with the two participants, enforced by select allowlists that branch on whether the scope constrains the caller to be one of them. | -| 21 | [Single writer for payment confirmation](21-single-writer-for-payment-confirmation.md) | `Payment.paymentStatus` is written by the confirmation pipeline and by nothing else; the webhook, the client's signature return, the on-demand sync and the reconcile cron all call `routeCapturedPayment` rather than recording the conclusion themselves, because a second writer turns `handlePaymentSuccess`'s already-SUCCEEDED guard from "this work is done" into "this work will never be done". | -| 22 | [Queue posture, revisited with measurements](22-queue-posture-revisited-with-measurements.md) | Measured cadence shows sub-hourly GitHub Actions schedules deliver roughly one tick per 100 minutes while nothing overlaps, so the defect is missed ticks rather than contention; the QStash escalation in ADR 14 is now authorised, Temporal stays out on cost and fit rather than on the architectural objection its 2026 Lambda workers retired, and Inngest is recorded with concrete adoption triggers. | -| 23 | [Notification scope](23-notification-scope.md) | A notification inherits the org-ness of the record that triggered it: dual-context payloads carry a required `NotificationScope`, deep links resolve to the owning dashboard rather than bouncing everyone to their personal tree, the Inbox filters the shared feed back apart by scope, and three org categories make the previously unmutable `ORG_*` family configurable. | -| 24 | [The offering content model](24-offering-content-model.md) | An offering is described by structured content rather than one free-text blob: `subtitle`, `targetAudience`, `whatsIncluded` and a polymorphic `PlanFaq` land on all four plan types, the two curriculum tables stay separate but both gain a free-text `sectionLabel` that groups sessions under a heading, `level` becomes the `PlanLevel` enum, all four plan types get a detail page gated by `isPlanViewable`, and the pricing toggle becomes a chooser with an Open-details and a booking CTA. | -| 25 | [Per-session reviews and the published score](25-per-session-reviews-and-published-score.md) | A review belongs to a session rather than to a relationship (`@@unique([appointmentId, consulteeProfileId])`), a group session contributes the mean of its attendees as one data point through the denormalized `ratingUnitId`, `ConsultantProfile.publishedRating` stays null below five distinct rated sessions, public reviews and the private CSAT in `AppointmentFeedback` remain separate objects because FTC 16 CFR §465.1(d) makes a bare star rating a consumer review, and every attendee of every held session is asked identically with no sentiment gate and no incentive (#705). | +All twenty-six ADRs below are written and live (#793 wrote the first twelve; #872 added 15–17; #971 added 18; the dashboard consolidation added 19–20; the money-productionization pass added 21–22; #1051 added 23; the offering content model added 24; #705 added 25; and the 2026-09-03 financial audit added 27 — 26 is numbered for a companion decision from the same audit that has not yet merged); this index is the authoritative list. Each row links to its record. +All twenty-six ADRs below are written and live (#793 wrote the first twelve; #872 added 15–17; #971 added 18; the dashboard consolidation added 19–20; the money-productionization pass added 21–22; #1051 added 23; the offering content model added 24; #705 added 25; and the 2026-09-03 financial audit added 26); this index is the authoritative list. Each row links to its record. + +| # | ADR | Decision in one line | +| --- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 01 | [Double-entry journal over three logs](01-double-entry-over-three-logs.md) | One balanced `LedgerTransaction`/`LedgerEntry` journal replaced `FundingLedgerEntry`, `WalletEntry`, and `SettlementLedgerEntry` (#772). | +| 02 | [Integer paise and basis points](02-integer-paise-and-basis-points.md) | All money is integer paise and all splits are integer basis points, so no float ever touches a balance. | +| 03 | [Deterministic ledger-account IDs](03-deterministic-ledger-account-ids.md) | Ledger accounts use deterministic composite IDs (kind|org|consultant|currency) instead of UUIDs (#783). | +| 04 | [Batch payouts over streaming](04-batch-payouts-over-streaming.md) | Earnings settle in periodic idempotent batches rather than per-earning transfers. | +| 05 | [GitHub Actions crons](05-github-actions-crons.md) | Scheduled jobs run as GitHub Actions invoking `npx tsx jobs/**` directly (with `CRON_SECRET`-gated routes as a manual fallback) rather than Netlify scheduled functions. | +| 06 | [Typed Membership over BetterAuth Member](06-typed-membership-over-betterauth-member.md) | Every permission gate reads the typed `Membership` row, never BetterAuth's own member table. | +| 07 | [Upstash rate limiting](07-upstash-rate-limiting.md) | BetterAuth's built-in limiter stays off; Upstash sliding windows gate the sensitive routes. | +| 08 | [Gapless invoice counters](08-gapless-invoice-counters.md) | Invoice and credit-note numbers come from per-org, per-fiscal-year atomic counters to satisfy CGST Rules 46/53. | +| 09 | [Webhook secret-rotation grace](09-webhook-rotation-grace.md) | Outbound webhook secret rotation dual-signs for 24 hours so receivers can cut over without a hard break. | +| 10 | [Session-generation clock](10-session-generation-clock.md) | Role changes bump `User.sessionGeneration` to force a membership refetch instead of revoking sessions. | +| 11 | [Live-payout submission freeze](11-live-payout-submission-freeze.md) | `ENABLE_LIVE_PAYOUTS` freezes only the gateway submission step; the whole pipeline upstream of it runs for real. | +| 12 | [PENDING_TRUST earnings parking](12-pending-trust-earnings-parking.md) | Earnings for unverified INVOICE-funded orgs park in `PENDING_TRUST` until the org verifies or pays, closing the ghost-org fraud hole (#687). | +| 13 | [Postgres-native concurrency](13-postgres-native-concurrency.md) | State transitions are guarded by CAS WHERE clauses, Serializable retries, version columns, and Redis cron locks — no Kafka, RabbitMQ, Temporal, or Inngest at this stage. | +| 14 | [Async and queue posture](14-async-queue-posture.md) | Background work stays queue-less for launch (GH Actions crons + `after()` + sweeper re-drives); Upstash QStash is the pre-approved escalation, gated on two named telemetry triggers. | +| 15 | [Currency as enum with display fields](15-currency-as-enum-with-display-fields.md) | Settlement currency stays the `Currency` enum; gateway and buyer codes live in free-text display fields, and the ledger is keyed INR-only (#783). | +| 16 | [Slot freshness without realtime](16-slot-freshness-without-realtime.md) | Slot freshness comes from server-authoritative 409 conflicts plus focused refetch and invalidate-on-mutation, not Supabase Realtime. | +| 17 | [Timezone pinned to IST for launch](17-timezone-pinned-to-ist-for-launch.md) | The platform pins to IST and removes the speculative DST materialization layer; the full IANA-TZID implementation is deferred to #872. | +| 18 | [Open B2B/B2C boundary](18-open-b2b-b2c-boundary.md) | Sponsors fund any marketplace consultant and collaborations stay org-blind by design; `ProgramConsultantAllowlist` and `Membership.exclusiveEngagement` began as schema stubs and have both been enforced at checkout since 2026-07-11. | +| 19 | [Personal-vs-org dashboard split](19-personal-vs-org-dashboard-split.md) | Dashboards split by the org-ness of the underlying session, plan or payment — views split, instruments do not; a nav entry must be a distinct destination, so scope variants become on-page toggles and filters become tabs; admin and staff keep two URL trees over one implementation, with access decided by a permission matrix rather than by which tree you landed in. | +| 20 | [Org visibility into member sessions](20-org-visibility-into-member-sessions.md) | An organization sees that a session happened — member, counterpart, plan title, time, status, cost — and never what happened in it; notes, feedback, recordings, document contents and chat stay with the two participants, enforced by select allowlists that branch on whether the scope constrains the caller to be one of them. | +| 21 | [Single writer for payment confirmation](21-single-writer-for-payment-confirmation.md) | `Payment.paymentStatus` is written by the confirmation pipeline and by nothing else; the webhook, the client's signature return, the on-demand sync and the reconcile cron all call `routeCapturedPayment` rather than recording the conclusion themselves, because a second writer turns `handlePaymentSuccess`'s already-SUCCEEDED guard from "this work is done" into "this work will never be done". | +| 22 | [Queue posture, revisited with measurements](22-queue-posture-revisited-with-measurements.md) | Measured cadence shows sub-hourly GitHub Actions schedules deliver roughly one tick per 100 minutes while nothing overlaps, so the defect is missed ticks rather than contention; the QStash escalation in ADR 14 is now authorised, Temporal stays out on cost and fit rather than on the architectural objection its 2026 Lambda workers retired, and Inngest is recorded with concrete adoption triggers. | +| 23 | [Notification scope](23-notification-scope.md) | A notification inherits the org-ness of the record that triggered it: dual-context payloads carry a required `NotificationScope`, deep links resolve to the owning dashboard rather than bouncing everyone to their personal tree, the Inbox filters the shared feed back apart by scope, and three org categories make the previously unmutable `ORG_*` family configurable. | +| 24 | [The offering content model](24-offering-content-model.md) | An offering is described by structured content rather than one free-text blob: `subtitle`, `targetAudience`, `whatsIncluded` and a polymorphic `PlanFaq` land on all four plan types, the two curriculum tables stay separate but both gain a free-text `sectionLabel` that groups sessions under a heading, `level` becomes the `PlanLevel` enum, all four plan types get a detail page gated by `isPlanViewable`, and the pricing toggle becomes a chooser with an Open-details and a booking CTA. | +| 25 | [Per-session reviews and the published score](25-per-session-reviews-and-published-score.md) | A review belongs to a session rather than to a relationship (`@@unique([appointmentId, consulteeProfileId])`), a group session contributes the mean of its attendees as one data point through the denormalized `ratingUnitId`, `ConsultantProfile.publishedRating` stays null below five distinct rated sessions, public reviews and the private CSAT in `AppointmentFeedback` remain separate objects because FTC 16 CFR §465.1(d) makes a bare star rating a consumer review, and every attendee of every held session is asked identically with no sentiment gate and no incentive (#705). | +| 27 | [State-as-outbox with a scheduled ticker](27-state-as-outbox-and-scheduled-ticker.md) | No generic outbox table is added — domain rows (`Payment`, `Appointment`, `Refund`, `WalletTopUp`, `WebhookEvent`, `OutboundWebhookDelivery`, `FailedEmail`) already carry the durable state one exists to provide; instead `netlify/functions/cron-tick.mts` POSTs the latency-sensitive `/api/cleanup/*` sweeps every five minutes, because GitHub Actions was measured delivering a sub-hourly schedule roughly once per hundred minutes (ADR 22), while Actions keeps the daily/weekly crons and the unbounded backstop (#866, #1010, #1356). | +| 26 | [GST — the platform bills as principal supplier](26-gst-principal-model.md) | The platform is the supplier of record for GST — 18% on the full discounted price, a B2C tax invoice and credit note on the platform's own gapless series, and no Section 52 TCS collection — paired with the pre-existing Section 194-O operator withholding on income tax, pending a CA opinion on whether that pairing holds (#1360, #1361, #1388). | diff --git a/docs/enterprise/70-design-decisions/15-currency-as-enum-with-display-fields.md b/docs/enterprise/70-design-decisions/15-currency-as-enum-with-display-fields.md index 0c2ad7ae3..707709bf1 100644 --- a/docs/enterprise/70-design-decisions/15-currency-as-enum-with-display-fields.md +++ b/docs/enterprise/70-design-decisions/15-currency-as-enum-with-display-fields.md @@ -47,10 +47,20 @@ boundary. `toCurrencyEnum()` (in `lib/payments/validation/currency-guards.ts`) is the single normaliser: it trims, upper-cases, and accepts only codes in the enum, throwing on anything else so a webhook caller dead-letters the event rather than settling a currency the platform cannot represent. That enum is -deliberately narrower than `CURRENCY_MULTIPLIERS` (in `lib/payments/index.ts`), -which lists display-only FX codes the UI can render but the platform cannot +deliberately narrower than `SUPPORTED_CURRENCY_CODES` (in +`lib/currency-codes.ts`), which lists the display-only FX codes the navbar can +render and the checkout pages can estimate in, but which the platform cannot settle; the two must not be conflated, and a comment in the guard cross-links -them. +them. `CURRENCY_MULTIPLIERS` used to play that role and is referenced by older +revisions of this document; it was deleted in #1396 because nothing imported it. + +The same file now carries `assertInrSettlement`, which is the enforcement half +of this decision. `toCurrencyEnum` says which currencies the platform can +represent; `assertInrSettlement` says which one it can actually settle, and it +runs as the first statement of both `createRazorpayOrder` and +`createStripeCheckoutSession`. A caller that reads a currency out of the +database and hands it to a gateway therefore fails loudly rather than minting an +order denominated in a foreign subunit. This decision also closed three latent multi-currency defects found in the same review. Ledger accounts must never be keyed by a row's settlement currency: diff --git a/docs/enterprise/70-design-decisions/26-gst-principal-model.md b/docs/enterprise/70-design-decisions/26-gst-principal-model.md new file mode 100644 index 000000000..889448fbe --- /dev/null +++ b/docs/enterprise/70-design-decisions/26-gst-principal-model.md @@ -0,0 +1,51 @@ +--- +title: GST — the platform bills as principal supplier +band: 70-design-decisions +audience: sde3 +status: live +last-reviewed: 2026-09-03 +--- + +# ADR 26 — GST: the platform bills as principal supplier + +## Context + +Checkout has always charged 18% GST on the full discounted booking price (`lib/payments/pricing/derive-checkout-amount.ts` → `lib/payments/tax/tax-engine.ts`), and the booking journal credits `GST_PAYABLE` for that amount (`lib/payments/payouts/earnings-service.ts`). That is the behaviour of a supplier of record: the platform sells the consultation to the buyer and remits output tax on the whole consideration. Income tax was wired the other way round: the platform withholds Section 194-O as an e-commerce operator paying an e-commerce participant (`lib/compliance/tds-194o.ts`, `lib/compliance/tds.ts`), which is the behaviour of a facilitator. + +The 2026-09-03 financial audit assumed the facilitator reading for GST as well and filed GST-TCS Section 52 collection (#1360) and GSTR builders (#1361) as launch blockers. The two readings cannot both drive the code: a facilitator charges GST only on its commission and collects 0.5% TCS on registered suppliers' sales; a principal charges GST on the whole price, issues the tax invoice to the buyer, and collects no TCS because it is the supplier. Consulting is not a Section 9(5) notified service, so neither reading is forced by statute; it is a business-model choice with a chartered accountant's sign-off. + +## Decision + +The platform bills as **principal supplier for GST**. Concretely: + +1. GST stays at 18% on the full discounted price, booked to `GST_PAYABLE` at settlement, exactly as today. +2. The platform will issue a numbered **B2C tax invoice** for every consumer supply and a **credit note** for every refund (`ConsumerInvoice`, `ConsumerCreditNote`, platform-wide gapless series under CGST Rule 46 and Rule 53), alongside the existing org invoices for sponsored supplies. This is design intent, not yet shipped: the models do not exist in the schema as of this ADR and land with PR-E (`feat/finance-b2c-tax-invoice`, in flight). +3. Place of supply for a consumer is the declared or remembered billing state; when no address is on record, it defaults to the **supplier's state under Section 12(2)(b)** of the IGST Act, which makes the supply intra-state (CGST + SGST). This is the opposite of the B2B derivation's IGST fallback, which stays as an audit signal for org invoices. +4. GST-TCS under Section 52 **does not apply** on this model and is not collected. The dormant schema (`Payment.gstTcsCollectedPaise`, `GstTcsBatch`, the GSTR-8 draft builder) stays in place, correctly annotated at 0.5%, and is only wired if the CA overturns this decision. +5. The platform's own outward-supply return is produced as a period **register export** (all tax invoices and credit notes, with place of supply and tax heads) that the CA files GSTR-1 and GSTR-3B from. No in-app GSTR JSON builders. +6. Income tax is unchanged: 194-O at 0.1% with the three-limb ₹5 lakh exemption, withheld on both the consultant and the host-org payout rails and reported on Form 140 (formerly 26Q) with Section 393 payment codes. + +## Alternatives considered + +The facilitator model — GST only on the platform's commission, 0.5% Section 52 TCS collected on registered consultants' sales, and monthly GSTR-8 filing — was rejected because it does not match the code that already exists: checkout has always charged 18% GST on the full discounted price and the booking journal has always credited `GST_PAYABLE` for that full amount, which is principal-supplier behaviour, not facilitator behaviour. Adopting the facilitator reading now would mean re-deriving every historical GST figure, wiring the dormant TCS schema, and building GSTR-8 tooling that the platform does not currently need — a pricing, invoice and ledger re-architecture rather than a continuation of the current design. It remains the fallback if the CA rejects the principal-supplier pairing (see Consequences below). + +## Consequences + +Every consumer payment will leave a statutory document trail the buyer can download once the B2C tax-invoice work lands, which closes the "incomplete information" gap in the money journey; that work (`ConsumerInvoice`, `ConsumerCreditNote`) is in flight and not yet in the schema (tracked as PR-E, `feat/finance-b2c-tax-invoice`). The register export gives the CA one file per month instead of a database query, once it ships alongside the invoices. Consultants who hold a GSTIN invoice the platform, not the buyer; that is a contractual and onboarding matter, not a code path, and belongs in the consultant terms. + +The pairing of principal-for-GST with operator-for-income-tax is defensible but unusual, and it is the first question on the CA list below. If the CA rejects it, the facilitator model is a pricing, invoice and ledger re-architecture (GST only on the platform fee, TCS on registered consultants, GSTR-8 monthly) and would be a new ADR. + +## Questions for the chartered accountant + +1. Does the principal-for-GST plus 194-O-operator pairing hold for a consultation marketplace where the platform sets the commission but the consultant sets the price and delivers the service? +2. Referral credits are platform-funded and are applied **after** tax at checkout (price → discount code → tax → credits), so a buyer using a ₹500 credit pays GST on the pre-credit base. Is a platform-funded credit a discount recorded on the invoice under Section 15(3)(a), which would reduce the taxable value, or third-party consideration, which would not? The code path is `deriveCheckoutAmount`; nothing changes until this is answered. +3. SAC classification: the platform defaults to 999293 (commercial training and coaching). For advisory consultations 998311 or 998399 may be the correct code; rates are unaffected, input-tax-credit trails are not. +4. Is the Section 12(2)(b) supplier-state default acceptable for consumers who decline to declare a state, or must the checkout collect a state before payment? +5. Confirm that Section 52 TCS registration is not required while the platform is the supplier of record. + +## Related + +- ADR 21 (single writer for payment confirmation) — the invoice is minted inside the same pipeline, never by a second writer. +- ADR 08 (gapless invoice counters) — the platform series reuses the same atomic counter shape. +- docs/compliance/02-gst-overview.md, docs/compliance/15-india-compliance-shipping-checklist.md, docs/compliance/10-rbi-pa-and-payment-architecture.md (Path C). +- #1360 (relabelled CA-gated), #1361 (re-scoped to the register export), #1365, #1370. diff --git a/docs/enterprise/70-design-decisions/27-state-as-outbox-and-scheduled-ticker.md b/docs/enterprise/70-design-decisions/27-state-as-outbox-and-scheduled-ticker.md new file mode 100644 index 000000000..6e6f6ec90 --- /dev/null +++ b/docs/enterprise/70-design-decisions/27-state-as-outbox-and-scheduled-ticker.md @@ -0,0 +1,37 @@ +--- +title: State-as-outbox with a scheduled ticker +band: 70-design-decisions +audience: sde3 +status: live +last-reviewed: 2026-09-03 +--- + +# ADR 27 — State-as-outbox with a scheduled ticker + +## Context + +ADR 14 kept the platform queue-less for launch; ADR 22 measured GitHub Actions delivering every sub-hourly schedule roughly once per hundred minutes and authorised QStash as the escalation. The 2026-09-03 financial audit asked for a transactional outbox for post-payment side effects (#1356), and the owner asked for an unbiased answer on whether one is needed. + +Three facts settle it. First, the repo already has the durable state an outbox exists to provide: `WebhookEvent` is an inbox with a processed flag, `OutboundWebhookDelivery` and `FailedEmail` are outboxes with retry state, and every money-bearing follow-up is keyed on a domain row that already exists (`Payment`, `Appointment`, `Refund`, `WalletTopUp`). Second, every job under `jobs/` already has an HTTP twin under `app/api/cleanup/*` built by `lib/cron/cleanup-route.ts`, gated by `CRON_SECRET` and wrapped in the same `withCronLock`, so any scheduler that can POST can drive the fleet. Third, the only side effect that lacked a re-drive was Stream channel creation and its notification after a capture, and that is derivable from the appointment row. + +So the missing piece was never a table. It was a scheduler that fires when it says it will. + +## Decision + +1. **Domain rows are the outbox.** No generic outbox table is added. A follow-up that must survive a crash is expressed as a nullable stamp on the row it belongs to (for example `Appointment.chatChannelEnsuredAt`) plus an idempotent ensure-step in an existing sweeper. +2. **A Netlify scheduled function is the ticker.** `netlify/functions/cron-tick.mts` runs every five minutes and POSTs the latency-sensitive sweep routes (stuck webhooks, refund cascade and reconcile, abandoned payments, payment-status reconcile, orphaned confirmations, orphaned top-up captures, outbound webhook dispatch, earnings sync and release) with the cron secret. Each route already holds a fail-closed Redis lock, so a ticker run and a GitHub Actions run cannot overlap; the loser answers 409 and that is expected. +3. **GitHub Actions stays** for daily and weekly business crons, for the unbounded backstop runs of the same sweepers, and for manual dispatch with its run history. +4. **Routes invoked by the ticker take a `limit`** so a single run fits inside the Next function ceiling of 26 seconds; the nightly Actions run remains unbounded. +5. **QStash remains the escalation**, not the default. Its triggers are unchanged from ADR 14 and ADR 22: a notification fan-out that exceeds the synchronous budget, a measured webhook-processing SLO breach that a five-minute tick cannot close, or the Netlify scheduler proving as unreliable as Actions. Inngest and Temporal remain out on the criteria ADR 22 records. + +## Consequences + +The worst case for a buyer whose `after()` callback died falls from about a hundred minutes to about five, with no new vendor, no new secret inside the money path and no new table to reconcile. The trade-off is one more place that fires the fleet, which is why the ticker only ever hits routes that are already lock-guarded and idempotent, and why the cron heartbeat keeps watching the Actions side independently. + +Anyone adding a follow-up to a money path should first ask which row already records the obligation and which sweeper already walks that row, and only then consider a new mechanism. + +## Related + +- ADR 05 (GitHub Actions crons), ADR 14 (queue posture), ADR 22 (measurements) — this ADR narrows their remedy, it does not reverse them. +- ADR 21 (single writer for payment confirmation) — the ticker never writes payment status; it only re-invokes the pipeline. +- #866, #1010 (QStash plan), #1356 (the outbox request this answers), #1246 (orphan gateway orders, still hygiene). diff --git a/docs/enterprise/90-audits/04-simplification-proposal.md b/docs/enterprise/90-audits/04-simplification-proposal.md index dc1cec108..a16295dd0 100644 --- a/docs/enterprise/90-audits/04-simplification-proposal.md +++ b/docs/enterprise/90-audits/04-simplification-proposal.md @@ -23,6 +23,14 @@ toc-depth: 2 > This document remains useful as a historical record of the doc-dedup + > dead-code + helper-consolidation recipes. +> **Note (2026-09-03):** The SCIM section below (A2, and every other +> reference to stubbing `lib/scim/` to a 501) is superseded. SCIM 2.0 has +> since shipped in full: `lib/scim/` implements Users CRUD, bearer-token +> authentication, group mapping and deprovisioning, and `ScimToken.expiresAt` +> is enforced on every request. The "zero customers using it, stub to 501" +> recommendation no longer applies to live code. This note closes #1373, +> which tracked the doc drift. + # Executive Summary The enterprise subsystem (~614 changed files, ~13,500 LoC of code + 11,141 lines of docs) is **complex but not over-complex** — most of the apparent weight is load-bearing. However, two parallel surveys (one over the 47 enterprise docs, one over the code surface) identified **~2,800 lines of preventable bloat** that can be removed with zero schema changes and zero customer-visible behavior changes. @@ -41,11 +49,11 @@ The enterprise subsystem (~614 changed files, ~13,500 LoC of code + 11,141 lines **Recommended phased rollout:** -| Phase | Effort | LoC saved | Risk | Customer impact | -|---|---|---|---|---| -| **Phase 1 — Dead code & doc dedup** | 1 day | ~2,100 | None | None | -| **Phase 2 — Helper consolidation** | 2 days | ~400 | Low | None | -| **Phase 3 — Optional refactors** | 1-2 weeks | ~1,800 | Medium | None | +| Phase | Effort | LoC saved | Risk | Customer impact | +| ----------------------------------- | --------- | --------- | ------ | --------------- | +| **Phase 1 — Dead code & doc dedup** | 1 day | ~2,100 | None | None | +| **Phase 2 — Helper consolidation** | 2 days | ~400 | Low | None | +| **Phase 3 — Optional refactors** | 1-2 weeks | ~1,800 | Medium | None | Phase 1 is recommended for the PR #655 closeout. Phases 2 and 3 can land as follow-up technical debt PRs. @@ -80,12 +88,12 @@ The `docs/enterprise/` directory holds **47 files / 11,141 lines** across 36 num The three-ledger concept is explained across **four documents** at conflicting detail levels: -| Doc | Focus | Lines | Issue | -|---|---|---|---| -| `07-payout-pipeline.md` | Org earnings + payout flow | 189 | Repeats 18's invariants at 90% detail | -| `09-wallet-and-ledger.md` | Wallet + funding ledger | 163 | Defers to 18 for invariants but re-explains them | -| `18-three-ledger-discipline.md` | All three ledgers + reconciliation | 220 | Positioned at #18 — too late for the consumer who already read 07/09 | -| `20-payment-legs.md` | Stackable legs | 118 | Adjacent concept, sometimes confused with ledger design | +| Doc | Focus | Lines | Issue | +| ------------------------------- | ---------------------------------- | ----- | -------------------------------------------------------------------- | +| `07-payout-pipeline.md` | Org earnings + payout flow | 189 | Repeats 18's invariants at 90% detail | +| `09-wallet-and-ledger.md` | Wallet + funding ledger | 163 | Defers to 18 for invariants but re-explains them | +| `18-three-ledger-discipline.md` | All three ledgers + reconciliation | 220 | Positioned at #18 — too late for the consumer who already read 07/09 | +| `20-payment-legs.md` | Stackable legs | 118 | Adjacent concept, sometimes confused with ledger design | **Root cause:** PR #655 retrofitted the three-ledger model after 07, 09, 20 were already written; doc 18 was meant to be a capstone but reads like an internal contradiction. @@ -117,12 +125,12 @@ Four customer scenarios (Wipro, LearnPro, IIT Madras, Rahul) appear in **three** Four docs have outlived their usefulness: -| Doc | Lines | Why deletable | -|---|---|---| -| `13-feature-flags-and-rollout.md` | 153 | Flags rotate within weeks; lives better as JSDoc in `lib/feature-flags.ts` | -| `17-hierarchy.md` | 112 | 95% "deferred UI" — feature not shipped | -| `19-harness-verdict.md` | 87 | Snapshot from 2026-05-15, stale within weeks | -| `22-route-migration-table.md` | 129 | Pre-Arch-4 → Arch-4 map; irrelevant post-2026-07 | +| Doc | Lines | Why deletable | +| --------------------------------- | ----- | -------------------------------------------------------------------------- | +| `13-feature-flags-and-rollout.md` | 153 | Flags rotate within weeks; lives better as JSDoc in `lib/feature-flags.ts` | +| `17-hierarchy.md` | 112 | 95% "deferred UI" — feature not shipped | +| `19-harness-verdict.md` | 87 | Snapshot from 2026-05-15, stale within weeks | +| `22-route-migration-table.md` | 129 | Pre-Arch-4 → Arch-4 map; irrelevant post-2026-07 | **Action — DELETE 13, 17, 19. ARCHIVE 22 to `docs/migrations/`.** @@ -156,19 +164,19 @@ Three docs carry too little to justify their own file: ## Phase 1 — Docs Simplification Summary -| # | Action | Δ files | Δ lines | -|---|---|---|---| -| 1 | Merge 07 + 09 + 18 → new 09-ledgers.md | -2 | -89 | -| 2 | Delete 14-scenarios-and-examples.md | -1 | -212 | -| 3 | Delete 13-feature-flags-and-rollout.md | -1 | -153 | -| 4 | Delete 17-hierarchy.md (placeholder) | -1 | -112 | -| 5 | Delete 19-harness-verdict.md (stale) | -1 | -87 | -| 6 | Archive 22 → `docs/migrations/` | 0 | 0 | -| 7 | Delete 11, 27, 32 (fold into siblings) | -3 | -279 | -| 8 | Trim 00-overview.md intro | 0 | -150 | -| 9 | Extract roles matrix from 04 | +1 | -170 net | -| 10 | Excise compliance from 08 | 0 | -80 | -| **Total** | | **-8 files** | **~-1,333 lines** | +| # | Action | Δ files | Δ lines | +| --------- | -------------------------------------- | ------------ | ----------------- | +| 1 | Merge 07 + 09 + 18 → new 09-ledgers.md | -2 | -89 | +| 2 | Delete 14-scenarios-and-examples.md | -1 | -212 | +| 3 | Delete 13-feature-flags-and-rollout.md | -1 | -153 | +| 4 | Delete 17-hierarchy.md (placeholder) | -1 | -112 | +| 5 | Delete 19-harness-verdict.md (stale) | -1 | -87 | +| 6 | Archive 22 → `docs/migrations/` | 0 | 0 | +| 7 | Delete 11, 27, 32 (fold into siblings) | -3 | -279 | +| 8 | Trim 00-overview.md intro | 0 | -150 | +| 9 | Extract roles matrix from 04 | +1 | -170 net | +| 10 | Excise compliance from 08 | 0 | -80 | +| **Total** | | **-8 files** | **~-1,333 lines** | **Post-simplification:** 39 docs, ~9,800 lines (12% reduction). Zero customer impact. @@ -198,10 +206,10 @@ The enterprise code surface spans ~13,500 LoC. Three categories of preventable b ### A3. Dead feature flags -| Flag | Status | -|---|---| -| `ENABLE_TDS_ADMIN_VIEW` | Feature shipped; flag now always-true in deployments | -| `ENABLE_HRIS` | No implementation exists; pre-scaffolding never wired | +| Flag | Status | +| ----------------------- | ----------------------------------------------------- | +| `ENABLE_TDS_ADMIN_VIEW` | Feature shipped; flag now always-true in deployments | +| `ENABLE_HRIS` | No implementation exists; pre-scaffolding never wired | **Action — DELETE both flags** from `lib/feature-flags.ts` and audit callers. @@ -233,10 +241,9 @@ const ROLE_CAPABILITIES: Record> = { // ... }; -export async function requireCapability( - orgId: string, - capability: Capability, -) { /* single predicate */ } +export async function requireCapability(orgId: string, capability: Capability) { + /* single predicate */ +} ``` Then `requireOrgOwner = requireCapability(..., "admin")` and `requireOrgBillingAdminOrOwner = requireCapability(..., "finance")`. @@ -287,16 +294,16 @@ One huge file with subscription/one-off branching, slot locking, wallet vs inten ## Phase 2 — Code Simplification Summary -| # | Action | Files | Δ LoC | Priority | -|---|---|---|---|---| -| 1 | Delete `payout-service.ts` | 1 file deleted | -910 | HIGH | -| 2 | Stub SCIM to 501 | 4 files → 1 file | -484 | MEDIUM | -| 3 | Delete `ENABLE_TDS_ADMIN_VIEW` flag | scattered | -20 | LOW | -| 4 | Delete `ENABLE_HRIS` flag | scattered | -20 | LOW | -| 5 | Unify role predicates → capability matrix | 2 files refactored | -200 | MEDIUM | -| 6 | Split SlotAllocationService | 3 files → 5 files | 0 net | OPTIONAL | -| 7 | Refactor checkout into handlers | 1 file → 5 files | 0 net | DEFER | -| **Phase 1 total (high+medium)** | | | **~-1,614** | | +| # | Action | Files | Δ LoC | Priority | +| ------------------------------- | ---------------------------------------------------------------------------- | ------------------ | ----------- | -------- | +| 1 | Delete `payout-service.ts` | 1 file deleted | -910 | HIGH | +| 2 | ~~Stub SCIM to 501~~ (superseded — SCIM 2.0 shipped in full, see note above) | 4 files → 1 file | -484 | MEDIUM | +| 3 | Delete `ENABLE_TDS_ADMIN_VIEW` flag | scattered | -20 | LOW | +| 4 | Delete `ENABLE_HRIS` flag | scattered | -20 | LOW | +| 5 | Unify role predicates → capability matrix | 2 files refactored | -200 | MEDIUM | +| 6 | Split SlotAllocationService | 3 files → 5 files | 0 net | OPTIONAL | +| 7 | Refactor checkout into handlers | 1 file → 5 files | 0 net | DEFER | +| **Phase 1 total (high+medium)** | | | **~-1,614** | | --- @@ -349,6 +356,7 @@ Dormant but cheap. Gated on `paymentGateway` schema field, doesn't intrude on ho **Effort:** 1 day. **Risk:** None. Actions: + 1. Delete `lib/payments/payouts/payout-service.ts` (after grep verifies zero production callers) 2. Stub `lib/scim/*` to a 501 handler 3. Delete `ENABLE_TDS_ADMIN_VIEW` and `ENABLE_HRIS` from `lib/feature-flags.ts` @@ -365,6 +373,7 @@ Actions: **Effort:** 2 days. **Risk:** Low. Actions: + 1. Introduce `lib/auth/capabilities.ts` with `requireCapability()` + role→capability matrix 2. Refactor `requireOrgAccess`, `requireOrgOwner`, `requireOrgBillingAdminOrOwner` to thin wrappers 3. Migrate ~70 route handlers (mechanical, no behavior change) @@ -377,6 +386,7 @@ Actions: **Effort:** 1-2 weeks. **Risk:** Medium to High. Trigger conditions: + - `SlotAllocationService` split → defer until the next slot-related feature touches the file - `checkout.ts` modularization → defer until per-type variance increases (currently stable) @@ -386,17 +396,17 @@ Don't do these speculatively. Wait for a real feature to justify. # Decision Matrix -| Proposal | Schema-locked? | Algorithm change? | Customer impact | LoC | Recommended for PR #655? | -|---|---|---|---|---|---| -| Delete `payout-service.ts` | ✅ no schema change | ✅ pure dead-code removal | None | -910 | **YES** | -| Stub SCIM | ✅ no schema change | ✅ runtime swap | None (no users) | -484 | **YES** | -| Delete dead flags | ✅ no schema change | ✅ env var removal | None | -40 | **YES** | -| Merge ledger docs | ✅ no code change | N/A | None | -89 | **YES** | -| Delete 7 stale/cosmetic docs | ✅ no code change | N/A | None | -1,113 | **YES** | -| Trim/archive 4 docs | ✅ no code change | N/A | None | -250 | **YES** | -| Unify role predicates | ✅ no schema change | ✅ refactor | None | -200 | Phase 2 | -| Split SlotAllocationService | ✅ no schema change | ✅ refactor | None | 0 net | Phase 3 | -| Modularize checkout.ts | ✅ no schema change | ✅ refactor | None | 0 net | Phase 3 (defer) | +| Proposal | Schema-locked? | Algorithm change? | Customer impact | LoC | Recommended for PR #655? | +| ---------------------------- | ------------------- | ------------------------- | --------------- | ------ | ------------------------ | +| Delete `payout-service.ts` | ✅ no schema change | ✅ pure dead-code removal | None | -910 | **YES** | +| ~~Stub SCIM~~ (superseded) | ✅ no schema change | ✅ runtime swap | None (no users) | -484 | **NO — SCIM shipped** | +| Delete dead flags | ✅ no schema change | ✅ env var removal | None | -40 | **YES** | +| Merge ledger docs | ✅ no code change | N/A | None | -89 | **YES** | +| Delete 7 stale/cosmetic docs | ✅ no code change | N/A | None | -1,113 | **YES** | +| Trim/archive 4 docs | ✅ no code change | N/A | None | -250 | **YES** | +| Unify role predicates | ✅ no schema change | ✅ refactor | None | -200 | Phase 2 | +| Split SlotAllocationService | ✅ no schema change | ✅ refactor | None | 0 net | Phase 3 | +| Modularize checkout.ts | ✅ no schema change | ✅ refactor | None | 0 net | Phase 3 (defer) | --- @@ -451,7 +461,7 @@ After Phase 1: - [ ] `npx tsc --noEmit` clean - [ ] `npx jest` — all suites pass (currently 61 suites / 990 tests; expect same count post-deletion, minus SCIM tests if any were present) - [ ] Manual smoke: SPONSOR org create → invite LEARNER → book CLASS → engagementsUsed increments correctly -- [ ] Manual smoke: hit `/api/organizations/[orgId]/scim/Users` → expect 501 +- [ ] ~~Manual smoke: hit `/api/organizations/[orgId]/scim/Users` → expect 501~~ (superseded — SCIM shipped; verify Users CRUD, bearer-token auth, group mapping, deprovisioning and `ScimToken.expiresAt` enforcement instead) --- diff --git a/docs/enterprise/explainers/complete-guide.md b/docs/enterprise/explainers/complete-guide.md index 8ef84e63b..db9a89c40 100644 --- a/docs/enterprise/explainers/complete-guide.md +++ b/docs/enterprise/explainers/complete-guide.md @@ -1299,7 +1299,7 @@ The chain is queryable end-to-end: you can ask "where did this ₹999 come from? | `EARNINGS_LEDGER_DRIFT` | `OrganizationEarnings` projection == CONSULTANT/ORG payable legs | | `PROGRAM_ASSIGNMENT_ENGAGEMENTS_DRIFT` | Per-assignment engagement counts | | `ACTIVE_SEAT_COUNT_DRIFT` | `seatsUsed` == active assignments | -| `PAYMENT_LEG_SUM_MISMATCH` | PaymentLegs sum to `Payment.amount` | +| `PAYMENT_LEG_SUM_MISMATCH` | PaymentLegs sum to `Payment.amount`, excluding referral credits (#1347) | | `ORG_PAYOUT_TOTAL_MISMATCH` | Org payout legs sum to claimed earnings | The #772 cutover validated `ok: true` with **0 findings** on a full reseed. diff --git a/docs/finances/07-tax-compliance-marketplace-obligations.md b/docs/finances/07-tax-compliance-marketplace-obligations.md index 89fb414e7..43b472acb 100644 --- a/docs/finances/07-tax-compliance-marketplace-obligations.md +++ b/docs/finances/07-tax-compliance-marketplace-obligations.md @@ -10,21 +10,21 @@ ### Flow 1: Consultee → Platform (Checkout) -| Question | Indian Buyer | International Buyer | -|----------|-------------|-------------------| -| What do we charge? | Plan price + 18% GST | Plan price only (0% tax) | -| Which gateway? | Razorpay (domestic) | Razorpay IBT | -| What currency? | INR | INR (Razorpay converts for us) | +| Question | Indian Buyer | International Buyer | +| ------------------ | -------------------- | ------------------------------ | +| What do we charge? | Plan price + 18% GST | Plan price only (0% tax) | +| Which gateway? | Razorpay (domestic) | Razorpay IBT | +| What currency? | INR | INR (Razorpay converts for us) | **Code's job**: Detect buyer country → apply correct tax → route to gateway. ✅ Built. ### Flow 2: Platform → Consultant (Payout) -| Question | Answer | -|----------|--------| -| How much do they get? | 80% of original price (before tax) | -| Do we deduct anything? | Only if we've paid them > ₹50K total this financial year (Apr–Mar) | -| How much to deduct? | 10% if they gave us their PAN, 20% if they didn't | +| Question | Answer | +| --------------------------------- | ------------------------------------------------------------------------------ | +| How much do they get? | 80% of original price (before tax) | +| Do we deduct anything? | Only if we've paid them > ₹50K total this financial year (Apr–Mar) | +| How much to deduct? | 10% if they gave us their PAN, 20% if they didn't | | Where does the deducted money go? | We deposit it to the government (not our money, not theirs — it's prepaid tax) | **Code's job**: Track cumulative FY payments → deduct TDS if over ₹50K → send net amount to Razorpay. ✅ Built. @@ -39,7 +39,7 @@ This is NOT code — it's CA/accountant work. Our code provides the data. ### Is GST Registration Mandatory? -**YES.** Under **Section 24(x) of the CGST Act**, every e-commerce operator (ECO) must register under GST irrespective of turnover. The normal Rs 20 lakh threshold does NOT apply. (Note: Section 24(ix) applies to suppliers selling *through* an ECO; Section 24(x) applies to the ECO itself.) +**YES.** Under **Section 24(x) of the CGST Act**, every e-commerce operator (ECO) must register under GST irrespective of turnover. The normal Rs 20 lakh threshold does NOT apply. (Note: Section 24(ix) applies to suppliers selling _through_ an ECO; Section 24(x) applies to the ECO itself.) The definition in **Section 2(45)** is broad: "any person who owns, operates or manages digital or electronic facility or platform for electronic commerce." Familiarise qualifies. @@ -51,24 +51,24 @@ Consulting/professional services are **NOT** in the Section 9(5) notified list ( ### TCS (Tax Collected at Source) — GST -| Parameter | Value | -|-----------|-------| -| TCS Rate | **0.5%** (reduced from 1% effective July 10, 2024) | -| Intra-state split | 0.25% CGST + 0.25% SGST | -| Inter-state | 0.5% IGST | -| Calculated on | Net value of taxable supplies (minus returns/cancellations) | -| Filing | **GSTR-8** by 10th of following month | -| Annual statement | By December 31 following the FY | -| Legal basis | **Section 52 of CGST Act** | +| Parameter | Value | +| ----------------- | ----------------------------------------------------------- | +| TCS Rate | **0.5%** (reduced from 1% effective July 10, 2024) | +| Intra-state split | 0.25% CGST + 0.25% SGST | +| Inter-state | 0.5% IGST | +| Calculated on | Net value of taxable supplies (minus returns/cancellations) | +| Filing | **GSTR-8** by 10th of following month | +| Annual statement | By December 31 following the FY | +| Legal basis | **Section 52 of CGST Act** | -The consultant gets credit in their electronic cash ledger. +The consultant gets credit in their electronic cash ledger. The "Calculated on" row above is the correct base — TCS under Section 52 is 0.5% of the **net taxable value** of supplies (gross minus returns and cancellations), which is **GST-exclusive**, never the gross amount inclusive of GST. **Model decision (2026-09-03):** [ADR 26](../enterprise/70-design-decisions/26-gst-principal-model.md) locked the platform in as the Principal supplier of record for GST, under which this table does not apply at all — the platform charges GST on the full price and issues its own tax invoice, so there is no "supply by a registered consultant through an e-commerce operator" event to collect TCS on. This section stays in the document as regulatory reference for the facilitator reading that was considered and not chosen; the dormant `GstTcsBatch`/`GstTcsAdjustment` schema is CA-gated, not wired. ### GST on Platform Commission -| Item | Rate | SAC Code | -|------|------|----------| +| Item | Rate | SAC Code | +| ------------------------ | ------- | ---------------------------- | | Platform commission/fees | **18%** | 9962 (retail trade services) | -| Educational services | **18%** | 999293 | +| Educational services | **18%** | 999293 | Commission invoices to consultants should use SAC 9962 for marketplace commission. @@ -78,34 +78,34 @@ Commission invoices to consultants should use SAC 9962 for marketplace commissio ### Section 194-O (TDS by E-Commerce Operator) -| Parameter | Value | -|-----------|-------| -| TDS Rate (from Oct 1, 2024) | **0.1%** (reduced from 1%) | -| Threshold (Individual/HUF) | Rs 5 lakh gross sales/year (if PAN/Aadhaar furnished) | -| Threshold (Companies/Firms/LLP) | **No threshold** — TDS from first rupee | -| No PAN/Aadhaar rate | **5%** (per Section 206AA) | -| Calculated on | Gross amount (including GST, shipping charges) | -| Filing | Quarterly in **Form 26Q** (residents) or **27Q** (non-residents) | +| Parameter | Value | +| ------------------------------- | ------------------------------------------------------------------------------------------------- | +| TDS Rate (from Oct 1, 2024) | **0.1%** (reduced from 1%) | +| Threshold (Individual/HUF) | Rs 5 lakh gross sales/year (if PAN/Aadhaar furnished) | +| Threshold (Companies/Firms/LLP) | **No threshold** — TDS from first rupee | +| No PAN/Aadhaar rate | **5%** (per Section 206AA) | +| Calculated on | Gross amount (including GST, shipping charges) | +| Filing | Quarterly in **Form 140** (formerly 26Q, residents) or **Form 144** (formerly 27Q, non-residents) | ### Section 194J (Professional Services TDS) -| Parameter | FY 2025-26 | -|-----------|-----------| -| Threshold | Rs 50,000/year per consultant | -| Rate (Professional services) | **10%** | -| Rate (Technical services) | **2%** | -| No PAN rate | **20%** | +| Parameter | FY 2025-26 | +| ---------------------------- | ----------------------------- | +| Threshold | Rs 50,000/year per consultant | +| Rate (Professional services) | **10%** | +| Rate (Technical services) | **2%** | +| No PAN rate | **20%** | ### TDS Filing Calendar -| Due Date | Action | Form | -|----------|--------|------| -| 7th of each month | TDS deposit | Challan 281 | -| July 31 | Q1 return (Apr–Jun) | 26Q | -| October 31 | Q2 return (Jul–Sep) | 26Q | -| January 31 | Q3 return (Oct–Dec) | 26Q | -| May 31 | Q4 return (Jan–Mar) | 26Q | -| Within 15 days of quarterly filing due date | Issue TDS certificate | Form 16A | +| Due Date | Action | Form | +| ------------------------------------------- | --------------------- | ----------------------- | +| 7th of each month | TDS deposit | Challan 281 | +| July 31 | Q1 return (Apr–Jun) | Form 140 | +| October 31 | Q2 return (Jul–Sep) | Form 140 | +| January 31 | Q3 return (Oct–Dec) | Form 140 | +| May 31 | Q4 return (Jan–Mar) | Form 140 | +| Within 15 days of quarterly filing due date | Issue TDS certificate | Form 131 (formerly 16A) | Late filing penalty: Rs 200/day (capped at total TDS amount). @@ -119,11 +119,11 @@ Late filing penalty: Rs 200/day (capped at total TDS amount). > **WARNING**: Commission/brokerage income may be excluded from Section 44AD presumptive taxation. Persons earning commission or brokerage income, or carrying on agency businesses, are specifically excluded from 44AD in some interpretations. -| Parameter | Detail | -|-----------|--------| -| Turnover limit | Rs 3 crore (if 95%+ digital receipts) or Rs 2 crore | -| Deemed profit | 6% (digital) or 8% (cash) of turnover | -| Lock-in | 5 consecutive years once opted | +| Parameter | Detail | +| ----------------- | --------------------------------------------------------------- | +| Turnover limit | Rs 3 crore (if 95%+ digital receipts) or Rs 2 crore | +| Deemed profit | 6% (digital) or 8% (cash) of turnover | +| Lock-in | 5 consecutive years once opted | | NOT available for | Companies, LLPs, persons earning commission/brokerage (debated) | **Impact**: If a CA rules that marketplace commission income disqualifies us from 44AD, the "0% tax up to Rs 50L" advantage of Sole Proprietorship in the CFO Master Plan shrinks significantly. **This needs urgent CA verification before finalizing entity structure.** @@ -156,12 +156,12 @@ Late filing penalty: Rs 200/day (capped at total TDS amount). ### International Consultant Payouts -| Method | Speed | Cost | Notes | -|--------|-------|------|-------| -| Wise | 1–3 days | ~1.6–1.7% + $2 | Good UI, mid-market FX rate | -| PayPal | 1–3 days | Up to 4.4% + FX markup | Widest reach, highest cost | -| Payoneer | 2–5 days | 2% FX markup | Popular for freelancer payouts | -| SWIFT/Wire | 3–7 days | Rs 1,000+ per transfer | Costliest, most traditional | +| Method | Speed | Cost | Notes | +| ---------- | -------- | ---------------------- | ------------------------------ | +| Wise | 1–3 days | ~1.6–1.7% + $2 | Good UI, mid-market FX rate | +| PayPal | 1–3 days | Up to 4.4% + FX markup | Widest reach, highest cost | +| Payoneer | 2–5 days | 2% FX markup | Popular for freelancer payouts | +| SWIFT/Wire | 3–7 days | Rs 1,000+ per transfer | Costliest, most traditional | For Section 195 TDS on payments to non-resident consultants, rates vary by DTAA (Double Taxation Avoidance Agreement). Requires 15CA/15CB certificates for outward remittances. @@ -173,12 +173,12 @@ For Section 195 TDS on payments to non-resident consultants, rates vary by DTAA **Almost certainly NO**, as long as we use a licensed PA (Razorpay) rather than directly handling payment flows. -| Scenario | PA License Needed? | -|----------|-------------------| -| Use Razorpay to collect payments, they settle to our account | **No** — we are a merchant | -| Use Razorpay Route to split payments to sellers | **No** — Razorpay is the PA | -| Collect payments into our own pool/escrow and distribute | **Possibly YES** | -| Directly handle card data or bank details | **YES** | +| Scenario | PA License Needed? | +| ------------------------------------------------------------ | --------------------------- | +| Use Razorpay to collect payments, they settle to our account | **No** — we are a merchant | +| Use Razorpay Route to split payments to sellers | **No** — Razorpay is the PA | +| Collect payments into our own pool/escrow and distribute | **Possibly YES** | +| Directly handle card data or bank details | **YES** | PA license requirements (for reference): Rs 15 crore net worth at application, Rs 25 crore within 3 years, escrow account with Scheduled Commercial Bank. @@ -188,14 +188,14 @@ PA license requirements (for reference): Rs 15 crore net worth at application, R ### From Day 1 -| Obligation | Rate | Filing | -|------------|------|--------| -| GST Registration (as ECO, no turnover threshold) | N/A | Must register before launch | -| TCS collection on net taxable supplies | 0.5% | GSTR-8 by 10th monthly | -| TDS u/s 194-O on gross e-commerce payments | 0.1% | Form 26Q quarterly | -| TDS u/s 194J on consultant payouts > Rs 50K/yr | 10% | Form 26Q quarterly | -| GST on platform commission | 18% | GSTR-1/GSTR-3B monthly | -| Export zero-rating (international buyers) | 0% | Verify buyer location | +| Obligation | Rate | Filing | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------- | +| GST Registration (as ECO, no turnover threshold) | N/A | Must register before launch | +| TCS collection on net taxable supplies — **CA-gated, not applicable today.** The platform bills as principal under ADR 26, so Section 52 TCS does not apply and no TCS is collected; this row is a facilitator-model reference that only becomes live if a chartered accountant overturns that decision. | 0.5% | GSTR-8 by 10th monthly (facilitator model only) | +| TDS u/s 194-O on gross e-commerce payments | 0.1% | Form 140 quarterly | +| TDS u/s 194J on consultant payouts > Rs 50K/yr | 10% | Form 140 quarterly | +| GST on platform commission | 18% | GSTR-1/GSTR-3B monthly | +| Export zero-rating (international buyers) | 0% | Verify buyer location | ### For International Transactions @@ -214,33 +214,33 @@ PA license requirements (for reference): Rs 15 crore net worth at application, R ## The Jargon Decoder -| Scary Term | What It Actually Means | -|------------|----------------------| -| GST (18%) | Sales tax. We add it on top of the price for Indian buyers. | -| Zero-rated export | Fancy way of saying "no tax for international buyers" | -| TDS (Section 194J) | When we pay consultants, we hold back 10% as their prepaid income tax and send it to the government. Only kicks in after ₹50K/year. | -| PAN | Like a tax SSN. If consultant doesn't give us one, we deduct 20% instead of 10% (penalty rate). | -| eFIRC | A receipt proving we received foreign money legally. Razorpay generates this automatically. We don't write code for it. | -| LUT (Letter of Undertaking) | A form filed with GST authorities saying "we export services, don't charge us GST on those." One-time filing, not code. | -| SAC Code (999293) | Category code for "consulting services" — goes on invoices. Already hardcoded. | -| Form 26Q | Quarterly report to government: "here's all the TDS we deducted." Our admin API provides the data, CA files the form. | -| FEMA | Foreign exchange law. As long as we use Razorpay (RBI-licensed), we're compliant. Not our problem in code. | -| TCS | Tax Collected at Source — 0.5% the platform collects from supplier on behalf of government. Different from TDS. | -| Section 194-O | E-commerce specific TDS — 0.1% on gross sales. Separate from 194J. | -| PA-CB | Payment Aggregator — Cross Border license from RBI. Needed to process international payments. Razorpay has one. | +| Scary Term | What It Actually Means | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| GST (18%) | Sales tax. We add it on top of the price for Indian buyers. | +| Zero-rated export | Fancy way of saying "no tax for international buyers" | +| TDS (Section 194J) | When we pay consultants, we hold back 10% as their prepaid income tax and send it to the government. Only kicks in after ₹50K/year. | +| PAN | Like a tax SSN. If consultant doesn't give us one, we deduct 20% instead of 10% (penalty rate). | +| eFIRC | A receipt proving we received foreign money legally. Razorpay generates this automatically. We don't write code for it. | +| LUT (Letter of Undertaking) | A form filed with GST authorities saying "we export services, don't charge us GST on those." One-time filing, not code. | +| SAC Code (999293) | The consumer invoice model's default classification code (`ConsumerInvoice.sacCode`), with **998311** as the alternative pending the CA's answer (#1369) — both carry 18% GST. Goes on invoices. | +| Form 140 (formerly 26Q) | Quarterly report to government: "here's all the TDS we deducted." Our admin API provides the data, CA files the form. | +| FEMA | Foreign exchange law. As long as we use Razorpay (RBI-licensed), we're compliant. Not our problem in code. | +| TCS | Tax Collected at Source — 0.5% the platform would collect from supplier on behalf of government under the facilitator model. CA-gated and not applicable today because the platform bills as principal (ADR 26). Different from TDS. | +| Section 194-O | E-commerce specific TDS — 0.1% on gross sales. Separate from 194J. | +| PA-CB | Payment Aggregator — Cross Border license from RBI. Needed to process international payments. Razorpay has one. | --- ## Recommended Financial SaaS Stack -| Need | Recommendation | Cost | Why | -|------|---------------|------|-----| -| Accounting | Zoho Books | Rs 1,249/mo | GST-compliant, auto-reconciliation with Razorpay, TDS management, multi-currency | -| GST Filing | ClearTax or Zoho Books built-in | Varies | Auto GSTR-1/3B/8 filing, e-invoicing | -| TDS Filing | RazorpayX (auto-TDS) + ClearTax | Included/varies | RazorpayX auto-deducts/deposits TDS, generates Form 16A | -| Invoicing | Zoho Invoice (free up to 1,000/yr) or built-in | Free–low | GST-compliant invoices with SAC codes | -| Reconciliation | Razorpay Dashboard + Zoho Books sync | Included | Auto-match payments to invoices | -| International payouts | Wise Business (later) | ~1.6% per transfer | Best FX rates, API available, for paying international consultants | +| Need | Recommendation | Cost | Why | +| --------------------- | ---------------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | +| Accounting | Zoho Books | Rs 1,249/mo | GST-compliant, auto-reconciliation with Razorpay, TDS management, multi-currency | +| GST Filing | ClearTax or Zoho Books built-in | Varies | Auto GSTR-1/3B filing, e-invoicing; GSTR-8 tooling is conditional on the facilitator model reversing ADR 26 and is not needed today | +| TDS Filing | RazorpayX (auto-TDS) + ClearTax | Included/varies | RazorpayX auto-deducts/deposits TDS, generates Form 131 | +| Invoicing | Zoho Invoice (free up to 1,000/yr) or built-in | Free–low | GST-compliant invoices with SAC codes | +| Reconciliation | Razorpay Dashboard + Zoho Books sync | Included | Auto-match payments to invoices | +| International payouts | Wise Business (later) | ~1.6% per transfer | Best FX rates, API available, for paying international consultants | ### Phased Approach @@ -279,7 +279,7 @@ PA license requirements (for reference): Rs 15 crore net worth at application, R - [ ] EU VAT registration (only if EU sales exceed thresholds) - [ ] Australian GST (only if AU sales > AUD 75K/year) - [ ] International consultant payouts (provider not yet selected; Section 195 withholding is unimplemented, see payout-service.ts) -- [ ] Form 16A auto-generation for consultants (annual TDS certificate) +- [ ] Form 131 (formerly 16A) auto-generation for consultants (annual TDS certificate) ### 🚫 NOT Our Problem @@ -302,15 +302,16 @@ PA license requirements (for reference): Rs 15 crore net worth at application, R These are **employment** obligations, not platform obligations. They have nothing to do with your SaaS code. -| Obligation | What It Is | When Mandatory | Applies to Consultants? | -|------------|-----------|----------------|------------------------| -| **PF (EPF)** | Retirement savings — employer pays 12% of basic salary | 20+ employees | No — independent contractors | -| **ESI** | Health insurance for employees earning < Rs 21,000/month — employer pays 3.25% | 10+ employees | No — independent contractors | -| **Gratuity** | Bonus after 5 years continuous service — 15 days salary per year of service | 10+ employees | No — independent contractors | +| Obligation | What It Is | When Mandatory | Applies to Consultants? | +| ------------ | ------------------------------------------------------------------------------ | -------------- | ---------------------------- | +| **PF (EPF)** | Retirement savings — employer pays 12% of basic salary | 20+ employees | No — independent contractors | +| **ESI** | Health insurance for employees earning < Rs 21,000/month — employer pays 3.25% | 10+ employees | No — independent contractors | +| **Gratuity** | Bonus after 5 years continuous service — 15 days salary per year of service | 10+ employees | No — independent contractors | ### Why Consultants Are Not Employees Platform consultants are independent contractors because they: + - Set their own prices - Set their own schedules - Use their own expertise @@ -335,14 +336,14 @@ These items are for your CA or legal advisor, not engineering. ### Before Launch (Priority) -| # | Action | Impact | Estimated Cost | -|---|--------|--------|---------------| -| 1 | CA opinion: "Are we an e-commerce operator under Section 2(45)?" | Determines GST registration, TCS, GSTR-8 obligations | Rs 5–10K one-time | -| 2 | CA opinion: "Which TDS section — 194J or 194-O or both?" | Different rates and thresholds | Part of same consultation | -| 3 | CA opinion: "Does 44AD apply to marketplace commission income?" | Determines if Sole Prop tax advantage holds | Part of same consultation | -| 4 | GST registration (if CA advises) | Must be done before first payment | Rs 2–5K | -| 5 | LUT filing for export zero-rating | Must be done before first international transaction | Free (online filing) | -| 6 | Monthly CA retainer for GST filing | GSTR-1, GSTR-3B, GSTR-8 (if applicable) | Rs 2–5K/month | +| # | Action | Impact | Estimated Cost | +| --- | ---------------------------------------------------------------- | ---------------------------------------------------- | ------------------------- | +| 1 | CA opinion: "Are we an e-commerce operator under Section 2(45)?" | Determines GST registration, TCS, GSTR-8 obligations | Rs 5–10K one-time | +| 2 | CA opinion: "Which TDS section — 194J or 194-O or both?" | Different rates and thresholds | Part of same consultation | +| 3 | CA opinion: "Does 44AD apply to marketplace commission income?" | Determines if Sole Prop tax advantage holds | Part of same consultation | +| 4 | GST registration (if CA advises) | Must be done before first payment | Rs 2–5K | +| 5 | LUT filing for export zero-rating | Must be done before first international transaction | Free (online filing) | +| 6 | Monthly CA retainer for GST filing | GSTR-1, GSTR-3B, GSTR-8 (if applicable) | Rs 2–5K/month | ### Immediate Non-Code Tasks diff --git a/docs/guides/cron-setup.md b/docs/guides/cron-setup.md index 69920a6b0..a62fd924f 100644 --- a/docs/guides/cron-setup.md +++ b/docs/guides/cron-setup.md @@ -4,6 +4,24 @@ This document explains how to set up automated cleanup of abandoned payments in the system. +## Running the Netlify scheduled ticker locally + +`netlify/functions/cron-tick.mts` is the scheduled function that drives the ten latency-sensitive `/api/cleanup/*` sweeps every five minutes in production, because GitHub Actions was measured delivering a sub-hourly schedule roughly once every hundred minutes rather than on its declared cadence (ADR 22, ADR 27). It needs two environment variables to do anything: `CRON_SECRET`, the same bearer token every `/api/cleanup/*` route already requires, and either `URL` (which Netlify sets automatically in every deployed context) or its local override, `CRON_TICK_BASE_URL`, pointed at wherever the Next app is actually listening. + +Netlify never fires a scheduled function on its declared cadence in local development, and a scheduled function cannot be invoked directly by URL either, so `netlify dev` alone will not produce a tick. Start `next dev` (or `netlify dev`, which manages one for you) with `CRON_TICK_BASE_URL` pointed at wherever it is listening, then invoke the function once through the CLI and read the same `{ event: "cron-tick", ok, lockHeld, failed, durationMs }` body it logs in production: + +```bash +CRON_SECRET=your-local-secret \ +CRON_TICK_BASE_URL=http://localhost:8888 \ +netlify dev +``` + +```bash +netlify functions:invoke cron-tick +``` + +A `lockHeld` entry for a target means `withCronLock` is already held by another run of that job — expected under a concurrent GitHub Actions run, and not a failure. A `failed` entry means the route answered something other than `200`, `207` or `409`, which is worth investigating the same way any other cleanup-route failure is. + ## Cleanup Endpoint **URL**: `/api/cleanup/abandoned-payments` diff --git a/docs/maintenance/04-cron-jobs-reference.md b/docs/maintenance/04-cron-jobs-reference.md index 9cb4b685a..046070386 100644 --- a/docs/maintenance/04-cron-jobs-reference.md +++ b/docs/maintenance/04-cron-jobs-reference.md @@ -20,14 +20,24 @@ Building that guard immediately turned up a second, larger instance of the same Two things now prevent a repeat. The Supabase clients and storage primitives a job needs live in `lib/supabase-storage-core.ts`, which carries no marker, and `lib/supabase.ts` re-exports them so application code is unchanged and still gets its client-import guard. And `__tests__/maintenance/workflow-import-env.test.ts` re-derives every scheduled workflow's import graph on each CI run, failing when one reaches a `server-only` module or when a job that reaches the Supabase client module is not given the two environment variables that module throws without. +## What changed in #1356 + +The 2026-09-03 financial audit re-measured GitHub Actions' sub-hourly schedules and confirmed the finding ADR 22 first recorded: an every-minute schedule delivers roughly once every hundred minutes rather than once a minute, because GitHub throttles scheduled workflows rather than dropping them outright. `netlify/functions/cron-tick.mts` closes that gap for the ten sweeps where the delay matters most. It is a Netlify scheduled function that fires every five minutes and POSTs each of the following, relative to `/api/cleanup/`, with a `CRON_SECRET` bearer token and a six-second per-target timeout: `sweep-stuck-webhook-events`, `cascade-refund-earnings`, `reconcile-refunds`, `abandoned-payments`, `reconcile-payment-status`, `reconcile-orphaned-confirmations`, `sweep-orphaned-topup-captures`, `dispatch-outbound-webhooks`, `sync-payment-earnings`, and `release-earnings`. ADR 27 records why this is a scheduler fix rather than a new outbox table: the durable state an outbox exists to provide already lives on the domain rows — `Payment`, `Appointment`, `Refund`, `WalletTopUp`, `WebhookEvent`, `OutboundWebhookDelivery`, `FailedEmail` — that these sweeps already walk. + +Every tick request carries a `?limit=`, and all ten routes above read it as an optional cap on the batch a single run may touch, defaulting to today's unbounded behaviour when the parameter is absent, so a nightly GitHub Actions run still processes the whole backlog while a five-minute tick stays inside its own budget. The default the ticker sends is fifty, but a target may override it: `abandoned-payments` is sent ten, because its per-row cost includes a gateway cancel round trip rather than a database write alone, and at fifty it could not finish inside the six-second per-target timeout (#1459). Whatever a smaller bite leaves behind is picked up by the unbounded GitHub Actions run. + +A tick and a GitHub Actions run of the same job cannot both proceed: both go through the same `withCronLock` every entrypoint already shares, so the loser answers 409. The ticker records a 409 as `lockHeld`, not a failure, because the run that lost the lock is a run that did not need to happen — the winner is already doing the same work. Only a response outside `200`, `207` and `409` counts as `failed`. `docs/guides/cron-setup.md` covers running the ticker locally. + +GitHub Actions is unchanged by this: it still owns every daily and weekly business cron, and for the ten sweeps above it becomes the backstop that runs regardless of ticker health rather than the primary schedule. + ## The fleet at a glance | Property | Count | | --------------------------------------- | ----- | | Scheduled workflows | 67 | | Locked via `withCronLock` | 63 | -| — fail-closed | 28 | -| — fail-open | 35 | +| — fail-closed | 29 | +| — fail-open | 34 | | Locked by a bespoke Redis lock | 2 | | Deliberately unlocked | 2 | | On the financial list | 20 | @@ -37,7 +47,7 @@ Two things now prevent a repeat. The Supabase clients and storage primitives a j Every scheduled workflow appears exactly once, grouped by the part of the product it serves. The columns mean the following. -**Schedule** is the raw cron expression in UTC, exactly as GitHub receives it. Treat it as an upper bound rather than a promise: GitHub throttles scheduled workflows under load, and a job asking for every minute has been measured delivering roughly every 2.75 hours. Minutes are deliberately staggered across the fleet so that simultaneous starts do not stampede the Supavisor connection pool, which `scripts/ci/check-workflow-hygiene.ts` enforces. +**Schedule** is the raw cron expression in UTC, exactly as GitHub receives it. Treat it as an upper bound rather than a promise, not the delivered cadence (ADR 22): GitHub throttles scheduled workflows under load, and a job asking for every minute has been measured delivering roughly once every hundred minutes. For the ten money sweeps the Netlify ticker drives — see [What changed in #1356](#what-changed-in-1356) below — the schedule column is no longer the cadence that actually holds; the ticker's five-minute interval is (ADR 27). Minutes are deliberately staggered across the fleet so that simultaneous starts do not stampede the Supavisor connection pool, which `scripts/ci/check-workflow-hygiene.ts` enforces. **Entrypoint** is the file the workflow executes. Where a second path appears beneath it, the first is a thin `jobs/**` wrapper holding the GitHub Actions plumbing — output variables, notice annotations, Sentry bootstrap — and the second is the `scripts/**` core holding the actual logic. The lock normally lives on the core so that every entry point inherits it, including the HTTP routes under `app/api/cleanup/`. @@ -51,34 +61,42 @@ Every scheduled workflow appears exactly once, grouped by the part of the produc These jobs move bookings through their lifecycle and hand back the slots that nobody paid for. They are the fleet's most visible half: when one of them stops, consultees see availability that does not exist and consultants see sessions that never close. -| Workflow | Schedule (UTC) | Entrypoint | Lock | Financial | Mutates | During maintenance | -| -------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | --------- | ---------------------------------------------------------------------------------------- | ------------------ | -| **Auto-Complete Appointments**
    `auto-complete-appointments` | `7 * * * *` | `jobs/appointments/auto-complete-appointments.ts`
    → `scripts/appointments/auto-complete-appointments.ts` | open | no | `Consultation`, `Subscription`, `Class`, `TrialSession` → COMPLETED; `ActivityLog`; Novu | Skips: OFFLINE | -| **Cleanup Invalid Appointments**
    `cleanup-invalid-appointments` | `12 * * * *` | `jobs/appointments/cleanup-invalid-appointments.ts`
    → `scripts/appointments/cleanup-invalid-appointments.ts` | open | no | Duplicate and invalid `Consultation`/`Subscription` cancelled, their slots released | Skips: OFFLINE | -| **Cleanup Stale Pending Consultations**
    `cleanup-stale-pending-consultations` | `37 * * * *` | `jobs/appointments/cleanup-stale-pending-consultations.ts`
    → `scripts/appointments/cleanup-stale-pending-consultations.ts` | open | no | Stale PENDING `Consultation` cancelled, reserved slots released | Skips: OFFLINE | -| **Cleanup Tentative Slots**
    `cleanup-tentative-slots` | `38 */2 * * *` | `jobs/appointments/cleanup-tentative-slots.ts`
    → `scripts/appointments/cleanup-tentative-slots.ts` | open | no | Tentative `SlotOfAppointment` reservations released | Skips: OFFLINE | -| **Detect Consultant No-Shows**
    `detect-consultant-no-shows` | `57 * * * *` | `jobs/appointments/detect-consultant-no-shows.ts`
    → `scripts/appointments/detect-consultant-no-shows.ts` | closed | no | `Consultation` no-show status, slot release, Novu notifications | Skips: OFFLINE | -| **Expire Reschedule Proposals**
    `expire-reschedule-proposals` | `45 * * * *` | `jobs/appointments/expire-reschedule-proposals.ts`
    → `scripts/appointments/expire-reschedule-proposals.ts` | open | no | Expired `RescheduleRequest` proposals | Skips: OFFLINE | -| **Expire Stale Requests**
    `expire-stale-requests` | `20 1 * * *` | `jobs/appointments/expire-stale-requests.ts`
    → `scripts/appointments/expire-stale-requests.ts` | open | no | Stale `Consultation` and `Subscription` requests expired | Skips: OFFLINE | -| **Expire Unpaid Trials**
    `expire-unpaid-trials` | `40 * * * *` | `jobs/trials/expire-unpaid-trials.ts`
    → `scripts/trials/expire-unpaid-trials.ts` | open | no | `TrialSession` expiry, which frees the held trial slot | Skips: OFFLINE | -| **Reconcile Orphaned Meeting Sessions**
    `reconcile-orphaned-sessions` | `25,55 * * * *` | `jobs/meetings/reconcile-orphaned-sessions.ts` | open | no | `MeetingSession` closure and slot state, reconciled against Stream calls | Skips: OFFLINE | -| **Reconcile Slot Availability**
    `reconcile-slot-availability` | `32 * * * *` | `jobs/appointments/reconcile-slot-availability.ts`
    → `scripts/appointments/reconcile-slot-availability.ts` | open | no | `SlotOfAppointment` availability re-derived from live bookings | Skips: OFFLINE | -| **Send Appointment Reminders**
    `send-appointment-reminders` | `47 * * * *` | `jobs/appointments/send-appointment-reminders.ts`
    → `scripts/appointments/send-appointment-reminders.ts` | open | no | Reads only; sends Novu reminders behind a Redis dedup key | Skips: OFFLINE | +| Workflow | Schedule (UTC) | Entrypoint | Lock | Financial | Mutates | During maintenance | +| -------------------------------------------------------------------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------ | --------- | -------------------------------------------------------------------------------------------------------------------- | ------------------ | +| **Auto-Complete Appointments**
    `auto-complete-appointments` | `7 * * * *` | `jobs/appointments/auto-complete-appointments.ts`
    → `scripts/appointments/auto-complete-appointments.ts` | open | no | `Consultation`, `Subscription`, `Class`, `TrialSession` → COMPLETED; `ActivityLog`; Novu | Skips: OFFLINE | +| **Cleanup Invalid Appointments**
    `cleanup-invalid-appointments` | `12 * * * *` | `jobs/appointments/cleanup-invalid-appointments.ts`
    → `scripts/appointments/cleanup-invalid-appointments.ts` | open | no | Duplicate and invalid `Consultation`/`Subscription` cancelled, their slots released | Skips: OFFLINE | +| **Cleanup Stale Pending Consultations**
    `cleanup-stale-pending-consultations` | `37 * * * *` | `jobs/appointments/cleanup-stale-pending-consultations.ts`
    → `scripts/appointments/cleanup-stale-pending-consultations.ts` | open | no | Stale PENDING `Consultation` cancelled, reserved slots released | Skips: OFFLINE | +| **Cleanup Tentative Slots**
    `cleanup-tentative-slots` | `38 */2 * * *` | `jobs/appointments/cleanup-tentative-slots.ts`
    → `scripts/appointments/cleanup-tentative-slots.ts` | open | no | Tentative `SlotOfAppointment` reservations released | Skips: OFFLINE | +| **Detect Consultant No-Shows**
    `detect-consultant-no-shows` | `57 * * * *` | `jobs/appointments/detect-consultant-no-shows.ts`
    → `scripts/appointments/detect-consultant-no-shows.ts` | closed | no | `Consultation` no-show status, slot release, Novu notifications | Skips: OFFLINE | +| **Expire Reschedule Proposals**
    `expire-reschedule-proposals` | `45 * * * *` | `jobs/appointments/expire-reschedule-proposals.ts`
    → `scripts/appointments/expire-reschedule-proposals.ts` | open | no | Expired `RescheduleRequest` proposals | Skips: OFFLINE | +| **Expire Stale Requests**
    `expire-stale-requests` | `20 1 * * *` | `jobs/appointments/expire-stale-requests.ts`
    → `scripts/appointments/expire-stale-requests.ts` | closed | no | Stale `Consultation` and `Subscription` requests expired, refunding SUCCEEDED payments through the refund front door | Skips: OFFLINE | +| **Expire Unpaid Trials**
    `expire-unpaid-trials` | `40 * * * *` | `jobs/trials/expire-unpaid-trials.ts`
    → `scripts/trials/expire-unpaid-trials.ts` | open | no | `TrialSession` expiry, which frees the held trial slot | Skips: OFFLINE | +| **Reconcile Orphaned Meeting Sessions**
    `reconcile-orphaned-sessions` | `25,55 * * * *` | `jobs/meetings/reconcile-orphaned-sessions.ts` | open | no | `MeetingSession` closure and slot state, reconciled against Stream calls | Skips: OFFLINE | +| **Reconcile Slot Availability**
    `reconcile-slot-availability` | `32 * * * *` | `jobs/appointments/reconcile-slot-availability.ts`
    → `scripts/appointments/reconcile-slot-availability.ts` | open | no | `SlotOfAppointment` availability re-derived from live bookings | Skips: OFFLINE | +| **Send Appointment Reminders**
    `send-appointment-reminders` | `47 * * * *` | `jobs/appointments/send-appointment-reminders.ts`
    → `scripts/appointments/send-appointment-reminders.ts` | open | no | Reads only; sends Novu reminders behind a Redis dedup key | Skips: OFFLINE | ## Money in Everything that reconciles what a consultee paid against what the gateway believes. Every job here that changes state is fail-closed, because the gateway is the authority and acting twice on its record is how a refund gets issued twice; the deadline alerter is the one exception, and it only reads. -| Workflow | Schedule (UTC) | Entrypoint | Lock | Financial | Mutates | During maintenance | -| -------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- | ------ | --------- | --------------------------------------------------------------------------------------------------------------- | ------------------------- | -| **Alert Dispute Deadlines**
    `alert-dispute-deadlines` | `2 * * * *` | `jobs/disputes/alert-dispute-deadlines.ts`
    → `scripts/disputes/alert-dispute-deadlines.ts` | open | no | Reads only; logs deadline alerts | Skips: OFFLINE | -| **Cascade Refund to Earnings**
    `cascade-refund-earnings` | `1-59/15 * * * *` | `jobs/refunds/cascade-refund-earnings.ts`
    → `scripts/refunds/cascade-refund-earnings.ts` | closed | yes | `Refund.cascadedAt`, `PaymentLeg`, consultant and org earnings, payout clawback, ledger | Skips: OFFLINE + DEGRADED | -| **Cleanup Abandoned Payments**
    `cleanup-abandoned-payments` | `6-59/15 * * * *` | `jobs/payments/cleanup-abandoned-payments.ts`
    → `scripts/payments/cleanup-abandoned-payments.ts` | closed | yes | `Payment` → EXPIRED, tentative slots released, `Consultation`/`Subscription` deleted, referral credits restored | Skips: OFFLINE + DEGRADED | -| **Handle Lost Disputes**
    `handle-lost-disputes` | `8 */6 * * *` | `jobs/disputes/handle-lost-disputes.ts`
    → `scripts/disputes/handle-lost-disputes.ts` | closed | yes | Consultant and org earnings refund amounts, `TDSRecord` reversals | Skips: OFFLINE + DEGRADED | -| **Reconcile Disputes**
    `reconcile-disputes` | `28 */6 * * *` | `jobs/disputes/reconcile-disputes.ts`
    → `scripts/disputes/reconcile-disputes.ts` | closed | yes | `Dispute` status reconciled against gateway records | Skips: OFFLINE + DEGRADED | -| **Reconcile Orphaned Confirmations**
    `reconcile-orphaned-confirmations` | `13-59/30 * * * *` | `jobs/payments/reconcile-orphaned-confirmations.ts`
    → `scripts/payments/reconcile-orphaned-confirmations.ts` | closed | no | `SlotOfAppointment.isTentative` and `Consultation`/`Subscription` approval status | Skips: OFFLINE | -| **Reconcile Payment Status**
    `reconcile-payment-status` | `18-59/30 * * * *` | `jobs/payments/reconcile-payment-status.ts`
    → `scripts/payments/reconcile-payment-status.ts` | closed | yes | `Payment` status reconciled against gateway records | Skips: OFFLINE + DEGRADED | -| **Reconcile Pending Refunds**
    `reconcile-pending-refunds` | `11-59/15 * * * *` | `jobs/refunds/reconcile-pending-refunds.ts`
    → `scripts/refunds/reconcile-pending-refunds.ts` | closed | yes | `Refund` status reconciled against the gateway; Novu notices | Skips: OFFLINE + DEGRADED | +| Workflow | Schedule (UTC) | Entrypoint | Lock | Financial | Mutates | During maintenance | +| -------------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- | ------ | --------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| **Alert Dispute Deadlines**
    `alert-dispute-deadlines` | `2 * * * *` | `jobs/disputes/alert-dispute-deadlines.ts`
    → `scripts/disputes/alert-dispute-deadlines.ts` | open | no | Reads only; logs deadline alerts | Skips: OFFLINE | +| **Cascade Refund to Earnings**
    `cascade-refund-earnings` | `1-59/15 * * * *` | `jobs/refunds/cascade-refund-earnings.ts`
    → `scripts/refunds/cascade-refund-earnings.ts` | closed | yes | `Refund.cascadedAt`, `PaymentLeg`, consultant and org earnings, payout clawback, ledger | Skips: OFFLINE + DEGRADED | +| **Cleanup Abandoned Payments**
    `cleanup-abandoned-payments` | `6-59/15 * * * *` | `jobs/payments/cleanup-abandoned-payments.ts`
    → `scripts/payments/cleanup-abandoned-payments.ts` | closed | yes | `Payment` → EXPIRED, tentative slots released, `Consultation`/`Subscription` deleted, referral credits restored | Skips: OFFLINE + DEGRADED | +| **Handle Lost Disputes**
    `handle-lost-disputes` | `8 */6 * * *` | `jobs/disputes/handle-lost-disputes.ts`
    → `scripts/disputes/handle-lost-disputes.ts` | closed | yes | Consultant and org earnings refund amounts, `TDSRecord` reversals | Skips: OFFLINE + DEGRADED | +| **Reconcile Disputes**
    `reconcile-disputes` | `28 */6 * * *` | `jobs/disputes/reconcile-disputes.ts`
    → `scripts/disputes/reconcile-disputes.ts` | closed | yes | `Dispute` status reconciled against gateway records | Skips: OFFLINE + DEGRADED | +| **Reconcile Orphaned Confirmations**
    `reconcile-orphaned-confirmations` | `13-59/30 * * * *` | `jobs/payments/reconcile-orphaned-confirmations.ts`
    → `scripts/payments/reconcile-orphaned-confirmations.ts` | closed | no | `SlotOfAppointment.isTentative`, `Consultation`/`Subscription` approval status, and `Appointment.chatChannelEnsuredAt` | Skips: OFFLINE | +| **Reconcile Payment Status**
    `reconcile-payment-status` | `18-59/30 * * * *` | `jobs/payments/reconcile-payment-status.ts`
    → `scripts/payments/reconcile-payment-status.ts` | closed | yes | `Payment` status reconciled against gateway records | Skips: OFFLINE + DEGRADED | +| **Reconcile Pending Refunds**
    `reconcile-pending-refunds` | `11-59/15 * * * *` | `jobs/refunds/reconcile-pending-refunds.ts`
    → `scripts/refunds/reconcile-pending-refunds.ts` | closed | yes | `Refund` status reconciled against the gateway; Novu notices | Skips: OFFLINE + DEGRADED | + +`cleanup-abandoned-payments` cancels each abandoned payment's gateway intent before it expires the row, and the expiry no longer waits on that cancel succeeding. A cancel that fails is recorded in `errors` and counted in `errorCount`, so the run reports `success: false` and the HTTP twin at `/api/cleanup/abandoned-payments` answers 500 rather than the 200 it used to force; the payment still moves PENDING→EXPIRED through its CAS in the same transaction that hands back the referral credits and releases the hold. Skipping that write was how a Payment came to sit PENDING for ever beside a slot that had already been released — a row no later sweep could see, because nothing about it still looked abandoned (#1464). The Stripe arm also respects the #1386 fence: with `STRIPE_ENABLED` set to anything other than `true` the sweep makes no gateway call at all and logs one line per run saying so, and when the fence is open an intent Stripe cannot find, or one that is already terminal, counts as nothing to cancel rather than as a failure. + +`reconcile-disputes` only reaches a gateway for Stripe disputes, and Stripe is fenced off in production, so when `STRIPE_ENABLED` is anything other than `true` the sweep leaves those disputes alone and counts them as `skippedFenced` rather than failing the run on a gateway we deliberately turned off (#1459); Razorpay disputes were already routed to manual dashboard review and are counted separately. Its HTTP twin no longer forces a 200 either, so a run that genuinely failed now answers 500 like every other sweep. + +`reconcile-orphaned-confirmations` runs two passes per invocation, because a capture can fail in two independent places. The first is the original #830 sweep: it finds `SUCCEEDED` payments whose slots are still tentative and re-runs the confirmation under the webhook's own Serializable discipline. The second, added by #1356, repairs the post-capture chat leg — appointments that are paid and confirmed but whose `chatChannelEnsuredAt` is still `NULL`, limited to the last seven days and processed oldest first. It calls the same `ensureChannelsForAppointment` the live pipeline calls, so the two cannot drift, and it reports `channelsEnsured` and `channelsFailed` alongside the existing counters. A run in which any channel failed answers 207 rather than 200: the next run will retry it, but an operator should see that a buyer is currently without a conversation. + +That job also has an HTTP twin at `/api/cleanup/reconcile-orphaned-confirmations`, which accepts an optional `?limit=` (a positive integer up to 500) and applies it to both passes. The GitHub Actions entrypoint has minutes to spend and uses the defaults; a Netlify ticker calling the route on a short schedule does not, and passing a small limit lets it take a bounded bite of each backlog per invocation instead of exhausting the function budget on one of them. An unparseable value is refused with a `400 INVALID_LIMIT` and a value above the cap is clamped to it, which is the shared `parseLimitParam` behaviour every other ticker target already had; the route used to swallow a malformed bound and sweep the defaults instead, which hid a broken caller behind a run that looked healthy (#1459). ## Money out @@ -90,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. @@ -113,12 +133,14 @@ Organisation billing runs on its own cycle engine, and these jobs advance it. Th These jobs exist because a statute or a regulator says they must, and their deadlines are external. Skipping one for a maintenance window is cheap; skipping one for a week is not. -| Workflow | Schedule (UTC) | Entrypoint | Lock | Financial | Mutates | During maintenance | -| ----------------------------------------------------------------------- | -------------- | ----------------------------------------------- | ------ | --------- | -------------------------------------------------------------------------------- | ------------------------ | -| **Consent Retention Sweeper**
    `consent-retention-sweeper` | `0 21 * * 0` | `jobs/compliance/consent-retention-sweeper.ts` | open | no | Expired `ConsentArtifact` rows deleted, and only when `DPDP_SWEEPER_DELETE=true` | Skips: OFFLINE | -| **DPDP DataBreach 72h Deadline Alerts**
    `databreach-deadline-alerts` | `27 * * * *` | `jobs/compliance/databreach-deadline-alerts.ts` | open | no | Reads only; emails the DPDP officer inbox | Skips: OFFLINE | -| **IRP IRN Uploader**
    `irp-uploader` | `50 2 * * *` | `jobs/compliance/irp-uploader.ts` | closed | yes | `OrganizationInvoice` IRN fields; uploads invoices to the IRP | Skips: OFFLINE, DEGRADED | -| **MSME Section 43B(h) Payment Alerts**
    `msme-payment-alerts` | `30 4 * * *` | `jobs/compliance/msme-payment-alerts.ts` | open | no | Reads only; emails the finance inbox | Skips: OFFLINE | +| Workflow | Schedule (UTC) | Entrypoint | Lock | Financial | Mutates | During maintenance | +| ------------------------------------------------------------------------- | ------------------- | ------------------------------------------------ | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | +| **Consent Retention Sweeper**
    `consent-retention-sweeper` | `0 21 * * 0` | `jobs/compliance/consent-retention-sweeper.ts` | open | no | Expired `ConsentArtifact` rows deleted, and only when `DPDP_SWEEPER_DELETE=true` | Skips: OFFLINE | +| **DPDP DataBreach 72h Deadline Alerts**
    `databreach-deadline-alerts` | `27 * * * *` | `jobs/compliance/databreach-deadline-alerts.ts` | open | no | Reads only; emails the DPDP officer inbox | Skips: OFFLINE | +| **IRP IRN Uploader**
    `irp-uploader` | `50 2 * * *` | `jobs/compliance/irp-uploader.ts` | closed | yes | `OrganizationInvoice` IRN fields; uploads invoices to the IRP | Skips: OFFLINE, DEGRADED | +| **GST Outward-Supplies Register Export**
    `gst-outward-register-export` | `40 1 3 * *` | `jobs/compliance/gst-outward-register-export.ts` | closed | yes | Mints any missing `ConsumerInvoice` for the period, then stamps `gstr1ExportedAt` on the invoices and credit notes it reported | Skips: OFFLINE, DEGRADED | +| **MSME Section 43B(h) Payment Alerts**
    `msme-payment-alerts` | `30 4 * * *` | `jobs/compliance/msme-payment-alerts.ts` | open | no | Reads only; emails the finance inbox | Skips: OFFLINE | +| **TDS quarterly return draft**
    `tds-return-draft` | `20 1 5 1,4,7,10 *` | `jobs/compliance/tds-26q-draft-export.ts` | open | no | Reads only; writes the return CSV to the private `org-invoices` bucket | Skips: OFFLINE | ## Stream and recordings @@ -173,12 +195,20 @@ The three that remain are deliberate. The same sweep closed the other half of the hole. The forty-odd HTTP twins under `app/api/cleanup/` import these job cores directly and had no guard at all, so an ops trigger ran the job straight through an OFFLINE window. They cannot call `abortIfMaintenance()`, whose `process.exit(0)` would take the Next instance down with the request, so they call `assertNotInMaintenance()` instead: the same phase rule, surfaced as a `MaintenanceActiveError` the handler answers with 503. +The GST outward-supplies register export joined that cohort in #1370. Its twin lives at `app/api/cleanup/gst-outward-register-export` and imports `runGstOutwardRegisterExport` from the job module, which is where the fail-closed `gst-outward-register-export` lock is taken, so an HTTP call that overlaps the scheduled run answers 409 instead of racing the gapless invoice series. The one thing the twin does differently is that it does not write the CSV: a serverless filesystem is read-only and nothing would collect the file, so the HTTP path heals the missing invoices, stamps `gstr1ExportedAt` and reports the counts, while the Actions run remains the way a CA actually obtains the file. + +Two more joined it in #1407, closing the last gaps in the "every job has an HTTP twin" rule. `app/api/cleanup/settle-invoice-accruals` imports `runSettleInvoiceAccruals` from `jobs/billing/settle-invoice-accruals.ts`, and `app/api/cleanup/tds-return-draft` imports `runTdsReturnDraftExport` from `jobs/compliance/tds-26q-draft-export.ts`. In both cases the exported function is where the cron lock is taken — fail-closed on the accrual rollup, because two overlapping runs would each roll the same accrual set into an invoice, and fail-open on the return draft, because that export only reads. Neither exported core calls `abortIfMaintenance()`; the maintenance guard stays on the entry points, so the Actions run exits the process and the route answers 503. + +The TDS job needed one structural change to make this possible. Its `main()` used to run at import, which meant that merely importing the module from a route would have fired the quarterly export; it is now behind the usual `require.main === module` guard, the shape the other ten job modules already use. The twin runs with the same defaults as the schedule — the quarter that closed, not the quarter containing today — writes the same full-PAN CSV to the private finance bucket, and returns the storage path instead of a GitHub artifact, since there is no artifact store on the HTTP path. + ## Locking `withCronLock` (`lib/cron/with-cron-lock.ts`) provides distributed mutual exclusion keyed `cron:lock:`, with a fifteen-minute TTL by default and thirty-five minutes for the payout and reconcile family. It exists because the same job can be entered three ways — the schedule, a manual `workflow_dispatch`, and an authenticated HTTP call — and jobs whose side effects are only partially idempotent must not run twice concurrently. It is a mutual-exclusion tool and nothing more. Data correctness comes from compare-and-set transitions and unique constraints, never from this lock, because Redis and PostgreSQL are separate failure domains and the lock can be lost without the database noticing. ADR 13 records that reasoning in full. +Every scheduled workflow also declares a workflow-level `concurrency: { group: ${{ github.workflow }}, cancel-in-progress: false }`, a second and redundant guard at the Actions layer that queues an overlapping run rather than killing one mid-flight; `withCronLock` remains the correctness guard, and `__tests__/maintenance/cron-lock-registry.test.ts` asserts the concurrency block is present on every scheduled entry so the two guards cannot drift apart. GitHub Actions allows one running and one pending run per concurrency group by default, and a newer run replaces the pending one rather than queuing behind it; the twelve workflows that also accept `workflow_dispatch` add `queue: max` so a second manual trigger cannot silently displace one already waiting, up to a hundred pending runs. + Four scheduled workflows do not use it, each for a stated reason. | Workflow | Mechanism | Why not `withCronLock` | diff --git a/docs/payments/01-architecture.md b/docs/payments/01-architecture.md index c1e26b606..d0a43ae1d 100644 --- a/docs/payments/01-architecture.md +++ b/docs/payments/01-architecture.md @@ -19,7 +19,17 @@ ## Overview -The payment system uses **Razorpay** as the sole active payment gateway, with Stripe retained as a secondary rail for Connect transfers. `DODO_PAYMENTS` exists in the `PaymentGateway` enum as a post-MVP placeholder with no implementation behind it; `POST_MVP_GATEWAY_STUBS` in `lib/payments/constants.ts` is the list, and `assertGatewayUsable` refuses one at runtime. The gateway comparison that led here is recorded in [gateways/gateway-evaluation-mar-2026.md](./gateways/gateway-evaluation-mar-2026.md). It handles four appointment types: +The payment system uses **Razorpay** as the sole active payment gateway. Stripe is implemented but fenced off, and `DODO_PAYMENTS` exists in the `PaymentGateway` enum as a post-MVP placeholder with no implementation behind it. `POST_MVP_GATEWAY_STUBS` in `lib/payments/constants.ts` is the placeholder list, and `assertGatewayUsable` in `lib/payments/validation/gateway-guards.ts` refuses both a placeholder and a fenced-off gateway at runtime. The gateway comparison that led here is recorded in [gateways/gateway-evaluation-mar-2026.md](./gateways/gateway-evaluation-mar-2026.md). + +| Gateway | Status | How it is gated | +| ----------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Razorpay** | Live, primary | No flag. `routeGateway` selects it for every buyer country, domestic directly and international over IBT. | +| **Stripe** | Implemented, fenced off | `STRIPE_ENABLED=true` on the server and `NEXT_PUBLIC_STRIPE_ENABLED=true` in the checkout UI. Auto-routing never selects it; only an explicit request reaches it, and `assertGatewayUsable` throws a `DisabledGatewayError` when the flag is unset. Refunds of existing Stripe payments are deliberately outside the fence. | +| **Dodo Payments** | Schema placeholder | Listed in `POST_MVP_GATEWAY_STUBS`. Any use throws `UnsupportedGatewayError`. | + +Stripe is retained as a contingency rail in case RBI rules make Razorpay unusable for a class of collections, and for Connect transfers if international payouts are ever turned on. It is not a live payment method, so no customer should ever see the Stripe button. + +The system handles four appointment types: | Type | Description | Slot Handling | | ---------------- | -------------------- | ---------------------------------- | @@ -963,12 +973,17 @@ AppointmentStatus: ### Gateway Support Matrix -| Feature | Stripe | Razorpay | -| ------------------- | ------ | ------------------- | -| Dispute Webhooks | Yes | Yes | -| List Disputes API | Yes | No (Dashboard only) | -| Submit Evidence API | Yes | No (Dashboard only) | -| Retrieve Dispute | Yes | No | +The matrix below covers the dispute surface, where the two gateways differ most, and the settlement currency, where they deliberately do not differ at all. + +| Feature | Stripe | Razorpay | +| -------------------- | ------------------------------- | ------------------------------- | +| Dispute Webhooks | Yes | Yes | +| List Disputes API | Yes | No (Dashboard only) | +| Submit Evidence API | Yes | No (Dashboard only) | +| Retrieve Dispute | Yes | No | +| Settlement currency | INR only, enforced at order creation | INR only, enforced at order creation | + +Settlement is INR-only by design, per [ADR 15](../enterprise/70-design-decisions/15-currency-as-enum-with-display-fields.md): every stored amount is an integer count of INR paise and the double-entry ledger is INR-denominated. That is enforced rather than assumed. `assertInrSettlement`, in `lib/payments/validation/currency-guards.ts`, is the first statement of both `createRazorpayOrder` and `createStripeCheckoutSession`, and it throws a `PaymentError` with code `NON_INR_SETTLEMENT` for anything else. The assertion sits at the gateway boundary rather than at each caller because callers read a currency out of the database — an organisation's billing account, an invoice's display currency, an overage event — and any one of them forwarding a stale non-INR value would otherwise mint an order denominated in that currency's own subunit while the platform recorded rupees. An international buyer is still served an INR order; their card issuer performs the conversion. See [multi-currency/01-architecture.md](./multi-currency/01-architecture.md) for the display-side story. --- diff --git a/docs/payments/05-b2c-b2b-funding-seam.md b/docs/payments/05-b2c-b2b-funding-seam.md index 8211601fb..11038a6c1 100644 --- a/docs/payments/05-b2c-b2b-funding-seam.md +++ b/docs/payments/05-b2c-b2b-funding-seam.md @@ -66,7 +66,17 @@ Each `Payment` carries zero or more `PaymentLeg` rows (append-only; `prisma/sche Legs are **append-only** — a refund never mutates the original leg; it upserts a negative `*_REVERSAL` sibling (`lib/payments/operations/refund.ts:778`). The `@@unique([paymentId, source])` constraint means one reversal leg per source; subsequent partial refunds decrement the existing reversal leg via `update: { amountPaise: { decrement: reverse } }`. -Invariant: `sum(non-reversal legs.amountPaise) == Payment.amount` when legs are present (LICENSE legs contribute 0). +Invariant: `sum(non-reversal, non-REFERRAL_CREDIT legs.amountPaise) == Payment.amount` when legs are present (LICENSE legs contribute 0). The referral credit sits outside the sum because `Payment.amount` is the post-credit gateway charge, so the credit has already been deducted from it and counting the leg as well would demand it twice (#1347). See [payment legs §3](../enterprise/10-money-and-ledger/09-payment-legs.md#3-invariants). + +### Programme overage across the rails (#1458) + +A booking that breaches its programme's cap does not settle the same way on every rail, because the rails collect at different moments. + +On the **INVOICE** rail the org has not paid anything yet, so the marginal is carved out of the base `INVOICE_ACCRUAL` leg into an `OVERAGE_INVOICE_ACCRUAL` leg and billed at the month-end rollup. On the **WALLET** rail the debit taken when the booking committed is the whole nominal price, so the overage is collected the moment the booking commits: no `OVERAGE_INVOICE_ACCRUAL` leg is written, `Payment.amount` is left exactly at the wallet debit, and the `OverageEvent` is recorded as `CHARGED` and settled against the payment whose `WALLET` leg collected it. Writing a leg there would have broken the leg-sum invariant above and, worse, incrementing `Payment.amount` on top of it made a later cancellation refund the organisation more than its wallet was ever debited. + +`CHARGE_MEMBER` is **not available on a WALLET-funded billing account**. Charging the member requires carving the over-cap portion back out of the parent payment, which on this rail would mean crediting the wallet mid-transaction — the credit-back that #715 has never built. The combination is refused when a programme is created or patched, and checkout keeps a fail-closed refusal (`OVERAGE_CHARGE_MEMBER_UNSUPPORTED`, HTTP 409) for any programme configured before that guard existed. + +`PROGRAM_CAP_EXHAUSTED` is the contract for the per-cycle overage ceiling. The settlement code throws it as an HTTP 402 with a machine-readable code, the checkout transaction's catch rethrows it unchanged because that code is registered in `BUSINESS_ERROR_CODES`, and the route answers 402 with a toast telling the buyer that the organisation's programme budget for this cycle is used up. It is a modelled outcome, so Sentry records it as expected volume rather than a fault; the two overage funding refusals above are deliberately not modelled, because they mean a programme was configured in a shape no rail can collect on. --- diff --git a/docs/payments/06-high-level-design.md b/docs/payments/06-high-level-design.md new file mode 100644 index 000000000..48ce9dc18 --- /dev/null +++ b/docs/payments/06-high-level-design.md @@ -0,0 +1,169 @@ +# Money subsystem — high-level design in four diagrams + +This page is the map a newcomer should read before any other payments document. It shows where money truth is written, what is allowed to lag behind it, and which mechanism closes each gap. Everything drawn here runs inside one Next.js application against one Postgres database and one Redis instance. There are no services, no queues and no message brokers; the domain rows themselves carry every pending obligation, and scheduled sweeps read those rows to finish the work (ADR 14, ADR 22 and ADR 27 explain why that posture was chosen over a broker). + +The diagrams describe the code as it stands after the 2026-09-03 finance train (PRs #1385, #1386, #1389, #1390, #1391, #1393, #1392 and #1414). Where a box only exists because of one of those PRs, the PR number is written on it. The verdict record that motivated the train lives in [audits/2026-09-03-finance-verdicts.md](./audits/2026-09-03-finance-verdicts.md). + +## 1. B2C: a consultee pays for a session + +The first diagram follows a single booking from the checkout request to the moment every side effect exists. The synchronous part is one Serializable database transaction taken under a Redis slot lock; it writes the Payment, its funding legs and the appointment hold together, so either all three exist or none does. The gateway then calls back asynchronously, and the webhook row is saved before the request is acknowledged, which makes the inbound event durable: the platform can redrive the persisted row without requiring Razorpay to resend it. One writer, running in one transaction, moves the money state and posts the ledger. Everything after that commit is best effort and is re-driven by the sweeps at the bottom. + +```mermaid +flowchart TB + U["Consultee (browser)"] + subgraph Sync["Synchronous request — one Serializable DB transaction, Redis slot lock held"] + CO["POST /api/checkout
    price → discount → 18% GST → referral credits"] + PAY["Payment PENDING (amount = gateway charge)
    + PaymentLegs (Σ non-credit legs = amount, DB trigger, #1385)"] + APPT["Appointment PENDING_PAYMENT
    slot hold via CAS + GiST exclusion"] + end + RZP[("Razorpay")] + subgraph Inbox["Webhook inbox — at-least-once, persisted before the 200"] + WE["WebhookEvent row
    (x-razorpay-event-id unique, deferCount #1391)"] + end + subgraph Writer["Single writer (ADR 21) — one DB transaction"] + HS["handlePaymentSuccess"] + P2["Payment SUCCEEDED + gatewayPaymentId (#1391)"] + A2["Appointment CONFIRMED (CAS in WHERE)"] + EARN["Earnings rows (consultant / platform split from the RateCard snapshot)"] + LED["LedgerTransaction + LedgerEntry rows
    (double entry; trigger rejects an unbalanced transaction)"] + AUD["SystemEvent audit row"] + end + subgraph After["Best effort after commit — idempotent, may fail"] + INV["ConsumerInvoice FAM-FY-SEQ tax invoice (#1393)"] + CH["Stream chat channel"] + MAIL["Emails / Novu"] + end + subgraph Sweeps["State-as-outbox sweeps — Netlify ticker every 5 min (#1390), GitHub Actions as backstop"] + S1["sweep-stuck-webhook-events"] + S2["reconcile-orphaned-confirmations + channel ensure-step (#1391)"] + S3["reconcile-payment-status (DB ↔ gateway)"] + S4["abandoned-payments (expire PENDING)"] + S5["gst-outward-register healer (missing invoices, #1393)"] + end + U --> CO --> PAY --> APPT + PAY -- "order_id (INR asserted, #1414)" --> RZP + U -- "pays" --> RZP -- "payment.captured" --> WE --> HS + HS --> P2 --> A2 --> EARN --> LED --> AUD + AUD -.-> INV + AUD -.-> CH + AUD -.-> MAIL + WE -. "deferred / crashed mid-way" .-> S1 --> HS + P2 -. "SUCCEEDED but no channel" .-> S2 --> CH + P2 -. "SUCCEEDED but no invoice" .-> S5 --> INV + RZP <-. "status drift" .-> S3 + PAY -. "never paid" .-> S4 +``` + +Three guarantees follow from this shape. The buyer's money state is exact at the moment the writer commits, because the Payment, the appointment, the earnings and the ledger entries are in the same transaction. Side effects are eventually consistent and are retried by the listed sweeps where a sweep exists; the five-minute ticker cadence is the normal retry interval, not a hard completion bound. Every sweep is idempotent, so a sweep and a webhook retry racing each other cannot double-post anything. + +## 2. B2C: refunds and consultant payouts + +The second diagram covers money leaving the platform on the consumer side. A refund is two-phase: the Refund row reserves the amount before the gateway is called, the gateway call carries the Refund id as its idempotency key, and the ledger reversal, the earnings clawback and the credit note are written only when the gateway confirms. A consultant payout releases earnings after the hold period, batches them, and writes the TDS record only when the RazorpayX webhook says the money moved. + +```mermaid +flowchart LR + subgraph Refund["Refund — two-phase, one front door"] + RQ["Refund quote (cancellation policy in basis points)"] + RR["Refund row PENDING (reserves the amount)"] + RG["Gateway refund
    X-Refund-Idempotency = Refund.id"] + RW["refund.processed webhook (found by gatewayPaymentId, #1391)"] + RL["Ledger reversal + earnings clawback
    + ConsumerCreditNote FAM-CN-FY-SEQ (#1393)"] + RS["reconcile-refunds / cascade-refund-earnings sweeps"] + end + subgraph Payout["Consultant payout"] + REL["release-earnings (after the hold period)"] + PB["ConsultantPayout batch (BATCHED)"] + RX[("RazorpayX")] + PW["payout webhook → PAID
    TDSRecord written here (s.194-O, 0.1%)"] + TR["tds-return-draft, quarterly (#1389)
    Form 140 CSV; full PAN only in the private bucket"] + end + RQ --> RR --> RG --> RW --> RL + RR -. "stuck" .-> RS --> RG + REL --> PB --> RX --> PW --> TR +``` + +The refund path has a single front door on purpose. Every rail that can return money to a buyer, whether a cancellation, a dispute, a removed event seat or an admin action, must produce a Refund row first, so the reserve, the idempotency key and the credit note are never skipped. + +## 3. B2B: an organisation funds its members + +The third diagram shows what changes when an organisation pays. The checkout, the price derivation, the GST calculation, the single writer and the ledger are the same code as the consumer path. What differs is the funding leg: a wallet, a licence seat, an invoice accrual or an overage charge replaces the card, and for wallet and licence legs no gateway call happens at all. Accrued legs roll up into one organisation invoice per month, and organisations that sell on the platform receive payouts with their own TDS records. + +```mermaid +flowchart TB + subgraph Org["Organisation"] + BA["BillingAccount
    rails: WALLET (prepaid) | INVOICE (net terms) | PO; currency INR only (#1414)"] + POOL["Seat pool / credit pool → allocations per member"] + OVR["Overage policy: BLOCK | CHARGE_MEMBER | CHARGE_ORG (circuit breaker)"] + end + subgraph Fund["Funding"] + TOP["Wallet top-up → Razorpay order → capture webhook → wallet ledger entries"] + PO["PurchaseOrder (remainingAmountPaise)"] + end + subgraph Book["Member books — org-funded checkout"] + MC["Same checkout, same price derivation, same GST
    PaymentLeg source = WALLET / LICENSE / INVOICE_ACCRUAL / OVERAGE"] + MP["Payment SUCCEEDED without a gateway call for wallet and licence legs
    same single writer, same ledger, same earnings"] + end + subgraph Bill["Monthly billing"] + ROLL["consolidated-invoice-rollup
    INVOICE_ACCRUAL legs → one OrganizationInvoice"] + OINV["OrganizationInvoice PDF (GST heads; IRN later)
    dunning; PO balance decrement in the same currency"] + OPAY["Organisation pays → capture → invoice PAID"] + end + subgraph OrgPayout["Org-side revenue share (partner organisations that sell)"] + OP["OrganizationPayout batch → RazorpayX"] + OT["TDSRecord at COMPLETED, organisation as deductee (#1389)"] + end + subgraph CA["Compliance exports for the CA"] + REG["gst-outward-register, monthly (#1393)
    consumer + org invoices + credit notes"] + TDS["tds-return-draft, quarterly (#1389)
    consultant + organisation deductees"] + end + BA --> POOL --> OVR + TOP --> BA + PO --> BA + POOL --> MC --> MP + OVR --> MC + MP --> ROLL --> OINV --> OPAY + PO -. "gates" .-> OINV + MP --> OP --> OT --> TDS + OINV --> REG +``` + +The three axes that make enterprise finance look complicated, namely the organisation's shape, its funding source and the programme type, are product facts rather than engineering choices. The code keeps them orthogonal so that a new combination is a configuration, not a new code path. + +## 4. Cross-cutting: truth, audit, schedulers and reconciliation + +The last diagram separates the four layers that the first three diagrams mix together. Money truth is strongly consistent and guarded by the database itself. The audit trail is a set of append-only rows written inside the same transactions, so it cannot disagree with the money. The schedulers are the only asynchronous machinery, and they do nothing but call HTTP routes that already exist. Reconciliation has exactly four purposes, and every scheduled money job maps to one of them. + +```mermaid +flowchart LR + subgraph Truth["Money truth — strongly consistent, one transaction"] + T1["Payment / Refund / Payout rows
    status moves only through the CAS-in-WHERE helpers"] + T2["Double-entry journal: LedgerTransaction + LedgerEntry
    balances are derived; walletBalance is a reconciled cache"] + T3["DB guards: leg-sum trigger, ledger-balanced trigger, CHECK sidecars"] + end + subgraph Audit["Audit trail — append-only rows"] + A1["OrgAuditLog (who did what inside an organisation)"] + A2["SystemEvent / recordSystemError (operations timeline)"] + A3["SystemJobExecution (every cron run, its lock, its outcome)"] + A4["WebhookEvent (raw inbound payload, retries)"] + end + subgraph Sched["Schedulers — no broker"] + G["GitHub Actions cron
    nightly and backstop runs (sub-hourly schedules fire only every ~100 min)"] + N["Netlify scheduled function cron-tick.mts (#1390)
    every 5 min, ten money routes, bounded by ?limit"] + R["app/api/cleanup/* routes
    CRON_SECRET + withCronLock (fail-closed for money jobs)"] + end + subgraph Recon["Reconciliation — four purposes"] + R1["DB ↔ gateway status"] + R2["DB ↔ journal caches"] + R3["crash-gap re-drives"] + R4["time expiry"] + end + G --> R + N --> R + R --> Recon + Recon --> Truth + Truth --> Audit +``` + +## When this design should change + +A message broker earns its place when a second, independently deployed consumer needs the same events, when inbound webhooks sustain tens of events per second, or when the application leaves a serverless host and can run a consumer process around the clock. None of those conditions holds today, and the first step when one does is an HTTP queue such as QStash driving the same cleanup routes (issue #866), not Kafka. Until then, the cost of this design is readability rather than correctness: a reader has to know the sweeps exist, which is why ADR 27 lists every one of them. diff --git a/docs/payments/07-b2c-tax-invoice.md b/docs/payments/07-b2c-tax-invoice.md new file mode 100644 index 000000000..51d243d61 --- /dev/null +++ b/docs/payments/07-b2c-tax-invoice.md @@ -0,0 +1,132 @@ +# B2C Tax Invoices and Credit Notes + +> How a personal buyer gets the statutory tax invoice for their booking, how a refund reverses it with a credit note, and how both reach the monthly GSTR-1 working file. Introduced by #1365 and #1370. + +--- + +## Why this exists + +The platform bills as the principal supplier for GST, which ADR 26 records as a locked decision. Checkout charges 18% on the discounted price through `lib/payments/pricing/derive-checkout-amount.ts` and `lib/payments/tax/tax-engine.ts`, and settlement credits `GST_PAYABLE` in `lib/payments/payouts/earnings-service.ts`. Every one of those charges is our own outward supply, so every one of them needs a document that satisfies CGST Rule 46. + +Organizations already had that document: an `OrganizationInvoice` with its own gapless per-org series, its own IRP e-invoice fields and its own dunning lifecycle. Personal buyers had nothing. Between the v0 lockdown in #768 and this change, a consumer who paid 18% GST received a payment confirmation and no invoice at all, and there was no register from which the platform's own outward supplies could be filed. + +This change adds the document trail and nothing else. It posts no ledger entries, it derives no tax from a rate, and it builds no IRN. Those exclusions are deliberate and are listed in full at the end of this page. + +## What is minted, and when + +Two models carry the documents, both defined in the invoicing section of `prisma/schema.prisma`. + +| Model | What it is | Keyed by | +| -------------------- | -------------------------------------------------------------------- | --------------------------------------- | +| `ConsumerInvoice` | The Rule 46 tax invoice for one successful consumer payment. | `paymentId`, unique. | +| `ConsumerCreditNote` | The section 34 credit note that reverses part or all of one invoice. | `refundId` or `disputeId`, both unique. | + +`mintConsumerInvoice` in `lib/payments/billing/consumer-invoice.ts` is called from two places, because a payment reaches its confirmed state by two different routes. The capture webhook calls it from `lib/payments/webhooks/handlers.ts` once the booking is confirmed, and the instant-confirm branch of `lib/payments/operations/checkout.ts` calls it for mock, zero-amount and org-sponsored checkouts, which never see a webhook at all. + +Both call sites wrap the mint in a try/catch that reports to Sentry at warning level and never rethrows. A confirmed booking must not roll back because a document could not be produced, and the monthly register export re-attempts anything that was missed. + +The mint is a silent no-op, returning a null id rather than throwing, in each of these cases: + +- the payment is not `SUCCEEDED`, or has been soft-deleted; +- the payment is org-funded, meaning it carries `billableToOrgInvoiceId` or any payment leg sourced from `WALLET`, `LICENSE`, `INVOICE_ACCRUAL` or `OVERAGE_INVOICE_ACCRUAL`. Those supplies are invoiced to the organization on the org series instead, and giving them a second document would double-count the same supply; +- `getPlatformSupplier()` returns null because `PLATFORM_GSTIN` is unset or malformed. The process logs this once. Issuing a legal-looking invoice with a fabricated GSTIN is worse than issuing none; +- the reconstructed total is zero or less. + +The idempotency probe on `paymentId` runs before any sequence number is allocated. That ordering is load-bearing: a webhook redelivery that allocated a number first and then discovered the invoice already existed would leave a permanent gap in a gapless statutory series. + +## Numbering + +Consumer documents run on a platform-wide series, not a per-buyer one, because the supplier is the platform and the series belongs to the supplier. Two counter tables hold the sequences, `platform_invoice_counters` and `platform_credit_note_counters`, and each allocation is an atomic upsert that returns the pre-increment value, exactly as the org counters do. + +| Document | Format | Example | Ceiling | +| ----------- | ------------------------- | ------------------ | ----------------------- | +| Invoice | `--` | `FAM-2026-00001` | 99,999 per fiscal year. | +| Credit note | `-CN--` | `FAM-CN-2026-0001` | 9,999 per fiscal year. | + +`PREFIX` comes from the optional `PLATFORM_INVOICE_PREFIX` environment variable and defaults to `FAM`. It passes through `fitPrefixToRule46`, which caps the whole number at the sixteen characters Rule 46(b) allows. The credit-note series is separate from the invoice series because Rule 53 requires it to be. Both use `indianFiscalYear`, so a document issued in March lands in the previous fiscal year, reckoned in IST. + +## Place of supply + +Section 12(2)(b) of the IGST Act says that where the recipient's address is not on record, a B2C supply is made at the **supplier's** location. That is the opposite of the rule the B2B path follows: `deriveGstBreakdown` falls back to IGST on an unknown buyer state and records `IGST_STATE_UNKNOWN`, because a registered buyer is expected to have a state and a missing one is a defect worth surfacing. For a consumer, a missing state is the statutory norm. + +`deriveConsumerInvoiceTax` is a pure function that implements this. It never re-derives tax from a rate; the taxable value is the charged total minus the charged tax, so the document agrees with the `GST_PAYABLE` credit to the paise. + +The supplier's own state is settled before that derivation runs. `resolveSupplierStateCode` reads the first two digits of `PLATFORM_GSTIN`, which are the state of registration by law, and falls back to `SUPPLIER_STATE_CODE` only when the GSTIN carries none. The same resolved value is handed to the derivation and stored on the row, so the heads on the document can never disagree with the state printed beside them. When the GSTIN and the environment variable name different states the mint fails closed exactly as a missing GSTIN does: no document is issued, a Sentry warning and a `SystemEvent` name both values, and the monthly register healer re-attempts the payment once the configuration is fixed. + +| Buyer state | Result | `placeOfSupplySource` | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| Same as the supplier's | CGST and SGST, split with CGST floored and SGST absorbing the odd paise | `DECLARED_AT_CHECKOUT` or `PROFILE_ON_RECORD` | +| A different state | IGST for the whole tax | `DECLARED_AT_CHECKOUT` or `PROFILE_ON_RECORD` | +| Absent or unresolvable | Placed at the supplier's own state, so CGST and SGST | `SUPPLIER_DEFAULT_12_2_B` | +| Outside India | Delegated to `deriveGstBreakdown` for the LUT gate, then mapped back with the amounts kept anchored to what was charged | Either, depending on whether a state was given | + +Where the source is `SUPPLIER_DEFAULT_12_2_B`, the rendered PDF carries the footnote "Place of supply determined under s.12(2)(b) IGST Act (no address of the recipient on record)", so a reader can tell a defaulted place of supply from a declared one. + +## Capturing the buyer's state + +Checkout can ask for the buyer's state, but it never insists. `app/checkout/components/BillingStateSelect.tsx` is mounted on the consultation, subscription, webinar and class checkout pages, labelled "Billing state (for GST)", and it submits the two-digit numeric code that the GST portal, the invoice and `lib/compliance/gst.ts` all compare on. Leaving it blank is a correct answer, not an incomplete one, because the statutory default applies. + +The declared value is written to `Payment.consumerStateCode` in the same transaction that creates the payment, and to `ConsulteeProfile.billingStateCode` when it differs from what the profile already held, so a repeat buyer is never asked twice. `/api/checkout/context` returns the remembered value and the checkout pages pre-fill the picker with it. + +At mint time the resolution order is the declaration on the payment, then the profile, then the statutory default, and `placeOfSupplySource` records which of the three applied. + +## Credit notes on refund + +A refund never deletes or rewrites the invoice. `mintConsumerCreditNote` issues a section 34 credit note beside it, and the pair is what reconciles. + +The reversal is strictly proportional over the tax-inclusive total and it keeps the same tax head the invoice used, because a credit note may not move a supply from one head to another. + +The cap is cumulative rather than per-note. `mintConsumerCreditNote` sums the `totalPaise` of every note already issued against the invoice, inside the same transaction, and credits at most the remainder. This matters because a partial refund and a later lost chargeback are two different idempotency keys against one invoice, so neither one's probe short-circuits the other; without the cumulative cap the pair could reverse more than the platform ever charged and understate the period's output tax. When the invoice is already credited in full the note is refused, a `SystemEvent` is recorded so an operator sees it, and the caller receives a null identifier. + +The heads are prorated from the invoice's total tax and then split again by the same floor-CGST rule the invoice itself used, rather than each head being prorated on its own. Flooring three heads independently leaves the stored row short of its own total by a paise or two, which the register reports as a reconciliation warning. Prorating once and letting the taxable value absorb the residual makes the identity `taxable + CGST + SGST + IGST == total` hold by construction, and no head on a note can exceed the corresponding head on the invoice. + +It is called from two places, mirroring the org-side `mintRefundCreditNote` it sits beside: Step 7.5 of the refund cascade in `lib/payments/operations/refund.ts`, and the lost-chargeback branch of `app/api/webhooks/utils.ts`. Both are idempotent on their own trigger, so a webhook redelivery or a cron retry re-reads the existing note. + +## Downloading a document + +| Route | Returns | +| -------------------------------------------------------------- | ------------------------------------------------------------------- | +| `GET /api/payments/[paymentId]/invoice/pdf` | A 302 to a 24-hour signed URL for the tax invoice. | +| `GET /api/payments/[paymentId]/credit-note/[creditNoteId]/pdf` | The same, for a credit note that belongs to that payment's invoice. | + +Both routes authorise the payment's own buyer or an ADMIN/STAFF operator, apply the `moneyOpsLimiter` bucket per actor, and return 503 with code `SUPPLIER_GSTIN_UNCONFIGURED` when `PLATFORM_GSTIN` is missing, or 404 with code `INVOICE_NOT_ISSUED` when no document exists. The PDF is rendered on the first request, uploaded to the existing private `org-invoices` bucket under a `consumer//.pdf` path, cached on the row for twenty-four hours, and re-signed without re-rendering on subsequent hits. + +The documents render from the snapshot stored on the row rather than from live supplier and buyer records, because a tax invoice must keep saying what it said on the day it was issued. + +Both PDFs register Noto Sans Devanagari from `public/fonts/` and apply it to the buyer's name and address only. Helvetica, which the org documents use throughout, has no Devanagari coverage, so a buyer writing in Hindi or Marathi would otherwise see their own name as a row of boxes. The font is read from a copy traced into the deployment bundle by `outputFileTracingIncludes` in `next.config.mjs`, never fetched over the network, and registration falls back to Helvetica if the file is absent rather than failing the download. + +The invoice number and a download link appear on the admin payment list and detail pages, and on the consultee's own payments tab. An empty cell there means the payment was org-funded, which is the correct answer rather than a missing document. + +## The outward-supplies register + +`jobs/compliance/gst-outward-register-export.ts` runs on the third of each month for the previous IST calendar month, well ahead of the eleventh-of-the-month GSTR-1 deadline. `GST_REGISTER_PERIOD_START` and `GST_REGISTER_PERIOD_END` override the period and must be set together; the workflow exposes them as dispatch inputs. + +It does four things in order. First it heals: any `SUCCEEDED`, non-deleted payment in the period with no consumer invoice is minted, each in its own short transaction, and a non-zero count is warned about because it means the checkout mint path missed something. Then it reads every `ConsumerInvoice`, issued `OrganizationInvoice`, `ConsumerCreditNote` and issued `CreditNote` in the period and shapes them into one register through the pure builder in `lib/compliance/gst-outward-register.ts`. Then it writes the CSV to `GST_REGISTER_CSV_OUT`, which the workflow uploads as a ninety-day artifact. Finally it stamps `gstr1ExportedAt` on everything it reported, in one transaction, guarded on the stamp being null so a second run in the same month reports the same documents again without moving anyone's first-reported timestamp. + +The CSV header is fixed, because the CA's import template depends on it: + +``` +doc_type,doc_number,doc_date,buyer_type,buyer_gstin,place_of_supply,taxable_paise,cgst_paise,sgst_paise,igst_paise,total_paise,sac_code,original_invoice_number,payment_id +``` + +The builder raises a warning for any document with no place of supply, any document whose tax heads do not reconcile to the total minus the taxable value, and any B2C document of ₹50,000 or more that lacks the recipient's address and state. + +The job is fail-closed on its cron lock and is on the financial job list, unlike the read-only compliance drafts beside it. Its healer allocates numbers from a gapless statutory series, and two concurrent runs would both see a payment as un-invoiced, both take a number, and one would lose the unique constraint, leaving a gap that cannot be filled. Not producing the register is recoverable; a gap in the series is not. + +## The ₹50,000 flag + +Rule 46 requires a B2C invoice of ₹50,000 or more to carry the recipient's name, address and state. The mint still issues the invoice when those are missing, because withholding a buyer's document is a worse outcome than issuing an incomplete one, and sets `needsBuyerAddress` on the row. The register turns that flag into a warning line so finance can chase the address before filing. + +## What this deliberately does not do + +- **It posts nothing to the ledger.** Output tax is already credited to `GST_PAYABLE` at settlement. A second posting from the document trail would double-count the liability. +- **It derives no tax from a rate.** The heads are split out of the tax the buyer actually paid. A rate-recomputed figure would drift from the settled amount the first time a discount or a rounding boundary moved. +- **It builds no IRN.** B2C is outside the e-invoicing scope; the IRP fields on `OrganizationInvoice` have no counterpart here. +- **It does not handle GST-TCS under section 52.** That remains with `jobs/compliance/gstr8-draft-export.ts`. +- **It does not block checkout.** The billing-state picker is optional by design, because the statutory default already produces a correct invoice. + +## Related + +- [B2C ↔ B2B funding seam](./05-b2c-b2b-funding-seam.md) +- [Invoicing (B2B)](../enterprise/10-money-and-ledger/08-invoicing.md) +- [Cron jobs reference](../maintenance/04-cron-jobs-reference.md) diff --git a/docs/payments/README.md b/docs/payments/README.md index 8f60f7b04..d62e26172 100644 --- a/docs/payments/README.md +++ b/docs/payments/README.md @@ -8,12 +8,15 @@ Complete documentation for the Familiarise payment system — checkout, gateways ## Overview -| # | Document | Description | -| --- | -------------------------------------------------------- | ------------------------------------------------------- | -| 01 | [Architecture](./01-architecture.md) | System design, database models, complete data flow | -| 02 | [Setup](./02-setup.md) | Payment gateway configuration, environment variables | -| 03 | [Status Enums Reference](./03-status-enums-reference.md) | All payment, refund, dispute, and booking status values | -| 04 | [Abandoned Solutions](./04-abandoned-solutions.md) | Previous approaches and why they were abandoned | +| # | Document | Description | +| --- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| 01 | [Architecture](./01-architecture.md) | System design, database models, complete data flow | +| 02 | [Setup](./02-setup.md) | Payment gateway configuration, environment variables | +| 03 | [Status Enums Reference](./03-status-enums-reference.md) | All payment, refund, dispute, and booking status values | +| 04 | [Abandoned Solutions](./04-abandoned-solutions.md) | Previous approaches and why they were abandoned | +| 05 | [B2C/B2B Funding Seam](./05-b2c-b2b-funding-seam.md) | Where the consumer and organisation funding paths meet and diverge | +| 06 | [High-Level Design](./06-high-level-design.md) | Four Mermaid diagrams: B2C payment, refunds and payouts, B2B funding, cross-cutting layers | +| 07 | [B2C Tax Invoices](./07-b2c-tax-invoice.md) | Consumer tax invoices, credit notes, and the outward-supplies register | ## Subsections diff --git a/docs/payments/audits/2026-09-03-finance-verdicts.md b/docs/payments/audits/2026-09-03-finance-verdicts.md new file mode 100644 index 000000000..ceba2b883 --- /dev/null +++ b/docs/payments/audits/2026-09-03-finance-verdicts.md @@ -0,0 +1,190 @@ +# Finance subsystem verdict record — 2026-09-03/04 + +**Branch:** `docs/finance-train-verdicts` | **Date:** 2026-09-03/04 | **Scope:** every finance-labelled issue filed by the same-day audit (#1351–#1373, #1377), the older finance issue backlog, the `bugs/**` money notes, and an HLD/LLD review of the payments, payouts, tax and compliance subsystems. + +This document is the durable record of a full verification pass over the finance subsystem. An unverified audit had already run earlier the same day and filed #1351–#1373 plus #1377 as agent-written findings against untracked notes in `bugs/financial-audit/`. Before any of those findings were turned into code, every one of them was re-checked against `dev@e1766fa2d` and the live Supabase database. The headline result is that of 41 audit findings, 12 were real defects, 9 were already fixed, 14 were refuted or unsubstantiated, and 6 were minor polish items — and the single highest-severity defect in the subsystem turned out to be one the original audit had framed incorrectly (`#1347`, see §3 below). This PR closes #1373 (the SCIM doc-drift item folded into the same sweep) and is part of the #1319 finance productionization umbrella. + +## 1. What was audited, and how it was verified + +The verification ran three read-only Explore agents in parallel against the codebase (one per subsystem area: payments/webhooks, payouts/TDS/GST, and booking-side money boundaries), a fourth agent that triaged every claim in the `bugs/**` money notes against current code, direct reads of the highest-risk files by the orchestrator, and seventeen web checks against primary regulatory and vendor sources (Razorpay API docs, GST notifications, the Income-tax Act 2025, RBI PA directions). Nothing in this record is taken on an agent's say-so alone: every "real" verdict below cites the file, the live-database check, or the primary source that confirmed it. + +The table below records the regulatory and vendor facts that were re-verified on 2026-09-03 and the specific effect each one had on the plan. + +| Fact | Effect on the plan | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Razorpay refunds require `X-Refund-Idempotency` of at least 10 characters (`[A-Za-z0-9_-]`) and return 409 while a refund is in flight | The repo's refund idempotency handling was already correct. | +| The `stripe-node` SDK takes the idempotency key as the second argument, `{ idempotencyKey }`, not inline in the params object | This shaped the PR-A fix in `lib/payments/core/stripe.ts`. | +| GST-TCS under Section 52 has been 0.5% since 10-Jul-2024, applies only to registered suppliers, and is filed via GSTR-8 by the 10th of the following month | Confirmed the schema's stale "1%" comments were wrong and that TCS is not applicable at all under the Principal model this train adopted. | +| Section 194-O has been 0.1% since 1-Oct-2024, with a ₹5 lakh threshold that applies only to individual/HUF payees who have furnished PAN, and a 5% no-PAN rate | `lib/compliance/tds-194o.ts` already implements the correct three-limb test. | +| The Income-tax Act 2025, in force from 1-Apr-2026, renumbers Form 26Q to Form 140, Form 27Q to Form 144, Form 16A to Form 131, and replaces section labels with Section 393 payment codes 1001–1067 | The return export must emit Section 393 codes; Form 16A generation is a TRACES portal download, not a code gap, so #1364 closed. | +| Razorpay orders never expire unless `expire_by` is set, and an authorized-but-uncaptured payment auto-refunds after five days | The audit's claim about trial-intent expiry (8.7) was wrong as stated. | +| Razorpay webhooks are delivered at-least-once, carry `x-razorpay-event-id`, and retry for 24 hours before the endpoint is disabled | Confirmed the existing webhook inbox design is correct. | +| The RBI Payment Aggregator Directions 2025 (15-Sep-2025) restrict split-settlement to contracted merchants | Confirmed the platform's Path C posture (Razorpay PG, RazorpayX payouts as a separate licensed rail) still stands. | +| Netlify Scheduled Functions support standard cron syntax, cap each invocation at 30 seconds, and support every-minute cadence on paid plans | This is the design basis for the five-minute ticker in PR-F. | +| QStash costs $1 per 100k messages (500/day free); Inngest gives 50k free events then $99/month; Temporal has a $100/month cost floor | All three stay deferred; none is justified by current volume. | +| Section 9(5)'s deemed-supplier list excludes consulting services | Confirms that neither the Principal nor the facilitator GST reading is forced by statute — it is the platform's business-model choice, made with CA sign-off pending. | +| CGST Rule 46 requires a tax invoice for every B2C taxable supply, and supplies of ₹50,000 or more require the buyer's name, address and state on that invoice | This is why a B2C tax invoice became a required deliverable under the Principal model rather than an optional one. | + +## 2. The eleven locked decisions + +Eleven decisions were locked with the user across three rounds of questions before any code was written, and every PR in the train respects them without exception. + +1. Refunds are in scope: the user's instruction was to fix everything refund-related the audit surfaced. +2. The GST model is **Principal**: the platform is the supplier of record, keeps 18% GST on the full discounted price, issues B2C tax invoices itself, and does not collect GST-TCS under Section 52 pending a CA opinion (this downgraded #1360 and #1361 rather than building them). +3. Stripe stays a dormant contingency behind Razorpay (the primary gateway) and the planned Dodo Payments integration for international buyers; this train fences Stripe rather than building it out to parity. +4. Schema and database changes are allowed freely, because the platform is pre-MVP and the data is mock data; the one Supabase project serves both dev and prod, so `db push` runs once per merged PR, run by the orchestrator, and never for two schema-bearing PRs concurrently. +5. The async posture stays **state-as-outbox plus a Netlify scheduled ticker**: no new generic outbox table, and no QStash adoption in this train. +6. The cron fleet gets hygiene only in this train — scheduling the one orphaned job and flipping one job's `failMode` — not a consolidation, with QStash left for a later escalation. +7. The leg invariant is defined as: `Payment.amount` stays the gateway charge, and `REFERRAL_CREDIT` legs are excluded from the funding sum that must equal it. +8. The B2C deliverable is a statutory tax invoice, a credit note, and an outward-supplies register for the CA, not in-app GSTR JSON builders. +9. Branch cleanup deletes the twelve already-merged remote branches and the local release branch only; open worktrees and open PRs are left untouched. +10. Ten issues close by hand with evidence comments rather than through an automatic `Closes` link: #677, #738, #737, #837, #1020, #270, #1371, #1341, #1355, #1364. +11. Core logic wins over test coverage for this train: no PR is test-only, and at most one compact pin lands per PR. + +## 3. Verdict table for the 2026-09-03 audit findings + +Every finding from the same-day audit (#1351–#1373, #1377) was checked against `dev@aa2ce08b8` (the commit the audit ran against) before this train started building. The table records the verdict, the evidence, and the PR that carries the fix. A blank PR column means the finding needed only a triage comment, not a code change. + +| Finding | Verdict | PR | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| 1.1/2.3 Stripe refund drops the idempotency key | **Real** — the Stripe button is live on every checkout page and `gateway-router.ts:52-60` honours it with no flag | #1386 | +| 1.2 Stripe test-key guard missing | Real, but the fence makes it moot; the guard was added anyway | #1386 | +| 1.3 no Stripe SDK timeout | Minor (the SDK default is 80 seconds); `timeout`/`maxNetworkRetries` set explicitly | #1386 | +| 1.4 `session.url!` non-null assertion | Minor (the client already toasts on a null URL); replaced with an explicit throw | #1386 | +| 1.5 refund issued without a status check | Overstated — the front-door guards already cover this; a one-liner hardened it further | #1386 | +| 1.6 `amount \|\| undefined` | Overstated — every caller already guards for amounts above zero; a defensive throw was added | #1386 | +| 2.1 client key rotates on remount | **Already fixed** by `findReusablePendingOrderPayment`, with a pinned test | comment only | +| 2.2 undefined refund key silently skipped | Unsubstantiated — deliberate; the single caller always passes `Refund.id`; the type was tightened to require it | in flight (fix/finance-refund-webhook-plumbing) | +| 3.1 lookup failure can lose a refund | Overstated — the `DeferSignal` plus sweeper re-drive already cover it; the residual gap (no gateway payment id stored on `Payment`) is closed by persisting `Payment.gatewayPaymentId` | in flight | +| 3.2 the signature-verify route has no rate limit | Partly true — auth, ownership and a SUCCEEDED short-circuit already gate it; `checkoutLimiter` was added | in flight | +| 3.3 no audit trail for client-side confirmation | Minor; a `recordSystemEvent` call was added | in flight | +| 3.4/7.1 Stripe bypasses GST/TDS | **Refuted** — both gateways share `handleRefundCreated`; the real residual (Stripe's route omits `providerPaymentId` and throws instead of deferring) was fixed | #1386 | +| 4.1/4.2 organization-side TDS never reaches `TDSRecord` or the return | **Real** and structural, though a runtime warning already existed and zero rows are affected today | #1389 | +| 5.1 consultee conflict check runs at READ COMMITTED | Refuted — it runs inside the Serializable transaction with a fail-closed lock | closed #1355 | +| 5.2 no GiST exclusion on the consultee side | Accepted design — the consultee lives on participants, not on `SlotOfAppointment` | closed #1355 | +| 5.3 PENDING refunds lock a consultant's balance forever | Refuted — FAILED refunds release the hold, and a 24-hour placeholder cap already exists | closed #1355 | +| 5.4 a five-minute stale window | Unsubstantiated — the CAS claim it rests on doesn't hold | closed #1355 | +| 6.1 no outbox for side effects | Partly true — booking and money side effects are already re-driven; the Stream channel/notification leg was not, and is now | in flight | +| 6.2 no alert during the defer window | Partly true (a 72-hour console warning already existed); a Sentry warning was added | in flight | +| 7.2 dispute status parsed as `z.string()` | Refuted — `mapDisputeStatus` already maps to null with a protective default | comment only | +| 7.3 refund currency unvalidated | Refuted — `toCurrencyEnum` already fails closed | comment only | +| 7.4 an overage transition inside a transaction rolls back the whole invoice | Refuted as stated; the real, inverse issue is that the CAS-updated count was silently discarded, leaving the overage un-transitioned | #1385 | +| 8.1 the `/support` route | Unsubstantiated — a deliberate prior fix already covers this | comment only | +| 8.2 the cancel dialog's refund-rail copy | Mostly fixed — the single-rail copy already exists | comment only | +| 8.3 `InvoicesPage` calling `window.open` | Unsubstantiated — no such code exists | comment only | +| 8.4 no polling while a dispute is open | Minor; a `refetchInterval` was added | in flight | +| 8.5 no toast for `REFUND_IN_FLIGHT` | Minor and admin-only; a toast mapping was added | in flight | +| 8.6 the invoice PDF renders Helvetica with no Devanagari support | Real | feat/finance-b2c-tax-invoice | +| 8.7 trial-intent never expires | Unsubstantiated — the parent page already gates on `paymentDueAt`, and Razorpay orders don't expire on their own | comment only | +| 8.8 the overage preview ignores available credits | Unsubstantiated — credits are disallowed on the org path by design | comment only | +| 9.1 dead code in `transactions.ts` | Trivial; the file was deleted | #1386 | +| 9.2 the idempotency header is optional | Unsubstantiated — structural guards below the header already cover it | comment only | +| 9.3 chaos-testing gaps | Superseded — the chaos harness already exists (#874) | comment only | +| 9.4 an unknown Stripe status maps to PENDING | Minor; a warning log was added | #1386 | +| N1 GST-TCS unshipped | Real as a schema gap, but **not applicable under the Principal model**; the schema comments' stale 1% rate (correct value is 0.5%) was fixed; #1360 was relabelled `compliance`, `launch: post-mvp`, and CA-gated | #1389 | +| N2 no GSTR-1/3B/9 builders | Under the Principal model the platform needs its own outward-supply return, not in-app GSTR JSON builders, so this became a CSV register for the CA instead; #1361 was re-scoped | feat/finance-b2c-tax-invoice | +| N3 no 26Q/140 FVU export | A CSV export for the CA, never an FVU file; #1362 was re-scoped | #1389 | +| N4 non-resident forms (27Q/144, 15CA/CB, §195) | Stays post-MVP and blocked; #1363 stays open | — | +| N5 no Form 16A generation | Not a code gap — Form 16A is a TRACES portal download, and the schema already stores the certificate number; #1364 closed | closed #1364 | +| N6 `Payment.consumerStateCode` never written | **Real** — confirmed zero writers and zero readers on the live schema | feat/finance-b2c-tax-invoice | +| N7 float TDS rates | Tech debt — the persisted values are already basis points; #1367 stays open | — | +| N8 the `TdsRate` table is unused | Tech debt, CA-gated; #1368 stays open | — | +| N9 SAC 999293 vs the 9983xx family | A CA classification decision; #1369 stays open | — | +| N10 the `reportedInGstr1` flag | Folded into the register export instead of standing alone | feat/finance-b2c-tax-invoice | +| N11 IRP upload stubbed | Post-MVP — the AATO threshold is ≥ ₹5 crore, which is nowhere near current volume; #1366 relabelled post-mvp | — | +| N12 wallet auto-charge | Duplicate of an existing #863 bullet | closed #1371 | +| N14 `CREDIT_POOL` refund round-trip untested | Test-only, and the user's decision this train is core logic over tests; #1372 stays open with a comment | — | +| #1341 cron lock fail-open | **Not an issue** — no such code branch exists; the lock only fails open when Redis is unconfigured (`isMockRedis`), and seven tests already pin that behaviour; `expire-stale-requests` was still flipped to fail-closed because it issues refunds | closed #1341; #1390 | +| #1347 referral-credit legs break the funding-sum invariant | **Real, P0 in effect** — the `payment_legs_sum_to_amount` trigger is live on the database, so any referral-credit checkout failed at commit with a `check_violation`. The schema, the checkout code and the trigger disagreed with each other about whether `Payment.amount` is the pre-credit or post-credit figure | #1385 | +| #1377 LIVE Razorpay keys are still gated | Correct operational posture; no change needed | — | + +## 4. Older-issue verdicts and the `bugs/**` counts + +The older finance-adjacent issue backlog was re-triaged alongside the new findings. The table below records the disposition for each issue that was in scope. + +| Issue | Verdict | Action | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| #270 service factory | Legit low-priority item, superseded by #1375 item C1 | closed as superseded | +| #366 recording monetization | Out of scope for this train (it belongs to the Stream subsystem); Phase 2 partly landed as `RecordingPurchase` | left open | +| #481 billing guardrails | An infrastructure issue, not a finance one; three of its code items (`take:` caps, `minimumCacheTTL`) are still true | left open, nothing dropped | +| #677 master finance tracker | Its open tail was re-filed 1:1 by today's audit | closed as superseded, with a comment mapping it to the successor issues | +| #738 / #737 B2C compliance | Same lineage as #677, now a fourth generation of the same tracker | closed as superseded | +| #770 contract lifecycle gaps | A legitimate post-MVP UI/feature gap | left open | +| #837 umbrella | Only #874 remains open under it, and the issue author confirmed the remaining checkboxes are decorative | closed as superseded by #1319/#874 | +| #863 residuals register | Two of its boxes were stale (`ScimToken` expiry and the multi-collaborator journal are both done) | comment added, issue kept open | +| #1020 dispute edge cases | Items 1, 2, 3 and 5 shipped with issue references in code; item 4 is a separate queue-cluster concern | closed as partly-fixed, remainder tracked in #1010 | +| #1092 database observability | The first checkbox is done; the rest are deferred to scale | comment added, issue kept open | +| #1135 priority DMs | A post-MVP feature request | left open | +| #1246 durable intent cancellation | A legitimate low-priority hygiene item (orphaned orders cannot currently be paid) | comment added, issue kept open | +| #1314 storage vendor decision | Out of scope for finance | left open | +| #1319 umbrella | Owner action items remain | kept open, this train linked | +| #1349 frozen decisions pointer | A documentation pointer, not an engineering gap | kept open | +| #1375 god-module epic | Kept open — both files it names grew again during this train | kept open | +| #866/#1010 QStash | Kept open; the Netlify ticker in #1390 is the interim measure | comment added | +| #705/#688/#692 | Partially fixed (items REF-2/3/5 and SC-1/16 are done) | progress comments added | + +### `bugs/**` money-note counts + +The `bugs/**` folder's money-related claims (142 in total across the finance and enterprise-money dossiers) were re-checked one by one: 63 are still true today (mostly by design, behind a flag, or already tracked in one of today's issues), 65 have been addressed since the note was written, and 14 are stale. Nothing beyond the findings already recorded in §3 turned up new work, with two exceptions, both doc-drift: the `prisma/schema.prisma` comment near line 4002 said `allocationIdempotencyKey` wiring was "tracked in #837" when the column has in fact been wired since the allocate routes started reading the `Idempotency-Key` header (fixed by this PR, §6 below), and `payment-legs-triggers.sql` is applied only through the `npm run db:leg-triggers` script rather than through the ordinary `db push` path, which this record calls out explicitly so nobody assumes a schema push alone fixes a broken trigger. The disposition for the whole folder is: annotate every file with a verdict blockquote pointing back to this record, and delete the untracked `bugs/financial-audit/` directory now that its findings have been folded into a committed record. + +## 5. HLD/LLD verdicts and the distributed-systems answers + +### What stays — genuine and load-bearing + +The double-entry journal (`LedgerTransaction`/`LedgerEntry`) with its reconciled caches, the `PaymentLeg` stacking model, time-scoped `RateCard` snapshots, CAS-in-WHERE state transitions, the fail-closed Redis mutex layered with Serializable transactions and Postgres GiST exclusion, two-phase refunds with a reserve row, the single-writer confirmation pipeline (ADR 21), the `WebhookEvent` inbox, `OrgAuditLog`/`SystemEvent`/`SystemJobExecution` as the audit trail, the batched payout state machine, and `PENDING_TRUST` earnings parking are all genuine, working machinery. Replacing any of this with a ledger vendor such as Formance or Modern Treasury would trade working code for a new dependency and buy nothing. + +### What was under-engineered, and is now built + +A B2C tax invoice with place of supply and an outward-supplies register was missing entirely, and the Principal GST model makes them statutory rather than optional. Organization-side TDS never reached the return builder. The credit-leg invariant was a blocking defect rather than a documentation mismatch. `Payment` never persisted the gateway's own payment identifier, which made refund lookups fragile. Stripe had no fence around it despite being dormant. The GitHub Actions cron cadence could not deliver sub-hourly reliably. The Stream channel/notification side effect of a payment was not re-driven on failure the way the money side effects already are. All of these are addressed by this train. + +### What was over-engineered or premature, and stays untouched + +Stripe should stay fenced, not deleted, since it remains the contingency gateway. The GST-TCS and GSTR-8 machinery stays dormant and CA-gated rather than being built out. Form 15CA/CB, Section 195 and non-resident handling stay out of scope. The `TdsRate` table stays unused until a CA asks for it. The IRP e-invoicing uploader stays gated behind the AATO threshold. The 74 one-job-per-file cron workflows are boilerplate-heavy but consolidating them would not fix a correctness problem, so they stay as-is. `CHARGE_MEMBER` overage billing stays fail-closed because no program is configured to use it yet. A multi-currency ledger stays out of scope — settlement is INR-only by design, with Dodo Payments deferred to a later international push. + +### The six distributed-systems answers + +The user asked six explicit architecture questions during this train; each is answered once here rather than scattered across PR descriptions. + +1. **Outbox pattern.** The outbox pattern is already present three times over — domain rows such as `Payment`, `Appointment`, `Refund`, `WalletTopUp`, `WebhookEvent`, `OutboundWebhookDelivery` and `FailedEmail` already carry the durable state a generic outbox table exists to provide. No new table was added; instead the cron cadence that drains those rows was fixed (§6a below). +2. **Double-entry ledger.** Yes, one is already built and reconciled nightly; it stays as-is. +3. **Audit trails.** The existing trail is sufficient; the one addition this train made was a `SystemEvent` for the client-side payment confirmation source, which had no audit record before. +4. **Optimistic versus pessimistic locking.** Both are used, deliberately layered: a short pessimistic Redis mutex guards the hot 30-minute slot atom, optimistic CAS plus Serializable retry guards state transitions more broadly, and a Postgres GiST exclusion constraint is the last line of defence on the consultant side. Pending payments and approvals expire via `expiresAt` plus CAS sweeps, and an approval lock's TTL is set equal to its transaction timeout. None of this changes. +5. **Reconciliation.** Reconciliation exists for four distinct purposes — gateway truth versus the database, the ledger's cached balances versus its journal, crash-gap re-drives, and time-based expiry — and every scheduled job in the inventory maps to exactly one of those purposes, so none of it is redundant. +6. **Message brokers.** Kafka and RabbitMQ are not justified: there is no consumer host for them, and current volume is three to four orders of magnitude too low to need one. QStash is the correct next escalation but not for this train. Inngest's adoption trigger has not been met. Temporal's cost floor exceeds the problem it would solve. + +### Cron classification + +The 74 scheduled workflows in the repository were classified by subsystem and by purpose. By subsystem: 10 are booking, 15 are finance-B2C, 12 are finance-B2B billing, 4 are payouts, 6 are compliance-tax, 9 are Stream, 11 are auth/cleanup, 6 are CI/tooling, and 1 is observability. By purpose: 28 are reconcile/sweep jobs, 22 are ordinary business jobs, 12 are operational, 6 are alerting, 3 are CI, and 3 are test-only. Two orphans turned up during this classification: `tds-26q-draft-export.ts` had no GitHub Actions workflow at all (fixed by #1389, which adds `tds-return-draft.yml`), and `gstr8-draft-export.yml` is dispatch-only, which is correct while GST-TCS collection stays dormant. + +## 6. Two live-database findings that changed the picture + +Two findings surfaced only by querying the live database, not by reading source, and both changed what this train had to fix. + +**The `payment_legs_sum_to_amount` trigger was broken on every leg write.** `prisma/sql/payment-legs-triggers.sql` defines `assert_payment_legs_on_leg_write()` with bare, unquoted references to `NEW.paymentId` and `OLD.paymentId`. Postgres folds unquoted identifiers to lowercase, so those references resolved to a column named `paymentid`, which does not exist on `PaymentLeg` (the real column is the quoted, camelCase `"paymentId"`). Pulling the live function body via `pg_get_functiondef` confirmed the bare references were exactly what shipped, which means the trigger has raised on every single leg write since the last time the trigger function itself was written, around 2026-07-28. #1385 fixes the identifiers to the quoted form and excludes `REFERRAL_CREDIT` legs from the sum at the same time; `npm run db:leg-triggers` must be re-run after that PR merges, because `prisma db push` does not manage triggers. + +**`Payment.consumerStateCode` had zero writers.** The column has existed in the schema, but nothing in `lib/payments/operations/checkout.ts` or anywhere else ever wrote to it, and nothing ever read it either. This is one of the reasons a B2C place-of-supply capture became a required deliverable of the Principal-model train (`feat/finance-b2c-tax-invoice`) rather than a nice-to-have: the column was reserved for exactly this and had sat empty since it was added. + +## 6a. Post-merge deployment runbook + +This runbook applies once per merged PR in the train, run by the orchestrator, and never for two schema-bearing PRs at the same time, because a single Supabase project serves both dev and prod. + +1. After every merge that touches `prisma/schema.prisma` (PR-D/#1389, PR-B, PR-E), run `npm run db:push` — which chains `db:sidecars` (triggers, leg triggers and CHECK constraints) — and then `npm run db:assert-sidecars` to confirm the sidecar objects actually landed. +2. After #1385 (PR-C) merges, run `npm run db:leg-triggers` even though that PR has no schema change of its own, because the live trigger described in §6 above is broken on every leg write until that script re-applies it; `db push` alone does not manage triggers. +3. Before the first live run of the new jobs, confirm the secrets are in place: the GitHub Actions `PAN_ENCRYPTION_KEY` for `tds-return-draft` (a missing key produces a blank PAN column and a silent exit 0, not a failure), the Supabase storage credentials already present in the secrets manifest, the Netlify production `CRON_SECRET` for the ticker plus the optional `CRON_TICK_BASE_URL`, `STRIPE_ENABLED` and `NEXT_PUBLIC_STRIPE_ENABLED` left unset, `PLATFORM_INVOICE_PREFIX` (optional, defaults to `FAM`), and `PLATFORM_GSTIN` plus `SUPPLIER_STATE_CODE`, both of which must be real values before the first consumer invoice mints — until then the mint step no-ops with a log line, and the register healer catches up once they're set. +4. Verify on the Netlify deploy preview that `cron-tick` fires every five minutes in the function log, that every route it calls answers 200 or 207 inside the 26-second function ceiling, and that a concurrent GitHub Actions dispatch of the same route returns 409 because the lock is held. +5. Owner actions that stay outside code: CA sign-off on ADR 26 (the questions listed there, plus #1388), rotating in Razorpay LIVE keys (#1377), and the load-gate exit criteria (#874). + +## Owner actions this train cannot complete + +- CA sign-off on the Principal GST model plus the 194-O operator pairing (ADR 26's question list, plus #1388's referral-credit question). +- Rotating in Razorpay LIVE keys (#1377). +- Confirming the Netlify team plan supports the five-minute ticker schedule — verified 2026-09-03 via `netlify api listAccountsForUser` that the Practitionist-Deploys team is on the Pro plan, so this is a note rather than a blocker. +- Leaving `STRIPE_ENABLED` unset in the production environment until Stripe is deliberately reactivated. +- Running the **pr-comment-triage** skill and the Sonar MCP quality-gate check on every PR in the train, which is a post-merge reminder rather than a deliverable of this PR. + +## Related + +- ADR 26 — [`docs/enterprise/70-design-decisions/26-gst-principal-model.md`](../../enterprise/70-design-decisions/26-gst-principal-model.md). +- `docs/compliance/02-gst-overview.md`, `docs/compliance/15-india-compliance-shipping-checklist.md`, `docs/compliance/10-rbi-pa-and-payment-architecture.md`, `docs/finances/07-tax-compliance-marketplace-obligations.md`. +- PR-C #1385, PR-A #1386, PR-D #1389, PR-F #1390, PR-B (`fix/finance-refund-webhook-plumbing`, in flight), PR-E (`feat/finance-b2c-tax-invoice`, in flight). +- #1319 (umbrella), #1387 (Stripe dispatcher untyped), #1388 (CA question on referral credits vs GST). diff --git a/docs/payments/checkout-flow/01-overview-and-consultation.md b/docs/payments/checkout-flow/01-overview-and-consultation.md index 645ee8dba..8775090a3 100644 --- a/docs/payments/checkout-flow/01-overview-and-consultation.md +++ b/docs/payments/checkout-flow/01-overview-and-consultation.md @@ -107,6 +107,8 @@ All checkout data is stored in payment intent metadata: > **Webhook backward-compat note:** Razorpay order `notes` objects embedded with the old keys (`startsAt` / `endsAt`) are still accepted by the webhook handler for in-flight orders created before the rename. `normalizeLegacySlotKeys()` in `schemas/webhooks/metadata.ts` maps old → new on ingest; new orders always use `startsAt` / `endsAt`. ``` +> **Empty optional fields are omitted, never sent as `""` (#1462).** `buildPaymentMetadata()` in `lib/payments/operations/checkout.ts` includes an optional key only when it has a value, because the webhook schemas type those fields with `.optional()`, which accepts an absent key and rejects an empty string. A subscription bought for a scheduling period carries no direct slot times, so it used to reach the gateway with `startsAt: ""` and `endsAt: ""`, and every capture webhook for such a sale then failed validation and stamped the payment `REQUIRES_MANUAL_RECOVERY` with the buyer already charged. Because a Razorpay order never expires, orders minted before the fix keep replaying with those empty strings, so `validateWebhookMetadata()` also strips empty-string entries before it normalizes legacy keys and parses. Omitting empty keys has the useful side effect of giving the fifteen-key gateway ceiling more headroom. + **Benefits:** - Webhook can recreate appointment even if frontend crashes @@ -133,27 +135,38 @@ await prisma.$transaction(async (tx) => { // - Webhook will be retried by gateway ``` -### 5. Three-Layer Race Condition Protection +### 5. Slot Occupancy Checks and the Buyer's Own Hold -For consultation and subscription slot bookings: +For consultation and subscription slot bookings, `validateSlotAvailability()` runs two blocking checks. The first rejects the request when any live appointment overlaps the requested window for this consultant, which includes another buyer's tentative hold. The second rejects it when the requesting buyer already holds an overlapping window with a live pending payment. ```typescript -// Layer 1: Check confirmed bookings -if (overlappingConfirmedBooking exists) { +// Check 1: any live overlapping appointment for this consultant +if (overlappingLiveAppointment exists) { throw Error("Time slot is already booked"); } -// Layer 2: Check same user duplicates -if (userHasPendingBookingForThisSlot) { - throw Error("You already have a pending booking"); -} - -// Layer 3: Rate limiting -if (pendingAttempts >= 3) { - throw Error("Time slot temporarily unavailable due to high demand"); +// Check 2: this buyer's own overlapping live hold +if (buyerHasOverlappingPendingHold) { + throw Error("You already have a pending booking for this time slot..."); } ``` +Both checks subtract the buyer's **self-hold** (#1463). A self-hold is an appointment that belongs to the requesting buyer, is for the same plan, has a payment that is still `PENDING` and still inside its expiry window, and covers exactly the window being requested. Such an appointment is not an occupant of the slot; it is the buyer's own open gateway order, and the open-order resume described below is the path that finishes or replaces it. Anything else keeps blocking, including a different buyer's hold on the same slot, a hold on a different plan, and this buyer's own hold on a window that merely overlaps the requested one. When the plan identity cannot be resolved at all, as with webinars and classes whose slot rows are shared between attendees, nothing is excluded. + +Exact coverage is compared against the appointment's whole slot run rather than a single row, because a booked window is stored as a series of contiguous thirty-minute atoms: the run's first start and last end are what must equal the request. + +The consultee-side conflict check inside the checkout lock applies the same exclusion, so the buyer's own hold does not resurface there as "You already have a session booked during this time." + +There is deliberately no per-slot attempt cap. Since check 1 blocks on any live hold, a count of pending attempts for one slot can never exceed one, so the hold itself is the cap. + +### 6. Open-Order Reuse + +A checkout that is remounted, reopened in a new tab, or retried after the buyer dismissed the gateway modal mints a fresh client idempotency key, so the same-key replay cannot recognise it. Before any gateway call, `findReusablePendingOrderPayment()` therefore looks for an open order the buyer can simply finish paying: a payment of theirs that is still `PENDING`, still inside its minted expiry window, on the same gateway, under the same organization, and joined to an appointment for the same plan. + +A candidate is adopted only when it is for the same booking as the current request. A consultation candidate must cover exactly the requested slot window, a subscription candidate must carry exactly the requested scheduling period, and every candidate's frozen amount must equal the total this request computed, so a changed coupon or credit balance can never be charged at a stale price. When a candidate is adopted, checkout returns the existing order id, amount and currency with `reused: true` and creates no second appointment and no second payment. + +Candidates that fail those gates are **superseded** rather than left open. Superseding runs in one transaction: the payment moves `PENDING` → `EXPIRED` through a compare-and-set that carries the old status in its `WHERE` clause, the appointment's tentative slots are cancelled through `transitionSlotCompletion()`, and the parent consultation or subscription is cancelled through its own guarded transition. Releasing the hold is not optional bookkeeping. If the payment were expired while its appointment kept occupying the calendar, the buyer's very next attempt would be rejected by the occupancy check above, which is the wall #1463 describes. Group events are excluded from the release because their slot rows are shared between attendees, so giving back a seat is a disconnect rather than a status move and belongs to the cancel-pending front door. + --- ## Consultation Checkout Flow diff --git a/docs/payments/checkout-flow/03-payment-processing.md b/docs/payments/checkout-flow/03-payment-processing.md index 4549882e0..0cdd406dd 100644 --- a/docs/payments/checkout-flow/03-payment-processing.md +++ b/docs/payments/checkout-flow/03-payment-processing.md @@ -263,13 +263,13 @@ export async function createStripeCheckoutSession({ #### Currency Conversion: -```typescript -// Lines 56-59 -const toSmallestUnit = (amount: number, currency: string): number => { - const multiplier = CURRENCY_MULTIPLIERS[currency] || 100; - return Math.round(amount * multiplier); // USD: 100 cents per dollar -}; -``` +There is none, and there has not been any since the paise migration. Every money +column in the schema already holds an integer count of the smallest unit, so the +amount is handed to the gateway exactly as it is stored. The +`CURRENCY_MULTIPLIERS` table this section used to show was deleted in #1396 +because nothing imported it. Settlement is INR-only in any case: +`assertInrSettlement` is the first statement of both `createRazorpayOrder` and +`createStripeCheckoutSession`, so a non-INR currency never reaches a gateway. ### 2.4 Razorpay Integration diff --git a/docs/payments/gateways/README.md b/docs/payments/gateways/README.md index 08229f4e2..0043dc610 100644 --- a/docs/payments/gateways/README.md +++ b/docs/payments/gateways/README.md @@ -202,7 +202,7 @@ Both gateways share a common abstraction layer: | File | Purpose | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `lib/payments/index.ts` | Unified orchestration — routes `createPaymentIntent()`, `cancelPaymentIntent()`, `createRefund()` to the correct gateway | -| `lib/payments/core/types.ts` | Shared types (`PaymentIntent`, `RefundResult`, `DisputeResult`), error classes (`PaymentError`, `RefundError`, `DisputeError`), `CURRENCY_MULTIPLIERS` | +| `lib/payments/core/types.ts` | Shared types (`PaymentIntent`, `RefundResult`, `DisputeResult`) and the error classes (`PaymentError`, `RefundError`, `DisputeError`) | | `lib/payments/payouts/payout-service.ts` | Provider-agnostic payout orchestration (batch creation, admin approval, processing) | | `lib/payments/payouts/earnings-service.ts` | Earnings calculation with flat 20% platform fee | | `lib/payments/payouts/constants.ts` | Hold periods, minimum amounts, fee percentages, payout mode limits | diff --git a/docs/payments/gateways/razorpay/01-setup.md b/docs/payments/gateways/razorpay/01-setup.md index e010ac10f..bf89a24f0 100644 --- a/docs/payments/gateways/razorpay/01-setup.md +++ b/docs/payments/gateways/razorpay/01-setup.md @@ -112,14 +112,16 @@ Dashboard > Settings > Webhooks > Add New Webhook **Events to select**: -| Category | Events | -| ------------------ | --------------------------------------------------------------------------------------------------------------- | -| Payment | `payment.captured`, `order.paid`, `payment.failed` | -| Refund | `refund.created`, `refund.processed`, `refund.failed` | -| Dispute | `payment.dispute.created`, `payment.dispute.won`, `payment.dispute.lost`, `payment.dispute.closed` | -| Payout (RazorpayX) | `payout.processed`, `payout.reversed`, `payout.rejected`, `payout.queued`, `payout.pending`, `payout.cancelled` | +| Category | Events | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Payment | `payment.captured`, `order.paid`, `payment.failed` | +| Refund | `refund.created`, `refund.processed`, `refund.failed` | +| Dispute | `payment.dispute.created`, `payment.dispute.under_review`, `payment.dispute.action_required`, `payment.dispute.won`, `payment.dispute.lost`, `payment.dispute.closed` | +| Payout (RazorpayX) | `payout.processed`, `payout.failed`, `payout.reversed`, `payout.rejected`, `payout.queued`, `payout.pending`, `payout.cancelled` | -Copy the webhook secret after creation and store it in the appropriate environment variable. +This list is the same one the go-live checklist requires, and it is the exact set the dispatcher in `app/api/webhooks/razorpay-dispatch.ts` handles. Omitting `payout.failed` is the expensive mistake, because it is the terminal event that tells the platform a bank refused the transfer; without it the earnings stay batched against a payout that will never arrive. + +Copy the webhook secret after creation and store it in the appropriate environment variable. Each mode has its own webhook secret, so the value generated in test mode will reject every live delivery and vice versa. Rotating the secret later is a two-sided change and must follow the grace-window procedure in [05-go-live-checklist.md](./05-go-live-checklist.md), because a hard cutover loses the events signed during the gap and Razorpay disables a webhook that has been failing for 24 hours. ### Test Mode vs Live Mode @@ -214,3 +216,4 @@ Set the ngrok URL as your webhook endpoint in the Razorpay dashboard. - [02-architecture-and-flow.md](./02-architecture-and-flow.md) — Payment flow and revenue split - [03-payout-flow.md](./03-payout-flow.md) — RazorpayX payout system - [04-kyc-and-onboarding.md](./04-kyc-and-onboarding.md) — KYC requirements +- [05-go-live-checklist.md](./05-go-live-checklist.md) — What must be true before the first live rupee diff --git a/docs/payments/gateways/razorpay/03-payout-flow.md b/docs/payments/gateways/razorpay/03-payout-flow.md index 1452126c3..017d3c5de 100644 --- a/docs/payments/gateways/razorpay/03-payout-flow.md +++ b/docs/payments/gateways/razorpay/03-payout-flow.md @@ -208,15 +208,20 @@ Webhook confirms status ### RazorpayX Payout Status Mapping -| RazorpayX Status | Internal Status | Description | -| ---------------- | --------------- | ---------------------------- | -| `queued` | PENDING | Queued due to low balance | -| `pending` | PENDING | Awaiting processing | -| `processing` | PROCESSING | Being processed by RazorpayX | -| `processed` | COMPLETED | Funds transferred to bank | -| `reversed` | FAILED | Bank returned the funds | -| `rejected` | FAILED | Payout rejected by RazorpayX | -| `cancelled` | CANCELLED | Payout cancelled | +RazorpayX has three intermediate payout states and five terminal ones, and every terminal state must map to a terminal internal state. If a terminal gateway state is read as an intermediate one, the payout never leaves PROCESSING, its earnings stay BATCHED, and the consultant is neither paid nor re-queued. The mapping below is the full set as documented at [RazorpayX Payout Status](https://razorpay.com/docs/x/payouts/status-details/). + +| RazorpayX Status | Internal Status | Description | +| ---------------- | --------------- | --------------------------------------------------------- | +| `queued` | PENDING | Queued due to low balance | +| `pending` | PENDING | Awaiting approval in the RazorpayX approval workflow | +| `processing` | PROCESSING | Being processed by RazorpayX | +| `processed` | COMPLETED | Funds transferred to bank | +| `reversed` | FAILED | Bank returned the funds; RazorpayX credited us back | +| `rejected` | FAILED | Approval was refused or lapsed | +| `failed` | FAILED | The transfer failed at RazorpayX, the bank, or in transit | +| `cancelled` | CANCELLED | A queued payout was cancelled manually | + +An unrecognised status deliberately maps to PENDING rather than to a terminal state, because "we do not know yet" must keep the reconciler polling instead of settling a payout on a guess. --- @@ -227,6 +232,7 @@ Webhook confirms status | `payout.processed` | Funds transferred successfully | Mark payout COMPLETED, earnings as PAID | | `payout.reversed` | Bank returned funds | Mark payout FAILED, restore available balance | | `payout.rejected` | RazorpayX rejected payout | Mark payout FAILED, alert admin | +| `payout.failed` | Transfer failed at the bank | Mark payout FAILED, return earnings to READY | | `payout.queued` | Insufficient balance, queued | Update payout status to PENDING | | `payout.pending` | Payout pending processing | Update payout status | | `payout.cancelled` | Payout cancelled | Mark payout CANCELLED | @@ -250,9 +256,11 @@ Webhook confirms status Since March 2025, RazorpayX **requires** an idempotency key on every payout request. This prevents duplicate payouts if a request is retried. -The system generates idempotency keys using the payout ID and timestamp: `payout_{payoutId}_{timestamp}` +The key must be deterministic for a given payout, because that is the only property that makes a retry safe. `generateIdempotencyKey` in `lib/payments/payouts/razorpay-payouts.ts` therefore returns `payout_{payoutId}` and nothing else. An earlier version appended a timestamp, which produced a fresh key on every attempt and so defeated the mechanism entirely: a retry after a timeout would have submitted a second payout for the same earnings. Do not reintroduce a clock, a random suffix or an attempt counter into this key. + +The key is sent via the `X-Payout-Idempotency` header. When the payout row already carries an `idempotencyKey`, that value is used ahead of the generated one, so every attempt on a given row lands on the same RazorpayX idempotency slot. -The key is sent via the `X-Payout-Idempotency` header. +RazorpayX bounds that header at 4 to 36 characters drawn from letters, digits, hyphens, underscores and spaces, and answers anything else with a 400. Two of our keys overshoot it: an organization payout derives `payout_`, which is 43 characters, and a consultant payout persists `payout__`, which is 72. `boundPayoutIdempotencyKey` therefore folds any key the gateway would refuse onto a 34-character digest of itself at the point the header is written. The fold is a pure function of the key, so determinism is preserved and a retry still returns the original payout rather than creating a second one. The persisted `idempotencyKey` is left alone, because it is also the row's unique constraint and the Stripe transfer key, and neither of those is bounded the way this header is. --- diff --git a/docs/payments/gateways/razorpay/05-go-live-checklist.md b/docs/payments/gateways/razorpay/05-go-live-checklist.md new file mode 100644 index 000000000..c6289d098 --- /dev/null +++ b/docs/payments/gateways/razorpay/05-go-live-checklist.md @@ -0,0 +1,115 @@ +# Razorpay Go-Live Checklist + +> What has to be true before the platform accepts its first rupee of real money through Razorpay, and what has to be verified in the Razorpay dashboard rather than in this repository. + +**Last Updated**: 2026-09-05 · **Tracking issue**: #1377 + +--- + +## How to read this page + +This checklist covers the payments product only. Consultant and organisation disbursement through RazorpayX is gated separately by `ENABLE_LIVE_PAYOUTS` and has its own runbook at [docs/enterprise/50-operations/06-live-payout-go-live-runbook.md](../../../enterprise/50-operations/06-live-payout-go-live-runbook.md); do not treat the two as one cutover, because accepting money and disbursing money can safely go live weeks apart. + +Several items below cannot be verified from the codebase at all. Auto-capture, the settlement cycle and the uncaptured-payment refund window are account settings that live in the Razorpay dashboard, and no amount of reading `lib/payments/core/razorpay.ts` will tell you how they are configured. Those items are marked as dashboard checks, and they need a screenshot or a dashboard link recorded against the issue rather than a code reference. + +--- + +## 1. Account activation + +The account has to be activated before live keys do anything at all, and activation is not instantaneous. + +- [ ] KYC is submitted and approved, and the dashboard shows the account as activated. Razorpay quotes one to three business days for this, and a rejection restarts the clock. +- [ ] The settlement bank account on the Razorpay account is the platform's current account, and the account holder name matches the registered business name exactly. +- [ ] GSTIN is recorded on the Razorpay account. This is what lets Razorpay issue us a compliant invoice for its own fees, which we need for input tax credit; it is unrelated to the tax invoices this platform issues to consumers, which are minted in-house (see [../../07-b2c-tax-invoice.md](../../07-b2c-tax-invoice.md)). +- [ ] If international cards are ever to be accepted, domestic acceptance is activated first and video KYC is complete. International acceptance is a separate approval, not a toggle. + +--- + +## 2. Keys and the test-key guard + +The platform holds two unrelated Razorpay credential pairs and one webhook secret per mode, and confusing any two of them is the most common cause of a silent outage. + +- [ ] `RAZORPAY_KEY_ID` and `RAZORPAY_SECRET` hold the **live** key pair, with `rzp_live_` as the key id prefix. Note that the secret's variable name is `RAZORPAY_SECRET` in this repository and not `RAZORPAY_KEY_SECRET`; only the legacy readers under `scripts/` accept the second name. +- [ ] `NEXT_PUBLIC_RAZORPAY_KEY_ID` holds the same live key **id**. It is the only Razorpay value that may ever be public, and neither secret may ever be given a `NEXT_PUBLIC_` prefix. +- [ ] `RAZORPAY_ALLOW_TEST_KEYS_IN_PRODUCTION` has been **deleted** from the production environment. While it is set to `true`, a test key under `NODE_ENV=production` only logs a loud error instead of throwing, which is deliberate for the pre-launch period when signup is closed and checkout is exercised with test cards. Once live keys are in place the variable has no legitimate use, and leaving it behind removes the guard that would otherwise catch a future accidental rollback to test keys. +- [ ] A production boot has been observed after the change. The test-key guard in `lib/payments/core/razorpay.ts` runs at module load, so a misconfigured production posture fails at require time rather than at the first customer — which means the deploy either comes up clean or does not come up at all. + +--- + +## 3. Webhooks + +Webhook delivery is how the platform learns that money moved. Everything else is best effort. + +- [ ] A webhook is registered in **live** mode pointing at `https:///api/webhooks/razorpay` over HTTPS on port 443. +- [ ] `RAZORPAY_WEBHOOK_SECRET` in the production environment holds the **live** webhook secret, which is a third value distinct from both the API secret and the test-mode webhook secret. A test-mode secret in production rejects every live delivery with a 400. +- [ ] The selected events are exactly the ones the dispatcher handles: `payment.captured`, `order.paid`, `payment.failed`, `refund.created`, `refund.processed`, `refund.failed`, `refund.speed_changed`, the six `payment.dispute.*` events, and the seven `payout.*` events. Selecting an event the dispatcher does not handle is harmless because unknown events are logged and acknowledged with a 200, but omitting a handled one loses the state transition entirely. +- [ ] The Alert Email Address on the webhook is a monitored inbox. Razorpay emails it when it disables a webhook, and that email is the only notification of the failure mode described below. +- [ ] A signed test delivery has reached production and produced a `WebhookEvent` row. The recipe lives in the Razorpay skill at `.claude/skills/razorpay/references/local-testing.md`; the signature is an HMAC-SHA256 of the exact bytes posted, so it must be generated from the same string that is sent. + +### Why a non-2xx is dangerous here + +Razorpay treats every non-2xx response as a delivery failure, retries on an exponential backoff for 24 hours, and then **disables the webhook** ([webhook FAQs](https://razorpay.com/docs/webhooks/faqs/)). A disabled webhook is not merely paused: events that fire while it is disabled are never delivered, and Razorpay has no self-serve replay. Recovering them means a support ticket, only works for events under 15 days old, and only works if the webhook was enabled when the event fired. This is why the route returns 200 for events it does not handle and reserves 503 for the single case where a retry is genuinely wanted, namely an unreachable database. + +### Rotating the webhook secret + +Rotating `RAZORPAY_WEBHOOK_SECRET` is a two-sided change that cannot be made atomically, because the operator saves the new secret in the Razorpay dashboard and the platform picks it up only on the next deploy. Every event signed in that gap would be rejected, and a long enough gap ends in the disabled webhook described above. + +`RAZORPAY_WEBHOOK_SECRET_PREVIOUS` exists to close that window, mirroring for inbound deliveries what [ADR 09](../../../enterprise/70-design-decisions/09-webhook-rotation-grace.md) does for outbound ones. The procedure is: + +1. Set `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` to the current secret and deploy. Nothing changes yet, because the current secret still verifies everything. +2. Generate the new secret in the Razorpay dashboard, set `RAZORPAY_WEBHOOK_SECRET` to it, and deploy. Deliveries signed with either secret now verify. +3. Watch the operations timeline. Every delivery that only the previous secret can verify writes a `WEBHOOK`/`WARN` system event naming the variable, so the rotation is visibly finished when those stop. +4. Delete `RAZORPAY_WEBHOOK_SECRET_PREVIOUS` and deploy. The old secret stops being honoured. + +Leaving the variable set indefinitely defeats the purpose of rotating a leaked secret, which is why step 4 is part of the procedure rather than optional cleanup. + +### RazorpayX payout deliveries + +RazorpayX payout events arrive at the same endpoint but are signed with `RAZORPAYX_WEBHOOK_SECRET`, a different value again. The route verifies against the payment-side secrets first and only consults the RazorpayX secret when that fails **and** the event name begins with `payout.`. That ordering is the safety property rather than an optimisation: because a non-payout event can never be accepted by the RazorpayX secret, holding a second secret cannot be used to smuggle a forged `payment.captured` through. + +--- + +## 4. Capture and settlement (dashboard checks) + +- [ ] **Automatic capture is on.** Verify at Settings → Payments → payment capture. Auto-capture is on by default, but if it has been turned off, payments sit in `authorized`, `payment.captured` never fires, and no booking is ever confirmed. The `payment_capture` request field that older integrations used is deprecated and this repository correctly does not send it; per-order overrides are available through the `payment.capture` and `payment.capture_options` objects on the Orders API, and the platform deliberately does not use them so that one dashboard setting governs every order ([capture settings](https://razorpay.com/docs/payments/payments/capture-settings/)). +- [ ] **The auto-refund window for uncaptured payments is understood.** A payment left in `authorized` past the account's `manual_expiry_period` is refunded automatically, at normal speed, so the customer sees it back in five to seven working days. Read the actual configured value off the dashboard rather than trusting a remembered default, because Razorpay's own pages have quoted three days and five days in different places. +- [ ] **The settlement cycle is recorded.** The standard domestic cycle is T+2 working days from capture, where working days exclude Sundays, the second and fourth Saturdays, and bank holidays; T+7 is the international cycle rather than a new-merchant probation ([settlements](https://razorpay.com/docs/payments/settlements/)). The finance owner needs this number to reconcile the bank statement against the ledger. +- [ ] **A real ₹1 payment has been taken end to end in live mode** and has produced a Payment row at SUCCEEDED, a confirmed appointment, balanced ledger entries and a consumer tax invoice. + +--- + +## 5. Refunds + +- [ ] A live refund has been issued against that ₹1 payment and has reached SUCCEEDED via the `refund.processed` webhook rather than by anyone editing a row. +- [ ] The team understands that live refunds settle in five to seven business days at normal speed. Test-mode refunds usually appear instantly, which is not a guarantee and must never be built into a flow; the only correct trigger for "the customer has their money" is `refund.processed`. +- [ ] Nobody has introduced a `speed` parameter. This platform always requests the default `normal` speed and never `optimum`, so it never pays the instant-refund fee and `refund.speed_changed` is informational only. Changing that is a pricing decision, not an engineering one. +- [ ] Refund idempotency is intact: every refund carries `X-Refund-Idempotency` set to the `Refund` row's id, which is minted before the gateway call and unchanged on the error path. A key derived from the payment id and amount would make two legitimate partial refunds of equal value collide, and the second would silently return the first refund instead of paying the customer again. + +--- + +## 6. Order metadata limits + +Razorpay caps order `notes` at **15 key-value pairs of at most 256 characters each**, and rejects the whole order with a `BAD_REQUEST_ERROR` when either limit is exceeded ([Orders API](https://razorpay.com/docs/api/orders/create/)). The receipt field is separately capped at 40 ASCII characters and must be unique per order. + +- [ ] Every producer of order notes has been counted against the 15-pair budget before any new key is added. `buildPaymentMetadata` in `lib/payments/operations/checkout.ts` already emits fifteen keys in the org-sponsored case, so it has no headroom left. +- [ ] No unbounded user-supplied string reaches `notes`. This is an open gap at the time of writing: the free-text booking note is validated as `z.string().optional()` with no maximum and is forwarded verbatim, so a note longer than 256 characters fails order creation and the customer cannot pay. It is tracked for the multi-currency and checkout PR that owns those files. + +--- + +## 7. Operations and observability + +- [ ] Sentry is receiving events from the payments subsystem, and the webhook route's signature-failure and parse-failure paths have been seen at least once in a preview environment so the alerting is known to work. +- [ ] The scheduled sweeps are running in production. Payment confirmation is durable because the `WebhookEvent` row is written before the 200 is returned, but recovery from a crashed handler depends on `sweep-stuck-webhook-events`, and recovery from an event that never arrived depends on `reconcile-payment-status`. Both are driven by the Netlify ticker every five minutes with GitHub Actions as a backstop; see [ADR 27](../../../enterprise/70-design-decisions/27-state-as-outbox-and-scheduled-ticker.md). +- [ ] No secret is logged. Payloads are scrubbed by `scrubWebhookPayload` before anything is written, and no code path prints `RAZORPAY_SECRET` or either webhook secret. +- [ ] Payment records are retained for at least eight years, as Indian tax law requires. Nothing in the money subsystem hard-deletes a Payment, Refund or invoice row, and that property must survive any future data-retention work. + +--- + +## Related Documents + +- [01-setup.md](./01-setup.md) — Account setup, keys, dashboard configuration and test credentials +- [02-architecture-and-flow.md](./02-architecture-and-flow.md) — Payment flow and revenue split +- [03-payout-flow.md](./03-payout-flow.md) — RazorpayX payout system and status mapping +- [04-kyc-and-onboarding.md](./04-kyc-and-onboarding.md) — KYC requirements and timelines +- [docs/payments/06-high-level-design.md](../../06-high-level-design.md) — Where money truth is written and which sweep closes each gap +- [docs/enterprise/50-operations/07-required-secrets.md](../../../enterprise/50-operations/07-required-secrets.md) — The full secrets manifest and what breaks when each one is missing diff --git a/docs/payments/gateways/stripe/01-setup.md b/docs/payments/gateways/stripe/01-setup.md index fc322e71c..d37331a8d 100644 --- a/docs/payments/gateways/stripe/01-setup.md +++ b/docs/payments/gateways/stripe/01-setup.md @@ -25,6 +25,8 @@ Stripe serves two roles in our system: ### When to Use Stripe vs Razorpay +The table below describes what Stripe is capable of, not what this deployment does. Today Razorpay takes every payment, domestic and international, and the routing table in `lib/payments/gateway-router.ts` never selects Stripe on its own. Read the fence section immediately after this one before assuming any of these rows is live. + | Customer From | Gateway | Why | | ------------- | -------- | ----------------------- | | India | Razorpay | UPI support, lower fees | @@ -35,6 +37,26 @@ Stripe serves two roles in our system: --- +## Fenced by default + +Stripe is switched off unless a deployment deliberately turns it on. It exists in the tree as a contingency rail: Razorpay is the primary gateway and covers international collections through IBT at roughly a sixth of Stripe's cost, Dodo Payments is the sanctioned post-MVP international rail, and Stripe is what we would fall back to if RBI rules changed and Razorpay could no longer settle a class of collections. Until that happens, a customer must never be able to reach a Stripe charge, because the account runs on test keys and no Stripe payment has ever been reconciled end to end. + +Three environment variables control this, and all three are optional and default to unset. + +| Variable | Read by | Effect when unset | +| -------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STRIPE_ENABLED` | `assertGatewayUsable` in `lib/payments/validation/gateway-guards.ts`, at call time | Any attempt to route a checkout to Stripe, or to mint a live Stripe payment intent, throws a `DisabledGatewayError`. Mock payments are the one deliberate exception, because `createPaymentIntent` returns a mock intent before it consults the guard. This is the fence that actually protects money. Set it to exactly `true` to open it. | +| `NEXT_PUBLIC_STRIPE_ENABLED` | `paymentGateways` in `app/checkout/plans/utils.ts`, inlined into the client bundle at build time | The Stripe card on all four checkout pages renders a disabled "Coming Soon" button and no `StripeCheckout` component mounts. It keeps the UI honest; because the value ships to the browser it is not a security control on its own. | +| `STRIPE_ALLOW_TEST_KEYS_IN_PRODUCTION` | `initializeStripeClient` in `lib/payments/core/stripe.ts`, when the client is lazily constructed | Under `NODE_ENV=production`, a test-mode secret key throws `STRIPE_TEST_KEY_IN_PRODUCTION` instead of booting, and both `sk_test_…` and the restricted `rk_test_…` count as test mode. Set it to `true` only for the pre-launch window where the production site legitimately runs on test keys, and delete it with the first live key. | + +Turning Stripe on therefore means setting both `STRIPE_ENABLED` and `NEXT_PUBLIC_STRIPE_ENABLED` to `true` and redeploying, since the public one is inlined at build time and a runtime change does not reach an already-built bundle. + +Refunds are deliberately outside the fence. `assertGatewayUsable` guards the paths that start new money movement — routing a checkout and creating a payment intent — and does not guard `createRefund`, refund lookups or dispute reads. A `Payment` row already written against Stripe has to stay refundable after the flag goes back to unset, otherwise closing the fence would strand real customer money. + +The guard mirrors the Razorpay one in `lib/payments/core/razorpay.ts`, with one difference worth knowing: Razorpay's test-key check runs at module load, while Stripe's runs inside the lazy client initializer, because gateway cores are loaded at call time (#1376) and a module-scope throw would fire on any import of the file. Both carry the same `next build` carve-out, so a build that legitimately holds test keys still completes. + +--- + ## Prerequisites | Requirement | Details | diff --git a/docs/payments/multi-currency/01-architecture.md b/docs/payments/multi-currency/01-architecture.md index 1945a9650..b65672b19 100644 --- a/docs/payments/multi-currency/01-architecture.md +++ b/docs/payments/multi-currency/01-architecture.md @@ -1,75 +1,82 @@ # Multi-Currency Architecture -## Strategy: India-First + Accept International +## Strategy: India-First, Accept International, Settle in INR -All prices are stored in **INR paise**. International buyers see approximate prices in their local currency (client-side conversion), but all charges are processed in INR via Razorpay. +Every price in this platform is stored as an integer count of INR paise, and every charge is taken in INR. An international buyer may switch the site into a foreign currency, in which case the prices they read are estimates converted client-side from those INR paise; the order the gateway mints is still an INR order and their card issuer performs the conversion at its own rate. This is a display feature, not a pricing feature, and the rest of this document is mostly about keeping that distinction visible to the person paying. -### Gateway Selection +Settlement being INR-only is enforced, not merely assumed. `assertInrSettlement` in `lib/payments/validation/currency-guards.ts` runs as the first statement of both `createRazorpayOrder` and `createStripeCheckoutSession` and throws a `PaymentError` with code `NON_INR_SETTLEMENT` on anything else. The three administrative surfaces that used to accept a currency choice — organisation creation, the billing-account patch, and purchase-order creation — now validate against `z.literal("INR")`. The `Currency` enum stays on those columns, for the reasons recorded in [ADR 15](../../enterprise/70-design-decisions/15-currency-as-enum-with-display-fields.md), but no API will write a value other than INR into them. -| Buyer Country | Gateway | Method | Fee | Settlement | -| ------------- | -------- | --------------------------------- | ----------------------- | ---------- | -| India | Razorpay | Domestic (UPI, cards, netbanking) | 2% + GST (~2.36%) | INR, T+2 | -| International | Razorpay | IBT (International Bank Transfer) | 1% + GST, zero forex | INR, T+1 | -| Fallback | Stripe | Checkout Sessions | ~6.3% (4.3% + 2% forex) | INR, T+5-7 | +### Gateway Selection -### Why Razorpay for Everything +| Buyer Country | Gateway | Method | Fee | Settlement | +| ------------- | -------- | -------------------------------------------- | ----------------------- | ---------- | +| India | Razorpay | Domestic (UPI, cards, netbanking) | 2% + GST (~2.36%) | INR, T+2 | +| International | Razorpay | The same INR order, paid by an overseas card | ~3% + GST | INR, T+7 | +| Fallback | Stripe | Checkout Sessions | ~6.3% (4.3% + 2% forex) | INR, T+5-7 | -1. **Cost**: Razorpay IBT is 1% vs Stripe's 6.3% for international — **5x cheaper** -2. **eFIRC**: Razorpay auto-generates eFIRC (Foreign Inward Remittance Certificate) monthly — Stripe requires manual coordination with bank -3. **PA-CB License**: Razorpay received RBI PA-CB license (Dec 2025) — authorized for cross-border -4. **UPI**: Zero-fee domestic payments (unique advantage over TopMate which uses Stripe) +The settlement column deserves one clarification, because an earlier revision of this table put the international row at T+1. That figure belongs to the MoneySaver Export Account, not to this flow. Razorpay settles an ordinary international card payment in INR on a [T+7 working-day cycle](https://razorpay.com/docs/payments/international-payments/faqs/), against T+2 for a domestic one, which is also what our own [gateway evaluation](../gateways/gateway-evaluation-mar-2026.md) recorded. -### Competitive Advantage vs TopMate +An earlier revision of this table quoted Razorpay's International Bank Transfer product at 1% with zero forex markup and automatic eFIRC. That was a description of a product this codebase has never used. IBT is the MoneySaver Export Account, a virtual-account bank-transfer rail with [no Orders API behind it](https://razorpay.com/docs/payments/international-payments/international-bank-transfer/), so no checkout could have routed through it. The international row above is what the router actually does, and it costs roughly two points more than the figure that used to appear here. -TopMate's effective international cost: **15-18%** (10% commission + 3% Stripe + 2-3% forex) -Familiarise's effective international cost: **~11%** (10% commission + 1% Razorpay IBT) +### Why Razorpay for Everything -**Structural moat**: 4-7% cost advantage per international transaction. +Razorpay remains the primary rail for three reasons that survive the correction above. It is still materially cheaper than Stripe for international cards, at roughly 3% against Stripe's 6.3% all-in. It holds an RBI PA-CB licence granted in December 2025, which authorises it for cross-border collection. And it accepts UPI, which is the bulk of domestic volume and something a Stripe-based competitor cannot offer on this rail. That last reason should not be overstated: UPI carries zero MDR because the RBI mandates it, but Razorpay still charges its standard 2% platform fee plus GST on a UPI collection, so the method is cheap for the buyer rather than free for us. ## Currency Flow ``` [Consultant sets price in INR paise] ↓ -[Consultee sees price in local currency (client-side conversion)] +[Consultee optionally switches display currency; prices are converted client-side for reading only] ↓ [Checkout: buyer country detected → tax determined → gateway auto-routed] ↓ -[Razorpay charges in INR (or auto-converts for IBT)] +[Order minted in INR; assertInrSettlement refuses anything else] ↓ -[Payment recorded with buyerCountry + isInternational flags] +[Payment recorded with buyerCountry + isInternational flags, plus the display currency and rate snapshot for audit] ↓ [Earnings created in INR → hold period → TDS calculated → payout in INR] ``` ## Buyer Country Detection -Server-side cascade at checkout: +The server runs a two-signal cascade at checkout, with a conservative fallback: + +1. `User.country`, when it is a two-letter ISO code — the highest-confidence signal, because someone asserted it. +2. The `cf-ipcountry` header, when a Cloudflare edge is in front of the deployment. +3. Fallback to `"IN"`, which charges GST rather than risking a missed collection. -1. `User.country` (profile field — highest confidence) -2. `cf-ipcountry` header (Cloudflare geo-IP) -3. `Accept-Language` → country mapping -4. Fallback: `"IN"` (conservative — charges GST rather than missing it) +`Accept-Language` was removed from this cascade and must not be reintroduced. It was once the third step, and in this deployment it was effectively the only step that ever fired, because `User.country` is a free-text onboarding field that cannot satisfy the two-letter check and production runs on Netlify with no Cloudflare in front of it. A browser default of `en-US`, which is common in India, therefore zero-rated domestic sales as exports. A browser locale is not evidence a tax authority recognises. The reasoning is recorded in full at `lib/payments/tax/buyer-country.ts` and pinned by `__tests__/payments/currency-and-tax-gates.test.ts`. ## Exchange Rate Handling -- Rates from `open.er-api.com` (free, daily updates, INR base) -- Cached for 24 hours server-side -- Client-side: React Query with 1-hour stale time -- Disclaimer shown: "Final amount may vary based on current exchange rate" -- `displayCurrencyAtCheckout` stored on Payment record (currency code shown to buyer, e.g., "USD") -- `exchangeRateAtCheckout` stored on Payment record (INR→display rate snapshot for audit) +Rates come from ExchangeRate-API's Open Access endpoint, which supports INR as a base currency and needs no API key. The endpoint is read from `EXCHANGE_RATE_API_URL` and defaults to `https://open.er-api.com/v6/latest/INR`, so moving to a paid host or a different provider does not require a code change. + +Caching happens in two places, and only one of them matters. `lib/currency.ts` holds the last response in a module-level variable for one hour, which means the cache belongs to a single serverless instance: Netlify runs many instances, there is no shared store on this path, and the admin invalidation endpoint at `/api/admin/exchange-rates` can only ever flush the one instance that happens to serve the flush request. The cache that actually spares the provider is the CDN, which `/api/currency` enables with `Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400`. That route is also rate-limited to thirty requests per minute per IP, because the provider answers abuse with a 429 that locks the caller out for roughly twenty minutes, and a single scripted client could otherwise take FX display down for everyone. + +Staleness is bounded. A response older than twenty-four hours is never served, even when the provider is failing: `getExchangeRates` throws instead, `/api/currency` answers 500, the client query exhausts its retries, and `rate` settles at `null`. That null is the important case, and it is handled deliberately rather than papered over. When there is no rate, `useCurrency` reports `currency` as INR, `symbol` as `₹`, and `isEstimate` as `false`, so the whole display degrades together and the site shows honest rupees rather than rupee amounts wearing a foreign symbol. A missing estimate is better than a stale one presented as current. + +## Disclosure and Attribution + +An earlier revision of this document claimed the app displayed "Final amount may vary based on current exchange rate". No such string existed anywhere in the codebase, on any checkout page or beside any price. What exists now is `app/checkout/components/FxEstimateNote.tsx`, rendered directly under the Total on all four checkout pages. It appears only while `isEstimate` is true — that is, only when a non-INR currency is selected and a rate is actually available — and it names the INR figure the gateway will take: "Estimated in USD. You will be charged ₹5,000.00 in INR by the payment gateway; your card issuer's rate applies." + +The same component carries the provider attribution, as does the navbar beside the currency switcher. ExchangeRate-API's Open Access tier [requires visible attribution](https://www.exchangerate-api.com/docs/free) wherever its rates are shown, so this is a licence term rather than a courtesy; the link text and target live in `lib/currency-codes.ts` as `RATE_PROVIDER_NAME` and `RATE_PROVIDER_URL` so that every surface that renders a converted figure uses the same one. + +Two fields on `Payment` record what the buyer saw. `displayCurrencyAtCheckout` holds the currency code the checkout page was rendered in, and it is now validated against the shared allowlist in `lib/currency-codes.ts` rather than accepted as any three-letter string; the value originates in `localStorage` and `Intl.NumberFormat` will happily render an invented code, so an unvalidated field meant arbitrary text persisting onto a money row. `exchangeRateAtCheckout` holds the INR-to-display rate at the moment of the order. Both are audit-only. No stored amount is ever derived from either. ## Key Files -| File | Purpose | -| -------------------------------------------- | ---------------------------------------- | -| `lib/payments/tax/buyer-country.ts` | Buyer country detection cascade | -| `lib/payments/tax/tax-engine.ts` | Tax determination (GST vs zero-rated) | -| `lib/payments/gateway-router.ts` | Auto-routing to Razorpay/Stripe | -| `lib/payments/validation/currency-guards.ts` | Currency consistency validation | -| `lib/currency.ts` | Exchange rates + locale→currency mapping | -| `hooks/useCurrency.ts` | Client-side currency display hook | +| File | Purpose | +| -------------------------------------------- | -------------------------------------------------------------------- | +| `lib/payments/tax/buyer-country.ts` | Buyer country detection cascade | +| `lib/payments/tax/tax-engine.ts` | Tax determination (GST vs zero-rated) | +| `lib/payments/gateway-router.ts` | Auto-routing to Razorpay/Stripe | +| `lib/payments/validation/currency-guards.ts` | `toCurrencyEnum`, `validatePlanCurrency`, `assertInrSettlement` | +| `lib/currency.ts` | Server-side rate fetch, per-instance cache, staleness bound | +| `lib/currency-codes.ts` | Shared display-currency allowlist and provider attribution strings | +| `hooks/useCurrency.ts` | Client-side display hook, including `isEstimate` and the INR degrade | +| `app/api/currency/route.ts` | Public rate endpoint, CDN-cached and IP rate-limited | +| `app/checkout/components/FxEstimateNote.tsx` | The checkout disclosure and its attribution | ## Future Phases diff --git a/docs/payments/payouts/03-payout-processing.md b/docs/payments/payouts/03-payout-processing.md index 540890f6d..5aeef8b54 100644 --- a/docs/payments/payouts/03-payout-processing.md +++ b/docs/payments/payouts/03-payout-processing.md @@ -374,6 +374,8 @@ flowchart TD 4. **Next batch** → Earnings included again 5. **New payout created** → Fresh retry +The stuck-payout handler re-arms a payout for retry through a compare-and-set, not a bare update (#1407). `scripts/payouts/handle-stuck-payouts.ts` reads its cohort of `PROCESSING` payouts once and then spends a gateway HTTP round-trip on each one in turn, which leaves a wide window in which a concurrent `process-payouts` run or an inbound payout webhook can move a row that the handler has already read. The reset to `APPROVED` therefore carries the state it expects to find in its `WHERE` clause — `status = PROCESSING` and `providerPayoutId IS NULL` — so a row that something else has advanced in the meantime is no longer matched. When the update affects zero rows the handler counts the payout as skipped and logs that it raced; it neither throws nor retries, because whichever writer moved the row now owns it. Without that guard the handler would stamp a payout back to `APPROVED` after a webhook had already completed it, and the next weekly batch would disburse the same money a second time. + --- ## Payout Database Schema diff --git a/docs/payments/webhooks/01-monitoring.md b/docs/payments/webhooks/01-monitoring.md index 02d62609e..4bf86f281 100644 --- a/docs/payments/webhooks/01-monitoring.md +++ b/docs/payments/webhooks/01-monitoring.md @@ -246,6 +246,14 @@ SKIP_PAYMENT=true # Remove for production - ✅ Track booking status updates - ✅ Review logs regularly +### Method 5: The deferral warning + +A Razorpay webhook can be valid and still be unprocessable on arrival, most commonly a `refund.created` that overtakes the `payment.captured` which would have created the Payment row. The handler answers those with a `DeferSignal`, the dispatcher deliberately leaves the row `processed=false, error=null`, and `sweep-stuck-webhook-events` re-drives it until the awaited row lands or the seven-day give-up cap fires. + +The problem with that design was that a deferred row is indistinguishable from a row whose handler crashed before recording anything, so an event that would never become processable stayed silent for a week. The dispatcher now increments `WebhookEvent.deferCount` every time it defers, and the sweeper raises a single Sentry warning per run listing every event that has deferred five or more times or has been unprocessed for over an hour. If you see `sweep-stuck-webhook-events: N webhook event(s) still unprocessed` in Sentry, the attached context names each event id, its provider, its type and its defer count. + +A high `deferCount` on a refund means the handler could not resolve the payment the refund names, and there are two quite different reasons for that. Check the local capture state first: look the `pay_…` id up against `Payment.gatewayPaymentId` and the order id against `Payment.paymentIntent`, and if neither finds a row then the payment really was never captured on our side and the event is a reconciliation question rather than a webhook one. If a row does exist, the failure is in the lookup rather than in the data, which on a pre-`gatewayPaymentId` row means the dispatcher's `payments.fetch` translation is failing — check the Razorpay credentials the function is running with and the gateway's availability, because an authentication or network failure there produces exactly the same silent, repeating deferral as a genuinely missing capture. + ## Success Indicators ### Your webhooks are working correctly if: diff --git a/eslint.config.mjs b/eslint.config.mjs index 6e24f080d..f23dd2a05 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -39,9 +39,12 @@ export default [ }, }, - // Base config for all JavaScript/TypeScript files + // Base config for all JavaScript/TypeScript files. `mts`/`cts` included so + // netlify/functions/*.mts (#1356 — the scheduled ticker) is linted rather + // than silently skipped; it was previously the only extension this repo + // ships that fell through every `files` glob below. { - files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], + files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], languageOptions: { globals: { ...globals.browser, diff --git a/hooks/useCurrency.ts b/hooks/useCurrency.ts index 1c6c7b7c3..8d0798b41 100644 --- a/hooks/useCurrency.ts +++ b/hooks/useCurrency.ts @@ -4,24 +4,20 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useSyncExternalStore } from "react"; import { reportSentryError } from "@/lib/observability/report"; import { CURRENCY_LOCALE_MAP } from "@/utils/formatting"; +import { + SUPPORTED_CURRENCIES, + type SupportedCurrency, +} from "@/lib/currency-codes"; const STORAGE_KEY = "preferred-currency"; const DEFAULT_CURRENCY = "INR"; -// Currencies offered in the navbar dropdown (keep this list lean) -export const SUPPORTED_CURRENCIES = [ - { code: "INR", symbol: "\u20B9", label: "INR (\u20B9)" }, - { code: "USD", symbol: "$", label: "USD ($)" }, - { code: "EUR", symbol: "\u20AC", label: "EUR (\u20AC)" }, - { code: "GBP", symbol: "\u00A3", label: "GBP (\u00A3)" }, - { code: "AUD", symbol: "A$", label: "AUD (A$)" }, - { code: "CAD", symbol: "C$", label: "CAD (C$)" }, - { code: "SGD", symbol: "S$", label: "SGD (S$)" }, - { code: "AED", symbol: "AED", label: "AED" }, - { code: "JPY", symbol: "\u00A5", label: "JPY (\u00A5)" }, -] as const; - -export type SupportedCurrency = (typeof SUPPORTED_CURRENCIES)[number]["code"]; +// #1396 — the list itself moved to lib/currency-codes.ts so schemas/checkout.ts +// can allowlist `displayCurrency` against the same codes without importing a +// React module. Re-exported here because every existing consumer imports it +// from the hook. +export { SUPPORTED_CURRENCIES }; +export type { SupportedCurrency }; // ---------- shared external store (cross-component reactivity) ---------- @@ -94,12 +90,6 @@ export function useCurrency() { retry: 2, }); - const currency = isINR ? "INR" : (data?.currency ?? selectedCurrency); - const symbol = isINR - ? "\u20B9" - : (data?.symbol ?? - SUPPORTED_CURRENCIES.find((c) => c.code === selectedCurrency)?.symbol ?? - selectedCurrency); // No rate yet (still loading, or the provider failed after retries). Falling // back to 1 meant multiplying by nothing and then stamping a foreign symbol // on the result: a ₹5,000 session rendered as "$5,000" — about 83x its real @@ -108,6 +98,24 @@ export function useCurrency() { // instead, which is honest at any moment rather than wrong for a while. const rate: number | null = isINR ? 1 : (data?.rate ?? null); + // #1396 — `currency` and `symbol` used to keep naming the selected currency + // even while `rate` was null and formatPrice was already rendering rupees, so + // the navbar advertised "$ USD" over prices denominated in INR. The whole + // triple degrades together now: no rate means no foreign labelling anywhere. + const degradedToINR = isINR || rate === null; + const currency = degradedToINR ? "INR" : (data?.currency ?? selectedCurrency); + const symbol = degradedToINR + ? "\u20B9" + : (data?.symbol ?? + SUPPORTED_CURRENCIES.find((c) => c.code === selectedCurrency)?.symbol ?? + selectedCurrency); + + // True exactly when the figures on screen are a converted estimate rather + // than the amount the gateway will charge. Checkout uses it to say so; the + // navbar uses it to attribute the rate provider. It is deliberately false + // during the degrade, because rupees shown as rupees are not an estimate. + const isEstimate = !isINR && rate !== null; + const convert = useCallback( (amountINR: number): number => { if (isINR || rate === null) return amountINR; @@ -121,9 +129,9 @@ export function useCurrency() { // Convert from paise (smallest unit) to major unit for display const amountInMajor = amountInPaise / 100; const converted = convert(amountInMajor); - // Until a rate arrives, render the amount in the currency it is actually - // denominated in rather than relabelling it. - const displayCurrency = rate === null ? "INR" : currency; + // `currency` is already "INR" whenever `rate` is null, so the amount is + // rendered in the currency it is actually denominated in. + const displayCurrency = currency; // Use currency-appropriate locale for correct grouping (e.g. ₹1,00,000 vs $100,000) const locale = CURRENCY_LOCALE_MAP[displayCurrency.toUpperCase()] || @@ -136,16 +144,19 @@ export function useCurrency() { maximumFractionDigits: 0, }).format(converted); } catch (error) { - // Only throws on a malformed currency code (e.g. a bad value from - // /api/currency) \u2014 a real data bug, not the "still loading" case. + // Intl accepts any well-formed three-letter code, so a merely unknown + // currency renders rather than throws \u2014 reaching here means the code is + // structurally malformed, which only happens when a tampered + // localStorage value is echoed back by /api/currency. Report it; the + // fallback below still renders an honest number. reportSentryError(error, { subsystem: "client", extra: { displayCurrency }, }); - return `${rate === null ? "\u20B9" : symbol}${Math.round(converted).toLocaleString()}`; + return `${symbol}${Math.round(converted).toLocaleString()}`; } }, - [convert, currency, symbol, rate], + [convert, currency, symbol], ); const setCurrency = useCallback( @@ -161,6 +172,7 @@ export function useCurrency() { currency, symbol, rate, + isEstimate, convert, formatPrice, setCurrency, diff --git a/hooks/useHoldCountdown.ts b/hooks/useHoldCountdown.ts new file mode 100644 index 000000000..87044e7d7 --- /dev/null +++ b/hooks/useHoldCountdown.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface HoldCountdown { + /** Whole minutes remaining, floored; 0 once inside the final minute. */ + minutesLeft: number; + /** True once `now` has passed `deadline`. */ + isExpired: boolean; +} + +/** + * Live minutes-remaining countdown against a tentative-hold deadline + * (Payment.expiresAt). Shared by SessionTimeline's held row and any other + * surface that needs to say "this reservation releases at