Skip to content

billing: add tenant billing contact fields and per-tenant controller#2902

Open
jshearer wants to merge 9 commits into
jshearer/billing_graphqlfrom
jshearer/billing_fields
Open

billing: add tenant billing contact fields and per-tenant controller#2902
jshearer wants to merge 9 commits into
jshearer/billing_graphqlfrom
jshearer/billing_fields

Conversation

@jshearer
Copy link
Copy Markdown
Contributor

@jshearer jshearer commented Apr 29, 2026

Summary

Customers cannot self-serve billing email changes today. Every request requires manual Stripe intervention. This adds admin-editable billing contact fields on tenants, a GraphQL mutation for managing them, and a per-tenant controller that reconciles the Stripe-backed subset to Stripe asynchronously.

  • Adds billing_email, billing_name, and billing_address fields to tenants, letting admins self-serve billing contact changes through a new setBillingContact GraphQL mutation instead of requesting manual Stripe edits
  • Introduces a per-tenant TenantController automation (the first tenant-scoped automation) that reconciles DB-authoritative billing contact data to Stripe asynchronously
  • Updates existing customer-creation paths (createBillingSetupIntent, billing-integrations) to prefer tenant-managed billing email over JWT claims when creating new Stripe customers

How it works

The mutation writes Postgres and returns. A trigger wakes the tenant's controller task, which reads current DB state, compares against the Stripe customer, and calls update_customer_billing_profile if they differ. Tenants without a Stripe customer store billing data in the DB; the controller treats "no customer" as a no-op, and customer-creation paths wake the controller afterward.

The controller follows the same sub-controller composition pattern as LiveSpecControllerExecutor: TenantControllerState contains a nested BillingContactStatus managed by the billing_contact sub-module, so future tenant automations can be added as additional sub-controllers.

Migration

20260429120000_tenant_controller_billing_contact.sql:

  • Adds columns. New tenants get a controller task via an insert trigger. Existing tenants get one lazily: wake_tenant_controller creates the task on-demand if controller_task_id is null, so setBillingContact or customer-creation paths work without a pre-existing task row.
  • Any source of change to these billing fields will trigger the automation to sync to stripe
  • Backfills billing_email and billing_address from CDC-synced stripe.customers so existing data matches Stripe without triggering reconciliation. Only tenants that received billing data from this backfill get a controller task row; the rest get one on first use.

Testing

I tested this e2e in a local stack with a testmode Stripe API key

@jshearer jshearer force-pushed the jshearer/billing_fields branch 3 times, most recently from dd24bcb to 2d02827 Compare April 29, 2026 21:35
@jshearer jshearer self-assigned this Apr 29, 2026
@jshearer jshearer added change:planned This is a planned change control-plane-api Change affecting the API of control-plane, may impact the UI, flowctl, etc labels Apr 29, 2026
@jshearer jshearer marked this pull request as ready for review April 29, 2026 21:40
@jshearer jshearer force-pushed the jshearer/billing_fields branch from 2d02827 to 769438a Compare April 29, 2026 22:42
@jshearer jshearer force-pushed the jshearer/billing_graphql branch from cc73e15 to 2184f2e Compare April 29, 2026 22:42
@jshearer jshearer force-pushed the jshearer/billing_fields branch from 769438a to 4203194 Compare April 29, 2026 22:43
@jshearer jshearer added waiting This change is waiting on something else and removed change:planned This is a planned change control-plane-api Change affecting the API of control-plane, may impact the UI, flowctl, etc waiting This change is waiting on something else labels May 4, 2026
@jshearer jshearer force-pushed the jshearer/billing_graphql branch 2 times, most recently from 183cb12 to e3ba37e Compare May 4, 2026 17:35
@jshearer jshearer force-pushed the jshearer/billing_fields branch from 4203194 to 583eeca Compare May 5, 2026 15:24
@jshearer jshearer force-pushed the jshearer/billing_graphql branch from e3ba37e to a8dea5d Compare May 6, 2026 16:17
jshearer added 2 commits May 6, 2026 14:54
Introduce a `BillingProvider` abstraction over the Stripe operations we need for customer, payment-method, invoice, and setup-intent handling, together with a Stripe-backed implementation and an in-memory test mock. The trait is deliberately scoped to outbound Stripe API calls so integration tests can stub them.

* `BillingProvider` trait: Stripe primitives plus composed default methods (`find_customer`, `require_customer`, `find_or_create_customer`, `fetch_invoice`).
* `StripeBillingProvider`: production implementation over `stripe::Client`.
* `InMemoryBillingProvider`: stateful test mock used by integration tests and local agent startup.
* `billing::db::fetch_invoice_rows`: typed read over the `invoices_ext` view with date-range and invoice-type filters.
@jshearer jshearer force-pushed the jshearer/billing_graphql branch 3 times, most recently from dbfe8d5 to ca57448 Compare May 6, 2026 19:34
jshearer added 5 commits May 6, 2026 16:04
…L surface

Add `BillingProvider` (optional) to the `App` struct and wire `--stripe-api-key` from CLI args. When absent, billing operations return "Billing is not configured". Add billing GraphQL mutations (`createBillingSetupIntent`, `setBillingPaymentMethod`, `deleteBillingPaymentMethod`) and `Tenant.billing` query with invoices, payment methods, and customer data. DataLoaders (`StripeInvoiceLoader`, `ChargeDataLoader`, `CustomerDataLoader`) are injected per-request when a billing provider is configured.
The agent refuses to start without either `STRIPE_API_KEY` or `BILLING_IN_MEMORY=true`, so every environment that starts the agent now has to supply one.

* `deploy-agent-api.yaml`: inject `STRIPE_API_KEY` from Cloud Run secret manager so the deployed agent starts.
* `platform-test.yaml`: conditionally run `graphql_billing_live_stripe` against `STRIPE_TESTMODE_API_KEY` when the secret is present.
* `mise/tasks/local/control-plane`: forward a shell-provided `STRIPE_API_KEY` into `agent.env`, or fall back to `BILLING_IN_MEMORY=true` so local dev works without additional setup.
Instead of building queries ad-hoc, let's centralize it a bit
* `CustomerDataLoader` resolves tenant names to Stripe customers. All customer lookups across `payment_methods`, `primary_payment_method`, and invoice resolution share this single DataLoader per request.
* `StripeInvoiceLoader` resolves invoices by customer ID, period, and type. Its keys now carry a `CustomerId` rather than a tenant name, so it has no customer-lookup logic of its own. Searches are parallelized via `join_all`.
* `ChargeDataLoader` resolves charges by `PaymentIntentId`, only hit when `paymentDetails` is selected.

Also moves `receipt_url` into `InvoicePaymentDetails` (both fields come from the charge) and adds a `ChargeStatus` enum so consumers can distinguish payment outcomes. Removes the unused `BillingProvider::fetch_invoice` default method.
@jshearer jshearer force-pushed the jshearer/billing_graphql branch from ca57448 to 98a8373 Compare May 6, 2026 20:06
@jshearer jshearer added control-plane waiting This change is waiting on something else labels May 8, 2026
jshearer added 2 commits May 8, 2026 14:54
…cy-Key

`find_or_create_customer` and `get_or_create_customer_for_tenant` search Stripe by tenant metadata then create if the search misses, but `customers.search` is eventually consistent so two near-simultaneous calls can both miss and both create a duplicate customer row for the same tenant.
Use a deterministic `Idempotency-Key` per tenant on `Customer::create` so concurrent or retried creations collapse inside Stripe's 24h window
Customers cannot self-serve billing email changes today. Every request requires manual Stripe intervention. This adds admin-editable billing contact fields on `tenants`, a GraphQL mutation for managing them, and a per-tenant controller that reconciles the Stripe-backed subset to Stripe asynchronously.

* `billing_email`, `billing_name`, `billing_address` columns on `tenants`. Insert trigger creates a controller task per tenant, update trigger wakes the controller when `billing_email` or `billing_address` change. Existing tenants are backfilled from `stripe.customers` CDC data.
* `setBillingContact` mutation writes the DB and returns immediately. `TenantBilling.contact` query field reads from the DB. No Stripe call in the request path.
* `BillingProvider::update_customer_billing_profile` for updating Stripe `Customer.email` and `Customer.address`.
* `TenantController` executor (`TaskType(12)`) with a `billing_contact` sub-controller that compares DB desired state against actual Stripe state and reconciles on mismatch, with retry backoff.
* `createBillingSetupIntent` and `billing-integrations` customer creation now prefer `billing_email` from the tenants table over the JWT user's email when creating new Stripe customers.
@jshearer jshearer force-pushed the jshearer/billing_fields branch from 583eeca to 4d347ab Compare May 8, 2026 19:02
@jshearer jshearer force-pushed the jshearer/billing_graphql branch from f0ac836 to 10b02d0 Compare May 15, 2026 00:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

control-plane waiting This change is waiting on something else

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant