Skip to main content

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 (else 422 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.
  • ensureCustomer does not link customers to Stripe today — it creates the customer with only external_id/name/email. Confirmed: probe customers have payment_provider: null and checkout_urlno_linked_payment_provider.
  • Lago silently ignores an unknown payment_provider_code on 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 maps payment_status → invoice status. We do not trigger payment from our app.

3. End-to-end flow

  1. Assign SpecialistensureCustomer creates the Lago customer with Stripe linked (billing_configuration.payment_provider = stripe, sync_with_provider = true) → Lago provisions the Stripe customer (provider_customer).
  2. Client opens /client/settings/billing → clicks Add payment method.
  3. FE → GET /client/billing/checkout-url → resolve authed org's Lago customer → provider.getCheckoutUrl(...) → Lago POST /customers/:id/checkout_url{ url }.
  4. FE opens the Stripe Checkout (setup) URL (new tab). Client enters card → Stripe vaults it → returns to /client/settings/billing via the provider's success_redirect_url.
  5. On invoice finalization, Lago auto-charges the vaulted card off-session → payment_status: succeeded → webhook invoice.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:
    billing_configuration: {
    payment_provider: "stripe",
    payment_provider_code: <LAGO_STRIPE_PAYMENT_PROVIDER_CODE>,
    sync_with_provider: true,
    }
    When the code is unset (or 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 "" on no_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.tsgetCheckoutUrl 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() → if url present, window.open(url, "_blank", "noopener"); if null, 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-url BE endpoint may remain (harmless) or be removed later — out of scope here.

6. Config / prerequisites (for the mock-card test)

  • LAGO_STRIPE_PAYMENT_PROVIDER_CODE on 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_urlhttps://dev.h852.work/client/settings/billing.

7. Error handling / edges

  • No Stripe provider / customer not linked → getCheckoutUrl returns "" → endpoint returns { url: null } → button shows the unavailable message. Never 500.
  • ensureCustomer Stripe-link is best-effort — a bad/missing code leaves the customer unlinked but does not fail Specialist assignment (fail-open preserved).
  • MockProvider path (no Lago) → getCheckoutUrl null → button unavailable; nothing breaks.

8. Testing

Unit (api):

  • ensureCustomer includes billing_configuration (payment_provider/code/sync) when a code is configured; omits it when unset.
  • getCheckoutUrl calls POST /customers/:id/checkout_url and returns "" on no_linked_payment_provider.
  • ClientBillingController.checkoutUrl returns { url } for a linked customer and { url: null } when no customer/url.

Frontend (jest/RTL):

  • billing page renders "Add payment method"; clicking it calls getBillingCheckoutUrl and opens the returned URL; null url → unavailable message (no portal link present).

E2E (dev, manual — the mock-card validation):

  1. Confirm the Lago org's Stripe provider code; set LAGO_STRIPE_PAYMENT_PROVIDER_CODE accordingly.
  2. Assign a Specialist to a test org → customer created linked to Stripe (provider_customer present).
  3. Billing page → Add payment method → Stripe Checkout → card 4242 4242 4242 4242 → vaulted → redirected back.
  4. Generate an invoice (terminate a sub → immediate finalized invoice).
  5. Confirm Lago auto-chargespayment_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-url endpoint + getCustomerPortalUrl (deprecate later).
  • Locking down Lago signup / provisioning the recruiter org (separate track).