billing: add tenant billing contact fields and per-tenant controller#2902
Open
jshearer wants to merge 9 commits into
Open
billing: add tenant billing contact fields and per-tenant controller#2902jshearer wants to merge 9 commits into
jshearer wants to merge 9 commits into
Conversation
dd24bcb to
2d02827
Compare
2d02827 to
769438a
Compare
cc73e15 to
2184f2e
Compare
769438a to
4203194
Compare
183cb12 to
e3ba37e
Compare
4203194 to
583eeca
Compare
e3ba37e to
a8dea5d
Compare
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.
dbfe8d5 to
ca57448
Compare
…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.
ca57448 to
98a8373
Compare
…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.
583eeca to
4d347ab
Compare
f0ac836 to
10b02d0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.billing_email,billing_name, andbilling_addressfields totenants, letting admins self-serve billing contact changes through a newsetBillingContactGraphQL mutation instead of requesting manual Stripe editsTenantControllerautomation (the first tenant-scoped automation) that reconciles DB-authoritative billing contact data to Stripe asynchronouslycreateBillingSetupIntent,billing-integrations) to prefer tenant-managed billing email over JWT claims when creating new Stripe customersHow 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_profileif 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:TenantControllerStatecontains a nestedBillingContactStatusmanaged by thebilling_contactsub-module, so future tenant automations can be added as additional sub-controllers.Migration
20260429120000_tenant_controller_billing_contact.sql:wake_tenant_controllercreates the task on-demand ifcontroller_task_idis null, sosetBillingContactor customer-creation paths work without a pre-existing task row.billing_emailandbilling_addressfrom CDC-syncedstripe.customersso 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