Skip to main content

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:

  1. sendInvitation() and finishSetupWithoutInvite() persist org.trialEndDate without the #1423 realign hook (it exists only in updateOrg()).
  2. Even where the hook fires, Lago's Subscriptions::UpdateService writes subscription_at only while a sub is pending. A sub created with subscription_at = today starts instantly and can never be moved.
  3. Terminate/recreate (the only way to move a started sub) trips the subscription.terminated webhook, which flips the billing_subscriptions mirror to canceled — and SyncSpecialistSubscriptionCommand never 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
1Keep provisioning at assignment time (Option A). Deferred provisioning is #894.
2Webhook race: store Lago's internal lago_id on the mirror and compare on terminated events.
3Mid-realign failure: withRetry on recreate; final failure → sync_failed / sync_failed_op='realign'; extend the existing retry-sync ops endpoint.
4Started subs that already issued invoices: skip realign, warn log, no Lago mutation. Human decision (credits/refunds), not automated.
5No 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_id holds our assignment id, which is deliberately reused across terminate/recreate, so it cannot distinguish the old sub from its replacement. Lago's lago_id is unique per provider subscription.
  • BillingSubscription domain object, storage entity, and mapper gain the field.
  • The provider port's createSubscription result gains providerLagoId; markActive stores 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_idActionResult
pendingPUT /subscriptions/:external_id subscription_at = startDate (today's behavior){ action: 'moved', providerLagoId }
started, zero issued invoicesDELETE /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 invoicesnothing{ 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.realignSubscriptionStart with the resolver's already-trial-clamped input.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.
  • moved carries 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 reports moved with 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). RealignRecreateFailedError after retries → mirror markSyncFailed('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 private realignBillingForTrialChange(orgId): load assignments, per-assignment resolver.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 updateOrg today):
    1. updateOrg (existing behavior, refactored to use the helper)
    2. sendInvitation — after persisting the Step-5 trialEndDateInput
    3. finishSetupWithoutInvite — after persisting the Step-5 trialEndDateInput

6. Webhook handler

  • LagoProvider.parseWebhook: subscription.terminated events additionally surface providerLagoId (= payload.subscription.lago_id).
  • HandleBillingWebhookCommand, subscription_canceled branch: 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).

  1. organizations.service: sendInvitation and finishSetupWithoutInvite fire realign when the trial date is provided and changed; skip when absent/unchanged; updateOrg keeps existing behavior; all three stay fail-open when realign throws.
  2. 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.
  3. retry-sync endpoint: op='realign' routes to realign; existing cancel/default branches unchanged.
  4. webhook: terminated with mismatched lago id → ignored; matching → canceled; mirror lago id NULL → canceled (back-compat).
  5. lago.provider: request shapes for all four realign cases, zero-invoice guard scoped to the subscription, RealignRecreateFailedError on create failure after terminate.
  6. migration: column exists, nullable, expand-only.