Enterprise foundation — org CRUD, billing modes, SSO, dashboard#655
Conversation
✅ Deploy Preview for familiarise ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Code Review
This pull request introduces the foundational infrastructure for enterprise functionality, including organization profiles, member management, and multi-tenant dashboard support via a new OrganizationSwitcher component. It implements three primary billing modes—TAG_ONLY, SEAT_PACK, and INVOICED_MONTHLY—and integrates SSO capabilities through BetterAuth. Key feedback includes a recommendation to use cryptographically secure sources for invoice number generation and a warning regarding potential precision issues when using floating-point arithmetic for currency rate calculations.
ChatGPT 5.4 codex feedbackI walked the enterprise docs ( 1. Checkout currently trusts any client-supplied
|
…variants Addresses 3 legit findings from PR #655 code review (ChatGPT 5.4 codex feedback via teetangh). 2 BS gemini-bot comments ignored. 2 SSO integration gaps documented as TODOs (not blockers for this PR). 1. BLOCKER FIX — Checkout org authorization (lib/payments/operations/checkout.ts) The checkout handler trusted any client-supplied organizationId without verifying the caller is an active member of that org. Any logged-in user could pass another org's ID and drain their credit pool (SEAT_PACK), push bookings onto their invoice (INVOICED_MONTHLY), or tag payments (TAG_ONLY). Fix: before applying any org billing mode, verify the caller has an active OrganizationMemberProfile for the org. Fail closed — reject with "You are not an active member" if no membership found. 2. FIX — Member PATCH seat/profile invariants ([orgId]/members/[memberId]) Changing a member's role (e.g., ORG_LEARNER → ORG_ADMIN) or status (ACTIVE → SUSPENDED) previously did not reconcile seatsUsed, seatAssignedAt, or consulteeProfileId/consultantProfileId FKs. Fix: compute wasSeatOccupying vs isSeatOccupying inside one transaction. Increment/decrement seatsUsed, set/clear seatAssignedAt, update profile FKs to match the new role. 3. FIX — seatsTotal enforcement ([orgId]/members + invitations/accept) seatsTotal was configurable and displayed but never enforced — orgs could exceed capacity freely. Fix: check seatsUsed >= seatsTotal before admitting ORG_LEARNER members in both: - POST /api/organizations/[orgId]/members (add existing user) - POST /api/organizations/invitations/accept (invitation flow) Returns 403 with a clear message when capacity is reached. 4. TODO — SSO provider config shape + auto-provisioning gap Added detailed TODO comments documenting that: - Provider registration stores raw samlConfig/oidcConfig strings; BetterAuth may expect structured JSON at runtime - SSO auto-provisioning creates a BetterAuth member but NOT the OrganizationMemberProfile the app requires for requireOrgAccess - The ssoSignInUrl in domain-check may not match BetterAuth's actual SSO entrypoint These are known SSO integration gaps, not blockers for the BUYER billing + org management foundation this PR ships. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ChatGPT 5.4 codex feedback (round 2)I did a second full pass over the updated PR diff and the refreshed PR description. There are definitely real improvements here: the client-supplied That said, I still don't think the PR is fully accurate to merge under the current description yet. The remaining issues are narrower now, but they're still correctness issues rather than polish. 1. ORG_ADMIN onboarding is still not actually atomicThe PR description says the onboarding server action creates the user + org + member + invitations atomically. The code does not currently do that.
So if org creation fails after the user update succeeds, the action returns an error but the user has already been converted into Proposed fix:
2. The ORG_ADMIN onboarding path bypasses the validation/gating already implemented in the org APIs
That means this path does not inherit the stronger validation rules from
Proposed fix:
3. Seat-limit enforcement is still incomplete in the update/remove pathsThe add-member and invite-accept paths now check
Proposed fix:
4. The new seat-limit checks are still raceable under concurrencyBoth:
check This is the classic read-check-write race. The earlier review comment asked for transactional enforcement, and the current implementation is still only partially there. Proposed fix:
5.
|
|
ChatGPT 5.4 codex feedback (round 3) I did another pass through the updated diff, especially the places called out in the earlier reviews. A couple of important things are definitely better now: the direct org-funded checkout auth hole in I do still see a few correctness gaps that I think are worth fixing before calling the enterprise foundation complete:
Proposed fix: move ORG_ADMIN org creation into the same transaction as the user/profile update, and validate the org payload up front with the same Zod schemas/enums the org APIs use instead of re-checking a couple of string fields after commit. Invitation row creation should also be part of the same transaction if the onboarding promise is “create org + owner + invites atomically”; only email delivery should stay out-of-band.
Proposed fix: make seat acquisition a single atomic state transition, e.g.
Proposed fix: either parse/normalize the submitted SAML/OIDC data into the exact BetterAuth provider schema before writing the row, or stop writing the table directly and go through whatever official BetterAuth registration path/hook exists for providers. Until then, I would narrow the PR description from “full SSO admin UI” to “SSO settings/provider scaffolding”.
Proposed fix: verify the real BetterAuth SSO sign-in endpoint and generate that exact URL/contract from Overall: this is closer, and the checkout authorization fix is a meaningful improvement, but I still would not describe the PR as fully atomic onboarding + complete seat enforcement + full SSO admin UI yet. The remaining work is mostly concentrated in onboarding transaction boundaries and the SSO contract, which is good news because it is narrow enough to finish cleanly in this PR. |
|
ChatGPT 5.4 codex feedback (round 4, product/UI pass) I did another pass with a more product-manager / UX lens instead of only a backend correctness lens. A lot of the implementation is there now, but there are still a few places where the PR reads more complete than the actual user journey.
Proposed fix: either (a) treat org creation as a draft flow with explicit
Proposed fix: either wire the real gateway flows before merge, or downgrade these CTAs to clearly disabled/coming-soon states and narrow the PR/UX copy so we are not presenting non-functional billing actions as available features.
Proposed fix: either add a billing mode selector to Settings (with the proper server-side safeguards already in place), or change the Credits page copy to direct the user to support/admin help instead of pointing at a control that does not exist.
Proposed fix: add an explicit SAML vs OIDC mode with the right fields for each, or narrow the UI/PR copy to SAML/provider scaffolding until the OIDC path is genuinely supported.
Proposed fix: either add the missing edit/create UI paths now, or scope the PR copy down to what is actually reachable from the dashboard today. From a PM perspective, hidden API capability is not feature-complete until the user journey exists. Overall: the remaining work is less about raw route count now and more about finishing or de-scoping the user journeys so the shipped product matches the story in the PR. The biggest theme is that several flows are built under the hood but still stop short of a coherent, self-serve admin experience. |
|
ChatGPT 5.4 codex feedback (round 5, billing-model pass) I did a focused pass on the organization billing algorithms and role matrix across checkout, credits, invoicing, refunds, analytics, and the org dashboard permissions. Structurally this is much better than the first iterations, but there are still a few places where the billing model is not yet internally consistent.
Proposed fix: centralize a single “billable org payment” predicate/helper and use it consistently. At minimum: require succeeded payments for billing/analytics, and decide explicitly whether refund-adjusted revenue should be gross or net in each surface.
Proposed fix: invoice generation should only roll up succeeded org-invoiced payments, and it should invoice the remaining billable balance (
Proposed fix: when refunding an ORG_INVOICED payment that is already attached to an invoice, either recompute the invoice totals/items transactionally or create an explicit credit-note / adjustment entry so the ledger remains auditable and invoice totals stay correct.
Proposed fix: enforce the limit in the same place org-funded checkout is authorized. Before accepting an
Proposed fix: codify the intended spending policy explicitly. If any active member may spend org budget, document that. If only specific roles (for example active
Proposed fix: trace the actual consultee/org-member booking UI and either wire Overall recommendation: pull org billing math into a dedicated shared service that owns these invariants:
Right now those rules are spread across checkout, invoice generation, refunds, analytics, and dashboard summary code, which is why the edge cases are diverging. |
|
ChatGPT 5.4 codex feedback: payments / payouts / refunds / disputes round I did another pass focused specifically on the financial state machine: checkout -> payment creation -> org billing -> refunds -> disputes -> payout surfaces, plus discounts / referral-credit interactions and whether the enterprise flows are actually reachable from the UI. A few earlier problems are fixed now, but I still see five meaningful gaps before I would call the enterprise money flows complete.
Proposed fix: extract the refund side effects into one shared helper used by both
Proposed fix: centralize “net billable amount for unbilled org-invoiced payments” into a shared query/helper and use that same net value for invoice generation, credit-limit enforcement, and the billing summary cards.
In Proposed fix: decide policy explicitly and encode it. If org-funded purchases should be pure org spend, block
The transport is there: Proposed fix: add an org-aware checkout entry point or selector for eligible members, pass
Proposed fix: either hide the nav/page entirely while the flag is off, or reframe it as an explicit waitlist/contact-us screen with no implication that payout history or batch payouts are active. Overall, the payment architecture is much closer, but the current weak spots are still around financial consistency across paths rather than schema shape. My biggest recommendation is to consolidate the money rules into shared helpers for:
That will keep checkout, billing summaries, invoice generation, refunds, and payouts from drifting apart again. |
|
ChatGPT 5.4 codex feedback: merge-readiness / stop line After all the review rounds so far, my current read is:
The earlier seat-cap finding appears fixed now: member add/reactivate, invitation acceptance, and learner seat transitions are all using atomic seat acquisition/release. If we want a practical stop line instead of iterating forever, I would treat these as the real must-fix items before calling the branch correct:
Everything else I would classify as follow-up work, not reason to keep the branch open indefinitely:
If you want a blunt assessment: this is now much closer to "mergeable enterprise foundation" than "unfinished experiment," but I still would not market it as fully complete enterprise billing / payouts without the four product-money fixes above plus green CI. |
|
ChatGPT 5.4 codex feedback: near-final merge-readiness pass This branch is much closer now. A lot of the earlier high-risk issues are genuinely fixed, CI is green, and I think the remaining list is finally short enough to be actionable instead of open-ended. I only see a handful of issues left, and I’d split them into two buckets: actual correctness gaps vs feature-completion / scope-claim gaps. Correctness gaps
In There is also a second issue in the same block: Proposed fix: move Phase 3 (
If you do not want to build a full credit-note system in this PR, I would at least make the scope explicit in the PR description and docs: refunds are financially correct for unbilled org charges, but post-invoice adjustments still require manual reconciliation. Feature-completion / scope-claim gaps
There are still three unresolved seams here:
On the UI side,
The backend supports
That means TAG_ONLY / SEAT_PACK / INVOICED_MONTHLY may be implemented server-side, but the real product flow still doesn’t clearly expose “book as yourself” vs “book through organization.” From a PM/UI perspective, this is still the biggest gap between “backend supports it” and “feature is complete.”
The core reporting and invoice generation surfaces exist, but the two money-in actions are still stubs:
The UI is more honest now because those actions are disabled / marked “coming soon,” which is much better than before. But the PR description still reads like all three BUYER billing modes are complete as product flows. I’d scope that down unless you wire at least one real top-up / invoice-settlement path.
My practical stop line If you want the shortest path to “good enough to merge,” I’d treat these as the true pre-merge items:
If those are addressed, I think the branch is finally in the range where the remaining work can move to follow-up issues instead of more PR churn. |
|
ChatGPT 5.4 codex feedback: recent commits diff (post-08c352ff) I reviewed only the newer commit band after Progress is genuinely strong here. The branch is much closer now, and I think the remaining list is finally short. I only see two correctness issues I’d still treat as important before merge, plus one smaller reconciliation/audit gap.
That means a single successful Razorpay purchase can credit the pool twice if both webhook events are delivered, which Razorpay commonly does. The problem is amplified by Proposed fix: make credit purchases settle exactly once. The cleanest version is to add webhook-owned settlement state on
For
So an org using SSO auto-join with default role Proposed fix: move this repair into a transaction that mirrors the standard org-member creation rules. At minimum, when the repaired role is
This is not as urgent as the double-credit bug above, but it does mean invoice settlements are harder to reconcile, trace, or refund later. If you do not want to model a full Overall: this was a good batch of commits. The org payer selector, real Razorpay top-up/payment flows, SSO config normalization, role-aware dashboard gating, and invitation-email path all move the branch materially closer to merge readiness. If you fix the two correctness issues above, I think the branch is finally in “ship with a short follow-up list” territory rather than “keep iterating forever.” |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces the foundation for enterprise organizations, supporting three billing modes (TAG_ONLY, SEAT_PACK, and INVOICED_MONTHLY), a dedicated organization dashboard, and SSO integration. The implementation includes atomic seat management, manual invoice generation, and a credit pool system. Feedback identifies a security vulnerability in the stubbed bank account storage and potential race conditions in invoice generation and last-owner validation. Reviewers also suggested improving financial reconciliation by utilizing actual gateway IDs in webhooks and recommended centralizing duplicated role ranking logic to improve maintainability.
There was a problem hiding this comment.
Pull request overview
This PR introduces an “enterprise” foundation across the app: organizations (CRUD + members + invitations), three org billing modes (tag-only / seat pack credits / invoiced monthly), SSO (BetterAuth SSO), and a new org dashboard experience (OrgSwitcher + role-gated org pages). It also extends checkout/refunds to support optional org-funded payments and adds seed + docs for the new system.
Changes:
- Add org-aware onboarding + dashboard UX (OrgSwitcher, org dashboard routes/pages, invitation acceptance flow).
- Add enterprise backend APIs (org members/invites/seats, plans, credits, billing/invoices, SSO provider + settings).
- Extend payments/checkout/refunds/webhooks to support org billing context (org payer selector + org credit/invoice flows).
Reviewed changes
Copilot reviewed 97 out of 99 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| utils/onboarding.ts | Add ORG_ADMIN onboarding schema + transforms |
| types/razorpay.d.ts | Shared global Razorpay TS types |
| schemas/checkout.ts | Add optional organizationId to checkout payload |
| prisma/seedFiles/config.ts | Seed volume config for org phase |
| prisma/seed.ts | Run Phase 15 org seeding |
| package.json | Upgrade BetterAuth + add SSO + OTel API |
| middleware.ts | Treat /api/organizations/* as private APIs |
| lib/payments/operations/org-credits.ts | Seat-pack credit pool operations (deduct/refund/purchase) |
| lib/feature-flags.ts | Introduce ENABLE_PROVIDER_ORGS flag |
| lib/errors/classification/payment-error-classification.ts | Classify “insufficient credits” as business error |
| lib/email.ts | Add org invitation email sender |
| lib/auth.ts | Enable BetterAuth organization + SSO plugins; add session membership sync |
| lib/auth-helpers.ts | Add requireOrgAccess + org role ranking helpers |
| lib/api/organizations/seat-helpers.ts | Atomic seat acquire/release helpers |
| emails/organizations/OrgInvitationEmail.tsx | New org invite email template |
| docs/enterprise/decisions/2026-04-10-pr2-enterprise-foundation.md | ADR documenting enterprise design decisions |
| components/dashboard/OrganizationSwitcher.tsx | OrgSwitcher dropdown component |
| components/dashboard/DashboardNavbar.tsx | Render OrgSwitcher in dashboard navbar |
| components/dashboard/CollapsibleSidebar.tsx | Add optional bottom “personal user chip” |
| app/organizations/invite/[token]/page.tsx | Public invitation acceptance page |
| app/form/onboarding/page.tsx | Add ORG_ADMIN flow + invite/callback bridging |
| app/form/onboarding/components/PersonalInfoAndRoleForm.tsx | Add “Organization Owner” role option |
| app/form/onboarding/components/OrgAdminReviewStep.tsx | ORG_ADMIN review + launch step |
| app/dashboard/staff/[staffId]/layout.tsx | Add OrgSwitcher to staff layout |
| app/dashboard/page.tsx | Route ORG_ADMIN users into org dashboard |
| app/dashboard/organization/page.tsx | Org landing/list page |
| app/dashboard/organization/create/types.ts | Org creation wizard types |
| app/dashboard/organization/create/schemas.ts | Org creation wizard zod schemas |
| app/dashboard/organization/create/page.tsx | Multi-step org creation wizard |
| app/dashboard/organization/create/components/OrgInfoStep.tsx | Wizard step: org info |
| app/dashboard/organization/create/components/InviteTeamStep.tsx | Wizard step: invite team parsing + role |
| app/dashboard/organization/create/components/BillingStep.tsx | Wizard step: billing mode + seats |
| app/dashboard/organization/[orgId]/useOrgRole.ts | Client role fetching + UI role gating hooks |
| app/dashboard/organization/[orgId]/payouts/page.tsx | Provider-gated payouts page |
| app/dashboard/organization/[orgId]/page.tsx | Redirect bare org route to /home |
| app/dashboard/organization/[orgId]/learners/page.tsx | Learners listing page |
| app/dashboard/organization/[orgId]/layout.tsx | Org dashboard layout + role-gated sidebar |
| app/dashboard/organization/[orgId]/home/page.tsx | Org overview stat cards |
| app/dashboard/organization/[orgId]/consultants/page.tsx | Provider-gated consultants page |
| app/dashboard/organization/[orgId]/analytics/page.tsx | Role-gated analytics page |
| app/dashboard/consultee/[consulteeId]/layout.tsx | Add OrgSwitcher to consultee layout |
| app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx | Add organizationProfileId: null to webinar plan payload |
| app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx | Add organizationProfileId: null to class plan payload |
| app/dashboard/admin/layout.tsx | Add OrgSwitcher to admin layout |
| app/checkout/plans/webinar/[planId]/page.tsx | Add OrgPayerSelector + org context in checkout request |
| app/checkout/plans/subscription/[planId]/page.tsx | Add OrgPayerSelector + org context in checkout request |
| app/checkout/plans/consultation/[planId]/page.tsx | Add OrgPayerSelector + org context in checkout request |
| app/checkout/plans/class/[planId]/page.tsx | Add OrgPayerSelector + org context in checkout request |
| app/checkout/components/OrgPayerSelector.tsx | New payer selector UI for org vs personal payment |
| app/auth/signup/page.tsx | Preserve callbackUrl through signup → onboarding; friendlier errors |
| app/auth/signin/page.tsx | Friendlier auth error mapping |
| app/api/webhooks/utils.ts | Add org-specific webhook success handler |
| app/api/webhooks/razorpay/route.ts | Route org payments to org webhook handler |
| app/api/payments/refunds/route.ts | Route refunds by org billing method + run org side effects |
| app/api/organizations/invitations/accept/route.ts | Accept invitation endpoint + seat enforcement |
| app/api/organizations/[orgId]/sso/route.ts | Org SSO settings + provider listing |
| app/api/organizations/[orgId]/sso/providers/route.ts | Provider CRUD (create/list) for SAML/OIDC |
| app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts | Provider delete endpoint |
| app/api/organizations/[orgId]/settings/route.ts | Settings alias endpoint |
| app/api/organizations/[orgId]/plans/route.ts | Org plan list/create |
| app/api/organizations/[orgId]/plans/[planId]/route.ts | Org plan get/update/soft-delete |
| app/api/organizations/[orgId]/payouts/route.ts | Provider-gated payouts API |
| app/api/organizations/[orgId]/payout-account/route.ts | Provider-gated payout account API |
| app/api/organizations/[orgId]/members/route.ts | Member list + add-by-email with seat enforcement |
| app/api/organizations/[orgId]/members/[memberId]/route.ts | Member patch/delete with last-owner + seat transitions |
| app/api/organizations/[orgId]/learners/route.ts | Learners list endpoint |
| app/api/organizations/[orgId]/invitations/route.ts | Invitation list/create + email dispatch |
| app/api/organizations/[orgId]/invitations/[invitationId]/route.ts | Invitation revoke |
| app/api/organizations/[orgId]/images/route.ts | Org logo/banner upload to Supabase |
| app/api/organizations/[orgId]/credits/route.ts | Credit pool + ledger summary |
| app/api/organizations/[orgId]/credits/purchases/route.ts | Credit purchase history |
| app/api/organizations/[orgId]/credits/purchase/route.ts | Initiate credit purchase (Razorpay order) |
| app/api/organizations/[orgId]/consultants/route.ts | Provider-gated consultants API |
| app/api/organizations/[orgId]/billing/route.ts | Billing summary aggregates |
| app/api/organizations/[orgId]/billing/invoices/route.ts | Invoice list + manual invoice create |
| app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts | Initiate invoice payment (Razorpay order) |
| app/api/organizations/[orgId]/billing/generate-invoice/route.ts | Manual invoiced-monthly rollup generator |
| app/api/organizations/[orgId]/analytics/route.ts | Analytics aggregates |
| app/api/auth/sso/domain-check/route.ts | Public SSO domain-check endpoint |
| actions/forms/onboarding.action.ts | Return orgId from onboarding action |
| .npmrc | Set legacy-peer-deps=true |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Edge case hardening — enterprise PR audit fixesReviewed and patched by Claude Sonnet 4.6 ( 🔴 Critical
🟡 Medium
🟢 New: Audit log
🟢 New: Orphaned credit purchase cleanupPurchases with
🟢 Seed (
|
Payment gains clientIdempotencyKey @unique; every checkout surface mints one stable key per mount (the wallet top-up pattern, ported down to the hottest money endpoint). The route replays the original response for a duplicate — SUCCEEDED returns the completed booking, PENDING returns the same gateway order for resume, terminal states 409 so the client mints a fresh key. Two concurrent identical requests that both miss the replay lookup race to the unique constraint; the loser's P2002 is caught and the winner's response replayed. Scoped to the caller's userId so a guessed key can't read someone else's payment. Closes #828 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…loses #827) Checkout's hard-overlap check only blocks against isTentative:false slots and its tentative dedup is same-user-only, so two different users could both pay for overlapping consultant slots and both get confirmed. The confirmation path for the exclusive booking types (consultation / subscription) now rechecks, inside the confirming tx, for an already- confirmed overlapping slot sharing a non-booker participant (the consultant). The loser stays tentative and surfaces a PAYMENT SystemEvent for refund (#830's orphan sweep is the automated follow-up). Capacity-based types (webinar/class) are deliberately not exclusive and skip the check. Closes #827 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iation scripts Prod env defines RAZORPAY_SECRET (the name lib/payments/core/razorpay.ts reads); the four payout/payment reconciliation scripts read only RAZORPAY_KEY_SECRET and silently skipped — every reconciliation safety net was OFF in production while appearing green. Canonical name first, legacy as fallback. (#677 PM-2's Date.now() idempotency key was already fixed.) Part of #677 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eight-scenario pre-launch chaos suite (go/no-go gate for #837) and the 1k/10k/100k concurrent-user capacity ladder grounding the ADR 13 no-new- infra posture in numbers: cron-vs-pooler contention breaks first at 1k (already seen at zero load — #821/#814/#709), Netlify's 125-concurrent- function ceiling at 10k, and the whole composition at 100k (the planned migration, not a pre-build). Part of #837 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ate-machine-hardening
…ASED The ERASED tombstone (DPDP §12) is terminal, but the erasure pipeline — the only ERASED writer — left ACTIVE/PAUSED assignments counting against program caps. Same cascade as member removal, status-guarded. (Gemini flagged the gap on the members PATCH route, where ERASED is unreachable by Zod; this is the real site.) Part of #812 review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The billableToOrgInvoiceId check moves from a pre-read into the parent UPDATE's WHERE clause (re-evaluated under the row lock), so an invoice rollup stamping the parent between read and write yields count 0 → 'invoiced' instead of silently diverging the leg from the issued document. Recarve writes parent-first; a leg missing the restored base on an uninvoiced parent is an invariant breach and throws to roll the tx back rather than half-applying. Per Gemini review on #826. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…disabled trash Two real bugs surfaced when verifying the prior commit (16cc955): 1. Self-row detection compared `session.user.id` with `MemberRow.user.id`, but BetterAuth's `session.user.id` is its internal id, not the Familiarise `User.id` mirrored on the MemberRow. The equality check silently always-failed, so self-delete UI guard never fired — the trash icon stayed clickable on your own row and the Remove dialog opened before the server's 403 came back. Switched to case-insensitive email comparison via an `isOwnRow(m)` helper. Email is invariant across both stores (auth flow enforces it as the verified, unique identity key) and is set on both `session.user.email` and MemberRow.user.email`. All three call sites (trash button, role dropdown disable, helper-text render) now route through the helper so they stay in sync. 2. The `title` tooltips on disabled trash buttons never showed — browsers don't dispatch mouseover events on disabled `<button>` elements, so the attribute is silently dropped. Wrapped the button in an inline-flex `<span title=...>` so the tooltip lands on the span (which receives hover regardless) instead of the disabled button. Applies to both the "You cannot remove yourself" and "Only an OWNER can remove an OWNER" tooltips. Server-side guard (members/[memberId]/route.ts) was already correct — the inline red error in the Remove dialog via `removeError` was the backstop that caught the UI miss and proved the server gate worked end-to-end. This commit restores the front-line UI guard so doomed clicks never even open the confirm dialog.
…guard, lazy key init Serializable + P2034 retry on the payment-success Phase-1 tx: two concurrent capture webhooks for overlapping slots both passed the #827 conflict findFirst at READ COMMITTED (each saw the other's slots still tentative); under SSI one aborts and the retry sees the winner confirmed. The SUCCEEDED early-return keeps retries idempotent. Stripe stores its hosted checkout URL in client_secret, which we don't persist — a Stripe PENDING replay now falls through to the fresh-key 409 instead of returning a null secret the client can't redirect with. Idempotency keys move from useRef(mint()) (initializer expression runs every render) to useState's lazy initializer (runs once per mount). Per Gemini review on #838. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enterprise state-machine and concurrency hardening: CAS transitions, optimistic locking, Serializable org PATCH, cron locks, webhook dead-letter
Surfaced when an OWNER tried to promote MAINTAINER to BILLING_ADMIN via the dashboard and got an opaque "Invalid body" 400 toast.
Two problems chained:
1. Server: `PATCH /api/organizations/[orgId]/members/[memberId]` had its own local `MemberRoleSchema` Zod enum at L30-37 that listed only six of the seven `MemberRole` values — **BILLING_ADMIN was missing**.
This drifted from:
- the Prisma `MemberRole` enum (7 values),
- `lib/labels/org-labels.ts:MemberRoleSchema` (the shared schema, 7 values),
- `SelfServiceMemberRoleSchema` (which DID include
BILLING_ADMIN — so you could INVITE someone as BILLING_ADMIN via POST /members but not promote an existing member to it via PATCH).
The documented role policy (complete-guide.md §9.2 / §9.3 + `lib/enterprise/role-transitions.ts:11-14`) blocks only LEARNER↔EXPERT, OWNER-touch-by-non-OWNER, and self-role-change — MAINTAINER ↔ BILLING_ADMIN is explicitly allowed. The schema drop was a code bug, not a policy choice.
Fix: add BILLING_ADMIN to the local enum + a comment warning that this list MUST stay in sync with the Prisma enum + shared schema. Long-term, the cleanest fix is to import the shared schema; kept local-with-warning for minimum diff in this commit.
2. Client: `addMember` and `updateMember` in`MembersPageClient.tsx` rendered the server's literal "Invalid body" string as the toast on any 400. Users had no way to
tell which field tripped the validation, and the server's `detail.fieldErrors` payload (containing exactly that info)
was ignored.
Fix: before falling back to the generic humanizer, inspect `body.detail.fieldErrors` and if any field has an error, surface "Couldn't save changes — invalid {field-list}. Refresh the page and try again, or contact support if this persists." Field names make the message actionable; the refresh hint covers the most common cause (stale role list / stale schema on the client after a deploy).
Both fixes ship together because they're two layers of the same incident: the server gap caused the failure, the client gap made it opaque. The server fix removes this specific symptom; the client fix protects against any future Zod schema drift surfacing with the same opaque toast.
Money correctness (PR-B, stacked): overage basePaise restore/recarve + GST gross-up test coverage
B2C hardening (PR-C, stacked): slot mis-binding, double-booking guard, checkout idempotency, reconciliation env fix
…delete race Regression pins for fixes that shipped in #825/#838 while their issues stayed open: the #788 same-source merge guard, the #827 first-confirmed- wins recheck (loser stays tentative + PAYMENT SystemEvent), and the #828 idempotent replay semantics (PENDING resume, SUCCEEDED short-circuit, terminal 409, Stripe non-resume, user scoping). confirmExistingAppointment is exported for test reach; the replay helper moves to lib/payments/operations/checkout-replay.ts (a non-route module — Next route files may only export HTTP methods). The one code fix: cleanup-tentative-slots deleted by id only, so a slot confirmed between the scan and the delete was destroyed (a paid booking with no slot). The delete now re-states isTentative + no-SUCCEEDED-payment, re-evaluated under the row lock. Closes #788 Closes #827 Closes #828 Closes #829 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The canonical glossary (availability window → bookable slot → booked slot → appointment → engagement → meeting; 'session' reserved for nothing) lands in 00-foundations, including the verdict that 'enterprise referrals' is not a feature — the phrase conflated trial attribution, the PERSONAL-funding credit rule, and B2C consultant qualification. Terminology fixes verified by the domain audit: the dead meetingRoom include flag (field doesn't exist on Appointment), the sessionsConsumed → engagementsConsumed rename across the overage family (engagement = the enterprise billing meter, per glossary), and the stale refund TODO rewritten to point at the real webhook-path credit restoration. knip.json checked in so future scans share the entry config. Part of #837. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ipts, orphaned hooks) Every deletion was double-verified: knip (configured with jobs/scripts/ emails/seed entries) flagged candidates, then a 10-agent pass checked each for dynamic imports, string references, workflow invocations, barrel chains, and framework conventions before sentencing. The verification saved 45 false positives — including the entire race-condition test suite (dynamically discovered by its master-runner) and a FeedbackPage default import the agents missed but the build gate caught. lib/compliance/form15 spared deliberately: a documented stub awaiting CA sign-off, not zombie code. Git history remains the recovery path for everything deleted. Gates: tsc clean, 1298/1298 jest, next build green. Part of #837. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…4 files The agent pass applied per-symbol judgment the scanner can't: types used in-file were DE-exported rather than deleted, duplicate named/default exports were resolved by checking how every consumer actually imports (catching one verifier inversion where the default import was the live one), barrel re-export chains were unwound in dependency order, and anything ambiguous was skipped and reported. The z.infer aliases whose same-named consumers actually resolve to @prisma/client were the largest single source of confusion deleted. Gates: tsc clean, 1298/1298 jest, next build green. Part of #837. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rlap-guard column, policy snapshot (#503/#753/#440) Every column here is now-or-never under the #781 freeze; implementation may lag, columns may not. #503 — SlotOfAvailabilityWeekly gains timezone (IANA) + localStartMinutes/ localEndMinutes: the DST-proof source of truth next to the frozen utcOffsetMinutes that breaks for any DST zone half the year. Both weekly write paths populate the new columns; the slot math migrates read-side in the follow-up, then the offset retires. #753 option B — creditsPerCycle → engagementsPerCycle across schema, API, dashboard, seeds, tests, and 11 docs: the meter counts engagements (bookings), never money; 'credits' advertised a paise-denominated meter the code never implemented. Closes the schema/code lie before freeze. #440 — SlotOfAppointment gains denormalized consultantProfileId (+ index), populated by both slot-creation paths; the btree_gist exclusion constraint itself needs raw SQL and lands with prisma-migrate adoption. B1 decision — Appointment.cancellationPolicySnapshot Json?: tiered refund windows resolved at booking so later policy edits never retroactively change a buyer's terms. Written+read by the cancel flow (PR-4). RESCHEDULED — keep + wire decision recorded on the enum; PR-4 stamps it. db push deferred (Supabase disconnected); prisma generate done; tsc clean; 1298/1298 tests. Closes #753 Part of #503 Part of #440 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…8/B11/B13) B1 — cancellation finally moves money. Checkout freezes tiered refund terms onto Appointment.cancellationPolicySnapshot (24h+=100%, 2-24h=50%, <2h=0%, consultant-initiated always 100%); the cancel route computes the percentage from the BOOKING-TIME snapshot and drives refundPayment (the canonical Serializable orchestrator). A failed refund surfaces instead of silently shipping a cancel-without-money. Org policy prose rides the snapshot for the support trail; structured per-org tiers are post-launch. B2 — cancel transitions are CAS-guarded (cancellable from PENDING/ APPROVED/APPROVED_PENDING_PAYMENT/SCHEDULED only; a double-cancel 409s before re-running refunds), slots only flip from SCHEDULED so history is never re-stamped, and the webhook's confirmApprovalStatus no longer resurrects a CANCELLED booking from a late capture — that capture now raises CAPTURE_AFTER_TERMINAL_STATE for the refund path. B8 — reallocation never hard-deletes an appointment with Payment rows (onDelete: Cascade was silently destroying the payment audit trail). B11 — class enrollment rejects partially-scheduled classes instead of silently linking the buyer to fewer sessions than they paid for. B13 — slot-level consultant soft-deletion recheck closes the plan-fetch/ slot-validate race. Part of #837. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e sweep Reschedule stamps replaced slots completionStatus=RESCHEDULED (the keep-and-wire decision — the lifecycle is no longer inferred from the overloaded isTentative boolean), CAS-guards its status resets so a reschedule racing a cancel/completion cannot resurrect the booking (409 NOT_RESCHEDULABLE), writes the activity-log entry cancel always had (new additive APPOINTMENT_RESCHEDULED activity type), and stops notifying the initiator about their own reschedule. Closes #830: reconcile-orphaned-confirmations finds SUCCEEDED payments whose slots are still tentative (crash between capture and confirmation) and re-drives confirmExistingAppointment under the webhook's own Serializable + retry discipline. The #827 first-confirmed-wins guard stays in force — a genuine double-booking loser is reported for the refund path, never force-confirmed. Wired as a fail-closed locked core + GH Actions wrapper + 30-minute workflow per ADR 05. Test harnesses migrated to the CAS shapes (updateMany with guards). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pin landed slot/session fixes (PR-1 of 4): regression tests + cleanup delete-race guard
…y-sweep Dead-code and terminology sweep (PR-2 of 4): glossary, 63 file deletions, 309 symbol removals
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 — full subsystem
This PR ships the complete enterprise subsystem on top of the existing
B2C marketplace: orgs, RBAC, SSO, billing/payouts, programs, audit, plus
the cross-cutting integrations (outbound webhooks, SCIM 2.0, DPDP §12
erasure, data export, retention crons, security headers, maintenance
tier-1, BILLING_ADMIN dashboard).
Production readiness: 100/100 per the May 2026 closeout.
CI: 957/957 jest,
tsc --noEmitclean, lint clean.Schema migrations: 4 applied to production via Supabase MCP
(
pr655_enterprise_lockdown_schema,pr655_schema_finalization_pre_mvp,pr655_maintenance_per_tenant_tier1).Defer list: #744 — Enterprise v1 post-MVP roadmap.
What ships in this PR
1. Organization lifecycle
Organizationmodel with the full capability matrix:canSponsor/canHost/INERT, funding modes(PERSONAL / WALLET / INVOICE / LICENSE), GST + MSME + PAN
compliance fields, hierarchy columns (
parentId / rootId / depth,UI deferred to Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744), 14 status + capability indexes.
/api/organizationscreates anOrganizationBillingAccount+OrgWorkspaceProfile+ member-OWNER membershipin one transaction.
PENDING_VERIFICATION → ACTIVE → SUSPENDED → DEACTIVATEDenforced viarequireOrgAccess+ adminverification routes.
/explore/enterprise/organisations.canHost: truehard-gated server-side behindENABLE_HOST_ORGS(rejects with typed
HOST_ORGS_GATED 400) so an org never ends uphalf-functional. Replaces the prior WIP banner — per reviewer
feedback "WIP banners are not production gates".
2. Membership + RBAC
MemberRoleenum:OWNER · MAINTAINER · BILLING_ADMIN · MANAGER · EXPERT · LEARNER · SUPPORT.BILLING_ADMINis the new finance-team role between MAINTAINER (rank 80) and MANAGER (rank 60) at
rank 70.
MemberStatusenum addsERASED(DPDP §12 tombstone).requireOrgAccess,requireOrgOwner,requireOrgBillingAdminOrOwner, and the page-leveluseRequireFinanceSurface/useRequireOperatorSurfacehooks.comparison) — MAINTAINER at rank 80 must still be denied billing
routes. Pinned by
__tests__/enterprise/billing-admin-gate.test.ts(9 cases).
billing-account PATCH, purchase-orders CRUD, invoices POST + PATCH
sso/**,domain-claims/**,members/**,invitations/**,scim/tokens/**.lib/enterprise/role-transitions.ts; LEARNER ↔ EXPERT disjointrule pinned by
__tests__/enterprise/member-anti-lockout.test.ts.sessionGenerationmarker bumped on every role change so thenext request through BetterAuth's
customSessionrefetcheswithout forcing a logout (audit Phase B.5).
3. SSO + auth
SsoProviderrows + theOrganizationSSOSettingsallowlist +OrgDomainClaimDNS-TXTverification.
defaultRoleForAutoJoin: LEARNER(principleof least privilege; rejects OWNER bootstrap).
lib/sso/provider-schemas.ts:validateSamlCertrunsnew X509Certificate(...)at registration AND at the pre-authdomain-checkendpoint. A bad legacy cert no longer crashesBetterAuth's SAML adapter with an empty-body 500 — the route
returns a typed
SSO_PROVIDER_MISCONFIGURED422 that the signinpage renders as a friendly toast.
4. Billing + invoicing
BillingAccountper sponsor org carrying the funding mode andper-currency wallet balance. Atomic
WalletEntry-based ledger.OrganizationInvoicewith GST-compliant breakdown (CGST/SGST/IGSTper place-of-supply), IRN placeholder + IRP submission cron,
per-org fiscal-year sequence allocation, PDF caching.
PurchaseOrder.remainingAmountPaisedecrement is now an atomicCAS via
updateManywithgtepredicate. Returns 409PO_BALANCE_EXCEEDEDon miss. VOID/CANCELLED transitions restorethe balance. 6-case regression test at
__tests__/enterprise/po-balance-enforcement.test.ts.invoice payment.
5. Programs + assignments
Programmodel withLICENSED_SEAT/CREDIT_POOL(shipped) andreserved
PROJECT/RETAINER/RESELLERfor V2 (schema-only,rejected at the API layer with
PROGRAM_TYPE_NOT_AVAILABLE 400—pinned by
__tests__/enterprise/programs-v2-rejection.test.ts).LicensedSeatConfigengagement caps + overage routing(BLOCK / CHARGE_MEMBER / CHARGE_ORG).
CreditPoolConfigcycle-based credit allocation.ProgramAssignmentper-period seat binding.BookingUtilizationper-payment engagement-consumed counter.RateCardwith time-scoped effective ranges + per-contract +per-membership overrides; basis-point split snapshot at booking.
6. Payouts + earnings
OrganizationPayoutAccount+ Razorpay-X / Stripe Connect linking.OrganizationEarningsper-payment org-share rows with hold +release state machine.
OrganizationPayoutweekly batch cron + manual admin route.MSME 15/45-day deadline alerts, RBI purpose code, DTAA rate,
clawback support, idempotent submission.
7. Audit + compliance
OrgAuditLogwith 11-category enum (now includingWEBHOOK).157 well-known action constants in
lib/enterprise/audit-actions.ts. Every mutation writes a row.(
scripts/cleanup/prune-audit-logs.ts, daily 03:15 UTC):7 years for INVOICE / PAYOUT / WALLET / CONTRACT / CONSENT (IT Act
§44AA); 2 years for MEMBER / SETTINGS / CATALOG / SYSTEM / PROGRAM
/ WEBHOOK. Emits one
AUDIT_PRUNEDsummary row per org per run.custody for compliance reviews).
8. Outbound webhooks (new subsystem)
External integrators (HRIS, finance ERP, customer-success tools) can
register HTTPS endpoints and subscribe to 8 lifecycle events:
member.added,member.removed,invoice.issued,invoice.paid,payout.completed,payout.failed,contract.signed,program.assigned.t=<unix>,v1=<hex>header, 9-hour replay window.Constant-time verifier (
crypto.timingSafeEqual).(1m → 5m → 30m → 2h → 8h then FAILED).
Permanent 4xx (excl. 408/429) skip retry; 5xx/408/429/network →
retry. Operator-paused endpoints short-circuit to FAILED.
redeliver). One-time secret reveal on POST; redacted everywhere
else.
transaction so a rollback also rolls back the delivery
(member-added that didn't commit doesn't emit).
.github/workflows/dispatch-outbound-webhooks.yml(every minute).
dispatch fan-out, worker retry schedule, operator-pause
short-circuit.
WebhookEndpoint+OutboundWebhookDelivery+WebhookEndpointStatus+DeliveryStatusenums. PlusWebhookEndpoint.secretRotatedAt+previousSecretHashcolumnsfor the 24h grace-window scaffolding (verifier upgrade is post-MVP).
docs/enterprise/29-outbound-webhooks.md.9. SCIM 2.0 (new subsystem)
Spec-compliant subset mounted at
/scim/v2/Usersso off-the-shelfIdP connectors (Okta, Azure AD, OneLogin, JumpCloud) work without
custom configuration.
/api/(IdPs hard-code the/scim/v2/prefix) with capital-cased segments per RFC 7644 §3.2.persisted as SHA-256 hash. Misuse on a revoked token writes a
SCIM_TOKEN_USED_AFTER_REVOKEaudit row.PATCH
replace activefor Okta/Azure deactivate, DELETE →SUSPEND (never erase — erasure is the user's own DPDP §12 right).
ScimGroupMappingrows; highest-rank role wins; LEARNER default for unmapped users.
a User returns
410 GonewhenUser.erasedAt IS NOT NULL.member.added/member.removedoutbound webhook withsource: "scim".ScimToken(+expiresAtfor auto-rotation TTL) +ScimGroupMapping+Membership.externalScimIdpartial-uniquescoped by orgId.
docs/enterprise/31-scim-provisioning.md.10. DPDP §12 automated erasure (new subsystem)
Pre-PR this was a manual CA + DB-admin process; now an admin can
fire-and-confirm in seconds.
POST /api/users/me/erasure-requests(idempotent — open requests dedup); admin reviews via
GET /api/admin/erasure-requests; processes via/process(runsscrubUserin a transaction) or rejects via/rejectwith a required reason.lib/compliance/erasure/scrub-user.ts) — one atomictransaction:
Erased User <hash>,erased-<hash>@erased.invalid, NULL elsewhere);erasedAtset;pseudonymousId = sha256(userId + salt).Membership.status → ERASED.ConsultantProfile.headline + videoIntroUrlscrubbed.Session+Accountrows hard-deleted (forcesimmediate sign-out across every device).
member.removedwebhook fan-out per affected org withsource: "dpdp_erasure"and pseudonymous payload.Payment*,OrganizationInvoice,OrganizationPayout,WalletEntry,FundingLedgerEntry,SettlementLedgerEntry,Refund.USER_ERASURE_REQUESTED / PROCESSED / REJECTED / SLA_WARNING. The audit row outlives the user's identity as theregulatory evidence-of-erasure record.
ErasureRequest(+processedByAdminId @relationFKadded in finalization). Partial-unique index ensures at-most-one
open request per user.
docs/enterprise/26-deletion-policy.md(Phase 2 rewrite).Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744 C1.
11. DPDP §11 data export (new surface)
OWNER + BILLING_ADMIN can pull a JSON bundle of every org-scoped
entity: members, memberships, contracts, programs, invoices,
earnings, payouts, audit log.
/api/organizations/[orgId]/data-exports—orgDataExportLimiter(1/24h per org). Async worker
(
scripts/cleanup/process-data-exports.ts, every 10 min) builds thebundle, uploads to Supabase Storage with 7-day signed URL, emails
the requester via Resend.
schemaVersion: 1.OrgDataExportJob+OrgDataExportStatusenum.docs/enterprise/33-data-export.md.12. Stream call/recording surfaces
GET /api/organizations/[orgId]/stream/calls(MANAGER+) —paginated, joins
Recordinglazily on?withRecordings=1.Organization.streamRecordingRetentionDays(default 90).scripts/cleanup/cleanup-old-stream-recordings.tsdaily crontombstones recordings past the per-org window.
13. Maintenance subsystem — Tier 1 multi-tenant
Pre-PR maintenance was a platform-wide on/off switch. Tier 1
unlocks the per-tenant features without forcing them in now.
MaintenanceWindow.organizationIdnullable FK(NULL = platform-wide, legacy behaviour preserved; non-null =
scoped to that tenant).
lib/maintenance-edge.tsexportsplatformMaintenanceKeys()+orgMaintenanceKeys(orgId)so the post-MVP admin route can writeorg-scoped state under a canonical Redis namespace from day one.
workflows) tracked in Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744 D3.
14. Security headers
next.config.mjsextended:Strict-Transport-Security—max-age=63072000; includeSubDomains; preload(2y).Content-Security-Policy-Report-Only— allow-list coversStream / Razorpay / Sentry / Supabase / Resend / Upstash;
media-srcincludesblob:+*.getstream.iofor call playback;frame-srcallows Razorpay checkout iframe.ENABLE_CSP_ENFORCE=trueflips to enforcing variant.
X-DNS-Prefetch-Control, Referrer-Policy, Permissions-Policy)
preserved.
/api/csp-reportsink (unauthenticated,spamLimiter-throttled)emits structured
event: "csp_violation"logs.docs/enterprise/23-runbooks.md.docs/enterprise/32-security-headers.md.15. BILLING_ADMIN dashboard surface
/integrations/route group:webhooks+scim+data-exportspages.Gated by
useRequireFinanceSurface(OWNER + MAINTAINER +BILLING_ADMIN + MANAGER).
FinanceLeadViewCardon/home— renders for BILLING_ADMINahead of the operator branch. Pulls outstanding invoices + wallet
balance from the existing analytics endpoint; Payouts + POs are
deep-link CTAs (analytics breakdowns tracked in Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744).
isAtLeast("MANAGER")checks withthe explicit
canSeeOperatorSurface/canSeeFinanceSurfacepredicates. Fixes a real bug where BILLING_ADMIN (rank 70 >
MANAGER 60) was silently granted access to
Members / Invitations / Audit / Settings.
16. Org-scope filter coverage (#674 follow-up — 6 leaks closed)
The #674 personal-vs-org scope split shipped
resolveOrgScope+denormalized
organizationIdcolumns on every leaf table, but thelegacy routes never got the filter. Fixed:
/api/slots/appointments—getAppointmentsnow takes anorgScopeFilterarg; consultant + consultee appointment tabs nolonger cross-tenant.
/api/dashboard/consultee/[id]/payments—Payment.organizationIdfilter; invoice subquery flows the same filter through the payment
join.
/api/trials—TrialSession.organizationIdfilter./api/waitlist—Waitlist.organizationIdfilter viagetUserWaitlistEntriesorgFilterarg./api/referrals— DELIBERATELY personal-only (referral codesfollow the user across orgs); doc-only record of intent.
components/chat/ChatSidebar.tsx— StreamqueryChannelsnowscopes on
custom.organization_id. Both initial fetch + paginatedload-more.
scopeadded touseCallbackdeps so the inboxrefetches on org-context toggle. Was the highest-impact leak —
a consultant in Acme + Zeta was seeing every chat cross-tenanted
in one inbox.
Conflict resolution remains intra-user, not inter-org by design.
SlotOfAvailability + SlotOfAppointment stay per-user — a consultant
has one body, one calendar, one meeting URL. The dashboard filter is
for visibility + finance attribution; double-booking detection across
orgs was already correct and unchanged.
Schema additions
Five Supabase MCP migrations applied to production
(all additive; no backfill required):
pr655_enterprise_lockdown_schema— 6 new tables, 5 newenums, 3 enum extensions (
MemberRole += BILLING_ADMIN,MemberStatus += ERASED,OrgAuditCategory += WEBHOOK),User += erasedAt + pseudonymousId,Membership += externalScimId,Organization += streamRecordingRetentionDays.pr655_schema_finalization_pre_mvp—ErasureRequest.processedByAdminIdFK,WebhookEndpoint.secretRotatedAt + previousSecretHash,ScimToken.expiresAt+ partial index.pr655_maintenance_per_tenant_tier1—MaintenanceWindow.organizationIdFK + indexes.Test coverage
PR start). Net +74 new tests:
over-counter bug in the existing cron
npx tsc --noEmitclean.npm run lintclean.the entire SCIM lifecycle (list / filter / create / PATCH / DELETE),
data export pipeline (request → worker → bundle build → Supabase
upload → download URL), DPDP §12 erasure (queue → process →
membership flip → audit row → webhook fan-out), and all four
cleanup crons.
Closing the May 2026 audit
lib/sso/provider-schemas.ts+domain-checkpre-auth guardhumanizeOrgErrortabledotenv/configacross 5 standalone jobs/callsendpoint (wasprune-audit-logs.tsdaily cronWhat's deliberately deferred
See #744 — Enterprise v1 post-MVP roadmap.
18 buckets across 7 sections. Highlights:
not shipping. New issue if a customer asks.
full impl needs an earnings-only helper extraction.
docs/compliance/10-rls-design-memo.md. App-layer auth issufficient for v1; trigger is Realtime/Storage in client OR SOC 2
audit OR anon-key leak.
enforcement — schema columns ship, enforcement reads are
follow-ups.
CSV export companion + HRIS UI + HOST org settlement
— all schema-ready, implementation deferred.
Commits in this PR
Plus everything pre-
fb68386cthat already shipped the org lifecycle,RBAC base, SSO scaffolding, billing/payouts base, and audit subsystem.
Test plan
npm test— 957/957 greennpx tsc --noEmit— cleannpm run lint— cleantick → bundle download from Supabase Storage)
Membership.status = ERASED, scrubbedUser.email, audit rowwritten,
member.removedwebhook queued)CRON_SECRET(audit-prune, Stream retention, webhook dispatch,data exports)
curl -I /(toggle PERSONAL → org1 → org2 → org1 across every tab) is
deferred to pilot smoke — tracked in Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744 B2.
🤖 Generated with Claude Code