Preflight Checklist
OpenMeter Version
10f948f17
Expected Behavior
Expected vs actual
Step | Expected | Actual
-- | -- | --
Subscribe + first sync | Charges + gathering mirrors created | ✓ Works
Cancel immediate (OM API only) | Worker trims charge periods to activeTo, may cut partial invoice if billable | Worker fails on load; gathering stale; no invoice
Cancel + ops force invoice (progressiveBillingOverride) | Immediate issued invoice | Works (separate code path; not used in this repro)
What we are NOT doing
- Not using Synqed ops cancel (which calls
ForceInvoiceCustomer with progressiveBillingOverride: true) - Not manually calling
POST /api/v1/billing/invoices/invoice or /advance - No custom renewal-grant code deployed on staging
This reproduces on stock OpenMeter with the documented credit_then_invoice config.
Impact
- Any subscription with
enable_credit_then_invoice: true breaks on the second billing-worker sync (cancel, renewal, cron subscription-sync, plan edit). - Cancel sync never completes → gathering invoices stay stale → native partial-period invoicing at cancel does not work.
- DLQ fills with
subscription.cancelled events. - Workaround today: ops force invoice on cancel (bypasses sync) — not acceptable for production customer flows.
Actual Behavior
Summary
With enable_credit_then_invoice: true, the first subscription sync succeeds (charges + gathering mirror lines). Any second sync (cancel, cron, plan change) fails in persistedstate.Loader.LoadForSubscription because the same unique_reference_id exists in both billing_invoice_lines and charges. Cancel events retry 9× → DLQ; gathering invoice never trims to activeTo.
Error
synchronize subscription: duplicate unique id across persisted lines and charges:
01KSA6NP21H4EYBP2KB4QNZKC6/default/synqed_feature_premium/v[0]/period[0]
Event: io.openmeter.subscription.v1.subscription.cancelled
Repro
- Enable both credit flags above.
- Customer + $10 grant +
credit_then_invoice subscription (2 usage rate cards). - Wait for initial billing-worker sync (succeeds).
- Optional: ingest usage via
POST /api/v1/events (TOOL_CALL_STANDARD / TOOL_CALL_PREMIUM). - Cancel OpenMeter only (no force invoice):
POST /api/v1/subscriptions/{id}/cancel
- Check billing-worker logs.
Example (staging)
|
-- | --
Subscription | 01KSA6NP21H4EYBP2KB4QNZKC6
Customer | 01KSA61A2R80VXA83SB658GPNQ
Subject | 9520ea79-2aaa-4310-8b53-6e24d46e743c
Cancel | 2026-05-23T10:46:11Z
After cancel (OM-only): sub → inactive ✓; gathering invoice still gathering, period unchanged, total: 0; no issued invoice; worker fails with error above.
Also reproduced on sub 01KSA3MVZSVPT731AD8H95CH5T at 2026-05-23T09:51:53Z.
Root cause (our analysis)
- With
enable_credit_then_invoice: true, sync creates charges (usageBasedChargeCollection), not classic invoice lines. - Charge
Create also writes a gathering mirror line with the same ChildUniqueReferenceID as Intent.UniqueReferenceID and Engine: charge_usagebased (openmeter/billing/charges/usagebased/service/create.go, charges/service/create.go). - On the next sync,
loader.go loads invoice lines and charges, then errors at lines 84–87 when the same ID appears in both maps. - First sync OK (empty persisted state). Every later sync fails.
Charge-backed gathering lines look like mirrors, not separate sync artifacts. Loader should skip them when loading charge state (e.g. ChargeID != nil or Engine.IsCharge()).
Steps To Reproduce
Reproduction
Enable credits.enabled: true and credits.enable_credit_then_invoice: true.
Create a customer with subjectKeys = org UUID.
Issue a credit grant (e.g. $10).
Create a subscription on a credit_then_invoice plan with two usage-based rate cards (e.g. synqed_feature_standard @ $2/unit, synqed_feature_premium @ $1/unit).
Wait for billing-worker initial sync (~seconds) — succeeds; gathering invoice + charges are created.
Optionally ingest usage events via POST /api/v1/events (CloudEvents with TOOL_CALL_STANDARD / TOOL_CALL_PREMIUM).
Cancel immediately via OpenMeter only (no force invoice):
POST /api/v1/subscriptions/{subscriptionId}/cancel
{"timing": "immediate"}
Observe billing-worker logs.
Observed error
synchronize subscription: duplicate unique id across persisted lines and charges:
01KSA6NP21H4EYBP2KB4QNZKC6/default/synqed_feature_premium/v[0]/period[0]
Full log context:
{
"ts": "2026-05-23T10:46:11Z",
"level": "WARN",
"caller": "openmeter/watermill/router/logger.go:40",
"msg": "Error occurred, retrying",
"service.name": "openmeter-billing-worker",
"service.version": "10f948f17",
"retry_no": 6,
"max_retries": 9,
"error.message": "synchronize subscription: duplicate unique id across persisted lines and charges: 01KSA6NP21H4EYBP2KB4QNZKC6/default/synqed_feature_premium/v[0]/period[0]"
}
After 9 retries → Failed to process message, message is going to DLQ on topic io.openmeter.subscription.v1.subscription.cancelled.
Additional Information
No response
Preflight Checklist
OpenMeter Version
10f948f17
Expected Behavior
Expected vs actual
What we are NOT doing
ForceInvoiceCustomerwithprogressiveBillingOverride: true)POST /api/v1/billing/invoices/invoiceor/advanceThis reproduces on stock OpenMeter with the documented
credit_then_invoiceconfig.Impact
enable_credit_then_invoice: truebreaks on the second billing-worker sync (cancel, renewal, cron subscription-sync, plan edit).subscription.cancelledevents.Actual Behavior
Summary
With
enable_credit_then_invoice: true, the first subscription sync succeeds (charges + gathering mirror lines). Any second sync (cancel, cron, plan change) fails inpersistedstate.Loader.LoadForSubscriptionbecause the sameunique_reference_idexists in bothbilling_invoice_linesandcharges. Cancel events retry 9× → DLQ; gathering invoice never trims toactiveTo.Error
Event:
io.openmeter.subscription.v1.subscription.cancelledRepro
credit_then_invoicesubscription (2 usage rate cards).POST /api/v1/events(TOOL_CALL_STANDARD/TOOL_CALL_PREMIUM).Example (staging)
After cancel (OM-only): sub →
inactive✓; gathering invoice stillgathering, period unchanged,total: 0; no issued invoice; worker fails with error above.Also reproduced on sub
01KSA3MVZSVPT731AD8H95CH5Tat2026-05-23T09:51:53Z.Root cause (our analysis)
enable_credit_then_invoice: true, sync creates charges (usageBasedChargeCollection), not classic invoice lines.Createalso writes a gathering mirror line with the sameChildUniqueReferenceIDasIntent.UniqueReferenceIDandEngine: charge_usagebased(openmeter/billing/charges/usagebased/service/create.go,charges/service/create.go).loader.goloads invoice lines and charges, then errors at lines 84–87 when the same ID appears in both maps.Charge-backed gathering lines look like mirrors, not separate sync artifacts. Loader should skip them when loading charge state (e.g.
ChargeID != nilorEngine.IsCharge()).Steps To Reproduce
Reproduction
Enable credits.enabled: true and credits.enable_credit_then_invoice: true.
Create a customer with subjectKeys = org UUID.
Issue a credit grant (e.g. $10).
Create a subscription on a credit_then_invoice plan with two usage-based rate cards (e.g. synqed_feature_standard @ $2/unit, synqed_feature_premium @ $1/unit).
Wait for billing-worker initial sync (~seconds) — succeeds; gathering invoice + charges are created.
Optionally ingest usage events via POST /api/v1/events (CloudEvents with TOOL_CALL_STANDARD / TOOL_CALL_PREMIUM).
Cancel immediately via OpenMeter only (no force invoice):
POST /api/v1/subscriptions/{subscriptionId}/cancel
{"timing": "immediate"}
Observe billing-worker logs.
Observed error
synchronize subscription: duplicate unique id across persisted lines and charges:
01KSA6NP21H4EYBP2KB4QNZKC6/default/synqed_feature_premium/v[0]/period[0]
Full log context:
{
"ts": "2026-05-23T10:46:11Z",
"level": "WARN",
"caller": "openmeter/watermill/router/logger.go:40",
"msg": "Error occurred, retrying",
"service.name": "openmeter-billing-worker",
"service.version": "10f948f17",
"retry_no": 6,
"max_retries": 9,
"error.message": "synchronize subscription: duplicate unique id across persisted lines and charges: 01KSA6NP21H4EYBP2KB4QNZKC6/default/synqed_feature_premium/v[0]/period[0]"
}
After 9 retries → Failed to process message, message is going to DLQ on topic io.openmeter.subscription.v1.subscription.cancelled.
Additional Information
No response