Skip to content

B2B

Generated from a canonical source

This page is a read-only projection of docs/handoff-corpus/b2b.md. Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.

What b2b is for

The invariant you must not break: a B2B company's subscriptions, MRR figures, and change-requests must never be visible to, or actionable by, a different company, or by a customer who is not a recorded actor on that subscription. Every B2B admin route scopes by store_hash first; b2b-company-mrr.ts's rollup partitions strictly on company_id/ parent_company_id equality; b2b-org-admin.ts verifies actor access via subscription_actors WHERE customer_id = JWT.customer_id AND role = 'org_admin' before any list/update/cancel action. If this breaks, a company sees or bills against another company's revenue, or a buyer-org member acts on a subscription outside their own org — a direct multi-tenant data-isolation failure (ADR-0009; b2b-org-admin.ts header comment "Anti-IDOR").

B2B breaks into seven reader-facing features, each anchored to its build story for traceability:

  • CS rep enrolls a B2B buyer with a card on file — a support rep impersonates the buyer-org in the merchant admin and creates a company-tagged subscription against the buyer-org's stored payment method (US-24.9 / US-22.9)
  • CS rep enrolls a B2B buyer on purchase order / net terms — the PO number is the payment method; no card required (US-24.4)
  • Company-scoped subscriptions in the buyer portal — a B2B buyer sees a company-name badge distinguishing company-managed subscriptions from personal ones (US-24.1)
  • Change requests route to a company approver — a buyer's pause/cancel/ quantity-change request enters a pending state until an admin approves or rejects it (US-24.3)
  • Company-hierarchy MRR rollup — a merchant admin sees recurring revenue rolled up across a parent company and its child companies (US-24.5)
  • Contract-term cancellation guard — a subscription with a committed term blocks or routes-to-legal a cancel attempted inside the contractual notice window (US-24.7)
  • org_admin acts on behalf of org members — a designated org_admin actor can list, reassign payment on, and cancel subscriptions belonging to other company members without being the subscription's owner (US-24.10)

The load-bearing decisions:

  • Admin-only enrollment via CS impersonation, not self-serve storefront checkout. A B2B Edition buyer never completes subscription checkout through the standard cart; every B2B subscription in Phase 1 is created by a CS rep through the Epic-22 admin surface. Cart-merge self-serve is an explicit Phase-3 stretch gated on spike #113 (ADR-0023). The storefront SubscriptionWidget's B2B branch is a "contact sales" dead-end by design, not a bug.
  • Company identity is a first-class column pair, not a metadata-only flag — in the data model. subscriptions.company_id / subscriptions.parent_company_id are dedicated, indexed columns (schema.sql:1153, 1659-1664). But — see Move 3 — not every write path populates them; two of the domain's own routes still read/write company identity through the metadata JSON blob instead.
  • Shared resolveLockedPrice price-lock helper, with one gap still open. Both B2B enrollment routes now honor the plan's lock_price_at_creation promise via the shared helper (Hive #1889/#1890); the direct-API handleSubscriptionPost path, which also accepts company_id/ parent_company_id, still never calls it (Hive #1891, open).
  • Approval is a status gate, not an executor. subscription_change_requests records a buyer's request and an admin's decision, but the approve/reject handlers only flip status — no code path in the repository ever writes status='applied'.

Canonical-framing attestation (operator-ratified 2026-07-02). The canonical B2B company join key is subscriptions.company_id / subscriptions.parent_company_id — real, indexed columns (schema.sql:1153,1659-1664). docs/architecture/b2b-edition.md and three sibling architecture docs instead name subscriptions.buyer_org_ref as the authoritative join key; that column has zero occurrences anywhere in application code (grep-confirmed). b2b-edition.md self-identifies as a "Spike finding — candidate evidence, verify samples before implementation" (dated 2026-05-16) and was never ratified into an ADR — the shipped column names diverged from it without correction. Separately, the multi-actor owner/payer/beneficiary/manager/org_admin role model that org_admin (US-24.10) depends on is mis-cited in two places: both ADR-0022 and ADR-0023's own body text attribute it to "ADR-0024" — but ADR-0024's actual and only subject is "DeliveryInstance as a first-class lazily-populated entity"; it contains zero mentions of actors or roles anywhere in its body (grep-confirmed). PRD-COMPANION.md's D19 row correctly attributes the multi-actor decision to ADR-0023 itself, confirming the "(ADR-0024)" citations inside ADR-0022 and ADR-0023 are citation errors, not evidence of a second, missed decision record.

How it actually works

Read the deciding decision before the code. ADR-0023 is the deciding record for "admin-only, not self-serve" — read it before assuming the storefront widget's B2B dead-end is an oversight. It is the design.

Enrollment (card-on-file). handleAdminB2bEnrollment (routes/admin/b2b-enrollment.ts) resolves the customer, an active plan, and the customer's most recent active payment method, then calls createSubscription with locked_price_cents: resolveLockedPrice(plan). Company identity here is written into metadata JSON, not the dedicated company_id/ parent_company_id columns — company_id and parent_company_id are spread conditionally into the JSON.stringify({...}) metadata object (b2b-enrollment.ts:244-246), never passed as top-level fields to createSubscription. The row's metadata.b2b = true and event_type: 'subscription.b2b_enrolled' mark the audit trail.

Enrollment (PO / net terms). handleAdminB2bPoEnrollment (routes/admin/b2b-po-enrollment.ts) is the same shape with no payment-method precondition: payment_method_id: null on the subscription row, and metadata.payment_method_type = 'po' records the PO number and net_terms_days (default 30). The header comment states "the renewal worker... reads payment_method_type" to skip the charge — see Move 3; no such read exists.

Company-scoped portal list. handlePortalB2bSubscriptionsList (routes/portal/b2b-subscriptions.ts) filters json_extract(s.metadata, '$.b2b') = 1 scoped to the JWT's own customer_id, and computes effective_role / allowed_actions server-side from metadata.b2b_role (admin | buyer full actions; view_only gets allowed_actions: []) so the portal UI doesn't reimplement role logic.

Change-request approval workflow. A buyer submits via handlePortalB2bSubmitChangeRequest (routes/portal/b2b-change-requests.ts), which verifies subscription ownership, rejects view_only role, and inserts a subscription_change_requests row at status='pending'. An admin then calls handleAdminB2bChangeRequestApprove or -Reject (routes/admin/b2b-change-requests.ts), which — per the file's own comment — "flip[s] status"; the actual mutation (pause/cancel/quantity change) is left to "the caller" using the existing portal mutation APIs. No handler in this codebase ever writes status='applied'.

Company-hierarchy MRR. handleAdminB2bCompanyMrr (routes/admin/b2b-company-mrr.ts) queries active/trialing B2B subscriptions with json_extract(s.metadata, '$.company_id') = ? OR json_extract(s.metadata, '$.parent_company_id') = ?reading company identity through the metadata JSON path, not the dedicated indexed columns, despite this being the route the canonical-framing attestation names as reading company_id/parent_company_id "directly." The pure function computeCompanyMrr then partitions direct members (company_id === target) from children (parent_company_id === target), normalizes each subscription's plan price to monthly cents, and sums.

org_admin actions. handlePortalOrgAdminSubscriptionsList / -UpdatePayer / -Cancel (routes/portal/b2b-org-admin.ts) all gate on subscription_actors WHERE customer_id = JWT.customer_id AND role = 'org_admin' before acting — readActors (extensions/multi-actor.ts) is called first in the mutation handlers, and the list handler queries subscription_actors directly for the caller's org_admin rows, then IN-filters subscriptions by store_hash on top. -Cancel audits the event to the org_admin actor, not the subscription's owning customer_idbeneficiary_customer_id is carried separately in the audit extras.

Contract-term cancel guard. checkContractCancelGuard (services/contract-cancel-guard.ts) is a pure function: given subscription.term_end_at + notice_period_days and the store's contract_cancel_guard_mode ('block' | 'route' | null), it computes window_opens_at = term_end_at - notice_period_days and returns blocked = now >= window_opens_at. A null mode, term_end_at, or notice_period_days disables the guard entirely (non-op for non-contract subscriptions). It sits in the portal cancel handler between the commitment-lock check and the early-cancel fee/refund calc:

flowchart TD
    A[Subscriber POST /api/v1/portal/subscriptions/:id/cancel] --> B[verifyPortalSessionAndOwnership]
    B -- fail --> B1[401/404]
    B -- ok --> C[RATE_LIMITER check — portal-action namespace]
    C -- exceeded --> C1[429]
    C -- ok --> D{checkCancelLock:<br/>cycles_completed < plan.commitment_cycles?}
    D -- locked --> D1[409 conflict, kind=cancel_locked,<br/>lock_expires_at in body]
    D -- unlocked --> E{checkContractCancelGuard:<br/>B2B contract notice window + store mode?}
    E -- "mode='block', inside window" --> E1[409 reason=contract_notice_period<br/>subscription stays active]
    E -- "mode='route', inside window" --> E2[409 reason=cancel_routed_to_legal<br/>enqueueDelivery: pending cancel_routed webhook row]
    E -- outside window / disabled / no contract --> F[resolveEarlyCancelPolicy — minimum-term fee/refund calc]
    F --> G[UPDATE subscriptions SET status='cancelled', cancelled_at, cancel_reason]
    G --> H[logEvent subscription.cancelled]
    H --> I[dispatchCancelTicket — best-effort CS helpdesk ticket]
    I --> J[200 response]

Diagram provenance. Excerpt of § 4 "Cancel" from the canonical, code-sourced docs/architecture/process-flows.md (derives_from pins worker.ts, routes/portal/cancel.ts, services/cancel-lock-policy.ts, services/contract-cancel-guard.ts, among 20 sources spanning three domains — the file-level sign_off applies to all its sections, not independently to § 4). Its frontmatter carries sign_off: status: pending, role: engineering — accurate to the code, not yet human-attested. The commitment-lock node (D) belongs to Epic 18, not B2B — shown here because the B2B contract guard (E) sits immediately downstream of it in the same handler; only nodes A-J are B2B or B2B-adjacent, the full source diagram has no other domains inlined at this point. In the handoff pipeline this is a build-time include, never a hand-copied fork.

The data model backing company identity, actor roles, and the approval workflow:

erDiagram
    subscriptions {
        TEXT company_id
        TEXT company_name
        TEXT parent_company_id
    }
    subscription_actors {
        TEXT id PK
        TEXT store_hash
        TEXT subscription_id FK
        TEXT customer_id
        TEXT role
        TEXT notification_prefs
        TEXT processor_connection_ref
        TEXT created_at
        TEXT updated_at
    }
    subscription_change_requests {
        TEXT id PK
        TEXT store_hash FK
        TEXT subscription_id FK
        TEXT customer_id FK
        TEXT change_type
        TEXT change_payload
        TEXT status
        INTEGER reviewer_user_id
        TEXT reviewer_notes
        TEXT expires_at
        TEXT applied_at
        TEXT created_at
        TEXT updated_at
    }
    subscriptions ||--o{ subscription_actors : "subscription_id"
    subscriptions ||--o{ subscription_change_requests : "subscription_id"

Diagram provenance. Excerpt of the canonical, code-sourced docs/architecture/data-model-erd.md (3 of ~85 tables — the company_id/company_name/parent_company_id columns on subscriptions, plus the two B2B-specific tables subscription_actors and subscription_change_requests; every other column and table is omitted). This source carries no sign_off field; its staleness marker is as_of_commit: 80fc35f4, staleness_threshold_days: infinite, and it is CI-enforced @generated — a fresh derive that drifts from the committed file fails erd-derive-ci.yml. Same rule applies: one transcluded source, never a hand-drawn fork.

Where intent and reality diverge

The derived coverage matrix (_coverage-matrix.json) reports US-24.1 through US-24.10 all at g4_status: "pass" — each has a behavioral scenario exercising the real handler. That is true, and it is not the whole truth. Seven typed deltas:

  • Superseded-framing residuedocs/architecture/b2b-edition.md and three sibling spike docs name subscriptions.buyer_org_ref as the canonical company join key; the shipped schema instead uses dedicated company_id/parent_company_id columns, and buyer_org_ref has zero occurrences anywhere in application code. b2b-edition.md self-marks as an unratified "Spike finding" (2026-05-16) but nothing in the corpus marks it superseded after the actual columns shipped under different names — a reader trusting the doc's schema section would search for a column that doesn't exist.
  • Built-but-untroddenhandlePortalB2bSubscriptionsList (GET /api/v1/portal/b2b/subscriptions) is a fully built, real server-side role-gated endpoint — computed effective_role/allowed_actions, anti-IDOR scoped to the JWT's own customer_id — but zero files under apps/storefront-svelte call it. The portal's actual role gate (SubscriberPortalApp.svelte's canManage) is a separate, disconnected client-side mechanism, documented dead on every live route by the BRD epic view's own gaps note.
  • Named-deferred — PO/net-terms subscriptions are created with payment_method_id=null on the stated intent that a renewal worker will read metadata.payment_method_type='po' and create a pending BC order instead of charging a processor; no such read site exists anywhere in the codebase. Tracing the actual renewal path (scheduler.ts's getPaymentMethodAsSystem(repo, null)) shows a PO subscription instead fails with payment_method_missing and enters the standard dunning policy — the opposite of the feature's stated intent, and untested by any scenario beyond creation-time coverage in b2b-po-enrollment.test.ts.
  • Named-deferred — volume-tier renewal pricing's pure resolver (resolveVolumeTier()) is real and G4-tested against config CRUD, but is never called from scheduler.ts or anywhere a live renewal charge is computed; a company's negotiated volume-tier discount is not applied to any real charge today, matching b2b-volume-tiers.ts's own "G5 boundary" disclaimer.
  • Dependency-gated — multi-location delivery's fan-out (expandDeliveryLocations()) is a real, G4-tested pure function computing per-location charge intents, but the route file's own header states the per-location BC order creation at renewal "is G5 (requires live BC orders API)"; scheduler.ts never calls expandDeliveryLocations(), so no renewal today actually creates the one-order-per-location split the feature promises.
  • Contract-verified, not live-verified — the entire domain is G4-tier (real D1 via applySchema, direct handler invocation) plus one browser-tier Playwright spec covering only the admin enrollment form's render/submit with the backend network call stubbed (b2b-enrollment.spec.ts). Route-reachability for all nine endpoints is confirmed by direct worker.ts read, independent of the scenario tier. No B2B path has any live-BC-sandbox proof, unlike payments (ADR-0082) or dunning (cit-to-mit-sandbox.spec.ts); live-smoke-sweep.spec.ts confirms only that the admin route loads, not that enrollment functions end-to-end.
  • Verified-but-incomplete — the approval workflow's data plane (submit / approve / reject, tenant-scoped, audited) is built and G4-tested, but per BRD AC2 ("On approval: execute the change via normal action APIs, Then the change applies") the actual mutation execution is not automated — no code path transitions a change request to status='applied'; an admin must separately invoke the underlying pause/cancel/quantity endpoint by hand after approving.

epic-24-b2b.scenario.ts's own header comment additionally claims US-24.5/.6/.7/.8 are "P3, not yet built; verified absent via grep" and that US-24.10 has "no route handler exposed" — both claims are stale relative to the same file's own body: real, assertion-bearing scenarios exist for all four, and the route files exist and are wired into worker.ts. This is a live comments-vs-code divergence inside a single test file — trace the scenario bodies directly, never the header, for this file.

How to operate & extend

  • Change company hierarchy MRR partitioning: computeCompanyMrr in routes/admin/b2b-company-mrr.ts — direct members match company_id, children match parent_company_id, one level deep (no recursive nesting in Phase 1).
  • The invariant you must not break: store_hash scoping first, then company_id/parent_company_id equality for MRR, then subscription_actors role verification for org_admin actions. Any query that joins across companies, or any org_admin handler that skips the subscription_actors check, is a tenant-isolation break.
  • A B2B enrollment writes company identity to metadata, not the dedicated columns. If you add a new B2B route that needs to filter or join on company identity, check whether the row you're reading came through b2b-enrollment.ts/b2b-po-enrollment.ts (metadata JSON) or handleSubscriptionPost (dedicated columns) — today the two paths do not populate the same representation, so a query against one representation silently misses rows created via the other route.
  • Extension seams: the approve/reject-then-caller-executes pattern in b2b-change-requests.ts is the seam to close if you wire status='applied' automation — the admin handlers already carry subscription_id and change_type/change_payload, so the executor only needs to dispatch to the existing mutation endpoints. resolveVolumeTier() and expandDeliveryLocations() are both G4-proven pure functions awaiting a scheduler.ts call site — that wiring, not new logic, is what would move volume-tier pricing and multi-location fan-out from computed-but-unused to live.

Confidence notes

  • The canonical-framing attestation's "reads/writes directly" claim for b2b-company-mrr.ts and b2b-enrollment.ts does not match what I traced in those files. Input-B's attested claim states company identity is read/written "directly" through the dedicated company_id/ parent_company_id columns in these two files. Reading them myself: b2b-enrollment.ts writes company_id/parent_company_id into the metadata JSON blob passed to createSubscription (lines 244-246), never as top-level fields — despite createSubscription in db.ts accepting and inserting company_id/company_name/parent_company_id as real column parameters (confirmed at db.ts's INSERT statement and bind list). b2b-company-mrr.ts reads company identity via json_extract(s.metadata, '$.company_id')/'$.parent_company_id') in its SQL, not s.company_id/s.company_parent_id column references. The one route I confirmed does use the dedicated columns directly is handleSubscriptionPost in subscriptions.ts (the direct-API POST), which passes company_id/parent_company_id as top-level fields to createSubscription. The dedicated columns are real and indexed, and the underlying claim that they are the intended canonical representation holds — but two of this domain's own routes do not write or read through them, so the MRR rollup and the CS-rep enrollment path are, in practice, disconnected from the indexes that exist to serve them. I did not resolve or edit Input-B's attestation; flagging the discrepancy here per the corpus's report-don't-silently-resolve rule.
  • checkContractCancelGuard has no scenario I traced beyond what Input-B cites. I read the function itself and confirmed its pure-function shape (mode/window/blocked) against contract-cancel-guard.ts, but did not independently re-derive the BLOCK/ROUTE/CONTROL assertions inside epic-24-contract-cancel-guard.scenario.ts line-by-line — I relied on Input-B's traced claim and the process-flows.md table row for that coverage. The pure-function logic itself is directly verified.
  • I did not independently re-verify every G4-pass claim in the coverage matrix beyond the deltas above (e.g., the exact assertion shapes inside epic-24-b2b.scenario.ts's tenant-isolation CONTROL cases). I did confirm the file's header-vs-body staleness directly (reading both the header comment and grepping for US-24.5/.6/.7/.8/.10 route file existence and worker.ts wiring), which is the specific claim this page's typed deltas depend on.