guides/organization_billing.md

# Organization billing

This guide is the **non-Sigra** mainline for **organization-shaped** Stripe billing on Phoenix: you establish identity with `phx.gen.auth` (or equivalent), resolve an **active organization** from the session with **membership checks**, attach **`use Accrue.Billable`** to the org row, and route subscribe/cancel flows through a small host billing facade that accepts **`Organization`** as the billable. It complements the adapter contract in [Auth adapters](auth_adapters.md)—that file stays the `Accrue.Auth` SSOT; here we focus on **session → organization → billable** and **ORG-03** obligations.

## Adoption proof matrix (ORG-09)

For the **blocking vs advisory** map of what merge-blocking CI proves versus optional recipe lanes, read [`examples/accrue_host/docs/adoption-proof-matrix.md`](https://github.com/szTheory/accrue/blob/main/examples/accrue_host/docs/adoption-proof-matrix.md). The **Organization billing proof (ORG-09)** subsection there is the canonical entry point for ORG-09. Merge-blocking drift in that matrix is enforced by **`scripts/ci/verify_adoption_proof_matrix.sh`** (run from the repo root). **Non-Sigra** in this guide still refers to **`Accrue.Auth` / `Accrue.Billable`** contracts and how you wire them—not a promise that the demo host never enables Sigra for convenience.

## Who this guide is for

Teams shipping **B2B or multi-tenant SaaS** where the Stripe Customer should follow the **organization**, not only the signed-in user. You already run (or plan) `phx.gen.auth`, you own org/membership tables, and you want a single linear checklist instead of piecing together fragments from several guides.

## Session → organization → billable

1. **Session identity** — Keep `fetch_current_user` / `MyAppWeb.UserAuth` (or equivalent) as the source of truth for **who** is signed in.
2. **Active organization** — Add `fetch_current_organization` as a plug or `on_mount` hook that loads the org id from the session **and verifies membership** before assigning `current_organization`. Never trust a raw `org_id` query param without a membership join.
3. **Billable row** — For org-shaped billing, add `use Accrue.Billable` to **`MyApp.Accounts.Organization`** (not only `User`) so Accrue’s customer/subscription rows anchor on the org.
4. **Host facade** — Implement `MyApp.Billing.subscribe/2`, `customer_for/1`, and related hooks so org flows pass **`Organization`** into `Accrue.Billing` helpers; keep policy (who may subscribe, cancel, update tax location) in the host module.
5. **Auth adapter** — Configure `config :accrue, :auth_adapter, MyApp.Auth.PhxGenAuth` (or your adapter). Copy the **module body** for `MyApp.Auth.PhxGenAuth` from [Auth adapters](auth_adapters.md); do not duplicate it here.

For **which row** owns finance exports and revenue reporting, see [Finance handoff](finance-handoff.md).

## ORG-03 boundaries at a glance

Accrue stores billing state, but **cross-tenant isolation** is host-owned. Every host surface falls into one of four path classes: **public**, **admin**, **webhook replay**, and **export**. The full ORG-03 requirement text lives in the repo milestone [v1.3-REQUIREMENTS.md](https://github.com/szTheory/accrue/blob/main/.planning/milestones/v1.3-REQUIREMENTS.md) (ORG-03); Phase 38 (**ORG-07**, **ORG-08**) adds deeper anti-patterns for Pow, custom org resolution, and replay matrices.

| Path class | Threat one-liner | Host obligation | Enforce at | Further reading |
|------------|------------------|------------------|------------|-------------------|
| public | IDOR via guessable org URLs | Scope every query by membership; never “first org in DB” defaults | Router plugs, context functions | ORG-03 |
| admin | Privilege escalation into another tenant’s billing | Require admin role **and** org membership before Accrue Admin or destructive billing UI | `require_admin_plug`, LiveView mounts | [Auth adapters](auth_adapters.md) |
| webhook replay | Cross-org mutation from replayed or mis-scoped events | Resolve billable from event metadata; no global `Repo.all` in handlers | Webhook handler, Oban workers | [Webhooks](webhooks.md) |
| export | Data spill into wrong tenant file or inbox | Filter exports by org scope; same Stripe account as configured customer | Export jobs, Sigma/RR joins | [Finance handoff](finance-handoff.md) |

## Minimal host model (Organization + Membership)

Model at least **`Organization`**, **`OrganizationMembership`** (user ↔ org + role), and optionally **`OrganizationInvitation`**. On **user registration**, bootstrap a **personal organization** plus membership so solo developers get a working org-shaped path without a second “create your workspace” tutorial. Keep slugs and soft-delete rules explicit so `current_organization` never points at a row the user should not see.

## phx.gen.auth checklist

1. Keep **`fetch_current_user`** as the identity source of truth (existing `phx.gen.auth` pipeline).
2. Add **`fetch_current_organization`** as a plug or LiveView `on_mount` that **verifies membership** before assigning `current_organization` (session stores an org id; membership table is the gate).
3. Add **`use Accrue.Billable`** on **`MyApp.Accounts.Organization`** with the correct `billable_type` for your app.
4. Ensure host **`MyApp.Billing`** functions used for org-shaped subscribe/customer flows take **`Organization`** (or a scope that resolves to one) as the billable argument passed into Accrue.
5. Set **`config :accrue, :auth_adapter, MyApp.Auth.PhxGenAuth`** — copy the adapter module body from [`guides/auth_adapters.md`](auth_adapters.md); it lists every `Accrue.Auth` callback.

## Pow-oriented checklist (ORG-07)

Pow answers **who is signed in**; it does **not** infer which **organization** is active. Treat `Pow.Plug.current_user/1` as the identity boundary, then run the same **membership-gated** `fetch_current_organization` pattern as the `phx.gen.auth` mainline—never promote a raw session org hint to `current_organization` without a membership join.

### Identity with Pow

Read the signed-in user with **`Pow.Plug.current_user/1`** on the `%Plug.Conn{}` (and LiveView assigns fed by the same pipeline). That value is the **identity** input to your plugs and `on_mount` hooks; every org decision still flows through explicit session + membership checks.

### Active organization and membership

Add **`fetch_current_organization`** as a plug or LiveView `on_mount` that loads an org id from the session **and verifies membership** before assigning `current_organization`. Pow does not infer active org tenancy—if you stash an org id in session, re-validate against your membership table on each request, matching steps 2–4 in **Session → organization → billable** above.

### Billable row and host facade

Attach **`use Accrue.Billable`** to **`MyApp.Accounts.Organization`** for org-shaped billing. Shape **`MyApp.Billing`** so subscribe/cancel/customer helpers accept **`Organization`** (or a scope that resolves to one) when calling `Accrue.Billing`, keeping policy (who may subscribe, cancel, update tax location) in the host module.

### Accrue.Auth configuration

Configure:

```elixir
config :accrue, :auth_adapter, MyApp.Auth.Pow
```

Copy the **`MyApp.Auth.Pow`** module body from [`auth_adapters.md`](auth_adapters.md)—that section is the SSOT for `Accrue.Auth` callbacks (`current_user/1`, `require_admin_plug/0`, audit hooks, optional step-up). Accrue Admin and audit paths still call `Accrue.Auth`; Pow is only how **`current_user/1`** is implemented.

### Maintenance and upgrades

Pow is **community-maintained**. Pin `pow` (and extensions) deliberately, read upstream changelog on every bump, and **re-verify** Plug ordering and session fetch after upgrades—Pow integrates at the connection layer and regressions often surface as missing assigns rather than compile errors.

## Custom organization model (ORG-08)

**ORG-08** covers hosts that resolve tenancy through **custom signals**—subdomains, headers, alternate session keys, or background jobs—while still anchoring billing on **`Organization`**. Those signals must always collapse to a **membership-verified** org row before any `Accrue.Billing` mutation. Canonical ORG-03 path-class rules remain in [v1.3-REQUIREMENTS.md](https://github.com/szTheory/accrue/blob/main/.planning/milestones/v1.3-REQUIREMENTS.md); the table below maps common mistakes to those classes.

| Anti-pattern | ORG-03 path class | Why it violates | Host obligation |
|--------------|-------------------|-----------------|-----------------|
| Trusting `org_id` query params on unauthenticated or partially authenticated routes | public | IDOR and accidental cross-tenant reads | Resolve org only after the session user passes a membership join; never “helpfully” default to the first org |
| Admin LiveViews that select org solely from `live_action` params | admin | Privilege escalation into another org’s billing UI | Require `on_mount` membership checks tied to session-backed org id; treat params as untrusted hints |
| Context modules that widen queries when org id is omitted | public | Silent cross-tenant data access | Require first-arg org scope or explicit org id sourced from verified session |
| Webhook replay handlers that call `Repo.all` without billable filters | webhook replay | Replay tooling lacks browser session—global queries span tenants | Resolve billable from Stripe/event metadata before touching Accrue tables |
| Export pipelines that join revenue tables without org predicates | export | Reporting leaks into the wrong workspace | Filter exports by org scope and the Stripe customer id tied to that org |

### LiveView admin

Accrue Admin and host operator UIs must inherit **org scope from the verified session** via plugs and **`on_mount`** hooks that re-run membership checks. **`live_action`** may carry intent (e.g., deep links) but must **not** be the only source of truth for which `Organization` is active—pair every param path with the same membership gate you use on HTTP routes.

### Context functions

`MyApp.Billing` / `MyApp.Accounts` functions should accept **org id or a scope struct derived from the verified session** as the first argument (or explicit keyword). Optional-org APIs are a footgun: widening queries when org is `nil` violates **public** and **admin** classes from ORG-03.

### Webhook replay

**Webhook replay** and catch-up jobs run without `current_user`. Tie each branch to processor metadata that pins **customer / billable id** back to a single org before mutating billing rows. Avoid global `Repo.all` “find any open subscription” helpers inside replay workers.

### `Accrue.Auth` actor alignment

Privileged tooling calls `Accrue.Auth.actor_id/1` when writing audit rows. Return the **real acting principal** (human admin id, service account id) — do not substitute a silent superuser string that hides who performed a destructive billing action.

## User-as-billable (bounded aside)

**User-as-billable** (Cashier-style: `use Accrue.Billable` on **`User`**) is a valid stepping stone for single-tenant or solo apps. Accrue still expects consistent **`owner_type`** / **`owner_id`** on persisted billing rows. If you later move Stripe Customer ownership to an **Organization**, plan a **migration** of customer/subscription ownership—Stripe IDs cannot silently “move” without host data work. **Pow** is covered in **ORG-07** above; **custom organization models** (alternate session keys, subdomains, replay/export matrices) are covered in **ORG-08** above.

## Reference wiring (examples/accrue_host)

The demo host is generator-agnostic proof, not a second tutorial:

| Module | Role |
|--------|------|
| `AccrueHost.Accounts.Organization` | Org schema with `use Accrue.Billable, billable_type: "Organization"` |
| `AccrueHost.Accounts.User` | User schema (also billable in the demo—illustrates the bounded aside) |
| `AccrueHost.Billing` | Host facade: `subscribe_active_organization/2`, `customer_for_scope/1`, policy hooks |

Cross-check the latest files under `examples/accrue_host/lib/accrue_host/` after upgrades.

## Footguns to avoid

- **Stale `active_organization_id`** after membership revoke — always re-check membership when loading org from session.
- **IDOR** on `/orgs/:id` without membership — param is untrusted; session + membership is trusted.
- **“First org in the database”** fallbacks in dev — they become production incidents.
- **Webhook handlers** that query billables without org/processor scope — replays and multi-tenant leaks.
- Assuming **`Accrue.Auth.Default`** is production-safe for non-Sigra apps — it is not; configure a real adapter.

## Related guides

- [Auth adapters](auth_adapters.md) — `Accrue.Auth` contract and `MyApp.Auth.PhxGenAuth` source.
- [Finance handoff](finance-handoff.md) — Stripe RR, Sigma, and **which host row** backs reporting.
- [Sigra integration](sigra_integration.md) — optional first-party adapter when Sigra is already a dependency.