Client Payment Flow (Card Collection) — Design
Date: 2026-06-01 · Owner: Alex · Status: SHIPPED 2026-06-05/06
2026-06-05/06 SHIPPED: implementation landed via #1860 (FE + backend integration) and #1986 (test coverage). The design below is preserved verbatim; cross-check against
api/src/billing/+frontend/src/app/client/settings/billing/before citing any specific code path.
Depends on: invoices view shipped (#1155 + #1180, merged). Provider-leveraged billing on Lago (self-hosted, plan_per_rate).
1. Goal
Let a client add a payment method so finalized invoices get paid, and make the path testable end-to-end with Stripe test cards on the dev Lago (free tier).
Scope (locked — minimal): add a card + Lago auto-charges finalized invoices off-session. Out of scope: update/remove card, set-default, dunning/retry UX, in-app payment-status banners. (Invoices already render via #1155/#1180.)
2. Key facts established during brainstorm (from Lago source + dev probes)
- Card collection = Stripe Checkout URL. Lago exposes
POST /api/v1/customers/:external_id/checkout_url(→Customers::GenerateCheckoutUrlService), no premium gate — returns a Stripe setup-mode Checkout URL to vault a card. Requires the customer to be linked to a payment provider (else422 no_linked_payment_provider). - The Lago Customer Portal does NOT collect cards. Its mutations are invoices/info/wallet only — no add-payment-method. So the existing "Manage payment methods →
portal_url" button is an invoice surface, not card entry, and is being replaced. ensureCustomerdoes not link customers to Stripe today — it creates the customer with onlyexternal_id/name/email. Confirmed: probe customers havepayment_provider: nullandcheckout_url→no_linked_payment_provider.- Lago silently ignores an unknown
payment_provider_codeon customer create (it just doesn't link; no error) — so attaching the config can't break the fail-open assignment flow. - Auto-charge is off-session and Lago-driven: once a card is vaulted, Lago charges finalized invoices itself and emits
invoice.payment_status_updated; our existing webhook normalization already mapspayment_status→ invoice status. We do not trigger payment from our app.
3. End-to-end flow
- Assign Specialist →
ensureCustomercreates the Lago customer with Stripe linked (billing_configuration.payment_provider = stripe,sync_with_provider = true) → Lago provisions the Stripe customer (provider_customer). - Client opens
/client/settings/billing→ clicks Add payment method. - FE →
GET /client/billing/checkout-url→ resolve authed org's Lago customer →provider.getCheckoutUrl(...)→ LagoPOST /customers/:id/checkout_url→{ url }. - FE opens the Stripe Checkout (setup) URL (new tab). Client enters card → Stripe vaults it → returns to
/client/settings/billingvia the provider'ssuccess_redirect_url. - On invoice finalization, Lago auto-charges the vaulted card off-session →
payment_status: succeeded→ webhookinvoice.payment_status_updated→ mirror flips to paid (existing normalization, no new code).
4. Backend changes
api/src/billing/infrastructure/providers/lago.provider.ts
ensureCustomer— when a Stripe provider code is configured, include:When the code is unset (orbilling_configuration: {
payment_provider: "stripe",
payment_provider_code: <LAGO_STRIPE_PAYMENT_PROVIDER_CODE>,
sync_with_provider: true,
}MockProvider), behave exactly as today (no link). Best-effort: an unknown code leaves the customer unlinked but does not fail customer creation / the sync command.getCheckoutUrl(externalCustomerId: string): Promise<string>—POST /customers/:id/checkout_url; return""onno_linked_payment_provider/ empty / error (never throw to the controller).
api/src/billing/domain/ports/billing-provider.port.ts — add getCheckoutUrl(externalCustomerId: string): Promise<string>.
api/src/billing/infrastructure/providers/mock.provider.ts — getCheckoutUrl returns "".
api/src/billing/interface/client-billing.controller.ts — add GET checkout-url:
@Get("checkout-url")
async checkoutUrl(@Req() req: Request) {
const orgId = resolveOrgIdFromUser(req);
const subs = await this.subs.findByOrgId(orgId);
const customerId = subs.find((s) => s.externalCustomerId)?.externalCustomerId;
if (!customerId) return { url: null };
const url = await this.provider.getCheckoutUrl(customerId);
return { url: url || null };
}
Same org-resolution + IDOR guard as the existing portal-url handler.
Config: the Stripe provider code is sourced from LAGO_STRIPE_PAYMENT_PROVIDER_CODE (already wired into the billing module / env). Its value must equal the actual code configured on the Lago org (confirmed from the Lago UI — may differ from stripe_test).
5. Frontend changes
frontend/src/lib/api.ts — add getBillingCheckoutUrl(): Promise<{ url: string | null }> → GET /client/billing/checkout-url.
frontend/src/app/client/settings/billing/page.tsx — replace the "Manage payment methods → portal" CTA with an "Add payment method" button:
- on click →
getBillingCheckoutUrl()→ ifurlpresent,window.open(url, "_blank", "noopener"); ifnull, show "Payment isn't available yet." (no crash). - The existing portal-url fetch/button is removed (the FE already renders invoices in-app, so the portal link is redundant). The
/client/billing/portal-urlBE endpoint may remain (harmless) or be removed later — out of scope here.
6. Config / prerequisites (for the mock-card test)
LAGO_STRIPE_PAYMENT_PROVIDER_CODEon the dev API service = the real provider code from the Lago UI.- The Lago org's Stripe provider must hold a Stripe TEST secret key (
sk_test_…). - Provider
success_redirect_url→https://dev.h852.work/client/settings/billing.
7. Error handling / edges
- No Stripe provider / customer not linked →
getCheckoutUrlreturns""→ endpoint returns{ url: null }→ button shows the unavailable message. Never 500. ensureCustomerStripe-link is best-effort — a bad/missing code leaves the customer unlinked but does not fail Specialist assignment (fail-open preserved).MockProviderpath (no Lago) →getCheckoutUrlnull → button unavailable; nothing breaks.
8. Testing
Unit (api):
ensureCustomerincludesbilling_configuration(payment_provider/code/sync) when a code is configured; omits it when unset.getCheckoutUrlcallsPOST /customers/:id/checkout_urland returns""onno_linked_payment_provider.ClientBillingController.checkoutUrlreturns{ url }for a linked customer and{ url: null }when no customer/url.
Frontend (jest/RTL):
- billing page renders "Add payment method"; clicking it calls
getBillingCheckoutUrland opens the returned URL; null url → unavailable message (no portal link present).
E2E (dev, manual — the mock-card validation):
- Confirm the Lago org's Stripe provider code; set
LAGO_STRIPE_PAYMENT_PROVIDER_CODEaccordingly. - Assign a Specialist to a test org → customer created linked to Stripe (
provider_customerpresent). - Billing page → Add payment method → Stripe Checkout → card
4242 4242 4242 4242→ vaulted → redirected back. - Generate an invoice (terminate a sub → immediate finalized invoice).
- Confirm Lago auto-charges →
payment_status: succeeded→ webhook → mirror/billing page shows Paid.
9. Out of scope / follow-ups
- Update/remove card, default method, dunning/retry UX, in-app payment-status banners.
- Removing the now-unused
/client/billing/portal-urlendpoint +getCustomerPortalUrl(deprecate later). - Locking down Lago signup / provisioning the recruiter org (separate track).