| name | subscriptionsync |
| description | Work with the subscription sync bridge in `openmeter/billing/worker/subscriptionsync/...`. Use when modifying how subscription target state is reconciled into billing artifacts such as invoice lines, split-line groups, or charges; when changing persisted-state loading, reconciler patch routing, or subscription sync tests; and when reasoning about the bridge between subscription views and billing state. |
Subscription Sync
Guidance for working with openmeter/billing/worker/subscriptionsync/.
What This Package Is
subscriptionsync is the bridge between the subscription domain and billing.
- Input: either a subscription ID (
models.NamespacedID) or an already-expanded subscription.SubscriptionView
- Output: reconciled billing state
- invoice lines / split-line groups
- charges
- persisted sync state
It does not own subscription editing rules and it does not own billing primitives. It translates subscription target state into billing-side operations.
Package Layout
openmeter/billing/worker/subscriptionsync/
├── service/ # orchestration entrypoint used by the worker
│ ├── service.go # Service struct, Config, FeatureFlags, constructor
│ ├── sync.go # internal sync orchestration for SyncByID/SyncByView entrypoints
│ ├── ref.go # normalizes subscription ID vs view references
│ ├── reconcile.go # build persisted snapshot + target state + plan
│ ├── handlers.go # event handlers (HandleCancelledEvent, HandleInvoiceCreation)
│ ├── base_test.go # shared SuiteBase + SyncSuiteBase test harness
│ ├── sync_test.go # invoice-sync scenarios
│ ├── creditsonly_test.go # credit-only charge scenarios
│ ├── syncbillinganchor_test.go # billing anchor / alignment scenarios
│ ├── persistedstate/ # package-owned persisted snapshot abstractions
│ ├── targetstate/ # expected billing/charge target generation
│ └── reconciler/ # plan + apply layer
│ ├── reconciler.go # Reconciler interface, Plan/Apply, diffItem, filterInScopeLines
│ ├── patch.go # Patch interfaces, PatchCollection, patchCollectionRouter
│ ├── patchinvoice.go # invoicePatchCollectionBase (shared invoice patch helpers)
│ ├── patchinvoiceline.go # lineInvoicePatchCollection
│ ├── patchinvoicelinehierarchy.go # lineHierarchyPatchCollection
│ ├── patchcharge.go # chargePatchCollection base + newChargeIntentBaseFromTargetState
│ ├── patchchargeflatfee.go # flatFeeChargeCollection
│ ├── patchchargeusagebased.go # usageBasedChargeCollection
│ ├── patchhelpers.go # shared patch utilities
│ ├── prorate.go # semanticProrateDecision
│ ├── invoiceupdater/ # invoice line/group CRUD
│ └── chargeupdater/ # charge creation (or disabled no-op)
├── reconciler/ # periodic reconciliation (batch re-sync of subscriptions)
├── adapter/ # sync-state persistence (Ent-backed)
└── service.go # top-level interface/config (subscriptionsync.Service, subscriptionsync.Adapter)
Core Flow
The public sync entrypoints are defined by subscriptionsync.SyncService:
SyncByID(...) and SyncByIDAndInvoiceCustomer(...) fetch subscription state internally.
SyncByView(...) and SyncByViewAndInvoiceCustomer(...) use an already-expanded subscription view.
- The
AndInvoiceCustomer variants run the regular sync and then invoice pending lines for the subscription customer.
The internal orchestration lives in service/sync.go (synchronizeSubscription, synchronizeSubscriptionAndInvoiceCustomer) and normalizes the public input through subscriptionReferenceOrView from service/ref.go.
High-level flow:
- Load the subscription entity, including deleted subscriptions when the ID path is used.
- Load persisted sync state and persisted billing artifacts.
- Build target state from the subscription view, or an empty target state when the subscription is deleted.
- Reconcile target vs persisted.
- Apply billing patches.
- Persist sync state.
The important bridge boundaries are:
persistedstate: current billing-side reality relevant to this subscription
targetstate: expected billing-side reality derived from the subscription view
reconciler: diff between the two, expressed as backend-specific patches
Deleted-subscription cleanup uses the ID path. subscription.Service.GetView follows the normal non-deleted read path, so code that needs to reconcile deleted subscriptions must use List(... IncludeDeleted: true) or the sync service's ID entrypoints rather than calling GetView first.
Persisted State
service/persistedstate owns the billing-side read model used by sync.
Important rules:
- Do not leak raw
billing.LineOrHierarchy through the rest of subscription sync.
- Use
persistedstate.Item and the ItemAs... helpers instead.
Item.Type() is package-owned and distinguishes:
invoice.line
invoice.splitLineGroup
charge.flatFee
charge.usageBased
State contains:
ByUniqueID map[string]Item
Invoices
Charge loading notes:
- charges are optional and loaded only when
ChargesService is configured
- persisted charges are merged into
State.ByUniqueID; downstream sync code should not depend on a separate charge-only map
- persisted charge unique IDs must not overlap persisted invoice unique IDs
- credit-purchase charges tied to subscriptions are currently unsupported and should error
Invoice loading notes:
- only invoices referenced by the loaded persisted entities are fetched
- missing referenced invoices are treated as errors
Invoices.IsGatheringInvoice(...) returns an error for unknown IDs
Target State
service/targetstate converts a subscription view into expected billing/charge items.
Useful points:
BuildInput.SubscriptionView is a pointer. A nil view represents a deleted subscription and builds an empty target state so persisted billing artifacts are removed.
StateItem.IsBillable() is the first gate
StateItem.GetServicePeriod() is the diff-level period source
StateItem.GetExpectedLine() is invoice-specific rendering; keep direct billing assumptions isolated to places that really need invoice lines
For direct billing sync, target items that are not billable or do not render to an expected line are filtered before invoice diffing.
Semantic Prorate Decision
reconciler/prorate.go contains semanticProrateDecision(existing, target). For flat fee lines, it compares the existing per-unit amount and service period against the target. If either differs, it returns ShouldProrate: true with original/target amounts so the patch can update period and amount atomically. Non-flat-fee items always return ShouldProrate: false and fall through to the normal shrink/extend path.
The service-level FeatureFlags (EnableFlatFeeInAdvanceProrating, EnableFlatFeeInArrearsProrating) gate whether proration is applied during target state generation.
Invoicing path
Semantic proration (semanticProrateDecision) and empty-period filtering (patchhelpers.go, patchinvoicelinehierarchy.go) are invoice-only concerns — they run only when patches.GetBackendType() == BackendTypeInvoicing. Within invoiced lines, flat fee lines are excluded from empty-period filtering because their prorating implementation handles period changes.
Charges path
Charge-backed targets do not use invoice-style semantic proration. The charge stack materializes and prorates the charge state itself, so reconciliation only needs to detect create/delete/period-shape changes. In the charges path, the flat fee charge is responsible for handling the omission of empty lines.
Reconciler
service/reconciler is intentionally split into:
- semantic diffing
- patch collection routing
- backend-specific apply
Current shape:
- invoice patches and charge patches are separate
- routing is based on persisted item type for existing entities
- default routing for new target items uses subscription settlement mode and rate-card type
- apply order is intentionally invoice-first, charge-second during the backend transition; this is not atomic across backends, and partial apply is acceptable because the invoice backend is being deprecated in favor of charges
Important routing rules:
GetCollectionFor(persistedItem) routes by persisted item type (invoice line, split-line group, flat fee charge, usage-based charge)
ResolveDefaultCollection(targetItem) routes new items (no persisted counterpart) by subscription settlement mode + price type:
credit_only + flat price -> flatFeeChargeCollection
credit_only + unit price -> usageBasedChargeCollection
- everything else ->
lineCollection (invoice lines)
The filterInScopeLines function gates which target items enter reconciliation. It filters out non-billable items for every backend, and only invoicing-backed targets are additionally gated on GetExpectedLine(). This runs before any diffing so absent targets naturally produce delete/no-op outcomes.
Charge-backed mutation notes:
- charge collections support create/delete and period-shape changes
- most shrink and extend operations are emitted as an emulated replacement: delete the existing charge and create a replacement charge from the target state
- usage-based
credit_then_invoice shrink and extend are emitted as native charge patches, including the target invoice-at, so the usage-based state machine can preserve existing immutable invoice/ledger state and keep the invoice train unblocked
- charge-backed explicit prorate still returns unsupported; charge domains own their own proration/materialization behavior
Invoice vs Charge Semantics
Keep these separate:
-
Invoice sync:
- may need
GetExpectedLine()
- reasons about gathering vs standard invoices
- updates invoice lines and split-line groups
-
Charge sync:
- provisions charge intents directly
- does not go through invoice-line rendering
- supports create/delete and period-shape changes
- emits most shrink and extend operations as delete+create replacements
- emits usage-based
credit_then_invoice shrink and extend as native charge patches
- keeps explicit prorate unsupported because charges own their own proration logic
Do not force charge behavior through invoice abstractions.
Split-Line Groups
Split-line hierarchies are invoice-only persisted items.
Important detail:
- annotation semantics are taken from the last relevant child line
- hierarchy shrink logic must delete an emptied usage-based child instead of updating it to an empty non-billable window
If you see regressions around progressive billing cancellation, inspect the hierarchy shrink path first.
Testing Guidance
Main service tests live in:
service/sync_test.go for invoice-oriented scenarios
service/creditsonly_test.go for charge-oriented credit_only scenarios
service/syncbillinganchor_test.go for billing anchor / alignment scenarios
service/base_test.go for shared setup and helpers
Test suite hierarchy:
SuiteBase — base struct embedding billingtest.BaseSuite + billingtest.SubscriptionMixin. Handles service construction, namespace/customer/feature provisioning, and teardown.
SyncSuiteBase — extends SuiteBase with sync-specific helpers: gatheringInvoice(...), createSubscriptionFromPlanAt(...), expectLines(...), line matchers (recurringLineMatcher, oneTimeLineMatcher).
Use setupChargesService(config) on SuiteBase to rebuild the sync service with a charge-capable stack (replaces the default no-charges service).
When a billing sync test needs to exercise deleted-subscription cleanup, prefer the public ID entrypoint (SyncByID / SyncByIDAndInvoiceCustomer) so the service owns the IncludeDeleted lookup. Subscription-domain coverage for deleted scheduled replacements belongs under openmeter/subscription/service/sync_test.go; keep billing-package tests focused on billing artifacts.
For charge-backed sync tests:
- prefer
openmeter/billing/charges/testutils.NewMockHandlers()
- these mocks are intentionally minimal but valid enough for charge creation/advancement
- do not query charge tables directly from sync tests; use
Charges.ListCharges(...) with SubscriptionIDs and ChargeTypes filters to assert the end state through the public charges stack
- when asserting charge subscription phase IDs, derive the expected phase from the child unique reference ID and the loaded subscription view instead of hardcoding phase IDs in scenario data
Pattern for credit-only tests:
- construct plan with
SettlementMode: productcatalog.CreditOnlySettlementMode
- create subscription through the plan workflow
- sync to a future horizon
- assert charges by unique reference IDs and exact periods
Event Handlers
service/handlers.go contains two event-driven entrypoints:
HandleCancelledEvent: triggered on subscription cancellation. Syncs up to the subscription's ActiveTo time. Skips pre-sync invoice creation to avoid creating invoices that would immediately change.
HandleInvoiceCreation: triggered when a standard invoice is created. Finds affected subscriptions from the invoice lines and re-syncs each to backfill the gathering invoice.
Periodic Reconciler
reconciler/reconciler.go (the top-level reconciler package, not service/reconciler) is the batch reconciliation component. It periodically re-syncs subscriptions to catch missed events.
Key methods:
ListSubscriptions(...) — pages through active subscriptions with their sync states
ReconcileSubscription(...) — calls SyncByID for the subscription under reconciliation
All(...) — reconciles all eligible subscriptions, skipping those with no billables or whose NextSyncAfter is in the future (unless Force is set)
Common Refactor Rules
- Keep
persistedstate package-owned. It is the anti-corruption boundary.
- Keep
diffItem(...) driven by semantic periods and target items, not by rendered invoice-line details unless required.
- Prefer adding narrow helpers over passing raw billing union types around.
- When adding new billing backends, route them through the reconciler collections instead of branching ad hoc in
sync.go.
Verification
When changing this package, the usual verification commands are:
nix develop --impure .#ci -c go vet ./...
nix develop --impure .#ci -c make lint-go
nix develop --impure .#ci -c env POSTGRES_HOST=127.0.0.1 go test -count=1 -tags dynamic ./openmeter/billing/worker/subscriptionsync/...
nix develop --impure .#ci -c env POSTGRES_HOST=127.0.0.1 go test -count=1 -tags dynamic ./test/billing
If the change touches charges provisioning behavior, also verify the relevant openmeter/billing/charges/... packages or suites.