Skip to content

release: dev → prod — enterprise foundation + B2C hardening train#853

Merged
teetangh merged 526 commits into
prodfrom
dev
Jun 11, 2026
Merged

release: dev → prod — enterprise foundation + B2C hardening train#853
teetangh merged 526 commits into
prodfrom
dev

Conversation

@teetangh

Copy link
Copy Markdown
Contributor

Production release following the #653 precedent. 526 commits, the bulk being:

Pre-flight verified: production's DATABASE_URL resolves to the same Supabase project (postgres.pzmbxqdgibfkhjwzeprf) that received today's schema push + ledger triggers, so deploy-after-push ordering holds. PG_POOL_MAX=1 set in the Netlify production context alongside this release.

Dev at merge time: required checks green, 1337/1337 Jest, race-condition workflow green, all seven implemented chaos scenarios passing, three previously failing crons re-dispatched green.

🤖 Generated with Claude Code

teetangh and others added 30 commits May 28, 2026 18:44
…h enforcement

- lib/maintenance.ts: new getActiveOrgMaintenanceWindow(orgId) read helper
- scripts/payouts/create-payout-batch.ts: skips individual tenants with active OFFLINE windows in the per-org loop
- __tests__/enterprise/org-maintenance-window.test.ts: 4 unit tests pin the scoping contract

Admin write API, per-org Redis keys, and Novu wiring deferred to a separate follow-up under #746.

Part of #746

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ment.organizationId

Resolution chain now: explicit arg → plan.organizationId → appointment.organizationId → null. Closes the case where the plan is platform-owned but the booking is org-funded.

createConsultationChannel/createSubscriptionChannel updated; DM channels intentionally remain untagged per scope.

Part of #674
Part of #746

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l audit

Adds the column + relation + index; writes the value at create time. Org call audit route indexes directly instead of joining through SlotOfAppointment.

Migration 20260528_meeting_session_organization_id_denorm applied to Supabase.

Part of #674
Part of #746

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- BillingSubscription.renewalReminderSentAt — once-per-cycle gate
- NOVU_WORKFLOWS.ORG_LICENSE_RENEWAL_UPCOMING + payload + notifier
- generate-subscription-invoices.ts: 7-day-out scan at job entry, claim-then-trigger

Migration 20260528_billing_subscription_renewal_reminder applied to Supabase.

Part of #746

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- NOVU_WORKFLOWS.ORG_DATA_EXPORT_READY + payload + notifier
- process-data-exports.ts wires it post-email; failures don't unwind the export

Erasure cascade and retention UI remain deferred to #701.

Part of #701
Part of #746

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New 36-cross-cutting-integrations.md is the single reference for which subsystems are enterprise-wired vs deliberately skipped, with code paths and rationale per row. Covers booking, money, membership, programs, Stream, compliance, operational, and the 25 dashboard routes plus an explicit SKIP list.

Indexed from 00-overview.md row 36.

Part of #746

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #1 — Programs v2 confirmed 95% no; PROJECT funding never had
a runtime path. PERSONAL/LICENSE/WALLET/INVOICE cover the 4 reachable
funding shapes per #768 Comment 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #2 + #3 — drops ProgramType.PROJECT/RETAINER, PayoutArrangement
AOR/EOR, the PROGRAM_TYPE_NOT_AVAILABLE rejection guard in
POST /organizations/[orgId]/programs, its error label, and the dedicated
test file. CreateBodySchema's discriminated union already rejects
unknown program types as "Invalid body" — no need for a dedicated v2 hint
now that the enum no longer carries the reserved values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #4 — hierarchy fields were schema-only with no UI/walks. Drops
both indexes (@@index parentId, @@index rootId), the OrgTree self-relation,
and the helper module lib/api/organizations/hierarchy.ts.

Cascades:
- POST /organizations no longer seeds rootId/depth.
- consolidated-invoice-rollup script/route/job removed (the rollup
  semantics presupposed a parent→child rollup that has no schema basis
  any more).
- ENABLE_CONSOLIDATED_INVOICE flag no longer gates anything; orphan
  dropped via maintenance-cron cleanup list.

Seed at prisma/seedFiles/15a-create-organizations.ts still writes rootId;
will be cleaned up in Wave 7 once prisma generate runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #18 — lazy escape hatch with no readers in repo. Drops the
column pre-MVP; future typed metadata gets a typed column when a real
need emerges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #26 (Comment 11 amendment) — same anti-pattern as Contract.terms.
canSponsor + canHost cover the 4 reachable shapes; no real long-tail
capability uses this column. Drop pre-MVP and add typed booleans when an
actual capability emerges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #27 (Comment 11 amendment) — typed reason / startedBy / endedBy /
bypassSecret cover every concrete op need. Operator-supplied catch-all
adds nothing concrete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #10 — terms was 'NET-X, caps, custom clauses'. NET-X already
lives in paymentTermsDays; caps live in LicensedSeatConfig /
CreditPoolConfig. The 'custom clauses' tail had no concrete reader. Drop
pre-MVP; if a customer requests freeform clauses later, file a
ContractClause child table at that point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768, #769

Lockdown #13 — HRIS connector schema was stubbed only. Per #769 vendor
strategy, when the first customer asks for HRIS sync we integrate
Merge.dev (200+ providers via one unified API; ~$10K-30K/yr) rather
than rebuilding per-platform.

Removed:
- prisma models: HrisConfig, HrisSyncJob, HrisEmployeeMap
- enums: HrisProvider, HrisSyncStatus
- routes: app/api/organizations/[orgId]/hris/{,csv-upload,sync}
- flag: lib/feature-flags.ts ENABLE_HRIS
- audit actions: SYSTEM.HRIS_SYNC_STARTED/COMPLETED/FAILED

The schema cost of HRIS integration is zero — Merge.dev returns
employee directory on demand against the customer's source-of-truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes part of #768 (Lockdown #19 / Comment 5)

SlotAllocationService.createAppointments was creating new Appointment
rows with organizationId=null even when the booking originated from an
org-funded membership. The org dashboard's
/api/organizations/[orgId]/appointments silently underreported
SUBSCRIPTION lazy allocations, CLASS pre-allocated sessions, and
CONSULTATION reschedules.

Fix:
- fetchEventData now resolves organizationId per event type:
    SUBSCRIPTION → placeholder Appointment Payment.organizationId
    CLASS        → classPlan.organizationId (host wins; per #768 design)
    CONSULTATION → existing Appointment.organizationId (reschedule keeps tag)
    WEBINAR      → webinarPlan.organizationId (shared-across-attendees)
- createAppointments now accepts organizationId and writes it on every
  appointment.create call.
- autoAllocate + manualAllocate thread the resolved value.

Test: __tests__/enterprise/appointment-org-stamping.test.ts pins the
8-row resolution matrix.

Marketplace WEBINAR registrant attribution stays on Payment.organizationId
(intentional — webinars are SHARED). Same for CLASS enrolment: a
Wipro learner enrolling in an Infosys-hosted class doesn't override the
host's slot tag; their funding is tracked via Payment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #11 (highest priority — blocks GST audit):
- Schema: drop OrganizationInvoice.items Json; add InvoiceLineItem model
  with typed columns (description, quantity, unitPricePaise, taxPaise,
  hsnCode, paymentId) + position for render order + cascade on invoice
  delete + SetNull on Payment delete.
- Writer: POST /organizations/[orgId]/billing-account/invoices creates
  child rows inside the same transaction via nested-create.
- Writer: jobs/billing/generate-subscription-invoices.ts (license cycle
  cron) uses nested-create for the single auto-generated line.
- Reader: invoice PDF route reads invoice.lineItems(orderBy position
  asc), maps to OrgInvoiceLineItem shape for the renderer. parseLineItems
  Json adapter removed.

Now SQL queries like 'sum line totals by HSN code' or 'list lines
linked to Payment X' run as flat joins instead of Json extraction.

Wave-1 cascade fixes (clears tsc warnings from dropped enum values
and dropped Contract.terms):
- Remove PROJECT key from FUNDING_SOURCE_LABEL/TAGLINE/BADGE_CLASS in
  lib/labels/org-labels.ts.
- Remove the PROJECT case in OrgPayerSelector.tsx.
- Remove PROJECT + RETAINER rows from programs page PROGRAM_TYPE config.
- Drop PROJECT-rejection guard in lib/payments/operations/checkout.ts
  (the enum no longer carries it).
- Drop terms write in POST /organizations/[orgId]/contracts.
- BillingPageClient summary schema: drop PROJECT from runtime z.enum.

tsc clean (0 non-seed errors). Seed file errors remain for Wave 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #7 — legacy Invoice predated the enterprise stack. Per #768
Comment 1.C.1 it modelled the same flow as OrganizationInvoice with
divergent semantics (one invoice per Payment vs. one invoice per
cycle). Keeping both invited the 'which one do I query?' trap and
doubled GST / IRP maintenance.

Per design decision: drop the legacy model and route personal-consultee
billing through email-to-support until v1.1 re-introduces a per-Payment
invoice surface.

Removed:
- prisma model Invoice + Payment.invoice back-relation
- routes app/api/invoices/{,id}/{,pdf}/route.ts
- module lib/payments/payouts/invoice-service.ts (whole helper)
- callers:
  * lib/payments/webhooks/handlers.ts no longer calls
    createInvoiceFromPayment from the success webhook
  * dashboard/consultee/[id]/payments/route.ts returns invoices=[]
    instead of querying Invoice (response shape preserved)
- public re-exports from lib/payments/payouts/index.ts

PaymentsTab UI renders no invoice rows for personal-card consultees;
the org-funded INVOICE flow (OrganizationInvoice) is unaffected.

tsc clean (0 non-seed errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #8 — disambiguates the consultant-side 1099-style payout flow
from OrganizationPayout (org-share for HOST/HYBRID orgs). Removes the
'which one do I query?' trap per #768 Comment 1.C.2.

Schema:
- model Payout → model ConsultantPayout (docstring explains the split).
- ConsultantProfile.payouts ConsultantPayout[]
- ConsultantEarnings.payout ConsultantPayout?
- TDSRecord.payout ConsultantPayout?

Code: bulk rename across 13 files:
- prisma.payout.* → prisma.consultantPayout.*
- tx.payout.* → tx.consultantPayout.*
- Prisma.PayoutWhereInput → Prisma.ConsultantPayoutWhereInput (and
  other Prisma input-arg types)

UI API-response type `interface Payout` in types/payouts.ts is
deliberately preserved — it's a UI/API shape, not the Prisma model,
and "Payout" is the right UX vocabulary for consultant earnings.

tsc clean (0 non-seed errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #9 — naming convention parity per #768 Comment 1.C.3.

Schema renames:
- ConsultantEarnings.platformFee → platformFeePaise
- ConsultantEarnings.consultantShare → consultantSharePaise
- Refund.amount → amountPaise
- Dispute.amount → amountPaise

All readers/writers updated across:
- ConsultantEarnings: dashboard/consultant/[id] route, lib/payments,
  scripts/payouts, scripts/refunds, lib/api/operators, tests
- Refund: lib/api/operators/stats, lib/payments/operations/refund,
  lib/referrals/service, app/api/webhooks/utils, admin analytics,
  staff support tickets, actions/maintenance/freeze-appointments,
  scripts/refunds/{cascade-refund-earnings,reconcile-pending-refunds}
- Dispute: app/api/webhooks/utils, app/api/payments/disputes,
  app/api/admin/maintenance, scripts/disputes/alert-dispute-deadlines

Payment.amount + OrganizationEarnings.platformFeePaise (already Paise)
are unchanged.

tsc clean (0 non-seed errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ultantTaxInfo)

Part of #768

Lockdown #5 — PAN PII compliance posture parity. Plaintext column
dropped; replaced with panEncrypted (Bytes, AES-256-GCM) + panLast4
for the 'PAN ending XXXX' UI. Mirrors ConsultantTaxInfo.panEncrypted.

Writers (POST/PATCH org routes) encrypt at write time via encryptPAN
helper from lib/payments/tax/pan-crypto. Pre-MVP DB reset means no
backfill required.

Readers:
- org-payout-service TDS-rate derivation now branches on
  'panEncrypted is non-null' (treats no-PAN as the higher-rate path).
  Plaintext decrypt deferred to Form 26Q admin filing.

tsc clean (0 non-seed errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768, #769

Lockdown items shipped:
- #6 OrgBrandingProfile carved out from Organization (1:1 sibling
  mirroring ConsultantProfile / OrgWorkspaceProfile). Branding columns
  (logo, bannerImage, primaryColor, secondaryColor, description,
  industry, website, sizeBucket) moved off the God Model.
- #12 WebinarCollaborator + ClassCollaborator permissions Json
  replaced with typed booleans (canApprovePayment, canViewAnalytics,
  canEditEvent, canSeeAttendees).
- #14/#15 OverageEvent table (append-only, Stripe Meter inspired) +
  LicensedSeatConfig.maxOveragePerCyclePaise +
  CreditPoolConfig.maxOveragePerCyclePaise circuit-breaker columns.
- #16 Organization.kybVerifiedAt + sumsubApplicantId (Sumsub KYB stub).
- #28 OrganizationPlan.config Json split into 4 per-type child models
  (OrgConsultationPlanConfig, OrgSubscriptionPlanConfig,
  OrgWebinarPlanConfig, OrgClassPlanConfig). Each has typed columns
  matching the public plan-type fields.

From #769 Comment 4:
- Organization.taxJurisdiction TaxJurisdiction @default(IN) +
  TaxJurisdiction enum prep for the multi-jurisdiction refactor when
  the first non-India customer signs.

Caller cascades (writers + readers updated to brandingProfile + the
typed configs):
- POST /organizations creates OrgBrandingProfile in same tx when any
  branding column is set.
- POST /organizations/[orgId]/catalog now takes a typed discriminated
  union over planType (config schema per type) and writes the matching
  child via nested-create.
- branding/[asset]/route.ts upserts on OrgBrandingProfile.
- Public org explore page + invitations preview + org-list
  + auth session payload all read brandingProfile and flatten at
  the response boundary so the UI shape stays stable.

tsc clean (0 non-seed errors). prisma validate clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #17 — REACHABLE_ORG_FUNDING_PATHS (lib/enterprise/reachable-paths.ts)
codifies the 7 post-Programs-v2-drop combinations of (capability,
fundingSource, programType). Routes, UI gates, and analytics consult
the constant rather than enumerating raw enums; test pin at
__tests__/enterprise/reachable-paths.test.ts asserts the matrix and
the canonical capabilityOf() resolver.

Lockdown #23 — Coming Soon scaffolding:
- lib/api/feature-pending.ts:respondFeaturePending(feature) emits the
  standard 501 + FEATURE_PENDING code; COMING_SOON_FEATURES lists the
  8 stubbed v0 surfaces per #768 Comment 6 table.
- components/enterprise/ComingSoonBadge.tsx renders the disabled-tooltip
  control. Single style/copy source so v1.1 reactivation flips in one
  place.

Wiring of the 8 stubbed surfaces into their respective routes is
deferred to a follow-up commit (touches multiple routes; this commit
delivers the helpers + types).

tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768, closes #674 (scope filter loose end)

Lockdown #25 — RFX-7 from ENTERPRISE_PRODUCTION_GRADE_CHECKLIST.
The serializer wrote "__personal__" while the resolver expected
"personal", and the URL middle-state "none" added ambiguity. Renamed
the canonical sentinel to "mine":

- ORG_FILTER_PERSONAL constant value: "__personal__" → "mine".
- serializeScope returns "mine" for personal scope.
- resolveOrgScope accepts "mine" canonically and "personal" as legacy
  alias (deprecation grace; drop in v1.1).

UI components (planner, requests, documents, AppointmentsTab) import
ORG_FILTER_PERSONAL by name and pass-through, so the rename ripples
through without per-page edits.

Also: ComingSoonBadge.tsx fix the cn helper import path
(@/lib/utils → @/utils/tailwind to match the repo's actual layout).

tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #768

Lockdown #7 (Wave 7) — seed updates so `prisma db reset && prisma db
seed` succeeds against the new schema:

- prisma/seedFiles/12a-create-refunds.ts: amount → amountPaise
- prisma/seedFiles/12b-create-disputes.ts: amount → amountPaise
- prisma/seedFiles/validation/impossible-cases.ts: amount → amountPaise
- prisma/seedFiles/13d-create-invoices.ts: DELETED (legacy Invoice
  dropped; per-Payment invoicing follow-up routed to email-to-support)
- prisma/seed.ts: drop createInvoices() call + import
- prisma/seedFiles/15a-create-organizations.ts:
  * drop HrisProvider + HrisSyncStatus enum imports
  * drop backfillRootId + rootId/parentId/depth writes
  * branding fields (industry/website/description) → OrgBrandingProfile
    nested-create
  * pan plaintext → panEncrypted via encryptPAN (mirrors writer routes)
  * Contract.terms Json blocks → typed paymentTermsDays + cap columns
  * HRIS three-table seeding (hrisConfig, hrisSyncJob, hrisEmployeeMap)
    deleted entirely
  * OrganizationInvoice.items Json → InvoiceLineItem nested-create

User-facing artifact — docs/enterprise/V0_LOCKDOWN_E2E_TEST_GUIDE.md
(~400 lines):

- Prerequisites + reset+reseed commands
- Test credentials per role + per funding path
- 7-path walk (all reachable funding × program combos)
- Coming Soon surface inventory (8 surfaces + audit-log demand signal)
- DPDP §7 verification flow
- OverageEvent + circuit-breaker verification
- Booking-org-stamping verification (#19 expected matrix)
- Schema lockdown verification (grep contract: each drop landed)
- Parked-items list with rationale per follow-up

tsc clean across the entire codebase including seeds.
prisma validate clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add LedgerAccount / LedgerTransaction / LedgerEntry + postLedgerTxn (Sigma debit == Sigma credit, idempotent on idempotencyKey, balances derived via ledgerBalancePaise). WalletTopUp replaces the per-row WalletEntry; revenue splits move to integer basis points. Removes the three single-entry logs (FundingLedgerEntry / WalletEntry / SettlementLedgerEntry + SettlementKind). Includes scripts/smoke/ledger-smoke.ts.

Part of #772

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every cash event now posts one balanced LedgerTransaction: booking (Dr funding legs / Cr PLATFORM_FEE + CONSULTANT_PAYABLE + ORG_PAYABLE + GST_PAYABLE), top-up (Dr CASH / Cr WALLET), invoice paid (Dr CASH / Cr ORG_RECEIVABLE), payouts (Dr *_PAYABLE / Cr CASH + TDS_PAYABLE), refunds. Wallet routed-settlement (WalletTopUp + cache); GST/TDS compliance; org billing/payout/analytics API routes follow the journal + bps.

Part of #772

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seed writes journal rows (top-ups + booking debits + usage ledger); reconcile-ledgers checks the 7 invariants over the double-entry journal + derived caches (WALLET_BALANCE_DRIFT, LEDGER_TXN_IMBALANCE, EARNINGS_LEDGER_DRIFT, PROGRAM_ASSIGNMENT_ENGAGEMENTS_DRIFT, ACTIVE_SEAT_COUNT_DRIFT, PAYMENT_LEG_SUM_MISMATCH, ORG_PAYOUT_TOTAL_MISMATCH). Collaborators move to revenueShareBps; invoice/overage jobs + cleanup/earnings scripts follow. Full reseed reconciles ok:true, 0 findings.

Closes #772

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y cutover

Full renumber into sectioned bands (00-05 foundations, 06-14 money/ledger, 15-19 IAM, 20-25 programs, 30-35 compliance/integrations, 40-44 ops, 50-52 scenarios) + README index. New money band (money model, chart of accounts, postings, wallet, booking->earnings, payouts, invoicing, payment legs, integrity) grounded in lib/payments/ledger + reconcile. Salvaged SSO testing/error-codes -> 15, money vocabulary -> 06, capability x funding matrix -> 02, clustered schema -> 00. Removed audits/history/playbooks/reference; refreshed complete-guide. 50-scenarios: full permutation matrix + 10 worked examples (Wipro, IIT Madras, LearnPro, consulting firm, etc.). All links resolve; 52 mermaid diagrams; zero stale model tokens.

Part of #771

Part of #772

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lockdown #17 — programs POST now rejects unreachable (capability x fundingSource x programType) combos via isReachableOrgFundingPath (code UNREACHABLE_FUNDING_PATH), subsuming the hard-coded BOGUS_LICENSE_CREDIT_POOL guard; the create-program dialog disables unreachable program types. Lockdown #23 — prune COMING_SOON_FEATURES to the one genuinely-pending surface (overage charging, #775); SCIM/DPDP-erasure/TDS/Form-26Q already shipped, HRIS dropped; wire ComingSoonBadge on the CHARGE_MEMBER overage control.

Part of #768

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lockdown #14/#15 — enforce maxOveragePerCyclePaise by wiring the (previously unused) computeOverage(): sum the cycle's OverageEvent.marginalPaise and fall back to BLOCK (402) once the ceiling is hit; stamp OverageEvent.invoiceLineItemId on settled events in the rollup. Event-only — instant CHARGE_MEMBER charging stays parked (#775/#715). Lockdown #22 — ORG_PROGRAM_CAP_NEAR Novu workflow + notifyOrgProgramCapNear, fired once per cycle on the transition into >=80% of cap, beside the existing 100% exhausted alert.

Part of #768

Part of #715

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
teetangh and others added 26 commits June 11, 2026 08:57
Schema-freeze batch (PR-3 of 4): TZID columns, engagement rename, overlap-guard column, refund-policy snapshot
…ectness

Cancel/reschedule correctness (PR-4 of 4): policy-snapshot refunds, CAS guards, orphan re-drive
The sharp catch (on #843): CREDIT_POOL is genuinely PRICE-metered —
recordBookingUtilization spends consumedPaise against the field × 100
(1 credit = ₹1) — so the engagementsPerCycle rename was the wrong
direction. Re-renamed to creditBudgetPerCycle with the unit documented;
LICENSED_SEAT counts engagements, CREDIT_POOL spends this budget.

Also from review: localStartDay/localEndDay columns on weekly
availability (the offset rolls the LOCAL day across midnight relative to
the UTC day — local-day queries need their own columns; freeze-gated),
the shared toLocalMinutes/toLocalDay helper replacing duplicated modular
math at both write sites, SlotAllocationService now throws instead of
silently nulling the #440 overlap-guard column when a consultant profile
can't resolve, the cancel route orders slots by startsAt so the EARLIEST
slot decides the refund tier, confirmApprovalStatus re-reads the fresh
status before logging CAPTURE_AFTER_TERMINAL_STATE (the pre-read raced
the very transition that made the CAS miss), and the sessions→engagements
terminology stragglers in the overage calculator + test comments.

Gates: tsc clean, 1309/1309 jest, next build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enterprise foundation — org CRUD, billing modes, SSO, dashboard
…peer-dep

fix(mcp): resolve supabase MCP -32000 crash from missing peer deps
…ds + #677 PM-1 closeout

lib/booking/transitions.ts is the consumer-side sibling of the enterprise
CAS module: allowed-from maps keyed by target state, baked into updateMany
WHERE clauses. Rewires the consultation/subscription PATCH approval flows
(item + collection routes), SlotAllocationService.updateEventStatus (with
an ALLOCATION_APPROVABLE_FROM self-edge so re-allocation keeps working),
the webhook tentative-expiry path, and the auto-complete sweep. Removes
requestStatus from both PUT surfaces; reschedule slot flips and
webinar/class transitions use explicit allowed-from sets.

Also finishes #677 PM-1: the three GH-Actions wrappers' credential warning
gates now match the canonical RAZORPAY_SECRET the scripts read, and
.env.sample documents the RazorpayX + webhook secrets.

lib/validation/limits.ts and the eventMutationLimiter land here because
the routes this PR touches consume them; the remaining #831 coverage rides
the next PR in the train.

Closes #836
Part of #837
Part of #677

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nds and limiters

Checkout locks get per-type TTLs (CLASS 300s — N sessions × M slot writes
plus a gateway round-trip outlived the flat 60s) and one checked
extendLock renewal before the gateway call that aborts 409 on ownership
loss; a timer heartbeat was rejected because serverless freeze makes
intervals unreliable. Tentative holds expire after 24 hours instead of 7
days. Every waitlist mutation (decline/skip/expire/leave/mark-booked) is
CAS-guarded so double-submits cannot double-fire handleSlotOpening or
resurrect terminal entries.

For #831: shared .max() bounds on every user-typed string (report route
gains a zod schema — it destructured raw unbounded fields), 64KB body caps,
the eventMutationLimiter applied to the event POST/PATCH and
validate/allocate routes that had zero limiter coverage, and the trial
limiter now applies uniformly instead of exempting privileged roles.

Closes #831
Closes #832
Closes #833
Part of #834 (code half; the join-table uniqueness rides the schema PR)
Part of #837

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…constraint sidecar

getTimezoneOffsetMinutes now reads Intl's machine-formatted longOffset
token, exact to the instant at DST boundaries; the old toLocaleString
string-parsing heuristic drifted around transitions, and date-fns-tz's
getTimezoneOffset was probed and rejected because it resolves at
calendar-day granularity on transition days. Overnight-slot status had
four divergent implementations across formatting, validation, and overlap
code; utils/schedule/overnight.ts is now the single rule and everything
else delegates. 28 table-driven tests pin IST, both 2026 US DST
boundaries, the +14:00 extreme, and the overnight truth table.

The #676 A1-A4 CHECK constraints ride a SQL sidecar
(prisma/sql/check-constraints.sql + npm run db:constraints) because
Prisma 7 has no @@check; they apply at the pre-MVP reset, never against
the shared dev DB mid-cycle. Payment amounts use >= 0 because
credit-covered and org-sponsored checkouts legitimately write zero.
A schema doc-comment marks the SlotOfAppointment↔User join uniqueness as
load-bearing for #834.

Part of #503 (items 1-2)
Part of #676
Part of #834 (schema half)
Part of #837

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…udit refresh

The race-condition suite gains a real-HTTP harness (BetterAuth session
login, concurrent fire, status histograms) and seven scenarios across two
new categories: cancel-vs-reschedule, reschedule storm, approve-vs-decline
(#836), multi-device login, org onboarding double-submit, webhook bulk
replay, and webhook out-of-order delivery. npm run test:chaos:api runs
them against a seeded DB + running server; they move no money and clean up
after themselves.

A local run demonstrated the owed db push: scenarios 9/10/11/13 fail on
the shared dev DB solely because Appointment.cancellationPolicySnapshot
and organizations.version exist in schema but not in the database;
12/15/16 pass. The chaos runbook records scenarios 9-16 and the updated
go/no-go set; the readiness audit gains a dated B2C addendum.

ADR 14 records the queue verdict: no Kafka/BullMQ/SQS for launch (all
need long-lived consumers Netlify cannot host); GH-Actions crons +
after() + sweeper re-drives + Upstash Redis stay; Upstash QStash is the
pre-approved escalation behind two named telemetry triggers; the real
ceilings are Netlify's 125-concurrent default and Supavisor tier caps.

Part of #837

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The owed push landed (data-preserving CreditPoolConfig rename, ledger
triggers re-applied); scenarios 9/10/11/13 went green on re-run, joining
12/15/16. Push-before-deploy noted as the production ordering lesson.

Part of #837

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review catch on #845 — the alias exists as the type-level lockstep proof
between WebinarStatus and ClassStatus; class transitions should read
through it so a future enum divergence flags the call sites too.

Part of #837

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…io-9 invariant

Review catches on #848. The cancel-vs-reschedule and reschedule-storm
scenarios now capture and restore the consultation status and slot states
so repeat runs do not consume the seed pool. The org double-submit cleanup
also deletes the BillingAccount the creation tx mints — org children
cascade, but the org holds that FK, so deleting the org orphaned it
(verified: one orphan from the first runs, now swept).

Hardening repeat-runs exposed an imprecise assertion: reschedule-first
ordering legally admits BOTH actions (reschedule resets to PENDING, and
PENDING is cancellable), so "exactly one winner" flaked by interleaving.
The test and runbook now state the real invariants — no 5xx, never zero
winners, a successful cancel is never resurrected, slots never mix
CANCELLED+RESCHEDULED. Verified stable across four consecutive runs.

Part of #837

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix: B2C CAS transition map — approval/decline/expiry/completion guards + #677 PM-1 closeout
fix: checkout lock budgets + tentative TTL + waitlist CAS + input bounds and limiters
…a-checks

fix: exact timezone offsets + canonical overnight resolution + CHECK-constraint sidecar
…-adr

test+docs: real-API chaos categories 07/09 + ADR 14 async posture + audit refresh
Rows from models with #780/#781 result extensions are Proxy-backed and
carry Symbol(nodejs.util.inspect.custom); spreading or returning them from
server components made React's serializer reject the payload ("Only plain
objects can be passed to Client Components" — seen on the home page's
FeaturedExpertsLoader). structuredClone cannot clone Proxies, so
lib/data/serialize.ts walks rows into genuinely plain objects (Dates
preserved) and every lib/data return that reaches a client component now
passes through it: home, explore-programs, plan-details, and both
org-member loaders. explore-experts already mapped fields explicitly and
consultant-detail already JSON-roundtrips; both left as-is.

Verified: home, /explore/programs, /explore/experts, and both plan-detail
pages render with zero serialization errors in the server log; tsc clean;
1337/1337 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Race Condition Tests workflow runs `npm run test:race` with no app
server, and the master-runner auto-discovers the new 07/09 categories —
which failed CI on the #848 merge push. Every real-API scenario now calls
ensureServerOrSkip() first and exits 0 with a SKIP line when the target is
unreachable (same semantics as the missing-fixture SKIPs); the pre-launch
staging run sets CHAOS_BASE_URL and runs for real. Verified both paths:
live server → scenarios pass; unreachable target → clean SKIP.

Part of #837

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#476 (merged to dev via #655) put a Redis-backed cron lock on every job
entry, so lib/redis now loads — and throws — at import time in any
workflow that does not provide UPSTASH_REDIS_REST_URL/TOKEN. Only some
workflow files were updated then; the other 30 began dying one by one as
their schedules fired post-#655 (Send Waitlist Reminders and Reconcile
Slot Availability were the first observed). This mirrors the env block
reconcile-payment-status.yml already carries into every job workflow that
lacked it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…chaos-ci-skip

fix: RSC boundary serialization + chaos CI skip guard
…ons)

Closes the two implementable gaps from CONCURRENCY_AND_LOAD_REPORT.md:

The report's Action 1 advice (connection_limit=1) predates the pg driver
adapter — with @prisma/adapter-pg the relevant knob is pg.Pool's max,
which defaults to 10 clients PER function instance (125 concurrent Netlify
invocations x 10 would dwarf Supavisor's client cap). PG_POOL_MAX makes it
configurable; unset keeps the pg default for long-lived local dev/jobs;
serverless deploys set 1-2. Verified live with PG_POOL_MAX=1. DATABASE_URL
itself was confirmed transaction-mode (port 6543, pgbouncer=true).

load-tests/smoke.js targets the three real read hot paths (health, slot
availability, consultant search) with BetterAuth login in setup and the
report's thresholds; the report's draft referenced endpoints that do not
exist and its booking-race k6 script duplicated what the race-condition
suite already covers properly, so both were adapted out. The workflow is
workflow_dispatch-only against an operator-supplied staging URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…knob

chore: k6 smoke test + serverless pg pool-size knob
@netlify

netlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

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

QR Code

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

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

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Important

Review skipped

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

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

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b0ef8829-3600-49ae-a14c-36fbf82de443

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces extensive updates to enterprise billing, compliance, and multi-tenant features, including new documentation, configuration updates, and a comprehensive suite of regression tests for GST splits, TDS calculations, SCIM authentication, and anti-lockout gates. It also integrates DPDP Act consent checks into Stream chat user upserts and adds organization tagging to Stream channels and meetings. The code review feedback highlights several improvement opportunities: replacing the platform-dependent shell grep helper in the audit actions test with a native Node.js implementation, restoring overridden global.fetch and process.env values in test teardowns to prevent test pollution, and optimizing the N+1 query pattern in the batch Stream user upsert by batch-fetching consent artifacts.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +21 to +41

import { execSync } from "child_process";
import path from "path";

const REPO_ROOT = path.resolve(__dirname, "..", "..");

function rg(pattern: string, paths: string[]): string {
// Use grep -rEn so we get file:line:body and ERE alternation.
// The -h flag is omitted intentionally — we want filenames in output.
try {
return execSync(
`grep -rEn ${JSON.stringify(pattern)} ${paths.map((p) => JSON.stringify(p)).join(" ")}`,
{ cwd: REPO_ROOT, encoding: "utf8" },
);
} catch (err) {
// grep returns non-zero on no-matches, which is the success case here.
const status = (err as { status?: number }).status;
if (status === 1) return "";
throw err;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The rg function uses execSync to run grep, which is platform-dependent and will fail on Windows environments where grep is not available in the shell. To ensure local tests can run reliably across all developer platforms (macOS, Linux, Windows), consider implementing a native Node.js recursive file search using fs.readdirSync and fs.readFileSync with a regular expression.

function rg(pattern: string, paths: string[]): string {
  const fs = require("fs");
  const regexStr = pattern.replace("[[:space:]]*", "\\s*");
  const regex = new RegExp(regexStr);
  const matches: string[] = [];
  function search(dir: string) {
    const files = fs.readdirSync(dir);
    for (const file of files) {
      const fullPath = path.join(dir, file);
      const stat = fs.statSync(fullPath);
      if (stat.isDirectory()) {
        search(fullPath);
      } else if (stat.isFile() && /\.(ts|tsx|js|jsx)$/.test(file)) {
        const content = fs.readFileSync(fullPath, "utf8");
        const lines = content.split("\n");
        for (let i = 0; i < lines.length; i++) {
          if (regex.test(lines[i])) {
            matches.push(path.relative(REPO_ROOT, fullPath) + ":" + (i + 1) + ":" + lines[i]);
          }
        } 
      }
    }
  }
  for (const p of paths) {
    const fullPath = path.resolve(REPO_ROOT, p);
    if (fs.existsSync(fullPath)) {
      const stat = fs.statSync(fullPath);
      if (stat.isDirectory()) {
        search(fullPath);
      } else {
        const content = fs.readFileSync(fullPath, "utf8");
        const lines = content.split("\n");
        for (let i = 0; i < lines.length; i++) {
          if (regex.test(lines[i])) {
            matches.push(path.relative(REPO_ROOT, fullPath) + ":" + (i + 1) + ":" + lines[i]);
          }
        }
      }
    }
  }
  return matches.join("\n");
}

Comment on lines +22 to +25
afterEach(() => {
process.env = OLD_ENV;
jest.restoreAllMocks();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Manually overriding global.fetch without restoring it can leak mock behavior to other test suites running in the same process, leading to flaky tests or unexpected failures. Consider saving the original global.fetch and restoring it in the afterEach block.

  const ORIGINAL_FETCH = global.fetch;

  afterEach(() => {
    process.env = OLD_ENV;
    global.fetch = ORIGINAL_FETCH;
    jest.restoreAllMocks();
  });

Comment on lines +61 to +71
beforeEach(() => {
jest.clearAllMocks();
process.env.RAZORPAY_KEY_ID = "k";
process.env.RAZORPAY_KEY_SECRET = "s";
process.env.RAZORPAYX_ACCOUNT_NUMBER = "acc";
(global as unknown as { fetch: unknown }).fetch = jest
.fn()
.mockResolvedValue({ ok: true, json: async () => ({ id: "pout_x" }) });
cp.updateMany.mockResolvedValue({ count: 1 }); // claim APPROVED→PROCESSING
ce.updateMany.mockResolvedValue({ count: 0 });
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Modifying process.env and overriding global.fetch without restoring them in an afterEach or afterAll block can pollute other test suites in the same test run. Consider saving their original values and restoring them after each test.

const ORIGINAL_FETCH = global.fetch;
const ORIGINAL_ENV = { ...process.env };

beforeEach(() => {
  jest.clearAllMocks();
  process.env.RAZORPAY_KEY_ID = "k";
  process.env.RAZORPAY_KEY_SECRET = "s";
  process.env.RAZORPAYX_ACCOUNT_NUMBER = "acc";
  (global as unknown as { fetch: unknown }).fetch = jest
    .fn()
    .mockResolvedValue({ ok: true, json: async () => ({ id: "pout_x" }) });
  cp.updateMany.mockResolvedValue({ count: 1 }); // claim APPROVED→PROCESSING
  ce.updateMany.mockResolvedValue({ count: 0 });
});

afterEach(() => {
  global.fetch = ORIGINAL_FETCH;
  process.env = ORIGINAL_ENV;
});

Comment on lines +143 to +151
// consenters only. The signup auth hook stamps consent at account
// creation so this should only filter when a user explicitly
// withdraws via the in-app /consent route.
const consentResults = await Promise.all(
users.map(async (u) => ({
user: u,
hasConsent: await checkConsent({
userId: u.id,
purposeCode: "STREAM_DATA_PROCESSING",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Executing checkConsent individually for each user in a loop using Promise.all results in an N+1 query pattern. If the batch size of users is large, this will cause significant database load and increased latency. Consider optimizing this by batch-fetching the active consent artifacts for all user IDs in a single query.

@teetangh teetangh merged commit ab1529f into prod Jun 11, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants