| name | billing |
| description | 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/`. |
| user-invocable | true |
| allowed-tools | Read, Edit, Write, Bash, Grep, Glob, Agent |
Billing
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.
Package Map
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)
Core Type Patterns
Union Types (Invoice, InvoiceLine)
Invoice, InvoiceLine, and Charge all use the same private discriminated union pattern:
type Invoice struct { t InvoiceType; std *StandardInvoice; gathering *GatheringInvoice }
inv := billing.NewInvoice[billing.StandardInvoice](std)
std, err := inv.AsStandardInvoice()
gi, err := inv.AsGatheringInvoice()
Never construct Invoice{} directly. The same pattern applies to InvoiceLine.
DBState on Lines
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):
- Add the field to
StandardLineBase (preferred) or UsageBasedLine
- Run
make generate to regenerate derived.gen.go — the Equal() diff is auto-generated by goderive and won't see the new field until regenerated
- Map it in
mapStandardInvoiceLineWithoutReferences so DBState reflects the real DB value
- Add a
Set/SetNillable call in the Create builder
- For nillable fields: add
.UpdateMyField() in the UpsertItems ON CONFLICT clause — sql.ResolveWithNewValues() only covers non-nillable columns automatically
mo.Option on Lines Collection
StandardInvoice.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.
ChildUniqueReferenceID (Idempotent Upserts)
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.
Shared Detailed-Line Base
The invoice-agnostic detailed-line domain shape lives in openmeter/billing/models/stddetailedline.
Rules:
- keep shared detailed-line fields on
stddetailedline.Base
- keep invoice-only fields such as
InvoiceID on billing.DetailedLineBase
- when adding charge-owned detailed-line wrappers, embed
stddetailedline.Base instead of copying the common fields again
- when shared detailed-line mapping helpers exist, reuse them from billing and charges adapters instead of rebuilding the common base-field mapping inline
InvoiceAt vs Period vs CollectionAt
Period: when the service was actually rendered (usage window)
InvoiceAt: when the line should appear on an invoice (may be delayed)
GatheringInvoice.NextCollectionAt / DB collection_at: when pending lines become eligible for automatic collection into a draft invoice
StandardInvoice.CollectionAt: the post-creation collection / quantity-snapshot cutoff for metered standard-invoice lines
These are distinct fields and must not be conflated.
Usage-Based Quantities
StandardLine.UsageBased.MeteredQuantity and Quantity represent the quantity for the standard line's own billing period, not a cumulative service-period quantity. For progressively billed lines, PreLinePeriodQuantity and MeteredPreLinePeriodQuantity carry the quantity already represented before the current standard line.
Billing's quantity snapshot path (service/quantitysnapshot.go) calculates this as:
PreLinePeriodQty: usage from the split/progressive group service-period start up to the current line start
LinePeriodQty: usage up to the current line end minus PreLinePeriodQty
Charge-backed usage-based lines must follow the same standard-line semantics when lifecycle hooks update line contents. Usage-based charge runs store cumulative RealizationRun.MeteredQuantity; charge mappers must translate it to billing line-period/pre-line-period quantities before setting StandardLine.UsageBased fields.
Service / Adapter Pattern
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:
- adapter + external services:
customerService, appService, ratingService, featureService, meterService, streamingConnector, publisher
invoiceCalculator (mockable in tests)
standardInvoiceHooks []StandardInvoiceHook (mutable, registered at startup)
Advancement Strategy
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).
Customer-Level Locking
Every invoice-mutating operation must call transactionForInvoiceManipulation which:
- Calls
UpsertCustomerLock outside any transaction (advisory lock record)
- Wraps the operation in a DB transaction
- Calls
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.
Invoice State Machine
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 exists
shouldAutoAdvance: checks DraftUntil <= now (auto-approval window has elapsed)
canIssuingSyncAdvance: polls InvoicingAppAsyncSyncer if the app implements async sync
Retryable lifecycle hooks:
- Invoice-issued and payment-booking line-engine callbacks run in dedicated retryable states (
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.
- Callback failures must be returned as validation-shaped errors so
FireAndActivate can transition to the corresponding *_failed status and RetryInvoice can re-enter only that hook state.
- App-driven triggers (for example
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.
Line Engine Lifecycle
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 behavior
charge_flatfee
charge_usagebased
charge_creditpurchase
billingservice.engineRegistry (service/lineengine.go) stores engines by LineEngineType, validates explicit engine tags on lines, and defaults missing line engines to LineEngineTypeInvoice.
Grouping model:
- Gathering-line work is grouped by
groupGatheringLinesByEngine
- Standard-line work is grouped by
groupStandardLinesByEngine
- Hooks are invoked once per engine group, never line-by-line from the billing state machine
Hook sequence and ownership:
BuildStandardInvoiceLines
- called while converting gathering lines into a new standard invoice in
service/gatheringinvoicependinglines.go
- must return standard lines reusing the same line IDs as the input gathering lines
OnStandardInvoiceCreated
- called after the standard invoice and standard lines have been persisted
- may mutate and return replacement lines
- billing validates output lines and enforces exact line-ID preservation before replacing them on the invoice
OnCollectionCompleted
- called from
InvoiceStateMachine.onCollectionCompleted
- may mutate and return replacement lines
- billing merges line-engine validation issues per component and continues across engines so one engine failure does not prevent other engines from snapshotting/updating their lines
OnInvoiceIssued
- called from retryable state
issuing.charge_booking
- side-effect only; returns
error, not mutated lines
OnPaymentAuthorized
- called from retryable state
payment_processing.booking_authorized
- side-effect only; returns
error
OnPaymentAuthorized + OnPaymentSettled
- called in order from retryable state
payment_processing.booking_authorized_and_settled when the payment app reports a direct paid outcome from payment_processing.pending
- this exists so charge-side handlers never settle without first creating the authorized realization / ledger booking
OnPaymentSettled
- called from retryable state
payment_processing.booking_settled
- side-effect only; returns
error
CalculateLines
- recalculates detailed lines/totals for standard lines already owned by the engine
- should be deterministic and line-local; orchestration happens in billing
Validation and failure contract:
- All hook inputs use
StandardLineEventInput aliases and must pass .Validate()
- Hooks that return lines must return valid lines with unchanged IDs
- Hook errors that represent business-rule failures should surface as validation-shaped errors so billing can persist them as invoice
ValidationIssues
- For side-effect hooks, billing wraps engine errors with
billing.NewLineEngineValidationError(...)
- For mutating hooks like
OnCollectionCompleted, billing uses MergeValidationIssues(...) with the engine component and keeps processing other engine groups
- Unwrapped infrastructure/programming errors still abort the operation and roll back the transition
Important behavior split:
OnStandardInvoiceCreated and OnCollectionCompleted are allowed to reshape line contents
OnInvoiceIssued, OnPaymentAuthorized, and OnPaymentSettled are for side effects only; they do not return lines back into billing
- Retryable payment/issuing states exist specifically so these side-effect hooks can fail and be retried without rerunning the entire invoice finalization path
App trigger interaction:
- App-driven invoice triggers such as
TriggerPaid can land in intermediary booking states rather than directly in paid
HandleInvoiceTrigger must therefore call AdvanceUntilStateStable after FireAndActivate, otherwise invoices remain stuck in payment_processing.booking_*
When adding a new line engine or hook:
- Extend
billing.LineEngine in openmeter/billing/lineengine.go
- Add no-op implementations to every concrete engine that already satisfies the interface
- Wire the billing invocation point in either
gatheringinvoicependinglines.go or stdinvoicestate.go
- Decide whether failures must be retryable; if yes, add dedicated intermediary invoice states instead of attaching
OnActive to a stable/final state
- Add focused billing tests in
test/billing/lineengine_test.go
Current test coverage pattern:
TestCollectionCompletedErrorsBecomeValidationIssues
TestOnInvoiceIssuedIsCalled
TestOnInvoiceIssuedFailureTransitionsToRetryableIssuingState
TestOnPaymentAuthorizedIsCalled
TestOnPaymentAuthorizedFailureTransitionsToRetryablePaymentState
TestOnPaymentSettledIsCalled
TestOnPaymentSettledFailureTransitionsToRetryablePaymentState
Use those tests as the template for new billing line-engine lifecycle behavior.
Gathering vs Standard Invoices
Gathering invoice: one per customer per currency, never advances through states. Collects pending lines (from subscription sync). Automatic standard-invoice creation is gated here by the billing profile's workflow.collection.alignment: NextCollectionAt is the next wake-up time for the collector, and InvoicePendingLines honors alignment by default. 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. CollectionAt on a standard invoice is no longer the primary place where alignment lives; it is the post-creation cutoff used by quantity snapshotting / collection-completed processing for metered lines.
Alignment placement:
- Billing-profile
workflow.collection.alignment is a pending-line collection policy, so automatic InvoicePendingLines paths should honor it before standard-invoice creation.
- Explicit/manual invoicing paths may bypass alignment intentionally via
WithBypassCollectionAlignment().
StandardInvoiceCollectionAt should not re-implement anchored alignment; it should derive from metered standard lines only.
Flat-fee standard invoices:
StandardInvoiceCollectionAt only considers non-deleted lines where DependsOnMeteredQuantity() == true.
- Flat-fee-only standard invoices therefore have domain
CollectionAt == nil.
- The V3 HTTP mapping currently emulates
collectionAt as CreatedAt for standard invoices when the domain field is nil, to preserve the historic API shape. Do not confuse this API compatibility shim with the domain semantics.
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().
Validation Issues
Two-tier validation:
- Structural validation:
.Validate() on every type, returns error, used for input sanity.
- Domain validation issues:
ValidationIssue{Severity, Code, Message, Component, Path} — stored on the invoice for business-rule violations.
ToValidationIssues(err) traverses the error tree unwrapping:
componentWrapper → sets Component
fieldPrefixWrapper → builds JSON path
ValidationIssue → leaf node
errors.Join trees → recurses
Critical: 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 error
ValidationWithFieldPrefix(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 Handling
Tax config lives in productcatalog.TaxConfig (defined in the product catalog package).
Present on:
StandardLineBase.TaxConfig *productcatalog.TaxConfig
GatheringLineBase.TaxConfig *productcatalog.TaxConfig
DetailedLineBase.TaxConfig *productcatalog.TaxConfig
InvoicingConfig.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 tax
Supplier tax code: SupplierContact.TaxCode *string — on the billing profile's supplier contact.
TaxCode Dual-Write (profile / customer override)
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.
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
App Integration
InvoicingApp interface (implemented by Stripe, Sandbox, custom apps):
ValidateStandardInvoice — called during DraftSyncing
UpsertStandardInvoice — sync to external system
FinalizeStandardInvoice — finalize + initiate payment collection
DeleteStandardInvoice — remove from external system
Optional 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.
StandardInvoiceHook (charges integration)
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.
Subscription → Billing Sync
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:
- Persisted state — load existing lines from DB for the subscription
- Target state — compute what lines should exist (phase iterator + billing cadence)
- Reconciler — diff (new / delete / upsert) + apply patches
Line identification: every line has a ChildUniqueReferenceID:
{subscriptionID}/{phaseKey}/{itemKey}/v[{version}]/period[{periodIndex}]
Billing timing (GetInvoiceAt()):
- Flat-fee in-advance →
BillingPeriod.Start
- All other →
max(ServicePeriod.End, BillingPeriod.End)
Worker / Background Processing
Events handled (Watermill, single Kafka topic):
subscription.Created/Updated/Continued/Cancelled → SynchronizeSubscriptionAndInvoiceCustomer
subscription.SubscriptionSyncEvent → HandleSubscriptionSyncEvent (self-loop after invoice issued)
billing.AdvanceStandardInvoiceEvent → asyncAdvanceHandler.Handle
billing.StandardInvoiceCreatedEvent → HandleInvoiceCreation (re-sync subscriptions referenced in new invoice)
Cron jobs:
AutoAdvancer.All — batch advance DraftWaitingAutoApproval and DraftWaitingForCollection invoices + stuck invoices
InvoiceCollector.All — batch move gathering lines to standard invoices when collection_at <= now
Advancement strategy in worker: asyncadvance.Handler uses ForegroundAdvancementStrategy (prevents infinite event loops). AutoAdvancer also uses foreground.
Rating / Pricing Engine
Located in openmeter/billing/rating/. Pricing types:
flat — flat rate (generates a single DetailedLine)
unit — per-unit pricing
tieredvolume — tiered volume
tieredgraduated — graduated tiered (cannot be split mid-period; splitting would produce incorrect amounts)
dynamic — dynamic pricing
All pricing types implement GenerateDetailedLines(StandardLineAccessor) GenerateDetailedLinesResult, producing []DetailedLine that carry PerUnitAmount, Quantity, TaxConfig, AmountDiscounts.
Totals
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
Testing
See references/testing.md for full test patterns. Key points:
BaseSuite (test/billing/suite.go):
- Real Postgres DB + Ent client + Atlas migrations
ForegroundAdvancementStrategy (synchronous state machine)
MockStreamingConnector for meter queries
invoicecalc.MockableInvoiceCalculator for overriding invoice calculations
GetUniqueNamespace(prefix) — ULID-based namespace isolation per test
SubscriptionMixin (test/billing/subscription_suite.go):
- Adds plan/subscription/addon/entitlement stack on top of
BaseSuite
- Embed this when tests need subscription wiring
SuiteBase for subscription sync tests (worker/subscriptionsync/service/suitebase_test.go):
- Embeds both
BaseSuite + SubscriptionMixin
- Adds
subscriptionsync.Service
BeforeTest: creates unique namespace, installs sandbox app, provisions billing profile, creates meter+feature+customer
Sandbox 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 operations
BaseSuite.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))
Key Files Reference
| 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 | AnnotationSubscriptionSyncIgnore, AnnotationSubscriptionSyncForceContinuousLines |
billing/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 |
Non-Obvious Gotchas
-
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.
-
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.
References
references/subscription-sync.md — detailed subscription→billing sync algorithm, phase iterator, reconciler
references/testing.md — full test setup patterns, suite helpers, clock control