Skip to content

Commit fb6e001

Browse files
authored
refactor: route invoice line edits through line engines (#4552)
1 parent ffb2523 commit fb6e001

49 files changed

Lines changed: 2615 additions & 314 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/billing/SKILL.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,11 +186,33 @@ The billing line-engine contract lives in `openmeter/billing/lineengine.go`. Bil
186186

187187
`billingservice.engineRegistry` (`service/lineengine.go`) stores engines by `LineEngineType`, validates explicit engine tags on lines, and defaults missing line engines to `LineEngineTypeInvoice`.
188188

189+
The same registry also owns the single `CreateLineRouter`. Billing's default router returns `LineEngineTypeInvoice`; charge-enabled application/test wiring must register a charge-aware router explicitly. Keep create routing behind the `billing.CreateLineRouter` interface instead of passing the registry into diffing helpers.
190+
189191
**Grouping model**:
190192
- Gathering-line work is grouped by `groupGatheringLinesByEngine`
191193
- Standard-line work is grouped by `groupStandardLinesByEngine`
192194
- Hooks are invoked once per engine group, never line-by-line from the billing state machine
193195

196+
### Mutable Invoice Line Edits
197+
198+
`UpdateStandardInvoiceInput` and `UpdateGatheringInvoiceInput` carry `ChangeSource`:
199+
200+
- `ChangeSourceAPIRequest`: user/API-originated line edits. Billing diffs the original invoice against the edited invoice with `diffMutableInvoiceLines`, applies API edits through line engines with `applyAPIInvoiceLineEdits`, and rebuilds the invoice from unchanged lines plus line-engine outputs.
201+
- `ChangeSourceSystem`: system-originated edits from billing/charges/subscription sync. Billing still computes the line diff, but does not invoke API create/update callbacks. For standard invoices only, deleted lines are dispatched through `OnMutableStandardLinesDeletedBySystem` because charge line updaters currently rely on that cleanup notification. Gathering invoices do not emit system delete callbacks.
202+
203+
The API edit path treats the diff as the owner of invoice lines while engines run:
204+
205+
- `applyAPIInvoiceLineEdits` clones the edited invoice and calls `UnsetLines()` before line-engine dispatch so the edited invoice is only the header/context, not a competing source of line truth.
206+
- Created standard invoice lines are preallocated through `UpsertInvoiceLines` before engine dispatch. This gives line engines stable billing-owned line IDs before charge-backed creates attach realization state. Gathering lines do not need preallocation because downstream state does not reference them directly.
207+
- Created lines without an engine are routed through `CreateLineRouter.GetLineEngineForCreateLine`. Existing updated/deleted lines use their persisted engine; missing or changed engines are validation errors.
208+
- API-created lines are stamped `ManuallyManagedLine` before routing because they have no previous ownership edge. API-updated and API-deleted lines are stamped after engines inspect the previous ownership edge, so engines can distinguish system/subscription-owned to manual transitions from manual-to-manual edits.
209+
- Engine outputs for API creates/updates must match input line counts and IDs. Billing validates nil outputs, duplicate IDs, missing IDs, and unexpected IDs before rebuilding the invoice.
210+
- Unchanged lines in the diff should carry the edited/expected line, not the persisted line, so non-engine-owned edits that are not part of `ExistingLineOverride` are preserved when the invoice is rebuilt.
211+
212+
`ExistingLineOverride` represents changes to apply to an existing line: `ExistingLine` is the old/current line owned by the engine, and `ChangesToApply` contains the edited values. `Apply` must clone before mutation and must not mutate `ExistingLine`; be especially careful with pointer fields such as `UsageBased.Price`.
213+
214+
The HTTP invoice-line merge layer should not enforce charge-managed edit rules or stamp `ManagedBy`; line engines and `applyAPIInvoiceLineEdits` own those decisions. HTTP merge may still enforce generic API invariants that are independent of line-engine ownership, such as blocking usage-discount changes on split lines.
215+
194216
**Hook sequence and ownership**:
195217
- `BuildStandardInvoiceLines`
196218
- called while converting gathering lines into a new standard invoice in `service/gatheringinvoicependinglines.go`
@@ -203,6 +225,15 @@ The billing line-engine contract lives in `openmeter/billing/lineengine.go`. Bil
203225
- called from `InvoiceStateMachine.onCollectionCompleted`
204226
- may mutate and return replacement lines
205227
- billing merges line-engine validation issues per component and continues across engines so one engine failure does not prevent other engines from snapshotting/updating their lines
228+
- `OnMutableInvoiceLinesEditedViaAPI`
229+
- called only for `ChangeSourceAPIRequest`
230+
- receives generic invoice-line create/update/delete diff groups for one engine
231+
- must return exactly one created line for every input created line and exactly one updated line for every input override; deletes are side-effect/validation input and are not returned
232+
- charge engines currently reject non-empty API edits with `ErrCannotUpdateChargeManagedLine` until charge-backed manual create/update/delete support is implemented
233+
- `OnMutableStandardLinesDeletedBySystem`
234+
- called only for standard-invoice `ChangeSourceSystem` deletes
235+
- receives deleted standard lines grouped by their existing engine
236+
- exists for charge cleanup side effects; do not use it for API deletes or gathering invoice deletes
206237
- `OnInvoiceIssued`
207238
- called from retryable state `issuing.charge_booking`
208239
- side-effect only; returns `error`, not mutated lines

.agents/skills/charges/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ Important rules:
143143
- production wiring must register charge line engines through `billing.Service.RegisterLineEngine(...)`
144144
- 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
145145
- charge test setups must also register those engines explicitly; keep this in `openmeter/billing/charges/testutils`
146+
- charge-enabled app/test wiring must also register the charge-aware `CreateLineRouter`; billing's default create router intentionally falls back to legacy `billing.LineEngineTypeInvoice`
146147
- if a charge create path stamps a new `LineEngineType`, app wiring and charge test wiring must register a matching implementation in the same change
148+
- 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
147149
- usage-based exposes its billing line engine from `usagebased.Service.GetLineEngine()`; register that returned engine instead of reusing the service type directly
148150
- flat-fee now follows the same pattern: `flatfee.Service.GetLineEngine()` returns the engine owned by the service package
149151
- 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`
@@ -161,7 +163,7 @@ Flat-fee credit-then-invoice lifecycle rules:
161163
- The flat-fee line engine should map persisted run 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.
162164
- `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.
163165
- 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.
164-
- Flat-fee mutable-line deletion cleanup is owned by the line engine callback. The shrink/extend/delete state-machine path should detach the current run before emitting a delete-line patch; `OnMutableStandardLinesDeleted(...)` should then correct credits, clear charge-owned detailed lines, and mark only the detached run deleted.
166+
- Flat-fee mutable-line deletion cleanup is owned by the line engine callback. The shrink/extend/delete state-machine path should detach the current run before emitting a delete-line patch; `OnMutableStandardLinesDeletedBySystem(...)` should then correct credits, clear charge-owned detailed lines, and mark only the detached run deleted.
165167
- For flat-fee shrink/extend before standard-line creation, replace the pending gathering line by charge ID. 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.
166168
- 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.
167169
- 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.
@@ -207,6 +209,7 @@ Current shared contract details:
207209
- collection-time errors should be normalized through `billing.NewLineEngineValidationError(...)` instead of rebuilding the validation-issue wrapper at each billing callsite
208210
- 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
209211
- 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
212+
- 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
210213

211214
## Timestamp Normalization
212215

app/common/billing.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ func NewBillingRegistry(
191191
breakageService,
192192
taxCodeService,
193193
fsConfig.NamespaceLockdown,
194+
creditsConfig,
195+
featureGate,
194196
)
195197
if err != nil {
196198
return BillingRegistry{}, err

app/common/charges.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/openmeterio/openmeter/openmeter/billing/charges/lineage"
1919
lineageadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/adapter"
2020
lineageservice "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/service"
21+
chargeslinerouter "github.com/openmeterio/openmeter/openmeter/billing/charges/linerouter"
2122
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
2223
metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter"
2324
chargesservice "github.com/openmeterio/openmeter/openmeter/billing/charges/service"
@@ -38,6 +39,7 @@ import (
3839
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
3940
"github.com/openmeterio/openmeter/openmeter/streaming"
4041
"github.com/openmeterio/openmeter/openmeter/taxcode"
42+
"github.com/openmeterio/openmeter/pkg/featuregate"
4143
"github.com/openmeterio/openmeter/pkg/framework/lockr"
4244
"github.com/openmeterio/openmeter/pkg/framework/transaction"
4345
)
@@ -391,6 +393,8 @@ func newChargesRegistry(
391393
breakageService ledgerbreakage.Service,
392394
taxCodeService taxcode.Service,
393395
fsNamespaceLockdown []string,
396+
creditsConfig config.CreditsConfiguration,
397+
featureGate *featuregate.FeatureGateChecker,
394398
) (*ChargesRegistry, error) {
395399
metaAdapter, err := NewChargesMetaAdapter(db, logger)
396400
if err != nil {
@@ -500,6 +504,18 @@ func newChargesRegistry(
500504
if err := billingService.RegisterLineEngine(creditPurchaseLineEngine); err != nil {
501505
return nil, fmt.Errorf("failed to register charges credit purchase line engine: %w", err)
502506
}
507+
createLineRouter, err := chargeslinerouter.New(chargeslinerouter.Config{
508+
CreditsEnabled: creditsConfig.Enabled,
509+
CreditThenInvoiceEnabled: creditsConfig.EnableCreditThenInvoice,
510+
FeatureGate: featureGate,
511+
})
512+
if err != nil {
513+
return nil, fmt.Errorf("failed to create charges create line router: %w", err)
514+
}
515+
516+
if err := billingService.RegisterCreateLineRouter(createLineRouter); err != nil {
517+
return nil, fmt.Errorf("failed to register charges create line router: %w", err)
518+
}
503519

504520
rootAdapter, err := NewChargesAdapter(db, logger)
505521
if err != nil {

openmeter/billing/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,38 @@ Then the adapter layer will use those IDs to make decisions if they want to pers
150150

151151
We could do the same logic in the adapter layer, but this approach makes it more flexible on the calculation layer if we want to generate new lines or not. If this becomes a burden we can do the same matching logic as part of the upsert logic in adapter.
152152

153+
## Lineengine Charges Integration Plan
154+
155+
Mutable invoice edits route created, updated, and deleted invoice lines through the line engine that owns the line. For charge-backed manual line creation, billing and charges have a circular dependency:
156+
157+
- the invoice line must reference the charge via `ChargeID`
158+
- the charge realization must reference the invoice line via `LineID` and `InvoiceID`
159+
160+
Billing owns invoice line identity and persistence. Charges owns charge creation, charge state transitions, realization runs, credit allocation, and mapping realization output back to invoice lines.
161+
162+
The intended charge-backed manual create flow is:
163+
164+
1. A mutable invoice is loaded and edited through `UpdateStandardInvoice`.
165+
2. The edit diff routes a newly created line to the create line router.
166+
3. Billing preallocates the created invoice line ID and inserts a provisional invoice line inside the same invoice manipulation transaction.
167+
4. Billing refreshes or updates the in-memory line DB snapshot so the final invoice update treats the provisional line as an existing row to update, not as another create.
168+
5. Billing invokes `LineEngine.OnMutableInvoiceLinesEditedViaAPI` with created lines that already have `LineID`, `InvoiceID`, and `Engine` populated. Charge-backed created lines do not have `ChargeID` yet.
169+
6. The charge line engine creates the manually managed charge from the created line payload.
170+
7. The charge line engine moves the new charge through an attach-to-existing-invoice-line state-machine transition. The exact trigger/state name can change, but the business meaning is that the charge attaches to a billing-owned invoice line that already exists.
171+
8. The charge state machine creates the realization run with the provided `LineID` and `InvoiceID`, persists charge state, allocates/corrects credits as needed, and maps the realization result back onto the invoice line.
172+
9. The charge line engine returns the realized invoice line with `ChargeID`, totals, detailed lines, and other charge-owned fields populated.
173+
10. Billing replaces the provisional line in the invoice aggregate with the returned line and persists it as an update to the provisional row.
174+
175+
All steps above must stay within the invoice manipulation transaction. If charge creation or realization fails, the provisional invoice line must roll back with the rest of the invoice edit.
176+
177+
Callback expectations for this flow:
178+
179+
- created lines passed to charge line engines must already have stable invoice line IDs
180+
- charge engines must return created lines with the same line IDs as the created input lines
181+
- updated lines are matched by existing line ID
182+
- deleted lines are side-effect/validation inputs and are not returned in the API invoice line edit result
183+
- billing validates callback output identity before replacing invoice lines
184+
153185
## Subscription adapter
154186

155187
The subscription adapter is responsible for feeding the billing with line items during the subscription's lifecycle. The generation of items is event-driven, new items are yielded when:

openmeter/billing/charges/creditpurchase/lineengine/engine.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ func (e *Engine) OnStandardInvoiceCreated(_ context.Context, input billing.OnSta
9090
return input.Lines, nil
9191
}
9292

93-
func (e *Engine) OnMutableStandardLinesDeleted(_ context.Context, _ billing.OnMutableStandardLinesDeletedInput) error {
93+
func (e *Engine) OnMutableInvoiceLinesEditedViaAPI(_ context.Context, _ billing.OnMutableInvoiceUpdateInput) (billing.OnMutableInvoiceUpdateResult, error) {
94+
return billing.OnMutableInvoiceUpdateResult{}, billing.ErrCannotUpdateChargeManagedLine
95+
}
96+
97+
func (e *Engine) OnMutableStandardLinesDeletedBySystem(_ context.Context, _ billing.OnMutableStandardLinesDeletedInput) error {
9498
return nil
9599
}
96100

openmeter/billing/charges/flatfee/service/lineengine.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,14 @@ func (e *LineEngine) OnCollectionCompleted(ctx context.Context, input billing.On
180180
return input.Lines, nil
181181
}
182182

183-
func (e *LineEngine) OnMutableStandardLinesDeleted(ctx context.Context, input billing.OnMutableStandardLinesDeletedInput) error {
183+
func (e *LineEngine) OnMutableInvoiceLinesEditedViaAPI(_ context.Context, _ billing.OnMutableInvoiceUpdateInput) (billing.OnMutableInvoiceUpdateResult, error) {
184+
// TODO: implement charge-backed manual creates by creating
185+
// a manually managed charge and attaching its realization to the
186+
// preallocated invoice line ID provided by billing.
187+
return billing.OnMutableInvoiceUpdateResult{}, billing.ErrCannotUpdateChargeManagedLine
188+
}
189+
190+
func (e *LineEngine) OnMutableStandardLinesDeletedBySystem(ctx context.Context, input billing.OnMutableStandardLinesDeletedInput) error {
184191
if err := input.Validate(); err != nil {
185192
return fmt.Errorf("validating input: %w", err)
186193
}

openmeter/billing/charges/invoiceupdater/invoiceupdate.go

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -386,14 +386,9 @@ func (u *Updater) resolveGatheringLineUpdatesByChargeID(ctx context.Context, cus
386386
line.Subscription.BillingPeriod.To = updatePatch.ServicePeriodTo
387387
}
388388

389-
genericLine, err := line.AsInvoiceLine().AsGenericLine()
390-
if err != nil {
391-
return fmt.Errorf("converting gathering line[%s] to generic line: %w", line.ID, err)
392-
}
393-
394389
lineUpdates := parsed.updatedLinesByInvoiceID[invoice.ID]
395390
lineUpdates.updatedLines = append(lineUpdates.updatedLines, invoiceLineUpdatePatch{
396-
line: genericLine,
391+
line: line.AsGenericLine(),
397392
op: PatchOpUpdateGatheringLineByChargeID,
398393
})
399394
parsed.updatedLinesByInvoiceID[invoice.ID] = lineUpdates
@@ -406,6 +401,7 @@ func (u *Updater) resolveGatheringLineUpdatesByChargeID(ctx context.Context, cus
406401
func (u *Updater) updateMutableStandardInvoice(ctx context.Context, invoice billing.StandardInvoice, linePatches invoicePatches) error {
407402
updatedInvoice, err := u.billingService.UpdateStandardInvoice(ctx, billing.UpdateStandardInvoiceInput{
408403
Invoice: invoice.GetInvoiceID(),
404+
ChangeSource: billing.ChangeSourceSystem,
409405
IncludeDeletedLines: true,
410406
EditFn: func(invoice *billing.StandardInvoice) error {
411407
for _, deletePatch := range linePatches.deletedLines {
@@ -483,6 +479,7 @@ func (u *Updater) updateMutableStandardInvoice(ctx context.Context, invoice bill
483479
func (u *Updater) updateGatheringInvoice(ctx context.Context, invoiceID billing.InvoiceID, linePatches invoicePatches) error {
484480
_, err := u.billingService.UpdateGatheringInvoice(ctx, billing.UpdateGatheringInvoiceInput{
485481
Invoice: invoiceID,
482+
ChangeSource: billing.ChangeSourceSystem,
486483
IncludeDeletedLines: true,
487484
EditFn: func(invoice *billing.GatheringInvoice) error {
488485
for _, deletePatch := range linePatches.deletedLines {

0 commit comments

Comments
 (0)