Work with the OpenMeter billing package. Use this skill whenever touching invoice lifecycle, billing profiles, customer overrides, invoice line items, gathering invoices, standard invoices, the invoice state machine, billing validation issues, billing-subscription sync, the billing worker, invoice calculation, rating/pricing engine, or tax config on billing objects. Also use when writing or debugging billing integration tests (BaseSuite, SubscriptionMixin), billing adapter (Ent queries), billing HTTP handlers, or the subscription→billing sync algorithm. Trigger this skill for any file under `openmeter/billing/`, `openmeter/billing/worker/`, `openmeter/billing/service/`, `openmeter/billing/adapter/`, `openmeter/billing/rating/`, `test/billing/`, or `cmd/billing-worker/`.
Guidance for working with the OpenMeter billing package (openmeter/billing/).
The charges subpackage has its own /charges skill — use it when touching openmeter/billing/charges/. This skill covers everything else in billing.
openmeter/billing/ # Domain types + service/adapter interfaces (no business logic here)
openmeter/billing/service/ # Service implementation + invoice state machine
openmeter/billing/adapter/ # Ent ORM persistence layer
openmeter/billing/httpdriver/ # HTTP handlers
openmeter/billing/rating/ # Pricing calculation engine (tiered, graduated, flat, dynamic)
openmeter/billing/models/totals/ # Shared Totals struct
openmeter/billing/validators/ # Subscription/customer pre-action hook validators
openmeter/billing/worker/ # Watermill event handlers + cron jobs
openmeter/billing/worker/subscriptionsync/ # Subscription→billing sync algorithm
openmeter/billing/worker/advance/ # Batch auto-advance cron
openmeter/billing/worker/collect/ # Gathering invoice collection cron
openmeter/billing/worker/asyncadvance/ # Event-driven advance handler
test/billing/ # Shared test suite base (BaseSuite, SubscriptionMixin)
Invoice, InvoiceLine, and Charge all use the same private discriminated union pattern:
// Private type tag, private concrete pointer fields
type Invoice struct { t InvoiceType; std *StandardInvoice; gathering *GatheringInvoice }
// Always construct via generic constructor
inv := billing.NewInvoice[billing.StandardInvoice](std)
// Access via typed methods (return value + error)
std, err := inv.AsStandardInvoice()
gi, err := inv.AsGatheringInvoice()
Never construct Invoice{} directly. The same pattern applies to InvoiceLine.
StandardLine.DBState *StandardLine stores the version as loaded from the DB. SaveDBSnapshot() is called automatically by mapStandardInvoiceLinesFromDB after every DB read — it deep-clones the freshly-mapped line into DBState before the service layer touches it.
The adapter's diffInvoiceLines then compares each line's current state against its DBState using auto-generated Equal methods (deriveEqualLineBase, deriveEqualUsageBasedLine in billing/derived.gen.go). Lines with no DBState go into the create bucket; lines that differ go into the update bucket; identical lines are skipped.
Critical call ordering: SaveDBSnapshot() must already be called (by the mapper) before any service mutation. If you ever need to manually capture a snapshot mid-service (e.g. after building a new line before further mutation), call line.SaveDBSnapshot() at that point — calling it after mutation defeats the diff.
Adding a new field checklist (fields not visible to the diff engine are silently skipped on UPDATE):
StandardLineBase (preferred) or UsageBasedLinemake generate to regenerate derived.gen.go — the Equal() diff is auto-generated by goderive and won't see the new field until regeneratedmapStandardInvoiceLineWithoutReferences so DBState reflects the real DB valueSet/SetNillable call in the Create builder.UpdateMyField() in the UpsertItems ON CONFLICT clause — sql.ResolveWithNewValues() only covers non-nillable columns automaticallymo.Option on Lines CollectionStandardInvoice.Lines is StandardInvoiceLines, which wraps mo.Option[StandardLines]. Absent means not loaded/expanded; present-but-empty means loaded but no lines. Use .IsPresent() / .OrEmpty() carefully — do not confuse nil with empty.
DetailedLine and GatheringLine carry ChildUniqueReferenceID string for idempotent upserts. When recalculating pricing, new detailed lines (without IDs) are matched to existing DB rows via this field through StandardLine.DetailedLinesWithIDReuse(), avoiding unnecessary delete/re-create cycles.
Period: when the service was actually rendered (usage window)InvoiceAt: when the line should appear on an invoice (may be delayed)CollectionAt: when the invoice entered collection (drives due-date calculation)These are distinct fields and must not be conflated.
billing.Service is a composite interface of 10 sub-interfaces defined in service.go:
ProfileService, CustomerOverrideService, InvoiceLineService, SplitLineGroupService, InvoiceService, StandardInvoiceService, GatheringInvoiceService, SequenceService, InvoiceAppService, LockableService, ConfigService.
billing.Adapter mirrors this split but is closer to the DB. Implementation is in adapter/.
The billingservice.Service struct (in service/service.go) holds:
customerService, appService, ratingService, featureService, meterService, streamingConnector, publisherinvoiceCalculator (mockable in tests)standardInvoiceHooks []StandardInvoiceHook (mutable, registered at startup)ForegroundAdvancementStrategy runs the state machine synchronously (used in tests and the async-advance worker). QueuedAdvancementStrategy stops and queues async advancement (used in HTTP handlers). Controlled via ConfigService.WithAdvancementStrategy().
The billing worker binary uses ForegroundAdvancementStrategy in the async-advance handler; the HTTP handlers use QueuedAdvancementStrategy (emit an event, return fast).
Every invoice-mutating operation must call transactionForInvoiceManipulation which:
UpsertCustomerLock outside any transaction (advisory lock record)LockCustomerForUpdate inside the transaction (row-level lock)This serializes all concurrent invoice operations for the same customer. Never bypass this pattern when writing new service methods that modify invoices.
Defined in service/stdinvoicestate.go using github.com/qmuntal/stateless. State machine instances are pooled via sync.Pool.
Key states and flow:
DraftCreated
→ DraftWaitingForCollection (calculate invoice)
→ DraftCollecting (guard: isReadyForCollection, or TriggerSnapshotQuantities)
→ DraftUpdating / DraftValidating
→ DraftInvalid (critical validation issue; TriggerRetry → DraftValidating)
→ DraftSyncing (OnActive: syncDraftInvoice → app.UpsertStandardInvoice)
→ DraftSyncFailed (TriggerRetry → DraftValidating)
→ DraftManualApprovalNeeded (if !autoAdvance; TriggerApprove → DraftReadyToIssue)
→ DraftWaitingAutoApproval (if autoAdvance + shouldAutoAdvance → DraftReadyToIssue)
→ DraftReadyToIssue
→ IssuingSyncing (OnActive: finalizeInvoice → app.FinalizeStandardInvoice)
→ IssuingChargeBooking (OnActive: line-engine OnInvoiceIssued; TriggerFailed → IssuingChargeBookingFailed)
→ Issued
→ PaymentProcessingPending
→ PaymentProcessingBookingAuthorized (TriggerAuthorized; OnActive: line-engine OnPaymentAuthorized)
→ PaymentProcessingAuthorized
→ PaymentProcessingBookingAuthorizedAndSettled (TriggerPaid from pending; OnActive: OnPaymentAuthorized then OnPaymentSettled)
→ PaymentProcessingBookingSettled (TriggerPaid from authorized; OnActive: line-engine OnPaymentSettled)
→ PaymentProcessingFailed / PaymentProcessingActionRequired / Overdue / Uncollectible / Voided
→ Paid
DeleteInProgress → DeleteSyncing → Deleted (TriggerFailed → DeleteFailed)
Key guards:
noCriticalValidationErrors: blocks state transitions when any ValidationIssue with Severity=critical existsshouldAutoAdvance: checks DraftUntil <= now (auto-approval window has elapsed)canIssuingSyncAdvance: polls InvoicingAppAsyncSyncer if the app implements async syncRetryable lifecycle hooks:
issuing.charge_booking, payment_processing.booking_authorized, payment_processing.booking_authorized_and_settled, payment_processing.booking_settled) instead of stable states like issued or paid.FireAndActivate can transition to the corresponding *_failed status and RetryInvoice can re-enter only that hook state.TriggerPaid via HandleInvoiceTrigger) must call AdvanceUntilStateStable after FireAndActivate. These triggers can land in intermediary booking states, not directly in the final stable state.payment_processing.booking_authorized_and_settled exists for direct pending -> paid provider flows. It preserves charge and ledger ordering by running authorization booking before settlement booking.payment_processing.authorized is a stable stop. TriggerAuthorized should stop there and must not auto-advance into settlement.Retrying stuck invoices: Use the existing RetryInvoice service method (service/invoice.go) rather than firing TriggerRetry directly. RetryInvoice first downgrades all critical validation issues to warnings before firing the trigger — without this step, noCriticalValidationErrors would immediately block re-advancement out of DraftValidating and the invoice would land back in DraftSyncFailed. For bulk retries, query with ExtendedStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusDraftSyncFailed} and call RetryInvoice per result.
The billing line-engine contract lives in openmeter/billing/lineengine.go. Billing owns the orchestration and grouping; each engine owns only the behavior for the lines assigned to its discriminator.
Registered engine types:
invoicing — the default billing-owned engine for generic invoice behaviorcharge_flatfeecharge_usagebasedcharge_creditpurchasebillingservice.engineRegistry (service/lineengine.go) stores engines by LineEngineType, validates explicit engine tags on lines, and defaults missing line engines to LineEngineTypeInvoice.
Grouping model:
groupGatheringLinesByEnginegroupStandardLinesByEngineHook sequence and ownership:
BuildStandardInvoiceLines
service/gatheringinvoicependinglines.goOnStandardInvoiceCreated
OnCollectionCompleted
InvoiceStateMachine.onCollectionCompletedOnInvoiceIssued
issuing.charge_bookingerror, not mutated linesOnPaymentAuthorized
payment_processing.booking_authorizederrorOnPaymentAuthorized + OnPaymentSettled
payment_processing.booking_authorized_and_settled when the payment app reports a direct paid outcome from payment_processing.pendingOnPaymentSettled
payment_processing.booking_settlederrorCalculateLines
Validation and failure contract:
StandardLineEventInput aliases and must pass .Validate()ValidationIssuesbilling.NewLineEngineValidationError(...)OnCollectionCompleted, billing uses MergeValidationIssues(...) with the engine component and keeps processing other engine groupsImportant behavior split:
OnStandardInvoiceCreated and OnCollectionCompleted are allowed to reshape line contentsOnInvoiceIssued, OnPaymentAuthorized, and OnPaymentSettled are for side effects only; they do not return lines back into billingApp trigger interaction:
TriggerPaid can land in intermediary booking states rather than directly in paidHandleInvoiceTrigger must therefore call AdvanceUntilStateStable after FireAndActivate, otherwise invoices remain stuck in payment_processing.booking_*When adding a new line engine or hook:
billing.LineEngine in openmeter/billing/lineengine.gogatheringinvoicependinglines.go or stdinvoicestate.goOnActive to a stable/final statetest/billing/lineengine_test.goCurrent test coverage pattern:
TestCollectionCompletedErrorsBecomeValidationIssuesTestOnInvoiceIssuedIsCalledTestOnInvoiceIssuedFailureTransitionsToRetryableIssuingStateTestOnPaymentAuthorizedIsCalledTestOnPaymentAuthorizedFailureTransitionsToRetryablePaymentStateTestOnPaymentSettledIsCalledTestOnPaymentSettledFailureTransitionsToRetryablePaymentStateUse those tests as the template for new billing line-engine lifecycle behavior.
Gathering invoice: one per customer per currency, never advances through states. Collects pending lines (from subscription sync). When lines become due (CollectionAt <= now), the InvoiceCollector cron calls InvoicePendingLines to move them into a new StandardInvoice. The gathering invoice is soft-deleted when it has no remaining lines.
Standard invoice: goes through the full state machine. Created from gathering lines by CreateStandardInvoiceFromGatheringLines.
Line splitting (progressive billing): when a usage-based line must be billed mid-period, the original line gets status=split and two children are created. The parent's SplitLineGroupID connects them. SplitLineHierarchy carries all siblings for computing GetPreviouslyBilledAmount().
Two-tier validation:
.Validate() on every type, returns error, used for input sanity.ValidationIssue{Severity, Code, Message, Component, Path} — stored on the invoice for business-rule violations.ToValidationIssues(err) traverses the error tree unwrapping:
componentWrapper → sets ComponentfieldPrefixWrapper → builds JSON pathValidationIssue → leaf nodeerrors.Join trees → recursesCritical: any unwrapped error at the root causes ToValidationIssues to return the original error (not converted to an issue). This distinguishes expected business rule violations from unexpected system errors.
Use:
ValidationWithComponent(ComponentName, err) — tags which app produced the errorValidationWithFieldPrefix(prefix, err) — builds the JSON path (e.g. "lines/0/price")StandardInvoice.MergeValidationIssues(err, component) — replaces all existing issues for that component (prevents stale accumulation on re-validation)Tax config lives in productcatalog.TaxConfig (defined in the product catalog package).
Present on:
StandardLineBase.TaxConfig *productcatalog.TaxConfigGatheringLineBase.TaxConfig *productcatalog.TaxConfigDetailedLineBase.TaxConfig *productcatalog.TaxConfigInvoicingConfig.DefaultTaxConfig *productcatalog.TaxConfig (invoice-level default)Tax merging: productcatalog.MergeTaxConfigs(override, base) is used in Profile.Merge() and StandardInvoice.GetLeafLinesWithConsolidatedTaxBehavior(). The invoice-level default tax config is merged into leaf lines that don't have their own config.
Workflow tax config (WorkflowTaxConfig):
Enabled bool — enables automatic tax calculation via the Tax app (e.g. Stripe Tax)Enforced bool — invoice fails if the app cannot compute taxSupplier tax code: SupplierContact.TaxCode *string — on the billing profile's supplier contact.
BillingWorkflowConfig and BillingCustomerOverride both carry two sets of tax columns (via TaxMixin in openmeter/ent/schema/taxcode.go):
| Column | Type | Purpose |
|---|---|---|
invoice_default_tax_settings | JSONB | Legacy blob — full TaxConfig struct (includes Stripe.Code, Behavior, TaxCodeID) |
tax_code_id | char(26) nullable FK → TaxCode | Normalized FK for relational queries |
tax_behavior | enum nullable | Normalized mirror of TaxConfig.Behavior |
Both sets are written together on every create/update. On reads, BackfillTaxConfig merges them.
BackfillTaxConfig (productcatalog/tax.go): read-path only. Fills nil fields in the JSONB-sourced *TaxConfig from the normalized columns — never overwrites existing values. This upgrades old rows in memory where the JSONB is populated but the FK columns are NULL.
// productcatalog.BackfillTaxConfig(cfg, taxBehavior, tc *taxcode.TaxCode) *TaxConfig
// Fills cfg.Behavior, cfg.Stripe.Code, cfg.TaxCodeID from the normalized columns only if nil.
resolveDefaultTaxCode (service/profile.go): called before every profile/customer-override create or update, and also in gatheringinvoicependinglines.go before creating pending lines (to resolve the merged profile's DefaultTaxConfig before it is snapshotted into the invoice). Calls taxCodeService.GetOrCreateByAppMapping for the Stripe code and stamps TaxCodeID onto the *TaxConfig in-place. When Stripe code is absent, it explicitly sets TaxCodeID = nil to clear any stale FK from a read-modify-write cycle.
workflowConfigWithTaxCode (adapter/profile.go:35): package-level Ent eager-load option (q.WithTaxCode()) used by GetProfile, ListProfiles, GetDefaultProfile, customer override fetches, and all invoice queries to ensure Edges.TaxCode is populated so mapWorkflowConfigFromDB can call BackfillTaxConfig correctly.
Create/update path adapter gotcha: Save() never populates edge structs. On the profile adapter, after cmd.Save(ctx), the TaxCode edge is manually fetched and assigned:
if saved.TaxCodeID != nil {
tc, err := a.db.TaxCode.Get(ctx, *saved.TaxCodeID)
saved.Edges.TaxCode = tc
}
The customer override adapter avoids this by re-fetching the full row via GetCustomerOverride after saving. GetCustomerOverride uses .WithTaxCode() directly on the override node itself, and workflowConfigWithTaxCode on the nested profile edge — so both the override's own TaxCode and the profile's TaxCode edge are populated.
GetOrCreateByAppMapping (taxcode/service/taxcode.go): find-or-create for a TaxCode row keyed by {namespace, AppType, TaxCode string}. The JSONB app_mappings column is the lookup key; key is auto-generated as "{appType}_{taxCode}" (e.g. "stripe_txcd_10000000"). Handles concurrent creation races via retry.
Complete write flow:
Service.CreateProfile(input)
→ resolveDefaultTaxCode → GetOrCreateByAppMapping → taxConfig.TaxCodeID = &tc.ID (in-place)
→ adapter.CreateProfile
→ BillingWorkflowConfig.Create()
.SetNillableInvoiceDefaultTaxSettings(cfg) // JSONB
.SetNillableTaxCodeID(cfg.TaxCodeID) // FK
.SetNillableTaxBehavior(cfg.Behavior) // enum
.Save(ctx)
→ manual: saved.Edges.TaxCode = db.TaxCode.Get(*saved.TaxCodeID)
Complete read flow:
adapter.GetProfile
→ Query().WithWorkflowConfig(workflowConfigWithTaxCode) // eager-loads TaxCode edge
→ mapWorkflowConfigFromDB
→ invoicing.DefaultTaxConfig = lo.EmptyableToPtr(dbWC.InvoiceDefaultTaxSettings) // JSONB
→ BackfillTaxConfig(cfg, dbWC.TaxBehavior, &tc) // fills nil fields from normalized cols
InvoicingApp interface (implemented by Stripe, Sandbox, custom apps):
ValidateStandardInvoice — called during DraftSyncingUpsertStandardInvoice — sync to external systemFinalizeStandardInvoice — finalize + initiate payment collectionDeleteStandardInvoice — remove from external systemOptional interfaces:
InvoicingAppAsyncSyncer — CanDraftSyncAdvance / CanIssuingSyncAdvance (polling-based async sync)InvoicingAppPostAdvanceHook — PostAdvanceStandardInvoiceHook (post-transition callback)Profile.Apps *ProfileApps references three apps by capability type: Tax, Invoicing, Payment.
billing.StandardInvoiceHook is a mutable slice on the service, populated at startup via RegisterStandardInvoiceHooks. The charges service registers itself this way. Hooks receive PostCreate / PostUpdate callbacks after invoice DB writes. Do not add billing logic here — use it only to notify other subsystems (like charges) of invoice state changes.
See references/subscription-sync.md for full details. Key concepts:
Entry point: subscriptionsync.Service.SynchronizeSubscriptionAndInvoiceCustomer — called by the worker on subscription events and after a new invoice is issued (self-loop to fill the next period).
Algorithm layers:
Line identification: every line has a ChildUniqueReferenceID:
{subscriptionID}/{phaseKey}/{itemKey}/v[{version}]/period[{periodIndex}]
Billing timing (GetInvoiceAt()):
BillingPeriod.Startmax(ServicePeriod.End, BillingPeriod.End)Events handled (Watermill, single Kafka topic):
subscription.Created/Updated/Continued/Cancelled → SynchronizeSubscriptionAndInvoiceCustomersubscription.SubscriptionSyncEvent → HandleSubscriptionSyncEvent (self-loop after invoice issued)billing.AdvanceStandardInvoiceEvent → asyncAdvanceHandler.Handlebilling.StandardInvoiceCreatedEvent → HandleInvoiceCreation (re-sync subscriptions referenced in new invoice)Cron jobs:
AutoAdvancer.All — batch advance DraftWaitingAutoApproval and DraftWaitingForCollection invoices + stuck invoicesInvoiceCollector.All — batch move gathering lines to standard invoices when collection_at <= nowAdvancement strategy in worker: asyncadvance.Handler uses ForegroundAdvancementStrategy (prevents infinite event loops). AutoAdvancer also uses foreground.
Located in openmeter/billing/rating/. Pricing types:
flat — flat rate (generates a single DetailedLine)unit — per-unit pricingtieredvolume — tiered volumetieredgraduated — graduated tiered (cannot be split mid-period; splitting would produce incorrect amounts)dynamic — dynamic pricingAll pricing types implement GenerateDetailedLines(StandardLineAccessor) GenerateDetailedLinesResult, producing []DetailedLine that carry PerUnitAmount, Quantity, TaxConfig, AmountDiscounts.
totals.Totals (in billing/models/totals/model.go) is present on both invoices and lines:
Amount — pre-discount, pre-tax gross
ChargesTotal — additional charges
DiscountsTotal — sum of all discounts
TaxesInclusiveTotal / TaxesExclusiveTotal / TaxesTotal
CreditsTotal — prepaid credits applied (pre-tax)
Total = Amount + ChargesTotal + TaxesExclusive - DiscountsTotal - CreditsTotal
See references/testing.md for full test patterns. Key points:
BaseSuite (test/billing/suite.go):
ForegroundAdvancementStrategy (synchronous state machine)MockStreamingConnector for meter queriesinvoicecalc.MockableInvoiceCalculator for overriding invoice calculationsGetUniqueNamespace(prefix) — ULID-based namespace isolation per testSubscriptionMixin (test/billing/subscription_suite.go):
BaseSuiteSuiteBase for subscription sync tests (worker/subscriptionsync/service/suitebase_test.go):
BaseSuite + SubscriptionMixinsubscriptionsync.ServiceBeforeTest: creates unique namespace, installs sandbox app, provisions billing profile, creates meter+feature+customerSandbox terminal state: In tests using the sandbox app, PostAdvanceStandardInvoiceHook fires TriggerPaid immediately after Issued. The observable terminal state is therefore Paid (not Issued) — asserting Issued will always fail. This is sandbox-specific; production apps stop at Issued and wait for payment.
SynchronizeSubscription horizon: When calling subscriptionsync.Service.SynchronizeSubscription, pass an asOf horizon larger than clock.Now() — equal to the subscription period end or beyond. If the horizon equals Now(), the service doesn't project future lines and the gathering invoice stays empty.
Provisioning helpers:
ProvisionBillingProfile(opts...) — takes option functions: WithProgressiveBilling(), WithCollectionInterval(period), WithManualApproval(), WithBillingProfileEditFn(fn)InstallSandboxApp — required before any invoice operationsBaseSuite.TaxCodeService taxcode.Service — available for direct taxcode assertions (e.g. s.TaxCodeService.GetTaxCodeByAppMapping(ctx, ...) to verify DB entities were created or not)BaseSuite.DBClient — direct Ent client for raw DB assertions (e.g. s.DBClient.TaxCode.Query().Where(taxcodedb.Namespace(ns)).Count(ctx))| File | What it defines |
|---|---|
billing/service.go | All Service sub-interfaces |
billing/adapter.go | All Adapter sub-interfaces |
billing/stdinvoice.go | StandardInvoice, StandardInvoiceStatus, StandardInvoiceLines |
billing/stdinvoicestate.go | Trigger constants (TriggerNext, TriggerApprove, etc.), StandardInvoiceOperation |
billing/stdinvoiceline.go | StandardLine, StandardLineBase, UsageBasedLine, NewFlatFeeLine() |
billing/invoiceline.go | GenericInvoiceLine interface, Period, InvoiceLineManagedBy |
billing/gatheringinvoice.go | GatheringInvoice, GatheringLine, GatheringLineBase |
billing/invoicelinesplitgroup.go | SplitLineGroup, SplitLineHierarchy, split-line math |
billing/profile.go | BaseProfile, Profile, WorkflowConfig, InvoicingConfig, CollectionConfig |
billing/validationissue.go | ValidationIssue, ToValidationIssues(), ValidationWithComponent(), ValidationWithFieldPrefix() |
billing/errors.go | All domain error sentinels (ErrInvoiceNotFound, etc.) |
billing/app.go | InvoicingApp interface, UpsertResults, FinalizeStandardInvoiceResult |
billing/discount.go | Discounts, PercentageDiscount, UsageDiscount, MaximumSpendDiscount |
billing/annotations.go | , |
toValidationIssues swallows nothing: an error that isn't wrapped in a ValidationIssue or componentWrapper at the leaf level will cause the whole call to return the original error (not a []ValidationIssue). Always wrap business rule violations before passing to MergeValidationIssues.
Graduated tiered pricing cannot be split: splitting a graduated line mid-period produces incorrect totals because earlier tiers become "already consumed." The rating engine returns an error for this case. Use continuous (non-split) lines for graduated pricing.
mo.Option absent ≠ empty: an absent StandardInvoice.Lines means "not requested/loaded" and must not be treated as "no lines." Always check .IsPresent() before calling .OrEmpty().
Namespace lockdown: WithLockedNamespaces([]string) on ConfigService blocks invoice advancement for those namespaces (used during migrations). Returns ErrNamespaceLocked. Don't bypass this in tests.
State machine pooling: InvoiceStateMachine instances use sync.Pool. The pool resets all fields after use. Do not hold references to state machine instances across operations.
Schema levels: StandardInvoice.SchemaLevel enables gradual schema migration. New code should always set and respect the schema level when reading/writing invoice data.
Worker uses BackgroundAdvancementStrategy: the billing-worker binary uses async advancement (events), but the asyncadvance.Handler within it uses ForegroundAdvancementStrategy to prevent event loops. Tests always use ForegroundAdvancementStrategy.
RemoveMetaForCompare(): both StandardInvoice and StandardLine have this method that strips DB-only fields for test assertions. Use it before require.Equal comparisons.
Hook registration is mutable: RegisterStandardInvoiceHooks appends to a slice on the billing service. The charges service self-registers at New(). In tests, the hook is registered once per suite (not per test) — reset handler function fields in TearDownTest() rather than re-registering.
references/subscription-sync.md — detailed subscription→billing sync algorithm, phase iterator, reconcilerreferences/testing.md — full test setup patterns, suite helpers, clock controlAnnotationSubscriptionSyncIgnoreAnnotationSubscriptionSyncForceContinuousLinesbilling/serviceconfig.go | AdvancementStrategy type and constants |
billing/service/stdinvoicestate.go | InvoiceStateMachine struct, full state machine wiring |
billing/service/invoicecalc/calculator.go | Calculator interface, MockableInvoiceCalculator |
billing/models/totals/model.go | totals.Totals struct |
billing/adapter/stdinvoicelines.go | GetLinesForSubscription DB query (line 755) |
billing/worker/worker.go | Watermill event handler wiring |
billing/worker/subscriptionsync/service/sync.go | Main sync algorithm |
billing/worker/subscriptionsync/service/targetstate/phaseiterator.go | Billing cadence loop + GetInvoiceAt() |
billing/worker/subscriptionsync/service/reconciler/reconciler.go | Diff algorithm |
test/billing/suite.go | BaseSuite definition |
test/billing/subscription_suite.go | SubscriptionMixin definition |
Line-engine outputs must preserve IDs: BuildStandardInvoiceLines, OnStandardInvoiceCreated, and OnCollectionCompleted all depend on exact line-ID reuse. Returning replacement lines with different IDs will fail billing validation before persistence.
Default engine inference is validator-only: populateGatheringLineEngine and populateStandardLineEngine default blank engines to invoicing, but if a line already has an explicit engine billing only validates the enum value. Registration is checked later when grouping/invoking hooks.
Payment/issuing side-effect hooks are not line-mutating hooks: OnInvoiceIssued, OnPaymentAuthorized, and OnPaymentSettled return only error. If you need to mutate invoice lines, do it earlier in OnStandardInvoiceCreated or OnCollectionCompleted.