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.