Skip to main content

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 (the entities: [...] array, line ~98) and api/src/app.module.ts TypeOrmModule.forRootAsync (line ~153). Adding a @Entity requires editing both.
  • Commands = atomic mutating use cases; Queries = atomic reads; controllers invoke only commands/queries; reusable logic = a service. Thin wrappers are fine.
  • Money = bigint cents + a currency string field (no Money class โ€” YAGNI). Store cents as bigint; TypeORM column type bigint returns a string at runtime โ€” convert with BigInt(value) on read.
  • Tests use better-sqlite3 in-memory (api/test helpers), run with npm --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) and type: "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 a BillingProviderPort
  • BILLING_SUBSCRIPTION_REPOSITORY โ†’ BillingSubscriptionRepositoryPort
  • INVOICE_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 existing process.env accessor 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 # Billing heading):
# 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 in docs/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 the parseWebhook normalization 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 (and invoice.payment_status_updated) webhook payload + the signature header name + verification method, for Task 3.2's parseWebhook.

  • 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 bigint internally. Some providers speak major-unit decimal strings ("75.19"); Lago speaks integer cents. These helpers use integer/bigint math โ€” never Number(bigint) or n * 100 (lossy above ~$90B and for non-representable decimals). Used by AssignmentBillingInputResolver (Task 6.5, converting the dollar base_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-json is portable across Postgres + sqlite. On Postgres it stores text; acceptable for a display snapshot. (If a true jsonb column is desired in prod, the migration in Task 2.3 uses jsonb; the entity stays simple-json for 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 (the entities: [...] array near line 98)

  • Modify: api/src/app.module.ts (the entities: [...] inside TypeOrmModule.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.ts identically (same two imports + same two array entries inside forRootAsync's entities).

  • 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.ts in the repo for the createTestingModule/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-client against a self-hosted Lago instance. Task 0.3 (the spike) is a hard prerequisite โ€” it pins down the items flagged VERIFY(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 our assignmentId (natural idempotency + lets webhooks map a fee back to an assignment).
  • Per-Specialist rate = subscription-level plan_overrides.amount_cents on the shared LAGO_SPECIALIST_PLAN_CODE plan.
  • Anchor alignment = billing_time: "anniversary" with subscription_at on the org anchor day, so an org's subscriptions bill together โ†’ one consolidated invoice with one fee per subscription.
  • Rate change โ€” Lago has no in-place price edit; VERIFY(0.3) whether to use plan upgrade/downgrade (same external_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 into lineItems (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 uses BigInt(...) directly โ€” majorToCents is 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 from org_specialist_assignments.monthly_rate_config.base_rate (falling back to the specialist catalog rate), already computed by OrganizationsService._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 by externalCustomerId via the subscription mirror (consolidated invoices). Only if neither resolves (e.g. a webhook arrives before any local sub row exists) is orgId left null and the row still stored โ€” the mirror is a projection, not a join requirement (per spec ยง9). billing_invoices.org_id is therefore nullable (Task 2.1 / 2.3); the Invoice domain orgId is string | 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 + its lineItems.

  • 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 to billing.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_PROVIDER is both the DI token (from the port) and the env var name. The env import is aliased to PROVIDER_NAME above to disambiguate. VERIFY(0.3): confirm the lago-javascript-client factory 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 rawBody for the legacy Stripe webhook (controllers read (req as any).rawBody). Reuse that mechanism. Confirm main.ts constructs 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 orgId from the authenticated user (req.user.orgId), never from a path/body param. Follow the existing controller pattern for @UseGuards(JwtAuthGuard) and reading req.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_failed subscription carries syncFailedOp recording which provider call failed. The retry MUST branch on it:

  • syncFailedOp === "cancel" โ†’ re-run CancelSpecialistSubscriptionCommand (works off the subscription's own externalSubscriptionId, 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 call updateSubscriptionPrice + markActive, resurrecting a subscription that should be canceled and billing the client for an unassigned Specialist.
  • otherwise ("create" / "update") โ†’ re-derive inputs via AssignmentBillingInputResolver and re-run SyncSpecialistSubscriptionCommand.
  • 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 Roles decorator + 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 (methods assignSpecialist ~1041, updateSpecialistAssignment ~1376, unassignSpecialist ~1621, unassignSpecificSpecialist ~1641)
  • Modify: api/src/organizations/organizations.module.ts (import BillingSubscriptionModule)

The resolver is the seam that turns an assignmentId into the SyncSpecialistSubscriptionInput (org anchor day, customer name/email, resolved rate, specialist name). It reads org_specialist_assignments, organizations, and specialists. Placing this logic in billing/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) and Specialist (firstName, monthlyRateConfig) against common/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 to imports: TypeOrmModule.forFeature([..., OrgSpecialistAssignment, Organization, Specialist]) and to providers: AssignmentBillingInputResolver; export it.

  • Step 6: Hook the commands into the four assignment methods. In organizations.module.ts, import BillingSubscriptionModule. In organizations.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). assignSpecialist returns the saved assignment; unassignSpecialist/unassignSpecificSpecialist delete 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 BillingSubscriptionModule to the imports array (alongside the existing BillingModule โ€” 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 Subscription consumers, delete the legacy billing module, drop the subscriptions table. 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 hasActiveSubscription test (or add one) asserting it returns true when the org has an active billing subscription. Update it to seed billing_subscriptions instead of subscriptions.

  • 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 subRepo usage with a read against BillingSubscriptionRepository (inject BILLING_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 the Subscription imports from admin.service.ts and organizations.service.ts; remove Subscription from organizations.module.ts forFeature. Add BillingSubscriptionModule to the imports of admin.module.ts and organizations.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 old billing.module.ts

  • Rename: api/src/billing/billing-subscription.module.ts โ†’ api/src/billing/billing.module.ts (and class BillingSubscriptionModule โ†’ BillingModule; update the import in app.module.ts, organizations.module.ts, admin.module.ts)

  • Modify: api/src/common/entities.ts (remove the Subscription entity, ~line 814)

  • Modify: api/src/data-source.ts + api/src/app.module.ts (remove Subscription from the entities arrays + its import)

  • Create: api/migrations/1751300000002-DropLegacySubscriptions.ts

  • Step 1: Remove BillingModule (legacy) from app.module.ts imports 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 Subscription entity from common/entities.ts and 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/* and frontend/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/summary and getBillingPortalUrl() โ†’ 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/billing and retrySync(assignmentId) โ†’ POST /ops/billing/subscriptions/:assignmentId/retry-sync.

  • Step 2: Render the overview table (org, specialist, status, rate). Highlight sync_failed rows with a badge and a โ€œRetry syncโ€ button that calls retrySync and refreshes. Show the syncFailedCount as 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.md billing 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/amountCents are bigint in domain, string in storage (TypeORM bigint), converted in mappers. All centsโ†”major-unit conversion goes through domain/money.ts (centsToMajor/majorToCents) โ€” never float * 100 or Number(bigint). BillingEvent is the single normalized webhook shape; both providers emit it. The SyncSpecialistSubscriptionInput shape produced by AssignmentBillingInputResolver (Task 6.5) is exactly what SyncSpecialistSubscriptionCommand.execute (Task 4.1) consumes.
  • Retry-sync routing: a sync_failed subscription records syncFailedOp (create/update/cancel); the SA retry routes a failed cancel to CancelSpecialistSubscriptionCommand (not the sync command, which would resurrect it) and everything else to SyncSpecialistSubscriptionCommand. The sync command's two arms are provisionNewSubscription (no provider sub yet) and applyRateChange (provider sub exists); it routes by presence of externalSubscriptionId. CancelSpecialistSubscriptionCommand cancels anything with a live externalSubscriptionId that isn't already canceled (so a sync_failed cancel retries, not skips), and reuses the stored cancelEffectiveDate (original unassign date) so a delayed retry doesn't overbill under arrears.
  • Webhook idempotency: InvoiceRepository.upsertByExternalId uses an atomic repo.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-client factory), the consolidation behavior, Organization/Specialist field names (Task 6.5), Roles guard import path (Task 6.4), and that main.ts enables rawBody (Task 6.2).