Trial clamp realign — design (#2411, Option A)
Date: 2026-06-10 Issue: #2411 Related: #1423 (original trial clamp, incomplete), #2412 (preview), #894 (Start Date concept — the deferred-provisioning alternative, out of scope here)
Problem
An org's Lago subscription is provisioned at Specialist-assignment time, which in the AM setup wizard happens before the AM picks the trial period on Step 5. Three compounding gaps mean the trial is then never reflected in billing:
sendInvitation()andfinishSetupWithoutInvite()persistorg.trialEndDatewithout the #1423 realign hook (it exists only inupdateOrg()).- Even where the hook fires, Lago's
Subscriptions::UpdateServicewritessubscription_atonly while a sub is pending. A sub created withsubscription_at = todaystarts instantly and can never be moved. - Terminate/recreate (the only way to move a started sub) trips the
subscription.terminatedwebhook, which flips thebilling_subscriptionsmirror tocanceled— andSyncSpecialistSubscriptionCommandnever resurrects canceled rows.
Observed end-to-end on AWS staging 2026-06-10 (org 8e771cbd-…, trial to Aug 10, card
would have been charged $2,200 on ~Jul 10).
Decisions (ratified in brainstorming)
| # | Decision |
|---|---|
| 1 | Keep provisioning at assignment time (Option A). Deferred provisioning is #894. |
| 2 | Webhook race: store Lago's internal lago_id on the mirror and compare on terminated events. |
| 3 | Mid-realign failure: withRetry on recreate; final failure → sync_failed / sync_failed_op='realign'; extend the existing retry-sync ops endpoint. |
| 4 | Started subs that already issued invoices: skip realign, warn log, no Lago mutation. Human decision (credits/refunds), not automated. |
| 5 | No backfill. The one affected staging org was remediated manually; once hooks exist, any straggler is fixed by re-saving its trial end date. |
Design
1. Data model (expand-only)
- New nullable column
billing_subscriptions.provider_subscription_lago_id varchar+ forward migration. Expand-only — zero-downtime safe under active-active; existing rows stay NULL. - Rationale:
external_subscription_idholds our assignment id, which is deliberately reused across terminate/recreate, so it cannot distinguish the old sub from its replacement. Lago'slago_idis unique per provider subscription. BillingSubscriptiondomain object, storage entity, and mapper gain the field.- The provider port's
createSubscriptionresult gainsproviderLagoId;markActivestores it.
2. Provider port: realignSubscriptionStart
updateSubscriptionStartDate is replaced by a semantic
realignSubscriptionStart(input): Promise<RealignResult> — intent: "make this
subscription start on date X". Input carries what recreate needs:
{ externalSubscriptionId, externalCustomerId, specialistName, monthlyRateCents, currency, anchorDay, startDate }.
Provider state for external_id | Action | Result |
|---|---|---|
| pending | PUT /subscriptions/:external_id subscription_at = startDate (today's behavior) | { action: 'moved', providerLagoId } |
| started, zero issued invoices | DELETE /subscriptions/:external_id?on_termination_invoice=skip, then POST /subscriptions (same external_id, per-rate plan, billing_time: 'anniversary', subscription_at = startDate) | { action: 'recreated', providerLagoId } |
| started, has invoices | nothing | { action: 'skipped_billed' } |
| absent (recovery after half-finished realign) | POST /subscriptions pending at startDate | { action: 'recreated', providerLagoId } |
The absent row is what makes realign idempotent: a retry after "terminated but recreate failed" simply lands there.
The zero-invoice guard is a provider-side check scoped to the subscription (not the customer — other Specialists' invoices must not block realign).
If terminate succeeds and recreate then fails, the provider throws a typed
RealignRecreateFailedError so the command can distinguish "Lago untouched" from
"Lago now has no sub".
MockProvider mirrors the result shape with no-ops.
3. Command layer: SyncSpecialistSubscriptionCommand.realignStart
- Calls
provider.realignSubscriptionStartwith the resolver's already-trial-clampedinput.effectiveDate(the resolver is unchanged). recreated→ update mirror in place:status='active',canceledAt=null,providerSubscriptionLagoId=<new>,currentRateCents=input.monthlyRateCents(the recreate provisioned at the input rate — mirror must match); anchor/external ids unchanged.movedcarries the provider sub's lago_id too (review finding): in the lost-response retry corner (recreate landed in Lago but the response was lost), the retry finds the new sub via the pending probe and reportsmovedwith the NEW lago_id — the command stamps it when non-null and different, else preserves. Without this the stale mirror id would invert the webhook guard. Relatedly,updateSubscriptionPrice(plan-change re-issue) also returns and stamps the replacement's lago_id (Task 2b).skipped_billed→ warn log with org/assignment ids; mirror untouched.- Recreate path wrapped in the command's existing
withRetry(3 attempts).RealignRecreateFailedErrorafter retries → mirrormarkSyncFailed('realign'). - Guard order preserved: no-op for canceled mirrors and mirrors without
externalSubscriptionId(same as today).
4. Ops retry endpoint
POST /ops/billing/subscriptions/:assignmentId/retry-sync gains a branch:
sync_failed_op === 'realign' → re-resolve the assignment and call realignStart
(instead of sync.execute). Recovery works via the provider's absent case.
5. Hook placement (organizations.service.ts)
- Extract the loop currently inlined in
updateOrg(~L1319) into a privaterealignBillingForTrialChange(orgId): load assignments, per-assignmentresolver.resolve → realignStart, each wrapped fail-open (warn log) — billing must never block an org operation. - Call sites, each guarded by its own trialEndChanged check (compare previous vs new ISO
date, same semantics as
updateOrgtoday):updateOrg(existing behavior, refactored to use the helper)sendInvitation— after persisting the Step-5trialEndDateInputfinishSetupWithoutInvite— after persisting the Step-5trialEndDateInput
6. Webhook handler
LagoProvider.parseWebhook:subscription.terminatedevents additionally surfaceproviderLagoId(=payload.subscription.lago_id).HandleBillingWebhookCommand,subscription_canceledbranch: when both the event and the mirror carry a lago id and they differ → ignore (debug log: stale termination for a superseded sub). When the mirror's stored lago id is NULL (legacy rows) or the event lacks one → today's behavior (mark canceled).
Out of scope
- #2412 (Next Invoice preview math) — follows this PR so the preview can mirror the same clamp semantics.
- #2413 (rate-history baseline row).
- Deferred provisioning / Start Date concept (#894).
- Backfill or audit scripts (decision 5).
Testing
Specs run on sqlite locally / Postgres in CI; nothing here is Postgres-only except the
migration (covered by the api-postgres-migrations CI job).
- organizations.service:
sendInvitationandfinishSetupWithoutInvitefire realign when the trial date is provided and changed; skip when absent/unchanged;updateOrgkeeps existing behavior; all three stay fail-open when realign throws. - sync command
realignStart: pending→moved; started+0-invoices→recreated and mirror updated (active, null canceledAt, new lago id); billed→skipped+warn, mirror untouched; recreate failure→sync_failed/realign; canceled/absent-provider-sub guards unchanged. - retry-sync endpoint:
op='realign'routes to realign; existingcancel/default branches unchanged. - webhook: terminated with mismatched lago id → ignored; matching → canceled; mirror lago id NULL → canceled (back-compat).
- lago.provider: request shapes for all four realign cases, zero-invoice guard
scoped to the subscription,
RealignRecreateFailedErroron create failure after terminate. - migration: column exists, nullable, expand-only.