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_idare 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 themetadataJSON blob instead. - Shared
resolveLockedPriceprice-lock helper, with one gap still open. Both B2B enrollment routes now honor the plan'slock_price_at_creationpromise via the shared helper (Hive #1889/#1890); the direct-APIhandleSubscriptionPostpath, which also acceptscompany_id/parent_company_id, still never calls it (Hive #1891, open). - Approval is a status gate, not an executor.
subscription_change_requestsrecords a buyer's request and an admin's decision, but the approve/reject handlers only flipstatus— no code path in the repository ever writesstatus='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_id
— beneficiary_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_frompinsworker.ts,routes/portal/cancel.ts,services/cancel-lock-policy.ts,services/contract-cancel-guard.ts, among 20 sources spanning three domains — the file-levelsign_offapplies to all its sections, not independently to § 4). Its frontmatter carriessign_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 — thecompany_id/company_name/parent_company_idcolumns onsubscriptions, plus the two B2B-specific tablessubscription_actorsandsubscription_change_requests; every other column and table is omitted). This source carries nosign_offfield; its staleness marker isas_of_commit: 80fc35f4,staleness_threshold_days: infinite, and it is CI-enforced@generated— a fresh derive that drifts from the committed file failserd-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 residue —
docs/architecture/b2b-edition.mdand three sibling spike docs namesubscriptions.buyer_org_refas the canonical company join key; the shipped schema instead uses dedicatedcompany_id/parent_company_idcolumns, andbuyer_org_refhas zero occurrences anywhere in application code.b2b-edition.mdself-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-untrodden —
handlePortalB2bSubscriptionsList(GET /api/v1/portal/b2b/subscriptions) is a fully built, real server-side role-gated endpoint — computedeffective_role/allowed_actions, anti-IDOR scoped to the JWT's owncustomer_id— but zero files underapps/storefront-sveltecall it. The portal's actual role gate (SubscriberPortalApp.svelte'scanManage) 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=nullon the stated intent that a renewal worker will readmetadata.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'sgetPaymentMethodAsSystem(repo, null)) shows a PO subscription instead fails withpayment_method_missingand enters the standard dunning policy — the opposite of the feature's stated intent, and untested by any scenario beyond creation-time coverage inb2b-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 fromscheduler.tsor anywhere a live renewal charge is computed; a company's negotiated volume-tier discount is not applied to any real charge today, matchingb2b-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.tsnever callsexpandDeliveryLocations(), 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 directworker.tsread, 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.tsconfirms 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:
computeCompanyMrrinroutes/admin/b2b-company-mrr.ts— direct members matchcompany_id, children matchparent_company_id, one level deep (no recursive nesting in Phase 1). - The invariant you must not break:
store_hashscoping first, thencompany_id/parent_company_idequality for MRR, thensubscription_actorsrole verification for org_admin actions. Any query that joins across companies, or any org_admin handler that skips thesubscription_actorscheck, 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 throughb2b-enrollment.ts/b2b-po-enrollment.ts(metadata JSON) orhandleSubscriptionPost(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.tsis the seam to close if you wirestatus='applied'automation — the admin handlers already carrysubscription_idandchange_type/change_payload, so the executor only needs to dispatch to the existing mutation endpoints.resolveVolumeTier()andexpandDeliveryLocations()are both G4-proven pure functions awaiting ascheduler.tscall 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.tsandb2b-enrollment.tsdoes not match what I traced in those files. Input-B's attested claim states company identity is read/written "directly" through the dedicatedcompany_id/parent_company_idcolumns in these two files. Reading them myself:b2b-enrollment.tswritescompany_id/parent_company_idinto themetadataJSON blob passed tocreateSubscription(lines 244-246), never as top-level fields — despitecreateSubscriptionindb.tsaccepting and insertingcompany_id/company_name/parent_company_idas real column parameters (confirmed atdb.ts's INSERT statement and bind list).b2b-company-mrr.tsreads company identity viajson_extract(s.metadata, '$.company_id')/'$.parent_company_id')in its SQL, nots.company_id/s.company_parent_idcolumn references. The one route I confirmed does use the dedicated columns directly ishandleSubscriptionPostinsubscriptions.ts(the direct-API POST), which passescompany_id/parent_company_idas top-level fields tocreateSubscription. 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. checkContractCancelGuardhas no scenario I traced beyond what Input-B cites. I read the function itself and confirmed its pure-function shape (mode/window/blocked) againstcontract-cancel-guard.ts, but did not independently re-derive the BLOCK/ROUTE/CONTROL assertions insideepic-24-contract-cancel-guard.scenario.tsline-by-line — I relied on Input-B's traced claim and theprocess-flows.mdtable 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 forUS-24.5/.6/.7/.8/.10route file existence andworker.tswiring), which is the specific claim this page's typed deltas depend on.