Decision Brief: Lago pricing strategy — plan-per-rate vs plan_overrides
Status: Accepted (dual-strategy shipped; default plan_per_rate) · Date: 2026-05-29 · Last reviewed: 2026-06-03 · Owner: Alex
Scope: How LagoProvider represents each Specialist's monthly rate in Lago.
Related: provider-leveraged billing spec, PR #1069 (LagoProvider), PR #1100 (Lago client fixes), PR #1105 (dual-strategy adapter — this PR, merged).
Resolution (2026-06-03). The dual-strategy adapter has shipped (PR #1105, merged) with
plan_per_rateas the default. Free dev Lago runs the system end-to-end today. The premium-license decision (Option A vs B inOPEN_DECISIONS.mdOD-10) remains open as a product/spend call, not a code call — promote to a numbered ADR when that call is made.
TL;DR
End-to-end testing against the live dev Lago revealed that plan_overrides — the per-subscription price-override mechanism our design relied on — is a Lago premium-licensed feature (403 feature_unavailable on the free self-hosted instance). We need a free-tier pricing path. This brief proposes shipping a dual-strategy adapter: default to plan-per-rate (works on free Lago today), and retain the existing plan_overrides code behind a config flag for if/when a premium license is enabled. Decision needed: accept plan-per-rate as the operating model, or pursue a Lago premium license.
Context — what we found
The provider-leveraged design priced each (org × Specialist) subscription via a single generic plan + a per-subscription plan_overrides.amount_cents. We chose this specifically to avoid creating a Lago plan per Specialist.
During Task 0.3 verification (assigning a Specialist on dev with BILLING_PROVIDER=lago), the integration plumbing worked end-to-end — the Lago customer was created, and the fail-open path correctly recorded sync_failed with no error surfaced to the AM — but createSubscription failed with 403 feature_unavailable. Reproduced directly:
createSubscriptionwithplan_overrides→403 feature_unavailable.- The identical call without
plan_overrides→ subscription created,status: active.
Confirmed against Lago's primary docs (the "Overriding a plan" section carries a PREMIUM FEATURE ✨ This feature is only available to users with a premium license badge). The free edition has no per-subscription custom-price mechanism; Lago's own docs point to "multiple plan variants with different base prices" — i.e. plan-per-rate.
(The instance also already runs the recruiter product's fixed-amount plans — pro $1,499, scale, starter — proving fixed-price plans work on the free tier.)
The model: plan = price, subscription = assignment
The crux of plan-per-rate:
- A plan is a price template (e.g.
specialist_usd_360000= $3,600/mo), reused across any subscription at that rate. - A subscription is one (org × Specialist) assignment — its own row, never shared.
- So N assignments → N subscriptions, but M distinct rates → M plans (M ≤ N).
Two Specialists at the same price → two subscriptions sharing one plan (distinct external_id per assignment, distinct name per Specialist). Consolidation groups by customer + billing day, so each subscription still contributes its own fee line:
Org "Acme" (one customer, anchor day 15)
├── sub external_id=assignmentA name="Aarav" → plan specialist_usd_360000 ($3,600)
└── sub external_id=assignmentB name="Bob" → plan specialist_usd_360000 ($3,600) ← same plan
→ ONE consolidated invoice: Aarav $3,600 + Bob $3,600 = $7,200
Options
| Option | Cost | Code change | Lago plans created | Notes |
|---|---|---|---|---|
| A — Lago premium license | recurring $ | ~none (current override code works once licensed) | 1 generic | Keeps the original design. Requires a paid Lago entitlement on the (shared) instance. |
| B — plan-per-rate (recommended default) | $0 | adapter-only (~150–200 LOC, 1 file + spec + bootstrap) | one per distinct rate (reused) | Lago's documented free-tier path. Low plan sprawl (rates cluster into tiers). |
| C — plan-per-Specialist | $0 | ~same as B | one per Specialist | Broken for our model — one plan can't hold two prices, fails when a Specialist is charged different rates per org. Rejected. |
| D — plan-per-(org × Specialist) | $0 | B + plan-lifecycle cleanup | one per assignment | Highest sprawl; most code. Rejected in favor of B. |
Proposed decision: dual-strategy adapter
Ship both, switched by config — default to the free path, retain premium:
LAGO_PRICING_STRATEGY = "plan_per_rate" (default, free) | "plan_overrides" (premium-licensed)
plan_per_rate(default):LagoProvider.ensureRatePlan(cents, currency)lazily creates/reuses aspecialist_<currency>_<cents>plan, subscribes to it; a rate change becomes a Lago upgrade/downgrade (plan switch). No license.plan_overrides(premium): the existing one-generic-plan + override code, retained intact. Activates when the flag is set and the instance is licensed; otherwise it 403→sync_failed(loud startup warning emitted).
Why dual-strategy: ships on free Lago now, makes premium a one-env-var upgrade later, and throws away zero code. Contained entirely to the provider adapter — domain entities, commands, queries, mirror, controllers, and assignment hooks are unchanged.
What's preserved either way (verified against Lago docs)
- Consolidation — one invoice per org, one itemized line (
fee) per Specialist. Independent of pricing strategy (groups by customer + billing day). - Proration — free under plan-per-rate, all three cases: mid-month assign (first-period pro-rata), mid-month unassign (last-period pro-rata = our arrears "days used" final invoice), and rate change (Lago's native upgrade/downgrade, auto-prorated — not premium; verified no badge on the upgrades/downgrades doc). Arguably a cleaner, better-documented proration path than the override approach.
- Arrears, anchor-day alignment, the fail-open +
sync_failed+ retry machinery, the webhook mirror — all unchanged.
Change over time
An org gets cheaper
Two distinct domain events, routed differently by sync-specialist-subscription.command.ts:
- Same Specialist, lower rate (re-rate / downgrade):
updateSubscriptionPrice→ensureRatePlan(newRate)→ re-issue with the sameexternal_idand the cheaperplan_code. Lago treats it as a native downgrade and auto-prorates the delta (free, not premium).subscription_atstays pinned toanchorDayso the sub remains consolidated. Note: Lago typically applies upgrades immediately but schedules downgrades to the next period — so "cheaper" likely lands next cycle, consistent with our bill-in-arrears stance. (Probe C confirms timing; the code path is correct either way.) - Org swaps Specialist A → B (B cheaper): different assignments = different subscriptions. Unassign A →
cancelSubscription→ final prorated arrears invoice for A's days used. Assign B →createSubscription→ new sub on B's cheaper plan, first period prorated. Both share the org customer + anchor → still one consolidated invoice.
All proration is Lago-native in both cases — we compute none of it.
Evolving plan-per-rate → plan_overrides (when premium is enabled)
Representation is per-subscription, sticky, and lives entirely in Lago — our DB mirror stores externalSubscriptionId = assignmentId regardless of strategy, so this is not a schema migration (no expand-contract on our side). Flipping the flag changes only the write path; it does not auto-convert existing subs.
- Lazy (recommended, no extra code): flip the flag. New assignments + any rate-change on an existing per-rate sub (re-issued at the same external_id) naturally rewrite onto shared-plan + override. Subs that never re-rate stay on their per-rate plan (harmless); old per-rate plans are orphaned but reusable.
- Active backfill (only for uniformity now): one-off idempotent command re-issuing each active sub at its current rate (zero price delta) while strategy =
plan_overrides. - Prereqs before flipping: (1) premium license actually active — else every create →
403 feature_unavailable→sync_failed(startup warning guards this); (2) sharedspecialist_monthlyplan exists (bootstrap creates it); (3) re-confirm Probe A consolidation under overrides (holds — consolidation is strategy-independent). - ⚠ Reverse direction is an open risk: if a premium license lapses, whether Lago keeps billing already-created override subs or starts rejecting them is unverified — confirm before depending on premium in prod.
Tradeoffs / caveats
- Rate change = two invoice events under plan-per-rate (immediate prorated old + period-end prorated new) — standard Lago upgrade/downgrade behavior.
- The flag is a deploy-time choice, not a hot toggle on live data. Subscriptions live on the representation they were created with; switching strategy with existing subscriptions would be a deliberate migration, not a flip.
- Plan-per-rate currency on rate-change currently defaults to USD (flagged
VERIFY(0.3)); thread currency through if multi-currency billing is ever needed. - Shared Lago org — h.work shares the
lago-sharedLago organization with the recruiter product. Webhooks/customers/invoices are org-scoped. For production, h.work should likely get its own Lago organization (clean webhook routing). Tracked separately.
Open questions for discussion
- Do we want a Lago premium license (keep the elegant override design) or accept plan-per-rate as the operating model? (Default ships plan-per-rate; premium is a flag flip later.)
- Production Lago org — provision a dedicated h.work Lago organization vs continue sharing
lago-shared? - Webhook signature — the dev endpoint is configured
signature_algo: hmac; confirm the HMAC key/format against a live delivery (adapter accepts base64/base64url/hex, fail-closed). - Multi-currency — USD-only assumed today; revisit if non-USD clients arrive.
Implementation status
Dual-strategy adapter implemented (this PR): LAGO_PRICING_STRATEGY config, ensureRatePlan, both branches in createSubscription/updateSubscriptionPrice, startup warning for the premium path, tests covering both strategies. 111 billing tests green. Default plan_per_rate works on the free dev Lago today.