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.
Guidance for working with openmeter/billing/worker/subscriptionsync/.
subscriptionsync is the bridge between the subscription domain and billing.
subscription.SubscriptionViewIt does not own subscription editing rules and it does not own billing primitives. It translates subscription target state into billing-side operations.
openmeter/billing/worker/subscriptionsync/
├── service/ # orchestration entrypoint used by the worker
│ ├── service.go # Service struct, Config, FeatureFlags, constructor
│ ├── sync.go # SynchronizeSubscription + SynchronizeSubscriptionAndInvoiceCustomer
│ ├── 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)
Service.SynchronizeSubscription(...) in service/sync.go is the main entrypoint.
High-level flow:
The important bridge boundaries are:
persistedstate: current billing-side reality relevant to this subscriptiontargetstate: expected billing-side reality derived from the subscription viewreconciler: diff between the two, expressed as backend-specific patchesservice/persistedstate owns the billing-side read model used by sync.
Important rules:
billing.LineOrHierarchy through the rest of subscription sync.persistedstate.Item and the ItemAs... helpers instead.Item.Type() is package-owned and distinguishes:
invoice.lineinvoice.splitLineGroupcharge.flatFeecharge.usageBasedState contains:
ByUniqueID map[string]ItemInvoicesCharge loading notes:
ChargesService is configuredState.ByUniqueID; downstream sync code should not depend on a separate charge-only mapInvoice loading notes:
Invoices.IsGatheringInvoice(...) returns an error for unknown IDsservice/targetstate converts a subscription view into expected billing/charge items.
Useful points:
StateItem.IsBillable() is the first gateStateItem.GetServicePeriod() is the diff-level period sourceStateItem.GetExpectedLine() is invoice-specific rendering; keep direct billing assumptions isolated to places that really need invoice linesFor direct billing sync, target items that are not billable or do not render to an expected line are filtered before invoice diffing.
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.
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.
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.
service/reconciler is intentionally split into:
Current shape:
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 -> flatFeeChargeCollectioncredit_only + unit price -> usageBasedChargeCollectionlineCollection (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.
Current charge limitations:
This is intentional. If a test expects credit-only cancellation to fail on delete, that is current behavior, not a bug in the test.
Keep these separate:
Invoice sync:
GetExpectedLine()Charge sync:
diffItem(...); charges own their own proration logicDo not force charge behavior through invoice abstractions.
Split-line hierarchies are invoice-only persisted items.
Important detail:
If you see regressions around progressive billing cancellation, inspect the hierarchy shrink path first.
Main service tests live in:
service/sync_test.go for invoice-oriented scenariosservice/creditsonly_test.go for charge-oriented credit_only scenariosservice/syncbillinganchor_test.go for billing anchor / alignment scenariosservice/base_test.go for shared setup and helpersTest 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).
For charge-backed sync tests:
openmeter/billing/charges/testutils.NewMockHandlers()Charges.ListCharges(...) with SubscriptionIDs and ChargeTypes filters to assert the end state through the public charges stackPattern for credit-only tests:
SettlementMode: productcatalog.CreditOnlySettlementModeservice/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.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 statesReconcileSubscription(...) — fetches subscription view and calls SynchronizeSubscriptionAll(...) — reconciles all eligible subscriptions, skipping those with no billables or whose NextSyncAfter is in the future (unless Force is set)persistedstate package-owned. It is the anti-corruption boundary.diffItem(...) driven by semantic periods and target items, not by rendered invoice-line details unless required.sync.go.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.