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 acceptamount_cents: 0as 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_centsis 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. Ifplan_overridesis 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 werepay_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
feesarray of length 2 (one per subscription), each fee carrying itsamount_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 pinsLagoProvider.normalizeInvoice'slineItemsmapping.
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):Record the exact parameter name/location Lago accepts for a back-dated termination (resolves thecurl -sS -X DELETE -H "$H_AUTH" "$LAGO_URL/api/v1/subscriptions/<id>?cancellation_date=2026-05-19" ...VERIFY(0.3)inLagoProvider.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:
- Override update โ
POST /api/v1/subscriptionsagain with the sameexternal_id(spike-ratechange) and a newplan_overrides.amount_cents(Lago upgrade/downgrade semantics). Inspect whether Lago prorates the delta and from what date. - If (1) isn't supported, terminate + recreate โ terminate
spike-ratechange, then recreate it with the sameexternal_idand 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.sitebucket, or a temporary/billing/webhooks/lagowith logging). - Record from a real
invoice.created(andinvoice.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. โ resolvesLagoProvider.verifySignature(currently fail-closed). - The top-level envelope:
webhook_typevalue strings (confirminvoice.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 (confirmpayment_succeeded_atโ NOTpayment_dispute_lost_at),file_url, and thefees[]shape from Probe A.
- The exact signature header name (e.g.
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.