Skip to content

feat: org billing accounts + funding designation (migration 041, W0 substrate)#51

Open
I-am-nothing wants to merge 2 commits into
mainfrom
feat/org-account-substrate
Open

feat: org billing accounts + funding designation (migration 041, W0 substrate)#51
I-am-nothing wants to merge 2 commits into
mainfrom
feat/org-account-substrate

Conversation

@I-am-nothing

Copy link
Copy Markdown
Contributor

Org-billing W0 substrate (design: workspace docs-temp/org-billing/design.md, D1 + W0 row — user-approved v2.1). Retires the account-billing-read D6 user-only rule.

What

  • One org = one ms_billing.accounts row (owner_kind='org', schema-ready since 001; first writer path). Created by EnsureOrgAccount — the advisory-locked get-or-create twin of the user leg (org lock namespace 'lbto').
  • Funding designation (org_billing_designations, migration 041): picks ONLY the funding instrument for the org account's invoices. sponsor = the acting org owner/admin's own card (validated server-side: the request carries no sponsor field, so pointing at another member's wallet is structurally impossible); org = the org's own Stripe customer/card. Attribution (periods, aggregates, roster, pool, budgets, collection, invoice mirror) always stays on the org account — funding switches re-route only future charges.
  • Charge-time funding hop: resolveChargeableCustomer resolves ChargeFundingAccount once per charge; all three charge legs (boundary, creation proration, module overage) inherit it. A revoked sponsorship degrades to the ordinary transient skipped_no_pm.
  • Resolution is designation- + activation-gated (ResolveOrgFundedAccount): sponsor designation activates immediately (anchor = designation day, ADR 0006 amendment); funding='org' activates at card bind — the pointer never flips to an unfunded account.
  • Unbilled org roster rows: RegisterApp accepts org owners; an undesignated org registers apps.account_id NULL + owner_org_id stamp (no base fee, no timers, excluded from every sweep — AppsPendingProration gains account_id IS NOT NULL; direct-path guards added in proration + timer reconcile).
  • RepointOrgUsage attach sweep: backfills roster account_id, folds NULL-account events into the account's first open window (recorded_at clamp + repointed_from audit column — the rollup windows by recorded_at, so older events would otherwise never enter any window), synthesizes fresh overage timers anchored at designation (prospective billing; no retroactive grace).
  • Sponsor lifecycle: Get/SetOrgDesignation (acting-user validation + server-computed disclosed_backlog_micros for the pre-confirm disclosure), RevokeSponsorship (self-revoke, authorized by being the sponsor), RepointOrgUsage — all four wired into the dispatcher.
  • PM RPC family widened to user-XOR-org (Ensure/Prepare/Start/Finish/GetPaymentMethods/Detach/SetDefault): W1's org billing page has no billing-engine leg, so this lands here. Ensure's payment_method capability checks the FUNDING account (a sponsor-funded org account owns no PM rows itself).

Migration 041 (038–040 reserved by #50)

New table org_billing_designations (+ funding-shape CHECK, sponsor FK ON DELETE CASCADE + covering partial index, updated_at trigger); apps.account_id DROP NOT NULL + apps.owner_org_id + apps_unbilled_only_org_check + partial index; usage_events.repointed_from. Down migration mirrors everything (unbilled roster rows are dropped — they carry no billed state).

Lock impact: ALTER TABLE apps (DROP NOT NULL / ADD COLUMN / ADD CONSTRAINT) takes a brief ACCESS EXCLUSIVE on ms_billing.apps; the CHECK validates existing rows in the same lock (small table). usage_events ADD COLUMN … NULL is metadata-only. Grants: covered by 024's ALTER DEFAULT PRIVILEGES.

Verification

  • go build ./..., go vet ./... (incl. -tags integration), full go test ./... — all green.
  • 22 new tests: designation service (sponsor validation, activation, sweep, revoke authz), RegisterApp org legs (funded / unbilled), funding hop through RunBillingCycle (sponsor customer charged, run + mirror keyed to the org account; revoke → transient no_pm), billing owner widening (funded gate beats row existence, org PM targets, validation).
  • Migrations 001→041 applied + 041 down + re-up cycled clean against a scratch Postgres 17.
  • Design was 4-lens adversarially reviewed + verified before implementation (12 blockers folded in).

Merge order

  1. This PR → 2. api-platform feat/org-owner-stamp (owner stamps + mirror org leg) → 3. run billing-backfill (its new org leg registers pre-existing org apps as unbilled roster rows). mirrorstack-docs docs/org-billing-w0 documents 037+041 and can merge any time after this. W1 (org billing page + designation UI) follows.

Out of scope, per design: the CreateApp/transfer 402 designation gate (turns on with W1's UI), ai_meter org attribution (conversations carry no app context until app-attributed AI), egress-sync (already owner-less by design for all apps; the repoint sweep scoops org events by app id).

🤖 Generated with Claude Code

I-am-nothing and others added 2 commits July 6, 2026 16:28
…ubstrate)

Org-billing W0 (docs-temp/org-billing/design.md D1) — retires the D6
user-only rule:

- One org = one ms_billing.accounts row (owner_kind='org'), created by
  EnsureOrgAccount (advisory-locked get-or-create, org namespace 'lbto')
  lazily at the org's first funding designation.
- The designation picks only the FUNDING INSTRUMENT: sponsor mode
  finalizes invoices against the sponsor's Stripe customer/default PM
  (ChargeFundingAccount hop in resolveChargeableCustomer, resolved at
  charge time); attribution never moves. Sponsor = the acting user's own
  account, validated server-side; only the current sponsor may
  self-revoke.
- Resolution (AccountByOwner / Ensure / RegisterApp) is designation- +
  activation-gated; an undesignated org registers UNBILLED roster rows
  (apps.account_id NULL + owner_org_id stamp) and lazy NULL-account
  events.
- RepointOrgUsage attach sweep: backfills roster account_id, folds
  NULL-account events into the account's first open window (recorded_at
  clamp + repointed_from audit — the rollup windows by recorded_at, so
  older events would otherwise never bill), synthesizes fresh overage
  timers anchored at designation (prospective billing).
- PM RPC family (Ensure/Prepare/Start/Finish/Get/Detach/SetDefault)
  widened to user-XOR-org owners; Ensure's payment_method capability
  checks the funding account.
- Migration slot 041: 038-040 are claimed by the open service-block
  PR #50.

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

Post-review fixes on the W0 substrate (4-lens /simplify pass):

- recoveryCustomer: all four crash-recovery sites (boundary hasFrozen,
  boundary moneyMayHaveMoved, creation proration, module overage) now
  resolve the SAME ChargeFundingAccount hop the fresh-charge path uses —
  a sponsor-funded org's crashed attempt was previously looked up under
  the org's own (usually absent) Stripe customer: a loud permanent error
  at best, a missed reconcile at worst. Regression test pins the sponsor
  customer on the recovery lookup. The funding-switch-during-crash race
  is backstopped by the deterministic ~24h idem keys (same envelope as
  the documented Search-lag note); recorded-instrument hardening travels
  with the W2 transfer wave.
- Ensure's funding hop hoists inside the RequirePaymentMethod branch and
  skips the provably-identity query for user principals.
- billing.AccountByOrgFunded renamed ResolveOrgFundedAccount — THE org
  resolution now greps as one name across all three stores.
- uuidRowFound decode helper (per package, matching the local-helper
  idiom) collapses six copies of the ErrNoRows/parse ceremony.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant