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/`.
73
92%
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 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.
The invoice-agnostic detailed-line domain shape lives in openmeter/billing/models/stddetailedline.
Rules:
stddetailedline.BaseInvoiceID on billing.DetailedLineBasestddetailedline.Base instead of copying the common fields againPeriod: 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 invoiceStandardInvoice.CollectionAt: the post-creation collection / quantity-snapshot cutoff for metered standard-invoice linesThese are distinct fields and must not be conflated.
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 startLinePeriodQty: usage up to the current line end minus PreLinePeriodQtyCharge-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.
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.
Delete lifecycle semantics: deleting an invoice is modeled as a delete action, not a generic retry. DeleteInvoice fires TriggerDelete with a typed delete trigger input that carries the delete source. DeleteInProgress consumes that input in an OnEntry action, stamps the source onto the invoice for audit, and performs source-specific cleanup. If delete syncing fails and the invoice reaches DeleteFailed, calling DeleteInvoice again should fire TriggerDelete again; do not route delete failures through RetryInvoice.
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.
The same registry also owns the single CreateLineRouter. Billing's default router returns LineEngineTypeInvoice; charge-enabled application/test wiring must register a charge-aware router explicitly. Keep create routing behind the billing.CreateLineRouter interface instead of passing the registry into diffing helpers.
Grouping model:
groupGatheringLinesByEnginegroupStandardLinesByEngineUpdateStandardInvoiceInput and UpdateGatheringInvoiceInput carry ChangeSource:
ChangeSourceAPIRequest: user/API-originated line edits. Billing diffs the original invoice against the edited invoice with diffMutableInvoiceLines, applies API edits through line engines with applyAPIInvoiceLineEdits, and rebuilds the invoice from unchanged lines plus line-engine outputs.ChangeSourceSystem: system-originated edits from billing/charges/subscription sync. Billing still computes the line diff, but does not invoke API create/update callbacks. For standard invoices only, deleted lines are dispatched through OnMutableStandardLinesDeletedBySystem because charge line updaters currently rely on that cleanup notification. Gathering invoices do not emit system delete callbacks.Subscription-sync charge patches can update or delete invoice lines as a side effect of reconciling charges. Those invoice updates must use ChangeSourceSystem: API edit callbacks are for user-initiated invoice-line edits, while system deletes use OnMutableStandardLinesDeletedBySystem so charge line engines can clean up detached line-backed runs.
The API edit path treats the diff as the owner of invoice lines while engines run:
applyAPIInvoiceLineEdits clones the edited invoice and calls UnsetLines() before line-engine dispatch so the edited invoice is only the header/context, not a competing source of line truth.UpsertInvoiceLines before engine dispatch. This gives line engines stable billing-owned line IDs before charge-backed creates attach realization state. Gathering lines do not need preallocation because downstream state does not reference them directly.CreateLineRouter.GetLineEngineForCreateLine. Existing updated/deleted lines use their persisted engine; missing or changed engines are validation errors.ManuallyManagedLine after routing because they have no previous ownership edge but still need engine selection and preallocation first. API-updated and API-deleted lines are also stamped after engines inspect the previous ownership edge, so engines can distinguish system/subscription-owned to manual transitions from manual-to-manual edits.ExistingLineOverride are preserved when the invoice is rebuilt.ExistingLineOverride represents changes to apply to an existing line: ExistingLine is the old/current line owned by the engine, and ChangesToApply contains the edited values. Apply must clone before mutation and must not mutate ExistingLine; be especially careful with pointer fields such as UsageBased.Price.
The HTTP invoice-line merge layer should not enforce charge-managed edit rules or stamp ManagedBy; line engines and applyAPIInvoiceLineEdits own those decisions. HTTP merge may still enforce generic API invariants that are independent of line-engine ownership, such as blocking usage-discount changes on split lines.
For legacy progressively billed split-line group members, API update validation should reject only actual unsupported mutations. Re-sending an unchanged price or feature key must not fail merely because those override fields are present; compare against the existing line value before returning ErrInvoiceProgressiveBillingNotSupported. Deleting split-line group member lines remains legacy-supported because the rating layer already excludes deleted lines from future calculations.
Hook sequence and ownership:
BuildStandardInvoiceLines
service/gatheringinvoicependinglines.goOnStandardInvoiceCreated
OnCollectionCompleted
InvoiceStateMachine.onCollectionCompletedOnMutableInvoiceLinesEditedViaAPI
ChangeSourceAPIRequestErrCannotUpdateChargeManagedLine until charge-backed manual create/update/delete support is implementedOnMutableStandardLinesDeletedBySystem
ChangeSourceSystem deletesOnInvoiceIssued
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). 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:
workflow.collection.alignment is a pending-line collection policy, so automatic InvoicePendingLines paths should honor it before standard-invoice creation.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.CollectionAt == nil.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().
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 colsInvoicingApp 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:
For charge-backed subscription sync state, subscription sync owns the base/source intent, not the customer-facing effective override layer. When comparing, sorting, repairing, or asserting charge-backed persisted state, use GetBaseIntent() or cached base intents from persisted-state wrappers. Reserve effective intent getters for API/ledger/customer-facing behavior where user overrides should be visible.
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 - CreditsTotalSee 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 | 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 |
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/subscription-sync.md — detailed subscription→billing sync algorithm, phase iterator, reconcilerreferences/testing.md — full test setup patterns, suite helpers, clock control1cdc2cb
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.