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—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. 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
- Session identity — Keep
fetch_current_user/MyAppWeb.UserAuth(or equivalent) as the source of truth for who is signed in. - Active organization — Add
fetch_current_organizationas a plug oron_mounthook that loads the org id from the session and verifies membership before assigningcurrent_organization. Never trust a raworg_idquery param without a membership join. - Billable row — For org-shaped billing, add
use Accrue.BillabletoMyApp.Accounts.Organization(not onlyUser) so Accrue’s customer/subscription rows anchor on the org. - Host facade — Implement
MyApp.Billing.subscribe/2,customer_for/1, and related hooks so org flows passOrganizationintoAccrue.Billinghelpers; keep policy (who may subscribe, cancel, update tax location) in the host module. - Auth adapter — Configure
config :accrue, :auth_adapter, MyApp.Auth.PhxGenAuth(or your adapter). Copy the module body forMyApp.Auth.PhxGenAuthfrom Auth adapters; do not duplicate it here.
For which row owns finance exports and revenue reporting, see Finance handoff.
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 (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 |
| 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 |
| 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 |
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
- Keep
fetch_current_useras the identity source of truth (existingphx.gen.authpipeline). - Add
fetch_current_organizationas a plug or LiveViewon_mountthat verifies membership before assigningcurrent_organization(session stores an org id; membership table is the gate). - Add
use Accrue.BillableonMyApp.Accounts.Organizationwith the correctbillable_typefor your app. - Ensure host
MyApp.Billingfunctions used for org-shaped subscribe/customer flows takeOrganization(or a scope that resolves to one) as the billable argument passed into Accrue. - Set
config :accrue, :auth_adapter, MyApp.Auth.PhxGenAuth— copy the adapter module body fromguides/auth_adapters.md; it lists everyAccrue.Authcallback.
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:
config :accrue, :auth_adapter, MyApp.Auth.PowCopy the MyApp.Auth.Pow module body from 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; 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_idafter membership revoke — always re-check membership when loading org from session. - IDOR on
/orgs/:idwithout 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.Defaultis production-safe for non-Sigra apps — it is not; configure a real adapter.
Related guides
- Auth adapters —
Accrue.Authcontract andMyApp.Auth.PhxGenAuthsource. - Finance handoff — Stripe RR, Sigma, and which host row backs reporting.
- Sigra integration — optional first-party adapter when Sigra is already a dependency.