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
-`shouldAutoAdvance`: checks `DraftUntil <= now` (auto-approval window has elapsed)
137
144
-`canIssuingSyncAdvance`: polls `InvoicingAppAsyncSyncer` if the app implements async sync
138
145
146
+
**Retryable lifecycle hooks**:
147
+
- Invoice-issued and payment-booking line-engine callbacks run in dedicated retryable states (`issuing.charge_booking`, `payment_processing.booking_authorized`, `payment_processing.booking_authorized_and_settled`, `payment_processing.booking_settled`) instead of stable states like `issued` or `paid`.
148
+
- Callback failures must be returned as validation-shaped errors so `FireAndActivate` can transition to the corresponding `*_failed` status and `RetryInvoice` can re-enter only that hook state.
149
+
- App-driven triggers (for example `TriggerPaid` via `HandleInvoiceTrigger`) must call `AdvanceUntilStateStable` after `FireAndActivate`. These triggers can land in intermediary booking states, not directly in the final stable state.
150
+
-`payment_processing.booking_authorized_and_settled` exists for direct `pending -> paid` provider flows. It preserves charge and ledger ordering by running authorization booking before settlement booking.
151
+
-`payment_processing.authorized` is a stable stop. `TriggerAuthorized` should stop there and must not auto-advance into settlement.
152
+
139
153
**Retrying stuck invoices**: Use the existing `RetryInvoice` service method (`service/invoice.go`) rather than firing `TriggerRetry` directly. `RetryInvoice` first **downgrades all critical validation issues to warnings** before firing the trigger — without this step, `noCriticalValidationErrors` would immediately block re-advancement out of `DraftValidating` and the invoice would land back in `DraftSyncFailed`. For bulk retries, query with `ExtendedStatuses: []billing.StandardInvoiceStatus{billing.StandardInvoiceStatusDraftSyncFailed}` and call `RetryInvoice` per result.
140
154
155
+
## Line Engine Lifecycle
156
+
157
+
The billing line-engine contract lives in `openmeter/billing/lineengine.go`. Billing owns the orchestration and grouping; each engine owns only the behavior for the lines assigned to its discriminator.
158
+
159
+
**Registered engine types**:
160
+
-`invoicing` — the default billing-owned engine for generic invoice behavior
161
+
-`charge_flatfee`
162
+
-`charge_usagebased`
163
+
-`charge_creditpurchase`
164
+
165
+
`billingservice.engineRegistry` (`service/lineengine.go`) stores engines by `LineEngineType`, validates explicit engine tags on lines, and defaults missing line engines to `LineEngineTypeInvoice`.
166
+
167
+
**Grouping model**:
168
+
- Gathering-line work is grouped by `groupGatheringLinesByEngine`
169
+
- Standard-line work is grouped by `groupStandardLinesByEngine`
170
+
- Hooks are invoked once per engine group, never line-by-line from the billing state machine
171
+
172
+
**Hook sequence and ownership**:
173
+
-`BuildStandardInvoiceLines`
174
+
- called while converting gathering lines into a new standard invoice in `service/gatheringinvoicependinglines.go`
175
+
- must return standard lines reusing the exact same line IDs as the input gathering lines
176
+
-`OnStandardInvoiceCreated`
177
+
- called after the standard invoice and standard lines have been persisted
178
+
- may mutate and return replacement lines
179
+
- billing validates output lines and enforces exact line-ID preservation before replacing them on the invoice
180
+
-`OnCollectionCompleted`
181
+
- called from `InvoiceStateMachine.onCollectionCompleted`
182
+
- may mutate and return replacement lines
183
+
- 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
184
+
-`OnInvoiceIssued`
185
+
- called from retryable state `issuing.charge_booking`
186
+
- side-effect only; returns `error`, not mutated lines
187
+
-`OnPaymentAuthorized`
188
+
- called from retryable state `payment_processing.booking_authorized`
189
+
- side-effect only; returns `error`
190
+
-`OnPaymentAuthorized` + `OnPaymentSettled`
191
+
- called in order from retryable state `payment_processing.booking_authorized_and_settled` when the payment app reports a direct paid outcome from `payment_processing.pending`
192
+
- this exists so charge-side handlers never settle without first creating the authorized realization / ledger booking
193
+
-`OnPaymentSettled`
194
+
- called from retryable state `payment_processing.booking_settled`
195
+
- side-effect only; returns `error`
196
+
-`CalculateLines`
197
+
- recalculates detailed lines/totals for standard lines already owned by the engine
198
+
- should be deterministic and line-local; orchestration happens in billing
199
+
200
+
**Validation and failure contract**:
201
+
- All hook inputs use `StandardLineEventInput` aliases and must pass `.Validate()`
202
+
- Hooks that return lines must return valid lines with unchanged IDs
203
+
- Hook errors that represent business-rule failures should surface as validation-shaped errors so billing can persist them as invoice `ValidationIssues`
204
+
- For side-effect hooks, billing wraps engine errors with `billing.NewLineEngineValidationError(...)`
205
+
- For mutating hooks like `OnCollectionCompleted`, billing uses `MergeValidationIssues(...)` with the engine component and keeps processing other engine groups
206
+
- Unwrapped infrastructure/programming errors still abort the operation and roll back the transition
207
+
208
+
**Important behavior split**:
209
+
-`OnStandardInvoiceCreated` and `OnCollectionCompleted` are allowed to reshape line contents
210
+
-`OnInvoiceIssued`, `OnPaymentAuthorized`, and `OnPaymentSettled` are for side effects only; they do not return lines back into billing
211
+
- Retryable payment/issuing states exist specifically so these side-effect hooks can fail and be retried without rerunning the entire invoice finalization path
212
+
213
+
**App trigger interaction**:
214
+
- App-driven invoice triggers such as `TriggerPaid` can land in intermediary booking states rather than directly in `paid`
215
+
-`HandleInvoiceTrigger` must therefore call `AdvanceUntilStateStable` after `FireAndActivate`, otherwise invoices remain stuck in `payment_processing.booking_*`
216
+
217
+
**When adding a new line engine or hook**:
218
+
1. Extend `billing.LineEngine` in `openmeter/billing/lineengine.go`
219
+
2. Add no-op implementations to every concrete engine that already satisfies the interface
220
+
3. Wire the billing invocation point in either `gatheringinvoicependinglines.go` or `stdinvoicestate.go`
221
+
4. Decide whether failures must be retryable; if yes, add dedicated intermediary invoice states instead of attaching `OnActive` to a stable/final state
222
+
5. Add focused billing tests in `test/billing/lineengine_test.go`
Use those tests as the template for new billing line-engine lifecycle behavior.
141
234
## Gathering vs Standard Invoices
142
235
143
236
**Gathering invoice**: one per customer per currency, never advances through states. Collects pending lines (from subscription sync). When lines become due (`CollectionAt <= now`), the `InvoiceCollector` cron calls `InvoicePendingLines` to move them into a new `StandardInvoice`. The gathering invoice is soft-deleted when it has no remaining lines.
@@ -394,7 +487,11 @@ See `references/testing.md` for full test patterns. Key points:
394
487
-**`RemoveMetaForCompare()`**: both `StandardInvoice` and `StandardLine` have this method that strips DB-only fields for test assertions. Use it before `require.Equal` comparisons.
395
488
396
489
-**Hook registration is mutable**: `RegisterStandardInvoiceHooks` appends to a slice on the billing service. The charges service self-registers at `New()`. In tests, the hook is registered once per suite (not per test) — reset handler function fields in `TearDownTest()` rather than re-registering.
490
+
-**Line-engine outputs must preserve IDs**: `BuildStandardInvoiceLines`, `OnStandardInvoiceCreated`, and `OnCollectionCompleted` all depend on exact line-ID reuse. Returning replacement lines with different IDs will fail billing validation before persistence.
491
+
492
+
-**Default engine inference is validator-only**: `populateGatheringLineEngine` and `populateStandardLineEngine` default blank engines to `invoicing`, but if a line already has an explicit engine billing only validates the enum value. Registration is checked later when grouping/invoking hooks.
397
493
494
+
-**Payment/issuing side-effect hooks are not line-mutating hooks**: `OnInvoiceIssued`, `OnPaymentAuthorized`, and `OnPaymentSettled` return only `error`. If you need to mutate invoice lines, do it earlier in `OnStandardInvoiceCreated` or `OnCollectionCompleted`.
0 commit comments