Skip to content

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

Merged
teetangh merged 460 commits into
devfrom
feature/enterprise
Jun 11, 2026
Merged

Enterprise foundation — org CRUD, billing modes, SSO, dashboard#655
teetangh merged 460 commits into
devfrom
feature/enterprise

Conversation

@teetangh

@teetangh teetangh commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

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

@netlify

netlify Bot commented Apr 10, 2026

Copy link
Copy Markdown

Deploy Preview for familiarise ready!

Name Link
🔨 Latest commit 74b9cc6
🔍 Latest deploy log https://app.netlify.com/projects/familiarise/deploys/6a2a300d14786500086165cf
😎 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: 39 (🔴 down 19 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.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces 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

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 added a commit that referenced this pull request Apr 10, 2026
…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.

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

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

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

@teetangh

Copy link
Copy Markdown
Contributor Author

ChatGPT 5.4 codex feedback: payments / payouts / refunds / disputes round

I did another pass focused specifically on the financial state machine: checkout -> payment creation -> org billing -> refunds -> disputes -> payout surfaces, plus discounts / referral-credit interactions and whether the enterprise flows are actually reachable from the UI.

A few earlier problems are fixed now, but I still see five meaningful gaps before I would call the enterprise money flows complete.

  1. Manual org-funded refunds still do not execute the normal refund side effects.

app/api/payments/refunds/route.ts:181-279 now correctly routes ORG_CREDIT and ORG_INVOICED refunds without calling the gateway, but after marking the refund SUCCEEDED it immediately returns. Unlike the webhook path in app/api/webhooks/utils.ts, this route never calls refundEarnings() or reverseCreditsForPayment(). That means a manual refund for an org-funded booking can credit back the seat-pack pool or unbill the org invoice while still leaving consultant earnings and consumed referral-credit ledger entries untouched. Financially, the same booking becomes refunded for the org but still paid out / still credit-consumed elsewhere.

Proposed fix: extract the refund side effects into one shared helper used by both handleRefundCreated() and /api/payments/refunds, and run it immediately for synthetic refund modes (ORG_CREDIT, ORG_INVOICED, and any other non-webhook refund path).

  1. INVOICED_MONTHLY exposure and pending-charge math still use gross payment amounts and ignore successful refunds.

lib/payments/operations/checkout.ts:1603-1629 enforces orgInvoiceCreditLimit by summing unbilled ORG_INVOICED payment amounts plus outstanding invoices. app/api/organizations/[orgId]/billing/route.ts:53-64 uses the same gross aggregation for the dashboard’s pending charges. But invoice generation now correctly nets out successful refunds in app/api/organizations/[orgId]/billing/generate-invoice/route.ts, which means the system is using two different definitions of “what the org still owes.” A partially or fully refunded unbilled payment can still consume invoice-credit-limit headroom and appear in pending charges even though it will no longer be billed at that amount.

Proposed fix: centralize “net billable amount for unbilled org-invoiced payments” into a shared query/helper and use that same net value for invoice generation, credit-limit enforcement, and the billing summary cards.

  1. Personal referral credits can still subsidize organization-funded bookings.

In lib/payments/operations/checkout.ts:357-454, discounts and referral credits are calculated before the org-funded branch decides to skip the gateway. Then later, in lib/payments/operations/checkout.ts:1890-1905, applyCreditsToPayment() still runs even when the payment method becomes ORG_CREDIT or ORG_INVOICED. So today a user can spend their own referral credits to reduce the amount charged to their organization. That mixes a personal-wallet incentive with an employer-funded purchase and makes both reimbursement/accounting and reward economics murky.

Proposed fix: decide policy explicitly and encode it. If org-funded purchases should be pure org spend, block useReferralCredits whenever organizationId is present. If they are allowed, the UI and billing copy should make that tradeoff explicit and the org billing reports should surface the personal-credit subsidy separately.

  1. The backend org billing branches still look unreachable from the actual checkout UI.

The transport is there: schemas/checkout.ts:299-334 supports organizationId. But the checkout pages I checked still build payloads without it, e.g. app/checkout/plans/consultation/[planId]/page.tsx:302-315, app/checkout/plans/subscription/[planId]/page.tsx:240-249, app/checkout/plans/class/[planId]/page.tsx:239-248, and app/checkout/plans/webinar/[planId]/page.tsx:249-258. From a product-completion perspective, that means SEAT_PACK / INVOICED_MONTHLY / TAG_ONLY may exist on the server but still are not exercisable through the real booking journey for org members.

Proposed fix: add an org-aware checkout entry point or selector for eligible members, pass organizationId through the checkout payload, and make the UI explain which payer is being charged (personal card vs organization credits vs organization invoice). Until that exists, the PR description should avoid implying the org billing modes are complete end-to-end.

  1. Provider-org payouts are still a zombie finance surface.

app/dashboard/organization/[orgId]/layout.tsx:96-125 still shows the Payouts nav for provider/hybrid orgs, but app/api/organizations/[orgId]/payouts/route.ts:1-67 remains feature-flag gated and returns 501 when ENABLE_PROVIDER_ORGS is off. The page at app/dashboard/organization/[orgId]/payouts/page.tsx:32-80 is therefore mostly a branded holding page rather than a live payout workflow. That is okay as an internal scaffold, but it is still a zombie product surface if we are describing enterprise payouts as implemented.

Proposed fix: either hide the nav/page entirely while the flag is off, or reframe it as an explicit waitlist/contact-us screen with no implication that payout history or batch payouts are active.

Overall, the payment architecture is much closer, but the current weak spots are still around financial consistency across paths rather than schema shape. My biggest recommendation is to consolidate the money rules into shared helpers for:

  • refund side effects
  • net org-billable amount
  • payer/source-of-funds policy (personal vs organization)

That will keep checkout, billing summaries, invoice generation, refunds, and payouts from drifting apart again.

@teetangh

Copy link
Copy Markdown
Contributor Author

ChatGPT 5.4 codex feedback: merge-readiness / stop line

After all the review rounds so far, my current read is:

  • the branch is now a strong enterprise foundation
  • it is not yet "fully complete" end-to-end as a product
  • the remaining work is no longer broad architecture churn; it is mostly a short list of financial-consistency and reachability gaps

The earlier seat-cap finding appears fixed now: member add/reactivate, invitation acceptance, and learner seat transitions are all using atomic seat acquisition/release.

If we want a practical stop line instead of iterating forever, I would treat these as the real must-fix items before calling the branch correct:

  1. Manual org-funded refunds must run the same side effects as webhook refunds.
    app/api/payments/refunds/route.ts still credits/unbills org refunds without also reversing consultant earnings / referral-credit usage.

  2. INVOICED_MONTHLY net billable math should be unified.
    Checkout credit-limit enforcement and billing summary cards still use gross unbilled amounts, while invoice generation now nets out refunds.

  3. Decide and encode policy for personal referral credits on org-funded bookings.
    Right now a user can still burn personal credits to reduce an employer-funded booking.

  4. Either wire org checkout from the UI or de-scope the claim.
    The backend supports organizationId, but the checkout pages still do not appear to send it, so org billing modes are not clearly exercisable end-to-end.

  5. Get CI / deploy green.
    The PR still has failing checks, so regardless of feature scope it is not merge-ready yet.

Everything else I would classify as follow-up work, not reason to keep the branch open indefinitely:

  • provider-org payouts: currently still a flagged scaffold / holding page
  • additional reporting refinements
  • extra billing UX polish and messaging
  • future credit-note / post-invoice adjustment workflows

If you want a blunt assessment: this is now much closer to "mergeable enterprise foundation" than "unfinished experiment," but I still would not market it as fully complete enterprise billing / payouts without the four product-money fixes above plus green CI.

@teetangh teetangh self-assigned this Apr 10, 2026
@teetangh teetangh added enhancement New feature or request infrastructure Infrastructure, deployment, and DevOps labels Apr 10, 2026
@teetangh

Copy link
Copy Markdown
Contributor Author

ChatGPT 5.4 codex feedback: near-final merge-readiness pass

This branch is much closer now. A lot of the earlier high-risk issues are genuinely fixed, CI is green, and I think the remaining list is finally short enough to be actionable instead of open-ended.

I only see a handful of issues left, and I’d split them into two buckets: actual correctness gaps vs feature-completion / scope-claim gaps.

Correctness gaps

  1. The manual refund path still mishandles side-effect ordering for partial refunds, and it drops forceRefund on paid-out earnings.

In app/api/payments/refunds/route.ts:261-286, side effects run before the refund row is updated from PENDING to SUCCEEDED in :288-294. That matters because reverseCreditsForPayment() computes cumulative restoration from SUCCEEDED refunds only. The first partial refund works because the helper falls back to the current refund amount when no prior succeeded refunds exist, but the second and later partial refunds will under-restore credits because the current refund is still invisible to the cumulative query.

There is also a second issue in the same block: forceRefund is validated up front, but refundEarnings() is called with only { refundAmount, paymentAmount } and not { forceRefund }. So an admin can explicitly force a refund on a payment whose earnings are already PAID, the refund itself will succeed, but the consultant earnings reversal path can still silently no-op because refundEarnings() requires forceRefund: true for paid-out earnings.

Proposed fix: move Phase 3 (refund.status = SUCCEEDED) ahead of the side-effect helpers, and pass forceRefund through to refundEarnings(). That will align the manual route with the assumptions already encoded in the webhook helper.

  1. ORG_INVOICED refunds still leave already-issued invoice totals stale.

app/api/payments/refunds/route.ts:214-225 still just nulls billableToOrgInvoiceId on the payment. The inline TODO is correct: once the payment was already part of a sent/paid invoice, the invoice amount, items, and financial meaning drift away from reality. That is no longer a vague future concern — now that the rest of org billing is tighter, this is one of the main remaining ledger-consistency holes.

If you do not want to build a full credit-note system in this PR, I would at least make the scope explicit in the PR description and docs: refunds are financially correct for unbilled org charges, but post-invoice adjustments still require manual reconciliation.

Feature-completion / scope-claim gaps

  1. SSO is still scaffolded rather than production-ready end-to-end.

There are still three unresolved seams here:

  • app/api/organizations/[orgId]/sso/providers/route.ts:12-21 explicitly documents that provider config is being written in a shape BetterAuth may not actually consume at runtime.
  • lib/auth.ts:227-232 enables sso() with no visible sync hook that creates the app’s required OrganizationMemberProfile, even though customSession() and requireOrgAccess() still depend on that typed sibling table.
  • app/api/auth/sso/domain-check/route.ts:59-69 still returns a guessed sign-in URL contract behind a TODO.

On the UI side, app/dashboard/organization/[orgId]/settings/sso/page.tsx now explicitly says “SAML supported; OIDC coming soon,” which is good and more honest than earlier rounds. But the PR description still says “Full SSO admin UI” and talks about per-org SAML/OIDC provider config as if the sign-in path is already verified. I would either finish the contract or narrow the claim to “SSO policy + SAML-oriented admin scaffolding.”

  1. The org billing modes still don’t look reachable from the real checkout UI.

The backend supports organizationId in schemas/checkout.ts, but the actual checkout page payloads I checked still don’t send it:

  • app/checkout/plans/consultation/[planId]/page.tsx:302-315
  • app/checkout/plans/subscription/[planId]/page.tsx:240-249
  • app/checkout/plans/class/[planId]/page.tsx:239-248
  • app/checkout/plans/webinar/[planId]/page.tsx:249-258

That means TAG_ONLY / SEAT_PACK / INVOICED_MONTHLY may be implemented server-side, but the real product flow still doesn’t clearly expose “book as yourself” vs “book through organization.” From a PM/UI perspective, this is still the biggest gap between “backend supports it” and “feature is complete.”

  1. Self-serve billing is still only partially real.

The core reporting and invoice generation surfaces exist, but the two money-in actions are still stubs:

  • app/api/organizations/[orgId]/credits/purchase/route.ts:1-69 still returns pendingPhaseJ
  • app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts:1-65 still returns pendingPhaseK

The UI is more honest now because those actions are disabled / marked “coming soon,” which is much better than before. But the PR description still reads like all three BUYER billing modes are complete as product flows. I’d scope that down unless you wire at least one real top-up / invoice-settlement path.

  1. The create-org wizard still leaves behind draft-like orgs when the user cancels midway.

app/dashboard/organization/create/page.tsx:44-80 still creates the organization on step 1, and :125-132 still lets the user cancel back to the dashboard without cleanup. That means abandoned setup attempts can leave org rows (and possibly uploaded branding assets) behind even though the user never “launches.” Not a merge blocker, but still a product/data cleanliness issue worth deciding intentionally.

My practical stop line

If you want the shortest path to “good enough to merge,” I’d treat these as the true pre-merge items:

  • refund side-effect ordering / forceRefund propagation
  • honest scoping around SSO and org checkout reachability, unless you actually wire them end-to-end
  • explicit scope note for post-invoice refund reconciliation

If those are addressed, I think the branch is finally in the range where the remaining work can move to follow-up issues instead of more PR churn.

@teetangh

Copy link
Copy Markdown
Contributor Author

ChatGPT 5.4 codex feedback: recent commits diff (post-08c352ff)

I reviewed only the newer commit band after 08c352ff — mainly the org payer selector, real Razorpay org-payment flows, SSO end-to-end wiring, role-guarded dashboard work, invitation emails, and the new inline edit/composer UX.

Progress is genuinely strong here. The branch is much closer now, and I think the remaining list is finally short. I only see two correctness issues I’d still treat as important before merge, plus one smaller reconciliation/audit gap.

  1. Credit-pack purchases are still not idempotent and can double-credit the org.

app/api/webhooks/razorpay/route.ts:181-207 now routes BOTH payment.captured and order.paid org-payment events into handleOrgPaymentSuccess(). But app/api/webhooks/utils.ts:27-46 handles credit_purchase by simply loading the OrgCreditPurchase row and calling purchaseCredits() every time. lib/payments/operations/org-credits.ts:106-139 is not idempotent — it increments both OrgCreditPool.balance and totalPurchased and writes a fresh ledger row on every call.

That means a single successful Razorpay purchase can credit the pool twice if both webhook events are delivered, which Razorpay commonly does. The problem is amplified by app/api/organizations/[orgId]/credits/purchase/route.ts:53-77: the purchase row is created before gateway completion, but OrgCreditPurchase in prisma/schema.prisma:842-856 has no status field, no provider order/payment ID, and no processed marker that the webhook can atomically claim.

Proposed fix: make credit purchases settle exactly once. The cleanest version is to add webhook-owned settlement state on OrgCreditPurchase (for example status, providerOrderId, providerPaymentId, processedAt) and flip it from pending -> confirmed inside the same transaction that calls purchaseCredits(). Then make the webhook no-op if the purchase is already confirmed. Without that, this path is still financially unsafe.

  1. The SSO membership auto-repair hook creates incomplete learner memberships and bypasses seat accounting.

lib/auth.ts:249-291 now auto-creates OrganizationMemberProfile rows for BetterAuth members that were auto-provisioned by SSO. That fixes basic access, but the created profile only gets { memberId, organizationProfileId, role, status }.

For ORG_LEARNER, that misses the same side effects the normal membership flows enforce elsewhere:

  • no acquireSeat() call, so seat budgets can be bypassed silently
  • no seatAssignedAt, so learner-seat analytics are wrong
  • no consulteeProfileId, so the typed learner/member linkage is incomplete

So an org using SSO auto-join with default role ORG_LEARNER can still end up with active learner memberships that do not consume seats and do not look like normal learner rows.

Proposed fix: move this repair into a transaction that mirrors the standard org-member creation rules. At minimum, when the repaired role is ORG_LEARNER, it should acquire a seat transactionally and populate consulteeProfileId + seatAssignedAt; otherwise fail closed instead of creating a half-valid learner membership.

  1. Smaller gap: invoice payments are now “real” from the UI, but the settlement path still drops most gateway audit detail.

app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts:48-63 creates a Razorpay order, and app/api/webhooks/utils.ts:47-57 marks the invoice PAID, but nothing links that settlement back to the invoice’s paymentId field in prisma/schema.prisma:761-763, and no provider order/payment identifier is persisted on the invoice itself.

This is not as urgent as the double-credit bug above, but it does mean invoice settlements are harder to reconcile, trace, or refund later. If you do not want to model a full Payment row here, at least storing the Razorpay order/payment identifiers on the invoice would help support and finance operations.

Overall: this was a good batch of commits. The org payer selector, real Razorpay top-up/payment flows, SSO config normalization, role-aware dashboard gating, and invitation-email path all move the branch materially closer to merge readiness. If you fix the two correctness issues above, I think the branch is finally in “ship with a short follow-up list” territory rather than “keep iterating forever.”

@teetangh teetangh requested a review from Copilot April 11, 2026 00:46
@teetangh

Copy link
Copy Markdown
Contributor Author

/gemini review

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the foundation for enterprise organizations, supporting three billing modes (TAG_ONLY, SEAT_PACK, and INVOICED_MONTHLY), a dedicated organization dashboard, and SSO integration. The implementation includes atomic seat management, manual invoice generation, and a credit pool system. Feedback identifies a security vulnerability in the stubbed bank account storage and potential race conditions in invoice generation and last-owner validation. Reviewers also suggested improving financial reconciliation by utilizing actual gateway IDs in webhooks and recommended centralizing duplicated role ranking logic to improve maintainability.

Comment thread app/api/organizations/[orgId]/payout-account/route.ts Outdated
Comment thread app/api/organizations/[orgId]/billing/generate-invoice/route.ts Outdated
Comment thread app/api/organizations/[orgId]/members/[memberId]/route.ts Outdated
Comment thread app/api/webhooks/razorpay/route.ts Outdated
Comment thread app/api/webhooks/utils.ts
Comment thread app/api/webhooks/utils.ts Outdated
Comment thread app/dashboard/organization/[orgId]/layout.tsx Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces an “enterprise” foundation across the app: organizations (CRUD + members + invitations), three org billing modes (tag-only / seat pack credits / invoiced monthly), SSO (BetterAuth SSO), and a new org dashboard experience (OrgSwitcher + role-gated org pages). It also extends checkout/refunds to support optional org-funded payments and adds seed + docs for the new system.

Changes:

  • Add org-aware onboarding + dashboard UX (OrgSwitcher, org dashboard routes/pages, invitation acceptance flow).
  • Add enterprise backend APIs (org members/invites/seats, plans, credits, billing/invoices, SSO provider + settings).
  • Extend payments/checkout/refunds/webhooks to support org billing context (org payer selector + org credit/invoice flows).

Reviewed changes

Copilot reviewed 97 out of 99 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
utils/onboarding.ts Add ORG_ADMIN onboarding schema + transforms
types/razorpay.d.ts Shared global Razorpay TS types
schemas/checkout.ts Add optional organizationId to checkout payload
prisma/seedFiles/config.ts Seed volume config for org phase
prisma/seed.ts Run Phase 15 org seeding
package.json Upgrade BetterAuth + add SSO + OTel API
middleware.ts Treat /api/organizations/* as private APIs
lib/payments/operations/org-credits.ts Seat-pack credit pool operations (deduct/refund/purchase)
lib/feature-flags.ts Introduce ENABLE_PROVIDER_ORGS flag
lib/errors/classification/payment-error-classification.ts Classify “insufficient credits” as business error
lib/email.ts Add org invitation email sender
lib/auth.ts Enable BetterAuth organization + SSO plugins; add session membership sync
lib/auth-helpers.ts Add requireOrgAccess + org role ranking helpers
lib/api/organizations/seat-helpers.ts Atomic seat acquire/release helpers
emails/organizations/OrgInvitationEmail.tsx New org invite email template
docs/enterprise/decisions/2026-04-10-pr2-enterprise-foundation.md ADR documenting enterprise design decisions
components/dashboard/OrganizationSwitcher.tsx OrgSwitcher dropdown component
components/dashboard/DashboardNavbar.tsx Render OrgSwitcher in dashboard navbar
components/dashboard/CollapsibleSidebar.tsx Add optional bottom “personal user chip”
app/organizations/invite/[token]/page.tsx Public invitation acceptance page
app/form/onboarding/page.tsx Add ORG_ADMIN flow + invite/callback bridging
app/form/onboarding/components/PersonalInfoAndRoleForm.tsx Add “Organization Owner” role option
app/form/onboarding/components/OrgAdminReviewStep.tsx ORG_ADMIN review + launch step
app/dashboard/staff/[staffId]/layout.tsx Add OrgSwitcher to staff layout
app/dashboard/page.tsx Route ORG_ADMIN users into org dashboard
app/dashboard/organization/page.tsx Org landing/list page
app/dashboard/organization/create/types.ts Org creation wizard types
app/dashboard/organization/create/schemas.ts Org creation wizard zod schemas
app/dashboard/organization/create/page.tsx Multi-step org creation wizard
app/dashboard/organization/create/components/OrgInfoStep.tsx Wizard step: org info
app/dashboard/organization/create/components/InviteTeamStep.tsx Wizard step: invite team parsing + role
app/dashboard/organization/create/components/BillingStep.tsx Wizard step: billing mode + seats
app/dashboard/organization/[orgId]/useOrgRole.ts Client role fetching + UI role gating hooks
app/dashboard/organization/[orgId]/payouts/page.tsx Provider-gated payouts page
app/dashboard/organization/[orgId]/page.tsx Redirect bare org route to /home
app/dashboard/organization/[orgId]/learners/page.tsx Learners listing page
app/dashboard/organization/[orgId]/layout.tsx Org dashboard layout + role-gated sidebar
app/dashboard/organization/[orgId]/home/page.tsx Org overview stat cards
app/dashboard/organization/[orgId]/consultants/page.tsx Provider-gated consultants page
app/dashboard/organization/[orgId]/analytics/page.tsx Role-gated analytics page
app/dashboard/consultee/[consulteeId]/layout.tsx Add OrgSwitcher to consultee layout
app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForWebinar.tsx Add organizationProfileId: null to webinar plan payload
app/dashboard/consultant/[consultantId]/(features)/planner/components/EventPlannerForClass.tsx Add organizationProfileId: null to class plan payload
app/dashboard/admin/layout.tsx Add OrgSwitcher to admin layout
app/checkout/plans/webinar/[planId]/page.tsx Add OrgPayerSelector + org context in checkout request
app/checkout/plans/subscription/[planId]/page.tsx Add OrgPayerSelector + org context in checkout request
app/checkout/plans/consultation/[planId]/page.tsx Add OrgPayerSelector + org context in checkout request
app/checkout/plans/class/[planId]/page.tsx Add OrgPayerSelector + org context in checkout request
app/checkout/components/OrgPayerSelector.tsx New payer selector UI for org vs personal payment
app/auth/signup/page.tsx Preserve callbackUrl through signup → onboarding; friendlier errors
app/auth/signin/page.tsx Friendlier auth error mapping
app/api/webhooks/utils.ts Add org-specific webhook success handler
app/api/webhooks/razorpay/route.ts Route org payments to org webhook handler
app/api/payments/refunds/route.ts Route refunds by org billing method + run org side effects
app/api/organizations/invitations/accept/route.ts Accept invitation endpoint + seat enforcement
app/api/organizations/[orgId]/sso/route.ts Org SSO settings + provider listing
app/api/organizations/[orgId]/sso/providers/route.ts Provider CRUD (create/list) for SAML/OIDC
app/api/organizations/[orgId]/sso/providers/[providerId]/route.ts Provider delete endpoint
app/api/organizations/[orgId]/settings/route.ts Settings alias endpoint
app/api/organizations/[orgId]/plans/route.ts Org plan list/create
app/api/organizations/[orgId]/plans/[planId]/route.ts Org plan get/update/soft-delete
app/api/organizations/[orgId]/payouts/route.ts Provider-gated payouts API
app/api/organizations/[orgId]/payout-account/route.ts Provider-gated payout account API
app/api/organizations/[orgId]/members/route.ts Member list + add-by-email with seat enforcement
app/api/organizations/[orgId]/members/[memberId]/route.ts Member patch/delete with last-owner + seat transitions
app/api/organizations/[orgId]/learners/route.ts Learners list endpoint
app/api/organizations/[orgId]/invitations/route.ts Invitation list/create + email dispatch
app/api/organizations/[orgId]/invitations/[invitationId]/route.ts Invitation revoke
app/api/organizations/[orgId]/images/route.ts Org logo/banner upload to Supabase
app/api/organizations/[orgId]/credits/route.ts Credit pool + ledger summary
app/api/organizations/[orgId]/credits/purchases/route.ts Credit purchase history
app/api/organizations/[orgId]/credits/purchase/route.ts Initiate credit purchase (Razorpay order)
app/api/organizations/[orgId]/consultants/route.ts Provider-gated consultants API
app/api/organizations/[orgId]/billing/route.ts Billing summary aggregates
app/api/organizations/[orgId]/billing/invoices/route.ts Invoice list + manual invoice create
app/api/organizations/[orgId]/billing/invoices/[invoiceId]/pay/route.ts Initiate invoice payment (Razorpay order)
app/api/organizations/[orgId]/billing/generate-invoice/route.ts Manual invoiced-monthly rollup generator
app/api/organizations/[orgId]/analytics/route.ts Analytics aggregates
app/api/auth/sso/domain-check/route.ts Public SSO domain-check endpoint
actions/forms/onboarding.action.ts Return orgId from onboarding action
.npmrc Set legacy-peer-deps=true

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/payments/operations/org-credits.ts Outdated
Comment thread lib/payments/operations/org-credits.ts Outdated
Comment thread lib/payments/operations/org-credits.ts Outdated
Comment thread app/api/webhooks/utils.ts Outdated
Comment thread app/api/organizations/[orgId]/payout-account/route.ts Outdated
Comment thread app/api/organizations/[orgId]/billing/route.ts Outdated
Comment thread app/api/organizations/[orgId]/billing/invoices/route.ts Outdated
Comment thread app/api/organizations/[orgId]/billing/generate-invoice/route.ts Outdated
Comment thread app/dashboard/organization/[orgId]/useOrgRole.ts Outdated
Comment thread .npmrc
@teetangh

Copy link
Copy Markdown
Contributor Author

Edge case hardening — enterprise PR audit fixes

Reviewed and patched by Claude Sonnet 4.6 (claude-sonnet-4-6). All changes are on this branch in commit 8d77372f.


🔴 Critical

# File Fix
1 catalog/route.ts IDOR — POST now verifies organizationProfileId IS NULL before linking a plan (prevents an ORG_ADMIN stealing a plan already owned by another org); DELETE verifies the plan belongs to this org before unlinking
2 [orgId]/route.ts seatsTotal PATCH rejected when new value < current seatsUsed — previously would create an impossible over-capacity state
3 members/route.ts + invitations/accept/route.ts + members/[memberId]/route.ts 422 returned when adding/promoting to ORG_LEARNER with no consulteeProfileId, or ORG_CONSULTANT with no consultantProfileId — previously stored a null FK silently

🟡 Medium

# File Fix
4 plans/[planId]/route.ts PATCH and DELETE now wrap findFirst + update in $transaction — closes the TOCTOU window between ownership check and mutation
5 sso/route.ts Added RFC-1123 domain regex to Zod schema; cross-org uniqueness check before upsert returns 409 if another org already claims a submitted domain
6 [orgId]/route.ts orgInvoiceCreditLimit schema changed from .nonnegative().positive() — setting to 0 previously hard-blocked all INVOICED_MONTHLY checkouts
7 credits/purchase/route.ts Removed arbitrary ₹5L per-purchase cap — enterprise orgs legitimately purchase large credit packs; gateway enforces its own transaction limits
8 invitations/accept/route.ts Profile-link guard applied here too (was missing from the invite flow, only existed in the direct-add flow)

🟢 New: Audit log

  • Added OrgAuditLog model + OrgAuditAction enum (MEMBER_ADDED, MEMBER_REMOVED, ROLE_CHANGE, STATUS_CHANGE) to schema.prisma
  • Each action includes a description String (human-readable) and details Json? (structured, queryable)
  • Audit entries written inside the transaction in: members POST, members PATCH, members DELETE, invitations accept
  • Follows the same description + metadata pattern as the existing ActivityLog model

🟢 New: Orphaned credit purchase cleanup

Purchases with paymentId IS NULL older than 24h are silently abandoned when a user drops off mid-checkout. New cron job follows the full 3-layer pattern:

Layer File
Core logic scripts/enterprise/cleanup-orphaned-org-credit-purchases.ts
GH Actions wrapper jobs/enterprise/cleanup-orphaned-org-credit-purchases.ts
HTTP endpoint app/api/cleanup/orphaned-org-credit-purchases/route.ts
Scheduler .github/workflows/cleanup-orphaned-org-credit-purchases.yml (daily 02:00 UTC)

🟢 Seed (15a-create-organizations.ts)

  • OrgCreditPurchase — 1–3 confirmed purchase records seeded per SEAT_PACK org
  • OrgAuditLogMEMBER_ADDED entry seeded for each org owner
  • Seat cap guard — ORG_LEARNER additions downgraded to ORG_MANAGER when seatsTotal would be exceeded (prevents the new DB CHECK constraint from failing on seed)

⏳ Pending (requires manual action)

Schema has drifted (ORG_ADMIN variant in UserRole is in the DB but not in migration history). Needs:

npx prisma migrate reset   # wipes dev DB — confirm before running
npm run db:seed

teetangh and others added 29 commits June 10, 2026 16:34
Payment gains clientIdempotencyKey @unique; every checkout surface mints
one stable key per mount (the wallet top-up pattern, ported down to the
hottest money endpoint). The route replays the original response for a
duplicate — SUCCEEDED returns the completed booking, PENDING returns the
same gateway order for resume, terminal states 409 so the client mints a
fresh key. Two concurrent identical requests that both miss the replay
lookup race to the unique constraint; the loser's P2002 is caught and the
winner's response replayed. Scoped to the caller's userId so a guessed key
can't read someone else's payment.

Closes #828

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…loses #827)

Checkout's hard-overlap check only blocks against isTentative:false slots
and its tentative dedup is same-user-only, so two different users could
both pay for overlapping consultant slots and both get confirmed. The
confirmation path for the exclusive booking types (consultation /
subscription) now rechecks, inside the confirming tx, for an already-
confirmed overlapping slot sharing a non-booker participant (the
consultant). The loser stays tentative and surfaces a PAYMENT SystemEvent
for refund (#830's orphan sweep is the automated follow-up). Capacity-based
types (webinar/class) are deliberately not exclusive and skip the check.

Closes #827

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

Prod env defines RAZORPAY_SECRET (the name lib/payments/core/razorpay.ts
reads); the four payout/payment reconciliation scripts read only
RAZORPAY_KEY_SECRET and silently skipped — every reconciliation safety net
was OFF in production while appearing green. Canonical name first, legacy
as fallback. (#677 PM-2's Date.now() idempotency key was already fixed.)

Part of #677

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The eight-scenario pre-launch chaos suite (go/no-go gate for #837) and the
1k/10k/100k concurrent-user capacity ladder grounding the ADR 13 no-new-
infra posture in numbers: cron-vs-pooler contention breaks first at 1k
(already seen at zero load — #821/#814/#709), Netlify's 125-concurrent-
function ceiling at 10k, and the whole composition at 100k (the planned
migration, not a pre-build).

Part of #837

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

The ERASED tombstone (DPDP §12) is terminal, but the erasure pipeline —
the only ERASED writer — left ACTIVE/PAUSED assignments counting against
program caps. Same cascade as member removal, status-guarded. (Gemini
flagged the gap on the members PATCH route, where ERASED is unreachable
by Zod; this is the real site.) Part of #812 review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The billableToOrgInvoiceId check moves from a pre-read into the parent
UPDATE's WHERE clause (re-evaluated under the row lock), so an invoice
rollup stamping the parent between read and write yields count 0 →
'invoiced' instead of silently diverging the leg from the issued
document. Recarve writes parent-first; a leg missing the restored base on
an uninvoiced parent is an invariant breach and throws to roll the tx
back rather than half-applying. Per Gemini review on #826.

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

 Two real bugs surfaced when verifying the prior commit (16cc955):

  1. Self-row detection compared `session.user.id` with `MemberRow.user.id`, but BetterAuth's `session.user.id` is its internal id, not the Familiarise `User.id` mirrored on the MemberRow. The equality check silently always-failed, so self-delete UI guard never fired — the trash icon stayed     clickable on your own row and the Remove dialog opened before the server's 403 came back. Switched to case-insensitive email comparison via an `isOwnRow(m)` helper. Email is invariant across both stores (auth flow enforces it as the verified, unique identity key) and is set on both `session.user.email` and MemberRow.user.email`. All three call sites (trash button, role dropdown disable, helper-text render) now route through the helper so they stay in sync.

  2. The `title` tooltips on disabled trash buttons never showed  — browsers don't dispatch mouseover events on disabled  `<button>` elements, so the attribute is silently dropped. Wrapped the button in an inline-flex `<span title=...>` so the tooltip lands on the span (which receives hover regardless) instead of the disabled button. Applies to both the "You cannot remove yourself" and "Only an OWNER can remove an OWNER" tooltips.

  Server-side guard (members/[memberId]/route.ts) was already correct — the inline red error in the Remove dialog via `removeError` was the backstop that caught the UI miss and proved the server gate worked end-to-end. This commit restores the front-line UI guard so doomed clicks never even open the confirm dialog.
…guard, lazy key init

Serializable + P2034 retry on the payment-success Phase-1 tx: two
concurrent capture webhooks for overlapping slots both passed the #827
conflict findFirst at READ COMMITTED (each saw the other's slots still
tentative); under SSI one aborts and the retry sees the winner confirmed.
The SUCCEEDED early-return keeps retries idempotent.

Stripe stores its hosted checkout URL in client_secret, which we don't
persist — a Stripe PENDING replay now falls through to the fresh-key 409
instead of returning a null secret the client can't redirect with.

Idempotency keys move from useRef(mint()) (initializer expression runs
every render) to useState's lazy initializer (runs once per mount).

Per Gemini review on #838.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Enterprise state-machine and concurrency hardening: CAS transitions, optimistic locking, Serializable org PATCH, cron locks, webhook dead-letter
Surfaced when an OWNER tried to promote MAINTAINER to BILLING_ADMIN via the dashboard and got an opaque "Invalid body" 400 toast.

  Two problems chained:
  1. Server: `PATCH /api/organizations/[orgId]/members/[memberId]` had its own local `MemberRoleSchema` Zod enum at L30-37 that listed only six of the seven `MemberRole` values — **BILLING_ADMIN was missing**.
     This drifted from:
       - the Prisma `MemberRole` enum (7 values),
       - `lib/labels/org-labels.ts:MemberRoleSchema` (the shared schema, 7 values),
       - `SelfServiceMemberRoleSchema` (which DID include
  BILLING_ADMIN — so you could INVITE someone as BILLING_ADMIN via POST /members but not promote an existing member to it via PATCH).
     The documented role policy (complete-guide.md §9.2 / §9.3 + `lib/enterprise/role-transitions.ts:11-14`) blocks only LEARNER↔EXPERT, OWNER-touch-by-non-OWNER, and self-role-change —  MAINTAINER ↔ BILLING_ADMIN is explicitly allowed. The schema drop was a code bug, not a policy choice.
     Fix: add BILLING_ADMIN to the local enum + a comment warning that this list MUST stay in sync with the Prisma enum + shared schema. Long-term, the cleanest fix is to import the shared schema; kept local-with-warning for minimum diff in this commit.

  2. Client: `addMember` and `updateMember` in`MembersPageClient.tsx` rendered the server's literal "Invalid body" string as the toast on any 400. Users had no way to
tell which field tripped the validation, and the server's        `detail.fieldErrors` payload (containing exactly that info)
  was ignored.
     Fix: before falling back to the generic humanizer, inspect `body.detail.fieldErrors` and if any field has an error, surface "Couldn't save changes — invalid {field-list}. Refresh the page and try again, or contact support if this persists." Field names make the message actionable; the refresh hint covers the most common cause (stale role list / stale schema on the client after a deploy).

  Both fixes ship together because they're two layers of the same incident: the server gap caused the failure, the client gap made it opaque. The server fix removes this specific symptom; the client fix protects against any future Zod schema drift surfacing with the same opaque toast.
Money correctness (PR-B, stacked): overage basePaise restore/recarve + GST gross-up test coverage
B2C hardening (PR-C, stacked): slot mis-binding, double-booking guard, checkout idempotency, reconciliation env fix
…delete race

Regression pins for fixes that shipped in #825/#838 while their issues
stayed open: the #788 same-source merge guard, the #827 first-confirmed-
wins recheck (loser stays tentative + PAYMENT SystemEvent), and the #828
idempotent replay semantics (PENDING resume, SUCCEEDED short-circuit,
terminal 409, Stripe non-resume, user scoping). confirmExistingAppointment
is exported for test reach; the replay helper moves to
lib/payments/operations/checkout-replay.ts (a non-route module — Next
route files may only export HTTP methods).

The one code fix: cleanup-tentative-slots deleted by id only, so a slot
confirmed between the scan and the delete was destroyed (a paid booking
with no slot). The delete now re-states isTentative + no-SUCCEEDED-payment,
re-evaluated under the row lock.

Closes #788
Closes #827
Closes #828
Closes #829

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The canonical glossary (availability window → bookable slot → booked slot
→ appointment → engagement → meeting; 'session' reserved for nothing)
lands in 00-foundations, including the verdict that 'enterprise
referrals' is not a feature — the phrase conflated trial attribution,
the PERSONAL-funding credit rule, and B2C consultant qualification.

Terminology fixes verified by the domain audit: the dead meetingRoom
include flag (field doesn't exist on Appointment), the
sessionsConsumed → engagementsConsumed rename across the overage family
(engagement = the enterprise billing meter, per glossary), and the stale
refund TODO rewritten to point at the real webhook-path credit
restoration. knip.json checked in so future scans share the entry config.

Part of #837.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ipts, orphaned hooks)

Every deletion was double-verified: knip (configured with jobs/scripts/
emails/seed entries) flagged candidates, then a 10-agent pass checked each
for dynamic imports, string references, workflow invocations, barrel
chains, and framework conventions before sentencing. The verification
saved 45 false positives — including the entire race-condition test suite
(dynamically discovered by its master-runner) and a FeedbackPage default
import the agents missed but the build gate caught. lib/compliance/form15
spared deliberately: a documented stub awaiting CA sign-off, not zombie
code. Git history remains the recovery path for everything deleted.

Gates: tsc clean, 1298/1298 jest, next build green. Part of #837.

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

The agent pass applied per-symbol judgment the scanner can't: types used
in-file were DE-exported rather than deleted, duplicate named/default
exports were resolved by checking how every consumer actually imports
(catching one verifier inversion where the default import was the live
one), barrel re-export chains were unwound in dependency order, and
anything ambiguous was skipped and reported. The z.infer aliases whose
same-named consumers actually resolve to @prisma/client were the largest
single source of confusion deleted.

Gates: tsc clean, 1298/1298 jest, next build green. Part of #837.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rlap-guard column, policy snapshot (#503/#753/#440)

Every column here is now-or-never under the #781 freeze; implementation
may lag, columns may not.

#503 — SlotOfAvailabilityWeekly gains timezone (IANA) + localStartMinutes/
localEndMinutes: the DST-proof source of truth next to the frozen
utcOffsetMinutes that breaks for any DST zone half the year. Both weekly
write paths populate the new columns; the slot math migrates read-side in
the follow-up, then the offset retires.

#753 option B — creditsPerCycle → engagementsPerCycle across schema, API,
dashboard, seeds, tests, and 11 docs: the meter counts engagements
(bookings), never money; 'credits' advertised a paise-denominated meter
the code never implemented. Closes the schema/code lie before freeze.

#440 — SlotOfAppointment gains denormalized consultantProfileId (+ index),
populated by both slot-creation paths; the btree_gist exclusion constraint
itself needs raw SQL and lands with prisma-migrate adoption.

B1 decision — Appointment.cancellationPolicySnapshot Json?: tiered refund
windows resolved at booking so later policy edits never retroactively
change a buyer's terms. Written+read by the cancel flow (PR-4).

RESCHEDULED — keep + wire decision recorded on the enum; PR-4 stamps it.

db push deferred (Supabase disconnected); prisma generate done; tsc clean;
1298/1298 tests.

Closes #753
Part of #503
Part of #440

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…8/B11/B13)

B1 — cancellation finally moves money. Checkout freezes tiered refund
terms onto Appointment.cancellationPolicySnapshot (24h+=100%, 2-24h=50%,
<2h=0%, consultant-initiated always 100%); the cancel route computes the
percentage from the BOOKING-TIME snapshot and drives refundPayment (the
canonical Serializable orchestrator). A failed refund surfaces instead of
silently shipping a cancel-without-money. Org policy prose rides the
snapshot for the support trail; structured per-org tiers are post-launch.

B2 — cancel transitions are CAS-guarded (cancellable from PENDING/
APPROVED/APPROVED_PENDING_PAYMENT/SCHEDULED only; a double-cancel 409s
before re-running refunds), slots only flip from SCHEDULED so history is
never re-stamped, and the webhook's confirmApprovalStatus no longer
resurrects a CANCELLED booking from a late capture — that capture now
raises CAPTURE_AFTER_TERMINAL_STATE for the refund path.

B8 — reallocation never hard-deletes an appointment with Payment rows
(onDelete: Cascade was silently destroying the payment audit trail).
B11 — class enrollment rejects partially-scheduled classes instead of
silently linking the buyer to fewer sessions than they paid for.
B13 — slot-level consultant soft-deletion recheck closes the plan-fetch/
slot-validate race.

Part of #837.

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

Reschedule stamps replaced slots completionStatus=RESCHEDULED (the
keep-and-wire decision — the lifecycle is no longer inferred from the
overloaded isTentative boolean), CAS-guards its status resets so a
reschedule racing a cancel/completion cannot resurrect the booking (409
NOT_RESCHEDULABLE), writes the activity-log entry cancel always had
(new additive APPOINTMENT_RESCHEDULED activity type), and stops
notifying the initiator about their own reschedule.

Closes #830: reconcile-orphaned-confirmations finds SUCCEEDED payments
whose slots are still tentative (crash between capture and confirmation)
and re-drives confirmExistingAppointment under the webhook's own
Serializable + retry discipline. The #827 first-confirmed-wins guard
stays in force — a genuine double-booking loser is reported for the
refund path, never force-confirmed. Wired as a fail-closed locked core +
GH Actions wrapper + 30-minute workflow per ADR 05.

Test harnesses migrated to the CAS shapes (updateMany with guards).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pin landed slot/session fixes (PR-1 of 4): regression tests + cleanup delete-race guard
…y-sweep

Dead-code and terminology sweep (PR-2 of 4): glossary, 63 file deletions, 309 symbol removals
Schema-freeze batch (PR-3 of 4): TZID columns, engagement rename, overlap-guard column, refund-policy snapshot
…ectness

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@teetangh teetangh merged commit 30aee87 into dev Jun 11, 2026
7 checks passed
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