Client Payment Flow (Card Collection) Implementation Plan
2026-06-05/06 SHIPPED: client billing rework landed via #1860 (Add Payment Method UI on
/client/settings/billing); test-case coverage added via #1986. The plan below is preserved verbatim for historical context — verify against current code (api/src/billing/,frontend/src/app/client/settings/billing/) before treating any step as authoritative.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a client add a payment method via a Stripe Checkout URL (so Lago auto-charges finalized invoices), and make it testable end-to-end with Stripe test cards.
Architecture: ensureCustomer eagerly links the Lago customer to the org's Stripe provider; a new getCheckoutUrl provider method + GET /client/billing/checkout-url endpoint returns Lago's Stripe Checkout (setup-mode) URL; the client billing page swaps its (non-functional-for-cards) Lago-portal link for an "Add payment method" button that opens that URL. Card vaulting + auto-charge happen entirely in Stripe/Lago.
Tech Stack: NestJS (api), Next.js 16 / React 19 (frontend), Lago (self-hosted, plan_per_rate), Stripe (test mode), Jest.
Spec: docs/superpowers/specs/2026-06-01-client-payment-flow-design.md
Working tree: worktree /home/alexdel/.config/superpowers/worktrees/humanwork/client-payment-flow, branch feat/client-payment-flow. Backend cmds from api/, frontend from frontend/. Commit identity: git -c user.email=alex@humanity.org -c user.name="Alex K".
Key facts (verified against Lago source + dev probes)
POST /api/v1/customers/:external_id/checkout_urlreturns{ "customer": { ..., "checkout_url": "https://…" } }(serializerroot_name: "customer", keycheckout_url). Ungated (no premium).422 { error_details: { base: ["no_linked_payment_provider"] } }if the customer isn't linked to a provider.- The
lago-javascript-clienthas no typed checkout method, so we call it with a rawfetch, mirroring the existingrefreshPublicKeyCachepattern (lago.provider.ts:530-543) which usesthis.lagoApiUrl(normalized to bare host) +Authorization: Bearer ${this.lagoApiKey}. - Lago silently ignores an unknown
payment_provider_codeon customer create (no error) — so attachingbilling_configurationis safe for the fail-open assignment flow. LAGO_STRIPE_PAYMENT_PROVIDER_CODEis not yet read in code (set only in Railway). Must be added toenv.tsand threaded intoLagoProvider.
File Structure
api/src/billing/domain/ports/billing-provider.port.ts— modify — addgetCheckoutUrl.api/src/billing/infrastructure/providers/mock.provider.ts— modify —getCheckoutUrl→"".api/src/billing/infrastructure/providers/lago.provider.ts— modify —getCheckoutUrl(raw fetch); 7th ctor paramstripePaymentProviderCode;ensureCustomerStripe-link.api/src/billing/infrastructure/providers/lago.provider.spec.ts— modify — tests for both.api/src/common/env.ts— modify — exportLAGO_STRIPE_PAYMENT_PROVIDER_CODE.api/src/billing/billing.module.ts— modify — import + pass the new constant.api/src/billing/interface/client-billing.controller.ts— modify —GET checkout-url.api/src/billing/interface/client-billing.controller.spec.ts— modify — endpoint test.frontend/src/lib/api.ts— modify —getBillingCheckoutUrl.frontend/src/app/client/settings/billing/page.tsx— modify — "Add payment method" button.
Task 1: getCheckoutUrl — port + MockProvider + LagoProvider
Files:
-
Modify:
api/src/billing/domain/ports/billing-provider.port.ts -
Modify:
api/src/billing/infrastructure/providers/mock.provider.ts -
Modify:
api/src/billing/infrastructure/providers/lago.provider.ts -
Test:
api/src/billing/infrastructure/providers/lago.provider.spec.ts -
Step 1: Add the port method
In billing-provider.port.ts, immediately after the getCustomerPortalUrl(externalCustomerId: string): Promise<string>; line, add:
/** Returns a Stripe Checkout (setup-mode) URL to collect/vault a card, or "" if the customer isn't linked to a provider. */
getCheckoutUrl(externalCustomerId: string): Promise<string>;
- Step 2: Implement in MockProvider
In mock.provider.ts, after the getCustomerPortalUrl method, add:
async getCheckoutUrl(_externalCustomerId: string): Promise<string> {
return "";
}
- Step 3: Write the failing LagoProvider test
In lago.provider.spec.ts, add a new describe block (place it near the other LagoProvider tests). It mocks the global fetch the provider uses:
describe("LagoProvider.getCheckoutUrl", () => {
const origFetch = global.fetch;
afterEach(() => { global.fetch = origFetch; });
function provider() {
// ctor: (client, specialistPlanCode, webhookSecret, lagoApiUrl, lagoApiKey, pricingStrategy, stripePaymentProviderCode)
return new LagoProvider(stubClient() as any, "specialist_monthly", "whsec", "https://lago.test", "key123");
}
it("returns the Stripe checkout_url from the customer payload", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ customer: { checkout_url: "https://checkout.stripe/abc" } }),
}) as any;
const url = await provider().getCheckoutUrl("org-1");
expect(url).toBe("https://checkout.stripe/abc");
const [calledUrl, opts] = (global.fetch as jest.Mock).mock.calls[0];
expect(calledUrl).toBe("https://lago.test/api/v1/customers/org-1/checkout_url");
expect(opts.method).toBe("POST");
expect(opts.headers.Authorization).toBe("Bearer key123");
});
it("returns '' on a non-OK response (e.g. no_linked_payment_provider)", async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, json: async () => ({}) }) as any;
expect(await provider().getCheckoutUrl("org-1")).toBe("");
});
it("returns '' when lagoApiUrl is not configured", async () => {
const p = new LagoProvider(stubClient() as any, "specialist_monthly", "whsec", "", "");
expect(await p.getCheckoutUrl("org-1")).toBe("");
});
});
(If stubClient() is not already a top-level helper in this spec, use the same client-stub construction the other tests in this file use.)
- Step 4: Run it to verify it fails
Run: npm --prefix api test -- src/billing/infrastructure/providers/lago.provider.spec.ts -t "getCheckoutUrl"
Expected: FAIL — getCheckoutUrl is not a function.
- Step 5: Implement
getCheckoutUrlin LagoProvider
In lago.provider.ts, add this method right after getCustomerPortalUrl:
/**
* Generate a Stripe Checkout (setup-mode) URL to collect/vault a card.
* Lago endpoint: POST /api/v1/customers/:external_id/checkout_url →
* { customer: { checkout_url } }. Requires the customer to be linked to a
* payment provider (else 422 no_linked_payment_provider → we return "").
* Raw fetch (the JS client has no typed method), mirroring refreshPublicKeyCache.
*/
async getCheckoutUrl(externalCustomerId: string): Promise<string> {
if (!this.lagoApiUrl) return "";
try {
const bareHost = this.lagoApiUrl.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
const url = `${bareHost}/api/v1/customers/${encodeURIComponent(externalCustomerId)}/checkout_url`;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (this.lagoApiKey) headers["Authorization"] = `Bearer ${this.lagoApiKey}`;
const res = await fetch(url, { method: "POST", headers });
if (!res.ok) return "";
const body = (await res.json()) as { customer?: { checkout_url?: string } };
return body?.customer?.checkout_url ?? "";
} catch {
return "";
}
}
- Step 6: Run tests to verify they pass
Run: npm --prefix api test -- src/billing/infrastructure/providers/lago.provider.spec.ts -t "getCheckoutUrl"
Expected: PASS (3 tests).
- Step 7: Build + commit
Run: npm --prefix api run build (no errors).
cd /home/alexdel/.config/superpowers/worktrees/humanwork/client-payment-flow
git add api/src/billing/domain/ports/billing-provider.port.ts api/src/billing/infrastructure/providers/mock.provider.ts api/src/billing/infrastructure/providers/lago.provider.ts api/src/billing/infrastructure/providers/lago.provider.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): getCheckoutUrl provider method (Stripe Checkout URL)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 2: Eagerly link customers to Stripe in ensureCustomer
Files:
-
Modify:
api/src/common/env.ts -
Modify:
api/src/billing/infrastructure/providers/lago.provider.ts -
Modify:
api/src/billing/billing.module.ts -
Test:
api/src/billing/infrastructure/providers/lago.provider.spec.ts -
Step 1: Add the env constant
In api/src/common/env.ts, after the LAGO_SPECIALIST_PLAN_CODE export (line ~73), add:
export const LAGO_STRIPE_PAYMENT_PROVIDER_CODE = process.env.LAGO_STRIPE_PAYMENT_PROVIDER_CODE ?? ""; // Lago payment-provider code (e.g. "stripe_test"); empty = don't link customers to a PSP
- Step 2: Add the constructor param to LagoProvider
In lago.provider.ts, add a 7th constructor parameter after pricingStrategy:
private readonly pricingStrategy: string = "plan_per_rate",
/** Lago payment-provider code (e.g. "stripe_test"). When set, ensureCustomer links the customer to it. Empty = no link. */
private readonly stripePaymentProviderCode: string = "",
) {}
- Step 3: Write the failing ensureCustomer test
In lago.provider.spec.ts add:
describe("LagoProvider.ensureCustomer Stripe link", () => {
it("attaches billing_configuration when a provider code is set", async () => {
const client = stubClient();
const p = new LagoProvider(client as any, "specialist_monthly", "whsec", "https://lago.test", "k", "plan_per_rate", "stripe_test");
await p.ensureCustomer({ orgId: "o1", name: "Acme", email: "a@acme.test", externalId: "o1" });
const arg = (client.customers.createCustomer as jest.Mock).mock.calls[0][0];
expect(arg.customer.billing_configuration).toEqual({
payment_provider: "stripe",
payment_provider_code: "stripe_test",
sync_with_provider: true,
});
});
it("omits billing_configuration when no provider code is set", async () => {
const client = stubClient();
const p = new LagoProvider(client as any, "specialist_monthly", "whsec", "https://lago.test", "k", "plan_per_rate", "");
await p.ensureCustomer({ orgId: "o1", name: "Acme", email: "a@acme.test", externalId: "o1" });
const arg = (client.customers.createCustomer as jest.Mock).mock.calls[0][0];
expect(arg.customer.billing_configuration).toBeUndefined();
});
});
(stubClient() must expose customers.createCustomer as a jest.fn() — use this spec's existing client stub; if it lacks createCustomer, add it as jest.fn().mockResolvedValue({}).)
- Step 4: Run it to verify it fails
Run: npm --prefix api test -- src/billing/infrastructure/providers/lago.provider.spec.ts -t "ensureCustomer Stripe link"
Expected: FAIL — billing_configuration is undefined in the first test.
- Step 5: Implement the link in
ensureCustomer
Replace the body of ensureCustomer in lago.provider.ts with:
async ensureCustomer(input: {
orgId: string;
name: string;
email: string;
externalId: string;
}): Promise<{ externalCustomerId: string }> {
const customer: Record<string, unknown> = {
external_id: input.externalId,
name: input.name,
email: input.email,
};
// Eagerly link to the org's Stripe provider so the checkout URL can vault a
// card and Lago can auto-charge invoices. Best-effort: an unset/unknown code
// leaves the customer unlinked but never fails creation (Lago ignores it).
if (this.stripePaymentProviderCode) {
customer.billing_configuration = {
payment_provider: "stripe",
payment_provider_code: this.stripePaymentProviderCode,
sync_with_provider: true,
};
}
await this.client.customers.createCustomer({ customer });
// We use orgId as the Lago external_customer_id — it's idempotent.
return { externalCustomerId: input.externalId };
}
- Step 6: Pass the code from the module
In billing.module.ts:
- Add
LAGO_STRIPE_PAYMENT_PROVIDER_CODEto thefrom "../common/env"import block (alongsideLAGO_PRICING_STRATEGY, etc.). - Add it as the 7th argument to
new LagoProvider(...):
const provider = new LagoProvider(
lagoClient,
LAGO_SPECIALIST_PLAN_CODE,
LAGO_WEBHOOK_SECRET,
lagoBareHost,
LAGO_API_KEY,
LAGO_PRICING_STRATEGY,
LAGO_STRIPE_PAYMENT_PROVIDER_CODE, // links customers to this Stripe provider in ensureCustomer
);
- Step 7: Run tests + build
Run: npm --prefix api test -- src/billing/infrastructure/providers/lago.provider.spec.ts -t "ensureCustomer Stripe link" → PASS (2).
Run: npm --prefix api run build → no errors.
- Step 8: Commit
git add api/src/common/env.ts api/src/billing/infrastructure/providers/lago.provider.ts api/src/billing/infrastructure/providers/lago.provider.spec.ts api/src/billing/billing.module.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): link customers to Stripe in ensureCustomer (eager, gated on provider code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 3: GET /client/billing/checkout-url endpoint
Files:
-
Modify:
api/src/billing/interface/client-billing.controller.ts -
Test:
api/src/billing/interface/client-billing.controller.spec.ts -
Step 1: Add the handler
In client-billing.controller.ts, add after the portalUrl handler:
@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 };
}
- Step 2: Add the spec test
In client-billing.controller.spec.ts, mirror the existing portalUrl test. The controller is built with (summaryQuery, provider, subs); provide a provider stub with getCheckoutUrl and a subs stub with findByOrgId. Add:
it("checkoutUrl returns the provider checkout url for a linked customer", async () => {
const provider = { getCheckoutUrl: jest.fn().mockResolvedValue("https://checkout.stripe/x") };
const subs = { findByOrgId: jest.fn().mockResolvedValue([{ externalCustomerId: "cus_1" }]) };
const ctrl = new ClientBillingController({} as any, provider as any, subs as any);
const res = await ctrl.checkoutUrl(clientReq("o1") as any);
expect(res).toEqual({ url: "https://checkout.stripe/x" });
expect(provider.getCheckoutUrl).toHaveBeenCalledWith("cus_1");
});
it("checkoutUrl returns { url: null } when the org has no Lago customer", async () => {
const provider = { getCheckoutUrl: jest.fn() };
const subs = { findByOrgId: jest.fn().mockResolvedValue([]) };
const ctrl = new ClientBillingController({} as any, provider as any, subs as any);
expect(await ctrl.checkoutUrl(clientReq("o1") as any)).toEqual({ url: null });
expect(provider.getCheckoutUrl).not.toHaveBeenCalled();
});
Use the same clientReq(orgId) helper the existing portalUrl/summary tests use (it builds a req whose user.orgMemberships[0].orgId resolves to orgId). If the existing tests construct the request differently, copy that exact construction.
- Step 3: Run tests + build
Run: npm --prefix api test -- src/billing/interface/client-billing.controller.spec.ts → PASS.
Run: npm --prefix api run build → no errors.
- Step 4: Commit
git add api/src/billing/interface/client-billing.controller.ts api/src/billing/interface/client-billing.controller.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): GET /client/billing/checkout-url (Stripe Checkout URL)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 4: Frontend — "Add payment method" button
Files:
-
Modify:
frontend/src/lib/api.ts -
Modify:
frontend/src/app/client/settings/billing/page.tsx -
Step 1: Add the API client function
In frontend/src/lib/api.ts, near getClientBillingPortalUrl (~line 3243), add:
export async function getBillingCheckoutUrl(): Promise<{ url: string | null }> {
return apiFetch<{ url: string | null }>("/client/billing/checkout-url");
}
- Step 2: Swap the portal CTA for an "Add payment method" button
In frontend/src/app/client/settings/billing/page.tsx:
- Add
getBillingCheckoutUrlto the@/lib/apiimport (next togetClientBillingPortalUrl). - Add an add-card handler inside the component (near the other handlers):
const handleAddPaymentMethod = async () => {
try {
const { url } = await getBillingCheckoutUrl();
if (url) {
window.open(url, "_blank", "noopener,noreferrer");
} else {
// Surface gracefully; mirror however this page already shows inline notices
// (e.g. a toast or a small message). Do not throw.
alert("Payment isn't available yet. Please contact your account manager.");
}
} catch {
alert("Couldn't start payment setup. Please try again.");
}
};
(If the page already uses a toast/notice helper, use that instead of alert to match house style.)
- Replace the portal-link block (currently around lines 676-682,
{portalUrl && portalUrl.startsWith("https://") && ( <a href={portalUrl} … >…</a> )}) with:
<button
type="button"
onClick={handleAddPaymentMethod}
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--primary)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "8px 14px",
background: "transparent",
cursor: "pointer",
}}
>
Add payment method
</button>
Leave the existing portalUrl state/fetch in place (now unused but harmless) so you don't have to alter the Promise.allSettled destructuring — or, if you prefer, remove getClientBillingPortalUrl from the import + its allSettled entry + the portalUrl state cleanly. Do not break the allSettled result destructuring if you leave it.
- Step 3: Verify build + lint
Run: npm --prefix frontend run build → succeeds.
Run: npx eslint frontend/src/lib/api.ts frontend/src/app/client/settings/billing/page.tsx (from repo root, in frontend/: npx eslint src/lib/api.ts src/app/client/settings/billing/page.tsx) → no errors on these files.
- Step 4: Commit
git add frontend/src/lib/api.ts frontend/src/app/client/settings/billing/page.tsx
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing-ui): Add payment method button -> Stripe Checkout URL
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Task 5: Full verification + config + manual e2e
Files: none (verification + ops)
- Step 1: Backend suite + build
Run: npm --prefix api test -- src/billing → all green.
Run: npm --prefix api run build → no errors.
- Step 2: Frontend build
Run: npm --prefix frontend run build → succeeds.
- Step 3: Config (dev) — set the real provider code
Set LAGO_STRIPE_PAYMENT_PROVIDER_CODE on the dev API service (Railway humanwork→dev→api) to the exact provider code read from the Lago UI (Settings → Integrations → Stripe — the code field, NOT the secret key). This is the gating config item: without the right code, ensureCustomer won't link and checkout_url → no_linked_payment_provider. The dev org already has a Stripe test provider configured (key ending …Hjq).
success_redirect_url is optional — Lago falls back to a default success page if blank (verified: stripe_service.rb:138 …success_redirect_url.presence || <default>). Set it to https://dev.h852.work/client/settings/billing for nicer return UX, but it does not block the test.
- Step 4: Manual e2e with a Stripe test card
- Assign a Specialist to a test org → its Lago customer is created linked (
provider_customerpresent — verify viaGET /api/v1/customers/<orgId>). - On
/client/settings/billingclick Add payment method → Stripe Checkout opens → enter4242 4242 4242 4242, any future expiry/CVC → submit → redirected back. - Generate an invoice (e.g. terminate a sub) → Lago auto-charges → invoice
payment_status: succeeded→ webhook → mirror shows Paid.
- Step 5: Commit (if any doc/status updates)
Update docs/implementation-status.md billing section to note client payment collection is wired (Add payment method → Stripe Checkout). Commit:
git add docs/implementation-status.md
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "docs: mark client payment collection wired (Stripe Checkout)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
Self-Review notes
- Spec coverage: getCheckoutUrl (T1), ensureCustomer Stripe-link + config threading (T2), checkout-url endpoint (T3), FE Add-payment-method CTA replacing portal (T4), config + mock-card e2e (T5). Error handling (return ""/null, best-effort link) baked into T1/T2/T3.
- Type consistency:
getCheckoutUrl(externalCustomerId: string): Promise<string>identical in port/mock/Lago; controller maps"" → nulland returns{ url: string | null }; FEgetBillingCheckoutUrl()returns{ url: string | null }. - Ordering: T1 adds the port method to all implementors together (compiles); T2's new ctor param has a default (compiles even before the module passes it, but the module is updated in the same task); each task leaves a compiling, green tree.
- Out of scope (per spec): update/remove card, dunning UI, removing the legacy portal-url endpoint.