Skip to main content

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_rate as the default. Free dev Lago runs the system end-to-end today. The premium-license decision (Option A vs B in OPEN_DECISIONS.md OD-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:

  • createSubscription with plan_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โ€‹

OptionCostCode changeLago plans createdNotes
A โ€” Lago premium licenserecurring $~none (current override code works once licensed)1 genericKeeps the original design. Requires a paid Lago entitlement on the (shared) instance.
B โ€” plan-per-rate (recommended default)$0adapter-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 Bone per SpecialistBroken 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)$0B + plan-lifecycle cleanupone per assignmentHighest 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 a specialist_<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 same external_id and the cheaper plan_code. Lago treats it as a native downgrade and auto-prorates the delta (free, not premium). subscription_at stays pinned to anchorDay so 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) shared specialist_monthly plan 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-shared Lago 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โ€‹

  1. 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.)
  2. Production Lago org โ€” provision a dedicated h.work Lago organization vs continue sharing lago-shared?
  3. 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).
  4. 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.