Work with OpenMeter billing charges, including the root charges facade, charge meta queries, charge creation and advancement, usage-based lifecycle state machines, realization runs, and charges test setup. Use when modifying `openmeter/billing/charges/...` or charge-related tests.
66
81%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Medium
Suggest reviewing before use
Guidance for working with OpenMeter billing charges.
This skill describes the charges package generically. Lifecycle state machines exist for type-specific settlement modes. All three charge types (usage-based, flat-fee, credit-purchase) follow the same structural pattern: ChargeBase/Charge with Realizations, own Status type, status_detailed DB column, and composite adapter interfaces.
Primary packages:
openmeter/billing/charges/openmeter/billing/charges/service/openmeter/billing/charges/meta/openmeter/billing/charges/lock/openmeter/billing/charges/usagebased/openmeter/billing/charges/usagebased/service/openmeter/billing/charges/usagebased/adapter/openmeter/billing/charges/flatfee/openmeter/billing/charges/flatfee/service/openmeter/billing/charges/flatfee/adapter/openmeter/billing/charges/creditpurchase/openmeter/billing/charges/creditpurchase/service/openmeter/billing/charges/creditpurchase/adapter/openmeter/billing/charges/service/invoicable_test.goopenmeter/billing/charges/service/advance_test.goWhen changing usage-based rating algorithms, update the package documentation in the same change. The calculation contracts are documented in:
openmeter/billing/charges/usagebased/service/rating/delta/README.mdopenmeter/billing/charges/usagebased/service/rating/periodpreserving/README.mdopenmeter/billing/charges/usagebased/service/rating/subtract/README.mdopenmeter/billing/charges is the root facade for charge operations.
For charge-owned detailed lines, the shared invoice-agnostic base belongs in openmeter/billing/models/stddetailedline. Prefer reusing stddetailedline.Base for invoice-agnostic base lines, or define a concrete charge-owned type that embeds/composes stddetailedline.Base when the package needs additional fields (for example usagebased.DetailedLine). Keep ownership implicit through containment in the parent aggregate (flatfee.Realizations or usagebased.RealizationRun) rather than duplicating charge_id / run_id fields in the domain type. Reuse the shared detailed-line base mapping and create helpers instead of duplicating common field assembly in charge adapters.
Charge-backed invoicing no longer relies on a charges-side InvoicePendingLines(...) wrapper. Billing owns invoice creation and dispatches gathering lines by billing.LineEngineType, while charge packages provide charge-specific line engines where needed.
Important layers:
charges.Service
Create(...), GetByID(...), GetByIDs(...), AdvanceCharges(...)charges/meta
charges/service
charges/usagebased
The generic rule is:
AdvanceCharges(...) is a facade method, not the state machine itselffinal state until their payment lifecycle is fully settled; if a charge is waiting on invoice payment authorization or settlement, keep it in an active.* detailed status instead of collapsing to finalAdapter rules:
openmeter/billing/charges/.../adapter, including shared helper functions. Prefer helpers to accept the adapter/repo handle rather than a raw *entdb.Client, so entutils.TransactingRepo(...) or entutils.TransactingRepoWithNoValue(...) can pass the transaction-bound handle from ctx. If a helper must accept a raw client, only call it with the swapped handle's client, such as tx.db inside the transacting callback.UpdateCharge(ctx, ChargeBase) persists charge base-row fields only. Realization-side rows, such as detailed lines, credit allocations/corrections, invoice accruals, payment records, and run/linkage rows, must be persisted through their dedicated adapter methods. Do not call UpdateCharge(...) only because expanded Realizations changed; expanded realizations are read-model state on the aggregate, not implicit write input.Important types:
charges.AdvanceChargesInput identifies the customer whose charges should advancemeta.Charge and meta.ChargeID define the shared charge identity and typecharges.Charge wraps concrete charge variantsflatfee.OverridableIntent carries the immutable flat-fee intent plus base and optional override mutable layers. Use accessors instead of reading layer fields directly unless the caller explicitly owns the base layer.flatfee.Intent is the concrete base/effective intent shape used when creating or cloning a flat-fee intent. Intent.AsOverridableIntent() maps it into the base layer.flatfee.ChargeBase stores the persisted flat-fee charge row: current Status plus durable Stateflatfee.State currently tracks:
AmountAfterProrationAdvanceAfterFeatureIDflatfee.Realizations stores expanded, non-base data loaded from child tables:
CreditRealizationsAccruedUsagePaymentDetailedLinesflatfee.Intent.CalculateAmountAfterProration() computes the prorated amount from AmountBeforeProration, ServicePeriod/FullServicePeriod ratio, and ProRating config, with currency-precision roundingusagebased.OverridableIntent carries the immutable usage-based intent plus base and optional override mutable layers. Use accessors instead of reading layer fields directly unless the caller explicitly owns the base layer.usagebased.Intent is the concrete base/effective intent shape used when creating or cloning a usage-based intent. Intent.AsOverridableIntent() maps it into the base layer.usagebased.ChargeBase stores the current Status and Stateusagebased.State currently tracks:
CurrentRealizationRunIDAdvanceAfterusagebased.RealizationRunBase stores:
TypeStoredAtLTServicePeriodToMeteredQuantityTotalsusagebased.RealizationRunBase.MeteredQuantity is a cumulative charge snapshot for [intent.ServicePeriod.From, ServicePeriodTo) capped by that run's StoredAtLT. Do not copy it directly into billing.StandardLine.UsageBased.MeteredQuantity for progressive billing: billing standard lines expect line-period quantity. Use usagebased.RealizationRuns.MapToBillingMeteredQuantity(currentRun) when mapping a run into a standard invoice line, and map LinePeriod to Quantity / MeteredQuantity, and PreLinePeriod to PreLinePeriodQuantity / MeteredPreLinePeriodQuantity.MapToBillingMeteredQuantity intentionally uses the latest prior invoice-backed run's persisted cumulative MeteredQuantity as PreLinePeriod. That prior value may have been captured with an older StoredAtLT than the current run. This differs from period-preserving rating internals, which may freshly snapshot prior event-time periods with the current StoredAtLT for correction calculations; standard invoice line quantities should reflect what was previously billed.usagebased.RealizationRun can expand:
DetailedLinesIntent deletion rules:
flatfee.Intent.IntentDeletedAt and usagebased.Intent.IntentDeletedAt mark the concrete base/original intent as deleted; when those charge types have no active override, adapters derive effective charge DeletedAt from this value.flatfee.IntentOverride.IntentDeletedAt and usagebased.IntentOverride.IntentDeletedAt mark the override intent as deleted; when an override row is present, adapters derive effective charge DeletedAt from the override value instead of the base intent value.override_* charge columns are compatibility/deprecated fields and should not be used for active override behavior.charge.DeletedAt from the aggregate (GetIntentDeletedAt()), and then persist the type-specific row plus charge meta.IntentDeletedAt fields or persistence to credit-purchase paths.Intent layer access rules:
OverridableIntent accessors such as GetEffectiveServicePeriod(), GetEffectiveInvoiceAt(), GetEffectiveTaxConfig(), GetEffectiveFeatureKey(), GetEffectivePrice(), or GetEffectiveMetaIntentMutableFields().GetEffectiveIntent() when only a few fields are needed. GetEffectiveIntent() clones and assembles a full intent, so it is useful at mapping boundaries but too broad for hot lifecycle checks.GetBaseIntent() or base-specific helpers, not effective getters, so user/API overrides do not feed back into the subscription target state.GetBaseIntent() and persist override fields only through the override-specific adapter paths. Do not derive base persistence values from effective intent accessors.Patch target rules:
billing.ChangeSource source intent instead of an explicit target whenever the target can be derived. Do not rely on a zero/default source.DeletedAt; otherwise an active override can hide a subscription-owned base charge that sync still needs to reconcile.PatchOpUpsertGatheringLineByChargeID should carry the full rebuilt billing.GatheringLine target state, not just period deltas. The invoice updater queries the existing pending gathering line by charge ID and merges the patch target with WithTargetState(...) so DB identity and invoice membership stay intact; if no active pending line exists, it creates one through the regular pending-line provisioning path.PatchShrink is system-only and always targets the base layer. API/customer-originated period shortening must use a distinct API patch such as PatchShrinkToRealizedPeriod, which resolves its target layer through API patch rules and creates or updates the override layer when the base intent is subscription-managed.Usage-based charge realization runs are not billing standard lines. When mapping a run back to billing.StandardLine in openmeter/billing/charges/usagebased/service/linemapper.go, preserve the billing-facing semantics:
MeteredQuantity and MeteredPreLinePeriodQuantity are raw metered usage for the current line period and prior line periods.Quantity and PreLinePeriodQuantity are net billable usage after rate-card usage discounts.StandardLine.UsageBased.UnitConfig is line pricing context, like StandardLine.UsageBased.Price; populate it before calling populateStandardLineFromRun(...) and have the mapper read it from the line. Do not pass unit config as a separate mapper input.billing/rating/service/mutator.ApplyUsageDiscount) instead of reimplementing discount math in charges. This keeps usage-based charges compatible with standard billing's discounts.usage.quantity and discounts.usage.preLinePeriodQuantity API behavior.RateCardDiscounts when mapping invoice-line discount metadata. Do not use usage-based rating's synthetic "usagebased-ratecard-*" correlation IDs for persisted invoice-line discount communication.Do not emulate every billing rating mutator in the line mapper. Usage discounts are special because they mutate line-header quantities and StandardLineDiscounts.Usage. Percentage discounts and maximum-spend discounts are amount discounts on detailed lines; if API parity is required for those, preserve amount-discount metadata through usage-based charge detailed lines instead of recalculating discounts during mapping. Minimum-spend commitments already materialize as commitment detailed lines during rating. Credits are owned by charge credit allocation and should be mapped from run credit realizations, not reapplied through the standard billing credits mutator.
Detailed-line expansion rules:
meta.ExpandDetailedLines is not standalone for charge reads; it requires meta.ExpandRealizationsmeta.ExpandDeletedRealizations is not standalone for charge reads; it requires meta.ExpandRealizationsmeta.ExpandRealizations by default because their invoice/ledger effect has been cleaned up; request meta.ExpandDeletedRealizations together with meta.ExpandRealizations only when a caller must inspect cleanup state, such as frontend audit views or deletion testsDeletedAt != nil, documenting the business reason at the guard, because deleted realizations are no longer effective billable historyinvalid_due_to_unsupported_credit_note realization runs are audit history for immutable invoice lines that should have been removed by prorating/credit-note support. They must not count as billing history for rating, pre-line quantities, or balance-style aggregate checks. Prefer the domain helper (RealizationRun.IsVoidedBillingHistory() / RealizationRuns.WithoutVoidedBillingHistory()) over ad hoc checks for deleted or unsupported-credit-note runs.RealizationRun.DetailedLinesCharge.Realizations.DetailedLines, not on the root chargemo.None() means detailed lines were not expanded; present options mean expanded data, even when the underlying slice is nil/emptyCharge-backed gathering lines must carry the correct billing line engine when they are created.
Current engine values:
billing.LineEngineTypeChargeFlatFeebilling.LineEngineTypeChargeUsageBasedbilling.LineEngineTypeChargeCreditPurchaseCurrent implementations:
openmeter/billing/charges/flatfee/service/lineengine.goopenmeter/billing/charges/creditpurchase/lineengineopenmeter/billing/charges/usagebased/service/lineengine.goImportant rules:
ChargeIDbilling/service.CreatePendingInvoiceLines(...) rejects charge-backed gathering lines with empty Enginebilling.Service.RegisterLineEngine(...)billing.Service.DeregisterLineEngine(...); use the public registry API instead of mutating billing internals from non-service packagesopenmeter/billing/charges/testutilsCreateLineRouter; billing's default create router intentionally falls back to legacy billing.LineEngineTypeInvoiceLineEngineType, app wiring and charge test wiring must register a matching implementation in the same changeGetLineEngine(); register the returned engine instead of reusing the service type directlyflatfee/service.New(...) requires a rating.Service; forgetting that dependency breaks app/test wiring with rating service cannot be nullOnMutableStandardLinesDeletedBySystem(...) so credit corrections, charge-owned detailed-line cleanup, and deleted-run marking stay consistent.ValidateMutableInvoiceLineEditViaAPI(...) before OnMutableInvoiceLinesEditedViaAPI(...). The validation hook must not mutate charge or invoice state; it should only validate engine ownership, patch shape, target layer, and preconditions needed to make the later state-machine patch deterministic. Charge mutation and invoice patch consumption belong in OnMutableInvoiceLinesEditedViaAPI(...).InvoiceAtAccessor only for line types that expose invoice-at as customer-facing scheduling input, such as gathering lines. billing.StandardLine.InvoiceAt is display-only for rendered gathering lines; standard-line charge create flows should derive intent invoice-at from the charge/payment-term semantics instead of reading that field as scheduling state.credit_then_invoice charges from the preallocated billing line and return target state merged onto that same line identity. Gathering-line creates should return the charge-created gathering line target state. Standard-line creates should attach an ongoing realization run to the preallocated standard line, then map the run back onto that line. Keep usage-based line-to-intent mapping code in usagebased/service/linemapper.go alongside the run-to-line mapper.billing.ErrCannotUpdateChargeManagedLine. Standard-line delete is allowed only when the charge has one non-voided realization run, and the line engine must validate the emitted standard-line delete patch, invoke mutable-standard-line realization cleanup, and apply at most one remaining gathering-line delete patch. Gathering-line delete with no non-voided realizations deletes the effective charge through the API delete patch. Gathering-line delete with existing non-voided realizations must use PatchShrinkToRealizedPeriod, delete only the remaining gathering tail, preserve existing standard invoice history, explicitly reclassify the latest kept partial run as RealizationRunTypeFinalRealization when that run now covers the shortened effective period, and otherwise preserve the charge's current status/state so the existing invoice lifecycle continues to own advancement. This patch carries only the new effective service-period end for validation and must not change invoice-at, billing period, or full service period.PatchShrinkToRealizedPeriod validation in the usage-based state-machine handler must prove the requested new effective period end equals the latest non-voided realization run's ServicePeriodTo. Keep this boundary check with the handler that mutates the charge, not in the line engine's preliminary validation path.usagebased/service/run.CreateRatedRunInput.Validate) that Charge.State.CurrentRealizationRunID is nil before creating a new run; keep the line-engine-side early return too so InvoicePendingLines fails with the charge-specific validation error at the billing boundary. In both places, key the guard off CurrentRealizationRunID, not a specific status prefix such as partial_invoiceall_payments_settled) once all invoiced runs on the charge are settled. Do not apply this rule generically to flat-fee or credit-purchase; those charge types may still keep payment states inside their own state machines.active.realization.* statuses for both partial and final invoice-backed runs. Keep the branch order as started -> waiting_for_collection -> processing -> issuing -> completed, keep invoice_issued as the boundary between processing and issuing, run FinalizeInvoiceRun(...) from the issuing state, and let completed dynamically route to active or active.awaiting_payment_settlement based on the effective service-period boundary and realization history. Do not reintroduce separate partial/final status branches; legacy active.partial_invoice.* and active.final_realization.* values are normalized when loading the state machine.status_detailed is an Ent enum for ChargeUsageBased; run make generate so the generated enum validators and migrate schema include the new values before trusting state-machine changesDeleteInvoice with billing.ChangeSourceSystem; billing stores that source for audit and dispatches OnMutableStandardLinesDeletedBySystem(...) only for non-deleted standard lines so charge engines can clean up line-backed runs without mutating the deleted invoice's visible line historyFlat-fee credit-then-invoice lifecycle rules:
billing.LineEngineTypeChargeFlatFee through the flat-fee line engine. Do not reintroduce public flat-fee invoice lifecycle service methods or call the flat-fee state machine from charges/service standard-invoice hooks.OnStandardInvoiceCreated, OnCollectionCompleted, OnInvoiceIssued, OnPaymentAuthorized, OnPaymentSettled, and mutable-line cleanup.credit_only is not a line-engine flow; the flat-fee line engine should treat non-credit_then_invoice standard invoice callbacks as lifecycle misuse.credit_then_invoice charges start as created, become active at the service-period start, and use active.realization.* substates for invoice lifecycle. Keep invoice-issued work in the issuing state and only move to final after required fiat payment settlement or a no-fiat run.CreateCurrentRun(...) must fail when the charge already has a non-detached current run. It may be created with invoice and line IDs when the standard line is known; otherwise the caller should pass the required run period and amount explicitly and attach line references later through the normal lifecycle.Immutable to choose invoice patching behavior. Mutable runs may update the standard line in place; immutable runs require deleting the old invoice line and creating a replacement gathering line when the amount changes. A deleted realization run must never remain the charge's current run.created, and set AdvanceAfter to the replacement service-period start.LineManualEdit trigger. The state machine owns override persistence. The line engine consumes the emitted invoice patch locally and returns the target line merged onto the existing invoice-line identity.Usage-based credit-then-invoice extension rules:
PatchExtend must represent a real extension: the new service period end must be after the persisted intent end; full service period and billing period ends may stay unchanged but must not move backwards. Carry the new invoice-at on the patch separately from the service-period end.active with AdvanceAfter set to the new service-period end so the extended tail can realize later.active.realization.started, active.realization.waiting_for_collection, or active.realization.processing), billing remains the owner of the ongoing invoice lifecycle. Extension may delete the mutable line, mark the current run deleted, and recreate a gathering line; a direct AdvanceCharges(...) before the new service-period end must be a no-op, and the replacement final run should be created by billing when the replacement gathering line is invoiced at the extended end.active.realization.issuing or active.realization.completed, extend is explicitly rejected by UnsupportedExtendOperation because invoice lifecycle callbacks or state-machine advancement still own those states. Subscription sync is expected to retry instead of moving the charge out of those states manually.active.awaiting_payment_settlement is allowed: preserve the invoice line and ledger bookings, reclassify the old terminal run as partial, move the charge back to active, and create only a tail gathering line.active; the extended tail will produce a new final run later. Immutable invoice cleanup should be surfaced as validation warnings by billing rather than reversing ledger bookings.Usage-based credit-then-invoice shrink rules:
PatchShrink must represent a real shrink: the new service period end must be before the persisted intent end and after the persisted service period start. Full service period and billing period ends may stay unchanged or move earlier, but must not move later. Carry the new invoice-at on the patch separately from the service-period end.credit_then_invoice charges so immutable invoice and ledger history can be preserved. Usage-based credit_only shrink remains an emulated delete/create replacement unless that mode explicitly gains native support.active.realization.started, active.realization.waiting_for_collection, or active.realization.processing) and extends past the new service-period end, shrink should delete that mutable standard line and create a replacement gathering line for the shrunk period. Billing's mutable-line deletion hook owns credit correction, run deletion, and moving the charge back to active.active.awaiting_payment_settlement and final, shrink is allowed even though the existing final invoice is immutable. Emit the line-delete patch anyway so billing records the immutable-invoice/prorating warning, leave existing invoice and ledger history untouched, move the charge back to active, and create a replacement gathering line for the shrunk period using the patch invoice-at.deleted. Subscription sync can retry after billing advances when the invoice lifecycle owns the current state.Operational consequence:
invoicing is not enough for charge-backed lines; existing persisted gathering lines may need a backfill if they should route to a charge engine after rolloutCurrent shared contract details:
openmeter/billing/lineengine.go are validated at the billing callsite before invoking the engine, and returned lines/results are validated after the callOnCollectionCompleted(...) takes billing.OnCollectionCompletedInput and returns updated billing.StandardLinesCalculateLines(...) returns updated billing.StandardLines; billing treats this as a pure recalculation boundary, validates exact line ID preservation, and merges the returned lines back into the invoice instead of relying on in-place mutationCalculateLines(...) no longer takes context.Context; if a future charge engine needs context-aware recalculation, propagate that need deliberately through the contract instead of using context.Background() as a workaroundSplitGatheringLine(...) takes a concrete billing.GatheringLine plus SplitAt and returns only the split line fragments; the billing caller owns fetching the current line from the gathering invoice and merging PreSplitAtLine / optional PostSplitAtLine back into the invoice aggregateSplitGatheringLineResult.PostSplitAtLine is *billing.GatheringLinebilling.ValidateStandardLineIDsMatchExactly(...) when a charge-side test or helper needs to assert that returned standard-line identities are preserved across a line-engine boundarybilling.NewLineEngineValidationError(...) instead of rebuilding the validation-issue wrapper at each billing callsitecredit_then_invoice; they are not the execution path for credit_only settlement modecredit_only settlement mode; treat that as a lifecycle misuse rather than adding credit_only behavior to the engineOnMutableInvoiceLinesEditedViaAPI(...); return billing.ErrCannotUpdateChargeManagedLine for unsupported charge-managed create/update/delete edits instead of pre-rejecting them in billing or HTTP codeCharge persistence assumes timestamp precision is bounded by streaming aggregation precision.
Rules:
streaming.MinimumWindowSizeDurationmeta.NormalizeTimestamp(...) is the shared primitive; it also converts to UTCmeta.NormalizeClosedPeriod(...) and Intent.Normalized() helpers are the domain-level normalization entrypointsAmountAfterProrationAdvanceAfter, StoredAtLT, ServicePeriodTo), normalize the computed timestamp before persisting it or handing it to downstream persistence callbacksDeletedAt; they should preserve the caller-provided instant and precisionImportant timestamp surfaces:
meta.Intent.ServicePeriodmeta.Intent.FullServicePeriodmeta.Intent.BillingPeriodflatfee.Intent.InvoiceAtusagebased.Intent.InvoiceAtflatfee.State.AdvanceAfterusagebased.State.AdvanceAfterusagebased.CreateRealizationRunInput.StoredAtLTusagebased.CreateRealizationRunInput.ServicePeriodTousagebased.UpdateRealizationRunInput.StoredAtLTPlacement guidance:
Intent.Normalized(), state-machine transition logic, temporary patch remap)charges/models/chargemetaSetInvoiceAt(...), SetStoredAtLt(...), SetServicePeriodTo(...), SetOrClearAdvanceAfter(...)) rather than rewriting the whole input object at the top of the adapter method.UTC() calls after meta.NormalizeTimestamp(...); the helper already returns UTCCharge lifecycle code owns currency rounding.
Rules:
creditrealization.Realizations.Correct(...) / CorrectAll(...) pathImportant money surfaces:
creditpurchase.Intent.CreditAmountflatfee.Intent.AmountBeforeProrationflatfee.State.AmountAfterProrationTotalsflatfee.OnAssignedToInvoiceInput.PreTaxTotalAmountflatfee.OnCreditsOnlyUsageAccruedInput.AmountToAllocateusagebased.CreditsOnlyUsageAccruedInput.AmountToAllocatecreditrealization.CreateAllocationInputscreditrealization.CreateCorrectionInputsPlacement guidance:
Intent.Normalized()AmountAfterProration is already rounded when calculated; adapters should persist it as-iscreditrealization helpers for correction flows instead of repeating callback-local normalization at each callsiteSet* writeInvoice accrual uses a non-negative, no-op-aware contract.
Rules:
invoicedusage.AccruedUsage rows with an empty LedgerTransaction.TransactionGroupIDCurrent expected behavior:
usagebased.OnInvoiceUsageAccruedInput.Validate() allows zero and rejects only negativesledgertransaction.GroupReference{}Zero invoice accrual is different from payment booking. Invoice-backed charge payment records (charges/models/payment) require a positive amount and a real ledger transaction reference. A fully credit-covered standard invoice can reach payment_processing.pending with Totals.Total == 0, but blindly sending TriggerPaid through the normal payment authorization/settlement path can fail with validation errors such as amount must be positive and transaction group ID is required. Add an explicit zero-total payment no-op path before expecting fully credited invoice-backed runs to reach settled payment state.
Credit-purchase charges have an API/domain enum mismatch for promotional grants.
Rules:
creditpurchase.SettlementTypePromotionalfunding_method=nonepurchase block entirelyapi/v3/handlers/customers/credits must map this case explicitly instead of treating promotional as an unsupported settlement typeImportant files:
api/v3/handlers/customers/credits/convert.goopenmeter/billing/charges/creditpurchase/settlement.goopenmeter/billing/creditgrant/service/service.goapi/spec/packages/aip/src/customers/credits/grant.tspUse small type-specific realization helper subpackages to keep charge services and state machines from becoming kitchen-sink orchestration layers.
The purpose of these subpackages is to separate reusable realization mechanics from lifecycle decisions:
Naming should describe the charge-domain unit being manipulated rather than the current ledger operation. Prefer realizations for flat-fee helpers because flat fees have credit realizations today and will also support invoiced/payment realization flows. For usage-based charges, a run helper is appropriate when the helper owns realization-run mechanics such as rated run creation, run persistence, credit allocation/correction, and run credit-realization lineage.
Keep these helpers type-specific instead of forcing a generic cross-charge state machine. Flat-fee and usage-based lifecycles share some mechanics, but their durable state and lifecycle semantics differ: usage-based has realization runs, collection cutoffs, and CurrentRealizationRunID; flat-fee has charge-level realizations, proration, invoice hooks, and payment hooks.
When extracting helpers:
credit_only and credit_then_invoice can differcharges.AdvanceCharges(...) advances both usage-based and flat-fee credit-only chargesusagebased.Service.AdvanceCharge(...) routes to the settlement-mode-specific state machine: CreditOnly uses the credits-only state machine and CreditThenInvoice uses NewCreditThenInvoiceStateMachine(...)flatfee.Service.AdvanceCharge(...) routes to the settlement-mode-specific state machine: CreditOnly uses the credits-only state machine and CreditThenInvoice uses NewCreditThenInvoiceStateMachine(...)AdvanceCharge(...) methods return *Charge (nil means noop, non-nil means at least one transition)invoice_created, collection_completed), not generic advance-loop transitionscharges.Create(...) runs in two phases:
autoAdvanceCreatedCharges(...) runs outside the transaction so that creation is persisted even if advancing fails (a worker can retry later)autoAdvanceCreatedCharges(...) (charges/service/create.go):
ChargeTypeUsageBased, ChargeTypeFlatFee)s.AdvanceCharges(...) (the facade) once per unique customerThis means a newly created credit-only charge (usage-based or flat fee) that is eligible for immediate activation will be returned as active (or final) from Create(...) itself.
For invoice-settled charges:
LineEngineTypeChargeFlatFee / LineEngineTypeChargeUsageBased respectively; credit-purchase has a separate line-engine lifecycle and should be handled explicitly instead of forced into flat-fee/usage-based rules.IsLineBillableAsOf(...) is currently billable only once asOf >= resolved service period end; keep the existing progressive-billing TODO in place when touching that logicBuildStandardInvoiceLines(...) is allowed to drive charge lifecycle transitions needed to create or attach the invoice-backed realization/run stateOnCollectionCompleted(...) is the single collection-time line-engine hook; do not reintroduce a generic shared SnapshotLines() abstraction for charge enginesinvoice_created and collection_completed over generic line-snapshot callbacksThe root-facade advance flow is:
charges.AdvanceCharges(...) lists non-final charge metas for the customerchargesByType(...)flatfee.Service.AdvanceCharge(...) per charge (no customer override or feature meters needed)usagebased.Service.AdvanceCharge(...) per chargeKey package responsibilities:
charges/service/advance.go
charges/meta/adapter
charges/lock
NewChargeKey(...) for charge-scoped lockingUsage-based advance currently does:
ChargeIDCustomerOverrideWithDetailscharges/lock.NewChargeKey(...)*lockr.LockerNewCreditsOnlyStateMachine(...) or NewCreditThenInvoiceStateMachine(...))AdvanceUntilStateStable(...)This is the main place where charge lifecycle logic exists today.
State-machine organization rule:
service/ or adapter/ package boundaryThe credits-only lifecycle is implemented in usagebased/service/creditsonly.go and usagebased/service/statemachine.go.
Relevant statuses:
createdactiveactive.realization.startedactive.realization.waiting_for_collectionactive.realization.processingactive.realization.completedfinalHigh-level transitions:
created -> active
IsInsideServicePeriod()AdvanceAfter to service-period start while waitingactive -> active.realization.started
IsAfterServicePeriod()AdvanceAfter to service-period end while waitingactive.realization.started -> active.realization.waiting_for_collection
StartFinalRealizationRun(...) creates the realization runactive.realization.waiting_for_collection -> active.realization.processing
IsAfterCollectionPeriod(...)active.realization.processing -> active.realization.completed
FinalizeRealizationRun(...) re-rates usage, computes delta vs initial run totals, then:
allocateCreditsRealizations.Correct() with handler callback OnCreditsOnlyUsageAccruedCorrectionactive.realization.completed -> final
AdvanceAfterAdvanceUntilStateStable(...) loops until the machine can no longer fire TriggerNext.
The flat fee credits-only lifecycle is implemented in flatfee/service/creditsonly.go and flatfee/service/triggers.go. Types are in flatfee/statemachine.go.
Statuses (much simpler than usage-based — no collection period):
createdactivefinalTransitions:
created -> active
IsAfterInvoiceAt() (clock.Now() >= charge.Intent.InvoiceAt)AdvanceAfter to InvoiceAt while waitingactive -> final
active)AllocateCredits(...) calls handler.OnCreditsOnlyUsageAccrued(...) with State.AmountAfterProrationadapter.CreateCreditAllocations(...)AdvanceAfter on entering finalKey differences from usage-based credits-only:
Intent.AmountBeforeProration and stored in State.AmountAfterProration, no meter snapshot or ratingFeatureMeter or CustomerOverride neededflatfee.Status with only top-level states for credit-only (not usage-based-style sub-statuses like active.realization.*)flatfee.ChargeBase; credit allocations / payment / accrued usage live in flatfee.RealizationsService construction requires a *lockr.Locker (same as usage-based).
Handler interface: OnCreditsOnlyUsageAccrued(ctx, OnCreditsOnlyUsageAccruedInput) returns creditrealization.CreateAllocationInputs. The production implementation in ledger/chargeadapter/flatfee.go is stubbed as not-implemented; the test handler is in charges/service/handlers_test.go.
Flat fee credit-only charges start with InitialStatus: flatfee.StatusCreated (not Active). The invoiced path still starts as flatfee.StatusActive.
The collection-period logic is central to this package.
Rules:
usagebased.InternalCollectionPeriod is 1 minuteStoredAtLT is the exclusive stored-at query cap for the run (stored_at < StoredAtLT)ServicePeriodTo is the exclusive event-time upper bound for the run (event_time < ServicePeriodTo)ServicePeriodToStoredAtLTServicePeriodTo and StoredAtLTStoredAtLT, not a recomputed valueAdvanceAfterCollectionPeriodEnd(...) sets AdvanceAfter = StoredAtLT + InternalCollectionPeriodIsAfterCollectionPeriod(...) checks clock.Now() >= StoredAtLT + InternalCollectionPeriodOverrideCollectionPeriodEnd = StoredAtLT + InternalCollectionPeriod so invoice collection waits for the same internal buffer as the charge state machineFinal-run StoredAtLT currently uses:
CustomerOverride.MergedProfile.WorkflowConfig.Collection.IntervalCharge.Intent.ServicePeriod.ToDo not depend on a concrete customer-override record being present. The merged profile is the important input.
Usage-based quantity is derived through snapshotQuantity(...).
Important behavior:
ServicePeriodTostored_at < cutoffStoredAtLTGetDetailedRatingForUsage(...) owns the current-run filtering rule: only realization runs with ServicePeriodTo < input.ServicePeriodTo are prior runs; a current run already present on the charge must be ignored rather than stripped by mutating the charge in the callerThis means late-arriving events can become eligible in later advances if their stored_at was previously too new but later falls before the next cutoff.
Realization runs are the persisted checkpoint for collection progress.
Important rules:
RealizationRun.Type, not by separate active status branchesStoredAtLT, ServicePeriodTo, and MeteredQuantity must be persisted on the run and mapped back into the domain modelCurrentRealizationRunID points at the active run while waiting/finalizingCurrentRealizationRunIDRealizationRuns.WithoutVoidedBillingHistory().Latest() when a state-machine handler needs the last effective realized boundary; do not hand-roll max-by-service-period selection at call sitesPersistence gotcha:
usagebased/adapter/charge.go, use SetOrClearCurrentRealizationRunID(...)Set... and Clear... branches unless there is a specific reasonCharge status persistence is split across:
For all three charge types (usage-based, flat-fee, credit-purchase):
When status changes:
status_detailed to the full type-specific statususagebased.Status.ToMetaChargeStatus(), flatfee.Status.ToMetaChargeStatus(), and creditpurchase.Status.ToMetaChargeStatus() are the bridges between the full state-machine status and the root charge meta status.
Implement ToMetaChargeStatus() by validating the charge-type-specific status and then deriving the root status with meta.DetailedStatusToMetaStatus(string(status)). Do not enumerate detailed states in this conversion; detailed status lists belong in Values() and lifecycle transition matrices, and duplicated switch statements drift as states are added.
status_detailed is a Go-validated enum, not a DB constraintAll three charge types declare the column as field.Enum("status_detailed").GoType(<type>.Status("")), which in this repo's Atlas setup maps to a plain Postgres character varying column — there is no DB-level CHECK enumerating the values. Validation happens only in Go: the generated StatusDetailedValidator and Status.Validate(), both driven by Status.Values(). Consequences:
make generate so the generated Go validator and the Enums list in ent/db/migrate/schema.go match Values(), but it does not require an Atlas migration — atlas migrate --env local diff ... reports "no changes to be made" because the column type is unchanged. Do not hand-write a migration for a status_detailed value change.charges/statemachine.Machine) uses external storage whose state setter calls Status.Validate() on every transition. A Configure(...)/Permit(...) into a status that is absent from Status.Values() passes CanFire(...) but fails the moment the transition actually fires. Keep configureStates() and Values() in sync: every status reachable in the transition matrix must be listed in Values(), and a status that is configured but never fired (because orchestration bypasses it) is a latent bug — either wire it into Values() and fire it, or remove it from configureStates().Key tests:
openmeter/billing/charges/service/advance_test.goopenmeter/billing/charges/service/invoicable_test.goUse these conventions for lifecycle tests:
AdvanceCharges(...) when testing orchestrationmustGetChargeByID(...)AdvanceCharges(...) return as a secondary assertionnil, at minimum match its status to the DB-loaded chargeTearDownTest)streaming/testutils.WithStoredAt(...) to simulate late eventsstored_at == StoredAtLT is excluded, and an event with stored_at before StoredAtLT is includedevent_time == ServicePeriodTo is excludedstreamingtestutils.NewMockStreamingConnector(...) plus the real billing rating service when a usage-based rating test should exercise production quantity lookup, pricing, discounts, or commitments end-to-endclock.FreezeTime(...) for exact StoredAtLT / AllocateAt assertionsdatetime.MustParseTimeInLocation(t, "...Z", time.UTC).AsTime() so inline lifecycle variants use the same readable, failure-reporting representation as the surrounding charge testsCreate(...) itself may return an already-advanced charge — assert the returned charge's status, do not assume it will be createdmustAdvanceFlatFeeCharges(...) helper — it filters the advance result to flat fee charges onlymock.Mock with On(...).Run(...).Return(...).Once() for expected handler callbacks so missing or unexpected calls fail; in service-suite tests, leave callbacks unset when validating that a flow fails before callbacks, because the shared CreditPurchaseTestHandler already errors if an unset callback is invokedtime.Time fields on domain models are value typed; use s.False(ts.IsZero()) instead of s.NotNil(ts) when asserting they are populatedTest suite teardown:
BaseSuite.TearDownTest() (capital D — testify calls this automatically between tests) resets FlatFeeTestHandler, CreditPurchaseTestHandler, UsageBasedTestHandler, and MockStreamingConnectorTearDownTest (capital D) in all sub-suites; TeardownTest (lowercase d) is not called by testifyMockStreamingConnector events are shared across all tests in the suite — always rely on TearDownTest to reset them rather than deferBilling-profile test gotcha:
ProvisionBillingProfile(...) supports multiple edit optionsGetDefaultProfile(...)For direct package runs, use the repo env and Postgres. Prefer direct command execution; do not wrap these in sh -lc, bash -lc, or similar helper shells when a direct invocation works.
POSTGRES_HOST=127.0.0.1 direnv exec . go test -run TestInvoicableCharges/TestUsageBasedCreditOnlyLifecycle -v ./openmeter/billing/charges/service
POSTGRES_HOST=127.0.0.1 direnv exec . go test ./openmeter/billing/charges/...When changing charges:
AdvanceCharges(...) only advances supported types (usage-based and flat-fee credit-only)When changing usage-based charges:
nil means noop contract for AdvanceCharge(...)StoredAtLT, ServicePeriodTo, and MeteredQuantity persisted on realization runsstored_at < cutoff behavior explicit in testsWhen changing flat-fee charges:
Active and is driven by invoice lifecycle hooksCreated and is driven by the state machine — do not mix the twoAmountAfterProration lives on flatfee.State, not flatfee.Intent — it is computed at creation via Intent.CalculateAmountAfterProration() and persisted on the base charge row. Callers must not provide it; they set AmountBeforeProration, ServicePeriod, FullServicePeriod, and ProRating on the IntentIntentWithInitialStatus carries AmountAfterProration alongside InitialStatus to pass the computed value from the service to the adapter at creation timeflatfee.State.AdvanceAfter must be passed through chargemeta.UpdateInput.AdvanceAfter on every UpdateCharge(...) callflatfee.Charge.Realizations is expand-only data loaded from child tables; tests and service code should read payment/accrued-usage/credit-allocation state there, not from flatfee.Statecharge_flat_fees.status_detailed mirrors status today; schema changes or migrations that introduce new flat-fee statuses must keep both columns consistent through ToMetaChargeStatus()flatfee.Handler interface has both invoiced-path methods and credits-only methods — implementors must satisfy all of themHandler methods requires updating: ledger/chargeadapter/flatfee.go, charges/service/handlers_test.gousagebased.Handler — new methods must be added to UnimplementedHandler, the ledger adapter (ledger/chargeadapter/usagebased.go), and the test handlercreditpurchase.Handler — new methods must be added to ledger/chargeadapter/creditpurchase.go and the test handlerUsage-based handler interface (usagebased.Handler):
OnCreditsOnlyUsageAccrued(ctx, CreditsOnlyUsageAccruedInput) → creditrealization.CreateAllocationInputs — allocate credits for a realization runOnCreditsOnlyUsageAccruedCorrection(ctx, CreditsOnlyUsageAccruedCorrectionInput) → creditrealization.CreateCorrectionInputs — correct (partially revert) existing credit allocations when finalization discovers usage decreasedCredit purchase handler interface (creditpurchase.Handler):
OnPromotionalCreditPurchase(ctx, Charge) → ledgertransaction.GroupReferenceOnCreditPurchaseInitiated(ctx, Charge) → ledgertransaction.GroupReferenceOnCreditPurchasePaymentAuthorized(ctx, PaymentEventInput) → ledgertransaction.GroupReferenceOnCreditPurchasePaymentSettled(ctx, PaymentEventInput) → ledgertransaction.GroupReferenceflatfee/service/service.go Config requires a *lockr.Locker — when constructing in tests, create the locker before the flat fee service
When changing credit purchase charges:
creditpurchase.ChargeBase stores base-row data: ManagedResource, Intent, Status (own creditpurchase.Status type); State exists but is an empty structcreditpurchase.Intent.Currency is the currency being purchased. For external and invoice settlements, GenericSettlement.Currency is the fiat settlement currency (currencyx.FiatCode), so it intentionally differs from the intent currency when purchasing a custom currency.creditpurchase.Charge embeds ChargeBase + Realizations — all lifecycle outcomes live in Realizations, not Statecreditpurchase.Realizations holds CreditGrantRealization, ExternalPaymentSettlement, and InvoiceSettlement (all loaded from edge tables)CreditGrantRealization is stored in its own charge_credit_purchase_credit_grants table, not on the base rowcreditpurchase.Status mirrors the flatfee pattern: StatusCreated, StatusActive, StatusFinal, StatusDeleted with ToMetaChargeStatus() bridgecharge_credit_purchases.status_detailed column mirrors status and is set via SetStatusDetailed(...) on create/updatepaid events must authorize first so OnCreditPurchasePaymentAuthorized(...) observes active.payment.authorized without an external payment realization; run settlement before the transition to final is persisted, so OnCreditPurchasePaymentSettled(...) observes the authorized payment realization while the charge is still active.payment.authorizedOnActive(...) for credit-purchase state-owned realization side effects such as grant initiation and payment authorization; use OnExitWith(...) for settlement when the callback must run before moving to final without adding a durable intermediate statusstatus_detailed values only when callers need to observe or retry a durable lifecycle stateUpdateCharge(ctx, ChargeBase) (ChargeBase, error) only updates base-row fields — do not call it just because realization edges changedCreateCreditGrant, CreateExternalPayment, UpdateExternalPayment, CreateInvoicedPayment, UpdateInvoicedPaymentChargeAdapter + CreditGrantAdapter + ExternalPaymentAdapter + InvoicedPaymentAdapterwithExpands helper in creditpurchase/adapter/charge.go adds .WithCreditGrant().WithExternalPayment().WithInvoicedPayment() to queries when ExpandRealizations is requested — use this helper instead of repeating the expand chainHandleExternalPaymentAuthorized) update charge.Realizations in memory and return the full Charge without calling UpdateChargeHandleExternalPaymentSettled, onPromotionalCreditPurchase) call UpdateCharge(ctx, charge.ChargeBase) and merge the result back: charge.ChargeBase = updatedBaseadapter.CreateCreditGrant(...) — do not write credit grant data through UpdateChargeExternal-settled credit purchase (its own lifecycle, separate from promotional/invoice):
ExternalCreditPurchaseStateMachine (creditpurchase/service/external.go), built by onExternalCreditPurchase(...); promotional and invoice settlement each have their own state machine toocreditpurchase.ExternalSettlement carries InitialStatus (InitialPaymentSettlementStatus: created / authorized / settled). externalInitialPaymentTrigger(...) maps it to the lifecycle entry: created → no trigger (empty string), authorized → billing.TriggerAuthorized, settled → billing.TriggerPaidbilling.TriggerAuthorized / billing.TriggerPaid for the payment lifecycle; do not introduce external-specific trigger names even though external and invoice settlement run on separate state machinescreditpurchase/service/realizations (realizations.Service): InitiateExternalCreditPurchase, AuthorizeExternalPayment, SettleExternalPayment, AuthorizeAndSettleExternalPayment. Same separation rule as the flat-fee/usage-based realization helpers — the helper executes mechanics and must not decide which trigger fires or which status is entered (its doc comment states this). Its Config.Validate() returns models.NewNillableGenericValidationError(errors.Join(errs...))Realizations.ExternalPaymentSettlement (payment.ErrPaymentAlreadyAuthorized); settle rejects a nil settlement (payment.ErrCannotSettleNotAuthorizedPayment) or one not in payment.StatusAuthorized (payment.ErrPaymentAlreadySettled)lineage.BackfillAdvanceLineageSegments(...) with FeatureFilters: charge.Intent.FeatureFilters.Normalize() — promotional (promotional.go), invoice (invoice.go), and external (realizations/service.go InitiateExternalCreditPurchase). Omitting FeatureFilters silently produces over-broad lineage segments for feature-scoped grants; guard each call on a non-empty TransactionGroupIDGenericSettlement.Validate() enforces it and InitiateExternalCreditPurchase re-checks before creating the grant realizationThe creditrealization package (openmeter/billing/charges/models/creditrealization/) defines the domain model for credit allocations and corrections (partial/full reverts).
CreateAllocationInput — positive-amount allocation input (has LineID). Collection type: CreateAllocationInputs.CreateCorrectionInput — positive-amount correction request (has CorrectsRealizationID). Collection type: CreateCorrectionInputs.CreateInput — unified DB write input (used by both allocations and corrections). Has Type field (TypeAllocation or TypeCorrection). Collection type: CreateInputs.Realization — full model read from DB, embeds CreateInput + NamespacedModel + ManagedModel + SortHint.Realizations — slice of Realization with query/aggregation methods.Amount in CreateInput and DB.Amount in CreateInput and DB (negated by AsCreateInputs).CreateCorrectionInput.Amount is always positive (the amount to correct). It gets negated when converting to CreateInput via CreateCorrectionInputs.AsCreateInputs().Realizations.Sum() returns the net total (allocations minus corrections).allocationsWithCorrections() computes remaining amounts by calling .Sub(corrections.Sum()) on each allocation. Since corrections have negative amounts in the DB, corrections.Sum() is negative, and .Sub(negative) correctly adds back — resulting in remaining = allocation + |corrections|. This is wrong — it makes remaining larger than the allocation. This is a known sign-convention risk: the Sub works correctly only if corrections are stored with positive amounts (old convention) or if the code is updated to use .Add(corrections.Sum()) with the new negative convention.The full correction orchestration is Realizations.Correct(amount, currency, callback):
CreateCorrectionRequest(amount, currency) — builds CorrectionRequest items in reverse creation order (latest allocation first)CorrectionRequest.ValidateWith(currency) — validates the request itemscallback(req) — caller (ledger handler) maps request items to CreateCorrectionInputs with ledger transaction referencesCreateCorrectionInputs.ValidateWith(realizations, totalAmount, currency) — validates corrections don't exceed remaining per-allocation amountsCreateCorrectionInputs.AsCreateInputs(realizations) — maps to []CreateInput with negated amounts, copies ServicePeriod from the corrected allocationTests are in correction_test.go (same package, not _test). Reusable helpers:
allocationBuilder — builds allocation Realization entries with auto-incrementing SortHint and configurable CreatedAtcorrectionFor(allocation, amount) — builds a correction Realization targeting a given allocationcorrectionCallback(txGroupID) — returns a func(CorrectionRequest) (CreateCorrectionInputs, error) for use with Correct()correctionRequestAmounts(cr) / correctionRequestAllocationIDs(cr) — extract slices for assertionscorrectionInputsSum(inputs) — sums CreateCorrectionInputs amountstestCurrency(t) — returns a USD currencyx.CalculatorTest structure follows the rate_test pattern: declarative test cases with t.Run subtests, shared helpers, no DB required.
resolveFeatureMeters(ctx, namespace, charges) takes an explicit namespace argument — do not access charges[0].Namespace directly (panics on empty slice)GetByMetas re-orders output to match input order; use lo.KeyBy (not lo.GroupBy) when building an intermediate lookup map — GroupBy produces map[K][]V and requires [0] indexing, KeyBy gives map[K]V directlyrefetchCharge in the state machine is a known interim pattern — the preferred direction is in-memory charge updates after adapter writes; avoid adding new refetchCharge calls without discussionbuildCreateUsageBasedCharge is a builder chain — do not call the same setter twice (Ent builder chains accept duplicate .SetX calls silently, the last one wins)currencyx.Calculator.IsRoundedToPrecision(amount) is the preferred way to check if an amount is rounded to currency precision — use it instead of manual RoundToPrecision(x).Equal(x) patterns1cdc2cb
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.