Skip to content

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

Open
jshearer wants to merge 1 commit intojshearer/billing_graphqlfrom
jshearer/billing_fields
Open

billing: add tenant billing contact fields and per-tenant controller#2902
jshearer wants to merge 1 commit intojshearer/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 5 times, most recently 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
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
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