Provider-Leveraged Billing โ Implementation Plan
๐ข SHIPPED โ 2026-05. Provider-leveraged billing landed with Lago as the provider via #1069 (initial Lago wiring), #1105 (subscription lifecycle on OSA changes), #1155 (webhook-driven invoice mirror + frontend billing UI on Lago), and #1016 (legacy fixed-plan module removed โ Phase 7). PR #1165 was a parallel ops-billing rebuild but was closed-unmerged; #1155 carries the FE surfaces. Architecture is codified in
docs/decisions/2026-05-29-lago-pricing-strategy.md. This plan is retained for historical context.
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: Build per-Specialist monthly billing that delegates scheduling, calendar-day proration, invoice generation + consolidation, and dunning to the billing provider's native subscription engine (Lago, self-hosted), reacting to domain changes and provider webhooks โ and remove the legacy fixed-plan billing module it supersedes. Each Specialist is an independent subscription; the provider groups an org's subscriptions into one consolidated, itemized invoice per cycle.
Architecture: Hexagonal billing/ module (domain โ application โ infrastructure โ interface). Domain changes (assign / re-rate / unassign a Specialist) drive create / update / cancel subscription; provider webhooks drive a thin local invoice/subscription mirror. No cron, no proration math, no invoice origination. One subscription per (org ร Specialist); bill in arrears.
Tech Stack: NestJS 11, TypeORM (PostgreSQL 16; better-sqlite3 in tests), Jest, lago-javascript-client (self-hosted Lago), Stripe (payment collection, via Lago's PSP integration).
Spec: docs/superpowers/specs/2026-05-26-billing-provider-leveraged-design.md
Branch: feat/billing-provider-leveraged (off dev). Supersedes #702 / PR #762 (closed without merge).
Conventions (read once)โ
- Persistence files are
*.storage.ts(TypeORM@Entity). Domain files (domain/*.ts) are pure โ no decorators, no TypeORM imports. - Entities are registered explicitly (no globs) in BOTH
api/src/data-source.ts(theentities: [...]array, line ~98) andapi/src/app.module.tsTypeOrmModule.forRootAsync(line ~153). Adding a@Entityrequires editing both. - Commands = atomic mutating use cases; Queries = atomic reads; controllers invoke only commands/queries; reusable logic = a service. Thin wrappers are fine.
- Money =
bigintcents + acurrencystring field (no Money class โ YAGNI). Store cents asbigint; TypeORM column typebigintreturns a string at runtime โ convert withBigInt(value)on read. - Tests use better-sqlite3 in-memory (
api/testhelpers), run withnpm --prefix api test -- <path>. CI uses real Postgres. - Commits: end every commit message with the trailer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>. Commit using inline identity:git -c user.email=alex@humanity.org -c user.name="Alex K" commit ...(never modify git config). - TypeORM column note for sqlite tests: use
type: "varchar"for enums (sqlite has no native enum) andtype: "bigint"for cents; both work on Postgres and sqlite.
File Structureโ
api/src/billing/
โโโ domain/
โ โโโ billing-subscription-status.ts # union type
โ โโโ invoice-status.ts # union type
โ โโโ billing-event.ts # normalized webhook event + NormalizedInvoice
โ โโโ errors.ts # BillingError subclasses
โ โโโ money.ts # centsToMajor / majorToCents (exact bigint, no float)
โ โโโ billing-subscription.ts # pure entity + behavior
โ โโโ invoice.ts # pure entity
โ โโโ ports/
โ โโโ billing-subscription.repository.port.ts
โ โโโ invoice.repository.port.ts
โ โโโ billing-provider.port.ts
โโโ application/
โ โโโ commands/
โ โ โโโ sync-specialist-subscription.command.ts
โ โ โโโ cancel-specialist-subscription.command.ts
โ โ โโโ handle-billing-webhook.command.ts
โ โโโ queries/
โ โโโ client-billing-summary.query.ts
โ โโโ ops-billing-overview.query.ts
โโโ infrastructure/
โ โโโ billing-subscription.storage.ts
โ โโโ invoice.storage.ts
โ โโโ billing-subscription.mapper.ts
โ โโโ invoice.mapper.ts
โ โโโ billing-subscription.repository.ts
โ โโโ invoice.repository.ts
โ โโโ providers/
โ โโโ mock.provider.ts
โ โโโ lago.provider.ts
โโโ interface/
โ โโโ client-billing.controller.ts
โ โโโ ops-billing.controller.ts
โ โโโ billing-webhook.controller.ts
โโโ billing.module.ts
Tokens shared across tasks (defined in Phase 1, referenced everywhere):
BILLING_PROVIDER(DI token, string) โ resolves to aBillingProviderPortBILLING_SUBSCRIPTION_REPOSITORYโBillingSubscriptionRepositoryPortINVOICE_REPOSITORYโInvoiceRepositoryPort
Phase 0 โ Prerequisites & Configโ
Task 0.1: Install the Lago clientโ
Files: Modify api/package.json, api/package-lock.json
Lago's official Node client is
lago-javascript-client. Confirm the latest version on npm at install time and pin it.
- Step 1: Install
npm --prefix api install lago-javascript-client
- Step 2: Verify it resolves
Run: node -e "require('/home/alexdel/Projects/humanwork/api/node_modules/lago-javascript-client'); console.log('ok')"
Expected: ok
- Step 3: Commit
git add api/package.json api/package-lock.json
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "chore(billing): add Lago client"
Task 0.2: Add billing config to env + exampleโ
Files: Modify api/src/common/env.ts, .env.example
- Step 1: Add config accessors to
api/src/common/env.ts(append, matching the file's existingprocess.envaccessor style):
/** Billing provider selector: 'lago' | 'mock'. Defaults to 'mock' when unset or no LAGO_API_KEY. */
export const BILLING_PROVIDER = (
process.env.BILLING_PROVIDER ?? (process.env.LAGO_API_KEY ? "lago" : "mock")
).toLowerCase();
export const LAGO_API_URL = process.env.LAGO_API_URL ?? ""; // self-hosted Lago base URL
export const LAGO_API_KEY = process.env.LAGO_API_KEY ?? "";
export const LAGO_WEBHOOK_SECRET = process.env.LAGO_WEBHOOK_SECRET ?? "";
export const LAGO_SPECIALIST_PLAN_CODE = process.env.LAGO_SPECIALIST_PLAN_CODE ?? "";
- Step 2: Document in
.env.example(append under a# Billingheading):
# Billing (per-Specialist monthly via self-hosted Lago). Mock mode if LAGO_API_KEY absent.
BILLING_PROVIDER=mock # lago | mock
LAGO_API_URL= # base URL of the self-hosted Lago API (e.g. http://lago-api:3000)
LAGO_API_KEY=
LAGO_WEBHOOK_SECRET=
LAGO_SPECIALIST_PLAN_CODE= # the generic "Specialist Monthly" plan code in Lago
# Payment collection is configured INSIDE Lago via its Stripe PSP integration (Stripe keys live in Lago).
- Step 3: Build to confirm no TS errors
Run: npm --prefix api run build
Expected: build succeeds.
- Step 4: Commit
git add api/src/common/env.ts .env.example
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): billing provider config (env)"
Task 0.3: Lago behavior spike (GATING โ verify before building the adapter)โ
Files: none (a throwaway spike against a local Lago instance + notes in the PR).
The design (spec ยง3.1, ยง3.2) rests on three Lago behaviors. Confirm all three against a real self-hosted Lago before implementing
LagoProvider(Task 3.2); if any is false, surface it โ the consolidation/proration approach may need to change (e.g. one subscription per org with one charge per Specialist).
-
Step 1: Use the existing Railway Lago instance (Lago is already self-hosted on Railway; no local stand-up needed โ point at it via
LAGO_API_URL, or a staging Lago project to avoid touching production data). Create a disposable org customer + the generic "Specialist Monthly" plan (LAGO_SPECIALIST_PLAN_CODE). See the runnable probe checklist indocs/superpowers/spikes/2026-05-27-lago-billing-spike.md. -
Step 2: Verify invoice consolidation. Create two subscriptions for the same customer, both anchored to the same billing day, with different per-subscription override amounts. Advance/trigger billing. Confirm Lago emits ONE invoice with TWO
fees(one per subscription) โ not two invoices. Record the invoice JSON shape (esp.fees[]โ{ subscription, amount_cents, from/to dates }) for theparseWebhooknormalization in Task 3.2. -
Step 3: Verify calendar-day proration. Start a subscription mid-month; confirm the prorated fee equals
monthlyRate ร activeDays รท daysInThatMonth(actual days in that month). Record which Lago proration setting produces this. -
Step 4: Verify arrears. Confirm the plan can be configured
pay_in_arrears(charge at period end), and that a mid-period termination produces a final prorated fee for days used (no advance charge / credit-note dance). -
Step 5: Verify the webhook + signature. Capture a real
invoice.created(andinvoice.payment_status_updated) webhook payload + the signature header name + verification method, for Task 3.2'sparseWebhook. -
Step 6: Record findings in a comment on the PR. If consolidation (Step 2) does not hold, STOP and re-open the design decision before proceeding.
Phase 1 โ Domain Layer (pure)โ
Task 1.1: Status unions, errors, and the BillingEvent contractโ
Files:
-
Create:
api/src/billing/domain/billing-subscription-status.ts -
Create:
api/src/billing/domain/invoice-status.ts -
Create:
api/src/billing/domain/errors.ts -
Create:
api/src/billing/domain/billing-event.ts -
Test:
api/src/billing/domain/billing-event.spec.ts -
Step 1: Write the status unions
billing-subscription-status.ts:
export type BillingSubscriptionStatus =
| "pending" // local row created, provider subscription not yet confirmed
| "active" // provider subscription live
| "canceled" // unassigned; provider subscription canceled
| "sync_failed"; // provider call failed after retries; needs SA retry
/**
* Which provider operation failed when status is "sync_failed". Drives how the
* SA "Retry sync" action re-routes: a failed `cancel` must retry cancellation,
* NOT a price update (which would resurrect a subscription meant to be canceled).
*/
export type SyncFailedOp = "create" | "update" | "cancel";
invoice-status.ts:
export type InvoiceStatus =
| "draft"
| "issued"
| "paid"
| "payment_failed"
| "void";
- Step 2: Write the errors
errors.ts:
export class BillingError extends Error {}
/** Provider call failed after in-process retries. The domain change still succeeded. */
export class ProviderSyncError extends BillingError {
constructor(
public readonly operation: string,
public readonly cause: unknown,
) {
super(`Billing provider sync failed during ${operation}`);
this.name = "ProviderSyncError";
}
}
/** Webhook signature verification failed. */
export class WebhookSignatureError extends BillingError {
constructor() {
super("Billing webhook signature verification failed");
this.name = "WebhookSignatureError";
}
}
- Step 3: Write the BillingEvent contract + NormalizedInvoice
billing-event.ts:
import { InvoiceStatus } from "./invoice-status";
/**
* A provider-agnostic snapshot of an invoice as the provider reports it.
* Every invoice.* webhook (issued/paid/payment_failed/voided) normalizes to
* this shape so the mirror upsert is uniform and last-writer-wins.
*/
export interface NormalizedInvoice {
externalInvoiceId: string;
externalCustomerId: string;
externalSubscriptionId: string | null;
status: InvoiceStatus;
amountCents: bigint;
currency: string;
hostedUrl: string | null;
pdfUrl: string | null;
lineItems: unknown | null; // display snapshot only; not a billing source
periodStart: string | null; // ISO date
periodEnd: string | null; // ISO date
issuedAt: string | null; // ISO timestamp
paidAt: string | null; // ISO timestamp
}
/**
* Normalized provider webhook. `parseWebhook` collapses every recognized
* invoice event into `invoice_upsert`; subscription cancellation into
* `subscription_canceled`; everything else into `ignored` (so the handler is
* total over the provider's event stream).
*/
export type BillingEvent =
| { kind: "invoice_upsert"; invoice: NormalizedInvoice }
| { kind: "subscription_canceled"; externalSubscriptionId: string }
| { kind: "ignored"; rawType: string };
- Step 4: Write a failing test (
billing-event.spec.ts) that pins the discriminant:
import { BillingEvent } from "./billing-event";
describe("BillingEvent", () => {
it("discriminates by kind", () => {
const e: BillingEvent = { kind: "ignored", rawType: "customer.updated" };
expect(e.kind).toBe("ignored");
if (e.kind === "ignored") expect(e.rawType).toBe("customer.updated");
});
});
- Step 5: Run test
Run: npm --prefix api test -- billing-event.spec
Expected: PASS (compile-only contract test).
- Step 6: Commit
git add api/src/billing/domain
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): domain status unions, errors, BillingEvent contract"
Task 1.2: BillingSubscription domain entityโ
Files:
-
Create:
api/src/billing/domain/billing-subscription.ts -
Test:
api/src/billing/domain/billing-subscription.spec.ts -
Step 1: Write the failing test
import { BillingSubscription } from "./billing-subscription";
const base = {
id: "sub-1",
orgId: "org-1",
specialistId: "sp-1",
assignmentId: "asg-1",
externalSubscriptionId: null,
externalCustomerId: "cus-1",
status: "pending" as const,
currentRateCents: 750000n,
currency: "USD",
anchorDay: 15,
provider: "mock",
createdAt: new Date("2026-05-01"),
updatedAt: new Date("2026-05-01"),
canceledAt: null,
};
describe("BillingSubscription", () => {
it("markActive sets external id + active status", () => {
const s = new BillingSubscription(base);
s.markActive("ext-sub-9");
expect(s.status).toBe("active");
expect(s.externalSubscriptionId).toBe("ext-sub-9");
});
it("markSyncFailed records the failing op without an external id", () => {
const s = new BillingSubscription(base);
s.markSyncFailed("create");
expect(s.status).toBe("sync_failed");
expect(s.syncFailedOp).toBe("create");
expect(s.externalSubscriptionId).toBeNull();
});
it("markActive clears a prior sync_failed op", () => {
const s = new BillingSubscription(base);
s.markSyncFailed("update");
s.markActive("ext-sub-9");
expect(s.syncFailedOp).toBeNull();
});
it("markCanceled sets canceledAt and status", () => {
const s = new BillingSubscription({ ...base, status: "active", externalSubscriptionId: "ext" });
s.markCanceled(new Date("2026-05-12"));
expect(s.status).toBe("canceled");
expect(s.canceledAt).toEqual(new Date("2026-05-12"));
});
it("rateChanged is true only when cents differ", () => {
const s = new BillingSubscription(base);
expect(s.rateChanged(750000n)).toBe(false);
expect(s.rateChanged(800000n)).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: npm --prefix api test -- billing-subscription.spec
Expected: FAIL ("Cannot find module './billing-subscription'").
- Step 3: Write the entity
import { BillingSubscriptionStatus, SyncFailedOp } from "./billing-subscription-status";
export interface BillingSubscriptionProps {
id: string;
orgId: string;
specialistId: string;
assignmentId: string;
externalSubscriptionId: string | null;
externalCustomerId: string;
status: BillingSubscriptionStatus;
syncFailedOp?: SyncFailedOp | null; // which op failed when status === "sync_failed"; else null
cancelEffectiveDate?: string | null; // ISO date; original requested cancel date, preserved across retries
rateChangeEffectiveDate?: string | null; // ISO date; original rate-change date, preserved across update retries
currentRateCents: bigint;
currency: string;
anchorDay: number;
provider: string;
createdAt: Date;
updatedAt: Date;
canceledAt: Date | null;
}
/** Pure mirror of one provider subscription for a single (org ร Specialist). */
export class BillingSubscription {
id: string;
orgId: string;
specialistId: string;
assignmentId: string;
externalSubscriptionId: string | null;
externalCustomerId: string;
status: BillingSubscriptionStatus;
syncFailedOp: SyncFailedOp | null = null; // defaults null; set via markSyncFailed, cleared on markActive/markCanceled
cancelEffectiveDate: string | null = null; // original requested cancel date, preserved across retries
rateChangeEffectiveDate: string | null = null; // original rate-change date, preserved across update retries
currentRateCents: bigint;
currency: string;
anchorDay: number;
provider: string;
createdAt: Date;
updatedAt: Date;
canceledAt: Date | null;
constructor(props: BillingSubscriptionProps) {
Object.assign(this, props);
}
markActive(externalSubscriptionId: string): void {
this.externalSubscriptionId = externalSubscriptionId;
this.status = "active";
this.syncFailedOp = null; // healthy again
this.rateChangeEffectiveDate = null; // any pending dated rate-change retry is resolved
this.updatedAt = new Date();
}
markSyncFailed(op: SyncFailedOp): void {
this.status = "sync_failed";
this.syncFailedOp = op;
this.updatedAt = new Date();
}
markCanceled(at: Date): void {
this.status = "canceled";
this.syncFailedOp = null;
this.canceledAt = at;
this.updatedAt = new Date();
}
updateRate(cents: bigint): void {
this.currentRateCents = cents;
this.updatedAt = new Date();
}
rateChanged(cents: bigint): boolean {
return this.currentRateCents !== cents;
}
}
- Step 4: Run test to verify it passes
Run: npm --prefix api test -- billing-subscription.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/domain/billing-subscription.ts api/src/billing/domain/billing-subscription.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): BillingSubscription domain entity"
Task 1.3: Invoice domain entityโ
Files:
-
Create:
api/src/billing/domain/invoice.ts -
Test:
api/src/billing/domain/invoice.spec.ts -
Step 1: Write the failing test
import { Invoice } from "./invoice";
import { NormalizedInvoice } from "./billing-event";
const snapshot: NormalizedInvoice = {
externalInvoiceId: "inv-1",
externalCustomerId: "cus-1",
externalSubscriptionId: "ext-sub-1",
status: "issued",
amountCents: 375000n,
currency: "USD",
hostedUrl: "https://pay/inv-1",
pdfUrl: null,
lineItems: [{ name: "Eleanora โ May", amountCents: "375000" }],
periodStart: "2026-05-01",
periodEnd: "2026-05-31",
issuedAt: "2026-06-01T00:00:00Z",
paidAt: null,
};
describe("Invoice", () => {
it("fromSnapshot maps a normalized provider invoice", () => {
const inv = Invoice.fromSnapshot(snapshot, { orgId: "org-1", specialistId: "sp-1", billingSubscriptionId: "sub-1" });
expect(inv.externalInvoiceId).toBe("inv-1");
expect(inv.status).toBe("issued");
expect(inv.amountCents).toBe(375000n);
expect(inv.orgId).toBe("org-1");
});
});
- Step 2: Run test to verify it fails
Run: npm --prefix api test -- invoice.spec
Expected: FAIL.
- Step 3: Write the entity
import { InvoiceStatus } from "./invoice-status";
import { NormalizedInvoice } from "./billing-event";
export interface InvoiceProps {
id: string | null; // null until persisted
orgId: string | null; // null when the webhook can't be resolved to a local subscription
specialistId: string | null;
billingSubscriptionId: string | null;
externalInvoiceId: string;
externalCustomerId: string;
status: InvoiceStatus;
amountCents: bigint;
currency: string;
hostedUrl: string | null;
pdfUrl: string | null;
lineItems: unknown | null;
periodStart: string | null;
periodEnd: string | null;
issuedAt: Date | null;
paidAt: Date | null;
}
/** Pure mirror of one provider invoice. */
export class Invoice {
id: string | null;
orgId: string | null;
specialistId: string | null;
billingSubscriptionId: string | null;
externalInvoiceId: string;
externalCustomerId: string;
status: InvoiceStatus;
amountCents: bigint;
currency: string;
hostedUrl: string | null;
pdfUrl: string | null;
lineItems: unknown | null;
periodStart: string | null;
periodEnd: string | null;
issuedAt: Date | null;
paidAt: Date | null;
constructor(props: InvoiceProps) {
Object.assign(this, props);
}
/** Build an Invoice from a provider webhook snapshot + resolved local links. */
static fromSnapshot(
s: NormalizedInvoice,
links: { orgId: string | null; specialistId: string | null; billingSubscriptionId: string | null },
): Invoice {
return new Invoice({
id: null,
orgId: links.orgId,
specialistId: links.specialistId,
billingSubscriptionId: links.billingSubscriptionId,
externalInvoiceId: s.externalInvoiceId,
externalCustomerId: s.externalCustomerId,
status: s.status,
amountCents: s.amountCents,
currency: s.currency,
hostedUrl: s.hostedUrl,
pdfUrl: s.pdfUrl,
lineItems: s.lineItems,
periodStart: s.periodStart,
periodEnd: s.periodEnd,
issuedAt: s.issuedAt ? new Date(s.issuedAt) : null,
paidAt: s.paidAt ? new Date(s.paidAt) : null,
});
}
}
- Step 4: Run test to verify it passes
Run: npm --prefix api test -- invoice.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/domain/invoice.ts api/src/billing/domain/invoice.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): Invoice domain entity"
Task 1.4: Portsโ
Files:
- Create:
api/src/billing/domain/ports/billing-subscription.repository.port.ts - Create:
api/src/billing/domain/ports/invoice.repository.port.ts - Create:
api/src/billing/domain/ports/billing-provider.port.ts
(No tests โ interfaces only. Tested via their implementations in later phases.)
- Step 1: Repository ports
billing-subscription.repository.port.ts:
import { BillingSubscription } from "../billing-subscription";
export const BILLING_SUBSCRIPTION_REPOSITORY = Symbol("BILLING_SUBSCRIPTION_REPOSITORY");
export interface BillingSubscriptionRepositoryPort {
findByAssignmentId(assignmentId: string): Promise<BillingSubscription | null>;
findByExternalSubscriptionId(externalSubscriptionId: string): Promise<BillingSubscription | null>;
/** Resolve an org from a provider customer id (for consolidated invoices that carry no single subscription id). */
findByExternalCustomerId(externalCustomerId: string): Promise<BillingSubscription | null>;
findByOrgId(orgId: string): Promise<BillingSubscription[]>;
/** Number of subscriptions for an org with status 'active' (trial-gate repoint). */
countActiveByOrgId(orgId: string): Promise<number>;
listAll(): Promise<BillingSubscription[]>;
save(subscription: BillingSubscription): Promise<BillingSubscription>;
}
invoice.repository.port.ts:
import { Invoice } from "../invoice";
export const INVOICE_REPOSITORY = Symbol("INVOICE_REPOSITORY");
export interface InvoiceRepositoryPort {
/** Insert or update keyed on externalInvoiceId (webhook idempotency). */
upsertByExternalId(invoice: Invoice): Promise<Invoice>;
findByExternalId(externalInvoiceId: string): Promise<Invoice | null>;
findByOrgId(orgId: string): Promise<Invoice[]>;
}
- Step 2: Provider port
billing-provider.port.ts:
import { BillingEvent, NormalizedInvoice } from "../billing-event";
export const BILLING_PROVIDER = Symbol("BILLING_PROVIDER");
/**
* Provider-agnostic billing port. Models the common denominator of subscription
* billing engines (Lago self-hosted today; Orb / Stripe Billing substitutable). The provider
* owns the billing calendar, proration, invoice generation, and dunning.
*/
export interface BillingProviderPort {
/** Idempotent on the org. Returns the provider customer id. */
ensureCustomer(input: {
orgId: string;
name: string;
email: string;
externalId: string; // our org id, used as the provider's external customer id
}): Promise<{ externalCustomerId: string }>;
/**
* Create a subscription for one (org ร Specialist). Idempotent on assignmentId
* (passed as the provider idempotency key). Provider prorates the first partial
* period from startDate to the next anchor boundary.
*/
createSubscription(input: {
externalCustomerId: string;
assignmentId: string; // idempotency key
specialistId: string;
specialistName: string;
monthlyRateCents: bigint;
currency: string;
anchorDay: number; // 1-31; provider clamps to month end
startDate: string; // ISO date
}): Promise<{ externalSubscriptionId: string }>;
/** Prorates the rate delta from effectiveDate. */
updateSubscriptionPrice(input: {
externalSubscriptionId: string;
newRateCents: bigint;
effectiveDate: string;
}): Promise<void>;
/** Prorates the final partial period up to effectiveDate (arrears โ final invoice). */
cancelSubscription(input: {
externalSubscriptionId: string;
effectiveDate: string;
}): Promise<void>;
getCustomerPortalUrl(externalCustomerId: string): Promise<string>;
/** Verify signature and normalize to a BillingEvent. Throws WebhookSignatureError on bad signature. */
parseWebhook(rawBody: string, signature: string): BillingEvent;
listInvoices(externalCustomerId: string): Promise<NormalizedInvoice[]>;
}
- Step 3: Build to confirm types compile
Run: npm --prefix api run build
Expected: success.
- Step 4: Commit
git add api/src/billing/domain/ports
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): repository + provider ports"
Task 1.5: Money conversion helpers (exact, no float)โ
Files:
- Create:
api/src/billing/domain/money.ts - Test:
api/src/billing/domain/money.spec.ts
Cents are
bigintinternally. Some providers speak major-unit decimal strings ("75.19"); Lago speaks integer cents. These helpers use integer/bigint math โ neverNumber(bigint)orn * 100(lossy above ~$90B and for non-representable decimals). Used byAssignmentBillingInputResolver(Task 6.5, converting the dollarbase_rate) and any provider adapter that needs majorโcents.
- Step 1: Write the failing test
import { centsToMajor, majorToCents } from "./money";
describe("money", () => {
it("centsToMajor formats cents as a major-unit string", () => {
expect(centsToMajor(750000n)).toBe("7500.00");
expect(centsToMajor(7519n)).toBe("75.19");
expect(centsToMajor(5n)).toBe("0.05");
expect(centsToMajor(-7519n)).toBe("-75.19");
});
it("majorToCents parses a major-unit string/number exactly", () => {
expect(majorToCents("7500.00")).toBe(750000n);
expect(majorToCents("75.19")).toBe(7519n);
expect(majorToCents("75.1")).toBe(7510n); // single decimal place
expect(majorToCents("75")).toBe(7500n); // no decimal point
expect(majorToCents(7500)).toBe(750000n); // whole-number input
expect(majorToCents("-75.19")).toBe(-7519n);
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- money.spec
Expected: FAIL.
- Step 3: Write
money.ts
/** Exact cents โ major-unit string (e.g. 7519n โ "75.19"). No float. */
export function centsToMajor(cents: bigint): string {
const sign = cents < 0n ? "-" : "";
const abs = cents < 0n ? -cents : cents;
return `${sign}${abs / 100n}.${(abs % 100n).toString().padStart(2, "0")}`;
}
/**
* Exact major-unit โ cents (e.g. "75.19" โ 7519n). Accepts a decimal string or a
* whole/decimal number. Truncates beyond 2 decimal places. No float arithmetic.
*/
export function majorToCents(major: string | number): bigint {
const raw = (typeof major === "number" ? major.toString() : major ?? "0").trim();
const neg = raw.startsWith("-");
const [whole, frac = ""] = raw.replace(/^-/, "").split(".");
const cents = BigInt(whole || "0") * 100n + BigInt((frac + "00").slice(0, 2) || "0");
return neg ? -cents : cents;
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- money.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/domain/money.ts api/src/billing/domain/money.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): exact money conversion helpers (no float)"
Phase 2 โ Persistence (storage, registration, migration, mappers, repositories)โ
Task 2.1: TypeORM storage entitiesโ
Files:
-
Create:
api/src/billing/infrastructure/billing-subscription.storage.ts -
Create:
api/src/billing/infrastructure/invoice.storage.ts -
Step 1:
billing-subscription.storage.ts
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity("billing_subscriptions")
@Index(["orgId"])
@Index(["status"])
@Index(["externalSubscriptionId"]) // webhook handler looks subscriptions up by this on every event
export class BillingSubscriptionStorage {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ name: "org_id", type: "uuid" })
orgId: string;
@Column({ name: "specialist_id", type: "uuid" })
specialistId: string;
@Column({ name: "assignment_id", type: "uuid", unique: true })
assignmentId: string;
@Column({ name: "external_subscription_id", type: "varchar", nullable: true })
externalSubscriptionId: string | null;
@Column({ name: "external_customer_id", type: "varchar" })
externalCustomerId: string;
@Column({ type: "varchar", default: "pending" })
status: string;
@Column({ name: "sync_failed_op", type: "varchar", nullable: true })
syncFailedOp: string | null;
@Column({ name: "cancel_effective_date", type: "varchar", nullable: true })
cancelEffectiveDate: string | null;
@Column({ name: "rate_change_effective_date", type: "varchar", nullable: true })
rateChangeEffectiveDate: string | null;
@Column({ name: "current_rate_cents", type: "bigint" })
currentRateCents: string; // bigint โ string at runtime
@Column({ type: "varchar", default: "USD" })
currency: string;
@Column({ name: "anchor_day", type: "int" })
anchorDay: number;
@Column({ type: "varchar" })
provider: string;
@CreateDateColumn({ name: "created_at" })
createdAt: Date;
@UpdateDateColumn({ name: "updated_at" })
updatedAt: Date;
@Column({ name: "canceled_at", type: "timestamptz", nullable: true })
canceledAt: Date | null;
}
- Step 2:
invoice.storage.ts
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
@Entity("billing_invoices")
@Index(["orgId"])
export class InvoiceStorage {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ name: "org_id", type: "uuid", nullable: true })
orgId: string | null;
@Column({ name: "specialist_id", type: "uuid", nullable: true })
specialistId: string | null;
@Column({ name: "billing_subscription_id", type: "uuid", nullable: true })
billingSubscriptionId: string | null;
@Column({ name: "external_invoice_id", type: "varchar", unique: true })
externalInvoiceId: string;
@Column({ name: "external_customer_id", type: "varchar" })
externalCustomerId: string;
@Column({ type: "varchar" })
status: string;
@Column({ name: "amount_cents", type: "bigint" })
amountCents: string;
@Column({ type: "varchar", default: "USD" })
currency: string;
@Column({ name: "hosted_url", type: "varchar", nullable: true })
hostedUrl: string | null;
@Column({ name: "pdf_url", type: "varchar", nullable: true })
pdfUrl: string | null;
@Column({ name: "line_items", type: "simple-json", nullable: true })
lineItems: unknown | null;
@Column({ name: "period_start", type: "varchar", nullable: true })
periodStart: string | null;
@Column({ name: "period_end", type: "varchar", nullable: true })
periodEnd: string | null;
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
issuedAt: Date | null;
@Column({ name: "paid_at", type: "timestamptz", nullable: true })
paidAt: Date | null;
@CreateDateColumn({ name: "created_at" })
createdAt: Date;
@UpdateDateColumn({ name: "updated_at" })
updatedAt: Date;
}
Note:
simple-jsonis portable across Postgres + sqlite. On Postgres it stores text; acceptable for a display snapshot. (If a truejsonbcolumn is desired in prod, the migration in Task 2.3 usesjsonb; the entity stayssimple-jsonfor test portability โ TypeORM tolerates the mismatch on read.)
- Step 3: Commit
git add api/src/billing/infrastructure/billing-subscription.storage.ts api/src/billing/infrastructure/invoice.storage.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): TypeORM storage entities"
Task 2.2: Register entities (data-source + app.module)โ
Files:
-
Modify:
api/src/data-source.ts(theentities: [...]array near line 98) -
Modify:
api/src/app.module.ts(theentities: [...]insideTypeOrmModule.forRootAsync, near line 153) -
Step 1: Import + register in
data-source.ts
Add imports near the other entity imports:
import { BillingSubscriptionStorage } from "./billing/infrastructure/billing-subscription.storage";
import { InvoiceStorage } from "./billing/infrastructure/invoice.storage";
Add to the entities: [ array:
BillingSubscriptionStorage,
InvoiceStorage,
-
Step 2: Import + register in
app.module.tsidentically (same two imports + same two array entries insideforRootAsync'sentities). -
Step 3: Build
Run: npm --prefix api run build
Expected: success.
- Step 4: Commit
git add api/src/data-source.ts api/src/app.module.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): register billing entities in data-source + app.module"
Task 2.3: Migration โ create billing tablesโ
Files: Create api/migrations/1751300000001-AddBillingSubscriptionAndInvoice.ts
- Step 1: Write the migration
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddBillingSubscriptionAndInvoice1751300000001 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`
CREATE TABLE "billing_subscriptions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"org_id" uuid NOT NULL,
"specialist_id" uuid NOT NULL,
"assignment_id" uuid NOT NULL,
"external_subscription_id" varchar,
"external_customer_id" varchar NOT NULL,
"status" varchar NOT NULL DEFAULT 'pending',
"sync_failed_op" varchar,
"cancel_effective_date" varchar,
"rate_change_effective_date" varchar,
"current_rate_cents" bigint NOT NULL,
"currency" varchar NOT NULL DEFAULT 'USD',
"anchor_day" int NOT NULL,
"provider" varchar NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"canceled_at" TIMESTAMPTZ,
CONSTRAINT "PK_billing_subscriptions" PRIMARY KEY ("id"),
CONSTRAINT "UQ_billing_subscriptions_assignment" UNIQUE ("assignment_id")
);
CREATE INDEX "IDX_billing_subscriptions_org" ON "billing_subscriptions" ("org_id");
CREATE INDEX "IDX_billing_subscriptions_status" ON "billing_subscriptions" ("status");
CREATE INDEX "IDX_billing_subscriptions_external" ON "billing_subscriptions" ("external_subscription_id");
CREATE TABLE "billing_invoices" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"org_id" uuid,
"specialist_id" uuid,
"billing_subscription_id" uuid,
"external_invoice_id" varchar NOT NULL,
"external_customer_id" varchar NOT NULL,
"status" varchar NOT NULL,
"amount_cents" bigint NOT NULL,
"currency" varchar NOT NULL DEFAULT 'USD',
"hosted_url" varchar,
"pdf_url" varchar,
"line_items" jsonb,
"period_start" varchar,
"period_end" varchar,
"issued_at" TIMESTAMPTZ,
"paid_at" TIMESTAMPTZ,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT "PK_billing_invoices" PRIMARY KEY ("id"),
CONSTRAINT "UQ_billing_invoices_external" UNIQUE ("external_invoice_id")
);
CREATE INDEX "IDX_billing_invoices_org" ON "billing_invoices" ("org_id");
`);
}
public async down(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE "billing_invoices"; DROP TABLE "billing_subscriptions";`);
}
}
- Step 2: Build (migrations compile with the app)
Run: npm --prefix api run build
Expected: success. (Migration is applied in prod via npm run typeorm migration:run; tests use synchronize against sqlite so no run needed here.)
- Step 3: Commit
git add api/migrations/1751300000001-AddBillingSubscriptionAndInvoice.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): migration for billing_subscriptions + billing_invoices"
Task 2.4: Mappers (storage โ domain)โ
Files:
-
Create:
api/src/billing/infrastructure/billing-subscription.mapper.ts -
Create:
api/src/billing/infrastructure/invoice.mapper.ts -
Test:
api/src/billing/infrastructure/billing-subscription.mapper.spec.ts -
Step 1: Write the failing test
import { BillingSubscriptionMapper } from "./billing-subscription.mapper";
import { BillingSubscription } from "../domain/billing-subscription";
describe("BillingSubscriptionMapper", () => {
it("round-trips cents as bigint", () => {
const domain = new BillingSubscription({
id: "s1", orgId: "o1", specialistId: "sp1", assignmentId: "a1",
externalSubscriptionId: null, externalCustomerId: "c1", status: "pending",
currentRateCents: 750000n, currency: "USD", anchorDay: 15, provider: "mock",
createdAt: new Date(), updatedAt: new Date(), canceledAt: null,
});
const storage = BillingSubscriptionMapper.toStorage(domain);
expect(storage.currentRateCents).toBe("750000");
const back = BillingSubscriptionMapper.toDomain(storage);
expect(back.currentRateCents).toBe(750000n);
expect(back.status).toBe("pending");
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- billing-subscription.mapper.spec
Expected: FAIL.
- Step 3: Write the mappers
billing-subscription.mapper.ts:
import { BillingSubscription } from "../domain/billing-subscription";
import { BillingSubscriptionStatus, SyncFailedOp } from "../domain/billing-subscription-status";
import { BillingSubscriptionStorage } from "./billing-subscription.storage";
export class BillingSubscriptionMapper {
static toDomain(s: BillingSubscriptionStorage): BillingSubscription {
return new BillingSubscription({
id: s.id,
orgId: s.orgId,
specialistId: s.specialistId,
assignmentId: s.assignmentId,
externalSubscriptionId: s.externalSubscriptionId,
externalCustomerId: s.externalCustomerId,
status: s.status as BillingSubscriptionStatus,
syncFailedOp: (s.syncFailedOp as SyncFailedOp | null) ?? null,
cancelEffectiveDate: s.cancelEffectiveDate ?? null,
rateChangeEffectiveDate: s.rateChangeEffectiveDate ?? null,
currentRateCents: BigInt(s.currentRateCents),
currency: s.currency,
anchorDay: s.anchorDay,
provider: s.provider,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
canceledAt: s.canceledAt,
});
}
static toStorage(d: BillingSubscription): BillingSubscriptionStorage {
const s = new BillingSubscriptionStorage();
if (d.id) s.id = d.id;
s.orgId = d.orgId;
s.specialistId = d.specialistId;
s.assignmentId = d.assignmentId;
s.externalSubscriptionId = d.externalSubscriptionId;
s.externalCustomerId = d.externalCustomerId;
s.status = d.status;
s.syncFailedOp = d.syncFailedOp;
s.cancelEffectiveDate = d.cancelEffectiveDate;
s.rateChangeEffectiveDate = d.rateChangeEffectiveDate;
s.currentRateCents = d.currentRateCents.toString();
s.currency = d.currency;
s.anchorDay = d.anchorDay;
s.provider = d.provider;
s.canceledAt = d.canceledAt;
return s;
}
}
invoice.mapper.ts:
import { Invoice } from "../domain/invoice";
import { InvoiceStatus } from "../domain/invoice-status";
import { InvoiceStorage } from "./invoice.storage";
export class InvoiceMapper {
static toDomain(s: InvoiceStorage): Invoice {
return new Invoice({
id: s.id,
orgId: s.orgId,
specialistId: s.specialistId,
billingSubscriptionId: s.billingSubscriptionId,
externalInvoiceId: s.externalInvoiceId,
externalCustomerId: s.externalCustomerId,
status: s.status as InvoiceStatus,
amountCents: BigInt(s.amountCents),
currency: s.currency,
hostedUrl: s.hostedUrl,
pdfUrl: s.pdfUrl,
lineItems: s.lineItems,
periodStart: s.periodStart,
periodEnd: s.periodEnd,
issuedAt: s.issuedAt,
paidAt: s.paidAt,
});
}
static toStorage(d: Invoice): InvoiceStorage {
const s = new InvoiceStorage();
if (d.id) s.id = d.id;
s.orgId = d.orgId;
s.specialistId = d.specialistId;
s.billingSubscriptionId = d.billingSubscriptionId;
s.externalInvoiceId = d.externalInvoiceId;
s.externalCustomerId = d.externalCustomerId;
s.status = d.status;
s.amountCents = d.amountCents.toString();
s.currency = d.currency;
s.hostedUrl = d.hostedUrl;
s.pdfUrl = d.pdfUrl;
s.lineItems = d.lineItems;
s.periodStart = d.periodStart;
s.periodEnd = d.periodEnd;
s.issuedAt = d.issuedAt;
s.paidAt = d.paidAt;
return s;
}
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- billing-subscription.mapper.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/infrastructure/billing-subscription.mapper.ts api/src/billing/infrastructure/invoice.mapper.ts api/src/billing/infrastructure/billing-subscription.mapper.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): storageโdomain mappers"
Task 2.5: Repositoriesโ
Files:
-
Create:
api/src/billing/infrastructure/billing-subscription.repository.ts -
Create:
api/src/billing/infrastructure/invoice.repository.ts -
Test:
api/src/billing/infrastructure/billing-subscription.repository.spec.ts -
Step 1: Write the failing integration test (uses the in-memory test DB harness โ mirror an existing
*.repository.spec.tsin the repo for thecreateTestingModule/DataSource setup):
import { Test } from "@nestjs/testing";
import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingSubscriptionStorage } from "./billing-subscription.storage";
import { BillingSubscriptionRepository } from "./billing-subscription.repository";
import { BillingSubscription } from "../domain/billing-subscription";
describe("BillingSubscriptionRepository", () => {
let repo: BillingSubscriptionRepository;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: "better-sqlite3", database: ":memory:", dropSchema: true,
entities: [BillingSubscriptionStorage], synchronize: true,
}),
TypeOrmModule.forFeature([BillingSubscriptionStorage]),
],
providers: [BillingSubscriptionRepository],
}).compile();
repo = moduleRef.get(BillingSubscriptionRepository);
});
const make = (over: Partial<any> = {}) => new BillingSubscription({
id: "", orgId: "o1", specialistId: "sp1", assignmentId: "a1",
externalSubscriptionId: null, externalCustomerId: "c1", status: "active",
currentRateCents: 750000n, currency: "USD", anchorDay: 15, provider: "mock",
createdAt: new Date(), updatedAt: new Date(), canceledAt: null, ...over,
});
it("saves and finds by assignmentId", async () => {
await repo.save(make());
const found = await repo.findByAssignmentId("a1");
expect(found?.currentRateCents).toBe(750000n);
});
it("countActiveByOrgId counts only active", async () => {
await repo.save(make({ assignmentId: "a1", status: "active" }));
await repo.save(make({ assignmentId: "a2", status: "canceled" }));
expect(await repo.countActiveByOrgId("o1")).toBe(1);
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- billing-subscription.repository.spec
Expected: FAIL.
- Step 3: Write
billing-subscription.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BillingSubscription } from "../domain/billing-subscription";
import { BillingSubscriptionRepositoryPort } from "../domain/ports/billing-subscription.repository.port";
import { BillingSubscriptionStorage } from "./billing-subscription.storage";
import { BillingSubscriptionMapper } from "./billing-subscription.mapper";
@Injectable()
export class BillingSubscriptionRepository implements BillingSubscriptionRepositoryPort {
constructor(
@InjectRepository(BillingSubscriptionStorage)
private readonly repo: Repository<BillingSubscriptionStorage>,
) {}
async findByAssignmentId(assignmentId: string): Promise<BillingSubscription | null> {
const row = await this.repo.findOne({ where: { assignmentId } });
return row ? BillingSubscriptionMapper.toDomain(row) : null;
}
async findByExternalSubscriptionId(externalSubscriptionId: string): Promise<BillingSubscription | null> {
const row = await this.repo.findOne({ where: { externalSubscriptionId } });
return row ? BillingSubscriptionMapper.toDomain(row) : null;
}
async findByExternalCustomerId(externalCustomerId: string): Promise<BillingSubscription | null> {
const row = await this.repo.findOne({ where: { externalCustomerId } });
return row ? BillingSubscriptionMapper.toDomain(row) : null;
}
async findByOrgId(orgId: string): Promise<BillingSubscription[]> {
const rows = await this.repo.find({ where: { orgId } });
return rows.map(BillingSubscriptionMapper.toDomain);
}
async countActiveByOrgId(orgId: string): Promise<number> {
return this.repo.count({ where: { orgId, status: "active" } });
}
async listAll(): Promise<BillingSubscription[]> {
const rows = await this.repo.find();
return rows.map(BillingSubscriptionMapper.toDomain);
}
async save(subscription: BillingSubscription): Promise<BillingSubscription> {
const saved = await this.repo.save(BillingSubscriptionMapper.toStorage(subscription));
return BillingSubscriptionMapper.toDomain(saved);
}
}
- Step 4: Write
invoice.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Invoice } from "../domain/invoice";
import { InvoiceRepositoryPort } from "../domain/ports/invoice.repository.port";
import { InvoiceStorage } from "./invoice.storage";
import { InvoiceMapper } from "./invoice.mapper";
@Injectable()
export class InvoiceRepository implements InvoiceRepositoryPort {
constructor(
@InjectRepository(InvoiceStorage)
private readonly repo: Repository<InvoiceStorage>,
) {}
async upsertByExternalId(invoice: Invoice): Promise<Invoice> {
const storage = InvoiceMapper.toStorage(invoice);
// Atomic INSERT โฆ ON CONFLICT (external_invoice_id) DO UPDATE. A find-then-save
// would race under concurrent/replayed webhook delivery (Lago retries while the
// first is in-flight) โ both reads see null, the second INSERT hits the UNIQUE
// and 500s. upsert() makes it one atomic statement, keeping delivery idempotent.
await this.repo.upsert(storage, ["externalInvoiceId"]);
const saved = await this.repo.findOne({ where: { externalInvoiceId: invoice.externalInvoiceId } });
return InvoiceMapper.toDomain(saved!);
}
async findByExternalId(externalInvoiceId: string): Promise<Invoice | null> {
const row = await this.repo.findOne({ where: { externalInvoiceId } });
return row ? InvoiceMapper.toDomain(row) : null;
}
async findByOrgId(orgId: string): Promise<Invoice[]> {
const rows = await this.repo.find({ where: { orgId } });
return rows.map(InvoiceMapper.toDomain);
}
}
- Step 5: Run to verify pass
Run: npm --prefix api test -- billing-subscription.repository.spec
Expected: PASS.
- Step 6: Commit
git add api/src/billing/infrastructure/billing-subscription.repository.ts api/src/billing/infrastructure/invoice.repository.ts api/src/billing/infrastructure/billing-subscription.repository.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): subscription + invoice repositories"
Phase 3 โ Providersโ
Task 3.1: MockProviderโ
Files:
-
Create:
api/src/billing/infrastructure/providers/mock.provider.ts -
Test:
api/src/billing/infrastructure/providers/mock.provider.spec.ts -
Step 1: Write the failing test
import { MockProvider } from "./mock.provider";
describe("MockProvider", () => {
let p: MockProvider;
beforeEach(() => { p = new MockProvider(); });
it("ensureCustomer is idempotent on externalId", async () => {
const a = await p.ensureCustomer({ orgId: "o1", name: "Acme", email: "a@acme.com", externalId: "o1" });
const b = await p.ensureCustomer({ orgId: "o1", name: "Acme", email: "a@acme.com", externalId: "o1" });
expect(a.externalCustomerId).toBe(b.externalCustomerId);
});
it("createSubscription is idempotent on assignmentId", async () => {
const args = { externalCustomerId: "cus_o1", assignmentId: "a1", specialistId: "sp1",
specialistName: "Eleanora", monthlyRateCents: 750000n, currency: "USD", anchorDay: 15, startDate: "2026-05-01" };
const r1 = await p.createSubscription(args);
const r2 = await p.createSubscription(args);
expect(r1.externalSubscriptionId).toBe(r2.externalSubscriptionId);
});
it("parseWebhook ignores unknown event types", () => {
const evt = p.parseWebhook(JSON.stringify({ type: "customer.updated" }), "sig");
expect(evt.kind).toBe("ignored");
});
it("parseWebhook normalizes invoice.issued to invoice_upsert", () => {
const body = JSON.stringify({
type: "invoice.issued",
invoice: { id: "inv1", customerId: "cus_o1", subscriptionId: "sub1", amountCents: "375000",
currency: "USD", periodStart: "2026-05-01", periodEnd: "2026-05-31" },
});
const evt = p.parseWebhook(body, "sig");
expect(evt.kind).toBe("invoice_upsert");
if (evt.kind === "invoice_upsert") {
expect(evt.invoice.externalInvoiceId).toBe("inv1");
expect(evt.invoice.status).toBe("issued");
expect(evt.invoice.amountCents).toBe(375000n);
}
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- mock.provider.spec
Expected: FAIL.
- Step 3: Write
mock.provider.ts
import { Injectable } from "@nestjs/common";
import { BillingProviderPort } from "../../domain/ports/billing-provider.port";
import { BillingEvent, NormalizedInvoice } from "../../domain/billing-event";
import { InvoiceStatus } from "../../domain/invoice-status";
/** In-memory provider used in tests and when no LAGO_API_KEY is configured. */
@Injectable()
export class MockProvider implements BillingProviderPort {
private customers = new Map<string, string>(); // externalId -> customerId
private subs = new Map<string, string>(); // assignmentId -> subscriptionId
async ensureCustomer(input: { orgId: string; name: string; email: string; externalId: string }) {
const existing = this.customers.get(input.externalId);
const externalCustomerId = existing ?? `cus_${input.externalId}`;
this.customers.set(input.externalId, externalCustomerId);
return { externalCustomerId };
}
async createSubscription(input: { assignmentId: string }) {
const existing = this.subs.get(input.assignmentId);
const externalSubscriptionId = existing ?? `sub_${input.assignmentId}`;
this.subs.set(input.assignmentId, externalSubscriptionId);
return { externalSubscriptionId };
}
async updateSubscriptionPrice(): Promise<void> {}
async cancelSubscription(): Promise<void> {}
async getCustomerPortalUrl(externalCustomerId: string): Promise<string> {
return `https://mock-portal.local/${externalCustomerId}`;
}
parseWebhook(rawBody: string): BillingEvent {
const payload = JSON.parse(rawBody) as { type: string; invoice?: any; subscriptionId?: string };
const statusByType: Record<string, InvoiceStatus> = {
"invoice.issued": "issued",
"invoice.paid": "paid",
"invoice.payment_failed": "payment_failed",
"invoice.voided": "void",
};
if (payload.type in statusByType && payload.invoice) {
const i = payload.invoice;
const invoice: NormalizedInvoice = {
externalInvoiceId: i.id,
externalCustomerId: i.customerId,
externalSubscriptionId: i.subscriptionId ?? null,
status: statusByType[payload.type],
amountCents: BigInt(i.amountCents ?? "0"),
currency: i.currency ?? "USD",
hostedUrl: i.hostedUrl ?? null,
pdfUrl: i.pdfUrl ?? null,
lineItems: i.lineItems ?? null,
periodStart: i.periodStart ?? null,
periodEnd: i.periodEnd ?? null,
issuedAt: payload.type === "invoice.issued" ? (i.issuedAt ?? new Date().toISOString()) : (i.issuedAt ?? null),
paidAt: payload.type === "invoice.paid" ? (i.paidAt ?? new Date().toISOString()) : (i.paidAt ?? null),
};
return { kind: "invoice_upsert", invoice };
}
if (payload.type === "subscription.canceled" && payload.subscriptionId) {
return { kind: "subscription_canceled", externalSubscriptionId: payload.subscriptionId };
}
return { kind: "ignored", rawType: payload.type };
}
async listInvoices(): Promise<NormalizedInvoice[]> {
return [];
}
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- mock.provider.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/infrastructure/providers/mock.provider.ts api/src/billing/infrastructure/providers/mock.provider.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): MockProvider"
Task 3.2: LagoProviderโ
Files:
- Create:
api/src/billing/infrastructure/providers/lago.provider.ts - Test:
api/src/billing/infrastructure/providers/lago.provider.spec.ts
The LagoProvider wraps
lago-javascript-clientagainst a self-hosted Lago instance. Task 0.3 (the spike) is a hard prerequisite โ it pins down the items flaggedVERIFY(0.3)below: exact client method names, the consolidation/fees[]shape, the calendar-day proration setting,pay_in_arrears, the rate-change mechanism, and the webhook signature scheme. The constructor takes the Lago client (+ plan code + webhook secret) so it is stubbable without network.Lago mapping (key differences from a generic biller):
- Subscription identity = Lago
external_id; we set it to ourassignmentId(natural idempotency + lets webhooks map a fee back to an assignment).- Per-Specialist rate = subscription-level
plan_overrides.amount_centson the sharedLAGO_SPECIALIST_PLAN_CODEplan.- Anchor alignment =
billing_time: "anniversary"withsubscription_aton the org anchor day, so an org's subscriptions bill together โ one consolidated invoice with onefeeper subscription.- Rate change โ Lago has no in-place price edit;
VERIFY(0.3)whether to use plan upgrade/downgrade (sameexternal_id, new override) or terminate-and-recreate. Modeled below as the override-update path; adjust per the spike.- Cancel = terminate the subscription (
destroySubscription); Lago prorates the final fee in arrears.fees[]โlineItemsโ the consolidated invoice's per-Specialist breakdown is normalized intolineItems(the itemization the client sees).
- Step 1: Write the failing test (Lago client injected so it can be stubbed)
import { LagoProvider } from "./lago.provider";
import { WebhookSignatureError } from "../../domain/errors";
function stubClient() {
return {
customers: {
createCustomer: jest.fn().mockResolvedValue({ data: { customer: { external_id: "o1" } } }),
},
subscriptions: {
createSubscription: jest.fn().mockResolvedValue({ data: { subscription: { external_id: "a1" } } }),
destroySubscription: jest.fn().mockResolvedValue({}),
},
invoices: { findAllInvoices: jest.fn().mockResolvedValue({ data: { invoices: [] } }) },
};
}
describe("LagoProvider", () => {
it("createSubscription uses assignmentId as the Lago external_id + override amount", async () => {
const client = stubClient();
const p = new LagoProvider(client as any, "specialist_monthly", "whsec");
const r = await p.createSubscription({
externalCustomerId: "o1", assignmentId: "a1", specialistId: "sp1",
specialistName: "Eleanora", monthlyRateCents: 750000n, currency: "USD", anchorDay: 15, startDate: "2026-05-01",
});
expect(r.externalSubscriptionId).toBe("a1");
expect(client.subscriptions.createSubscription).toHaveBeenCalled();
});
it("parseWebhook throws WebhookSignatureError on bad signature", () => {
const client = stubClient();
const p = new LagoProvider(client as any, "specialist_monthly", "whsec");
// verifySignature returns false for a bad signature โ throws
jest.spyOn(p as any, "verifySignature").mockReturnValue(false);
expect(() => p.parseWebhook("{}", "bad")).toThrow(WebhookSignatureError);
});
it("parseWebhook normalizes invoice.created with fees[] into per-Specialist lineItems", () => {
const client = stubClient();
const p = new LagoProvider(client as any, "specialist_monthly", "whsec");
jest.spyOn(p as any, "verifySignature").mockReturnValue(true);
const body = JSON.stringify({
webhook_type: "invoice.created",
invoice: {
lago_id: "inv1", payment_status: "pending", status: "finalized",
total_amount_cents: 581422, currency: "USD",
external_customer_id: "o1",
from_date: "2026-05-01", to_date: "2026-05-31",
fees: [
{ external_subscription_id: "a1", amount_cents: 360000, from_date: "2026-05-01", to_date: "2026-05-31", item: { name: "Bob Hethson" } },
{ external_subscription_id: "a2", amount_cents: 221422, from_date: "2026-05-14", to_date: "2026-05-31", item: { name: "Chris Lean" } },
],
},
});
const evt = p.parseWebhook(body, "good");
expect(evt.kind).toBe("invoice_upsert");
if (evt.kind === "invoice_upsert") {
expect(evt.invoice.externalInvoiceId).toBe("inv1");
expect(evt.invoice.amountCents).toBe(581422n);
expect((evt.invoice.lineItems as any[]).length).toBe(2);
}
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- lago.provider.spec
Expected: FAIL.
- Step 3: Write
lago.provider.ts(the module factory injects a real Lago client โ see Task 6.1)
import { Injectable } from "@nestjs/common";
import { BillingProviderPort } from "../../domain/ports/billing-provider.port";
import { BillingEvent, NormalizedInvoice } from "../../domain/billing-event";
import { InvoiceStatus } from "../../domain/invoice-status";
import { WebhookSignatureError } from "../../domain/errors";
import { majorToCents } from "../../domain/money";
/**
* Lago adapter (self-hosted). Maps the provider port to lago-javascript-client.
* Lago owns the billing calendar, calendar-day proration, invoice generation +
* consolidation, and (via its Stripe PSP integration) dunning. This adapter only
* translates calls and normalizes webhooks.
*
* VERIFY(0.3): exact client method/param names, fees[] shape, proration setting,
* pay_in_arrears, rate-change mechanism, and webhook signature scheme.
*/
@Injectable()
export class LagoProvider implements BillingProviderPort {
constructor(
private readonly client: any, // lago-javascript-client (injected by the factory)
private readonly specialistPlanCode: string,
private readonly webhookSecret: string,
) {}
async ensureCustomer(input: { orgId: string; name: string; email: string; externalId: string }) {
// Lago createCustomer is an upsert on external_id โ idempotent on the org id.
await this.client.customers.createCustomer({
customer: { external_id: input.externalId, name: input.name, email: input.email },
});
return { externalCustomerId: input.externalId }; // we use the org id as the Lago external customer id
}
async createSubscription(input: {
externalCustomerId: string; assignmentId: string; specialistId: string; specialistName: string;
monthlyRateCents: bigint; currency: string; anchorDay: number; startDate: string;
}) {
await this.client.subscriptions.createSubscription({
subscription: {
external_customer_id: input.externalCustomerId,
plan_code: this.specialistPlanCode,
external_id: input.assignmentId, // subscription identity = our assignment id (idempotent)
billing_time: "anniversary", // bill on subscription_at's day โ align to org anchor
subscription_at: input.startDate, // proration runs from here to the next anchor boundary
name: input.specialistName, // surfaces on the invoice line (fee item name)
plan_overrides: { amount_cents: Number(input.monthlyRateCents) }, // per-Specialist rate; VERIFY(0.3) override shape
},
});
return { externalSubscriptionId: input.assignmentId };
}
async updateSubscriptionPrice(input: { externalSubscriptionId: string; newRateCents: bigint; effectiveDate: string }): Promise<void> {
// VERIFY(0.3): Lago has no in-place price edit. Re-issue the subscription with the
// same external_id and a new amount override (upgrade/downgrade โ Lago prorates),
// OR terminate-and-recreate. Confirm which preserves the consolidated-invoice behavior.
await this.client.subscriptions.createSubscription({
subscription: {
external_id: input.externalSubscriptionId,
plan_code: this.specialistPlanCode,
plan_overrides: { amount_cents: Number(input.newRateCents) },
},
});
}
async cancelSubscription(input: { externalSubscriptionId: string; effectiveDate: string }): Promise<void> {
// Terminate as of the ORIGINAL unassign date (not "now") so arrears proration covers
// only days actually used โ otherwise a retry delay would overbill the client.
// VERIFY(0.3): exact Lago param name for a termination date on destroySubscription.
await this.client.subscriptions.destroySubscription(input.externalSubscriptionId, {
cancellation_date: input.effectiveDate,
});
}
async getCustomerPortalUrl(externalCustomerId: string): Promise<string> {
// Lago is not a payment rail; collection is via the Stripe PSP integration.
// Resolve the org's Stripe customer portal. VERIFY(0.3): exact retrieval path
// (Lago customer โ payment_provider customer id โ Stripe billingPortal.sessions.create).
return this.client.customers?.getCustomerPortalUrl?.(externalCustomerId)
.then((r: any) => r?.data?.customer?.portal_url ?? "")
.catch(() => "") ?? "";
}
parseWebhook(rawBody: string, signature: string): BillingEvent {
if (!this.verifySignature(rawBody, signature)) throw new WebhookSignatureError();
const payload = JSON.parse(rawBody) as { webhook_type: string; invoice?: any; subscription?: any };
if ((payload.webhook_type === "invoice.created" || payload.webhook_type === "invoice.payment_status_updated") && payload.invoice) {
return { kind: "invoice_upsert", invoice: this.normalizeInvoice(payload.invoice) };
}
if (payload.webhook_type === "subscription.terminated" && payload.subscription?.external_id) {
return { kind: "subscription_canceled", externalSubscriptionId: payload.subscription.external_id };
}
return { kind: "ignored", rawType: payload.webhook_type };
}
async listInvoices(externalCustomerId: string): Promise<NormalizedInvoice[]> {
const res = await this.client.invoices.findAllInvoices({ external_customer_id: externalCustomerId });
return (res?.data?.invoices ?? []).map((i: any) => this.normalizeInvoice(i));
}
/**
* SECURITY โ fail CLOSED. Lago signs webhooks (JWT/RS256 verified against the
* instance's webhook public key, or HMAC with the shared secret). The real scheme
* is pinned by the Task 0.3 spike. Until it is implemented this returns `false`, so
* `parseWebhook` rejects EVERY request with 401 โ the endpoint is safely closed
* rather than processing forged `invoice.*` / `subscription.terminated` events.
* VERIFY(0.3): replace the body with the verified scheme; do NOT ship a permissive
* "non-empty header" check.
*/
private verifySignature(rawBody: string, signature: string): boolean {
return false; // fail-closed until the real Lago verification is wired (Task 0.3)
}
private mapStatus(invoice: any): InvoiceStatus {
// Lago: payment_status โ pending|succeeded|failed; status โ draft|finalized|voided.
if (invoice.status === "voided") return "void";
if (invoice.status === "draft") return "draft";
if (invoice.payment_status === "succeeded") return "paid";
if (invoice.payment_status === "failed") return "payment_failed";
return "issued"; // finalized + pending payment
}
private normalizeInvoice(i: any): NormalizedInvoice {
const lineItems = (i.fees ?? []).map((f: any) => ({
externalSubscriptionId: f.external_subscription_id ?? null,
specialistName: f.item?.name ?? null,
amountCents: String(f.amount_cents ?? 0),
periodStart: f.from_date ?? null,
periodEnd: f.to_date ?? null,
}));
return {
externalInvoiceId: i.lago_id,
externalCustomerId: i.external_customer_id,
externalSubscriptionId: null, // consolidated invoice spans subscriptions
status: this.mapStatus(i),
amountCents: BigInt(i.total_amount_cents ?? 0), // Lago amounts are already integer cents
currency: i.currency ?? "USD",
hostedUrl: i.file_url ?? null,
pdfUrl: i.file_url ?? null,
lineItems,
periodStart: i.from_date ?? null,
periodEnd: i.to_date ?? null,
issuedAt: i.issuing_date ?? null,
// VERIFY(0.3): exact Lago field for payment-success timestamp (likely payment_succeeded_at).
// NOT payment_dispute_lost_at โ that is the chargeback-loss timestamp, null for normal payments.
paidAt: i.payment_status === "succeeded" ? (i.payment_succeeded_at ?? null) : null,
};
}
}
Note: Lago returns amounts as integer cents already (
amount_cents,total_amount_cents), so the invoice path usesBigInt(...)directly โmajorToCentsis still imported for the override amount if a major-unit source is ever used. Confirm units in the spike.
- Step 4: Run to verify pass
Run: npm --prefix api test -- lago.provider.spec
Expected: PASS.
-
Step 5: Reconcile with the Task 0.3 spike findings โ adjust every
VERIFY(0.3)site (method names,fees[]/amount shape, proration setting, rate-change mechanism, signature scheme) to match the real Lago instance. Re-run the test. -
Step 6: Commit
git add 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): LagoProvider adapter (consolidated invoices, feesโlineItems)"
Phase 4 โ Application Commandsโ
Commands depend on an active-assignment rate source. The org's anchor day comes from
organizations.onboarded_at; the per-assignment rate comes fromorg_specialist_assignments.monthly_rate_config.base_rate(falling back to the specialist catalog rate), already computed byOrganizationsService._recomputeOrgTotalRate. To avoid a circular dependency, commands receive the resolved inputs (rate, anchor, customer info) from their caller (the assignment hook in Task 6.5) rather than reaching into OrganizationsService.
Task 4.1: SyncSpecialistSubscriptionCommand (create or update)โ
Files:
-
Create:
api/src/billing/application/commands/sync-specialist-subscription.command.ts -
Test:
api/src/billing/application/commands/sync-specialist-subscription.command.spec.ts -
Step 1: Write the failing test (against MockProvider + an in-memory fake repo)
import { SyncSpecialistSubscriptionCommand } from "./sync-specialist-subscription.command";
import { MockProvider } from "../../infrastructure/providers/mock.provider";
import { BillingSubscription } from "../../domain/billing-subscription";
class FakeRepo {
rows: BillingSubscription[] = [];
async findByAssignmentId(id: string) { return this.rows.find(r => r.assignmentId === id) ?? null; }
async save(s: BillingSubscription) {
if (!s.id) s.id = `sub-${this.rows.length + 1}`;
this.rows = this.rows.filter(r => r.id !== s.id).concat(s);
return s;
}
}
const input = {
orgId: "o1", specialistId: "sp1", assignmentId: "a1", specialistName: "Eleanora",
monthlyRateCents: 750000n, currency: "USD", anchorDay: 15, effectiveDate: "2026-05-01",
customer: { name: "Acme", email: "a@acme.com", externalId: "o1" },
};
describe("SyncSpecialistSubscriptionCommand", () => {
it("creates a subscription on first sync and marks it active", async () => {
const repo = new FakeRepo();
const cmd = new SyncSpecialistSubscriptionCommand(repo as any, new MockProvider());
await cmd.execute(input);
const sub = await repo.findByAssignmentId("a1");
expect(sub?.status).toBe("active");
expect(sub?.externalSubscriptionId).toBe("sub_a1");
});
it("updates price when rate changed on an existing active subscription", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const spy = jest.spyOn(provider, "updateSubscriptionPrice");
const cmd = new SyncSpecialistSubscriptionCommand(repo as any, provider);
await cmd.execute(input); // create
await cmd.execute({ ...input, monthlyRateCents: 800000n }); // re-rate
expect(spy).toHaveBeenCalledTimes(1);
expect((await repo.findByAssignmentId("a1"))?.currentRateCents).toBe(800000n);
});
it("no-ops the provider when rate unchanged", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const spy = jest.spyOn(provider, "updateSubscriptionPrice");
const cmd = new SyncSpecialistSubscriptionCommand(repo as any, provider);
await cmd.execute(input);
await cmd.execute(input); // same rate
expect(spy).not.toHaveBeenCalled();
});
it("marks sync_failed when provider create throws after retries", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
jest.spyOn(provider, "createSubscription").mockRejectedValue(new Error("provider down"));
const cmd = new SyncSpecialistSubscriptionCommand(repo as any, provider);
await cmd.execute(input); // should not throw
expect((await repo.findByAssignmentId("a1"))?.status).toBe("sync_failed");
});
it("preserves the original rate-change date on a delayed update retry (not today)", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const cmd = new SyncSpecialistSubscriptionCommand(repo as any, provider);
await cmd.execute(input); // create โ active
// simulate a rate change that failed its retries, recording the original date:
const sub = await repo.findByAssignmentId("a1");
Object.assign(sub!, { status: "sync_failed", syncFailedOp: "update", rateChangeEffectiveDate: "2026-05-10" });
await repo.save(sub!);
const spy = jest.spyOn(provider, "updateSubscriptionPrice");
// SA retries 10 days later; resolver passes today (2026-05-20)
await cmd.execute({ ...input, monthlyRateCents: 800000n, effectiveDate: "2026-05-20" });
expect(spy.mock.calls[0][0].effectiveDate).toBe("2026-05-10"); // original, not the retry date
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- sync-specialist-subscription.command.spec
Expected: FAIL.
- Step 3: Write the command
import { Inject, Injectable, Logger } from "@nestjs/common";
import { BillingSubscription } from "../../domain/billing-subscription";
import {
BILLING_SUBSCRIPTION_REPOSITORY,
BillingSubscriptionRepositoryPort,
} from "../../domain/ports/billing-subscription.repository.port";
import { BILLING_PROVIDER, BillingProviderPort } from "../../domain/ports/billing-provider.port";
export interface SyncSpecialistSubscriptionInput {
orgId: string;
specialistId: string;
assignmentId: string;
specialistName: string;
monthlyRateCents: bigint;
currency: string;
anchorDay: number;
effectiveDate: string; // ISO date
customer: { name: string; email: string; externalId: string };
}
const MAX_ATTEMPTS = 3;
@Injectable()
export class SyncSpecialistSubscriptionCommand {
private readonly logger = new Logger(SyncSpecialistSubscriptionCommand.name);
constructor(
@Inject(BILLING_SUBSCRIPTION_REPOSITORY) private readonly repo: BillingSubscriptionRepositoryPort,
@Inject(BILLING_PROVIDER) private readonly provider: BillingProviderPort,
) {}
async execute(input: SyncSpecialistSubscriptionInput): Promise<void> {
const existing = await this.repo.findByAssignmentId(input.assignmentId);
if (existing && existing.status === "canceled") return; // never resurrect a canceled subscription
// Route by whether a PROVIDER subscription already exists โ not by status.
// A sub that failed DURING an update is sync_failed but still has an
// externalSubscriptionId; it must go to applyRateChange (retry the price change),
// not provisionNewSubscription (which would no-op via idempotency and leave the old rate).
if (existing && existing.externalSubscriptionId) {
await this.applyRateChange(existing, input);
return;
}
await this.provisionNewSubscription(existing, input); // no provider sub yet (new, or failed-during-create)
}
private async provisionNewSubscription(existing: BillingSubscription | null, input: SyncSpecialistSubscriptionInput): Promise<void> {
const sub = existing ?? new BillingSubscription({
id: "", orgId: input.orgId, specialistId: input.specialistId, assignmentId: input.assignmentId,
externalSubscriptionId: null, externalCustomerId: "", status: "pending",
currentRateCents: input.monthlyRateCents, currency: input.currency, anchorDay: input.anchorDay,
provider: "", createdAt: new Date(), updatedAt: new Date(), canceledAt: null,
});
try {
const { externalCustomerId } = await this.provider.ensureCustomer({
orgId: input.orgId, name: input.customer.name, email: input.customer.email, externalId: input.customer.externalId,
});
sub.externalCustomerId = externalCustomerId;
const { externalSubscriptionId } = await this.withRetry(() => this.provider.createSubscription({
externalCustomerId, assignmentId: input.assignmentId, specialistId: input.specialistId,
specialistName: input.specialistName, monthlyRateCents: input.monthlyRateCents,
currency: input.currency, anchorDay: input.anchorDay, startDate: input.effectiveDate,
}), "createSubscription");
sub.markActive(externalSubscriptionId);
sub.currentRateCents = input.monthlyRateCents;
} catch (err) {
this.logger.error(`sync create failed for assignment ${input.assignmentId}: ${(err as Error).message}`);
sub.markSyncFailed("create");
}
await this.repo.save(sub);
}
private async applyRateChange(sub: BillingSubscription, input: SyncSpecialistSubscriptionInput): Promise<void> {
// Genuine no-op only when already healthy AND the rate is unchanged. A
// sync_failed sub must always retry (even at the same rate) to clear the flag.
if (sub.status === "active" && !sub.rateChanged(input.monthlyRateCents)) return;
// Preserve the ORIGINAL rate-change date across retries (the resolver always passes
// today). The provider prorates the delta from this date; using today on a delayed
// retry would mis-prorate the gap. First call records it; retries reuse it.
const effective = sub.rateChangeEffectiveDate ?? input.effectiveDate;
sub.rateChangeEffectiveDate = effective;
try {
await this.withRetry(() => this.provider.updateSubscriptionPrice({
externalSubscriptionId: sub.externalSubscriptionId!, newRateCents: input.monthlyRateCents,
effectiveDate: effective,
}), "updateSubscriptionPrice");
sub.markActive(sub.externalSubscriptionId!); // clears prior sync_failed + rateChangeEffectiveDate
sub.updateRate(input.monthlyRateCents);
} catch (err) {
this.logger.error(`sync update failed for assignment ${input.assignmentId}: ${(err as Error).message}`);
sub.markSyncFailed("update"); // rateChangeEffectiveDate persists for the retry
}
await this.repo.save(sub);
}
private async withRetry<T>(fn: () => Promise<T>, op: string): Promise<T> {
let lastErr: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try { return await fn(); }
catch (err) { lastErr = err; if (attempt < MAX_ATTEMPTS) await new Promise(r => setTimeout(r, 100 * attempt)); }
}
throw lastErr;
}
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- sync-specialist-subscription.command.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/application/commands/sync-specialist-subscription.command.ts api/src/billing/application/commands/sync-specialist-subscription.command.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): SyncSpecialistSubscriptionCommand (create/update + retry + sync_failed)"
Task 4.2: CancelSpecialistSubscriptionCommandโ
Files:
-
Create:
api/src/billing/application/commands/cancel-specialist-subscription.command.ts -
Test:
api/src/billing/application/commands/cancel-specialist-subscription.command.spec.ts -
Step 1: Write the failing test
import { CancelSpecialistSubscriptionCommand } from "./cancel-specialist-subscription.command";
import { MockProvider } from "../../infrastructure/providers/mock.provider";
import { BillingSubscription } from "../../domain/billing-subscription";
class FakeRepo {
rows: BillingSubscription[] = [];
async findByAssignmentId(id: string) { return this.rows.find(r => r.assignmentId === id) ?? null; }
async save(s: BillingSubscription) { this.rows = this.rows.filter(r => r.id !== s.id).concat(s); return s; }
}
const active = () => new BillingSubscription({
id: "sub-1", orgId: "o1", specialistId: "sp1", assignmentId: "a1",
externalSubscriptionId: "sub_a1", externalCustomerId: "cus_o1", status: "active",
currentRateCents: 750000n, currency: "USD", anchorDay: 15, provider: "mock",
createdAt: new Date(), updatedAt: new Date(), canceledAt: null,
});
describe("CancelSpecialistSubscriptionCommand", () => {
it("cancels an active subscription", async () => {
const repo = new FakeRepo(); repo.rows.push(active());
const provider = new MockProvider();
const spy = jest.spyOn(provider, "cancelSubscription");
const cmd = new CancelSpecialistSubscriptionCommand(repo as any, provider);
await cmd.execute({ assignmentId: "a1", effectiveDate: "2026-05-12" });
expect(spy).toHaveBeenCalled();
expect((await repo.findByAssignmentId("a1"))?.status).toBe("canceled");
});
it("is a no-op when there is no subscription to cancel", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const spy = jest.spyOn(provider, "cancelSubscription");
const cmd = new CancelSpecialistSubscriptionCommand(repo as any, provider);
await cmd.execute({ assignmentId: "missing", effectiveDate: "2026-05-12" });
expect(spy).not.toHaveBeenCalled();
});
it("retries a previously-failed cancel (status sync_failed) instead of skipping it", async () => {
const repo = new FakeRepo();
// a cancel that failed its retries: sync_failed + syncFailedOp 'cancel', valid external id
repo.rows.push(Object.assign(active(), { status: "sync_failed", syncFailedOp: "cancel", cancelEffectiveDate: "2026-05-12" }));
const provider = new MockProvider();
const spy = jest.spyOn(provider, "cancelSubscription");
const cmd = new CancelSpecialistSubscriptionCommand(repo as any, provider);
await cmd.execute({ assignmentId: "a1", effectiveDate: "2026-05-20" }); // SA retries 8 days later
expect(spy).toHaveBeenCalled();
// original unassign date is preserved (arrears: bill only days used), NOT the retry date
expect(spy.mock.calls[0][0].effectiveDate).toBe("2026-05-12");
expect((await repo.findByAssignmentId("a1"))?.status).toBe("canceled");
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- cancel-specialist-subscription.command.spec
Expected: FAIL.
- Step 3: Write the command
import { Inject, Injectable, Logger } from "@nestjs/common";
import {
BILLING_SUBSCRIPTION_REPOSITORY,
BillingSubscriptionRepositoryPort,
} from "../../domain/ports/billing-subscription.repository.port";
import { BILLING_PROVIDER, BillingProviderPort } from "../../domain/ports/billing-provider.port";
@Injectable()
export class CancelSpecialistSubscriptionCommand {
private readonly logger = new Logger(CancelSpecialistSubscriptionCommand.name);
constructor(
@Inject(BILLING_SUBSCRIPTION_REPOSITORY) private readonly repo: BillingSubscriptionRepositoryPort,
@Inject(BILLING_PROVIDER) private readonly provider: BillingProviderPort,
) {}
async execute(input: { assignmentId: string; effectiveDate: string }): Promise<void> {
const sub = await this.repo.findByAssignmentId(input.assignmentId);
// Cancel anything that has a live provider subscription and isn't already
// canceled โ this includes status "sync_failed" (a previous cancel that failed
// its retries), which an SA retry must be able to push through. Skipping on
// "!== active" would leave a failed cancel stuck in sync_failed forever.
if (!sub || !sub.externalSubscriptionId || sub.status === "canceled") return; // nothing to cancel
// Preserve the ORIGINAL requested cancellation date across retries. Billing is
// in arrears ("days used"); using today's date on a delayed retry would bill the
// client for the gap between the unassign and the retry. First call records it;
// retries reuse it regardless of what the caller passes.
const effective = sub.cancelEffectiveDate ?? input.effectiveDate;
sub.cancelEffectiveDate = effective;
try {
await this.provider.cancelSubscription({
externalSubscriptionId: sub.externalSubscriptionId, effectiveDate: effective,
});
sub.markCanceled(new Date(effective));
} catch (err) {
this.logger.error(`cancel failed for assignment ${input.assignmentId}: ${(err as Error).message}`);
sub.markSyncFailed("cancel");
}
await this.repo.save(sub);
}
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- cancel-specialist-subscription.command.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/application/commands/cancel-specialist-subscription.command.ts api/src/billing/application/commands/cancel-specialist-subscription.command.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): CancelSpecialistSubscriptionCommand"
Task 4.3: HandleBillingWebhookCommandโ
Files:
-
Create:
api/src/billing/application/commands/handle-billing-webhook.command.ts -
Test:
api/src/billing/application/commands/handle-billing-webhook.command.spec.ts -
Step 1: Write the failing test
import { HandleBillingWebhookCommand } from "./handle-billing-webhook.command";
import { MockProvider } from "../../infrastructure/providers/mock.provider";
import { Invoice } from "../../domain/invoice";
import { BillingSubscription } from "../../domain/billing-subscription";
class FakeInvoiceRepo {
rows: Invoice[] = [];
async upsertByExternalId(inv: Invoice) {
const i = this.rows.findIndex(r => r.externalInvoiceId === inv.externalInvoiceId);
if (i >= 0) this.rows[i] = inv; else { inv.id = `inv-${this.rows.length + 1}`; this.rows.push(inv); }
return inv;
}
async findByExternalId(id: string) { return this.rows.find(r => r.externalInvoiceId === id) ?? null; }
}
class FakeSubRepo {
rows: BillingSubscription[] = [];
async findByExternalSubscriptionId(id: string) { return this.rows.find(r => r.externalSubscriptionId === id) ?? null; }
async findByExternalCustomerId(id: string) { return this.rows.find(r => r.externalCustomerId === id) ?? null; }
async save(s: BillingSubscription) { this.rows = this.rows.filter(r => r.id !== s.id).concat(s); return s; }
}
const issuedBody = JSON.stringify({
type: "invoice.issued",
invoice: { id: "inv1", customerId: "cus_o1", subscriptionId: "sub_a1", amountCents: "375000",
currency: "USD", periodStart: "2026-05-01", periodEnd: "2026-05-31" },
});
describe("HandleBillingWebhookCommand", () => {
it("upserts an invoice from invoice.issued, resolving org via the local subscription", async () => {
const invRepo = new FakeInvoiceRepo();
const subRepo = new FakeSubRepo();
subRepo.rows.push(new BillingSubscription({
id: "sub-1", orgId: "o1", specialistId: "sp1", assignmentId: "a1",
externalSubscriptionId: "sub_a1", externalCustomerId: "cus_o1", status: "active",
currentRateCents: 750000n, currency: "USD", anchorDay: 15, provider: "mock",
createdAt: new Date(), updatedAt: new Date(), canceledAt: null,
}));
const cmd = new HandleBillingWebhookCommand(new MockProvider(), invRepo as any, subRepo as any);
await cmd.execute(issuedBody, "sig");
const inv = await invRepo.findByExternalId("inv1");
expect(inv?.orgId).toBe("o1");
expect(inv?.specialistId).toBe("sp1");
expect(inv?.status).toBe("issued");
});
it("resolves org via externalCustomerId for a consolidated invoice (no subscription id)", async () => {
const invRepo = new FakeInvoiceRepo();
const subRepo = new FakeSubRepo();
subRepo.rows.push(new BillingSubscription({
id: "sub-1", orgId: "o1", specialistId: "sp1", assignmentId: "a1",
externalSubscriptionId: "sub_a1", externalCustomerId: "cus_o1", status: "active",
currentRateCents: 750000n, currency: "USD", anchorDay: 15, provider: "mock",
createdAt: new Date(), updatedAt: new Date(), canceledAt: null,
}));
// consolidated invoice: no subscriptionId, only the customer
const consolidated = JSON.stringify({
type: "invoice.issued",
invoice: { id: "inv2", customerId: "cus_o1", amountCents: "581422", currency: "USD",
periodStart: "2026-05-01", periodEnd: "2026-05-31" },
});
const cmd = new HandleBillingWebhookCommand(new MockProvider(), invRepo as any, subRepo as any);
await cmd.execute(consolidated, "sig");
expect((await invRepo.findByExternalId("inv2"))?.orgId).toBe("o1");
});
it("is idempotent on duplicate delivery", async () => {
const invRepo = new FakeInvoiceRepo();
const cmd = new HandleBillingWebhookCommand(new MockProvider(), invRepo as any, new FakeSubRepo() as any);
await cmd.execute(issuedBody, "sig");
await cmd.execute(issuedBody, "sig");
expect(invRepo.rows.length).toBe(1);
});
it("ignores unknown events without throwing", async () => {
const invRepo = new FakeInvoiceRepo();
const cmd = new HandleBillingWebhookCommand(new MockProvider(), invRepo as any, new FakeSubRepo() as any);
await cmd.execute(JSON.stringify({ type: "customer.updated" }), "sig");
expect(invRepo.rows.length).toBe(0);
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- handle-billing-webhook.command.spec
Expected: FAIL.
- Step 3: Write the command
import { Inject, Injectable, Logger } from "@nestjs/common";
import { BILLING_PROVIDER, BillingProviderPort } from "../../domain/ports/billing-provider.port";
import { INVOICE_REPOSITORY, InvoiceRepositoryPort } from "../../domain/ports/invoice.repository.port";
import {
BILLING_SUBSCRIPTION_REPOSITORY,
BillingSubscriptionRepositoryPort,
} from "../../domain/ports/billing-subscription.repository.port";
import { Invoice } from "../../domain/invoice";
@Injectable()
export class HandleBillingWebhookCommand {
private readonly logger = new Logger(HandleBillingWebhookCommand.name);
constructor(
@Inject(BILLING_PROVIDER) private readonly provider: BillingProviderPort,
@Inject(INVOICE_REPOSITORY) private readonly invoices: InvoiceRepositoryPort,
@Inject(BILLING_SUBSCRIPTION_REPOSITORY) private readonly subs: BillingSubscriptionRepositoryPort,
) {}
/** Throws WebhookSignatureError on bad signature (controller maps to 401). */
async execute(rawBody: string, signature: string): Promise<void> {
const event = this.provider.parseWebhook(rawBody, signature);
if (event.kind === "ignored") {
this.logger.debug(`Ignored billing event: ${event.rawType}`);
return;
}
if (event.kind === "subscription_canceled") {
const sub = await this.subs.findByExternalSubscriptionId(event.externalSubscriptionId);
if (sub && sub.status !== "canceled") { sub.markCanceled(new Date()); await this.subs.save(sub); }
return;
}
// invoice_upsert โ resolve local links via the subscription, then upsert.
const snap = event.invoice;
let orgId: string | null = null;
let specialistId: string | null = null;
let billingSubscriptionId: string | null = null;
if (snap.externalSubscriptionId) {
const sub = await this.subs.findByExternalSubscriptionId(snap.externalSubscriptionId);
if (sub) { orgId = sub.orgId; specialistId = sub.specialistId; billingSubscriptionId = sub.id; }
}
if (!orgId) {
// Consolidated invoices (Lago) carry no single subscription id, so the block
// above never resolves them. Resolve the org via any of its subscriptions that
// share this provider customer โ the mirror maps externalCustomerId โ org for
// whichever provider issued it (works whether externalCustomerId is the org UUID
// (Lago) or an opaque provider id (Orb/Stripe)). By the time an invoice arrives,
// the org's subscriptions already exist (they were created on assignment).
const byCustomer = await this.subs.findByExternalCustomerId(snap.externalCustomerId);
if (byCustomer) orgId = byCustomer.orgId;
}
const invoice = Invoice.fromSnapshot(snap, { orgId, specialistId, billingSubscriptionId });
await this.invoices.upsertByExternalId(invoice);
}
}
Note: org resolution is two-step โ by
externalSubscriptionId(per-subscription invoices), then byexternalCustomerIdvia the subscription mirror (consolidated invoices). Only if neither resolves (e.g. a webhook arrives before any local sub row exists) isorgIdleft null and the row still stored โ the mirror is a projection, not a join requirement (per spec ยง9).billing_invoices.org_idis therefore nullable (Task 2.1 / 2.3); theInvoicedomainorgIdisstring | null. A future reconciliation can backfill the rare nulls; not in scope.
- Step 4: Run to verify pass
Run: npm --prefix api test -- handle-billing-webhook.command.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/application/commands/handle-billing-webhook.command.ts api/src/billing/application/commands/handle-billing-webhook.command.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): HandleBillingWebhookCommand (mirror upsert, idempotent)"
Phase 5 โ Application Queriesโ
Task 5.1: ClientBillingSummaryQuery (list consolidated invoices)โ
Files:
- Create:
api/src/billing/application/queries/client-billing-summary.query.ts - Test:
api/src/billing/application/queries/client-billing-summary.query.spec.ts
The provider already emits one consolidated invoice per cycle (spec ยง3.1/ยง7) with the per-Specialist breakdown in
lineItems. So this query does no grouping/roll-up โ it lists the org's mirrored invoices newest-first; the UI renders each invoice's total + itslineItems.
- Step 1: Write the failing test
import { ClientBillingSummaryQuery } from "./client-billing-summary.query";
import { Invoice } from "../../domain/invoice";
class FakeInvoiceRepo {
constructor(private rows: Invoice[]) {}
async findByOrgId() { return this.rows; }
}
const inv = (over: Partial<any>) => new Invoice({
id: "i", orgId: "o1", specialistId: null, billingSubscriptionId: null,
externalInvoiceId: "x", externalCustomerId: "c", status: "issued", amountCents: 581422n,
currency: "USD", hostedUrl: null, pdfUrl: null,
lineItems: [
{ specialistName: "Bob Hethson", amountCents: "360000", periodStart: "2026-05-01", periodEnd: "2026-05-31" },
{ specialistName: "Chris Lean", amountCents: "221422", periodStart: "2026-05-14", periodEnd: "2026-05-31" },
],
periodStart: "2026-05-01", periodEnd: "2026-05-31", issuedAt: null, paidAt: null, ...over,
});
describe("ClientBillingSummaryQuery", () => {
it("lists the org's consolidated invoices newest-first, each with its line items", async () => {
const repo = new FakeInvoiceRepo([
inv({ externalInvoiceId: "apr", periodStart: "2026-04-01" }),
inv({ externalInvoiceId: "may", periodStart: "2026-05-01" }),
]);
const result = await new ClientBillingSummaryQuery(repo as any).execute("o1");
expect(result.invoices.map(i => i.externalInvoiceId)).toEqual(["may", "apr"]); // newest first
expect((result.invoices[0].lineItems as any[]).length).toBe(2);
expect(result.invoices[0].amountCents).toBe(581422n);
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- client-billing-summary.query.spec
Expected: FAIL.
- Step 3: Write the query
import { Inject, Injectable } from "@nestjs/common";
import { INVOICE_REPOSITORY, InvoiceRepositoryPort } from "../../domain/ports/invoice.repository.port";
import { Invoice } from "../../domain/invoice";
export interface ClientBillingSummary {
orgId: string;
invoices: Invoice[]; // consolidated, newest first; per-Specialist breakdown is each invoice's lineItems
}
@Injectable()
export class ClientBillingSummaryQuery {
constructor(@Inject(INVOICE_REPOSITORY) private readonly invoices: InvoiceRepositoryPort) {}
async execute(orgId: string): Promise<ClientBillingSummary> {
const all = await this.invoices.findByOrgId(orgId);
const invoices = [...all].sort((a, b) => (a.periodStart ?? "") < (b.periodStart ?? "") ? 1 : -1);
return { orgId, invoices };
}
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- client-billing-summary.query.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/application/queries/client-billing-summary.query.ts api/src/billing/application/queries/client-billing-summary.query.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): ClientBillingSummaryQuery (list consolidated invoices)"
Task 5.2: OpsBillingOverviewQueryโ
Files:
-
Create:
api/src/billing/application/queries/ops-billing-overview.query.ts -
Test:
api/src/billing/application/queries/ops-billing-overview.query.spec.ts -
Step 1: Write the failing test
import { OpsBillingOverviewQuery } from "./ops-billing-overview.query";
import { BillingSubscription } from "../../domain/billing-subscription";
class FakeSubRepo {
constructor(private rows: BillingSubscription[]) {}
async listAll() { return this.rows; }
}
const sub = (over: Partial<any>) => new BillingSubscription({
id: "s", orgId: "o1", specialistId: "sp1", assignmentId: "a1",
externalSubscriptionId: "x", externalCustomerId: "c", status: "active",
currentRateCents: 750000n, currency: "USD", anchorDay: 15, provider: "mock",
createdAt: new Date(), updatedAt: new Date(), canceledAt: null, ...over,
});
describe("OpsBillingOverviewQuery", () => {
it("summarizes subscriptions and surfaces sync_failed count", async () => {
const repo = new FakeSubRepo([
sub({ assignmentId: "a1", status: "active" }),
sub({ assignmentId: "a2", status: "sync_failed" }),
]);
const q = new OpsBillingOverviewQuery(repo as any);
const r = await q.execute();
expect(r.totalSubscriptions).toBe(2);
expect(r.syncFailedCount).toBe(1);
expect(r.subscriptions.find(s => s.assignmentId === "a2")?.status).toBe("sync_failed");
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- ops-billing-overview.query.spec
Expected: FAIL.
- Step 3: Write the query
import { Inject, Injectable } from "@nestjs/common";
import {
BILLING_SUBSCRIPTION_REPOSITORY,
BillingSubscriptionRepositoryPort,
} from "../../domain/ports/billing-subscription.repository.port";
export interface OpsBillingOverview {
totalSubscriptions: number;
activeCount: number;
syncFailedCount: number;
subscriptions: Array<{
orgId: string; specialistId: string; assignmentId: string;
status: string; syncFailedOp: string | null; currentRateCents: string; currency: string;
}>;
}
@Injectable()
export class OpsBillingOverviewQuery {
constructor(@Inject(BILLING_SUBSCRIPTION_REPOSITORY) private readonly subs: BillingSubscriptionRepositoryPort) {}
async execute(): Promise<OpsBillingOverview> {
const all = await this.subs.listAll();
return {
totalSubscriptions: all.length,
activeCount: all.filter(s => s.status === "active").length,
syncFailedCount: all.filter(s => s.status === "sync_failed").length,
subscriptions: all.map(s => ({
orgId: s.orgId, specialistId: s.specialistId, assignmentId: s.assignmentId,
status: s.status, syncFailedOp: s.syncFailedOp, currentRateCents: s.currentRateCents.toString(), currency: s.currency,
})),
};
}
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- ops-billing-overview.query.spec
Expected: PASS.
- Step 5: Commit
git add api/src/billing/application/queries/ops-billing-overview.query.ts api/src/billing/application/queries/ops-billing-overview.query.spec.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): OpsBillingOverviewQuery"
Phase 6 โ Interface + Module Wiring + Assignment Hooksโ
Task 6.1: BillingModule with provider factoryโ
Files: Create api/src/billing/billing.module.ts (this REPLACES the legacy one in Phase 7; for now create the new module under the same path is not possible until legacy is removed โ so create it as billing.module.ts content but DO NOT delete legacy yet; instead, name the new file billing-subscription.module.ts temporarily and rename in Phase 7).
To keep the build green while the legacy module still exists, this task creates
api/src/billing/billing-subscription.module.ts. Phase 7 deletes the legacy module and renames this tobilling.module.ts.
- Step 1: Write the module
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Client } from "lago-javascript-client";
import { BillingSubscriptionStorage } from "./infrastructure/billing-subscription.storage";
import { InvoiceStorage } from "./infrastructure/invoice.storage";
import { BillingSubscriptionRepository } from "./infrastructure/billing-subscription.repository";
import { InvoiceRepository } from "./infrastructure/invoice.repository";
import { MockProvider } from "./infrastructure/providers/mock.provider";
import { LagoProvider } from "./infrastructure/providers/lago.provider";
import { BILLING_SUBSCRIPTION_REPOSITORY } from "./domain/ports/billing-subscription.repository.port";
import { INVOICE_REPOSITORY } from "./domain/ports/invoice.repository.port";
import { BILLING_PROVIDER } from "./domain/ports/billing-provider.port";
import { SyncSpecialistSubscriptionCommand } from "./application/commands/sync-specialist-subscription.command";
import { CancelSpecialistSubscriptionCommand } from "./application/commands/cancel-specialist-subscription.command";
import { HandleBillingWebhookCommand } from "./application/commands/handle-billing-webhook.command";
import { ClientBillingSummaryQuery } from "./application/queries/client-billing-summary.query";
import { OpsBillingOverviewQuery } from "./application/queries/ops-billing-overview.query";
import { ClientBillingController } from "./interface/client-billing.controller";
import { OpsBillingController } from "./interface/ops-billing.controller";
import { BillingWebhookController } from "./interface/billing-webhook.controller";
import { AuthModule } from "../auth/auth.module";
import { BILLING_PROVIDER as PROVIDER_NAME, LAGO_API_URL, LAGO_API_KEY, LAGO_WEBHOOK_SECRET, LAGO_SPECIALIST_PLAN_CODE } from "../common/env";
@Module({
imports: [TypeOrmModule.forFeature([BillingSubscriptionStorage, InvoiceStorage]), AuthModule],
controllers: [ClientBillingController, OpsBillingController, BillingWebhookController],
providers: [
{ provide: BILLING_SUBSCRIPTION_REPOSITORY, useClass: BillingSubscriptionRepository },
{ provide: INVOICE_REPOSITORY, useClass: InvoiceRepository },
{
provide: BILLING_PROVIDER,
useFactory: () => {
if (PROVIDER_NAME === "lago" && LAGO_API_KEY) {
// lago-javascript-client: Client(apiKey, { baseUrl }). VERIFY(0.3) exact factory signature.
const client = Client(LAGO_API_KEY, { baseUrl: LAGO_API_URL });
return new LagoProvider(client, LAGO_SPECIALIST_PLAN_CODE, LAGO_WEBHOOK_SECRET);
}
return new MockProvider();
},
},
SyncSpecialistSubscriptionCommand,
CancelSpecialistSubscriptionCommand,
HandleBillingWebhookCommand,
ClientBillingSummaryQuery,
OpsBillingOverviewQuery,
],
exports: [SyncSpecialistSubscriptionCommand, CancelSpecialistSubscriptionCommand],
})
export class BillingSubscriptionModule {}
Note the import alias collision:
BILLING_PROVIDERis both the DI token (from the port) and the env var name. The env import is aliased toPROVIDER_NAMEabove to disambiguate.VERIFY(0.3): confirm thelago-javascript-clientfactory export + signature (Client(apiKey, { baseUrl })) against the installed package.
- Step 2: Build (controllers don't exist yet โ expect failure; create stubs in 6.2-6.4 first if doing strict TDD). Proceed to 6.2-6.4, then return to wire. Commit after 6.4.
Task 6.2: BillingWebhookController (raw body)โ
Files:
- Create:
api/src/billing/interface/billing-webhook.controller.ts - Test:
api/src/billing/interface/billing-webhook.controller.spec.ts
Raw body: the app already enables
rawBodyfor the legacy Stripe webhook (controllers read(req as any).rawBody). Reuse that mechanism. Confirmmain.tsconstructs the app with{ rawBody: true }(it must, since the legacy webhook relies on it).
- Step 1: Write the failing test
import { BillingWebhookController } from "./billing-webhook.controller";
import { WebhookSignatureError } from "../domain/errors";
describe("BillingWebhookController", () => {
it("delegates to the command and returns received:true", async () => {
const cmd = { execute: jest.fn().mockResolvedValue(undefined) };
const ctrl = new BillingWebhookController(cmd as any);
const res = await ctrl.handle({ rawBody: Buffer.from("{}") } as any, "sig");
expect(cmd.execute).toHaveBeenCalledWith("{}", "sig");
expect(res).toEqual({ received: true });
});
it("maps a signature error to 401", async () => {
const cmd = { execute: jest.fn().mockRejectedValue(new WebhookSignatureError()) };
const ctrl = new BillingWebhookController(cmd as any);
await expect(ctrl.handle({ rawBody: Buffer.from("{}") } as any, "bad")).rejects.toMatchObject({ status: 401 });
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- billing-webhook.controller.spec
Expected: FAIL.
- Step 3: Write the controller
import { BadRequestException, Controller, Headers, HttpCode, HttpStatus, Post, Req, UnauthorizedException } from "@nestjs/common";
import { Request } from "express";
import { HandleBillingWebhookCommand } from "../application/commands/handle-billing-webhook.command";
import { WebhookSignatureError } from "../domain/errors";
@Controller("billing/webhooks")
export class BillingWebhookController {
// Injected dependency is named `command` to avoid colliding with the public
// `handle` test-helper method below (TS forbids a field + method same name).
constructor(private readonly command: HandleBillingWebhookCommand) {}
@Post("lago")
@HttpCode(HttpStatus.OK)
async handleLago(@Req() req: Request, @Headers("x-lago-signature") signature: string) {
// VERIFY(0.3): Lago's exact webhook signature header name + scheme.
return this.handle_(req, signature);
}
// exposed for unit testing without HTTP plumbing
async handle(req: Request, signature: string) {
return this.handle_(req, signature);
}
private async handle_(req: Request, signature: string) {
const rawBody = (req as any).rawBody as Buffer | undefined;
if (!rawBody) throw new BadRequestException("Missing raw body โ ensure rawBody is enabled");
try {
await this.command.execute(rawBody.toString("utf8"), signature ?? "");
return { received: true };
} catch (err) {
if (err instanceof WebhookSignatureError) throw new UnauthorizedException(err.message);
throw err;
}
}
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- billing-webhook.controller.spec
Expected: PASS.
Task 6.3: ClientBillingControllerโ
Files:
- Create:
api/src/billing/interface/client-billing.controller.ts - Test:
api/src/billing/interface/client-billing.controller.spec.ts
IDOR: clients may only read their own org's billing. Resolve
orgIdfrom the authenticated user (req.user.orgId), never from a path/body param. Follow the existing controller pattern for@UseGuards(JwtAuthGuard)and readingreq.user.
- Step 1: Write the failing test
import { ClientBillingController } from "./client-billing.controller";
describe("ClientBillingController", () => {
it("summary uses the authenticated user's orgId (IDOR-safe)", async () => {
const query = { execute: jest.fn().mockResolvedValue({ orgId: "o1", cycles: [] }) };
const provider = { getCustomerPortalUrl: jest.fn() };
const subRepo = { findByOrgId: jest.fn() };
const ctrl = new ClientBillingController(query as any, provider as any, subRepo as any);
const res = await ctrl.summary({ user: { orgId: "o1" } } as any);
expect(query.execute).toHaveBeenCalledWith("o1");
expect(res.orgId).toBe("o1");
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- client-billing.controller.spec
Expected: FAIL.
- Step 3: Write the controller
import { Controller, Get, Inject, Req, UseGuards } from "@nestjs/common";
import { Request } from "express";
import { JwtAuthGuard } from "../../auth/jwt-auth.guard";
import { ClientBillingSummaryQuery } from "../application/queries/client-billing-summary.query";
import { BILLING_PROVIDER, BillingProviderPort } from "../domain/ports/billing-provider.port";
import {
BILLING_SUBSCRIPTION_REPOSITORY,
BillingSubscriptionRepositoryPort,
} from "../domain/ports/billing-subscription.repository.port";
@Controller("client/billing")
@UseGuards(JwtAuthGuard)
export class ClientBillingController {
constructor(
private readonly summaryQuery: ClientBillingSummaryQuery,
@Inject(BILLING_PROVIDER) private readonly provider: BillingProviderPort,
@Inject(BILLING_SUBSCRIPTION_REPOSITORY) private readonly subs: BillingSubscriptionRepositoryPort,
) {}
@Get("summary")
async summary(@Req() req: Request) {
const orgId = (req as any).user.orgId as string;
return this.summaryQuery.execute(orgId);
}
@Get("portal-url")
async portalUrl(@Req() req: Request) {
const orgId = (req as any).user.orgId as string;
const subs = await this.subs.findByOrgId(orgId);
const customerId = subs.find(s => s.externalCustomerId)?.externalCustomerId;
if (!customerId) return { url: null };
return { url: await this.provider.getCustomerPortalUrl(customerId) };
}
}
- Step 4: Run to verify pass
Run: npm --prefix api test -- client-billing.controller.spec
Expected: PASS.
Task 6.4: OpsBillingControllerโ
Files:
- Create:
api/src/billing/interface/ops-billing.controller.ts - Test:
api/src/billing/interface/ops-billing.controller.spec.ts
Ordering: this controller imports
AssignmentBillingInputResolver, which is created in Task 6.5. Do Task 6.5 Steps 1-4 (create the resolver + its test) BEFORE this task so the controller file compiles, then return here.Access: AM + SuperAdmin only. Use
@UseGuards(JwtAuthGuard, PlatformRolesGuard)+ the repo's roles decorator pattern used elsewhere (@Roles('superadmin','account_manager')).Retry-sync routing (critical): a
sync_failedsubscription carriessyncFailedOprecording which provider call failed. The retry MUST branch on it:
syncFailedOp === "cancel"โ re-runCancelSpecialistSubscriptionCommand(works off the subscription's ownexternalSubscriptionId, so it does not depend on the assignment row โ which is already deleted after an unassign). Routing a failed-cancel through the sync command would callupdateSubscriptionPrice+markActive, resurrecting a subscription that should be canceled and billing the client for an unassigned Specialist.- otherwise (
"create"/"update") โ re-derive inputs viaAssignmentBillingInputResolverand re-runSyncSpecialistSubscriptionCommand.
- Step 1: Write the failing test
import { OpsBillingController } from "./ops-billing.controller";
describe("OpsBillingController", () => {
const make = (sub: any) => {
const query = { execute: jest.fn().mockResolvedValue({ totalSubscriptions: 0, activeCount: 0, syncFailedCount: 0, subscriptions: [] }) };
const resolver = { resolve: jest.fn().mockResolvedValue({ orgId: "o1", assignmentId: "a1" }) };
const sync = { execute: jest.fn().mockResolvedValue(undefined) };
const cancel = { execute: jest.fn().mockResolvedValue(undefined) };
const subs = { findByAssignmentId: jest.fn().mockResolvedValue(sub) };
const ctrl = new OpsBillingController(query as any, resolver as any, sync as any, cancel as any, subs as any);
return { ctrl, query, resolver, sync, cancel, subs };
};
it("overview returns the query result", async () => {
const { ctrl } = make({ syncFailedOp: "create" });
expect((await ctrl.overview()).totalSubscriptions).toBe(0);
});
it("retrySync of a failed create re-derives inputs and re-runs the sync command", async () => {
const { ctrl, resolver, sync, cancel } = make({ syncFailedOp: "create" });
await ctrl.retrySync("a1");
expect(resolver.resolve).toHaveBeenCalledWith("a1");
expect(sync.execute).toHaveBeenCalled();
expect(cancel.execute).not.toHaveBeenCalled();
});
it("retrySync of a failed cancel re-runs the CANCEL command (no resurrection)", async () => {
const { ctrl, sync, cancel } = make({ syncFailedOp: "cancel" });
await ctrl.retrySync("a1");
expect(cancel.execute).toHaveBeenCalled();
expect(sync.execute).not.toHaveBeenCalled();
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- ops-billing.controller.spec
Expected: FAIL.
- Step 3: Write the controller
import { Controller, Get, Inject, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
import { JwtAuthGuard } from "../../auth/jwt-auth.guard";
import { PlatformRolesGuard, Roles } from "../../auth/roles.guard";
import { OpsBillingOverviewQuery } from "../application/queries/ops-billing-overview.query";
import { SyncSpecialistSubscriptionCommand } from "../application/commands/sync-specialist-subscription.command";
import { CancelSpecialistSubscriptionCommand } from "../application/commands/cancel-specialist-subscription.command";
import { AssignmentBillingInputResolver } from "../application/assignment-billing-input.resolver";
import {
BILLING_SUBSCRIPTION_REPOSITORY,
BillingSubscriptionRepositoryPort,
} from "../domain/ports/billing-subscription.repository.port";
@Controller("ops/billing")
@UseGuards(JwtAuthGuard, PlatformRolesGuard)
@Roles("superadmin", "account_manager")
export class OpsBillingController {
constructor(
private readonly overviewQuery: OpsBillingOverviewQuery,
private readonly resolver: AssignmentBillingInputResolver,
private readonly sync: SyncSpecialistSubscriptionCommand,
private readonly cancel: CancelSpecialistSubscriptionCommand,
@Inject(BILLING_SUBSCRIPTION_REPOSITORY) private readonly subs: BillingSubscriptionRepositoryPort,
) {}
@Get()
async overview() {
return this.overviewQuery.execute();
}
@Post("subscriptions/:assignmentId/retry-sync")
async retrySync(@Param("assignmentId") assignmentId: string) {
const sub = await this.subs.findByAssignmentId(assignmentId);
if (!sub) throw new NotFoundException(`no billing subscription for assignment ${assignmentId}`);
if (sub.syncFailedOp === "cancel") {
// Retry the cancellation โ NOT a sync (which would re-price + reactivate a
// subscription meant to be canceled). Works off sub.externalSubscriptionId,
// so it does not need the (already-deleted) assignment row. The date passed
// here is a fallback only: the command reuses sub.cancelEffectiveDate (the
// original unassign date) so a delayed retry doesn't overbill.
await this.cancel.execute({ assignmentId, effectiveDate: new Date().toISOString().slice(0, 10) });
} else {
const input = await this.resolver.resolve(assignmentId);
await this.sync.execute(input);
}
return { retried: true };
}
}
Confirm the
Rolesdecorator + guard import paths against an existing ops controller in the repo; adjust if the project exposes them differently.
- Step 4: Run to verify pass
Run: npm --prefix api test -- ops-billing.controller.spec
Expected: PASS.
- Step 5: Commit Phase 6.1-6.4
git add api/src/billing/billing-subscription.module.ts api/src/billing/interface
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): controllers (client/ops/webhook) + module wiring"
Task 6.5: AssignmentBillingInputResolver + hook commands into assignment lifecycleโ
Files:
- Create:
api/src/billing/application/assignment-billing-input.resolver.ts - Create:
api/src/billing/application/assignment-billing-input.resolver.spec.ts - Modify:
api/src/organizations/organizations.service.ts(methodsassignSpecialist~1041,updateSpecialistAssignment~1376,unassignSpecialist~1621,unassignSpecificSpecialist~1641) - Modify:
api/src/organizations/organizations.module.ts(importBillingSubscriptionModule)
The resolver is the seam that turns an
assignmentIdinto theSyncSpecialistSubscriptionInput(org anchor day, customer name/email, resolved rate, specialist name). It readsorg_specialist_assignments,organizations, andspecialists. Placing this logic inbilling/application(not OrganizationsService) keeps the dependency direction billingโorg-data via repositories, avoiding a circular module import.
- Step 1: Write the failing resolver test
import { AssignmentBillingInputResolver } from "./assignment-billing-input.resolver";
describe("AssignmentBillingInputResolver", () => {
it("derives sync input: anchor day from onboarded_at, rate from override else catalog", async () => {
const assignmentRepo = { findOne: jest.fn().mockResolvedValue({
id: "a1", orgId: "o1", specialistId: "sp1", monthlyRateConfig: { base_rate: 7500, currency: "USD" },
}) };
const orgRepo = { findOne: jest.fn().mockResolvedValue({
id: "o1", name: "Acme", onboardedAt: new Date("2026-01-15"), billingEmail: "a@acme.com",
}) };
const specialistRepo = { findOne: jest.fn().mockResolvedValue({
id: "sp1", firstName: "Eleanora", monthlyRateConfig: { base_rate: 5000 },
}) };
const r = new AssignmentBillingInputResolver(assignmentRepo as any, orgRepo as any, specialistRepo as any);
const input = await r.resolve("a1");
expect(input.anchorDay).toBe(15);
expect(input.monthlyRateCents).toBe(750000n); // 7500.00 * 100, override wins
expect(input.specialistName).toBe("Eleanora");
expect(input.customer.externalId).toBe("o1");
});
});
- Step 2: Run to verify fail
Run: npm --prefix api test -- assignment-billing-input.resolver.spec
Expected: FAIL.
- Step 3: Write the resolver (inject TypeORM repos for the existing entities)
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { OrgSpecialistAssignment } from "../../onboarding/onboarding.entities";
import { Organization, Specialist } from "../../common/entities";
import { SyncSpecialistSubscriptionInput } from "../application/commands/sync-specialist-subscription.command";
import { majorToCents } from "../domain/money";
@Injectable()
export class AssignmentBillingInputResolver {
constructor(
@InjectRepository(OrgSpecialistAssignment) private readonly assignments: Repository<OrgSpecialistAssignment>,
@InjectRepository(Organization) private readonly orgs: Repository<Organization>,
@InjectRepository(Specialist) private readonly specialists: Repository<Specialist>,
) {}
async resolve(assignmentId: string): Promise<SyncSpecialistSubscriptionInput> {
const a = await this.assignments.findOne({ where: { id: assignmentId } });
if (!a) throw new NotFoundException(`assignment ${assignmentId} not found`);
const org = await this.orgs.findOne({ where: { id: a.orgId } });
if (!org) throw new NotFoundException(`org ${a.orgId} not found`);
const sp = await this.specialists.findOne({ where: { id: a.specialistId } });
const onboardedAt = org.onboardedAt ?? org.createdAt ?? new Date();
const anchorDay = onboardedAt.getUTCDate();
// base_rate is a major-unit amount (dollars) in monthly_rate_config; convert
// exactly via the shared helper rather than float `* 100`.
const baseRate = a.monthlyRateConfig?.base_rate ?? sp?.monthlyRateConfig?.base_rate ?? 0;
const currency = a.monthlyRateConfig?.currency ?? "USD";
return {
orgId: a.orgId,
specialistId: a.specialistId,
assignmentId: a.id,
specialistName: sp?.firstName ?? "Specialist",
monthlyRateCents: majorToCents(baseRate),
currency,
anchorDay,
effectiveDate: new Date().toISOString().slice(0, 10),
customer: {
name: org.name,
email: (org as any).billingEmail ?? (org as any).contactEmail ?? "",
externalId: org.id,
},
};
}
}
Confirm the actual field names on
Organization(onboardedAt,name, billing/contact email) andSpecialist(firstName,monthlyRateConfig) againstcommon/entities.ts; adjust the optional-chaining fallbacks to the real columns.
- Step 4: Run to verify pass
Run: npm --prefix api test -- assignment-billing-input.resolver.spec
Expected: PASS.
-
Step 5: Register the resolver + its repos in the module. In
billing-subscription.module.ts, add toimports:TypeOrmModule.forFeature([..., OrgSpecialistAssignment, Organization, Specialist])and toproviders:AssignmentBillingInputResolver; export it. -
Step 6: Hook the commands into the four assignment methods. In
organizations.module.ts, importBillingSubscriptionModule. Inorganizations.service.ts, inject the three collaborators (AssignmentBillingInputResolver,SyncSpecialistSubscriptionCommand,CancelSpecialistSubscriptionCommand) and call them AFTER the existing_recomputeOrgTotalRate(...)line in each method. Wrap each call in try/catch + log (billing must never block the assignment โ fail-open per spec ยง9):
// after: await this._recomputeOrgTotalRate(orgId); (in assignSpecialist & updateSpecialistAssignment)
try {
const input = await this.assignmentBillingInputResolver.resolve(saved.id); // saved = the assignment row
await this.syncSpecialistSubscription.execute(input);
} catch (err) {
this.logger.warn(`billing sync skipped for assignment ${saved.id}: ${(err as Error).message}`);
}
// after: await this._recomputeOrgTotalRate(orgId); (in unassignSpecialist & unassignSpecificSpecialist)
try {
await this.cancelSpecialistSubscription.execute({
assignmentId: removedAssignmentId, effectiveDate: new Date().toISOString().slice(0, 10),
});
} catch (err) {
this.logger.warn(`billing cancel skipped for assignment ${removedAssignmentId}: ${(err as Error).message}`);
}
Identify the exact local variable holding the saved assignment / removed assignment id in each method (read the method bodies).
assignSpecialistreturns the saved assignment;unassignSpecialist/unassignSpecificSpecialistdelete by id โ capture that id before deletion.
- Step 7: Build + run the organizations service tests to confirm no regression
Run: npm --prefix api run build && npm --prefix api test -- organizations.service
Expected: build succeeds; existing org tests pass (billing calls are fail-open and mocked/no-op under MockProvider).
- Step 8: Commit
git add api/src/billing/application/assignment-billing-input.resolver.ts api/src/billing/application/assignment-billing-input.resolver.spec.ts api/src/billing/billing-subscription.module.ts api/src/organizations/organizations.service.ts api/src/organizations/organizations.module.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): resolver + hook sync/cancel into assignment lifecycle (fail-open)"
Task 6.6: Register BillingSubscriptionModule in app.moduleโ
Files: Modify api/src/app.module.ts
-
Step 1: Import + add
BillingSubscriptionModuleto theimportsarray (alongside the existingBillingModuleโ both coexist until Phase 7). -
Step 2: Build + boot smoke
Run: npm --prefix api run build
Expected: success.
- Step 3: Commit
git add api/src/app.module.ts
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): register BillingSubscriptionModule"
Phase 7 โ Legacy Removal + Consumer Repoint (ATOMIC)โ
Everything in this phase is one logical change: detach the four legacy
Subscriptionconsumers, delete the legacy billing module, drop thesubscriptionstable. Do it as a tight sequence and keep the build green at each step. Commit once at the end so the removal is atomic in history.
Task 7.1: Repoint conversations.hasActiveSubscription (behavioral)โ
Files: Modify api/src/conversations/conversations.service.ts (hasActiveSubscription, ~line 2078)
-
Step 1: Update the test first. Find the existing
hasActiveSubscriptiontest (or add one) asserting it returns true when the org has an active billing subscription. Update it to seedbilling_subscriptionsinstead ofsubscriptions. -
Step 2: Repoint the raw query to the new table (keep fail-open):
const result = await this.orgRepo!.manager.query(
`SELECT id FROM billing_subscriptions
WHERE org_id = $1 AND status = 'active'
LIMIT 1`,
[orgId],
);
return Array.isArray(result) && result.length > 0;
- Step 3: Run conversations tests
Run: npm --prefix api test -- conversations.service
Expected: PASS.
Task 7.2: Repoint admin + organizations readsโ
Files: Modify api/src/admin/admin.service.ts (~line 383), api/src/organizations/organizations.service.ts (~lines 306, 2259), and their modules.
-
Step 1: Replace each
subRepousage with a read againstBillingSubscriptionRepository(injectBILLING_SUBSCRIPTION_REPOSITORY). For admin/org "get subscription for org" โfindByOrgId(orgId)returning the list (or first); for "list all" โlistAll(). Adjust the response shape the callers expect (these feed dashboards โ map to{ orgId, status, currentRateCents }[]). -
Step 2: Remove the
@InjectRepository(Subscription)constructor params and theSubscriptionimports fromadmin.service.tsandorganizations.service.ts; removeSubscriptionfromorganizations.module.tsforFeature. AddBillingSubscriptionModuleto the imports ofadmin.module.tsandorganizations.module.ts(organizations already imports it from Task 6.5). -
Step 3: Build
Run: npm --prefix api run build
Expected: success.
Task 7.3: Delete legacy billing + drop subscriptions table; rename new moduleโ
Files:
-
Delete:
api/src/billing/billing.service.ts,billing.controller.ts,payment-provider.interface.ts,stripe.provider.ts,request-finance.provider.ts, and the oldbilling.module.ts -
Rename:
api/src/billing/billing-subscription.module.tsโapi/src/billing/billing.module.ts(and classBillingSubscriptionModuleโBillingModule; update the import inapp.module.ts,organizations.module.ts,admin.module.ts) -
Modify:
api/src/common/entities.ts(remove theSubscriptionentity, ~line 814) -
Modify:
api/src/data-source.ts+api/src/app.module.ts(removeSubscriptionfrom the entities arrays + its import) -
Create:
api/migrations/1751300000002-DropLegacySubscriptions.ts -
Step 1: Remove
BillingModule(legacy) fromapp.module.tsimports and delete the six legacy files. -
Step 2: Rename the new module file + class and fix all importers (
app.module.ts,organizations.module.ts,admin.module.ts). -
Step 3: Remove the
Subscriptionentity fromcommon/entities.tsand both entity-registration arrays (data-source.ts,app.module.ts). -
Step 4: Write the drop migration
import { MigrationInterface, QueryRunner } from "typeorm";
export class DropLegacySubscriptions1751300000002 implements MigrationInterface {
public async up(q: QueryRunner): Promise<void> {
await q.query(`DROP TABLE IF EXISTS "subscriptions";`);
}
public async down(): Promise<void> {
// Irreversible: the legacy fixed-plan table is intentionally dropped (superseded by
// billing_subscriptions). Fail loudly so a revert doesn't silently leave an inconsistent DB.
throw new Error("irreversible migration: 'subscriptions' table was dropped intentionally");
}
}
- Step 5: Grep for stragglers
Run: grep -rn "from '../common/entities'" api/src | grep -i Subscription; grep -rn "agent_starter\|agent_pro" api/src --include="*.ts" | grep -v "\.spec\.ts"
Expected: no remaining references to the legacy Subscription entity or fixed-plan literals in non-test code.
- Step 6: Full build + test
Run: npm --prefix api run build && npm --prefix api test
Expected: build succeeds; the suite passes (legacy billing tests for the deleted files must be removed in this step too โ delete billing.service.spec.ts / billing.controller.spec.ts if present).
- Step 7: Commit (atomic)
git add -A api/src api/migrations
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "refactor(billing)!: remove legacy fixed-plan billing; repoint consumers to per-Specialist subscriptions
BREAKING: drops the 'subscriptions' table + Subscription entity + agent_starter/pro/enterprise plans. hasActiveSubscription, admin + org dashboards now read billing_subscriptions."
Phase 8 โ Frontend (client + ops billing surfaces)โ
Scope: minimal pages that consume the new endpoints. Next.js 16 / React 19 โ read existing
frontend/app/client/*andfrontend/app/ops/*pages for the data-fetching + layout conventions before writing. No new design system work.
Task 8.1: Client billing pageโ
Files: Create frontend/app/client/billing/page.tsx (+ a small billing-api.ts client if the repo centralizes fetches)
-
Step 1: Add an API client function
getClientBillingSummary()โGET /client/billing/summaryandgetBillingPortalUrl()โGET /client/billing/portal-url, following the repo's existing authenticated-fetch helper. -
Step 2: Build the page: list the org's consolidated invoices (newest first). For each invoice render its header (period dates, total, status badge, hosted-URL/PDF link) and its line items (one per Specialist โ name, prorated period, amount โ read from the invoice's
lineItems). Add a โManage payment methodsโ button โ portal URL (Stripe). -
Step 3: Lint + build
Run: npm --prefix frontend run lint && npm --prefix frontend run build
Expected: success.
- Step 4: Commit
git add frontend/app/client/billing
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): client billing page (consolidated invoices + line items + portal link)"
Task 8.2: Ops billing overviewโ
Files: Create frontend/app/ops/billing/page.tsx
-
Step 1: Add API client functions
getOpsBillingOverview()โGET /ops/billingandretrySync(assignmentId)โPOST /ops/billing/subscriptions/:assignmentId/retry-sync. -
Step 2: Render the overview table (org, specialist, status, rate). Highlight
sync_failedrows with a badge and a โRetry syncโ button that callsretrySyncand refreshes. Show thesyncFailedCountas a top-of-page alert when > 0. -
Step 3: Lint + build
Run: npm --prefix frontend run lint && npm --prefix frontend run build
Expected: success.
- Step 4: Commit
git add frontend/app/ops/billing
git -c user.email=alex@humanity.org -c user.name="Alex K" commit -m "feat(billing): ops billing overview (+ retry-sync for sync_failed)"
Phase 9 โ Close-outโ
Task 9.1: Update docs + close #762โ
Files: Modify docs/implementation-status.md (mark billing built); reference the spec.
-
Step 1: Update
docs/implementation-status.mdbilling row to reflect the per-Specialist provider-leveraged implementation, linking this plan + spec. -
Step 2: Open the PR
git push -u origin feat/billing-provider-leveraged
gh pr create --base dev --title "feat(billing): per-Specialist provider-leveraged billing (supersedes #762)" \
--body "Implements docs/superpowers/specs/2026-05-26-billing-provider-leveraged-design.md. Supersedes #702/#762. See plan: docs/superpowers/plans/2026-05-26-billing-provider-leveraged.md.
๐ค Generated with [Claude Code](https://claude.com/claude-code)"
-
Step 3: Close PR #762 without merge, referencing this PR as its replacement.
-
Step 4: Wait for Greptile review before merging (repo convention).
Self-Review Notes (for the implementer)โ
- Type consistency:
currentRateCents/amountCentsarebigintin domain,stringin storage (TypeORMbigint), converted in mappers. All centsโmajor-unit conversion goes throughdomain/money.ts(centsToMajor/majorToCents) โ never float* 100orNumber(bigint).BillingEventis the single normalized webhook shape; both providers emit it. TheSyncSpecialistSubscriptionInputshape produced byAssignmentBillingInputResolver(Task 6.5) is exactly whatSyncSpecialistSubscriptionCommand.execute(Task 4.1) consumes. - Retry-sync routing: a
sync_failedsubscription recordssyncFailedOp(create/update/cancel); the SA retry routes a failedcanceltoCancelSpecialistSubscriptionCommand(not the sync command, which would resurrect it) and everything else toSyncSpecialistSubscriptionCommand. The sync command's two arms areprovisionNewSubscription(no provider sub yet) andapplyRateChange(provider sub exists); it routes by presence ofexternalSubscriptionId.CancelSpecialistSubscriptionCommandcancels anything with a liveexternalSubscriptionIdthat isn't alreadycanceled(so async_failedcancel retries, not skips), and reuses the storedcancelEffectiveDate(original unassign date) so a delayed retry doesn't overbill under arrears. - Webhook idempotency:
InvoiceRepository.upsertByExternalIduses an atomicrepo.upsert(โฆ, ["externalInvoiceId"])(INSERT โฆ ON CONFLICT DO UPDATE), safe under concurrent/replayed delivery. - Spec coverage: ยง2 react-architecture โ Phases 4-6; ยง3 multi-Specialist/arrears/consolidation โ per-assignment subscriptions + arrears in LagoProvider cancel + provider-grouped invoices; ยง4 module tree โ Phases 1-6 file layout; ยง5 flows โ commands/queries/controllers; ยง6 port โ Task 1.4 + providers; ยง7 mirror (one consolidated invoice, feesโlineItems) โ Phase 2 + LagoProvider.normalizeInvoice; ยง8 idempotency โ unique constraints (Task 2.3) +
findByAssignmentId/upsertByExternalId; ยง9 errors โ retry +sync_failed+ webhook 401 + fail-open hooks; ยง10.1 legacy removal โ Phase 7; ยง11 config โ Task 0.2; ยง12 testing โ MockProvider + per-task specs. - Provider-specifics to verify (Task 0.3 spike + at implementation): every
VERIFY(0.3)site in the LagoProvider (method names,fees[]/amount units, calendar-day proration setting,pay_in_arrears, rate-change mechanism, webhook signature scheme,lago-javascript-clientfactory), the consolidation behavior,Organization/Specialistfield names (Task 6.5),Rolesguard import path (Task 6.4), and thatmain.tsenablesrawBody(Task 6.2).