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.
Guidance for working with OpenMeter billing charges.
This skill describes the charges package generically. Lifecycle state machines exist for both usage-based and flat-fee credit-only branches. 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.goopenmeter/billing/charges is the root facade for charge operations.
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 itselfImportant 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.Intent carries AmountBeforeProration, ProRating, SettlementMode, PaymentTerm, InvoiceAt — immutable inputs provided by the callerflatfee.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:
CreditRealizationsAccruedUsagePaymentflatfee.Intent.CalculateAmountAfterProration() computes the prorated amount from AmountBeforeProration, ServicePeriod/FullServicePeriod ratio, and ProRating config, with currency-precision roundingusagebased.Intent carries FeatureKey, Price, SettlementMode, InvoiceAt, and ServicePeriodusagebased.ChargeBase stores the current Status and Stateusagebased.State currently tracks:
CurrentRealizationRunIDAdvanceAfterusagebased.RealizationRunBase stores:
TypeAsOfCollectionEndMeterValueTotalsCharge-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/lineengineopenmeter/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/testutilsLineEngineType, app wiring and charge test wiring must register a matching implementation in the same changeusagebased.Service.GetLineEngine(); register that returned engine instead of reusing the service type directlyOperational 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 engineCharge 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, AsOf, CollectionEnd, storedAtOffset), normalize the computed timestamp before persisting it or handing it to downstream persistence callbacksImportant timestamp surfaces:
meta.Intent.ServicePeriodmeta.Intent.FullServicePeriodmeta.Intent.BillingPeriodflatfee.Intent.InvoiceAtusagebased.Intent.InvoiceAtflatfee.State.AdvanceAfterusagebased.State.AdvanceAfterusagebased.CreateRealizationRunInput.AsOfusagebased.CreateRealizationRunInput.CollectionEndusagebased.UpdateRealizationRunInput.AsOfPlacement guidance:
Intent.Normalized(), state-machine transition logic, temporary patch remap)charges/models/chargemetaSetInvoiceAt(...), SetAsof(...), SetCollectionEnd(...), 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{}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(...) currently only supports CreditOnly; it does not have a CreditThenInvoice state machine pathAdvanceCharge(...) 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:
Create(...) flowsLineEngineTypeChargeUsageBased; do not introduce this discriminator unless a corresponding billing engine exists or the path is intentionally blockedIsLineBillableAsOf(...) is currently billable only once asOf >= resolved service period end; keep the existing progressive-billing TODO in place when touching that logiccredit_then_invoice, BuildStandardInvoiceLines(...) is allowed to drive charge lifecycle transitions needed to create the invoice-backed realization runOnCollectionCompleted(...) 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.final_realization.startedactive.final_realization.waiting_for_collectionactive.final_realization.processingactive.final_realization.completedfinalHigh-level transitions:
created -> active
IsInsideServicePeriod()AdvanceAfter to service-period start while waitingactive -> active.final_realization.started
IsAfterServicePeriod()AdvanceAfter to service-period end while waitingactive.final_realization.started -> active.final_realization.waiting_for_collection
StartFinalRealizationRun(...) creates the realization runactive.final_realization.waiting_for_collection -> active.final_realization.processing
IsAfterCollectionPeriod(...)active.final_realization.processing -> active.final_realization.completed
FinalizeRealizationRun(...) re-rates usage, computes delta vs initial run totals, then:
allocateCreditsRealizations.Correct() with handler callback OnCreditsOnlyUsageAccruedCorrectionactive.final_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 (not sub-statuses like active.final_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 minuteStartFinalRealizationRun(...) computes storedAtOffset = clock.Now() - InternalCollectionPeriodCollectionEndCollectionEnd, not a recomputed valueAdvanceAfterCollectionPeriodEnd(...) sets AdvanceAfter = CollectionEnd + InternalCollectionPeriodIsAfterCollectionPeriod(...) checks clock.Now() >= CollectionEnd + InternalCollectionPeriodGetCollectionPeriodEnd(...) 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:
stored_at < cutoffstoredAtOffsetThis 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:
CollectionEnd must be persisted on the run and mapped back into the domain modelCurrentRealizationRunID points at the active run while waiting/finalizingCurrentRealizationRunIDPersistence 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.
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 eventsclock.FreezeTime(...) for exact AsOf / AllocateAt assertionsCreate(...) 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 onlyonCreditsOnlyUsageAccrued) must return credit allocations that sum to the input AmountToAllocateTest 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:
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 CollectionEnd 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.Adapter.UpdateCharge(...) takes flatfee.ChargeBase and persists only base-row fields; do not call it just because flatfee.Realizations changed
CreatePayment(...), UpdatePayment(...), CreateInvoicedUsage(...), and CreateCreditAllocations(...) already persist the realization-side rows; a follow-up UpdateCharge(...) is redundant unless base-row fields changed too
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 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, Charge) → ledgertransaction.GroupReferenceOnCreditPurchasePaymentSettled(ctx, Charge) → 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.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/updateUpdateCharge(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 UpdateChargeThe 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) patterns