Skip to main content

Lago Billing Spike (gates Task 0.3 of the billing plan)

โœ… RESOLVED / ๐Ÿšข SHIPPED โ€” 2026-05. Probe A (consolidation) passed; Lago issues one consolidated invoice per cycle per customer. Billing then shipped per #1069 (LagoProvider), #1105 (dual-strategy), #1155 (FE + webhook), #1016 (Phase-7 teardown); #1165 was closed-unmerged and superseded by #1155 โ€” see docs/decisions/2026-05-29-lago-pricing-strategy.md. This spike document is retained as the source of the consolidation finding that unblocked Task 0.3.

Purpose: Verify, against the existing Railway-hosted Lago, the load-bearing behaviors the provider-leveraged billing design (spec / plan) assumes. Every VERIFY(0.3) marker in LagoProvider resolves to a finding here. This spike is a hard prerequisite for writing the adapter.

Decision gate: If Probe A (consolidation) fails โ€” i.e. Lago issues separate invoices per subscription rather than one grouped invoice โ€” stop and re-open the design (fallback: one subscription per org with one charge per Specialist). Everything else is "adjust the adapter," not "redesign."

This is a throwaway spike: create a disposable org/customer + plan, probe, record findings in the PR, then delete the test data. Do not run against production billing data.


Setupโ€‹

# Point at the Railway Lago instance (use a staging Lago project if one exists).
export LAGO_URL="https://<your-railway-lago>.up.railway.app" # = LAGO_API_URL
export LAGO_KEY="<lago api key>" # = LAGO_API_KEY
export H_AUTH="Authorization: Bearer $LAGO_KEY"
export H_JSON="Content-Type: application/json"
export CUST="spike-org-$(date +%s)" # disposable external_customer_id

Quick connectivity check:

curl -sS -H "$H_AUTH" "$LAGO_URL/api/v1/plans" | head -c 300; echo
# Expect: a JSON object with a "plans" array (even if empty). 401 โ†’ bad key/URL.

S1 โ€” Create the generic "Specialist Monthly" plan (arrears)โ€‹

curl -sS -X POST -H "$H_AUTH" -H "$H_JSON" "$LAGO_URL/api/v1/plans" -d '{
"plan": {
"name": "Specialist Monthly (spike)",
"code": "specialist_monthly_spike",
"interval": "monthly",
"amount_cents": 0,
"amount_currency": "USD",
"pay_in_advance": false
}
}' | python3 -m json.tool
  • Confirm: plan created with pay_in_advance: false (arrears). Record: does Lago accept amount_cents: 0 as a base that a per-subscription override can raise? (If not, the per-Specialist override model needs rethinking โ€” note it.)

S2 โ€” Create a disposable customerโ€‹

curl -sS -X POST -H "$H_AUTH" -H "$H_JSON" "$LAGO_URL/api/v1/customers" -d "{
\"customer\": { \"external_id\": \"$CUST\", \"name\": \"Spike Org\", \"email\": \"spike@example.com\" }
}" | python3 -m json.tool

Probe A โ€” Invoice consolidation โŸต THE GATING CHECKโ€‹

Question: Does Lago group an org's same-cycle subscriptions into one invoice with one fee per subscription?

Pick a subscription_at whose first monthly anniversary has ALREADY elapsed before today, so a complete cycle exists to bill (with arrears there is nothing to bill until a cycle closes). Set it ~6 weeks back, same day for both subs (the shared org anchor):

export ANCHOR_AT="$(date -u -d '6 weeks ago' +%Y-%m-%dT00:00:00Z 2>/dev/null || date -u -v-6w +%Y-%m-%dT00:00:00Z)"
echo "anchor: $ANCHOR_AT" # e.g. ~2026-04-15; its anniversary (~2026-05-15) has passed โ†’ one closed cycle
# Two Specialists, same customer, same anniversary day (the org anchor).
curl -sS -X POST -H "$H_AUTH" -H "$H_JSON" "$LAGO_URL/api/v1/subscriptions" -d "{
\"subscription\": {
\"external_customer_id\": \"$CUST\",
\"plan_code\": \"specialist_monthly_spike\",
\"external_id\": \"spike-bob\",
\"billing_time\": \"anniversary\",
\"subscription_at\": \"$ANCHOR_AT\",
\"plan_overrides\": { \"amount_cents\": 360000 }
}
}" | python3 -m json.tool

curl -sS -X POST -H "$H_AUTH" -H "$H_JSON" "$LAGO_URL/api/v1/subscriptions" -d "{
\"subscription\": {
\"external_customer_id\": \"$CUST\",
\"plan_code\": \"specialist_monthly_spike\",
\"external_id\": \"spike-chris\",
\"billing_time\": \"anniversary\",
\"subscription_at\": \"$ANCHOR_AT\",
\"plan_overrides\": { \"amount_cents\": 500000 }
}
}" | python3 -m json.tool
  • โš  Confirm first: that plan_overrides.amount_cents is accepted and actually overrides the rate (inspect the returned subscription, or the eventual fee amounts). This is core โ€” the entire per-Specialist-rate model depends on it. If plan_overrides is unsupported in this Lago version, record the correct override mechanism (e.g. a per-customer plan, or a charge-based model).
  • Inspect invoices immediately after creation, BEFORE triggering anything (GET โ€ฆ/invoices?external_customer_id=$CUST) and note the baseline count. With arrears (pay_in_advance: false) expect zero initial invoices. โš  If the plan were pay_in_advance: true, Lago issues an initial invoice on creation (possibly one per subscription) โ€” that is NOT the consolidation result; distinguish those creation invoices from the cycle invoice produced after the elapsed anniversary.

Trigger a billing run for the elapsed cycle (the practical crux โ€” pick whichever the Railway instance supports; record which works):

  • Option 1 (clock/job): trigger Lago's billing job โ€” Clock::SubscriptionsBillerJob (run via the Lago worker shell / a Railway one-off command), or Lago's dev "time-travel" clock if enabled.
  • Option 2 (wait for the tick): because $ANCHOR_AT's anniversary has already passed, the scheduler bills the closed cycle on its next run; re-poll invoices until one appears.
  • Option 3: consult the Railway Lago's docs/console for a "force billing" affordance.

Inspect the invoice(s):

curl -sS -H "$H_AUTH" "$LAGO_URL/api/v1/invoices?external_customer_id=$CUST" | python3 -m json.tool
  • โœ… PASS: the cycle invoice (the one beyond any baseline noted above) is a single invoice with a fees array of length 2 (one per subscription), each fee carrying its amount_cents, from_date/to_date, and a subscription reference.
  • โŒ FAIL: two separate invoices โ†’ STOP, re-open design (see decision gate).
  • Record: the exact fees[] shape โ€” the field that ties a fee back to a subscription (external_subscription_id? subscription.external_id? lago_subscription_id?) and where the Specialist-facing label lives (item.name?). This pins LagoProvider.normalizeInvoice's lineItems mapping.

Probe B โ€” Calendar-day proration + arrears (fast path, via termination)โ€‹

Terminating a subscription issues a final invoice immediately (in arrears), so this verifies proration + arrears + gives a real invoice/webhook without waiting a cycle.

# Fresh sub starting at a month boundary, known rate.
curl -sS -X POST -H "$H_AUTH" -H "$H_JSON" "$LAGO_URL/api/v1/subscriptions" -d "{
\"subscription\": {
\"external_customer_id\": \"$CUST\",
\"plan_code\": \"specialist_monthly_spike\",
\"external_id\": \"spike-prorate\",
\"billing_time\": \"anniversary\",
\"subscription_at\": \"2026-05-01T00:00:00Z\",
\"plan_overrides\": { \"amount_cents\": 310000 }
}
}" | python3 -m json.tool

# Terminate mid-month โ†’ expect a final prorated fee for days used.
curl -sS -X DELETE -H "$H_AUTH" "$LAGO_URL/api/v1/subscriptions/spike-prorate" | python3 -m json.tool

curl -sS -H "$H_AUTH" "$LAGO_URL/api/v1/invoices?external_customer_id=$CUST" | python3 -m json.tool
  • Confirm: the final fee โ‰ˆ 3100.00 ร— daysUsed รท daysInMay(31). If terminated on, say, day 19 โ†’ ~3100 ร— 19/31 โ‰ˆ $1900. Record the exact denominator Lago uses (actual days in month vs a fixed 30) and which proration setting produced it.
  • Confirm arrears: the charge is for days already used (not a future period) and there is no advance charge to refund.
  • โš  cancellation_date: re-run a termination passing a past date and confirm Lago honors it (the design passes the original unassign date on retries):
    curl -sS -X DELETE -H "$H_AUTH" "$LAGO_URL/api/v1/subscriptions/<id>?cancellation_date=2026-05-19" ...
    Record the exact parameter name/location Lago accepts for a back-dated termination (resolves the VERIFY(0.3) in LagoProvider.cancelSubscription).

Probe C โ€” Rate change mechanismโ€‹

Question: How do you change a running subscription's amount, with proration of the delta?

Use a dedicated subscription external_id=spike-ratechange (create one first, like Probe B) so cleanup is deterministic. Try, in order, and record which Lago supports + how it prorates:

  1. Override update โ€” POST /api/v1/subscriptions again with the same external_id (spike-ratechange) and a new plan_overrides.amount_cents (Lago upgrade/downgrade semantics). Inspect whether Lago prorates the delta and from what date.
  2. If (1) isn't supported, terminate + recreate โ€” terminate spike-ratechange, then recreate it with the same external_id and the new override; confirm Lago prorates the closing + opening fees. (Reusing the id keeps teardown simple.)

Record: the chosen mechanism โ†’ resolves the VERIFY(0.3) in LagoProvider.updateSubscriptionPrice. Confirm the proration start date is controllable (the design passes the original rate-change date on retries).


Probe D โ€” Webhook payload + signatureโ€‹

Probe A/B already triggered invoice.created (and a payment event once Stripe PSP is wired). Capture a real delivery.

  • In the Lago org settings, ensure a webhook endpoint points at a capture URL you control (e.g. a webhook.site bucket, or a temporary /billing/webhooks/lago with logging).
  • Record from a real invoice.created (and invoice.payment_status_updated) delivery:
    • The exact signature header name (e.g. X-Lago-Signature?).
    • The verification scheme โ€” Lago signs with JWT (RS256); fetch the instance's public key and verify, OR HMAC with the shared secret. Record the endpoint that serves the public key (e.g. GET /api/v1/webhooks/public_key / json_public_key) and a working verification snippet. โ†’ resolves LagoProvider.verifySignature (currently fail-closed).
    • The top-level envelope: webhook_type value strings (confirm invoice.created, invoice.payment_status_updated, subscription.terminated).
    • Invoice field names used by normalizeInvoice: lago_id, status, payment_status, total_amount_cents, currency, external_customer_id, from_date/to_date, issuing_date, the payment-success timestamp (confirm payment_succeeded_at โ€” NOT payment_dispute_lost_at), file_url, and the fees[] shape from Probe A.

Findings template (paste into the PR / plan Task 0.3)โ€‹

LAGO SPIKE FINDINGS (date, Lago version: ____)
A. Consolidation: PASS / FAIL โ€” one invoice w/ N fees? ____
feeโ†’subscription field: ____ specialist label field: ____
billing trigger that worked: ____
B. Proration: denominator = actual days-in-month? ____ setting: ____
Arrears confirmed: ____ back-dated cancellation_date param: ____
C. Rate change: mechanism = override-update / terminate+recreate ____
proration start controllable: ____
D. Webhook: signature header = ____ scheme = JWT(RS256)/HMAC ____
public-key endpoint = ____ webhook_type strings = ____
payment-success field = ____ plan_overrides.amount_cents supported = ____
GATE: proceed to LagoProvider? YES / NO (if A failed โ†’ re-open design)

Teardownโ€‹

for sub in spike-bob spike-chris spike-prorate spike-ratechange; do
curl -sS -X DELETE -H "$H_AUTH" "$LAGO_URL/api/v1/subscriptions/$sub" >/dev/null 2>&1
done
# If Probe C's terminate+recreate used a different external_id, delete that too.
# Then delete the disposable customer ($CUST) + spike plan (specialist_monthly_spike)
# via the Lago console or their DELETE endpoints.

Note on accuracy: the exact Lago request/response field names above reflect Lago's documented REST API but may differ by version โ€” that imprecision is the point of the spike. Trust the live instance over this doc, and record deltas in the findings template.