You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: .agents/skills/billing/SKILL.md
+31Lines changed: 31 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -186,11 +186,33 @@ The billing line-engine contract lives in `openmeter/billing/lineengine.go`. Bil
186
186
187
187
`billingservice.engineRegistry` (`service/lineengine.go`) stores engines by `LineEngineType`, validates explicit engine tags on lines, and defaults missing line engines to `LineEngineTypeInvoice`.
188
188
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
+
189
191
**Grouping model**:
190
192
- Gathering-line work is grouped by `groupGatheringLinesByEngine`
191
193
- Standard-line work is grouped by `groupStandardLinesByEngine`
192
194
- Hooks are invoked once per engine group, never line-by-line from the billing state machine
193
195
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
+
194
216
**Hook sequence and ownership**:
195
217
-`BuildStandardInvoiceLines`
196
218
- 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
203
225
- called from `InvoiceStateMachine.onCollectionCompleted`
204
226
- may mutate and return replacement lines
205
227
- 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
206
237
-`OnInvoiceIssued`
207
238
- called from retryable state `issuing.charge_booking`
208
239
- side-effect only; returns `error`, not mutated lines
Copy file name to clipboardExpand all lines: .agents/skills/charges/SKILL.md
+4-1Lines changed: 4 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -143,7 +143,9 @@ Important rules:
143
143
- production wiring must register charge line engines through `billing.Service.RegisterLineEngine(...)`
144
144
- 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
145
145
- 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`
146
147
- 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
147
149
- usage-based exposes its billing line engine from `usagebased.Service.GetLineEngine()`; register that returned engine instead of reusing the service type directly
148
150
- flat-fee now follows the same pattern: `flatfee.Service.GetLineEngine()` returns the engine owned by the service package
149
151
- 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`
- 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.
162
164
-`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.
163
165
- 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.
165
167
- 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.
166
168
- 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.
167
169
- 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:
207
209
- collection-time errors should be normalized through `billing.NewLineEngineValidationError(...)` instead of rebuilding the validation-issue wrapper at each billing callsite
208
210
- 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
209
211
- 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
Copy file name to clipboardExpand all lines: openmeter/billing/README.md
+32Lines changed: 32 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -150,6 +150,38 @@ Then the adapter layer will use those IDs to make decisions if they want to pers
150
150
151
151
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.
152
152
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
+
153
185
## Subscription adapter
154
186
155
187
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:
0 commit comments