Skip to main content

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_url returns { "customer": { ..., "checkout_url": "https://…" } } (serializer root_name: "customer", key checkout_url). Ungated (no premium). 422 { error_details: { base: ["no_linked_payment_provider"] } } if the customer isn't linked to a provider.
  • The lago-javascript-client has no typed checkout method, so we call it with a raw fetch, mirroring the existing refreshPublicKeyCache pattern (lago.provider.ts:530-543) which uses this.lagoApiUrl (normalized to bare host) + Authorization: Bearer ${this.lagoApiKey}.
  • Lago silently ignores an unknown payment_provider_code on customer create (no error) — so attaching billing_configuration is safe for the fail-open assignment flow.
  • LAGO_STRIPE_PAYMENT_PROVIDER_CODE is not yet read in code (set only in Railway). Must be added to env.ts and threaded into LagoProvider.

File Structure

  • api/src/billing/domain/ports/billing-provider.port.tsmodify — add getCheckoutUrl.
  • api/src/billing/infrastructure/providers/mock.provider.tsmodifygetCheckoutUrl"".
  • api/src/billing/infrastructure/providers/lago.provider.tsmodifygetCheckoutUrl (raw fetch); 7th ctor param stripePaymentProviderCode; ensureCustomer Stripe-link.
  • api/src/billing/infrastructure/providers/lago.provider.spec.tsmodify — tests for both.
  • api/src/common/env.tsmodify — export LAGO_STRIPE_PAYMENT_PROVIDER_CODE.
  • api/src/billing/billing.module.tsmodify — import + pass the new constant.
  • api/src/billing/interface/client-billing.controller.tsmodifyGET checkout-url.
  • api/src/billing/interface/client-billing.controller.spec.tsmodify — endpoint test.
  • frontend/src/lib/api.tsmodifygetBillingCheckoutUrl.
  • frontend/src/app/client/settings/billing/page.tsxmodify — "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 getCheckoutUrl in 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>"

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:

  1. Add LAGO_STRIPE_PAYMENT_PROVIDER_CODE to the from "../common/env" import block (alongside LAGO_PRICING_STRATEGY, etc.).
  2. 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:

  1. Add getBillingCheckoutUrl to the @/lib/api import (next to getClientBillingPortalUrl).
  2. 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.)

  1. 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_urlno_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
  1. Assign a Specialist to a test org → its Lago customer is created linked (provider_customer present — verify via GET /api/v1/customers/<orgId>).
  2. On /client/settings/billing click Add payment method → Stripe Checkout opens → enter 4242 4242 4242 4242, any future expiry/CVC → submit → redirected back.
  3. 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 "" → null and returns { url: string | null }; FE getBillingCheckoutUrl() 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.