Skip to content

Enterprise foundation — org CRUD, billing modes, SSO, dashboard#655

Open
teetangh wants to merge 239 commits into
devfrom
feature/enterprise
Open

Enterprise foundation — org CRUD, billing modes, SSO, dashboard#655
teetangh wants to merge 239 commits into
devfrom
feature/enterprise

Conversation

@teetangh
Copy link
Copy Markdown
Contributor

@teetangh teetangh commented Apr 10, 2026

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 --noEmit clean, 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

  • Organization model 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.
  • Wizard flow → POST /api/organizations creates an Organization
    • BillingAccount + OrgWorkspaceProfile + member-OWNER membership
      in one transaction.
  • Org status state machine: PENDING_VERIFICATION → ACTIVE → SUSPENDED → DEACTIVATED enforced via requireOrgAccess + admin
    verification routes.
  • Public org marketplace at /explore/enterprise/organisations.
  • canHost: true hard-gated server-side behind ENABLE_HOST_ORGS
    (rejects with typed HOST_ORGS_GATED 400) so an org never ends up
    half-functional. Replaces the prior WIP banner — per reviewer
    feedback "WIP banners are not production gates".

2. Membership + RBAC

  • MemberRole enum: OWNER · MAINTAINER · BILLING_ADMIN · MANAGER · EXPERT · LEARNER · SUPPORT. BILLING_ADMIN is the new finance-
    team role
    between MAINTAINER (rank 80) and MANAGER (rank 60) at
    rank 70.
  • MemberStatus enum adds ERASED (DPDP §12 tombstone).
  • 4 gate helpers: requireOrgAccess, requireOrgOwner,
    requireOrgBillingAdminOrOwner, and the page-level
    useRequireFinanceSurface / useRequireOperatorSurface hooks.
  • The BILLING_ADMIN gate is an explicit disjunction (not a rank
    comparison) — MAINTAINER at rank 80 must still be denied billing
    routes. Pinned by __tests__/enterprise/billing-admin-gate.test.ts
    (9 cases).
  • 12 route gate downgrades from OWNER-only to OWNER + BILLING_ADMIN:
    billing-account PATCH, purchase-orders CRUD, invoices POST + PATCH
    • pay, wallet top-ups POST, payouts POST + PATCH, rate-cards POST
    • PATCH. Stays OWNER-only: org DELETE, sso/**,
      domain-claims/**, members/**, invitations/**,
      scim/tokens/**.
  • Role transition state machine in
    lib/enterprise/role-transitions.ts; LEARNER ↔ EXPERT disjoint
    rule pinned by __tests__/enterprise/member-anti-lockout.test.ts.
  • sessionGeneration marker bumped on every role change so the
    next request through BetterAuth's customSession refetches
    without forcing a logout (audit Phase B.5).

3. SSO + auth

  • BetterAuth SSO plugin wired with per-org SsoProvider rows + the
    OrganizationSSOSettings allowlist + OrgDomainClaim DNS-TXT
    verification.
  • JIT auto-join with defaultRoleForAutoJoin: LEARNER (principle
    of least privilege; rejects OWNER bootstrap).
  • Cert validation hard-gate (Bug SSO.1):
    lib/sso/provider-schemas.ts:validateSamlCert runs new X509Certificate(...) at registration AND at the pre-auth
    domain-check endpoint. A bad legacy cert no longer crashes
    BetterAuth's SAML adapter with an empty-body 500 — the route
    returns a typed SSO_PROVIDER_MISCONFIGURED 422 that the signin
    page renders as a friendly toast.
  • Anti-lockout guards on enforceSSO + provider delete.
  • Cert-expiry alert cron at 30d WARN / 7d CRITICAL thresholds.

4. Billing + invoicing

  • BillingAccount per sponsor org carrying the funding mode and
    per-currency wallet balance. Atomic WalletEntry-based ledger.
  • OrganizationInvoice with GST-compliant breakdown (CGST/SGST/IGST
    per place-of-supply), IRN placeholder + IRP submission cron,
    per-org fiscal-year sequence allocation, PDF caching.
  • PO balance enforcement (Bug PO.2/PO.4 hardening):
    PurchaseOrder.remainingAmountPaise decrement is now an atomic
    CAS via updateMany with gte predicate. Returns 409
    PO_BALANCE_EXCEEDED on miss. VOID/CANCELLED transitions restore
    the balance. 6-case regression test at
    __tests__/enterprise/po-balance-enforcement.test.ts.
  • Contract/PO/Invoice three-way match.
  • Razorpay + Stripe + Cashfree gateway router for wallet top-ups +
    invoice payment.

5. Programs + assignments

  • Program model with LICENSED_SEAT / CREDIT_POOL (shipped) and
    reserved PROJECT / RETAINER / RESELLER for V2 (schema-only,
    rejected at the API layer with PROGRAM_TYPE_NOT_AVAILABLE 400
    pinned by __tests__/enterprise/programs-v2-rejection.test.ts).
  • LicensedSeatConfig engagement caps + overage routing
    (BLOCK / CHARGE_MEMBER / CHARGE_ORG).
  • CreditPoolConfig cycle-based credit allocation.
  • ProgramAssignment per-period seat binding.
  • BookingUtilization per-payment engagement-consumed counter.
  • RateCard with time-scoped effective ranges + per-contract +
    per-membership overrides; basis-point split snapshot at booking.

6. Payouts + earnings

  • OrganizationPayoutAccount + Razorpay-X / Stripe Connect linking.
  • OrganizationEarnings per-payment org-share rows with hold +
    release state machine.
  • OrganizationPayout weekly batch cron + manual admin route.
  • India-statutory fields: TDS §194O section selection,
    MSME 15/45-day deadline alerts, RBI purpose code, DTAA rate,
    clawback support, idempotent submission.

7. Audit + compliance

  • OrgAuditLog with 11-category enum (now including WEBHOOK).
    157 well-known action constants in
    lib/enterprise/audit-actions.ts. Every mutation writes a row.
  • Audit retention cron
    (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_PRUNED summary row per org per run.
  • Audit-log CSV export route audit-logs the export itself (chain of
    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.

  • Signing: HMAC-SHA256 with Stripe-compatible
    t=<unix>,v1=<hex> header, 9-hour replay window.
    Constant-time verifier (crypto.timingSafeEqual).
  • Worker: exponential backoff retry
    (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.
  • 7 org-scoped routes (CRUD + rotate-secret + deliveries list +
    redeliver). One-time secret reveal on POST; redacted everywhere
    else.
  • Dispatch helper enqueues delivery rows inside the originating
    transaction so a rollback also rolls back the delivery
    (member-added that didn't commit doesn't emit).
  • Cron at .github/workflows/dispatch-outbound-webhooks.yml
    (every minute).
  • 19-case test suite covering signing roundtrip, replay rejection,
    dispatch fan-out, worker retry schedule, operator-pause
    short-circuit.
  • Schema: WebhookEndpoint + OutboundWebhookDelivery +
    WebhookEndpointStatus + DeliveryStatus enums. Plus
    WebhookEndpoint.secretRotatedAt + previousSecretHash columns
    for the 24h grace-window scaffolding (verifier upgrade is post-MVP).
  • Docs: docs/enterprise/29-outbound-webhooks.md.

9. SCIM 2.0 (new subsystem)

Spec-compliant subset mounted at /scim/v2/Users so off-the-shelf
IdP connectors (Okta, Azure AD, OneLogin, JumpCloud) work without
custom configuration.

  • Path placement is deliberately outside /api/ (IdPs hard-code the
    /scim/v2/ prefix) with capital-cased segments per RFC 7644 §3.2.
  • Auth: per-org bearer tokens — raw value shown once at mint,
    persisted as SHA-256 hash. Misuse on a revoked token writes a
    SCIM_TOKEN_USED_AFTER_REVOKE audit row.
  • Operations: create/update users (idempotent on email),
    PATCH replace active for Okta/Azure deactivate, DELETE →
    SUSPEND (never erase — erasure is the user's own DPDP §12 right).
  • Group → role mapping: per-org ScimGroupMapping rows; highest-
    rank role wins; LEARNER default for unmapped users.
  • Erasure short-circuit: every operation that would create/revive
    a User returns 410 Gone when User.erasedAt IS NOT NULL.
  • Webhook fan-out: every SCIM mutation emits the matching
    member.added / member.removed outbound webhook with
    source: "scim".
  • 15-case test suite.
  • Schema: ScimToken (+ expiresAt for auto-rotation TTL) +
    ScimGroupMapping + Membership.externalScimId partial-unique
    scoped by orgId.
  • Docs: 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.

  • Flow: user files at POST /api/users/me/erasure-requests
    (idempotent — open requests dedup); admin reviews via
    GET /api/admin/erasure-requests; processes via
    /process (runs scrubUser in a transaction) or rejects via
    /reject with a required reason.
  • Scrub (lib/compliance/erasure/scrub-user.ts) — one atomic
    transaction:
    • User PII → pseudonymous values (Erased User <hash>,
      erased-<hash>@erased.invalid, NULL elsewhere); erasedAt set;
      pseudonymousId = sha256(userId + salt).
    • Every Membership.status → ERASED.
    • ConsultantProfile.headline + videoIntroUrl scrubbed.
    • BetterAuth Session + Account rows hard-deleted (forces
      immediate sign-out across every device).
    • member.removed webhook fan-out per affected org with
      source: "dpdp_erasure" and pseudonymous payload.
  • Retained (IT Act §44AA carve-out): Payment*,
    OrganizationInvoice, OrganizationPayout, WalletEntry,
    FundingLedgerEntry, SettlementLedgerEntry, Refund.
  • SCIM 410 short-circuit prevents re-provisioning.
  • Audit: USER_ERASURE_REQUESTED / PROCESSED / REJECTED / SLA_WARNING. The audit row outlives the user's identity as the
    regulatory evidence-of-erasure record.
  • Schema: ErasureRequest (+ processedByAdminId @relation FK
    added in finalization). Partial-unique index ensures at-most-one
    open request per user.
  • Docs: docs/enterprise/26-deletion-policy.md (Phase 2 rewrite).
  • Auto-process cron is documented but not deployed — tracked in
    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.

  • POST /api/organizations/[orgId]/data-exportsorgDataExportLimiter
    (1/24h per org). Async worker
    (scripts/cleanup/process-data-exports.ts, every 10 min) builds the
    bundle, uploads to Supabase Storage with 7-day signed URL, emails
    the requester via Resend.
  • Bundle shape: flat JSON, schemaVersion: 1.
  • Schema: OrgDataExportJob + OrgDataExportStatus enum.
  • Docs: docs/enterprise/33-data-export.md.

12. Stream call/recording surfaces

  • GET /api/organizations/[orgId]/stream/calls (MANAGER+) —
    paginated, joins Recording lazily on ?withRecordings=1.
  • Per-org recording retention via
    Organization.streamRecordingRetentionDays (default 90).
    scripts/cleanup/cleanup-old-stream-recordings.ts daily cron
    tombstones 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.organizationId nullable FK
    (NULL = platform-wide, legacy behaviour preserved; non-null =
    scoped to that tenant).
  • lib/maintenance-edge.ts exports platformMaintenanceKeys() +
    orgMaintenanceKeys(orgId) so the post-MVP admin route can write
    org-scoped state under a canonical Redis namespace from day one.
  • Tier 2 (per-org admin API, capability scoping, scoped Novu
    workflows) tracked in Enterprise v1 post-MVP roadmap — defer list from PR #655 closeout #744 D3.

14. Security headers

next.config.mjs extended:

  • Strict-Transport-Security
    max-age=63072000; includeSubDomains; preload (2y).
  • Content-Security-Policy-Report-Only — allow-list covers
    Stream / Razorpay / Sentry / Supabase / Resend / Upstash;
    media-src includes blob: + *.getstream.io for call playback;
    frame-src allows Razorpay checkout iframe. ENABLE_CSP_ENFORCE=true
    flips to enforcing variant.
  • Existing 5 (X-Frame-Options, X-Content-Type-Options,
    X-DNS-Prefetch-Control, Referrer-Policy, Permissions-Policy)
    preserved.
  • /api/csp-report sink (unauthenticated, spamLimiter-throttled)
    emits structured event: "csp_violation" logs.
  • Runbook for the report-only → enforce cutover at
    docs/enterprise/23-runbooks.md.
  • Docs: docs/enterprise/32-security-headers.md.

15. BILLING_ADMIN dashboard surface

  • New /integrations/ route group:
    webhooks + scim + data-exports pages.
    Gated by useRequireFinanceSurface (OWNER + MAINTAINER +
    BILLING_ADMIN + MANAGER).
  • FinanceLeadViewCard on /home — renders for BILLING_ADMIN
    ahead 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).
  • Sidebar fix: replaced rank-only isAtLeast("MANAGER") checks with
    the explicit canSeeOperatorSurface / canSeeFinanceSurface
    predicates. 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 organizationId columns on every leaf table, but the
legacy routes never got the filter. Fixed:

  • /api/slots/appointmentsgetAppointments now takes an
    orgScopeFilter arg; consultant + consultee appointment tabs no
    longer cross-tenant.
  • /api/dashboard/consultee/[id]/paymentsPayment.organizationId
    filter; invoice subquery flows the same filter through the payment
    join.
  • /api/trialsTrialSession.organizationId filter.
  • /api/waitlistWaitlist.organizationId filter via
    getUserWaitlistEntries orgFilter arg.
  • /api/referrals — DELIBERATELY personal-only (referral codes
    follow the user across orgs); doc-only record of intent.
  • components/chat/ChatSidebar.tsx — Stream queryChannels now
    scopes on custom.organization_id. Both initial fetch + paginated
    load-more. scope added to useCallback deps so the inbox
    refetches 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):

  1. pr655_enterprise_lockdown_schema — 6 new tables, 5 new
    enums, 3 enum extensions (MemberRole += BILLING_ADMIN,
    MemberStatus += ERASED, OrgAuditCategory += WEBHOOK),
    User += erasedAt + pseudonymousId, Membership += externalScimId,
    Organization += streamRecordingRetentionDays.
  2. pr655_schema_finalization_pre_mvp
    ErasureRequest.processedByAdminId FK,
    WebhookEndpoint.secretRotatedAt + previousSecretHash,
    ScimToken.expiresAt + partial index.
  3. pr655_maintenance_per_tenant_tier1
    MaintenanceWindow.organizationId FK + indexes.

Test coverage

  • 957/957 Jest tests pass (up from 876/883 with 7 failing at
    PR start). Net +74 new tests:
    • SSO domain-check misconfigured-cert (3 cases)
    • PO balance enforcement (6 cases)
    • org-error humanization (13 cases including snapshot)
    • BILLING_ADMIN gate disjunction (9 cases)
    • outbound webhooks signing + dispatch + worker (19 cases)
    • SCIM resource + auth (15 cases)
    • stale-invitation cleanup (4 cases) + fixed a real
      over-counter bug
      in the existing cron
  • npx tsc --noEmit clean.
  • npm run lint clean.
  • Live exercise against production schema during smoke run covered
    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

Audit item Closed by
7 failing Jest tests Batch 1 fixes
SSO.1 cert validation crash lib/sso/provider-schemas.ts + domain-check pre-auth guard
PO.2/PO.4 balance enforcement Already enforced; regression tests + docs added
UI.M.4 error code leakage 6 new entries in humanizeOrgError table
CE Prisma 7 standalone init dotenv/config across 5 standalone jobs
WEBHOOK outbound (was ❌) Full subsystem (subsystem 8)
ORG_ADMIN notification surface (was ❌) Outbound webhooks + SCIM webhook fan-out
Enterprise security headers (was ❌) Subsystem 14
DPDP §12 erasure (was ⚠️ Phase 2) Subsystem 10
Stream /calls endpoint (was ⚠️) Subsystem 12
Stale-invite cleanup (was ⚠️) Concurrency-guard fix + 4-case regression
Global audit retention (was ⚠️) prune-audit-logs.ts daily cron
Org-scope filter coverage (was ⚠️ partial) 6 leaks closed, subsystem 16
Billing-admin role split (was ⚠️) BILLING_ADMIN role + 12 route downgrades + dashboard
SCIM 2.0 (was ❌) Subsystem 9
Programs V2 schema (was ⚠️) Enum + placeholder FK slots ready (see #744 F)

What's deliberately deferred

See #744 — Enterprise v1 post-MVP roadmap.
18 buckets across 7 sections. Highlights:

  • Programs V2 (PROJECT / RETAINER / RESELLER) — schema reserved,
    not shipping. New issue if a customer asks.
  • Round-3 booking-flow tests (design: personal vs organization scope across every feature — direction A (filter) vs direction B (split) #674) — fixture skeleton in repo,
    full impl needs an earnings-only helper extraction.
  • RLS enablement — design memo at
    docs/compliance/10-rls-design-memo.md. App-layer auth is
    sufficient for v1; trigger is Realtime/Storage in client OR SOC 2
    audit OR anon-key leak.
  • Webhook secret rotation grace verifier + SCIM token expiry
    enforcement
    — schema columns ship, enforcement reads are
    follow-ups.
  • DPDP §12 erasure cron + SCIM PATCH full RFC coverage +
    CSV export companion + HRIS UI + HOST org settlement
    — all schema-ready, implementation deferred.
  • Maintenance Tier 2 — per-org admin API + capability scoping.

Commits in this PR

cba8f2c6 fix(enterprise): green tsc — 8 type errors caught by CI
1c0878ff fix(enterprise): close 6 org-scope leaks + schema finalization + maintenance Tier 1
59e5e875 feat(enterprise): polish — headers, retention, exports, BILLING_ADMIN dashboard
e40914fa feat(enterprise): DPDP §12 automated right-to-erasure
27279c96 feat(enterprise): SCIM 2.0 provisioning
4b4ce31d feat(enterprise): outbound webhooks subsystem
f7133eaa fix(enterprise): green CI + critical bugs + BILLING_ADMIN role wiring

Plus everything pre-fb68386c that already shipped the org lifecycle,
RBAC base, SSO scaffolding, billing/payouts base, and audit subsystem.


Test plan

  • npm test — 957/957 green
  • npx tsc --noEmit — clean
  • npm run lint — clean
  • Live exercise of SCIM lifecycle against production schema
  • Live exercise of data export pipeline end-to-end (POST → worker
    tick → bundle download from Supabase Storage)
  • Live exercise of DPDP §12 erasure (verified
    Membership.status = ERASED, scrubbed User.email, audit row
    written, member.removed webhook queued)
  • Live exercise of all four cleanup crons against the live
    CRON_SECRET (audit-prune, Stream retention, webhook dispatch,
    data exports)
  • CSP + HSTS headers verified via curl -I /
  • Org-scope filter verified at API layer; full UI walkthrough
    (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

teetangh and others added 12 commits April 10, 2026 05:46
…IDER feature-flagged)

Adds the enterprise/organization profile layer on top of BetterAuth's
existing Organization/Member/Invitation tables. The split:

  - BetterAuth tables (identity): name, slug, role-as-string, invitations
  - OrganizationProfile (this PR): kind, billing mode, branding, rates, seats
  - OrganizationMemberProfile (this PR): typed enum role + status + profile FKs

PROVIDER (consultant agency) support is in the schema but gated by the
ENABLE_PROVIDER_ORGS env flag at the API/UI layer (lib/feature-flags.ts
in the next commit). The PROVIDER-specific tables are marked with
FEATURE-FLAGGED comments — they exist on disk but are inert until the
flag is flipped. See Issue #646.

New models:

  - OrganizationProfile (1:1 with Organization) — kind, status,
    billingMode, billing email, branding, revenue rates (gated),
    seat budget, payment terms, policies
  - OrganizationMemberProfile (1:1 with Member) — typed role enum,
    status, optional consultantProfileId/consulteeProfileId FKs
  - OrganizationSSOSettings — allowedEmailDomains, enforceSSO,
    defaultRoleForAutoJoin
  - OrganizationInvoice — used by both INVOICED_MONTHLY (BUYER) and
    PROVIDER (gated). Includes GST compliance fields, billing cycle
    metadata, autoGenerated flag for cron-vs-manual.
  - OrganizationPlan — org-curated catalog plan templates
  - OrgCreditPool / OrgCreditPurchase / OrgCreditLedger — SEAT_PACK
    billing pool + immutable credit ledger
  - OrganizationPayoutAccount / OrganizationPayout / OrganizationEarnings
    — FEATURE-FLAGGED PROVIDER-only tables, schema-only

New enums:

  - OrganizationKind (BUYER | PROVIDER | HYBRID)
  - OrganizationStatus (PENDING_VERIFICATION | ACTIVE | SUSPENDED | DEACTIVATED)
  - OrganizationBillingMode (TAG_ONLY | SEAT_PACK | INVOICED_MONTHLY)
  - OrgSizeBucket (4 size buckets)
  - OrgMemberRole (6 values; ORG_CONSULTANT and ORG_SUPPORT FEATURE-FLAGGED)
  - OrgMemberStatus
  - OrgPayoutAccountStatus
  - OrgInvoiceStatus

Additive links to existing models (additive only, no breaking changes):

  - Member.organizationMemberProfile (back-relation)
  - ConsultantProfile.organizationMemberProfile + isIndependent flag
  - ConsulteeProfile.organizationMemberProfile + isIndependent flag
  - ConsultationPlan/SubscriptionPlan/WebinarPlan/ClassPlan.organizationProfileId
  - Payment.organizationProfileId + Payment.billableToOrgInvoiceId
  - Payment back-relations: organizationEarnings (PROVIDER, gated),
    organizationInvoiceSettled, orgCreditPurchase

Verified:
  - npx prisma format passes
  - npx prisma validate passes
  - npx prisma generate produces types for all new models

Schema migration is purely additive — no existing columns dropped.
Supabase migration deferred to Phase O at the end of PR2.

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

Phase B of PR2. Wires up the BetterAuth Organization + SSO plugins on
top of the schema added in Phase A.

Why upgrade better-auth from 1.4.18 → 1.6.2:

  - @better-auth/sso requires per-version peer dep matching:
    @better-auth/sso@1.4.18 has packaging issues (imports `better-call`
    at runtime without declaring it as a direct dep, so Node ESM
    resolution can't find it when nested under better-auth/node_modules).
  - @better-auth/sso@1.6.2 works cleanly but requires better-auth@^1.6.2
    and @better-auth/core@^1.6.2 (peer dep mismatch with 1.4.18).
  - The 1.4 → 1.6 upgrade is API-compatible for our customSession +
    organization usage. Verified by re-running `npx @better-auth/cli generate`
    after the upgrade — schema generation succeeds, plugin loading
    succeeds, no breaking changes to the customSession callback shape.

Dependencies added to package.json:
  - better-auth: ^1.4.18 → ^1.6.2
  - @better-auth/sso: ^1.6.2 (new)
  - @opentelemetry/api: ^1.9.0 (new — required by @better-auth/core@1.6.2
    runtime instrumentation; declared as peer dep, not pulled in by `npm ci`)

Note: install requires --legacy-peer-deps because better-call@1.3.5
declares an OPTIONAL peer dep on zod@^4 that npm 11 enforces strictly.
The project uses zod@^3.25 (constrained by eslint-plugin-react-hooks
and zod-validation-error). The optional peer is benign at runtime —
better-call works fine with zod 3 — but the install fails without
--legacy-peer-deps. The lockfile is updated accordingly.

Schema changes (auto-applied by `npx @better-auth/cli generate -y`):

  - New `SsoProvider` model — BetterAuth's auto-generated table for
    per-org SAML/OIDC providers. Has `organizationId` so providers can
    be linked to orgs (this is exactly what the SSO admin UI in Phase L
    will register against).
  - `User.ssoproviders SsoProvider[]` back-relation
  - `User.invitations Invitation[]` back-relation (BetterAuth's
    organization plugin now expects this; Prisma format auto-added a
    matching `user`/`userId` field on the Invitation model — additive,
    nullable, no migration breakage)
  - `Session.activeOrganizationId String?` — BetterAuth tracks the
    user's currently-active org context in the session
  - New indexes on `Member.organizationId`, `Member.userId`,
    `Invitation.organizationId`, `Invitation.email`, `Verification.identifier`
  - Cosmetic: `@db.Timestamptz()` → `@db.Timestamptz` (parens optional
    in Prisma 7 — purely formatting, no migration impact)

lib/auth.ts changes:

  - Import `organization` from "better-auth/plugins"
  - Import `sso` from "@better-auth/sso"
  - Enable both plugins:
      organization({ organizationLimit: 5, creatorRole: "ORG_OWNER" })
      sso()
  - Extend customSession callback to load `organizationMemberships`
    via prisma.organizationMemberProfile.findMany — joined to the
    typed sibling profile + parent OrganizationProfile/Organization,
    filtered to status=ACTIVE on both layers. The session now exposes
    user.organizationMemberships[] for the OrgSwitcher (Phase G) and
    any org-aware route to read without an extra DB roundtrip.

Verified:
  - npx prisma validate passes
  - npx prisma generate produces types for new models
  - npx @better-auth/cli generate succeeds (loads auth.ts + writes schema)
  - npm ci --legacy-peer-deps succeeds with the new lockfile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase C + Phase D of PR2 enterprise foundation.

- lib/feature-flags.ts: ENABLE_PROVIDER_ORGS env flag gating PROVIDER
  org code paths (org creation, member role assignment, earnings split,
  payouts/consultants routes). Defaults to false pre-MVP. See Issue #646
  for the PROVIDER follow-up checklist.

- lib/auth-helpers.ts: add requireOrgAccess(orgId, minRole?) and
  requireOrgOwner(orgId) helpers plus orgRoleSatisfies comparator
  backed by a numeric ORG_ROLE_RANK table. Platform ADMINs bypass org
  membership with a synthesized OWNER stub for operability; STAFF do
  NOT bypass — they are platform-side, not org-side.

- middleware.ts: add /api/organizations/ to AUTHENTICATED_API_PREFIXES
  so routes require a session cookie before reaching the handler
  (handler-level requireOrgAccess still runs for the typed role check).
Phase E of PR2 enterprise foundation. Adds the full app/api/organizations
surface that the dashboard pages (Phase F) and the public invite flow
(Phase H) call into. 25 route files in total.

Core CRUD
- POST/GET /api/organizations: list and create with PROVIDER feature gate
- GET/PATCH/DELETE /api/organizations/[orgId]: resource ops; soft delete
  via status=DEACTIVATED; PROVIDER rate-sum validation gated
- GET/PATCH /api/organizations/[orgId]/settings: pass-through alias

Members + invitations
- GET/POST /api/organizations/[orgId]/members
- PATCH/DELETE /api/organizations/[orgId]/members/[memberId] with last-owner
  guard and seatsUsed bookkeeping for ORG_LEARNER
- GET/POST /api/organizations/[orgId]/invitations + DELETE single
- POST /api/organizations/invitations/accept (token + email match required)
- ORG_CONSULTANT/ORG_SUPPORT roles return 501 unless ENABLE_PROVIDER_ORGS

Plans (org-owned catalog)
- GET/POST /api/organizations/[orgId]/plans
- GET/PATCH/DELETE /api/organizations/[orgId]/plans/[planId]
- assignedConsultantIds[] non-empty assignment is PROVIDER-only

Billing (BUYER)
- GET /api/organizations/[orgId]/billing: stats summary
- GET/POST /api/organizations/[orgId]/billing/invoices
- POST /api/organizations/[orgId]/billing/generate-invoice (manual rollup
  for INVOICED_MONTHLY; full Inngest cron lands in Phase K)
- POST /api/organizations/[orgId]/billing/invoices/[invoiceId]/pay
  (returns pendingPhaseK stub until gateway plumbing lands)

Credits (SEAT_PACK)
- GET /api/organizations/[orgId]/credits (pool + ledger)
- GET /api/organizations/[orgId]/credits/purchases
- POST /api/organizations/[orgId]/credits/purchase
  (returns pendingPhaseJ stub until gateway plumbing lands)

SSO
- GET/PATCH /api/organizations/[orgId]/sso (settings + linked providers)
- GET/POST /api/organizations/[orgId]/sso/providers
- DELETE /api/organizations/[orgId]/sso/providers/[providerId]

PROVIDER stubs (501 unless ENABLE_PROVIDER_ORGS)
- /api/organizations/[orgId]/payouts (GET/POST)
- /api/organizations/[orgId]/payout-account (GET/PUT/DELETE)
- /api/organizations/[orgId]/consultants (GET)

Misc
- /api/organizations/[orgId]/learners (BUYER + HYBRID)
- /api/organizations/[orgId]/analytics (stat-card aggregates)

All routes use the new requireOrgAccess / requireOrgOwner helpers for the
typed-role auth check; platform admins bypass with the synthesized OWNER
stub from Phase D. PROVIDER-gated routes return 501 with the flag name in
the body so the dashboard can render an "upgrade required" CTA later.

Also fixes the planner Event types to include the new
ClassPlan/WebinarPlan.organizationProfileId nullable field added in Phase A.
Phase F of PR2 enterprise foundation. Adds the full
app/dashboard/organization/* surface that the org CRUD API routes from
Phase E feed into. 15 page files in total (1 landing + 1 layout + 1
redirect + 12 inner pages).

Layout
- /dashboard/organization/[orgId]/layout.tsx — useSession() gate, fetches
  the org via /api/organizations/[orgId], renders CollapsibleSidebar with
  conditional nav items based on org.kind (consultants/payouts hide for
  BUYER) and billingMode (credits hides unless SEAT_PACK)

Landing
- /dashboard/organization/page.tsx — lists user's orgs, "New organization"
  form (BUYER kind only — PROVIDER hidden by feature flag at API layer)

Pages
- /[orgId]/page.tsx — server redirect to /home
- /[orgId]/home/page.tsx — stat-card overview (members, learners, plans,
  this-month revenue) plus billing-mode-aware secondary cards
- /[orgId]/members/page.tsx — table + add-member dialog (existing users)
- /[orgId]/invitations/page.tsx — pending invitations table + invite
  dialog + copy-link button (no email user required)
- /[orgId]/learners/page.tsx — ORG_LEARNER list
- /[orgId]/consultants/page.tsx — gated; renders feature-locked card when
  /api/organizations/[orgId]/consultants returns 501
- /[orgId]/plans/page.tsx — org-owned catalog plans CRUD with create
  dialog
- /[orgId]/billing/page.tsx — billing summary stat cards + invoices
  table; "Generate invoice" button for INVOICED_MONTHLY orgs; "Pay"
  buttons stub-tolerate the pendingPhaseK marker
- /[orgId]/credits/page.tsx — SEAT_PACK credit pool + ledger; "Buy
  credits" dialog; renders "wrong mode" panel for non-SEAT_PACK orgs
- /[orgId]/payouts/page.tsx — gated PROVIDER feature; renders
  feature-locked card when API returns 501
- /[orgId]/analytics/page.tsx — six-card stat grid
- /[orgId]/settings/page.tsx — profile + billing email + payment terms +
  seat budget form; link to SSO settings
- /[orgId]/settings/sso/page.tsx — domain policy + enforce-SSO toggle +
  default-role selector + provider list with add/delete dialog (SAML
  metadata XML upload field)

All pages use the existing dashboard primitives (DashboardHeader,
DashboardContent, DashboardGrid, StatCard, Card, Table, Dialog, Switch,
Select) so visual conventions match the consultant/admin/staff
dashboards.

Type-check clean across the whole project.
…in/staff navbars

Phase G of PR2 enterprise foundation. The switcher is the integration
point that lets a user with both a personal dashboard and one or more
org affiliations jump between them without leaving the platform.

- components/dashboard/OrganizationSwitcher.tsx — DropdownMenu reading
  session.user.organizationMemberships from the customSession callback.
  Self-hides when memberships.length === 0, so B2C users with no org
  affiliation see a navbar that is byte-for-byte identical to today.
  Each row shows the org name + kind badge + role badge and links into
  /dashboard/organization/[orgId]/home. The bottom item routes to the
  landing page for org creation/management.

- components/dashboard/DashboardNavbar.tsx — drop the switcher next to
  the NotificationInbox. This is the navbar used by both consultant and
  consultee dashboards, so the integration is one edit covering both.

- app/dashboard/admin/layout.tsx and
  app/dashboard/staff/[staffId]/layout.tsx — drop the switcher into the
  sticky top header next to NotificationInbox. The CollapsibleSidebar
  layout doesn't have a DashboardNavbar, so the integration is layout-
  level for these two roles.

The personal consultant + consultee dashboards (~22 inner pages
combined) get zero structural changes — the dashboards keep all their
queries, Stream integration, auth gates, and routing. The only delta is
an extra dropdown in the navbar that is invisible for users with no org
memberships.
Phase H of PR2 enterprise foundation.

- app/organizations/invite/[token]/page.tsx — public route (outside the
  /dashboard tree, so middleware does not gate it). Branches on session:
    * Unauthenticated → renders sign in / sign up CTAs that preserve the
      token via callbackUrl, so a brand-new user signs up and lands back
      here with a session cookie ready to accept.
    * Authenticated → POSTs the token to
      /api/organizations/invitations/accept (built in Phase E) and routes
      the user into /dashboard/organization/[orgId]/home on success.
  Handles the alreadyMember case (idempotent re-acceptance), expired
  tokens, and email-mismatch errors with friendly inline messaging.
Phase I of PR2 enterprise foundation. Extends the checkout schema and
handler to accept an optional organizationId. When present:

1. schemas/checkout.ts — adds `organizationId: z.string().optional()` to
   `checkoutSchema` and the `createCheckoutData` utility

2. lib/payments/operations/checkout.ts — at the top of `handleCheckout`,
   resolves the OrganizationProfile by `organizationId` and reads
   `billingMode`. For TAG_ONLY (the default BUYER mode), the only
   change is setting `organizationProfileId` on the Payment row so the
   org billing dashboard can query tagged payments.

   SEAT_PACK → credit deduction will branch here in Phase J.
   INVOICED_MONTHLY → invoice rollup will branch here in Phase K.
   PROVIDER 3-way earnings split → gated by ENABLE_PROVIDER_ORGS (no
   changes to earnings-service.ts in this commit; BUYER split is
   unchanged).

The `orgBillingMode` variable is captured but unused until Phases J/K;
eslint-disable-next-line suppresses the no-unused-vars warning.
…teway

Phase J+K (partial) of PR2 enterprise foundation. Wires the two non-gateway
org billing modes into the checkout transaction.

New module: lib/payments/operations/org-credits.ts
- deductCredits(tx, orgProfileId, amountPaise, paymentId?)
  — decrements OrgCreditPool.balance inside the caller's Prisma TX,
    writes an immutable OrgCreditLedger row, throws if insufficient.
- creditRefund(tx, orgProfileId, amountPaise, paymentId?)
  — reverses a deduction (used by Phase M refund handler).
- purchaseCredits(tx, orgProfileId, creditsPaise, paymentId?)
  — grants credits after a gateway webhook confirms a credit-pack buy.

Checkout handler changes (lib/payments/operations/checkout.ts):
- Detects `orgBillingMode === "SEAT_PACK"` → sets
  `isOrgCreditPayment = true`, generates synthetic payment ID
  (org_credit_*), skips PaymentIntent, marks Payment as SUCCEEDED
  immediately, sets paymentMethod = "ORG_CREDIT".
- Inside the Serializable transaction: calls `deductCredits()` after
  the Payment row is created — the pool balance check + decrement is
  atomic with the booking, so concurrent checkouts cannot overdraw.

- Detects `orgBillingMode === "INVOICED_MONTHLY"` → sets
  `isOrgInvoicedPayment = true`, generates synthetic payment ID
  (org_invoiced_*), skips PaymentIntent, marks Payment as SUCCEEDED
  immediately, sets paymentMethod = "ORG_INVOICED".
  billableToOrgInvoiceId stays null until the monthly Inngest cron or
  the manual generate-invoice endpoint (Phase E) rolls it up.

Both modes:
- skipPayment = true → slots are confirmed immediately (no webhook)
- isMockPayment flag set so the post-processing branch (earnings,
  referrals, waitlist) runs inline instead of waiting for a webhook
- organizationProfileId is set on the Payment row for billing queries

TAG_ONLY (Phase I) is unchanged — normal gateway flow with org tag.
Phase L of PR2 enterprise foundation. Instead of querying Prisma from
edge middleware (not edge-compatible), the SSO domain routing is done
via a public API route that the signin page calls client-side.

GET /api/auth/sso/domain-check?email=alice@acme.com

1. Extracts the domain from the email
2. Queries OrganizationSSOSettings for enforceSSO=true + domain match
3. Looks up the linked SsoProvider
4. Returns { enforceSSO: true, providerId, ssoSignInUrl } or
   { enforceSSO: false }

The signin page can use this response to redirect the user to the IdP
instead of showing the password form. The ssoSignInUrl points to
BetterAuth's /api/auth/sso/sign-in/[providerId] which handles the
SAML/OIDC flow.

Route is public (falls under the existing /api/auth/ prefix in
middleware's PUBLIC_API_PREFIXES). Added a clarifying comment to the
prefix entry.
Phase M of PR2 enterprise foundation. Extends the two-phase refund
handler in /api/payments/refunds to branch on paymentMethod before
calling the gateway.

- ORG_CREDIT (SEAT_PACK): calls `creditRefund()` from org-credits.ts to
  credit the pool balance back. No gateway call — purely a ledger
  reversal. The OrgCreditLedger row records the positive delta with
  reason "refund".

- ORG_INVOICED (INVOICED_MONTHLY): if the payment is still linked to an
  unpaid invoice (billableToOrgInvoiceId set), unbills it so it drops
  out of the next invoice aggregation. If the invoice was already paid,
  the refund is marked SUCCEEDED and the admin issues a manual credit
  note on the next billing cycle.

- CARD / TAG_ONLY / B2C: standard gateway refund path unchanged.

Also includes organizationProfile in the Phase-1 Prisma select so the
org fields are available in the refund routing logic.
Phase N of PR2. Documents the four locked decisions:
1. Same dashboards + OrgSwitcher (no new layouts)
2. PROVIDER deferred via ENABLE_PROVIDER_ORGS feature flag
3. All three BUYER billing modes (TAG_ONLY, SEAT_PACK, INVOICED_MONTHLY)
4. Full SSO admin UI via domain-check endpoint

The three existing enterprise docs already carry SUPERSEDED banners
from PR1. The canonical design doc (00-canonical-design.md) is left
as a follow-up since the PR itself is the canonical source of truth.
@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 10, 2026

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit de985b4
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a1845bf552b18000842028c
😎 Deploy Preview https://deploy-preview-655--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.

Copy link
Copy Markdown
Contributor

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

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 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.

Comment thread app/api/organizations/[orgId]/billing/generate-invoice/route.ts Outdated
Comment thread app/api/organizations/[orgId]/route.ts Outdated
teetangh and others added 2 commits April 10, 2026 15:30
Adds enterprise organization seeding as Phase 15 in the seed pipeline
and fixes schema issues found during seeding prep.

New: prisma/seedFiles/15a-create-organizations.ts
- Creates orgs across all 3 billing modes (TAG_ONLY, SEAT_PACK,
  INVOICED_MONTHLY) scaled by SEED_MODE (small: 4, medium: 8, large: 15)
- Each org gets: owner + 3-10 members (ORG_ADMIN, ORG_MANAGER,
  ORG_LEARNER), org plans, pending invitations
- SEAT_PACK orgs get a seeded credit pool with purchase + booking ledger
  entries, pool balance reconciled to match the ledger
- INVOICED_MONTHLY orgs get a sample invoice (DRAFT/SENT/PAID)
- ~30% of orgs get SSO settings with an allowed domain
- One memberless "Platform Admin Test Org" for testing admin bypass

Updated: prisma/seedFiles/config.ts
- Added organizations, membersPerOrg, plansPerOrg to VolumeConfig
- Added org count to printConfigSummary output

Updated: prisma/seed.ts
- Imports + calls createOrganizations(users) as Phase 15

Schema fixes (prisma/schema.prisma):
- Removed @unique from OrganizationMemberProfile.consultantProfileId and
  .consulteeProfileId — a consultant/consultee CAN belong to multiple
  orgs, so these must be non-unique FKs
- Changed ConsultantProfile.organizationMemberProfile → plural
  organizationMemberProfiles (1:many back-relation)
- Same for ConsulteeProfile
- Added indexes on consulteeProfileId and consultantProfileId

Bug fixes:
- members/route.ts: reactivate REMOVED members instead of 409 when
  re-adding. The old code rejected re-adds even after soft-remove,
  making it impossible to re-invite someone.
- checkout.ts: added "insufficient credits" to preserved error messages
  so SEAT_PACK pool errors surface as 400 not 500
- payment-error-classification.ts: classify "insufficient credits" as
  AVAILABILITY (400) not generic server error (500)

New: prompts/enterprise-tests/e2e-enterprise-agent-001-org-lifecycle.md
- 13-phase E2E test prompt for Supabase MCP + Chrome DevTools MCP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a polished 5-step organization creation wizard matching the
individual onboarding experience, fixes the invitation token loss bug,
and adds an org image upload endpoint.

Organization Setup Wizard (/dashboard/organization/create)
- 5 steps: Org Info → Billing & Seats → Branding → Invite Team → Review
- Same stepper UI as individual onboarding (green circles + connectors)
- Step 0 creates the org on "Next" (so step 2 has an orgId for uploads)
- Step 1 PATCHes billing mode, payment terms, seat budget
- Step 2 uploads logo/banner via new image API, PATCHes colors
- Step 3 bulk email input with validation, dedup, per-email chips
- Step 4 review with edit links + parallel invitation sending
- Steps 2+3 have "Skip for now" option
- Landing page (/dashboard/organization) simplified: inline form
  replaced with Link to the wizard

Image Upload API (POST /api/organizations/[orgId]/images)
- ORG_ADMIN+ auth via requireOrgAccess
- Accepts FormData: file (JPEG/PNG/WebP, max 5MB) + type (logo|banner)
- Uploads to Supabase bucket "organizations" at {orgId}/{type}-{uuid}
- Updates Organization.logo and/or OrganizationProfile branding fields

Invitation Flow Fix (3 files)
- app/organizations/invite/[token]/page.tsx: stores token in
  localStorage before redirecting to signup (bridges the signup →
  onboarding → dashboard chain where callbackUrl gets lost)
- app/auth/signup/page.tsx: reads callbackUrl from searchParams, passes
  it through to /form/onboarding and SocialLoginButtons
- app/form/onboarding/page.tsx: after onboarding success, checks
  localStorage for pendingOrgInviteToken (primary) and callbackUrl
  query param (fallback) before the role-based dashboard redirect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@teetangh
Copy link
Copy Markdown
Contributor Author

ChatGPT 5.4 codex feedback

I walked the enterprise docs (docs/enterprise/*, docs/roadmap/enterprise/*), the linked issues (#367, #438, #646, plus the older assessment docs), and the PR implementation end-to-end. The overall direction is good and the schema/API/dashboard foundation is substantial, but I don't think the PR is yet accurate to merge as-is under the headline of “enterprise foundation complete”. There are a few correctness gaps that currently undercut the two most important promises in the ADR: org-funded billing and full SSO support.

1. Checkout currently trusts any client-supplied organizationId

lib/payments/operations/checkout.ts:1571-1801 resolves validatedData.organizationId and immediately uses it to switch into SEAT_PACK / INVOICED_MONTHLY behavior. There is no verification that the authenticated user is an active member of that org, is seat-assigned, or is otherwise authorized to spend that org's budget.

That means any logged-in user can hit /api/checkout with another org's ID and:

  • drain that org's credit pool (SEAT_PACK)
  • push bookings onto that org's invoice rollup (INVOICED_MONTHLY)
  • tag their booking against that org for reporting (TAG_ONLY)

This is the most serious issue in the PR because it breaks the trust boundary around enterprise billing.

Proposed fix:

  • Resolve org context through a membership-aware helper, not a raw profile lookup.
  • Require an active OrganizationMemberProfile for the caller before any org billing mode is applied.
  • For BUYER orgs, restrict org-funded checkout to the member types that are actually allowed to consume enterprise-funded bookings (ORG_LEARNER, and maybe ORG_MANAGER/ORG_ADMIN if intentionally allowed).
  • If seat assignment matters, verify seatAssignedAt != null before allowing funded checkout.
  • Fail closed: if the org is missing, deactivated, or the caller is not authorized, reject the request rather than silently downgrading behavior.

2. The SSO plugin and the app's typed org layer are not actually wired together

lib/auth.ts enables sso() with default provisioning behavior, while customSession() and requireOrgAccess() only recognize memberships that have a corresponding OrganizationMemberProfile. BetterAuth SSO provisioning creates a member row; it does not create the typed sibling profile this PR relies on for roles/session/org access.

So the likely runtime result is:

  1. SSO/domain provisioning adds the user to the BetterAuth org tables
  2. The app never creates OrganizationMemberProfile
  3. organizationMemberships in session stays empty
  4. requireOrgAccess() returns 403 “Not a member of this organization”

That means the SSO flow may authenticate a user successfully while still locking them out of the org dashboard and org APIs.

Proposed fix:

  • Either disable BetterAuth's implicit org provisioning for now and keep membership creation fully app-controlled, or
  • add a synchronization hook/job that creates the matching OrganizationMemberProfile whenever SSO/domain provisioning creates a member row.

If you keep plugin provisioning enabled, that sync needs to populate at least:

  • organizationProfileId
  • typed role
  • status
  • consulteeProfileId / consultantProfileId when applicable
  • seatAssignedAt for learner-style roles

Related follow-up: OrganizationSSOSettings.defaultRoleForAutoJoin is currently stored in your app table but I couldn't find it being fed into BetterAuth provisioning, so even the default role policy appears disconnected from the actual sign-in path.

3. The current SSO admin UI stores providers in a shape BetterAuth likely can't use

app/api/organizations/[orgId]/sso/providers/route.ts accepts samlConfig / oidcConfig as arbitrary strings and writes them straight into ssoProvider. The dashboard page only exposes:

  • provider ID
  • domain
  • issuer
  • raw SAML metadata XML textarea

But the installed BetterAuth SSO plugin expects structured provider configuration for OIDC/SAML handling, and its runtime reads oidcConfig / samlConfig as parsed config objects (or JSON-encoded equivalents). Right now provider registration can succeed at the DB layer while leaving sign-in broken at runtime.

Proposed fix:

  • Prefer calling BetterAuth's SSO registration endpoint or helper contract instead of manually inserting rows.
  • If you keep manual writes, normalize provider config into the exact structure the plugin expects before insert.
  • Split the admin UI into explicit SAML vs OIDC flows.
  • For SAML: parse metadata XML into the fields BetterAuth expects instead of storing opaque XML.
  • For OIDC: expose discovery URL / client ID / client secret / auth endpoints as needed and persist structured JSON.

Related issue: app/api/auth/sso/domain-check/route.ts returns ssoSignInUrl: /api/auth/sso/sign-in/${provider.providerId}, but BetterAuth's SSO entrypoint is body-driven (/sign-in/sso with provider/domain/email), so the returned URL contract may also be wrong depending on how the frontend intends to consume it.

4. Member updates do not preserve seat/accounting invariants

app/api/organizations/[orgId]/members/[memberId]/route.ts:88-103 updates only role and status on the member rows. It does not reconcile:

  • seatsUsed
  • seatAssignedAt
  • learner/consultant profile foreign keys

That creates drift for common transitions like:

  • ORG_LEARNER -> ORG_ADMIN
  • ORG_ADMIN -> ORG_LEARNER
  • ACTIVE -> SUSPENDED
  • SUSPENDED -> ACTIVE
  • ORG_CONSULTANT -> ORG_LEARNER

Today create/delete partially maintain seat counts, but PATCH can easily leave analytics and future billing checks incorrect.

Proposed fix:

  • Treat PATCH as a full membership transition handler, not a shallow field update.
  • Compute wasSeatOccupying vs isSeatOccupying inside one transaction.
  • Increment/decrement seatsUsed accordingly.
  • Set/clear seatAssignedAt accordingly.
  • Update consulteeProfileId / consultantProfileId to match the new role.
  • Keep BetterAuth member.role and the typed sibling in sync in the same transaction.

5. seatsTotal is configurable and displayed, but not enforced anywhere meaningful

The org settings route lets admins configure seatsTotal, and the analytics/home pages surface seat utilization, but the learner-add and invitation-accept paths only increment seatsUsed; they never stop the org from going beyond capacity. That makes seat management effectively advisory even though the enterprise docs and UI frame it as a real control.

Proposed fix:

  • Enforce capacity in all learner-admission paths:
    • add existing user (POST /members)
    • reactivate removed learner
    • invitation accept
    • any future SSO/domain auto-join path
  • Do the check transactionally using current seatsUsed and seatsTotal.
  • Decide whether null means unlimited and preserve that explicitly.

Recommended merge posture

My recommendation would be:

  • treat the checkout authorization issue as a blocker
  • treat the SSO wiring/provider-shape issues as blockers if the PR description continues to claim “full SSO admin UI” / working SSO foundation
  • treat the seat/accounting invariants as required follow-up before relying on enterprise analytics/billing correctness

Suggested implementation order

  1. Lock down checkout org authorization first.
  2. Decide whether SSO provisioning is app-owned or BetterAuth-owned, then make the typed sibling model consistent with that decision.
  3. Fix provider registration to match BetterAuth's actual runtime schema/entrypoints.
  4. Harden membership transitions and seat accounting.
  5. Enforce seatsTotal anywhere a learner seat can be consumed.

Once those are addressed, I think the PR will match the enterprise ADR much more closely and the remaining gaps will be legitimate follow-ups rather than correctness holes.

teetangh and others added 5 commits April 10, 2026 16:27
…ssages

BetterAuth's server-side validation returns raw error strings like
"[body.email] Invalid email address; [body.password] Too small:
expected string to have >=1 characters" which were being dumped
directly into the toast notification.

Added friendlyAuthError() to both signin and signup pages that:
- Detects email validation errors → "Please enter a valid email address."
- Detects password length errors → "Password must be at least 8 characters."
- Detects duplicate accounts → "An account with this email already exists."
- Detects invalid credentials → "Invalid email or password."
- Strips [body.field] prefixes from any unrecognized errors as fallback
Adds a dedicated enterprise onboarding path so org administrators can
sign up and create their organization in one flow, without going through
the irrelevant CONSULTEE onboarding (career goals, skills, budget).

Schema
- prisma/schema.prisma: add ORG_ADMIN to UserRole enum

Onboarding schemas (utils/onboarding.ts)
- Add orgAdminFormFields to the form-level discriminated union with org
  fields (orgName, orgBillingEmail, orgBillingMode, etc.)
- Add ORG_ADMIN branch to OnboardingDataSchema (server payload schema)
- Extend OnboardingFormData type to include org fields

Server processing (utils/onboarding-server.ts)
- Add ORG_ADMIN case in upsertProfileByRole (returns empty — no personal
  profile created)
- Add createOrgForOnboarding() helper that atomically creates
  Organization + OrganizationProfile + Member(ORG_OWNER) +
  OrganizationMemberProfile, OrgCreditPool for SEAT_PACK, and fires
  off invitations
- Return orgId in the response so the client redirects to the org dashboard
- ORG_ADMIN is NOT blocked by the STAFF/ADMIN invite-only guard

Onboarding UI (app/form/onboarding/)
- PersonalInfoAndRoleForm.tsx: add "Organization Admin" as 3rd role card
  ("Set up an org for your school or company")
- page.tsx: add ORG_ADMIN step labels ["Personal Info", "Organization
  Setup", "Review & Launch"], step rendering, and post-submit redirect
  to /dashboard/organization/[orgId]/home
- OrgAdminOrgSetupStep.tsx (new): merged org wizard — org name, billing
  email, billing mode, description, industry, size, website, payment
  terms, seats, plus bulk email invite with validation and chips
- OrgAdminReviewStep.tsx (new): summary sections with edit links,
  "Create Account & Organization" button

Dashboard routing (app/dashboard/page.tsx)
- Add ORG_ADMIN case: routes to first org dashboard from session
  memberships, or /dashboard/organization if no orgs yet

OrganizationSwitcher
- Always visible for ORG_ADMIN users (even with 0 orgs) so they can
  access "Create or manage organizations"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- app/api/organizations/[orgId]/route.ts: add billingMode to the PATCH
  schema so the org wizard's review step can persist the billing mode
  selected in step 1
- ReviewStep.tsx: include billingMode in the final confirmation PATCH
- CollapsibleSidebar.tsx: truncate long org names in the sidebar header
  (flex-1 min-w-0 truncate) to prevent layout overflow

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

Three bugs fixed:

1. z.literal(UserRole.ORG_ADMIN) → z.literal("ORG_ADMIN" as const) in
   OnboardingDataSchema and orgAdminFormFields. The Prisma enum value was
   undefined in the stale .next server webpack cache, making the discriminated
   union unable to match "ORG_ADMIN" role — using a hardcoded string avoids
   any module-load-time enum resolution issues.

2. Both ORG_ADMIN switch cases in transformOnboardingFormToServerData and
   transformFrontendToServerData now use dual case labels (UserRole.ORG_ADMIN
   + "ORG_ADMIN" as any) and pass role: formData.role to avoid Prisma enum
   dependency.

3. transformOnboardingFormToServerData ORG_ADMIN case now includes all org
   fields (orgName, orgBillingEmail, orgBillingMode, etc.) so they reach the
   server-side createOrgForOnboarding function.

DB migration also applied: ALTER TYPE "UserRole" ADD VALUE 'ORG_ADMIN'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…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>
@teetangh
Copy link
Copy Markdown
Contributor Author

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 organizationId trust issue is now closed, seat/profile reconciliation in member PATCH is much better than before, and the ORG_ADMIN onboarding flow is a meaningful UX improvement.

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 atomic

The PR description says the onboarding server action creates the user + org + member + invitations atomically. The code does not currently do that.

utils/onboarding-server.ts:582-639 first commits the user update/onboarding completion in one transaction, then calls createOrgForOnboarding() afterward in a separate transaction. Inside createOrgForOnboarding(), invitations are then created outside that transaction as fire-and-forget work (utils/onboarding-server.ts:768-787).

So if org creation fails after the user update succeeds, the action returns an error but the user has already been converted into ORG_ADMIN and had their profile IDs nulled out. Similarly, invitation failures don't roll back anything even though the PR text says this flow is atomic.

Proposed fix:

  • Either move ORG_ADMIN org creation into the same transaction as the user update, or
  • soften the PR description and UX language to explicitly say this is a staged flow, not an atomic one.
  • If invitations are meant to be part of the contract, they need to be transactional or at least explicitly best-effort in the product/PR language.

2. The ORG_ADMIN onboarding path bypasses the validation/gating already implemented in the org APIs

utils/onboarding.ts:182-198 accepts org-backed fields like orgBillingMode, orgSizeBucket, and orgInviteRole as plain strings. createOrgForOnboarding() then blindly casts those strings into enum-backed fields / invitation roles (utils/onboarding-server.ts:711-734, :768-784).

That means this path does not inherit the stronger validation rules from /api/organizations and /api/organizations/[orgId]/invitations:

  • malformed enum-like values become 500s instead of clean validation errors
  • provider-gated roles can be smuggled in through orgInviteRole even though the regular invitations API blocks them behind ENABLE_PROVIDER_ORGS
  • this creates a second, looser org-creation contract that can drift from the main API surface

Proposed fix:

  • Reuse the same enums/schemas as the org APIs for onboarding payload validation.
  • Validate orgInviteRole as OrgMemberRole, then apply the same provider-role gate used in /api/organizations/[orgId]/invitations.
  • Prefer calling a shared service/helper for org creation rather than maintaining separate API-vs-onboarding implementations.

3. Seat-limit enforcement is still incomplete in the update/remove paths

The add-member and invite-accept paths now check seatsTotal, which is good, but the invariant is still incomplete:

  • app/api/organizations/[orgId]/members/[memberId]/route.ts:88-152 can promote or reactivate a member into an active ORG_LEARNER seat without checking capacity first. So once an org is full, an admin can still bypass the limit by PATCHing an existing member into ORG_LEARNER.
  • app/api/organizations/[orgId]/members/[memberId]/route.ts:195-208 decrements seatsUsed on DELETE for any learner row, regardless of whether that row was actually occupying a seat at the time. Repeated DELETEs, or deleting an already suspended/removed learner, can push seatsUsed below the real active-seat count.

Proposed fix:

  • In PATCH, if !wasSeatOccupying && isSeatOccupying, re-check capacity before incrementing seatsUsed.
  • In DELETE, only decrement when the target was actually seat-occupying (role === ORG_LEARNER && status === ACTIVE).
  • Consider centralizing seat transitions into one helper so POST/PATCH/DELETE/accept all share the same occupancy logic.

4. The new seat-limit checks are still raceable under concurrency

Both:

  • app/api/organizations/[orgId]/members/route.ts:121-131
  • app/api/organizations/invitations/accept/route.ts:111-121

check seatsUsed >= seatsTotal before entering the transaction that increments seatsUsed. Two concurrent requests can both pass that pre-check and both increment afterward, oversubscribing the org anyway.

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:

  • Re-read / enforce seat capacity inside the same transaction that writes the member + increments seatsUsed.
  • If you want stronger guarantees, do the member admit + seat increment under a serializable transaction or an atomic conditional update pattern.

5. billingMode is now mutable, but there's still no guard against changing it after real usage

app/api/organizations/[orgId]/route.ts:20-54 + :145-155 now allows billingMode to be patched freely. But the schema comment still says it is selected at org creation and “immutable after first payment” (prisma/schema.prisma:466-467).

Right now there's no guard for:

  • existing tagged payments
  • existing SEAT_PACK credit pool / balance history
  • unbilled INVOICED_MONTHLY payments
  • previously issued invoices

So an org can switch modes after live usage and leave the billing state semantically inconsistent. Example: moving from SEAT_PACK to TAG_ONLY with a non-zero credit pool, or from INVOICED_MONTHLY to TAG_ONLY with outstanding unbilled payments.

Proposed fix:

  • Either make billingMode creation-only again, or
  • enforce explicit transition rules (e.g. only before first payment / only when no outstanding credits / only when no unbilled invoiceable payments).

6. SSO is still not production-ready, so the PR description is still ahead of the implementation there

I know this is now acknowledged with TODO comments, but it is still materially true after the re-review:

  • app/api/organizations/[orgId]/sso/providers/route.ts:12-21 explicitly documents that provider config is being stored in a shape BetterAuth may not actually use at runtime.
  • app/api/auth/sso/domain-check/route.ts:59-69 still returns a probably-wrong sign-in URL contract.
  • the app still relies on OrganizationMemberProfile for org access/session enrichment, while BetterAuth SSO provisioning creates member rows, not the typed sibling profile.

So I would still avoid describing this as “full SSO admin UI” in the sense of working enterprise SSO. The admin page exists, but the end-to-end sign-in contract still looks incomplete.

Proposed fix:

  • either narrow the PR wording to “SSO configuration scaffolding/admin UI”
  • or finish the provider-shape + membership-sync integration before merge if true working SSO is part of the acceptance bar

Recommended merge posture now

At this point I think the PR is much closer, but I'd still want the following resolved or explicitly de-scoped before merge:

  • ORG_ADMIN onboarding atomicity / validation mismatch
  • seat-capacity invariants in PATCH/DELETE/concurrency paths
  • billingMode transition guardrails
  • SSO wording reduced unless the runtime integration is completed

If helpful, I can do one more pass after those are addressed, but this second round is much tighter than the first one and mostly about closing the remaining state-machine gaps.

…lidation

Addresses 5 legit findings from round 2 review (ChatGPT 5.4 codex).

1. FIX — Onboarding bypasses API validation (onboarding-server.ts)
   orgBillingMode and orgInviteRole were accepted as plain strings
   without the same enum/PROVIDER-gate validation the API routes enforce.
   Added billingMode whitelist and PROVIDER role gate before org creation.

2. FIX — Seat capacity not checked in PATCH (members/[memberId])
   Promoting a member to ORG_LEARNER via PATCH bypassed seatsTotal.
   Added pre-check + transactional re-check when gaining a seat.

3. FIX — DELETE decremented seats for non-active learners
   Repeated DELETEs or deleting suspended/removed learners could push
   seatsUsed below 0. Now only decrements when target was actually
   occupying a seat (role=ORG_LEARNER AND status=ACTIVE).

4. FIX — Seat checks raceable (members/route.ts + invitations/accept)
   The pre-transaction seatsUsed check could be passed by two concurrent
   requests. Added transactional re-read of seatsUsed/seatsTotal inside
   the same TX that writes the member + increments the counter.

5. FIX — billingMode mutable after payments ([orgId]/route.ts)
   billingMode could be PATCHed freely even after live payments existed,
   creating inconsistent billing state. Added a guard: if any payments
   are tagged to this org, reject the billing mode change with a clear
   error message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@teetangh
Copy link
Copy Markdown
Contributor Author

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 handleCheckout() looks closed, and the member PATCH/DELETE seat bookkeeping is much healthier than before.

I do still see a few correctness gaps that I think are worth fixing before calling the enterprise foundation complete:

  1. utils/onboarding-server.ts:582-655 + :710-805 + utils/onboarding.ts:182-198
    ORG_ADMIN onboarding is still not atomic, and some org validation still happens after the user/profile transaction has already committed. processOnboardingData() updates the user first, then only afterward checks orgBillingMode / gated invite roles, and then calls createOrgForOnboarding() in a separate transaction. That means a bad or drifted ORG_ADMIN payload can partially succeed: the user can be updated to ORG_ADMIN with onboardingCompleted: true, then the org creation path can return an error or throw, leaving the account mutated without the promised org. The schema is also still looser than the org APIs (orgBillingMode, orgSizeBucket, orgInviteRole are plain strings in onboarding while the real org routes use enums), so this path can still drift from /api/organizations and /api/organizations/[orgId]/invitations.

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.

  1. app/api/organizations/[orgId]/members/route.ts:142-214, app/api/organizations/[orgId]/members/[memberId]/route.ts:130-175, app/api/organizations/invitations/accept/route.ts:149-199
    The seat-cap fix still is not actually race-safe under concurrency. All three paths now re-read seatsUsed inside a transaction, but they still do a normal read followed by a later increment, and these transactions do not run at Serializable isolation and do not lock the org row. Two concurrent requests can still both read the same seatsUsed, both pass, and both increment, so an org can still oversubscribe seats through add/reactivate/promote/invite-accept races.

Proposed fix: make seat acquisition a single atomic state transition, e.g. updateMany / raw SQL that increments only when seatsTotal IS NULL OR seatsUsed < seatsTotal, and fail when the affected row count is 0. Alternatively, run these transactions at Serializable with retry handling, but the conditional update pattern is usually simpler here. Also, the current “seat limit reached” throws in these transactions still bubble into generic 500 responses, so the business error should be converted back into 403/409 consistently.

  1. app/api/organizations/[orgId]/sso/providers/route.ts:12-21 + :92-111
    SSO provider registration is still documented in-code as incomplete scaffolding rather than a working BetterAuth integration. The route is still persisting raw samlConfig / oidcConfig strings directly into ssoProvider, and the TODO explicitly says the plugin may expect a different runtime shape. So the admin UI can still report “provider created” while the actual sign-in flow remains unverified or non-functional.

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”.

  1. lib/auth.ts:227-253 + app/api/organizations/[orgId]/sso/providers/route.ts:18-21 + app/api/auth/sso/domain-check/route.ts:59-69
    The end-to-end SSO contract still is not closed. sso() is enabled with no sync hook creating OrganizationMemberProfile, the provider route still calls that out explicitly, and the public domain-check endpoint still returns a guessed sign-in URL with a TODO saying the BetterAuth route shape may be wrong. So even if provider rows exist, the current implementation still looks like: domain-check may hand back the wrong URL, and successful SSO login may still not produce the typed org membership that customSession() / requireOrgAccess() rely on.

Proposed fix: verify the real BetterAuth SSO sign-in endpoint and generate that exact URL/contract from domain-check; then add a post-provision sync step that creates or repairs the OrganizationMemberProfile sibling row whenever SSO/domain auto-join creates a BetterAuth member. Without that, “SSO enabled” is still ahead of reality.

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.

Addresses round 3 feedback. Two structural improvements:

1. ORG_ADMIN onboarding is now truly atomic (onboarding-server.ts)
   - Moved org creation (Organization + OrganizationProfile + Member +
     OrganizationMemberProfile + OrgCreditPool) into the SAME Prisma
     transaction as the user update. If org creation fails, the user is
     NOT left as ORG_ADMIN with no org.
   - Org field validation (billingMode whitelist + PROVIDER role gate)
     runs BEFORE the transaction, so bad payloads are rejected before
     any DB state is mutated.
   - Invitations remain fire-and-forget AFTER the TX commits (email
     delivery is best-effort, not part of the atomicity contract).
   - Replaced createOrgForOnboarding (separate TX) with
     createOrgInTransaction (takes a TX client parameter).

2. Race-safe seat enforcement (new: lib/api/organizations/seat-helpers.ts)
   - acquireSeat(tx, orgProfileId) — single atomic conditional UPDATE:
     `SET seatsUsed = seatsUsed + 1 WHERE seatsTotal IS NULL OR
     seatsUsed < seatsTotal`. Returns false when at capacity. Two
     concurrent calls cannot both succeed.
   - releaseSeat(tx, orgProfileId) — `SET seatsUsed = GREATEST(
     seatsUsed - 1, 0)`. Idempotent, cannot go negative.
   - All seat-touching paths now use these helpers:
     * POST /members — acquireSeat inside TX, throws SEAT_LIMIT_REACHED
       (caught as 403)
     * PATCH /members/[memberId] — acquireSeat when gaining seat,
       releaseSeat when losing seat
     * DELETE /members/[memberId] — releaseSeat only if wasSeatOccupying
     * POST /invitations/accept — acquireSeat inside TX
   - Removed the old read-check-write pattern (pre-TX count check +
     in-TX re-read + separate increment) from all four paths.
   - SEAT_LIMIT_REACHED errors are caught and returned as 403 with a
     user-friendly message (not generic 500).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@teetangh
Copy link
Copy Markdown
Contributor Author

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.

  1. app/dashboard/organization/create/page.tsx:44-80 + :125-132 + app/dashboard/organization/create/components/BrandingStep.tsx:58-106 + app/dashboard/organization/create/components/ReviewStep.tsx:42-60 / :95
    The setup wizard still creates real org state too early, which can leave zombie orgs and assets behind. On step 1 "Next" it already POSTs /api/organizations, the header exposes a plain "Cancel" link back to /dashboard/organization, and the branding step uploads real logo/banner files before launch. If the user abandons the wizard, closes the tab, or cancels after step 1, we keep a partially configured organization + owner membership (and possibly uploaded media) even though the product language still frames step 5 as the real "Launch" moment. There is also a false-success risk in the review step: the final PATCH response is not checked before redirecting to /home, so a failing finalization can still look like a successful launch.

Proposed fix: either (a) treat org creation as a draft flow with explicit DRAFT/cleanup semantics and only surface it after launch, or (b) move creation later and use temporary upload storage until final submit. At minimum, "Cancel" should offer cleanup when orgId already exists, and the review step should fail closed on non-OK PATCH responses instead of redirecting unconditionally.

  1. app/dashboard/organization/[orgId]/credits/page.tsx:88-97 + :203-235 + app/api/organizations/[orgId]/credits/purchase/route.ts:4-10 / :61-67 + app/dashboard/organization/[orgId]/billing/page.tsx:113-123 / :237-247 + app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts:4-9 / :47-54
    From a product-completion standpoint, SEAT_PACK and invoice settlement are still not fully operable from the UI. The dashboard shows real "Buy credits" and "Pay" CTAs, but both flows still terminate in stub responses (pendingPhaseJ / pendingPhaseK) and client-side alerts saying gateway integration is coming later. That means an org owner still cannot actually top up a SEAT_PACK pool or pay an outstanding invoice through the product, even though the PR headline says all three billing modes are in.

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.

  1. app/dashboard/organization/[orgId]/credits/page.tsx:116-124 + app/dashboard/organization/[orgId]/settings/page.tsx:145-240
    There is a UX dead end around billing mode management. The Credits page tells the user to "Switch the billing mode in Settings to enable credit pool management," but the Settings page does not actually expose any billingMode control. So a user can follow the product's guidance and still have no way to complete the task. The underlying org PATCH route supports billingMode, but this path is unreachable from the dashboard.

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.

  1. app/dashboard/organization/[orgId]/settings/sso/page.tsx:287-290 + :344-400
    The SSO page still overstates the UI surface compared with what a customer can actually configure. The page copy says "SAML and OIDC providers registered for this organization," but the add-provider dialog only exposes generic provider ID/domain/issuer fields plus a single "SAML metadata XML" textarea. There is no provider type selection, no OIDC discovery URL, and no OIDC client credentials flow. Even setting aside the backend TODOs, this is not yet a full SAML/OIDC admin UI from a product perspective.

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.

  1. app/dashboard/organization/[orgId]/members/page.tsx:135-201 + app/api/organizations/[orgId]/members/[memberId]/route.ts:1-23 / :37 + app/dashboard/organization/[orgId]/plans/page.tsx:144-204 + app/api/organizations/[orgId]/plans/[planId]/route.ts:1-24 / :54 + app/dashboard/organization/[orgId]/billing/page.tsx:191-194 + app/api/organizations/[orgId]/billing/invoices/route.ts:1-9 / :82
    A few of the new org APIs are still zombie features from the actual product surface. The member API supports PATCH role/status changes, but the Members page only allows add/remove. The plan API supports PATCH, but the Plans page only offers create + archive. The invoices API supports manual invoice creation, and the Billing page copy explicitly mentions "Manual + auto-generated invoices," but there is no manual invoice composer anywhere in the UI. So the backend surface is ahead of the dashboard, and the PR description currently reads more like future-state than current user-state.

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.

…ow SSO copy

Addresses round 4 product/UX feedback:

1. ReviewStep: check PATCH response before redirecting. Previously the
   final PATCH was fire-and-forget — a failing finalization could look
   like a successful launch. Now throws on non-OK response.

2. Credits "Buy credits" button: disabled with "(coming soon)" label
   instead of looking functional and returning a stub alert. Updated
   the "wrong mode" copy to not point at a non-existent Settings control
   ("Switch billing mode in Settings" → "set during creation").

3. Billing "Pay" button on invoices: disabled with "(coming soon)" label
   instead of triggering the pendingPhaseK stub flow.

4. SSO page: narrowed copy from "SAML / OIDC" to "SAML supported; OIDC
   coming soon" in both the header and provider list description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@teetangh
Copy link
Copy Markdown
Contributor Author

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.

  1. app/api/organizations/[orgId]/billing/route.ts:32-61 + app/api/organizations/[orgId]/analytics/route.ts:43-67
    The summary and analytics layers are still aggregating raw org-tagged Payment rows instead of economically valid org charges. These queries filter on organizationProfileId and date, but they do not require paymentStatus = SUCCEEDED, and they do not net out refunds. That means failed or pending checkouts can still inflate org booking/revenue counts, and refunded org-funded bookings can still remain in month-to-date gross / pending charge numbers.

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.

  1. app/api/organizations/[orgId]/billing/generate-invoice/route.ts:42-71
    INVOICED_MONTHLY invoice generation is still billing every unbilled org-tagged payment at full payment.amount. The rollup does not restrict to paymentMethod === ORG_INVOICED, does not require paymentStatus === SUCCEEDED, and does not subtract successful refunds. A refunded-but-unbilled org charge can therefore still land on the invoice at gross amount.

Proposed fix: invoice generation should only roll up succeeded org-invoiced payments, and it should invoice the remaining billable balance (payment.amount - successful refunds) rather than the original gross amount.

  1. app/api/payments/refunds/route.ts:212-226
    ORG_INVOICED refunds still leave invoice accounting inconsistent after an invoice has already been created. The refund path simply nulls billableToOrgInvoiceId on the payment and marks the refund as succeeded, but it never updates the linked OrganizationInvoice.amount, items, or status. So the invoice can continue to show and charge for a booking that was refunded after the invoice was generated.

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.

  1. app/api/organizations/[orgId]/route.ts:46 + app/api/organizations/[orgId]/billing/route.ts:95
    orgInvoiceCreditLimit is configurable and exposed in the summary response, but I could not find any actual enforcement in checkout or invoice generation. So an INVOICED_MONTHLY org can still keep booking indefinitely regardless of the configured credit limit.

Proposed fix: enforce the limit in the same place org-funded checkout is authorized. Before accepting an ORG_INVOICED booking, calculate current outstanding exposure (unbilled charges + unpaid sent/overdue invoices, net of refunds/credits) and reject when the next booking would exceed the org’s credit limit.

  1. lib/payments/operations/checkout.ts:1580-1597
    The org-funded checkout authorization is currently role-blind. The new membership check correctly blocks arbitrary outsiders, but the code only requires “active member” and does not distinguish who is actually allowed to consume org budget. If the intended policy is “learner seats consume buyer-org spend,” then owners/admins/managers/support/consultants can currently also route bookings to SEAT_PACK or INVOICED_MONTHLY org billing.

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 ORG_LEARNER, or learner + manager/admin) should be allowed, enforce it here before selecting ORG_CREDIT / ORG_INVOICED.

  1. schemas/checkout.ts:93-97 + client search for organizationId
    From a feature-completion perspective, I still could not find a real frontend checkout path that actually sends organizationId. The backend billing branches exist, but if no client flow populates that field, then the organization billing modes are still only partially realized in product terms.

Proposed fix: trace the actual consultee/org-member booking UI and either wire organizationId into the request path or narrow the PR language to say the org-billing backend foundation is implemented while the frontend booking selector is still pending.

Overall recommendation: pull org billing math into a dedicated shared service that owns these invariants:

  • who is allowed to spend on behalf of an org
  • what counts as a billable org charge
  • how refunds reduce billable balance
  • how credit limits are enforced
  • how summaries/invoices/analytics derive from the same source of truth

Right now those rules are spread across checkout, invoice generation, refunds, analytics, and dashboard summary code, which is why the edge cases are diverging.

…rcement

Addresses round 5 billing-model-focused feedback:

1. FIX — Analytics/billing filter by paymentStatus=SUCCEEDED
   billing/route.ts: month-to-date gross + pending charges now require
   paymentStatus=SUCCEEDED. Pending charges also require
   paymentMethod=ORG_INVOICED (not all org-tagged payments).
   analytics/route.ts: all 3 payment queries (this month count, last
   month count, revenue aggregate) now require SUCCEEDED.

2. FIX — Invoice generation filters + refund netting
   generate-invoice/route.ts: only rolls up SUCCEEDED + ORG_INVOICED
   payments. Each payment's billable amount is now net of successful
   refunds (payment.amount - sum(refunds.amount)). Fully-refunded
   bookings are excluded from the invoice entirely.

3. FIX — orgInvoiceCreditLimit enforcement in checkout
   checkout.ts: before accepting an ORG_INVOICED booking, calculates
   current outstanding exposure (unbilled succeeded ORG_INVOICED
   payments + unpaid SENT/OVERDUE invoices). Rejects when exposure
   would exceed the org's configured credit limit.

4. DOCUMENTED — Role-blind org spending policy
   checkout.ts: added explicit comment that any active member may spend
   org budget (intentional). If role-based restrictions are needed later,
   the check goes next to the membership verification.

5. DOCUMENTED — Invoice refund adjustment gap
   refunds/route.ts: added TODO comment explaining that ORG_INVOICED
   refunds on already-paid invoices do not update the invoice amount.
   Credit-note / invoice adjustment is a separate feature.

6. ACKNOWLEDGED — No frontend sends organizationId yet
   Already listed as a follow-up in the PR description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
teetangh and others added 7 commits May 16, 2026 00:48
…n, session refresh, IDOR posture)

Closes the P0/P1 findings from the enterprise auth + SSO audit (3 Explore
agents: auth core, SSO end-to-end, multi-tenant isolation).

Phase A — P0 ship-blockers:

  A.1 JIT auto-join role floor locked to LEARNER. Pre-fix, an admin
      could set `OrganizationSSOSettings.defaultRoleForAutoJoin =
      "OWNER"` and the first SSO user became co-owner of the org
      instantly — catastrophic privilege grant if the IdP was ever
      misconfigured. The new `JitDefaultRoleSchema = z.literal("LEARNER")`
      in lib/labels/org-labels.ts rejects every other role with 400
      `INVALID_DEFAULT_ROLE`. UI replaces the prior role dropdown with
      a locked "Learner" label + helper text. Admins promote
      explicitly via /dashboard/.../members (audit-logged).

  A.2 SAML cert validation via Node `crypto.X509Certificate`. Pre-fix,
      `samlConfigSchema.cert` was `z.string().min(1)` — any garbage
      passed. BetterAuth's underlying `@node-saml/node-saml` adapter
      crashed at first signin with
      `TypeError: Cannot read properties of undefined (reading 'metadata')`
      returning 500 with empty body. New `validateSamlCert` refinement
      rejects malformed PEM at registration with a friendly error
      pointing at where to find the correct cert in the IdP admin
      console. Two existing malformed-cert rows soft-deleted with audit
      trail.

  A.3 customSession bareMembers catch narrowed to P2002. The bare
      `catch {}` swallowed network drops, Supabase RLS denials, FK
      violations — users could end up with a session cookie but no
      Membership row, landing on a broken dashboard. Now only
      `PrismaClientKnownRequestError` with code `P2002` is swallowed
      (concurrent session-create won the membership); everything else
      re-throws.

Phase B — P1 hardening:

  B.1 SSO error toast wrapper. New `lib/sso/signin-with-toast.ts`
      wraps `signIn.sso()` with: (a) BetterAuth `{ data, error }`
      inspection (the SDK never throws on logical failure), (b) 2s
      redirect watchdog (catches 500-with-empty-body crashes), (c)
      generic destructive toast on either failure. Replaces 3 inline
      `await signIn.sso(...)` sites in signin/signup pages that
      previously had no error handling.

  B.2 ACS URL type inference fixed. providers/route.ts hardcoded
      `type: null` meant OIDC providers showed the wrong setup
      instructions. Now derived from `samlConfig` / `oidcConfig`
      presence.

  B.3 Domain-verification gate at provider registration. The POST
      handler now requires (a) an `OrgDomainClaim` row exists for the
      domain owned by THIS org, and (b) `verifiedAt IS NOT NULL`. Pre-
      fix, an org could register a provider for an unverified domain
      (or even one owned by another org) — the runtime hook would
      silently refuse to honor it, leaving admins staring at a
      registered provider that mysteriously never fires. Now: 422
      `DOMAIN_NOT_OWNED` / `DOMAIN_NOT_VERIFIED` at the registration
      step itself.

  B.4 SsoProvider composite unique. Added `@@unique([organizationId,
      domain])`. Belt-and-suspenders for B.3's route-level check —
      future hand-SQL / migration bypasses are now caught at the DB
      level.

  B.5 sessionGeneration refresh plumbing. New `User.sessionGeneration
      Int @default(0)`. Every membership mutation that affects the
      effective permission set (PATCH role/status/departmentLabel,
      DELETE soft-remove, POST add, invitation accept) calls
      `bumpUserSessionGeneration(tx, userId)`. The customSession hook
      always re-reads memberships, and now carries the live
      `sessionGeneration` in the session payload for client-side
      stale-detection. Pre-fix, a LEARNER promoted to OWNER (or an
      OWNER demoted) kept their old permissions for up to 24h until
      BetterAuth's session rotation window passed.

  B.6 Extracted shared `lookupEnforcedOrg` to lib/sso/enforce-session.ts.
      Three call sites (session.create.before, customSession,
      domain-check route) previously duplicated the domain →
      OrgDomainClaim → SsoProvider lookup with subtle drift (issue
      #673). Single source of truth now.

  B.7 N+1 fix in customSession membership sync. Pre-fetch the user's
      `consulteeProfileId` + `consultantProfileId` once at the top of
      customSession; pass through to `applyMembershipRoleEffects` via
      a new `preloadedProfiles` argument. For a user in 10 SSO orgs,
      avoids 10 redundant `users.findUnique` round-trips per session
      lookup.

  B.8 Rate-limit policy documented. BetterAuth's built-in limiter
      stays disabled (per-process counters drift on serverless); the
      Upstash-backed `authLimiter` in middleware.ts:192-197 provides
      the globally-coherent gate at 10 req / 15 min / IP. Added a
      multi-line comment block at lib/auth.ts:31 explaining the
      decision + listing the coverage matrix.

Phase C — Tests:

  Extended prompts/enterprise-tests/1-membership-auth/1.3-sso-and-domain-claims.md
  with 5 new MCP-driven cases (SSO.6 through SSO.10) covering the
  cert-validation reject, the domain-verification 422s, the JIT-role
  400, the domain-check rate limit, and the role-change session
  refresh.

Phase D — Docs:

  - docs/enterprise/08-sso-and-authentication.md extended with JIT
    default role section, cert rotation procedure, cert format
    validation, IdP email-verification operator guidance, and
    cross-links to the new reference docs.
  - docs/enterprise/28-jit-and-session-refresh.md (NEW). Full sequence
    of JIT auto-join, the sessionGeneration marker, role-change
    refresh without forced logout. Includes sequence diagram + failure
    modes + how-to-detect.
  - docs/enterprise/30-rate-limiting.md (NEW). Coverage matrix +
    rationale for the BetterAuth-disabled policy.
  - docs/enterprise/reference/sso-error-codes.md (NEW). Every typed
    HTTP error code emitted by the SSO routes, paired with the
    humanizeOrgError copy + operator fix.
  - docs/enterprise/00-overview.md index extended with the 3 new docs.
  - prompts/enterprise-tests/_shared/shared-setup.md §8 key-code-anchors
    extended with the new audit-batch surfaces.

All inline JSDoc / block comments in the code carry the WHY (failure
modes, upstream pointers, why-not-the-other-design rationale) per the
plan's "Shape 1 / 2 / 3" depth bar. New developers reading these files
cold should be able to infer the constraints without spelunking git
history.

Out of scope (deferred):
  - P2 code-health: isAtLeastRole undefined guard, unified auth-helper
    error shape, OrgAuditLog DB-level CHECK constraint
  - DPDP user self-service data export
  - Single Logout (SLO) for SAML / OIDC front-channel
  - OIDC scopes default + IdP-specific docs

Schema migration: 20260516000000_auth_sso_hardening adds
User.sessionGeneration + SsoProvider composite unique. Phase A.1 data
normalization (defaultRoleForAutoJoin → LEARNER) applied directly via
Supabase MCP since prisma/migrations is gitignored in this repo.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI green-up (876/883 → 957/957):
- stream/user-actions: add consentArtifact delegate to mock
- enterprise/member-anti-lockout: wire tx.user.update + sessionGen assertion
- sso/provider-schemas: inline real RSA-2048 PEM; pin 2 negative cases
- enterprise/programs-v2-rejection: switch jest.mock specifiers to relative
- jest.config: skip .claude/worktrees + __tests__/fixtures during crawl

Critical bug fixes (4 reviewer findings, May 2026 audit):
- SSO cert pre-auth guard: domain-check route parses stored cert via
  validateSamlCert before handing the client an ssoBody; returns typed
  SSO_PROVIDER_MISCONFIGURED so BetterAuth's adapter never crashes on
  malformed legacy PEM rows
- PO balance enforcement test surface: 6-case regression covering CAS
  decrement, exact-balance drain, overflow rejection, VOID restoration
- UI error humanization: 6 new ORG_ERROR_COPY entries
  (PROGRAM_TYPE_NOT_AVAILABLE, PO_BALANCE_EXCEEDED / INSUFFICIENT,
  DOMAIN_NOT_OWNED / VERIFIED, SSO_PROVIDER_MISCONFIGURED) + snapshot test
- Prisma 7 standalone jobs: dotenv/config + $disconnect across the five
  jobs/**/* entry points that were missing them

Schema migration (applied via Supabase MCP under
pr655_enterprise_lockdown_schema):
- MemberRole += BILLING_ADMIN at rank 70 (between MAINTAINER 80 +
  MANAGER 60)
- MemberStatus += ERASED (DPDP §12 tombstone)
- OrgAuditCategory += WEBHOOK
- User += erasedAt + pseudonymousId (unique, indexed)
- Membership += externalScimId (partial-unique scoped by orgId)
- Organization += streamRecordingRetentionDays @default(90)
- 6 new tables: WebhookEndpoint, OutboundWebhookDelivery, ScimToken,
  ScimGroupMapping, ErasureRequest, OrgDataExportJob
- 5 new enums: WebhookEndpointStatus, DeliveryStatus, ScimTokenStatus,
  ErasureStatus, OrgDataExportStatus

BILLING_ADMIN role split (finance-team operator):
- ORG_ROLE_RANK[BILLING_ADMIN] = 70; rationale in role-ranks.ts
- New requireOrgBillingAdminOrOwner disjunction helper (explicit role
  match, NOT rank-based — MAINTAINER at rank 80 must be denied)
- 12 route gate downgrades from OWNER-only:
  billing-account/route.ts, purchase-orders (POST + [poId]),
  invoices (POST + [invoiceId] PATCH + pay), wallet/top-ups POST,
  payouts (POST + [payoutId] PATCH), rate-cards (POST + [cardId])
- Stays OWNER-only: org DELETE, sso/**, domain-claims/**, members/**,
  invitations/**
- Labels + Zod enums updated; create-wizard role picker includes
  BILLING_ADMIN
- 9-case billing-admin-gate.test.ts pins the disjunction (MAINTAINER
  rank-80 must NOT pass)
- canHost: true now hard-gated server-side with HOST_ORGS_GATED
  (replaces WIP-banner pattern per reviewer feedback)

Docs: 04-roles-and-permissions.md gate matrix; 08-sso cert validation
runtime guard; 10-invoicing PO balance invariant; 23-runbooks local
cron run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the integrator-polling gap so external systems (HRIS,
finance ERPs, customer-success tools) can react to org lifecycle
events without scraping the dashboard.

Subsystem (lib/enterprise/outbound-webhooks/):
- event-types.ts — closed catalog of 8 events (member.added/removed,
  invoice.issued/paid, payout.completed/failed, contract.signed,
  program.assigned). Adding events is a one-line edit + emit-point.
- signing.ts — HMAC-SHA256 with Stripe-shaped t=<unix>,v1=<hex>
  header. 9h replay window matches the worker's max retry wall-clock.
  Constant-time verifier via crypto.timingSafeEqual.
- dispatch.ts — fire-and-forget DB enqueue. Filters subscribed
  ACTIVE endpoints via `eventSubscriptions has $eventType`; safe
  inside or outside Prisma transactions (commits with the caller).
- worker.ts — runDispatchTick: pulls up to 50 PENDING/RETRY rows,
  signs + POSTs with 10s timeout, exponential backoff
  (1m→5m→30m→2h→8h then FAILED). Permanent 4xx (not 408/429) skip
  retry; 5xx/408/429/network errors get the next backoff slot.
  PAUSED endpoints short-circuit to FAILED — operator pause wins.
- audit.ts — one summary row per delivery final state (not per
  attempt); keeps the audit log readable.

Routes (7 under app/api/organizations/[orgId]/webhooks/):
- GET / (MANAGER+) — redacted list
- POST / (OWNER + BILLING_ADMIN) — one-time secret reveal on create
- PATCH /[endpointId] — pause/resume + URL/event edits
- DELETE /[endpointId] (OWNER only — cascade-deletes pending
  deliveries, governance-sensitive)
- POST /[endpointId]/rotate-secret (OWNER only)
- GET /[endpointId]/deliveries (MANAGER+) — paginated log with
  payload included for receiver-side debugging
- POST /[endpointId]/deliveries/[deliveryId]/redeliver
  (OWNER + BILLING_ADMIN)

Worker cron: jobs/cleanup/dispatch-outbound-webhooks.ts +
.github/workflows/dispatch-outbound-webhooks.yml (every minute).
HTTP shim at app/api/cleanup/dispatch-outbound-webhooks (CRON_SECRET
gated).

Emit-points wired:
- membership-transitions: member.added / member.removed (in-app
  invites). Dispatched inside the same tx so a rollback also rolls
  back the delivery row.
- billing-account/invoices POST: invoice.issued (only on the
  DRAFT→ISSUED path; a no-op PATCH doesn't re-emit).
- More emit-points (invoice.paid, payout.*, contract.signed,
  program.assigned) are scaffolded; remaining wire-ups land in a
  follow-up.

Rate limit: orgWebhookLimiter (5/min per org) on POST/PATCH/rotate.

19-case test suite (signing roundtrip, replay rejection, dispatch
fan-out, worker retry schedule, operator-pause short-circuit).

Docs: docs/enterprise/29-outbound-webhooks.md — event catalog,
signature scheme, retry table, webhook.site integration recipe.

Live-verified end-to-end against production schema (Supabase MCP)
during PR #655 smoke run.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Off-the-shelf IdP connectors (Okta, Azure AD, OneLogin, JumpCloud)
hard-code /scim/v2/<resource> as the canonical path; this commit
mounts the spec-compliant subset at app/scim/v2/ so those connectors
work without custom configuration.

Path placement is deliberately outside /api/ because IdPs reject
non-spec paths; capital-cased segments (/Users, /Groups, /Schemas)
match RFC 7644 §3.2 — some IdP clients are case-sensitive on these.
The middleware short-circuits with NextResponse.next() for any path
under /scim/v2/ before BetterAuth's session-cookie check; the route
handlers self-authenticate via bearer tokens.

Subsystem (lib/scim/):
- auth.ts — bearer parser; sha256 hash lookup against ScimToken; bumps
  lastUsedAt (fire-and-forget); writes SCIM_TOKEN_USED_AFTER_REVOKE
  audit row when a revoked token authenticates (so rotation gaps are
  visible without grepping worker logs). Enforces scimLimiter
  (60 RPM per token).
- resource-user.ts — toScimUser mapper + resolveRoleFromGroupNames
  (highest-rank wins; LEARNER default for least-privilege).
- operations.ts — createOrReprovisionScimUser (idempotent upsert by
  (orgId, userName)) + deprovisionScimUser (DELETE = SUSPEND, never
  erase — erasure is the user's own DPDP §12 right). Every mutation
  dispatches member.added / member.removed via the outbound-webhook
  helper so subscribers see IdP-driven and in-app provisioning
  through the same socket.
- errors.ts — SCIM 2.0 error envelope per RFC 7644 §3.12.

Erasure short-circuit: every operation that would create or revive a
User refuses with 410 Gone when User.erasedAt IS NOT NULL. Surfaces
in the IdP's provisioning report as a permanent error.

Routes (mounted at app/scim/v2):
- GET /Users (list + filter=userName eq "...")
- POST /Users (create or re-provision; honors externalId + groups)
- GET /Users/[id]
- PATCH /Users/[id] (replace active for Okta/Azure deactivate; reject
  other ops with 400 invalidSyntax to keep the attack surface small)
- DELETE /Users/[id] (soft-deprovision → SUSPENDED)

Org-scoped admin (under app/api/organizations/[orgId]/scim/, OWNER-only):
- tokens (GET list, POST mint with one-time reveal, DELETE revoke
  soft-flips status to REVOKED keeping the row for the
  used-after-revoke audit trail)
- group-mappings (GET, POST, DELETE) — bind IdP group names
  (IT-Admins, Finance-Leads, etc.) to local MemberRole

Tests (15 cases):
- resource-user: shape + parseDisplayName + role resolution
  (highest-rank wins; unknown groups ignored)
- auth: missing header → 401; bogus token → 401; revoked → 401 +
  audit row; rate-limit → 429; happy path returns grant +
  bumps lastUsedAt

Docs: docs/enterprise/31-scim-provisioning.md — endpoint catalog,
auth flow, group→role table, erasure interaction, Okta + Azure
curl recipes.

Live-verified end-to-end during PR #655 smoke (list / filter /
create / PATCH deactivate / DELETE; webhook fan-out confirmed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-PR this was a manual CA + DB-admin process logged in
docs/compliance/erasure-requests-manual-log.md. This commit
automates it: user files a request, admin reviews, scrub fires
inside a single transaction, financial rows are retained per IT Act
§44AA, webhooks fan out for IdP deprovisioning, and the manual log
is closed.

Scrub service (lib/compliance/erasure/scrub-user.ts):
- pseudonymousId = sha256(userId + ERASURE_SALT) — deterministic
  one-way hash used to rewrite audit-log JSON without losing
  forensic cross-reference
- User PII overwrite: name → "Erased User <hash>"; email →
  erased-<hash>@erased.invalid; image/phone/address/bio/linkedinUrl/
  dateOfBirth → NULL; erasedAt + pseudonymousId set
- Membership[].status → ERASED across every active org membership
- ConsultantProfile.headline + videoIntroUrl scrubbed
- BetterAuth Session + Account rows hard-deleted (immediate sign-out
  across every device, SSO accounts dropped too)
- Per-org member.removed webhook fan-out with source: dpdp_erasure,
  payload uses pseudonymousId NOT raw userId/email
- Idempotent: re-running observes erasedAt + returns existing
  pseudonym without writing

Financial rows RETAINED per IT Act §44AA + DPDP §12 carve-out:
Payment*, OrganizationInvoice, OrganizationPayout, WalletEntry,
FundingLedgerEntry, SettlementLedgerEntry, Refund.

API routes:
- POST /api/users/me/erasure-requests (idempotent — open request
  returns the existing row; partial-unique index on
  (userId, status IN PENDING|IN_PROGRESS) is the DB-side guarantee)
- GET /api/users/me/erasure-requests (own status check)
- GET /api/admin/erasure-requests (review queue, ADMIN-only)
- POST /api/admin/erasure-requests/[id]/process (flip to
  IN_PROGRESS, invoke scrubUser, mark COMPLETED on success;
  failure path returns the row to PENDING with the error in `notes`
  so the queue picks it up again)
- POST /api/admin/erasure-requests/[id]/reject (REJECTED + required
  reason — e.g. financial dispute open)

SCIM 410 Gone short-circuit: createOrReprovisionScimUser refuses
to revive an erased user. Surfaces in IdP provisioning reports as a
permanent error so the operator removes the user from their IdP
roster.

Audit trail: USER_ERASURE_REQUESTED / PROCESSED / REJECTED /
SLA_WARNING under the SYSTEM category, one row per affected org.
The audit row outlives the user's identity — it's the regulatory
evidence-of-erasure record.

Live-verified end-to-end during PR #655 smoke:
- Membership.status: ACTIVE → ERASED
- User.email: alice@... → erased-94587a24...@erased.invalid
- User.name: "Fanout Test" → "Erased User 94587a24"
- erasedAt + pseudonymousId populated
- ErasureRequest COMPLETED + completedAt + processedByAdminId
- member.removed webhook queued with source: dpdp_erasure
- USER_ERASURE_PROCESSED audit row written

Docs: docs/enterprise/26-deletion-policy.md Phase 2 section
rewritten — request lifecycle, scrub field-by-field table,
cross-feature interactions (SCIM 410, webhook fan-out, audit
retention), no recovery path (deliberate — issue a new User if
erasure was an admin error).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Closes the remaining ⚠️ cross-cutting items from the May 2026 audit
plus the BILLING_ADMIN dashboard surface, hygiene cleanup, and the
RLS design memo.

Security headers (next.config.mjs):
- Strict-Transport-Security: max-age=63072000; includeSubDomains;
  preload
- Content-Security-Policy-Report-Only (default) — allow-list covers
  Stream/Razorpay/Sentry/Supabase/Resend/Upstash; media-src includes
  blob: + *.getstream.io for call playback; frame-src allows
  Razorpay checkout iframe
- ENABLE_CSP_ENFORCE=true flips to enforcing variant
- /api/csp-report sink (unauthenticated; spamLimiter-throttled)
  emits event: "csp_violation" structured logs

Stream call export + retention:
- GET /api/organizations/[orgId]/stream/calls (MANAGER+) reads
  MeetingSession joined to the org via Appointment.organizationId
  (denormalized #674 column). Optional ?withRecordings=1 lazy-joins
  Recording. Audits STREAM_CALLS_EXPORTED per call.
- Organization.streamRecordingRetentionDays (default 90) +
  scripts/cleanup/cleanup-old-stream-recordings.ts daily cron at
  03:00 UTC tombstones Recordings past the per-org window. Status
  flips to EXPIRED; underlying Stream S3 lifecycle is Stream's
  problem (free tier 2w; paid is per-channel).

Audit retention (scripts/cleanup/prune-audit-logs.ts, 03:15 UTC):
- 7y for INVOICE / PAYOUT / WALLET / CONTRACT / CONSENT (IT Act
  §44AA financial-record + DPDP §12 evidence-of-erasure)
- 2y for MEMBER / SETTINGS / CATALOG / SYSTEM / PROGRAM / WEBHOOK
- Per-org AUDIT_PRUNED summary row with deleted-counts; summary
  itself prunes after 2y (SYSTEM bucket)

Stale invitation hardening:
- Fixed an over-counter bug in the existing cron: `expired += 1`
  fired unconditionally outside the transaction even when the
  concurrency guard short-circuited on a racing accept. Now returns
  a boolean from inside the tx and increments only on a real flip.
- 4-case regression test pins: no-op when no stale rows; happy-path
  flip + audit row; concurrency guard skip; per-row error isolation.

DPDP §11 data export (lib/compliance/erasure not the only DPDP
surface — §11 is right-to-access):
- POST /api/organizations/[orgId]/data-exports (OWNER +
  BILLING_ADMIN) — orgDataExportLimiter (1/24h per org)
- GET list (last 30 days) + GET [exportId]/download (Supabase signed
  URL, 7-day TTL; refuses non-READY / expired)
- Worker scripts/cleanup/process-data-exports.ts (every 10 min)
  builds an 8-entity bundle (organization + members + memberships +
  contracts + programs + invoices + earnings + payouts + audit log),
  uploads to Supabase Storage bucket `org-exports/{orgId}/{jobId}.json`,
  emails the requester via Resend. Local-dev fallback writes to
  /tmp/familiarise-data-exports/ when Supabase env keys aren't set.
- Fixed Prisma filter caught during live exercise: Program has no
  direct organizationId column — programs hang off Contract.

BILLING_ADMIN dashboard surface:
- New /integrations/ route group with three pages
  (webhooks, scim, data-exports) gated by useRequireFinanceSurface.
  Mutation forms render only for OWNER where appropriate (SCIM token
  + group-mapping creation); listings open to OWNER + MAINTAINER +
  BILLING_ADMIN + MANAGER.
- FinanceLeadViewCard on /home renders for BILLING_ADMIN ahead of
  the operator branch. Pulls outstanding-invoices + wallet-balance
  from the existing analytics endpoint; Payouts + POs are deep-link
  CTAs until analytics ships their breakdowns.
- New canSeeFinanceSurface / canSeeOperatorSurface predicates in
  lib/auth/role-ranks.ts. Sidebar layout switched from rank-only
  isAtLeast() to the explicit disjunction — fixes a real bug where
  BILLING_ADMIN (rank 70 > MANAGER 60) was silently granted access
  to Members / Invitations / Learners / Experts / Audit / Settings.
- useRequireFinanceSurface + useRequireOperatorSurface hooks in
  useOrgRole.ts as the page-level counterparts.

WIP banner sweep — per PR #655 reviewer "WIP banners are not
production gates":
- Removed all three banner render sites in
  components/organization/create-wizard/OrgInfoStep.tsx +
  BillingStep.tsx and
  app/dashboard/organization/[orgId]/programs/page.tsx
- canHost: true now hard-gated server-side with HOST_ORGS_GATED 400
  (replaces the wizard banner)
- Programs V2 page guidance lives in the issue tracker only
- Component file components/enterprise/EnterpriseWipBanner.tsx
  deleted — all consumers removed

Hygiene / docs:
- docs/enterprise/32-security-headers.md — header inventory + CSP
  allow-list rationale + rollout plan
- docs/enterprise/33-data-export.md — bundle shape + rate limit +
  download flow
- docs/compliance/10-rls-design-memo.md — service-role-bypass
  pattern, per-table policy sketch, migration risks, three concrete
  triggers for when to enable RLS (Realtime/Storage client-side,
  SOC 2 audit, anon-key leak). NOT applied — documented for the
  next implementer.
- docs/enterprise/23-runbooks.md — CSP report-only → enforce flip
  protocol (7-day observe, log triage, smoke recipe, rollback)
- __tests__/fixtures/createBookingWithEarnings.ts — skeleton for
  the deferred Round-3 booking-flow tests; full implementation
  requires extracting an earnings-only helper from earnings-service
  and is tracked as follow-up

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Three discrete groups of fixes from the May 2026 audit's coverage
check (run after PR #655 batches 1–8 landed).

## Org-scope filter coverage (6 surfaces, was leaking cross-tenant data)

The #674 / B1-hybrid scope split shipped a `resolveOrgScope` helper +
denormalized `organizationId` columns on every leaf table, but the
rollout was uneven — `requests`, `planner`, `documents`, `recordings`
got the filter; legacy routes never did. Every fix here applies the
same three-mode shape (personal / org:<id> / all-for-admin) the
working endpoints use:

- `/api/slots/appointments` — getAppointments() now takes an
  `orgScopeFilter` argument; consultant/consultee tabs no longer
  cross-tenant
- `/api/dashboard/consultee/[id]/payments` — Payment.organizationId
  filter; invoice subquery flows the same filter through the
  payment join
- `/api/trials` — TrialSession.organizationId filter
- `/api/waitlist` + `lib/waitlist/queue-manager.getUserWaitlistEntries`
  — Waitlist.organizationId filter via an `orgFilter` param
- `/api/referrals` — DELIBERATELY personal-only (referral codes
  follow the user across orgs); doc-only change recording the intent
  so future readers don't mistake it for a leak
- `components/chat/ChatSidebar.tsx` — the highest-impact leak:
  `client.queryChannels` had no `custom.organization_id` filter, so a
  consultant in Acme + Zeta saw every chat cross-tenanted in one
  inbox. Both initial fetch + paginated load-more now scope by
  `organization_id` ($exists:false for personal, $eq:<id> for org).
  `scope` added to fetchChannels deps so the inbox refetches on
  org-context toggle without a hard reload.

## Schema finalization (3 critical closures, audit recommendation)

Locks the schema before pilot customer data lands so future feature
work doesn't ship a breaking migration:

- `ErasureRequest.processedByAdminId` — added `@relation` FK to User
  with `onDelete: SetNull` (audit row outlives staff turnover; named
  relation "ErasureRequestProcessor" disambiguates from the user's
  own erasure-request edge)
- `WebhookEndpoint.secretRotatedAt` + `previousSecretHash` — 24h
  rotation grace window scaffolding so the worker can verify
  deliveries signed with either secret during cutover. Column lands
  now; rotate-secret route still overwrites `secret` directly —
  flipping to grace-aware reads is a follow-up.
- `ScimToken.expiresAt` — optional auto-rotation TTL. Enforcement
  read is a follow-up; column exists today so OWNERs can set a
  6/12-month TTL when minting + the rotation reminder cron has a
  stable column to scan.

Migration applied via Supabase MCP under
`pr655_schema_finalization_pre_mvp` (additive; existing rows
unaffected).

## Maintenance subsystem — Tier 1 multi-tenant (organizationId + Redis namespacing)

Unlocks the per-tenant features in the post-MVP issue without forcing
them in now:

- `maintenance_windows.organizationId` (nullable FK with
  `onDelete: Cascade`). NULL = platform-wide window (every legacy row
  + the default). Non-null = scoped to that tenant; other orgs see
  the platform stay green.
- `lib/maintenance-edge.ts` exports `platformMaintenanceKeys()` +
  `orgMaintenanceKeys(orgId)` so the future admin route can write
  org-scoped state under a canonical Redis namespace from day one.
  Middleware reads the platform key today; the org-aware read is a
  one-line swap when Tier 2 ships.
- Migration applied via `pr655_maintenance_per_tenant_tier1`.

## Constraints / scope

- Conflict resolution is INTRA-USER, not INTER-ORG by design.
  SlotOfAvailability + SlotOfAppointment stay per-user; the dashboard
  filter is for visibility + finance attribution.
- Tier 2 maintenance (per-org admin API, capability scoping, scoped
  Novu workflows), the SCIM token rotation enforcement, and the
  webhook secret grace-window verifier all go in the giant post-MVP
  issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
teetangh and others added 6 commits May 16, 2026 16:37
CI's `tsc --noEmit` step caught what local jest couldn't (jest doesn't
typecheck production code; project's standing pref is no-builds-unless-
asked, so the earlier batches' TS-errors slipped through).

- lib/auth/role-ranks.ts: explicit `Set<MemberRole>` constructor type
  arg so OPERATOR_ROLES / FINANCE_ROLES don't widen to Set<string>
- app/dashboard/organization/[orgId]/audit/page.tsx: list + tone-map
  WEBHOOK (the OrgAuditCategory enum extension from PR #655 batch 2
  required this — the `satisfies Record<OrgAuditCategory, string>`
  contract was failing in CI)
- FinanceLeadViewCard.tsx: StatCard prop is `title` not `label`
- integrations/{webhooks,scim,data-exports}/page.tsx: DashboardHeader
  prop is `subtitle` not `description`
- lib/auth-helpers.ts: admin-stub Membership now carries the new
  `externalScimId: null` column added by PR #655 batch 2
- billing-admin-gate test: runtime `"member" in result` narrowing so
  TS picks the success branch of the access-grant discriminated union

Local `tsc --noEmit` + `jest` both clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

fix(programs): add coveredPlanTypes selector and learner assignment UI (#740, #741)
… field

- ProgramAssignment has no status column in the schema, so the Status column in ManageProgramDialog always rendered a blank badge. Removed the phantom status field from AssignmentListItem and replaced the static render with a date-derived label: Active / Upcoming / Expired.
…nt earnings fix

- 1. ensureConsultantProfile P2002 (membership-transitions.ts)
  Seeded users whose ConsultantProfile row existed but User.consultantProfileId was null triggered a unique constraint on ConsultantProfile.userId when the lazy-create ran. Fixed: check ConsultantProfile.findUnique({ where: { userId }}) before create; backfill User.consultantProfileId if found.

  - 2. /my-arrangement "Recent earnings" always empty for HOST orgs (my-arrangement/page.tsx)
   Original query filtered ConsultantEarnings by payment.organizationId = orgId, but HOST-org payments have organizationId=null (learner pays personally; org relationship lives only on OrganizationEarnings). Fixed: first fetch OrganizationEarnings.paymentId rows for the org, then query ConsultantEarnings by paymentId IN (those IDs).

  Scenario (Provider/host-only) now fully walked end-to-end: HOST wizard →
  EXPERT add → booking → OrganizationEarnings (10%) + ConsultantEarnings (80%)
  confirmed → /my-arrangement shows correct split + recent earnings table.
teetangh and others added 6 commits May 20, 2026 00:00
Two new schema surfaces for the PR #655 closeout features. Both
migrations are already applied to the live Supabase DB so prisma
generate stays in sync.

OrgWorkspaceProfile (20260518000000) gains four operator-preference
columns:
  - defaultLandingOrganizationId — soft FK on the org to open by
    default; no relation so org deletes don't cascade
  - notificationRoutingMode (new enum: BELL_AND_EMAIL | BELL_ONLY |
    EMAIL_ONLY | NEITHER) — read by Novu dispatchers
  - locale (BCP-47), currencyDisplayCode (ISO 4217) — workspace
    shell number/date/currency formatting

SystemEvent (20260519000000) is a NEW admin-only event table that
sits next to OrgAuditLog. Engineering-grade payloads (Prisma stack
traces, raw worker errors, internal IDs) live here instead of
leaking into org-visible audit rows. OrgAuditLog keeps human prose;
SystemEvent keeps the raw error + stack + correlationId.

Schema docstrings stay terse — full design lives in
docs/enterprise/34-workspace-preferences.md and 35-system-events.md.

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

User-facing surfaces for the PR #655 closeout.

Purchase Orders dashboard
  Full CRUD on /dashboard/organization/[orgId]/purchase-orders backed by
  the existing API. Modular layout — page.tsx is a thin orchestrator,
  components/ holds Create/Edit/Delete dialogs + Stat cards + Table, and
  utils/ holds api + types + formatting. Multi-currency (INR no-decimal,
  forex 2-decimal), status badges, search, and an audited delete that
  refuses when contracts/invoices reference the PO.

OrgWorkspace operator settings
  Real settings page replacing the placeholder card. Three independent
  sections — default landing org, notification routing, locale + currency.
  Each section has its own Save button so a slow Save in one doesn't
  block another. Backed by GET/PATCH /api/org-workspace/[id]/settings
  with an IDOR guard mirroring the workspace billing route. Modular
  components/ + utils/ layout.

Sidebar grouping (CollapsibleSidebar + per-org layout)
  The 20-item flat sidebar is now five clusters: People / Commerce /
  Operations / Insights / Configuration. Operations defaults collapsed
  for OWNER + MAINTAINER (operational data isn't their primary nav),
  open for MANAGER + SUPPORT. CollapsibleSidebar accepts an optional
  `groups` prop alongside the existing `items` so the simpler dashboards
  (staff, admin, workspace) stay on the flat API.

Honest role-visibility audit
  Fixes a real bug — SUPPORT (rank 30) previously saw nothing but
  Overview because the layout's isAtLeast("MANAGER") gate denied them
  every operational tab. Now SUPPORT has read access to Members,
  Operations group, Audit, and Analytics to investigate L1/L2 tickets.

Commerce reorder + requiresPO gate
  Contracts → Purchase Orders → Programs (was Programs → Contracts →
  POs). Matches the data dependency: every Program FKs to a Contract,
  so the setup story now reads top-to-bottom. Purchase Orders tab
  hides in the sidebar when Organization.requiresPO=false — orgs
  without India AP 3-way-match no longer see the noise. Authz on the
  PO routes is unchanged.

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

Three-layer fix for org-visible Prisma stack traces in OrgAuditLog:
  - lib/enterprise/audit-sanitize.ts: read-side scrub redacts Prisma
    error / stack / schema-name patterns before rows hit org surfaces
  - lib/enterprise/system-events.ts + app/api/admin/system-events:
    new admin-only operational events channel (writes via the new
    SystemEvent table, gated by requireAdminAuth)
  - scripts/cleanup/process-data-exports.ts +
    app/api/organizations/[orgId]/hris/sync/route.ts: failure paths
    write a clean prose row to OrgAuditLog and the raw error to
    SystemEvent via recordSystemError
  - app/api/organizations/[orgId]/audit/{route,export/route}.ts:
    list + CSV endpoints pipe descriptions/details through sanitizers
  - __tests__/enterprise/audit-sanitize.test.ts: 16 tests, 100% cov
    incl. regression for the LicensedSeatConfigNullableScalarRelationFilter
    leak pattern from the bug report

Compliance flag gating (default off, returns 404 when unset):
  - ENABLE_IRP_UPLOADER on .github/workflows/irp-uploader.yml
    (workflow_dispatch still works for manual validation)
  - ENABLE_TDS_ADMIN_VIEW on app/api/admin/tds/route.ts (GET+POST)
  - ENABLE_HRIS on app/api/organizations/[orgId]/hris/{route,sync,csv-upload}

Razorpay prefill #717: reject mobile numbers with 10+ identical
consecutive digits (e.g. 9999999999 / +919999999999) before opening
checkout — Razorpay test mode rejects these with an opaque modal
otherwise. 17 new tests in __tests__/payments/razorpay-prefill.test.ts.

ENT-4: add PROGRAM_DELETED audit action; the program DELETE site was
reusing PROGRAM_PAUSED and conflating two state transitions for
audit consumers.

Docs:
  - PR-655-closeout-audit.md: validation deliverable with kept-vs-deferred
    matrix, route inventory, role-coverage table, verification commands
  - 34-workspace-preferences.md: OrgWorkspaceProfile preferences design
  - 35-system-events.md: SystemEvent vs OrgAuditLog channel separation
  - Enterprise-Simplification-Proposal.{md,docx}: phased plan for
    ~2,800 LoC of bloat removal under the schema-locked constraint

Verification: 61 jest suites / 990 tests pass; tsc --noEmit clean;
npm run lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- /my-program renders "Unlimited — no cap" instead of overage label when cap is null
- Checkout shows ₹0 with "Session value covered by enterprise license" for LICENSE bookings
- Contract create form captures LICENSE flat fee + cycle; hides NET-X for LICENSE
- /billing gets Annual License panel + LICENSE-aware empty state
- /home Get Started marks billing configured when BillingSubscription is present
- payment-terms validation skipped when LICENSE field hidden (Gemini review follow-up)

Closes #755
Closes #756

Co-Authored-By: Shubham Kumar <shubham79a>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ date filter)

- /home gets a "To reimburse (30d)" StatCard for fundingSource=PERSONAL orgs (MANAGER+), backed by a new payment.aggregate in /api/organizations/[orgId]/analytics
- /reimbursements gets From/To date Inputs that flow into the existing API ?from/?to params (and the CSV export link)

Closes #714

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

buildPaymentMetadata() now accepts an optional orgContext arg ({ organizationId, fundingSource }); both fields are spread into Razorpay notes when non-null. Non-org flows unchanged.

Audit / fraud-verification trail (#687) — gateway record now carries the org claim alongside the booking; PR-3 reconciler can cross-check vs Payment.organizationId.

Part of #687

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
teetangh and others added 6 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Enterprise Enterprise tier — B2B org features, Architecture 4 infrastructure Infrastructure, deployment, and DevOps

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants