CtrlK
BlogDocsLog inGet started
Tessl Logo

charges

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

Quality

81%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Medium

Suggest reviewing before use

SKILL.md
Quality
Evals
Security

Charges

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.

Scope

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.go
  • openmeter/billing/charges/service/advance_test.go

When 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.md
  • openmeter/billing/charges/usagebased/service/rating/periodpreserving/README.md
  • openmeter/billing/charges/usagebased/service/rating/subtract/README.md

Current Design

openmeter/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
    • public entrypoint for Create(...), GetByID(...), GetByIDs(...), AdvanceCharges(...)
  • charges/meta
    • shared charge metadata, charge type, short status, and common query surface
  • charges/service
    • orchestration across charge types
  • charges/usagebased
    • the deepest current lifecycle implementation

The generic rule is:

  • the root charges package owns cross-type orchestration
  • type-specific packages own type-specific lifecycle and persistence
  • AdvanceCharges(...) is a facade method, not the state machine itself
  • type-specific service subpackages may own reusable realization mechanics when the parent service becomes too broad; keep state-machine decisions in the lifecycle files and move only mechanical operations such as rating snapshots, realization persistence, credit allocation/correction, and realization lineage persistence into these helpers
  • invoice-backed charges must not reach the meta final 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 final

Adapter rules:

  • Keep Ent access transaction-aware in 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.
  • For flat-fee and usage-based adapters, 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 advance
  • meta.Charge and meta.ChargeID define the shared charge identity and type
  • charges.Charge wraps concrete charge variants
  • flatfee.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 State
  • flatfee.State currently tracks:
    • AmountAfterProration
    • AdvanceAfter
    • FeatureID
  • flatfee.Realizations stores expanded, non-base data loaded from child tables:
    • CreditRealizations
    • AccruedUsage
    • Payment
    • DetailedLines
  • flatfee.Intent.CalculateAmountAfterProration() computes the prorated amount from AmountBeforeProration, ServicePeriod/FullServicePeriod ratio, and ProRating config, with currency-precision rounding
  • Flat-fee and usage-based charge-backed targets do not use invoice-style semantic proration or empty-period filtering. The charge stack materializes, prorates, and omits empty invoice artifacts according to the charge type's lifecycle rules.
  • usagebased.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 State
  • usagebased.State currently tracks:
    • CurrentRealizationRunID
    • AdvanceAfter
  • usagebased.RealizationRunBase stores:
    • Type
    • StoredAtLT
    • ServicePeriodTo
    • MeteredQuantity
    • Totals
  • usagebased.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:
    • DetailedLines

Intent 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.
  • Flat-fee and usage-based intent overrides are type-owned domain objects stored in dedicated one-to-one override tables. Override presence is represented by the override row existing. The older embedded override_* charge columns are compatibility/deprecated fields and should not be used for active override behavior.
  • Delete flows should first update the right intent deletion field, then resolve charge.DeletedAt from the aggregate (GetIntentDeletedAt()), and then persist the type-specific row plus charge meta.
  • Credit-purchase charges do not participate in intent override deletion semantics; do not add IntentDeletedAt fields or persistence to credit-purchase paths.

Intent layer access rules:

  • Customer-facing charge behavior should read the effective layer through dedicated OverridableIntent accessors such as GetEffectiveServicePeriod(), GetEffectiveInvoiceAt(), GetEffectiveTaxConfig(), GetEffectiveFeatureKey(), GetEffectivePrice(), or GetEffectiveMetaIntentMutableFields().
  • Prefer field-specific effective getters over 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.
  • Subscription sync and repair code targets subscription-owned source state. It should use GetBaseIntent() or base-specific helpers, not effective getters, so user/API overrides do not feed back into the subscription target state.
  • Adapter writes should persist the base intent with 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:

  • Charge patch payloads carry billing.ChangeSource source intent instead of an explicit target whenever the target can be derived. Do not rely on a zero/default source.
  • Subscription sync emits system-sourced patches because it reconciles subscription-owned source state, not user/API overrides.
  • State machines resolve the effective target layer from base intent ownership, override presence, and patch source. System period patches target base only and must reject API-sourced period changes. API manual edits/deletes target an existing override when present, target base for manually managed base intents without an override, and create/target an override for subscription/system-owned base intents.
  • Type-specific state machines own only active customer-facing charge lifecycle: status transitions, realization runs, credit corrections, and invoice patches. If a patch targets the base layer while an override exists, that base layer is hidden source state and the state machine must reject it instead of treating it as a lifecycle no-op.
  • Subscription-sync and repair paths may still need to reconcile hidden base/source intent fields, such as base period shrink/extend/delete under an active override. Apply those updates before state-machine dispatch in service-level reconciliation, persist only the source intent, and do not emit invoice patches or mutate realization/customer-facing state from that path.
  • Delete patches follow the same target rule: deleting the hidden base layer while an override is active should mark only the base intent deleted through the service-level source reconciliation path and leave the effective override-backed charge behavior intact.
  • Charge listing for subscription-sync persisted-state loading must filter on base-intent deletion state, not effective DeletedAt; otherwise an active override can hide a subscription-owned base charge that sync still needs to reconcile.
  • Gathering-line upsert patches are target-state patches. 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 Invoice Line Mapping

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.
  • Reuse the standard billing usage-discount mutator contract (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.
  • Use the standard line's real 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.ExpandRealizations
  • meta.ExpandDeletedRealizations is not standalone for charge reads; it requires meta.ExpandRealizations
  • usage-based deleted realization runs are hidden from meta.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 tests
  • production code that sums or interprets realization history must explicitly skip runs with DeletedAt != nil, documenting the business reason at the guard, because deleted realizations are no longer effective billable history
  • usage-based invalid_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.
  • usage-based detailed lines live under RealizationRun.DetailedLines
  • flat-fee detailed lines also live under Charge.Realizations.DetailedLines, not on the root charge
  • mo.None() means detailed lines were not expanded; present options mean expanded data, even when the underlying slice is nil/empty

Billing Line Engines

Charge-backed gathering lines must carry the correct billing line engine when they are created.

Current engine values:

  • billing.LineEngineTypeChargeFlatFee
  • billing.LineEngineTypeChargeUsageBased
  • billing.LineEngineTypeChargeCreditPurchase

Current implementations:

  • flat fee line engine: openmeter/billing/charges/flatfee/service/lineengine.go
  • credit purchase line engine: openmeter/billing/charges/creditpurchase/lineengine
  • usage-based line engine: openmeter/billing/charges/usagebased/service/lineengine.go

Important rules:

  • do not rely on billing to infer a charge-backed engine from ChargeID
  • billing/service.CreatePendingInvoiceLines(...) rejects charge-backed gathering lines with empty Engine
  • production wiring must register charge line engines through billing.Service.RegisterLineEngine(...)
  • tests that temporarily add engines can remove them again through billing.Service.DeregisterLineEngine(...); use the public registry API instead of mutating billing internals from non-service packages
  • charge test setups must also register those engines explicitly; keep this in openmeter/billing/charges/testutils
  • charge-enabled app/test wiring must also register the charge-aware CreateLineRouter; billing's default create router intentionally falls back to legacy billing.LineEngineTypeInvoice
  • if a charge create path stamps a new LineEngineType, app wiring and charge test wiring must register a matching implementation in the same change
  • for charge-backed standard line creation through invoice API edits, billing preallocates the standard line ID before invoking the charge line engine; charge engines should attach charge/realization state to that existing line identity instead of creating a separate line identity
  • flat-fee and usage-based services expose billing line engines through GetLineEngine(); register the returned engine instead of reusing the service type directly
  • because flat-fee owns its line engine, flatfee/service.New(...) requires a rating.Service; forgetting that dependency breaks app/test wiring with rating service cannot be null
  • invoice-backed charge line engines must map persisted run/realization state back onto returned standard invoice lines after run creation. Do not rely on state-machine methods mutating a standard-line pointer as their public contract.
  • For invoice-backed charge line engines, flat-fee and usage-based should follow the same structural contract: charge state machines emit invoice patches, and line engines consume those patches at the billing boundary. Standard-line delete patches must flow through the same deleted-standard-line cleanup used by OnMutableStandardLinesDeletedBySystem(...) so credit corrections, charge-owned detailed-line cleanup, and deleted-run marking stay consistent.
  • Invoice-backed charge API invoice-line edits/deletes are mediated by emitted invoice patches. Standard-line edits use update target-state patches, gathering-line edits use upsert target-state patches, gathering-line delete patches are validated locally, and standard-line delete patches invoke deleted-standard-line cleanup after the state machine detaches the current run. Zero-proration delete patches are currently rejected unless the charge engine explicitly models that delete result.
  • Billing calls 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(...).
  • API-created invoice-backed charge lines are preallocated and provisionally persisted by billing before charge line-engine dispatch. Charge engines must require the billing-owned line ID, create charge/realization state against that line identity when the charge lifecycle needs it, and return target state merged onto the preallocated line. Do not create a second invoice-line identity inside the charge package for the same customer-facing line.
  • If a charge line engine creates charge intents directly, it must preserve root charges create semantics that would otherwise be bypassed. When an API-created line omits tax config, use the billing edit callback's default tax-code resolver rather than adding a charge-service dependency on tax-code lookup.
  • When a charge line engine creates an intent from an API-created invoice line, use 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.
  • Usage-based API invoice-line creates build manually managed 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.
  • Usage-based API invoice-line updates are not supported yet and should still return 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.
  • For usage-based gathering-line API delete with existing realizations, 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.
  • for usage-based realization creation, validate at the run-creation boundary (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_invoice
  • usage-based payment handling is intentionally different from flat-fee and credit-purchase: the usage-based state machine owns realization only, while the usage-based line engine/run service records payment authorization/settlement directly on historical runs and only re-enters the state machine through an aggregate trigger (for example all_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.
  • usage-based invoice branches use the unified 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.
  • when adding or renaming usage-based detailed statuses, remember that 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 changes
  • when charge code deletes an empty standard invoice, call billing DeleteInvoice 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 history

Flat-fee credit-then-invoice lifecycle rules:

  • Flat-fee invoice lifecycle behavior must be driven by 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.
  • The charges standard-invoice hook may still run for cross-charge concerns such as revenue recognition and credit-purchase callbacks. For flat-fee invoice statuses, keep the hook processors as no-ops and let the line engine own OnStandardInvoiceCreated, OnCollectionCompleted, OnInvoiceIssued, OnPaymentAuthorized, OnPaymentSettled, and mutable-line cleanup.
  • Flat-fee 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.
  • Flat-fee 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.
  • Flat-fee realization runs use 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.
  • For flat-fee shrink/extend/manual edit before standard-line creation, update the pending gathering line by charge ID using a full target-state gathering-line patch. Only zero-proration pending gathering targets should emit a delete-by-charge patch. For a mutable standard-line-backed current run, update the same standard line in place. For an immutable current run, keep the old invoice history intact; if the recalculated amount is unchanged, emit no customer-visible invoice change, and if the amount changes, detach the run, create a replacement gathering line, reset the charge to created, and set AdvanceAfter to the replacement service-period start.
  • Flat-fee API invoice-line edits are handled by the flat-fee line engine through the 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.
  • Flat-fee shrink/extend should reject while invoice issuing/completion callbacks own the charge state. Subscription sync can retry after billing advances out of those transient states.
  • Flat-fee API invoice-line edits may change customer-facing line fields such as service period, invoice-at, price, tax config, and discounts as long as the line remains flat-fee. The edit should persist through the flat-fee manual override path; subscription sync continues reconciling the base/source state and should not rewrite the override-backed effective line.
  • Flat-fee invoice accrual should not create an accrued-usage row or call the ledger-backed accrual handler when the standard line total is zero. The run can still become immutable and move through the no-fiat finalization path.
  • Flat-fee payment booking is line/run based rather than current-run only: asynchronous payment callbacks must locate the realization run by standard-line ID so detached historical runs can still receive authorization/settlement. No-fiat runs skip payment booking callbacks.

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.
  • Do not stretch an in-progress final realization run to cover the extension. There can only be one active run, and stretching the old final run would keep the run open across the extension and delay the standard invoice lifecycle.
  • If the current invoice-backed realization run reaches the old effective service-period end and is still backed by a mutable invoice line, the extend flow should update the charge intent, emit an invoice-line delete patch for that mutable standard line, and let the billing invoice updater/line engine delete the invoice line and mark the run deleted. The charge should move back to active with AdvanceAfter set to the new service-period end so the extended tail can realize later.
  • While an invoice-backed run that reaches the old effective service-period end is before invoice issuing (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.
  • Once an invoice-backed run reaches 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.
  • Extending from 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.
  • If the old terminal realization has already passed into immutable invoice territory, keep the invoice and ledger untouched. Reclassify the old final run as a partial invoice run and move the charge back to 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.
  • Pending gathering lines for the charge should be extended in place by charge ID using a full target-state gathering-line patch. The target line's service period must cover only the remaining charge period not already represented by non-voided realization runs; do not rebuild the pending gathering line from the whole effective charge period once prior runs exist. Existing standard invoice lines should only be deleted in the mutable-current-final case above.
  • These rules are intentionally about not blocking the invoice train: extension should not hold a standard invoice lifecycle open while waiting for newly extended usage windows to finish.

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.
  • Shrink is native for usage-based 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.
  • Pending gathering lines should be shrunk in place by charge ID using a full target-state gathering-line patch for the remaining unbilled period. This updates the line service period, invoice-at, price/tax/discount state, and subscription billing period through the invoice updater.
  • If the current invoice-backed realization run is still backed by a mutable invoice line (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.
  • In 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.
  • Shrink must reject if a non-deleted run beyond the new service-period end is not invoice-backed. Do not prorate, rewrite immutable invoices, or reverse immutable ledger bookings from the charge state machine.
  • Shrink is explicitly unsupported in issuing/completed invoice states and deleted. Subscription sync can retry after billing advances when the invoice lifecycle owns the current state.

Operational consequence:

  • adding a new charge engine enum without a registered implementation causes invoice collection to fail when billing resolves the line engine
  • a schema migration defaulting old persisted rows to 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 rollout

Current shared contract details:

  • line-engine transport structs in openmeter/billing/lineengine.go are validated at the billing callsite before invoking the engine, and returned lines/results are validated after the call
  • OnCollectionCompleted(...) takes billing.OnCollectionCompletedInput and returns updated billing.StandardLines
  • collection-time engines must preserve the exact line ID set they were given; billing validates that returned line IDs match input line IDs exactly and then merges the returned lines back into the invoice
  • CalculateLines(...) 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 mutation
  • CalculateLines(...) 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 workaround
  • SplitGatheringLine(...) 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 aggregate
  • optional split outputs use pointers; SplitGatheringLineResult.PostSplitAtLine is *billing.GatheringLine
  • use billing.ValidateStandardLineIDsMatchExactly(...) when a charge-side test or helper needs to assert that returned standard-line identities are preserved across a line-engine boundary
  • collection-time errors should be normalized through billing.NewLineEngineValidationError(...) instead of rebuilding the validation-issue wrapper at each billing callsite
  • charge line engines are only responsible for invoice-backed charge flows such as credit_then_invoice; they are not the execution path for credit_only settlement mode
  • because of that boundary, it is acceptable for a charge line engine to return an error when invoked with credit_only settlement mode; treat that as a lifecycle misuse rather than adding credit_only behavior to the engine
  • charge line engines own API-edit rejection through OnMutableInvoiceLinesEditedViaAPI(...); return billing.ErrCannotUpdateChargeManagedLine for unsupported charge-managed create/update/delete edits instead of pre-rejecting them in billing or HTTP code

Timestamp Normalization

Charge persistence assumes timestamp precision is bounded by streaming aggregation precision.

Rules:

  • persisted charge timestamps must be truncated to streaming.MinimumWindowSizeDuration
  • meta.NormalizeTimestamp(...) is the shared primitive; it also converts to UTC
  • meta.NormalizeClosedPeriod(...) and Intent.Normalized() helpers are the domain-level normalization entrypoints
  • normalize intent timestamps before validation and before any derived calculation that depends on durations or boundaries
  • flat-fee proration must use normalized periods, otherwise sub-second inputs can change AmountAfterProration
  • for usage-based lifecycle timestamps (AdvanceAfter, StoredAtLT, ServicePeriodTo), normalize the computed timestamp before persisting it or handing it to downstream persistence callbacks
  • do not normalize deletion timestamps such as DeletedAt; they should preserve the caller-provided instant and precision

Important timestamp surfaces:

  • meta.Intent.ServicePeriod
  • meta.Intent.FullServicePeriod
  • meta.Intent.BillingPeriod
  • flatfee.Intent.InvoiceAt
  • usagebased.Intent.InvoiceAt
  • flatfee.State.AdvanceAfter
  • usagebased.State.AdvanceAfter
  • usagebased.CreateRealizationRunInput.StoredAtLT
  • usagebased.CreateRealizationRunInput.ServicePeriodTo
  • usagebased.UpdateRealizationRunInput.StoredAtLT

Placement guidance:

  • prefer domain-side normalization when constructing or mutating intents and state (Intent.Normalized(), state-machine transition logic, temporary patch remap)
  • keep a persistence backstop in shared write helpers such as charges/models/chargemeta
  • in adapters, normalize at the actual write setter (SetInvoiceAt(...), SetStoredAtLt(...), SetServicePeriodTo(...), SetOrClearAdvanceAfter(...)) rather than rewriting the whole input object at the top of the adapter method
  • do not add redundant .UTC() calls after meta.NormalizeTimestamp(...); the helper already returns UTC

Currency Normalization

Charge lifecycle code owns currency rounding.

Rules:

  • all currency amounts that enter or leave the charge lifecycle must be rounded with the charge currency calculator
  • normalize charge-domain totals and inputs in charge code where charges own the amount
  • ledger-backed allocation realizations may be stored exactly as returned by ledger-owned handlers for credits-only flows
  • correction request and correction creation outputs are normalized in the shared creditrealization.Realizations.Correct(...) / CorrectAll(...) path
  • zero-valued corrections are allowed after rounding; treat them as a no-op instead of an error

Important money surfaces:

  • creditpurchase.Intent.CreditAmount
  • flatfee.Intent.AmountBeforeProration
  • flatfee.State.AmountAfterProration
  • usage-based realization Totals
  • flatfee.OnAssignedToInvoiceInput.PreTaxTotalAmount
  • flatfee.OnCreditsOnlyUsageAccruedInput.AmountToAllocate
  • usagebased.CreditsOnlyUsageAccruedInput.AmountToAllocate
  • creditrealization.CreateAllocationInputs
  • creditrealization.CreateCorrectionInputs

Placement guidance:

  • normalize durable charge inputs in domain helpers such as Intent.Normalized()
  • for flat fees, make sure AmountAfterProration is already rounded when calculated; adapters should persist it as-is
  • when calling charge handlers that feed ledger-backed allocations/corrections, decide explicitly whether charges or ledger owns the returned amount; for current credits-only allocation flows, store ledger-returned allocation amounts as-is
  • prefer shared normalization in creditrealization helpers for correction flows instead of repeating callback-local normalization at each callsite
  • do not mutate whole intents inside adapters just to normalize currency; if an adapter needs a persistence backstop, keep it local to the Set* write
  • when ledger logic derives monetary values from balances, entries, or its own calculations, round those values in ledger code as well; do not rely only on upstream callers

Zero Invoice Accrual

Invoice accrual uses a non-negative, no-op-aware contract.

Rules:

  • negative invoice-accrual amounts are invalid
  • zero invoice-accrual amounts are valid no-ops
  • positive invoice-accrual amounts must produce a non-empty ledger transaction group reference
  • charges-side services should short-circuit zero before calling persistence that expects a real ledger transaction
  • do not persist invoicedusage.AccruedUsage rows with an empty LedgerTransaction.TransactionGroupID

Current expected behavior:

  • usagebased.OnInvoiceUsageAccruedInput.Validate() allows zero and rejects only negatives
  • usage-based and flat-fee service/orchestration layers should skip invoice-accrual persistence when the invoice line total is zero
  • ledger handlers may still defensively tolerate zero and return ledgertransaction.GroupReference{}
  • when a service proceeds with non-zero invoice accrual, it must require a non-empty transaction group reference before storing accrued usage

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.

HTTP/API Conversion

Credit-purchase charges have an API/domain enum mismatch for promotional grants.

Rules:

  • in the billing domain, promotional credit grants are represented as creditpurchase.SettlementTypePromotional
  • in the v3 customer credits API, the same case is represented as funding_method=none
  • v3 API responses for promotional grants must omit the purchase block entirely
  • conversion code in api/v3/handlers/customers/credits must map this case explicitly instead of treating promotional as an unsupported settlement type

Important files:

  • api/v3/handlers/customers/credits/convert.go
  • openmeter/billing/charges/creditpurchase/settlement.go
  • openmeter/billing/creditgrant/service/service.go
  • api/spec/packages/aip/src/customers/credits/grant.tsp

Realization Helper Subpackages

Use 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:

  • state-machine files decide which trigger/status/action applies for a settlement mode
  • realization helper packages execute reusable mechanics once that decision has already been made
  • helper packages must not decide which trigger to fire, which status to enter, or whether a charge lifecycle event should advance

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:

  • inject only the dependencies the helper needs, usually adapter, handler, lineage, and any rating helper
  • expose struct-returning methods for multi-value outcomes
  • keep credit allocation/correction exactness explicit at the call site, because credit_only and credit_then_invoice can differ
  • keep lineage persistence next to realization creation so allocation and correction paths do not duplicate lineage bookkeeping
  • keep the parent service responsible for transactions, locking, and building settlement-mode-specific state machines

Supported Behavior

  • charges.AdvanceCharges(...) advances both usage-based and flat-fee credit-only charges
  • usagebased.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(...)
  • Both AdvanceCharge(...) methods return *Charge (nil means noop, non-nil means at least one transition)
  • For invoice-settled flows, invoice creation and collection completion are still billing-triggered downstream events (invoice_created, collection_completed), not generic advance-loop transitions

Create + Auto-Advance Flow

charges.Create(...) runs in two phases:

  1. Transaction phase — creates all charge records (flat-fee, usage-based, credit-purchase) and any gathering invoice lines
  2. Post-create auto-advanceautoAdvanceCreatedCharges(...) runs outside the transaction so that creation is persisted even if advancing fails (a worker can retry later)

autoAdvanceCreatedCharges(...) (charges/service/create.go):

  • Iterates over the created charges using a type switch (ChargeTypeUsageBased, ChargeTypeFlatFee)
  • Collects unique customer IDs that have credit-only charges of either type
  • Calls s.AdvanceCharges(...) (the facade) once per unique customer
  • Merges advanced charges back into the result by charge ID

This 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:

  • invoice-settled charge creation must stamp gathering lines with the matching billing line engine whenever that charge type participates in billing line-engine collection. Flat-fee and usage-based must stamp 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.
  • usage-based IsLineBillableAsOf(...) is currently billable only once asOf >= resolved service period end; keep the existing progressive-billing TODO in place when touching that logic
  • for invoice-backed flat-fee and usage-based charges, BuildStandardInvoiceLines(...) is allowed to drive charge lifecycle transitions needed to create or attach the invoice-backed realization/run state
  • OnCollectionCompleted(...) is the single collection-time line-engine hook; do not reintroduce a generic shared SnapshotLines() abstraction for charge engines
  • prefer explicit charge lifecycle triggers such as invoice_created and collection_completed over generic line-snapshot callbacks
  • collection-time charge-engine failures must surface as invoice validation issues through the billing line-engine validation flow, not as raw invoice-state-machine failures

Root Charges Advance Flow

The root-facade advance flow is:

  1. charges.AdvanceCharges(...) lists non-final charge metas for the customer
  2. It partitions charges by type using chargesByType(...)
  3. Early return if no usage-based and no flat-fee charges
  4. For flat-fee credit-only charges: calls flatfee.Service.AdvanceCharge(...) per charge (no customer override or feature meters needed)
  5. For usage-based charges: resolves merged customer profile, feature meters, then calls usagebased.Service.AdvanceCharge(...) per charge

Key package responsibilities:

  • charges/service/advance.go
    • customer-scoped orchestration
    • preloads customer/profile + feature context for usage-based only
    • flat-fee credit-only charges are self-contained (fixed amount, no meters)
  • charges/meta/adapter
    • lists non-final charges for a customer
  • charges/lock
    • provides NewChargeKey(...) for charge-scoped locking

Usage-Based Advance Flow

Usage-based advance currently does:

  1. Validates:
    • ChargeID
    • expanded customer in CustomerOverrideWithDetails
    • valid merged profile
    • resolved feature meter
  2. Takes a charge-scoped lock using:
    • charges/lock.NewChargeKey(...)
    • *lockr.Locker
  3. Reloads the charge with realizations expanded
  4. Routes by settlement mode
  5. Builds the settlement-mode-specific state machine (NewCreditsOnlyStateMachine(...) or NewCreditThenInvoiceStateMachine(...))
  6. Calls AdvanceUntilStateStable(...)

This is the main place where charge lifecycle logic exists today.

State-machine organization rule:

  • keep state-machine-specific methods and helpers in the state-machine file that owns them
  • do not spread one charge state machine across multiple files unless the split is the standard service/ or adapter/ package boundary
  • line-engine files may call into a state machine, but they should not define that state machine's transition handlers

Credits-Only State Machine

The credits-only lifecycle is implemented in usagebased/service/creditsonly.go and usagebased/service/statemachine.go.

Relevant statuses:

  • created
  • active
  • active.realization.started
  • active.realization.waiting_for_collection
  • active.realization.processing
  • active.realization.completed
  • final

High-level transitions:

  1. created -> active
    • guarded by IsInsideServicePeriod()
    • sets AdvanceAfter to service-period start while waiting
  2. active -> active.realization.started
    • guarded by IsAfterServicePeriod()
    • sets AdvanceAfter to service-period end while waiting
  3. active.realization.started -> active.realization.waiting_for_collection
    • StartFinalRealizationRun(...) creates the realization run
  4. active.realization.waiting_for_collection -> active.realization.processing
    • guarded by IsAfterCollectionPeriod(...)
  5. active.realization.processing -> active.realization.completed
    • FinalizeRealizationRun(...) re-rates usage, computes delta vs initial run totals, then:
      • positive delta → allocates additional credits via allocateCredits
      • negative delta → corrects existing allocations via Realizations.Correct() with handler callback OnCreditsOnlyUsageAccruedCorrection
      • zero delta → no-op
  6. active.realization.completed -> final
    • clears AdvanceAfter

AdvanceUntilStateStable(...) loops until the machine can no longer fire TriggerNext.

Flat Fee Credits-Only State Machine

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):

  • created
  • active
  • final

Transitions:

  1. created -> active
    • guarded by IsAfterInvoiceAt() (clock.Now() >= charge.Intent.InvoiceAt)
    • sets AdvanceAfter to InvoiceAt while waiting
  2. active -> final
    • unconditional (fires immediately after active)
    • AllocateCredits(...) calls handler.OnCreditsOnlyUsageAccrued(...) with State.AmountAfterProration
    • validates credit allocations sum equals amount
    • persists credit realizations via adapter.CreateCreditAllocations(...)
    • clears AdvanceAfter on entering final

Key differences from usage-based credits-only:

  • No collection period, no two-phase realization
  • Amount is computed at creation from Intent.AmountBeforeProration and stored in State.AmountAfterProration, no meter snapshot or rating
  • No FeatureMeter or CustomerOverride needed
  • Uses flatfee.Status with only top-level states for credit-only (not usage-based-style sub-statuses like active.realization.*)
  • Persists only flatfee.ChargeBase; credit allocations / payment / accrued usage live in flatfee.Realizations

Service 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.

Collection Period Semantics

The collection-period logic is central to this package.

Rules:

  • usagebased.InternalCollectionPeriod is 1 minute
  • StoredAtLT 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)
  • final usage-based runs use the charge intent's service-period end as ServicePeriodTo
  • final usage-based runs use the charge service-period end plus the billing profile collection interval as StoredAtLT
  • partial invoice runs use the standard line period end as both ServicePeriodTo and StoredAtLT
  • waiting logic must use the persisted run StoredAtLT, not a recomputed value
  • AdvanceAfterCollectionPeriodEnd(...) sets AdvanceAfter = StoredAtLT + InternalCollectionPeriod
  • IsAfterCollectionPeriod(...) checks clock.Now() >= StoredAtLT + InternalCollectionPeriod
  • usage-based standard invoice lines should set OverrideCollectionPeriodEnd = StoredAtLT + InternalCollectionPeriod so invoice collection waits for the same internal buffer as the charge state machine

Final-run StoredAtLT currently uses:

  • CustomerOverride.MergedProfile.WorkflowConfig.Collection.Interval
  • added to Charge.Intent.ServicePeriod.To

Do not depend on a concrete customer-override record being present. The merged profile is the important input.

Rating and Event Snapshot Semantics

Usage-based quantity is derived through snapshotQuantity(...).

Important behavior:

  • query window starts at the charge intent's service-period start
  • query window ends at the run's ServicePeriodTo
  • stored-at filtering uses stored_at < cutoff
  • the cutoff is the run's StoredAtLT
  • the service-period end is expected to behave as exclusive in lifecycle tests
  • GetDetailedRatingForUsage(...) 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 caller
  • minimum commitment is final-only for usage-based snapshots; detailed rating ignores it when the current service-period end is before the charge intent service-period end
  • realtime/current totals should ignore minimum commitment before the charge intent service-period end and include it at/after the service-period end

This 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

Realization runs are the persisted checkpoint for collection progress.

Important rules:

  • invoice-backed and credits-only realization steps create runs; final vs partial behavior is represented on RealizationRun.Type, not by separate active status branches
  • StoredAtLT, ServicePeriodTo, and MeteredQuantity must be persisted on the run and mapped back into the domain model
  • CurrentRealizationRunID points at the active run while waiting/finalizing
  • finalization must clear CurrentRealizationRunID
  • use RealizationRuns.WithoutVoidedBillingHistory().Latest() when a state-machine handler needs the last effective realized boundary; do not hand-roll max-by-service-period selection at call sites

Persistence gotcha:

  • in usagebased/adapter/charge.go, use SetOrClearCurrentRealizationRunID(...)
  • do not hand-roll separate Set... and Clear... branches unless there is a specific reason

Status Persistence

Charge status persistence is split across:

  • the shared meta charge row
  • the charge-type-specific row

For all three charge types (usage-based, flat-fee, credit-purchase):

When status changes:

  • update the meta charge status to the short/meta status
  • update the type-specific charge status_detailed to the full type-specific status

usagebased.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 constraint

All 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:

  • Adding or removing a detailed status requires 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.
  • The shared state-machine base (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().

Testing Guidance

Key tests:

  • openmeter/billing/charges/service/advance_test.go
  • openmeter/billing/charges/service/invoicable_test.go

Use these conventions for lifecycle tests:

  • validate root-facade behavior through AdvanceCharges(...) when testing orchestration
  • validate persisted state through mustGetChargeByID(...)
  • only use the direct AdvanceCharges(...) return as a secondary assertion
  • if a returned charge is non-nil, at minimum match its status to the DB-loaded charge
  • install usage-based handler callbacks only in the subtests that expect them (handler is reset in TearDownTest)
  • use streaming/testutils.WithStoredAt(...) to simulate late events
  • when testing stored-at cutoffs, remember the predicate is exclusive: an event with stored_at == StoredAtLT is excluded, and an event with stored_at before StoredAtLT is included
  • when testing service-period cutoffs, remember the event-time window is half-open: an event with event_time == ServicePeriodTo is excluded
  • prefer streamingtestutils.NewMockStreamingConnector(...) plus the real billing rating service when a usage-based rating test should exercise production quantity lookup, pricing, discounts, or commitments end-to-end
  • prefer clock.FreezeTime(...) for exact StoredAtLT / AllocateAt assertions
  • define fixed UTC test-fixture timestamps with datetime.MustParseTimeInLocation(t, "...Z", time.UTC).AsTime() so inline lifecycle variants use the same readable, failure-reporting representation as the surrounding charge tests
  • rely on the default billing profile unless the test explicitly needs customer-specific override behavior
  • for credit-only charges (usage-based or flat fee), Create(...) itself may return an already-advanced charge — assert the returned charge's status, do not assume it will be created
  • for credit-only charges (usage-based or flat fee), handler callbacks must not return credit allocations above the requested amount; exact allocation paths must return allocations that sum to the requested amount
  • for flat fee credit-only tests, use mustAdvanceFlatFeeCharges(...) helper — it filters the advance result to flat fee charges only
  • for credit-purchase state-machine unit tests, use testify mock.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 invoked
  • when testing timestamp truncation, use sub-second fixtures and assert the persisted charge/run fields are second-aligned after create/advance
  • time.Time fields on domain models are value typed; use s.False(ts.IsZero()) instead of s.NotNil(ts) when asserting they are populated
  • cover the temporary shrink/extend remap path as well; it synthesizes new intents and must normalize the replacement period ends before re-create

Test suite teardown:

  • BaseSuite.TearDownTest() (capital D — testify calls this automatically between tests) resets FlatFeeTestHandler, CreditPurchaseTestHandler, UsageBasedTestHandler, and MockStreamingConnector
  • Use TearDownTest (capital D) in all sub-suites; TeardownTest (lowercase d) is not called by testify
  • MockStreamingConnector events are shared across all tests in the suite — always rely on TearDownTest to reset them rather than defer

Billing-profile test gotcha:

  • ProvisionBillingProfile(...) supports multiple edit options
  • when asserting collection timing, verify the created profile is default and, if needed, compare it to GetDefaultProfile(...)

Running Tests

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/...

Editing Checklist

When changing charges:

  • decide whether the change belongs in:
    • the root facade
    • meta queries
    • charge locking
    • a type-specific package
  • preserve the current root rule that AdvanceCharges(...) only advances supported types (usage-based and flat-fee credit-only)
  • keep meta status and type-specific status in sync

When changing usage-based charges:

  • confirm whether the change belongs in the facade, usage-based service, state machine, or adapter
  • preserve the nil means noop contract for AdvanceCharge(...)
  • preserve merged-profile based collection-period resolution
  • keep StoredAtLT, ServicePeriodTo, and MeteredQuantity persisted on realization runs
  • keep the stored_at < cutoff behavior explicit in tests
  • update lifecycle tests if late-event visibility changes

When changing flat-fee charges:

  • the invoiced path (CreditThenInvoice/InvoiceOnly) starts as Active and is driven by invoice lifecycle hooks
  • the credit-only path starts as Created and is driven by the state machine — do not mix the two
  • AmountAfterProration 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 Intent
  • IntentWithInitialStatus carries AmountAfterProration alongside InitialStatus to pass the computed value from the service to the adapter at creation time
  • flatfee.State.AdvanceAfter must be passed through chargemeta.UpdateInput.AdvanceAfter on every UpdateCharge(...) call
  • flatfee.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.State
  • charge_flat_fees.status_detailed mirrors status today; schema changes or migrations that introduce new flat-fee statuses must keep both columns consistent through ToMetaChargeStatus()
  • the flatfee.Handler interface has both invoiced-path methods and credits-only methods — implementors must satisfy all of them
  • adding new Handler methods requires updating: ledger/chargeadapter/flatfee.go, charges/service/handlers_test.go
  • the same applies to usagebased.Handler — new methods must be added to UnimplementedHandler, the ledger adapter (ledger/chargeadapter/usagebased.go), and the test handler
  • the same applies to creditpurchase.Handler — new methods must be added to ledger/chargeadapter/creditpurchase.go and the test handler

Usage-based handler interface (usagebased.Handler):

  • OnCreditsOnlyUsageAccrued(ctx, CreditsOnlyUsageAccruedInput)creditrealization.CreateAllocationInputs — allocate credits for a realization run
  • OnCreditsOnlyUsageAccruedCorrection(ctx, CreditsOnlyUsageAccruedCorrectionInput)creditrealization.CreateCorrectionInputs — correct (partially revert) existing credit allocations when finalization discovers usage decreased

Credit purchase handler interface (creditpurchase.Handler):

  • OnPromotionalCreditPurchase(ctx, Charge)ledgertransaction.GroupReference
  • OnCreditPurchaseInitiated(ctx, Charge)ledgertransaction.GroupReference
  • OnCreditPurchasePaymentAuthorized(ctx, PaymentEventInput)ledgertransaction.GroupReference
  • OnCreditPurchasePaymentSettled(ctx, PaymentEventInput)ledgertransaction.GroupReference

flatfee/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 struct
  • creditpurchase.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.
  • Custom-currency gathering lines must use the settlement fiat currency and convert the purchased credit amount using the settlement cost basis. Keep the purchased amount and currency visible in the line description so downstream billing retains both sides of the purchase.
  • creditpurchase.Charge embeds ChargeBase + Realizations — all lifecycle outcomes live in Realizations, not State
  • creditpurchase.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 row
  • creditpurchase.Status mirrors the flatfee pattern: StatusCreated, StatusActive, StatusFinal, StatusDeleted with ToMetaChargeStatus() bridge
  • charge_credit_purchases.status_detailed column mirrors status and is set via SetStatusDetailed(...) on create/update
  • external credit-purchase direct provider paid 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.authorized
  • prefer OnActive(...) 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 status
  • do not add credit-purchase detailed statuses only to model callback ordering; add persisted status_detailed values only when callers need to observe or retry a durable lifecycle state
  • UpdateCharge(ctx, ChargeBase) (ChargeBase, error) only updates base-row fields — do not call it just because realization edges changed
  • Realization edges are created/updated through dedicated adapter methods: CreateCreditGrant, CreateExternalPayment, UpdateExternalPayment, CreateInvoicedPayment, UpdateInvoicedPayment
  • Adapter interface is composite: ChargeAdapter + CreditGrantAdapter + ExternalPaymentAdapter + InvoicedPaymentAdapter
  • The withExpands helper in creditpurchase/adapter/charge.go adds .WithCreditGrant().WithExternalPayment().WithInvoicedPayment() to queries when ExpandRealizations is requested — use this helper instead of repeating the expand chain
  • Service methods that only change edge data (e.g., HandleExternalPaymentAuthorized) update charge.Realizations in memory and return the full Charge without calling UpdateCharge
  • Service methods that change status (e.g., HandleExternalPaymentSettled, onPromotionalCreditPurchase) call UpdateCharge(ctx, charge.ChargeBase) and merge the result back: charge.ChargeBase = updatedBase
  • Credit grant creation must go through adapter.CreateCreditGrant(...) — do not write credit grant data through UpdateCharge

External-settled credit purchase (its own lifecycle, separate from promotional/invoice):

  • the external settlement lifecycle has its own state machine ExternalCreditPurchaseStateMachine (creditpurchase/service/external.go), built by onExternalCreditPurchase(...); promotional and invoice settlement each have their own state machine too
  • creditpurchase.ExternalSettlement carries InitialStatus (InitialPaymentSettlementStatus: created / authorized / settled). externalInitialPaymentTrigger(...) maps it to the lifecycle entry: created → no trigger (empty string), authorizedbilling.TriggerAuthorized, settledbilling.TriggerPaid
  • reuse the billing invoice triggers billing.TriggerAuthorized / billing.TriggerPaid for the payment lifecycle; do not introduce external-specific trigger names even though external and invoice settlement run on separate state machines
  • external realization mechanics live in creditpurchase/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...))
  • the realization layer owns the payment guards: authorize rejects a charge that already has Realizations.ExternalPaymentSettlement (payment.ErrPaymentAlreadyAuthorized); settle rejects a nil settlement (payment.ErrCannotSettleNotAuthorizedPayment) or one not in payment.StatusAuthorized (payment.ErrPaymentAlreadySettled)
  • every credit-purchase grant path must call 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 TransactionGroupID
  • external/invoice settlement cost basis must be positive; GenericSettlement.Validate() enforces it and InitiateExternalCreditPurchase re-checks before creating the grant realization

Credit Realization Model

The creditrealization package (openmeter/billing/charges/models/creditrealization/) defines the domain model for credit allocations and corrections (partial/full reverts).

Type hierarchy

  • 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.

Sign convention

  • Allocations have positive Amount in CreateInput and DB.
  • Corrections have negative 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.

Correction flow

The full correction orchestration is Realizations.Correct(amount, currency, callback):

  1. CreateCorrectionRequest(amount, currency) — builds CorrectionRequest items in reverse creation order (latest allocation first)
  2. CorrectionRequest.ValidateWith(currency) — validates the request items
  3. Calls callback(req) — caller (ledger handler) maps request items to CreateCorrectionInputs with ledger transaction references
  4. CreateCorrectionInputs.ValidateWith(realizations, totalAmount, currency) — validates corrections don't exceed remaining per-allocation amounts
  5. CreateCorrectionInputs.AsCreateInputs(realizations) — maps to []CreateInput with negated amounts, copies ServicePeriod from the corrected allocation

Unit test patterns for creditrealization

Tests are in correction_test.go (same package, not _test). Reusable helpers:

  • allocationBuilder — builds allocation Realization entries with auto-incrementing SortHint and configurable CreatedAt
  • correctionFor(allocation, amount) — builds a correction Realization targeting a given allocation
  • correctionCallback(txGroupID) — returns a func(CorrectionRequest) (CreateCorrectionInputs, error) for use with Correct()
  • correctionRequestAmounts(cr) / correctionRequestAllocationIDs(cr) — extract slices for assertions
  • correctionInputsSum(inputs) — sums CreateCorrectionInputs amounts
  • testCurrency(t) — returns a USD currencyx.Calculator

Test structure follows the rate_test pattern: declarative test cases with t.Run subtests, shared helpers, no DB required.

Adapter Gotchas

  • 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 directly
  • refetchCharge 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 discussion
  • buildCreateUsageBasedCharge 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) patterns
Repository
openmeterio/openmeter
Last updated
First committed

Is this your skill?

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.