Trial Clamp Realign (#2411) Implementation Plan
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: Make a changed org trial end date actually move billing: hook realign into all trial-setting paths and make realign able to move started (but unbilled) Lago subscriptions via terminate-without-invoice + recreate, surviving the resulting webhook race.
Architecture: Spec at docs/superpowers/specs/2026-06-10-trial-clamp-realign-design.md. Five layers, each its own task: (1) new mirror column provider_subscription_lago_id; (2) createSubscription surfaces Lago's internal lago_id; (3) provider port method realignSubscriptionStart with four-state semantics (pending→move, started-unbilled→terminate+recreate, billed→skip, absent→create); (4) command + ops-retry wiring with sync_failed_op='realign'; (5) webhook lago_id comparison + org-service hooks in sendInvitation/finishSetupWithoutInvite.
Tech Stack: NestJS 11, TypeORM (Postgres prod / better-sqlite3 in local tests), Lago self-hosted (SDK lago-javascript-client + raw fetch for endpoints the SDK lacks), Jest.
Working directory: ~/.config/superpowers/worktrees/humanwork/trial-clamp/api — all commands below run from there. Branch: fix/2411-trial-clamp-realign.
Conventions that bite:
- bigint columns come back as strings; domain uses
bigint(seecurrentRateCents). - Specs run on sqlite locally; don't use Postgres-only SQL in entities.
- Webhook handler matches mirrors by
external_subscription_id= our assignment id, which is reused across terminate/recreate — that's the entire reason for the new lago_id column. git commitmessages end withCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>.
Task 1: Mirror column provider_subscription_lago_id (domain + storage + mapper + migration)
Files:
-
Modify:
src/billing/domain/billing-subscription.ts -
Modify:
src/billing/infrastructure/billing-subscription.storage.ts -
Modify:
src/billing/infrastructure/billing-subscription.mapper.ts -
Create:
migrations/1799000000001-AddProviderLagoIdToBillingSubscriptions.ts -
Test:
src/billing/infrastructure/billing-subscription.mapper.spec.ts -
Step 1: Write the failing mapper round-trip test
Append to the existing describe block in src/billing/infrastructure/billing-subscription.mapper.spec.ts (open the file first and match its existing fixture style — it builds a BillingSubscription and asserts toStorage/toDomain field mapping):
it("round-trips providerSubscriptionLagoId through storage", () => {
const domain = new BillingSubscription({
id: "b1", orgId: "o1", specialistId: "sp1", assignmentId: "a1",
externalSubscriptionId: "a1", externalCustomerId: "o1",
status: "active", currentRateCents: 220000n, currency: "USD",
anchorDay: 10, provider: "lago",
createdAt: new Date(), updatedAt: new Date(), canceledAt: null,
providerSubscriptionLagoId: "lago-uuid-1",
});
const storage = BillingSubscriptionMapper.toStorage(domain);
expect(storage.providerSubscriptionLagoId).toBe("lago-uuid-1");
const back = BillingSubscriptionMapper.toDomain(storage);
expect(back.providerSubscriptionLagoId).toBe("lago-uuid-1");
});
it("defaults providerSubscriptionLagoId to null for legacy rows", () => {
const storage = new BillingSubscriptionStorage();
storage.orgId = "o1"; storage.specialistId = "sp1"; storage.assignmentId = "a1";
storage.externalSubscriptionId = "a1"; storage.externalCustomerId = "o1";
storage.status = "active"; storage.currentRateCents = "220000";
storage.currency = "USD"; storage.anchorDay = 10; storage.provider = "lago";
storage.createdAt = new Date(); storage.updatedAt = new Date();
storage.canceledAt = null;
storage.providerSubscriptionLagoId = null; // column default
expect(BillingSubscriptionMapper.toDomain(storage).providerSubscriptionLagoId).toBeNull();
});
- Step 2: Run the test to verify it fails
Run: npm test -- billing-subscription.mapper.spec
Expected: FAIL — TS compile error: providerSubscriptionLagoId does not exist on BillingSubscriptionProps / BillingSubscriptionStorage.
- Step 3: Add the field to domain, storage, and mapper
In src/billing/domain/billing-subscription.ts, add to BillingSubscriptionProps (after externalCustomerId):
/** Lago's internal subscription id. Distinguishes a recreated sub from its
* terminated predecessor — external_subscription_id (our assignment id) is
* deliberately reused across terminate/recreate, so it can't. Null for rows
* created before #2411 and for the mock provider. */
providerSubscriptionLagoId?: string | null;
Add the class field (after the externalCustomerId field):
providerSubscriptionLagoId: string | null = null;
Change markActive to optionally store it:
markActive(externalSubscriptionId: string, providerLagoId?: string | null): void {
this.externalSubscriptionId = externalSubscriptionId;
if (providerLagoId !== undefined) this.providerSubscriptionLagoId = providerLagoId;
this.status = "active";
this.syncFailedOp = null; // healthy again
this.rateChangeEffectiveDate = null; // any pending dated rate-change retry is resolved
this.updatedAt = new Date();
}
In src/billing/infrastructure/billing-subscription.storage.ts, add after the externalCustomerId column:
@Column({ name: "provider_subscription_lago_id", type: "varchar", nullable: true })
providerSubscriptionLagoId: string | null;
In src/billing/infrastructure/billing-subscription.mapper.ts:
-
toDomain: addproviderSubscriptionLagoId: s.providerSubscriptionLagoId ?? null, -
toStorage: adds.providerSubscriptionLagoId = d.providerSubscriptionLagoId; -
Step 4: Run the test to verify it passes
Run: npm test -- billing-subscription.mapper.spec
Expected: PASS (all tests in the file).
- Step 5: Create the migration
Create migrations/1799000000001-AddProviderLagoIdToBillingSubscriptions.ts (prefix 1799… continues the existing 1798… sequence — check ls migrations | tail and bump if someone landed 1799… meanwhile):
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* #2411 expand-only: store Lago's internal subscription id on the mirror so the
* subscription.terminated webhook can distinguish a stale termination (our own
* realign terminate of a superseded sub) from a real cancellation. Nullable —
* legacy rows keep NULL and the webhook handler falls back to current behavior.
*/
export class AddProviderLagoIdToBillingSubscriptions1799000000001 implements MigrationInterface {
name = "AddProviderLagoIdToBillingSubscriptions1799000000001";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "billing_subscriptions" ADD COLUMN IF NOT EXISTS "provider_subscription_lago_id" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "billing_subscriptions" DROP COLUMN IF EXISTS "provider_subscription_lago_id"`,
);
}
}
- Step 6: Build to confirm no TS errors
Run: npm run build
Expected: clean exit. (The migration is exercised by the CI api-postgres-migrations job; locally sqlite specs use synchronize from the entity.)
- Step 7: Commit
git add src/billing/domain/billing-subscription.ts src/billing/infrastructure/billing-subscription.storage.ts src/billing/infrastructure/billing-subscription.mapper.ts src/billing/infrastructure/billing-subscription.mapper.spec.ts migrations/1799000000001-AddProviderLagoIdToBillingSubscriptions.ts
git commit -m "feat(billing): add provider_subscription_lago_id to billing_subscriptions mirror (#2411)
Expand-only migration + domain/storage/mapper plumbing. markActive can now
record the provider's internal subscription id, which a later task uses to
ignore stale subscription.terminated webhooks after terminate+recreate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 2: createSubscription returns providerLagoId
Files:
-
Modify:
src/billing/domain/ports/billing-provider.port.ts:57-66 -
Modify:
src/billing/infrastructure/providers/lago.provider.ts(createSubscription, ~L243-297) -
Modify:
src/billing/infrastructure/providers/mock.provider.ts(createSubscription) -
Modify:
src/billing/application/commands/sync-specialist-subscription.command.ts(provisionNewSubscription) -
Test:
src/billing/infrastructure/providers/lago.provider.spec.ts -
Test:
src/billing/application/commands/sync-specialist-subscription.command.spec.ts -
Step 1: Write the failing provider test
Open src/billing/infrastructure/providers/lago.provider.spec.ts and find how existing tests stub the SDK client (they pass a fake client object into new LagoProvider(client, …)). Add, following that style:
it("createSubscription returns Lago's internal lago_id as providerLagoId", async () => {
const client = {
subscriptions: {
createSubscription: jest.fn().mockResolvedValue({
data: { subscription: { lago_id: "lago-uuid-42", external_id: "a1" } },
}),
},
plans: {
findPlan: jest.fn().mockResolvedValue({ data: { plan: { code: "specialist_usd_220000" } } }),
createPlan: jest.fn(),
},
};
const provider = new LagoProvider(client as any, "specialist_monthly", "whsec");
const res = await provider.createSubscription({
externalCustomerId: "o1", assignmentId: "a1", specialistId: "sp1",
specialistName: "Jessamine", monthlyRateCents: 220000n, currency: "USD",
anchorDay: 10, startDate: "2026-08-10",
});
expect(res.externalSubscriptionId).toBe("a1");
expect(res.providerLagoId).toBe("lago-uuid-42");
});
it("createSubscription returns providerLagoId null when the SDK response lacks it", async () => {
const client = {
subscriptions: { createSubscription: jest.fn().mockResolvedValue(undefined) },
plans: {
findPlan: jest.fn().mockResolvedValue({ data: { plan: { code: "specialist_usd_220000" } } }),
createPlan: jest.fn(),
},
};
const provider = new LagoProvider(client as any, "specialist_monthly", "whsec");
const res = await provider.createSubscription({
externalCustomerId: "o1", assignmentId: "a1", specialistId: "sp1",
specialistName: "Jessamine", monthlyRateCents: 220000n, currency: "USD",
anchorDay: 10, startDate: "2026-08-10",
});
expect(res.providerLagoId).toBeNull();
});
If the existing spec already has a shared makeClient()-style helper, reuse it instead of inlining the client literal — but keep the lago_id in the stubbed response.
- Step 2: Run to verify failure
Run: npm test -- lago.provider.spec
Expected: FAIL — providerLagoId not on the return type / undefined at runtime.
- Step 3: Implement
In src/billing/domain/ports/billing-provider.port.ts, change createSubscription's return type:
}): Promise<{ externalSubscriptionId: string; providerLagoId: string | null }>;
In src/billing/infrastructure/providers/lago.provider.ts createSubscription: capture the SDK response in both strategy branches and return the lago id. Change the two await this.client.subscriptions.createSubscription({...}) calls to const res = await this.client.subscriptions.createSubscription({...}) (declare let res: any; before the if so both branches assign it), and change the final return to:
return {
externalSubscriptionId: input.assignmentId,
// lago-javascript-client wraps payloads in HttpResponse — same access
// pattern as listInvoices' `res?.data?.invoices`.
providerLagoId: res?.data?.subscription?.lago_id ?? null,
};
In src/billing/infrastructure/providers/mock.provider.ts createSubscription, change the return to:
return { externalSubscriptionId, providerLagoId: `lago_${input.assignmentId}` };
In src/billing/application/commands/sync-specialist-subscription.command.ts provisionNewSubscription, thread it into the mirror — change:
const { externalSubscriptionId } = await this.withRetry(() => this.provider.createSubscription({
to:
const { externalSubscriptionId, providerLagoId } = await this.withRetry(() => this.provider.createSubscription({
and sub.markActive(externalSubscriptionId); to sub.markActive(externalSubscriptionId, providerLagoId);.
- Step 4: Write the failing command test (lago id lands on the mirror)
Append to src/billing/application/commands/sync-specialist-subscription.command.spec.ts (reuses the file's existing FakeRepo + input fixtures):
it("stores the provider's lago id on the mirror after create", async () => {
const repo = new FakeRepo();
const cmd = new SyncSpecialistSubscriptionCommand(repo as any, new MockProvider());
await cmd.execute(input);
expect((await repo.findByAssignmentId("a1"))?.providerSubscriptionLagoId).toBe("lago_a1");
});
- Step 5: Run both spec files
Run: npm test -- --testPathPatterns 'lago.provider.spec|sync-specialist-subscription.command.spec'
Expected: PASS. (If the jest flag name differs on this Jest major, npm test -- lago.provider.spec and npm test -- sync-specialist-subscription.command.spec separately.)
- Step 6: Build + commit
Run: npm run build → clean.
git add src/billing/domain/ports/billing-provider.port.ts src/billing/infrastructure/providers/lago.provider.ts src/billing/infrastructure/providers/mock.provider.ts src/billing/application/commands/sync-specialist-subscription.command.ts src/billing/infrastructure/providers/lago.provider.spec.ts src/billing/application/commands/sync-specialist-subscription.command.spec.ts
git commit -m "feat(billing): surface Lago lago_id from createSubscription onto the mirror (#2411)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 2b: updateSubscriptionPrice refreshes the mirror's lago_id (review finding)
Lago's plan-change flow (used for rate changes) re-issues createSubscription with the same
external_id — the replacement subscription gets a NEW lago_id. Without refreshing the
mirror, the stored lago_id goes stale after every rate change and Task 6's webhook guard
would invert (a plan-change termination of the OLD sub matches the stale mirror value and
wrongly cancels; a genuine later termination of the CURRENT sub is ignored as stale).
Files:
-
Modify:
src/billing/domain/ports/billing-provider.port.ts(updateSubscriptionPrice return type) -
Modify:
src/billing/infrastructure/providers/lago.provider.ts(updateSubscriptionPrice both branches) -
Modify:
src/billing/infrastructure/providers/mock.provider.ts -
Modify:
src/billing/application/commands/sync-specialist-subscription.command.ts(applyRateChange) -
Test:
src/billing/application/commands/sync-specialist-subscription.command.spec.ts -
Port:
updateSubscriptionPrice(...): Promise<{ providerLagoId: string | null }>. -
LagoProvider: both strategy branches capture the SDK response; return
{ providerLagoId: res?.data?.subscription?.lago_id ?? null }. -
MockProvider: return
{ providerLagoId: \lago_${input.externalSubscriptionId}_v2` }`. -
Command
applyRateChange:const { providerLagoId } = await this.withRetry(...); thensub.markActive(sub.externalSubscriptionId!, providerLagoId)— explicit value (possibly null): a stale id is WORSE than null (null falls back to legacy webhook behavior; stale inverts the guard). -
Test (TDD, command spec): after
executewith a changed rate, the mirror'sproviderSubscriptionLagoIdequals the mock's new id, not the create-time id. -
Test (provider spec): updateSubscriptionPrice returns the lago_id from the SDK response.
-
npm test -- --testPathPatterns 'lago.provider.spec|sync-specialist-subscription.command.spec'green; build clean; commit.
Task 3: Provider realignSubscriptionStart (replaces updateSubscriptionStartDate)
Files:
- Modify:
src/billing/domain/ports/billing-provider.port.ts(replaceupdateSubscriptionStartDate) - Modify:
src/billing/domain/errors.ts(addRealignRecreateFailedError) - Modify:
src/billing/infrastructure/providers/lago.provider.ts(replaceupdateSubscriptionStartDate, ~L481-503) - Modify:
src/billing/infrastructure/providers/mock.provider.ts - Test:
src/billing/infrastructure/providers/lago.provider.spec.ts
The Lago endpoints used here (verified live against the staging instance 2026-06-10):
-
GET {base}/api/v1/subscriptions/{external_id}→ 200 active sub | 404 -
GET {base}/api/v1/subscriptions/{external_id}?status=pending→ 200 pending sub | 404 -
PUT {base}/api/v1/subscriptions/{external_id}body{subscription:{subscription_at}}— moves a pending sub -
DELETE {base}/api/v1/subscriptions/{external_id}?on_termination_invoice=skip— terminates without a final invoice -
GET {base}/api/v1/invoices?external_subscription_id={external_id}&per_page=1→meta.total_count -
Step 1: Add the error type
In src/billing/domain/errors.ts append (match the file's existing error-class style):
/**
* #2411: realign terminated the provider subscription but the recreate call
* failed — the org temporarily has NO provider subscription. The sync command
* maps this to status='sync_failed' / sync_failed_op='realign' so the ops
* retry-sync endpoint can re-run realign (whose absent-sub branch just creates).
*/
export class RealignRecreateFailedError extends Error {
constructor(externalSubscriptionId: string, cause: string) {
super(`realign recreate failed for subscription ${externalSubscriptionId}: ${cause}`);
this.name = "RealignRecreateFailedError";
}
}
- Step 2: Replace the port method
In src/billing/domain/ports/billing-provider.port.ts, delete the updateSubscriptionStartDate declaration (L84-93) and add:
/**
* Make the subscription start (bill from) `startDate` — used when an org's
* trialEndDate changes (#1423/#2411). `startDate` must already be
* trial-clamped and anchor-aligned (the resolver's effectiveDate).
*
* - pending sub → moved in place → { action: "moved" }
* - started, no invoices → terminate(skip) + recreate → { action: "recreated", providerLagoId }
* - started, has invoices→ untouched (human decision) → { action: "skipped_billed" }
* - absent → create pending at startDate → { action: "recreated", providerLagoId }
*
* The absent branch is what makes the operation idempotent/retryable after a
* half-finished run. Throws RealignRecreateFailedError when the create step
* fails (the terminate may already have happened).
*/
realignSubscriptionStart(input: {
externalSubscriptionId: string; // our assignment id (Lago external_id)
externalCustomerId: string;
specialistId: string;
specialistName: string;
monthlyRateCents: bigint;
currency: string;
anchorDay: number;
startDate: string; // ISO date
}): Promise<RealignStartResult>;
And near the top of the file (after PaymentMethodState):
/** Result of realignSubscriptionStart — see the method doc for the state table. */
export type RealignStartResult =
| { action: "moved" }
| { action: "recreated"; providerLagoId: string | null }
| { action: "skipped_billed" };
- Step 3: Write the failing provider tests
Append to src/billing/infrastructure/providers/lago.provider.spec.ts. These tests exercise the raw-fetch paths, so stub global.fetch (restore it in afterEach). The provider must be constructed with lagoApiUrl/lagoApiKey (4th/5th ctor args) for the fetch paths to be active:
describe("realignSubscriptionStart", () => {
const mkProvider = (client: any) =>
new LagoProvider(client as any, "specialist_monthly", "whsec", "https://lago.test", "key-1");
const realignInput = {
externalSubscriptionId: "a1", externalCustomerId: "o1", specialistId: "sp1",
specialistName: "Jessamine", monthlyRateCents: 220000n, currency: "USD",
anchorDay: 10, startDate: "2026-08-10",
};
/** fetch stub keyed on `${method} ${url-substring}` — first match wins. */
function stubFetch(routes: Array<[string, { status: number; body?: unknown }]>) {
return jest.fn(async (url: any, init?: any) => {
const key = `${init?.method ?? "GET"} ${url}`;
const hit = routes.find(([m]) => key.includes(m.split(" ")[0]) && key.includes(m.split(" ")[1]));
if (!hit) throw new Error(`unexpected fetch: ${key}`);
const [, res] = hit;
return {
ok: res.status < 400, status: res.status,
json: async () => res.body ?? {},
text: async () => JSON.stringify(res.body ?? {}),
} as any;
});
}
const sdkClient = () => ({
subscriptions: {
createSubscription: jest.fn().mockResolvedValue({
data: { subscription: { lago_id: "lago-new", external_id: "a1" } },
}),
},
plans: {
findPlan: jest.fn().mockResolvedValue({ data: { plan: { code: "specialist_usd_220000" } } }),
createPlan: jest.fn(),
},
});
afterEach(() => { (global as any).fetch = undefined; });
it("moves a pending subscription in place (PUT subscription_at)", async () => {
const fetchMock = stubFetch([
["GET status=pending", { status: 200, body: { subscription: { lago_id: "lago-old", status: "pending" } } }],
["PUT /api/v1/subscriptions/a1", { status: 200, body: { subscription: { lago_id: "lago-old" } } }],
]);
(global as any).fetch = fetchMock;
const res = await mkProvider(sdkClient()).realignSubscriptionStart(realignInput);
expect(res).toEqual({ action: "moved" });
const putCall = fetchMock.mock.calls.find(([, init]: any) => init?.method === "PUT");
expect(JSON.parse(putCall![1].body).subscription.subscription_at).toBe("2026-08-10");
});
it("terminates (skip invoice) and recreates a started, unbilled subscription", async () => {
const client = sdkClient();
const fetchMock = stubFetch([
["GET status=pending", { status: 404 }],
["GET /api/v1/invoices", { status: 200, body: { invoices: [], meta: { total_count: 0 } } }],
["GET /api/v1/subscriptions/a1", { status: 200, body: { subscription: { lago_id: "lago-old", status: "active" } } }],
["DELETE /api/v1/subscriptions/a1", { status: 200, body: { subscription: { lago_id: "lago-old", status: "terminated" } } }],
]);
(global as any).fetch = fetchMock;
const res = await mkProvider(client).realignSubscriptionStart(realignInput);
expect(res).toEqual({ action: "recreated", providerLagoId: "lago-new" });
const delCall = fetchMock.mock.calls.find(([, init]: any) => init?.method === "DELETE");
expect(String(delCall![0])).toContain("on_termination_invoice=skip");
expect(client.subscriptions.createSubscription).toHaveBeenCalledTimes(1);
const created = client.subscriptions.createSubscription.mock.calls[0][0].subscription;
expect(created.external_id).toBe("a1");
expect(created.subscription_at).toBe("2026-08-10");
expect(created.billing_time).toBe("anniversary");
});
it("skips a started subscription that already issued invoices", async () => {
const client = sdkClient();
const fetchMock = stubFetch([
["GET status=pending", { status: 404 }],
["GET /api/v1/invoices", { status: 200, body: { invoices: [{}], meta: { total_count: 1 } } }],
["GET /api/v1/subscriptions/a1", { status: 200, body: { subscription: { lago_id: "lago-old", status: "active" } } }],
]);
(global as any).fetch = fetchMock;
const res = await mkProvider(client).realignSubscriptionStart(realignInput);
expect(res).toEqual({ action: "skipped_billed" });
expect(client.subscriptions.createSubscription).not.toHaveBeenCalled();
expect(fetchMock.mock.calls.some(([, init]: any) => init?.method === "DELETE")).toBe(false);
});
it("creates a pending subscription when none exists (recovery branch)", async () => {
const client = sdkClient();
(global as any).fetch = stubFetch([
["GET status=pending", { status: 404 }],
["GET /api/v1/subscriptions/a1", { status: 404 }],
]);
const res = await mkProvider(client).realignSubscriptionStart(realignInput);
expect(res).toEqual({ action: "recreated", providerLagoId: "lago-new" });
});
it("throws RealignRecreateFailedError when create fails after terminate", async () => {
const client = sdkClient();
client.subscriptions.createSubscription.mockRejectedValue(new Error("lago 500"));
(global as any).fetch = stubFetch([
["GET status=pending", { status: 404 }],
["GET /api/v1/invoices", { status: 200, body: { invoices: [], meta: { total_count: 0 } } }],
["GET /api/v1/subscriptions/a1", { status: 200, body: { subscription: { lago_id: "lago-old", status: "active" } } }],
["DELETE /api/v1/subscriptions/a1", { status: 200, body: {} }],
]);
await expect(mkProvider(client).realignSubscriptionStart(realignInput))
.rejects.toThrow(RealignRecreateFailedError);
});
});
Add the import at the top of the spec: import { RealignRecreateFailedError } from "../../domain/errors";
Note on the fetch stub: route keys are matched as two substrings (method, path fragment).
GET /api/v1/subscriptions/a1must be listed AFTERGET status=pendingso the pending probe (whose URL also contains/subscriptions/a1) hits the more specific route first — the helper checks routes in order.
- Step 4: Run to verify failure
Run: npm test -- lago.provider.spec
Expected: FAIL — realignSubscriptionStart is not a function.
- Step 5: Implement in LagoProvider
In src/billing/infrastructure/providers/lago.provider.ts: add imports RealignRecreateFailedError from ../../domain/errors and RealignStartResult from the port. Replace the whole updateSubscriptionStartDate method (L481-503) with:
/** Strip trailing slashes and a trailing /api/v1 so URL building never doubles the prefix. */
private bareHost(): string {
return this.lagoApiUrl.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
}
private lagoHeaders(json = false): Record<string, string> {
const headers: Record<string, string> = {};
if (json) headers["Content-Type"] = "application/json";
if (this.lagoApiKey) headers["Authorization"] = `Bearer ${this.lagoApiKey}`;
return headers;
}
/** GET one subscription by external_id. status undefined = Lago's default (active). 404 → null. */
private async fetchSubscription(externalId: string, status?: "pending"): Promise<any | null> {
const qs = status ? `?status=${status}` : "";
const res = await fetch(
`${this.bareHost()}/api/v1/subscriptions/${encodeURIComponent(externalId)}${qs}`,
{ headers: this.lagoHeaders() },
);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Lago getSubscription ${res.status}: ${await res.text().catch(() => "")}`);
}
const body = await res.json().catch(() => null);
return body?.subscription ?? null;
}
/** Count invoices attributed to one subscription (zero-invoice guard for realign). */
private async countSubscriptionInvoices(externalSubscriptionId: string): Promise<number> {
const res = await fetch(
`${this.bareHost()}/api/v1/invoices?external_subscription_id=${encodeURIComponent(externalSubscriptionId)}&per_page=1`,
{ headers: this.lagoHeaders() },
);
if (!res.ok) {
throw new Error(`Lago listInvoices ${res.status}: ${await res.text().catch(() => "")}`);
}
const body = await res.json().catch(() => null);
return body?.meta?.total_count ?? 0;
}
/**
* Make the subscription bill from `startDate` — see the port doc for the
* four-state table (#2411). Replaces updateSubscriptionStartDate, which could
* only move pending subs and silently no-oped on started ones.
*/
async realignSubscriptionStart(input: {
externalSubscriptionId: string;
externalCustomerId: string;
specialistId: string;
specialistName: string;
monthlyRateCents: bigint;
currency: string;
anchorDay: number;
startDate: string;
}): Promise<RealignStartResult> {
if (!this.lagoApiUrl) return { action: "moved" }; // unconfigured provider — nothing to do
// 1) Pending sub: move it in place (Lago writes subscription_at only while pending).
const pending = await this.fetchSubscription(input.externalSubscriptionId, "pending");
if (pending) {
const res = await fetch(
`${this.bareHost()}/api/v1/subscriptions/${encodeURIComponent(input.externalSubscriptionId)}`,
{
method: "PUT",
headers: this.lagoHeaders(true),
body: JSON.stringify({ subscription: { subscription_at: input.startDate } }),
},
);
if (!res.ok) {
throw new Error(`Lago realign move ${res.status}: ${await res.text().catch(() => "")}`);
}
return { action: "moved" };
}
// 2) Started sub: only terminate+recreate while it has never billed.
const active = await this.fetchSubscription(input.externalSubscriptionId);
if (active) {
const invoiceCount = await this.countSubscriptionInvoices(input.externalSubscriptionId);
if (invoiceCount > 0) return { action: "skipped_billed" };
const res = await fetch(
`${this.bareHost()}/api/v1/subscriptions/${encodeURIComponent(input.externalSubscriptionId)}?on_termination_invoice=skip`,
{ method: "DELETE", headers: this.lagoHeaders() },
);
if (!res.ok) {
throw new Error(`Lago realign terminate ${res.status}: ${await res.text().catch(() => "")}`);
}
}
// 3) Absent (or just terminated): create pending at the clamped start.
// From here on, a failure leaves the org with NO provider sub — surface
// it as RealignRecreateFailedError so the command marks sync_failed/realign.
try {
const created = await this.createSubscription({
externalCustomerId: input.externalCustomerId,
assignmentId: input.externalSubscriptionId, // external_id == assignment id
specialistId: input.specialistId,
specialistName: input.specialistName,
monthlyRateCents: input.monthlyRateCents,
currency: input.currency,
anchorDay: input.anchorDay,
startDate: input.startDate,
});
return { action: "recreated", providerLagoId: created.providerLagoId };
} catch (err) {
throw new RealignRecreateFailedError(input.externalSubscriptionId, (err as Error).message);
}
}
Also update refreshPublicKeyCache and any other place that inlines the bare-host normalization to use this.bareHost() ONLY if it is a trivial mechanical swap — otherwise leave them; DRY here is optional, correctness is not.
- Step 6: Update MockProvider
In src/billing/infrastructure/providers/mock.provider.ts, replace updateSubscriptionStartDate with:
async realignSubscriptionStart(_input: {
externalSubscriptionId: string;
externalCustomerId: string;
specialistId: string;
specialistName: string;
monthlyRateCents: bigint;
currency: string;
anchorDay: number;
startDate: string;
}): Promise<import("../../domain/ports/billing-provider.port").RealignStartResult> {
// Mock treats every sub as pending-movable; tests needing other actions spy on this.
return { action: "moved" };
}
- Step 7: Fix the one remaining caller so the build compiles
src/billing/application/commands/sync-specialist-subscription.command.ts realignStart still calls updateSubscriptionStartDate. Task 4 rewrites it properly; for THIS commit make the minimal mechanical change so the build is green — replace the body's provider call:
await this.provider.realignSubscriptionStart({
externalSubscriptionId: sub.externalSubscriptionId,
externalCustomerId: sub.externalCustomerId,
specialistId: input.specialistId,
specialistName: input.specialistName,
monthlyRateCents: input.monthlyRateCents,
currency: sub.currency,
anchorDay: sub.anchorDay,
startDate: input.effectiveDate,
});
(Result intentionally ignored in this task.)
- Step 8: Run tests + build
Run: npm test -- lago.provider.spec → PASS.
Run: npm run build → clean (confirms no other updateSubscriptionStartDate callers exist; git grep -n updateSubscriptionStartDate src/ must return nothing).
- Step 9: Commit
git add src/billing/domain/ports/billing-provider.port.ts src/billing/domain/errors.ts src/billing/infrastructure/providers/lago.provider.ts src/billing/infrastructure/providers/mock.provider.ts src/billing/infrastructure/providers/lago.provider.spec.ts src/billing/application/commands/sync-specialist-subscription.command.ts
git commit -m "feat(billing): realignSubscriptionStart — move pending, terminate+recreate started-unbilled subs (#2411)
Replaces updateSubscriptionStartDate, which could only move pending subs and
silently no-oped on started ones (the root cause of #2411: same-day subs start
instantly and could never be trial-realigned).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 4: Command realignStart consumes the realign result
Files:
-
Modify:
src/billing/domain/billing-subscription-status.ts(add"realign"toSyncFailedOp) -
Modify:
src/billing/application/commands/sync-specialist-subscription.command.ts(realignStart) -
Test:
src/billing/application/commands/sync-specialist-subscription.command.spec.ts -
Step 1: Write the failing tests
Append to sync-specialist-subscription.command.spec.ts:
describe("realignStart (#2411)", () => {
async function mkActiveSub(repo: FakeRepo, provider: MockProvider) {
const cmd = new SyncSpecialistSubscriptionCommand(repo as any, provider);
await cmd.execute(input); // creates an active mirror with externalSubscriptionId
return cmd;
}
it("recreated → mirror stays active with the new lago id and cleared canceledAt", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const cmd = await mkActiveSub(repo, provider);
const sub = (await repo.findByAssignmentId("a1"))!;
sub.canceledAt = new Date(); // simulate the terminated-webhook having landed first
jest.spyOn(provider, "realignSubscriptionStart")
.mockResolvedValue({ action: "recreated", providerLagoId: "lago-new" });
await cmd.realignStart({ ...input, effectiveDate: "2026-08-10" });
const after = (await repo.findByAssignmentId("a1"))!;
expect(after.status).toBe("active");
expect(after.providerSubscriptionLagoId).toBe("lago-new");
expect(after.canceledAt).toBeNull();
});
it("moved → no mirror write needed (status/lago id unchanged)", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const cmd = await mkActiveSub(repo, provider);
const before = (await repo.findByAssignmentId("a1"))!.providerSubscriptionLagoId;
await cmd.realignStart({ ...input, effectiveDate: "2026-08-10" }); // mock returns moved
const after = (await repo.findByAssignmentId("a1"))!;
expect(after.status).toBe("active");
expect(after.providerSubscriptionLagoId).toBe(before);
});
it("skipped_billed → warns, mirror untouched", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const cmd = await mkActiveSub(repo, provider);
jest.spyOn(provider, "realignSubscriptionStart")
.mockResolvedValue({ action: "skipped_billed" });
await cmd.realignStart({ ...input, effectiveDate: "2026-08-10" });
expect((await repo.findByAssignmentId("a1"))!.status).toBe("active");
});
it("RealignRecreateFailedError after retries → sync_failed/realign, error propagates", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const cmd = await mkActiveSub(repo, provider);
jest.spyOn(provider, "realignSubscriptionStart")
.mockRejectedValue(new RealignRecreateFailedError("a1", "lago down"));
await expect(cmd.realignStart({ ...input, effectiveDate: "2026-08-10" })).rejects.toThrow(RealignRecreateFailedError);
const after = (await repo.findByAssignmentId("a1"))!;
expect(after.status).toBe("sync_failed");
expect(after.syncFailedOp).toBe("realign");
});
it("non-realign errors propagate WITHOUT flipping the mirror to sync_failed", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const cmd = await mkActiveSub(repo, provider);
jest.spyOn(provider, "realignSubscriptionStart").mockRejectedValue(new Error("network blip before any mutation"));
await expect(cmd.realignStart({ ...input, effectiveDate: "2026-08-10" })).rejects.toThrow("network blip");
expect((await repo.findByAssignmentId("a1"))!.status).toBe("active");
});
it("no-ops for canceled mirrors and mirrors without a provider sub", async () => {
const repo = new FakeRepo();
const provider = new MockProvider();
const spy = jest.spyOn(provider, "realignSubscriptionStart");
const cmd = new SyncSpecialistSubscriptionCommand(repo as any, provider);
await cmd.realignStart(input); // no mirror at all
expect(spy).not.toHaveBeenCalled();
});
});
Add the import at the top: import { RealignRecreateFailedError } from "../../domain/errors";
- Step 2: Run to verify failure
Run: npm test -- sync-specialist-subscription.command.spec
Expected: FAIL — "realign" not assignable to SyncFailedOp; recreated-case assertions fail.
- Step 3: Implement
In src/billing/domain/billing-subscription-status.ts:
export type SyncFailedOp = "create" | "update" | "cancel" | "realign";
In sync-specialist-subscription.command.ts, add the import import { RealignRecreateFailedError } from "../../domain/errors"; and replace the whole realignStart method with:
/**
* Realign a subscription's billing start to `input.effectiveDate` (already
* trial-clamped by the resolver). #1423 could only move pending subs; #2411
* extends this to started-but-unbilled subs via the provider's
* terminate(skip)+recreate, and records the recreated sub's lago id so the
* resulting subscription.terminated webhook can be told apart from a real
* cancellation. Whole-call withRetry is safe: realign is idempotent (a
* half-finished run's absent-sub state lands in the provider's create branch).
* Callers (org-update hooks) wrap this fail-open.
*/
async realignStart(input: SyncSpecialistSubscriptionInput): Promise<void> {
const sub = await this.repo.findByAssignmentId(input.assignmentId);
if (!sub || sub.status === "canceled" || !sub.externalSubscriptionId) return;
try {
const result = await this.withRetry(
() =>
this.provider.realignSubscriptionStart({
externalSubscriptionId: sub.externalSubscriptionId!,
externalCustomerId: sub.externalCustomerId,
specialistId: input.specialistId,
specialistName: input.specialistName,
monthlyRateCents: input.monthlyRateCents,
currency: sub.currency,
anchorDay: sub.anchorDay,
startDate: input.effectiveDate,
}),
"realignSubscriptionStart",
);
if (result.action === "recreated") {
// The terminate fired a subscription.terminated webhook that may land
// before OR after this write — storing the new lago id is what lets the
// webhook handler ignore the stale event either way.
sub.markActive(sub.externalSubscriptionId!, result.providerLagoId);
sub.canceledAt = null;
await this.repo.save(sub);
} else if (result.action === "skipped_billed") {
this.logger.warn(
`realign skipped for assignment ${input.assignmentId} (org ${input.orgId}): ` +
`provider subscription already issued invoices — trial change needs manual credits`,
);
}
// "moved": provider-side only; nothing on the mirror changes.
} catch (err) {
if (err instanceof RealignRecreateFailedError) {
// Terminate happened, recreate didn't: org has no provider sub. Make it
// visible in /ops/billing and retryable via retry-sync (op='realign').
sub.markSyncFailed("realign");
await this.repo.save(sub);
}
throw err;
}
}
Note the guard semantics are unchanged from today (!sub, canceled, !externalSubscriptionId all return early) — after a failed recreate externalSubscriptionId is still set (it's the assignment id), so retry passes the guard.
- Step 4: Run to verify pass
Run: npm test -- sync-specialist-subscription.command.spec
Expected: PASS — including the pre-existing tests (execute paths unchanged).
- Step 5: Commit
git add src/billing/domain/billing-subscription-status.ts src/billing/application/commands/sync-specialist-subscription.command.ts src/billing/application/commands/sync-specialist-subscription.command.spec.ts
git commit -m "feat(billing): realignStart handles recreated/skipped/failed provider realign results (#2411)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 5: Ops retry-sync handles sync_failed_op='realign'
Files:
-
Modify:
src/billing/interface/ops-billing.controller.ts(retrySync, ~L163-195) -
Test:
src/billing/interface/ops-billing.controller.spec.ts -
Step 1: Write the failing test
Open src/billing/interface/ops-billing.controller.spec.ts and find the existing retrySync tests (there are tests for the cancel branch and the default branch — match their construction of the controller with mock subs/resolver/sync/cancel). Add:
it("retry-sync routes sync_failed_op='realign' to realignStart, not execute", async () => {
const sub = { assignmentId: "a1", status: "sync_failed", syncFailedOp: "realign" };
const subs = { findByAssignmentId: jest.fn().mockResolvedValue(sub) };
const resolver = { resolve: jest.fn().mockResolvedValue({ assignmentId: "a1", effectiveDate: "2026-08-10" }) };
const sync = { execute: jest.fn(), realignStart: jest.fn().mockResolvedValue(undefined) };
const cancel = { execute: jest.fn() };
const controller = makeController({ subs, resolver, sync, cancel }); // reuse the file's existing factory/ctor pattern
const res = await controller.retrySync("a1");
expect(res).toEqual({ retried: true });
expect(sync.realignStart).toHaveBeenCalledWith(expect.objectContaining({ assignmentId: "a1" }));
expect(sync.execute).not.toHaveBeenCalled();
expect(cancel.execute).not.toHaveBeenCalled();
});
If the spec file has no makeController helper, construct the controller exactly as the neighboring retrySync tests do (new OpsBillingController(...) with the same positional mocks) — copy one and swap the mocks above in.
- Step 2: Run to verify failure
Run: npm test -- ops-billing.controller.spec
Expected: FAIL — realign op falls through to the default branch (sync.execute called).
- Step 3: Implement
In retrySync, between the cancel branch and the else:
} else if (sub.syncFailedOp === "realign") {
// A realign terminated the provider sub but the recreate failed. Re-running
// realign is the correct retry: its absent-sub branch just creates the
// pending sub at the (re-resolved, still trial-clamped) effective date.
const input = await this.resolver.resolve(assignmentId);
await this.sync.realignStart(input);
} else {
- Step 4: Run to verify pass
Run: npm test -- ops-billing.controller.spec
Expected: PASS.
- Step 5: Commit
git add src/billing/interface/ops-billing.controller.ts src/billing/interface/ops-billing.controller.spec.ts
git commit -m "feat(billing): retry-sync recovers sync_failed/realign by re-running realign (#2411)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 6: Webhook handler ignores stale terminations via lago_id
Files:
-
Modify:
src/billing/domain/billing-event.ts(subscription_canceled gains providerLagoId) -
Modify:
src/billing/infrastructure/providers/lago.provider.ts(parseWebhook, ~L651-660) -
Modify:
src/billing/application/commands/handle-billing-webhook.command.ts(~L43-47) -
Test:
src/billing/application/commands/handle-billing-webhook.command.spec.ts -
Test:
src/billing/infrastructure/providers/lago.provider.spec.ts -
Step 1: Write the failing tests
In handle-billing-webhook.command.spec.ts, find how existing tests stub the provider's parseWebhook (they inject a fake provider returning a BillingEvent) and the subs repo. Add:
describe("stale termination guard (#2411)", () => {
function mkSub(lagoId: string | null) {
return {
status: "active", providerSubscriptionLagoId: lagoId,
markCanceled: jest.fn(function (this: any, at: Date) { this.status = "canceled"; this.canceledAt = at; }),
} as any;
}
it("ignores subscription.terminated whose lago_id differs from the mirror's (superseded sub)", async () => {
const sub = mkSub("lago-new");
const provider = { parseWebhook: jest.fn().mockReturnValue({
kind: "subscription_canceled", externalSubscriptionId: "a1", providerLagoId: "lago-old",
}) };
const subs = { findByExternalSubscriptionId: jest.fn().mockResolvedValue(sub), save: jest.fn() };
const cmd = new HandleBillingWebhookCommand(provider as any, {} as any, subs as any);
await cmd.execute("raw", "sig");
expect(sub.markCanceled).not.toHaveBeenCalled();
expect(subs.save).not.toHaveBeenCalled();
});
it("cancels when the event lago_id matches the mirror's", async () => {
const sub = mkSub("lago-old");
const provider = { parseWebhook: jest.fn().mockReturnValue({
kind: "subscription_canceled", externalSubscriptionId: "a1", providerLagoId: "lago-old",
}) };
const subs = { findByExternalSubscriptionId: jest.fn().mockResolvedValue(sub), save: jest.fn() };
const cmd = new HandleBillingWebhookCommand(provider as any, {} as any, subs as any);
await cmd.execute("raw", "sig");
expect(sub.markCanceled).toHaveBeenCalled();
expect(subs.save).toHaveBeenCalledWith(sub);
});
it("falls back to cancel when the mirror has no stored lago_id (legacy rows)", async () => {
const sub = mkSub(null);
const provider = { parseWebhook: jest.fn().mockReturnValue({
kind: "subscription_canceled", externalSubscriptionId: "a1", providerLagoId: "lago-old",
}) };
const subs = { findByExternalSubscriptionId: jest.fn().mockResolvedValue(sub), save: jest.fn() };
const cmd = new HandleBillingWebhookCommand(provider as any, {} as any, subs as any);
await cmd.execute("raw", "sig");
expect(sub.markCanceled).toHaveBeenCalled();
});
});
(Adjust the new HandleBillingWebhookCommand(...) arity to match the file's existing tests — the 4th/5th ctor params are @Optional() and may be omitted.)
In lago.provider.spec.ts, find the existing parseWebhook test for subscription.terminated (it stubs signature verification — match how) and extend/add the assertion:
expect(event).toEqual({
kind: "subscription_canceled",
externalSubscriptionId: "a1",
providerLagoId: "lago-old",
});
with the webhook body including subscription: { external_id: "a1", lago_id: "lago-old" }.
- Step 2: Run to verify failure
Run: npm test -- --testPathPatterns 'handle-billing-webhook.command.spec|lago.provider.spec'
Expected: FAIL — event shape lacks providerLagoId; stale event still cancels.
- Step 3: Implement
In src/billing/domain/billing-event.ts:
| {
kind: "subscription_canceled";
externalSubscriptionId: string;
/** Lago's internal id of the terminated sub — used to ignore stale
* terminations of a superseded (terminate+recreated) subscription (#2411). */
providerLagoId?: string | null;
}
In lago.provider.ts parseWebhook, the terminated branch becomes:
if (
payload.webhook_type === "subscription.terminated" &&
payload.subscription?.external_id
) {
return {
kind: "subscription_canceled",
externalSubscriptionId: payload.subscription.external_id,
providerLagoId: payload.subscription.lago_id ?? null,
};
}
In handle-billing-webhook.command.ts, the subscription_canceled branch becomes:
if (event.kind === "subscription_canceled") {
const sub = await this.subs.findByExternalSubscriptionId(event.externalSubscriptionId);
if (!sub || sub.status === "canceled") return;
// #2411: external_subscription_id (our assignment id) is reused across
// terminate+recreate, so a termination event alone can't tell "this sub
// was canceled" from "an older incarnation of this sub was superseded".
// When both sides know the provider's internal id and they differ, the
// event refers to the superseded incarnation — ignore it. Legacy rows
// (no stored lago id) keep today's behavior.
if (
event.providerLagoId &&
sub.providerSubscriptionLagoId &&
event.providerLagoId !== sub.providerSubscriptionLagoId
) {
this.logger.debug(
`Ignoring stale subscription.terminated for superseded provider sub ${event.providerLagoId} (current: ${sub.providerSubscriptionLagoId})`,
);
return;
}
sub.markCanceled(new Date());
await this.subs.save(sub);
return;
}
- Step 4: Run to verify pass
Run: npm test -- --testPathPatterns 'handle-billing-webhook.command.spec|lago.provider.spec'
Expected: PASS, including all pre-existing webhook tests.
- Step 5: Commit
git add src/billing/domain/billing-event.ts src/billing/infrastructure/providers/lago.provider.ts src/billing/application/commands/handle-billing-webhook.command.ts src/billing/application/commands/handle-billing-webhook.command.spec.ts src/billing/infrastructure/providers/lago.provider.spec.ts
git commit -m "feat(billing): ignore stale subscription.terminated webhooks after terminate+recreate (#2411)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 7: Org-service hooks — realign on every trial-setting path
Files:
-
Modify:
src/organizations/organizations.service.ts(updateOrg ~L1304-1327, sendInvitation ~L1509-1517, finishSetupWithoutInvite ~L1652-1672) -
Create:
src/organizations/trial-realign-hook.spec.ts -
Step 1: Write the failing spec
Create src/organizations/trial-realign-hook.spec.ts, using the same Object.create(OrganizationsService.prototype) pattern as workspace-provisioning-hook.spec.ts (real constructor is too heavy for unit tests):
/**
* Focused unit tests for the #2411 trial-billing realign hook
* `realignBillingForTrialChange` — the shared helper called from updateOrg,
* sendInvitation, and finishSetupWithoutInvite whenever trialEndDate changes.
* The surrounding orchestration is covered by existing integration tests.
*/
import { Logger } from "@nestjs/common";
import { OrganizationsService } from "./organizations.service";
function makeService(opts: {
assignments?: Array<{ id: string }>;
resolver?: { resolve: jest.Mock } | null;
sync?: { realignStart: jest.Mock } | null;
assignmentRepoThrows?: boolean;
}) {
const svc: any = Object.create(OrganizationsService.prototype);
svc.logger = new Logger("test");
svc.assignmentRepo = {
find: opts.assignmentRepoThrows
? jest.fn().mockRejectedValue(new Error("db down"))
: jest.fn().mockResolvedValue(opts.assignments ?? []),
};
svc.assignmentBillingInputResolver =
opts.resolver === null ? undefined : opts.resolver ?? { resolve: jest.fn().mockResolvedValue({ assignmentId: "a1" }) };
svc.syncSpecialistSubscription =
opts.sync === null ? undefined : opts.sync ?? { realignStart: jest.fn().mockResolvedValue(undefined) };
return svc;
}
describe("realignBillingForTrialChange (#2411)", () => {
it("resolves and realigns every assignment of the org", async () => {
const resolver = { resolve: jest.fn().mockResolvedValue({ assignmentId: "a1" }) };
const sync = { realignStart: jest.fn().mockResolvedValue(undefined) };
const svc = makeService({ assignments: [{ id: "a1" }, { id: "a2" }], resolver, sync });
await svc.realignBillingForTrialChange("org-1");
expect(resolver.resolve).toHaveBeenCalledTimes(2);
expect(sync.realignStart).toHaveBeenCalledTimes(2);
});
it("continues with remaining assignments when one realign throws (fail-open per assignment)", async () => {
const resolver = { resolve: jest.fn().mockResolvedValue({ assignmentId: "x" }) };
const sync = {
realignStart: jest.fn()
.mockRejectedValueOnce(new Error("lago down"))
.mockResolvedValueOnce(undefined),
};
const svc = makeService({ assignments: [{ id: "a1" }, { id: "a2" }], resolver, sync });
await expect(svc.realignBillingForTrialChange("org-1")).resolves.toBeUndefined();
expect(sync.realignStart).toHaveBeenCalledTimes(2);
});
it("never throws — even when listing assignments fails", async () => {
const svc = makeService({ assignmentRepoThrows: true });
await expect(svc.realignBillingForTrialChange("org-1")).resolves.toBeUndefined();
});
it("no-ops when billing wiring is absent (resolver/sync undefined)", async () => {
const svc = makeService({ assignments: [{ id: "a1" }], resolver: null, sync: null });
await expect(svc.realignBillingForTrialChange("org-1")).resolves.toBeUndefined();
expect(svc.assignmentRepo.find).not.toHaveBeenCalled();
});
});
- Step 2: Run to verify failure
Run: npm test -- trial-realign-hook.spec
Expected: FAIL — realignBillingForTrialChange is not a function.
- Step 3: Extract the helper and rewire updateOrg
In src/organizations/organizations.service.ts, add the private method (place it right after updateOrg):
/**
* Billing hook (#1423/#2411, fail-open): a changed trial end date moves the
* point at which billing may start. Realign every assignment's provider
* subscription: pending subs are moved in place; started-but-unbilled subs
* are terminate(skip)+recreated at the clamped date; already-billed subs are
* skipped (manual credits — see #2411). MUST never throw: billing problems
* must not block org operations. Called from every path that persists a
* changed trialEndDate (updateOrg, sendInvitation, finishSetupWithoutInvite).
*/
private async realignBillingForTrialChange(orgId: string): Promise<void> {
if (!this.assignmentBillingInputResolver || !this.syncSpecialistSubscription) return;
try {
const assignments = await this.assignmentRepo.find({ where: { orgId } });
for (const a of assignments) {
try {
const input = await this.assignmentBillingInputResolver.resolve(a.id);
await this.syncSpecialistSubscription.realignStart(input);
} catch (err) {
this.logger.warn(
`billing realign skipped for assignment ${a.id}: ${(err as Error).message}`,
);
}
}
} catch (err) {
this.logger.warn(`billing realign skipped for org ${orgId}: ${(err as Error).message}`);
}
}
In updateOrg, replace the existing inline block (the if (trialEndChanged && this.assignmentBillingInputResolver && this.syncSpecialistSubscription) { … } block with its for-loop, ~L1304-1327) with:
// Billing hook (fail-open, #1423/#2411): see realignBillingForTrialChange.
if (trialEndChanged) {
await this.realignBillingForTrialChange(orgId);
}
- Step 4: Hook sendInvitation
In sendInvitation (~L1509), the current trial-persist block is:
if (trialEndDateInput) {
const parsed = new Date(trialEndDateInput);
if (isNaN(parsed.getTime())) {
throw new BadRequestException("Invalid trialEndDate");
}
org.trialEndDate = parsed;
await this.orgRepo.save(org);
}
Replace with:
if (trialEndDateInput) {
const parsed = new Date(trialEndDateInput);
if (isNaN(parsed.getTime())) {
throw new BadRequestException("Invalid trialEndDate");
}
const prevTrialEndIso = org.trialEndDate
? new Date(org.trialEndDate).toISOString().slice(0, 10)
: null;
org.trialEndDate = parsed;
await this.orgRepo.save(org);
// #2411: Step 5 is where the AM actually picks the trial — the provider
// subscription was provisioned at assignment time with no trial knowledge,
// so a changed date must realign billing here, not only in updateOrg.
if (parsed.toISOString().slice(0, 10) !== prevTrialEndIso) {
await this.realignBillingForTrialChange(org.id);
}
}
- Step 5: Hook finishSetupWithoutInvite
In finishSetupWithoutInvite (~L1652), the current code sets org.trialEndDate = parsed; inside if (trialEndDateInput), then later (after the gate) runs org.status = "active"; await this.orgRepo.save(org);. Capture the change flag where the date is parsed, and fire the hook after the save:
let trialEndChanged = false;
if (trialEndDateInput) {
const parsed = new Date(trialEndDateInput);
if (isNaN(parsed.getTime())) {
throw new BadRequestException("Invalid trialEndDate");
}
const prevTrialEndIso = org.trialEndDate
? new Date(org.trialEndDate).toISOString().slice(0, 10)
: null;
org.trialEndDate = parsed;
trialEndChanged = parsed.toISOString().slice(0, 10) !== prevTrialEndIso;
}
and after await this.orgRepo.save(org);:
// #2411: same Step-5 trial-pick as sendInvitation — realign after the org
// (with its new trialEndDate) is actually persisted.
if (trialEndChanged) {
await this.realignBillingForTrialChange(org.id);
}
- Step 6: Run the new spec + build
Run: npm test -- trial-realign-hook.spec → PASS.
Run: npm run build → clean.
- Step 7: Commit
git add src/organizations/organizations.service.ts src/organizations/trial-realign-hook.spec.ts
git commit -m "fix(orgs): realign billing on every trial-setting path, not only updateOrg (#2411)
sendInvitation and finishSetupWithoutInvite persist the AM's Step-5 trial
choice but never fired the #1423 realign hook — the provider subscription
(provisioned at assignment time, before the trial is known) kept billing from
day one. Extracts the hook into realignBillingForTrialChange and calls it from
all three paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 8: Full verification + PR
Files: none new.
- Step 1: Full API test suite
Run: npm test
Expected: all suites pass (sqlite). Pay attention to any spec referencing updateSubscriptionStartDate or constructing BillingSubscription literals — fix any missed compile breaks in tests, keeping behavior assertions intact.
- Step 2: Lint
Run: npm run lint
Expected: clean (or only pre-existing warnings — do not fix unrelated files).
- Step 3: Push and open the PR against dev
git push -u origin fix/2411-trial-clamp-realign
gh pr create --base dev --title "fix(billing): honor org trial in billing — realign provider subs on every trial-setting path (#2411)" --body "$(cat <<'EOF'
Closes #2411. Implements Option A per docs/superpowers/specs/2026-06-10-trial-clamp-realign-design.md.
- New mirror column `provider_subscription_lago_id` (expand-only migration) so the
subscription.terminated webhook can distinguish a stale termination (our own
terminate+recreate of a superseded sub) from a real cancellation.
- `realignSubscriptionStart` provider op: pending→moved in place; started+unbilled→
terminate(on_termination_invoice=skip)+recreate pending at the trial-clamped date;
billed→skipped (warn, manual credits); absent→create (idempotent recovery).
- `sync_failed_op='realign'` + retry-sync branch for the terminate-succeeded/recreate-failed case.
- `realignBillingForTrialChange` hook now fires from sendInvitation and
finishSetupWithoutInvite (the Step-5 trial-pick paths), not only updateOrg.
Verified end-to-end context: staging org 8e771cbd… (trial to Aug 10) was billed from
Jun 10; remediated manually with exactly the terminate(skip)+recreate+mirror-restore
sequence this PR automates.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
- Step 4: Wait for Greptile review before downstream work (per repo convention — un-draft the PR if Greptile doesn't comment).
Self-review notes (already applied)
- Spec coverage: §1→Task 1, §2 port→Tasks 2+3, §3 command→Task 4, §4 retry→Task 5, §5 hooks→Task 7, §6 webhook→Task 6, testing §→ distributed per task. No gaps.
- Type consistency:
RealignStartResultdefined in the port (Task 3) and consumed in Tasks 3-4;SyncFailedOpgains"realign"in Task 4 before first use;markActive(externalSubscriptionId, providerLagoId?)defined in Task 1, used in Tasks 2 and 4. - Ordering: Task 3 step 7 makes a minimal mechanical change to the command so each commit compiles; Task 4 completes it.