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.
77
96%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Medium
Suggest reviewing before use
Guidance for working with openmeter/billing/worker/subscriptionsync/.
subscriptionsync is the bridge between the subscription domain and billing.
models.NamespacedID) or an already-expanded 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 # 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)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.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:
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 patchesDeleted-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.
service/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:
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 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.
Charge-backed mutation notes:
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 unblockedKeep these separate:
Invoice sync:
GetExpectedLine()Charge sync:
credit_then_invoice shrink and extend as native charge patchesDo 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).
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:
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(...) — calls SyncByID for the subscription under reconciliationAll(...) — 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/billingIf the change touches charges provisioning behavior, also verify the relevant openmeter/billing/charges/... packages or suites.
1cdc2cb
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.