Skip to content

Commit 0e65dc7

Browse files
committed
feat: payment settlement support refactor
1 parent 6163a01 commit 0e65dc7

19 files changed

Lines changed: 876 additions & 76 deletions

File tree

.agents/skills/billing/SKILL.md

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,15 @@ DraftCreated
125125
→ DraftWaitingAutoApproval (if autoAdvance + shouldAutoAdvance → DraftReadyToIssue)
126126
→ DraftReadyToIssue
127127
→ IssuingSyncing (OnActive: finalizeInvoice → app.FinalizeStandardInvoice)
128+
→ IssuingChargeBooking (OnActive: line-engine OnInvoiceIssued; TriggerFailed → IssuingChargeBookingFailed)
128129
→ Issued
129-
→ PaymentProcessingPending / Paid / Overdue / Uncollectible / Voided
130+
→ PaymentProcessingPending
131+
→ PaymentProcessingBookingAuthorized (TriggerAuthorized; OnActive: line-engine OnPaymentAuthorized)
132+
→ PaymentProcessingAuthorized
133+
→ PaymentProcessingBookingAuthorizedAndSettled (TriggerPaid from pending; OnActive: OnPaymentAuthorized then OnPaymentSettled)
134+
→ PaymentProcessingBookingSettled (TriggerPaid from authorized; OnActive: line-engine OnPaymentSettled)
135+
→ PaymentProcessingFailed / PaymentProcessingActionRequired / Overdue / Uncollectible / Voided
136+
→ Paid
130137
131138
DeleteInProgress → DeleteSyncing → Deleted (TriggerFailed → DeleteFailed)
132139
```
@@ -136,8 +143,94 @@ DeleteInProgress → DeleteSyncing → Deleted (TriggerFailed → DeleteFailed)
136143
- `shouldAutoAdvance`: checks `DraftUntil <= now` (auto-approval window has elapsed)
137144
- `canIssuingSyncAdvance`: polls `InvoicingAppAsyncSyncer` if the app implements async sync
138145

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+
139153
**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.
140154

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`
223+
224+
**Current test coverage pattern**:
225+
- `TestCollectionCompletedErrorsBecomeValidationIssues`
226+
- `TestOnInvoiceIssuedIsCalled`
227+
- `TestOnInvoiceIssuedFailureTransitionsToRetryableIssuingState`
228+
- `TestOnPaymentAuthorizedIsCalled`
229+
- `TestOnPaymentAuthorizedFailureTransitionsToRetryablePaymentState`
230+
- `TestOnPaymentSettledIsCalled`
231+
- `TestOnPaymentSettledFailureTransitionsToRetryablePaymentState`
232+
233+
Use those tests as the template for new billing line-engine lifecycle behavior.
141234
## Gathering vs Standard Invoices
142235

143236
**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:
394487
- **`RemoveMetaForCompare()`**: both `StandardInvoice` and `StandardLine` have this method that strips DB-only fields for test assertions. Use it before `require.Equal` comparisons.
395488

396489
- **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.
397493

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`.
398495
## References
399496

400497
- `references/subscription-sync.md` — detailed subscription→billing sync algorithm, phase iterator, reconciler

openmeter/billing/adapter/invoice.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -850,8 +850,15 @@ func (a *adapter) IsAppUsed(ctx context.Context, appID app.AppID) error {
850850
billing.StandardInvoiceStatusIssuingChargeBookingFailed,
851851
billing.StandardInvoiceStatusIssued,
852852
billing.StandardInvoiceStatusPaymentProcessingPending,
853+
billing.StandardInvoiceStatusPaymentProcessingBookingAuthorized,
854+
billing.StandardInvoiceStatusPaymentProcessingBookingAuthorizedFailed,
855+
billing.StandardInvoiceStatusPaymentProcessingBookingAuthorizedAndSettled,
856+
billing.StandardInvoiceStatusPaymentProcessingBookingAuthorizedAndSettledFailed,
857+
billing.StandardInvoiceStatusPaymentProcessingAuthorized,
853858
billing.StandardInvoiceStatusPaymentProcessingFailed,
854859
billing.StandardInvoiceStatusPaymentProcessingActionRequired,
860+
billing.StandardInvoiceStatusPaymentProcessingBookingSettled,
861+
billing.StandardInvoiceStatusPaymentProcessingBookingSettledFailed,
855862
billing.StandardInvoiceStatusOverdue,
856863
),
857864
billinginvoice.DeletedAtIsNil(),

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ func (e *Engine) OnInvoiceIssued(_ context.Context, _ billing.OnInvoiceIssuedInp
8787
return nil
8888
}
8989

90+
func (e *Engine) OnPaymentAuthorized(_ context.Context, _ billing.OnPaymentAuthorizedInput) error {
91+
return nil
92+
}
93+
94+
func (e *Engine) OnPaymentSettled(_ context.Context, _ billing.OnPaymentSettledInput) error {
95+
return nil
96+
}
97+
9098
func (e *Engine) CalculateLines(input billing.CalculateLinesInput) (billing.StandardLines, error) {
9199
if input.Invoice.ID == "" {
92100
return nil, fmt.Errorf("invoice id is required")

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ func (e *Engine) OnInvoiceIssued(_ context.Context, _ billing.OnInvoiceIssuedInp
130130
return nil
131131
}
132132

133+
func (e *Engine) OnPaymentAuthorized(_ context.Context, _ billing.OnPaymentAuthorizedInput) error {
134+
return nil
135+
}
136+
137+
func (e *Engine) OnPaymentSettled(_ context.Context, _ billing.OnPaymentSettledInput) error {
138+
return nil
139+
}
140+
133141
func (e *Engine) CalculateLines(input billing.CalculateLinesInput) (billing.StandardLines, error) {
134142
if input.Invoice.ID == "" {
135143
return nil, fmt.Errorf("invoice id is required")

openmeter/billing/charges/service/creditpurchase_test.go

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ func (s *CreditPurchaseTestSuite) TestExternalAuthorizedCreditPurchaseManuallySe
274274

275275
var chargeID meta.ChargeID
276276
var initatedTrnsID string
277+
var authorizedTrnsID string
277278

278279
s.Run("initiated", func() {
279280
defer s.CreditPurchaseTestHandler.Reset()
@@ -306,7 +307,6 @@ func (s *CreditPurchaseTestSuite) TestExternalAuthorizedCreditPurchaseManuallySe
306307
initatedTrnsID = initatedCallback.id
307308
})
308309

309-
var authorizedTrnsID string
310310
s.Run("authorized", func() {
311311
defer s.CreditPurchaseTestHandler.Reset()
312312

@@ -476,7 +476,6 @@ func (s *CreditPurchaseTestSuite) TestStandardInvoiceCreditPurchase() {
476476
s.NotEmpty(invoiceID)
477477
})
478478

479-
var authorizedTrnsID string
480479
s.Run("authorized", func() {
481480
defer s.CreditPurchaseTestHandler.Reset()
482481

@@ -515,23 +514,16 @@ func (s *CreditPurchaseTestSuite) TestStandardInvoiceCreditPurchase() {
515514
creditPurchaseCharge, err := charge.AsCreditPurchaseCharge()
516515
s.NoError(err)
517516

518-
// Invoice settlement should be set
519-
s.NotNil(creditPurchaseCharge.Realizations.InvoiceSettlement)
520-
lineID := creditPurchaseCharge.Realizations.InvoiceSettlement.LineID
521-
s.NotEmpty(lineID)
522-
523-
s.Equal(1, authorizedCallback.nrInvocations)
524-
s.NotNil(creditPurchaseCharge.Realizations.InvoiceSettlement)
525-
s.NotNil(creditPurchaseCharge.Realizations.InvoiceSettlement.Authorized)
526-
s.Equal(authorizedCallback.id, creditPurchaseCharge.Realizations.InvoiceSettlement.Authorized.TransactionGroupID)
517+
// Payment authorization is no longer persisted at pending.
518+
s.Equal(0, authorizedCallback.nrInvocations)
519+
s.Nil(creditPurchaseCharge.Realizations.InvoiceSettlement)
527520
s.Equal(creditpurchase.StatusActive, creditPurchaseCharge.Status, "charge status should be active")
528521

529522
// validate the standard line
530523
lines := invoice.Lines.OrEmpty()
531524
s.Require().Len(lines, 1)
532525

533526
line := lines[0]
534-
s.Equal(lineID, line.ID)
535527
s.Equal(USD, line.Currency)
536528
s.Equal(billing.Period{
537529
Start: datetime.MustParseTimeInLocation(s.T(), "2026-01-01T00:00:00Z", time.UTC).AsTime(),
@@ -554,20 +546,21 @@ func (s *CreditPurchaseTestSuite) TestStandardInvoiceCreditPurchase() {
554546
// validate invoice totals
555547
s.Equal(alpacadecimal.NewFromFloat(50), invoice.Totals.Amount)
556548
s.Equal(alpacadecimal.NewFromFloat(50), invoice.Totals.Total)
557-
558-
authorizedTrnsID = authorizedCallback.id
559549
})
560550

561551
s.Run("settled", func() {
562552
defer s.CreditPurchaseTestHandler.Reset()
553+
authorizedCallback := newCountedLedgerTransactionCallback[creditpurchase.Charge]()
554+
s.CreditPurchaseTestHandler.onCreditPurchasePaymentAuthorized = authorizedCallback.Handler(s.T())
555+
563556
// Then the settled callback should be called, with a grant realization and a payment settlement
564557
settledCallback := newCountedLedgerTransactionCallback[creditpurchase.Charge]()
565558
s.CreditPurchaseTestHandler.onCreditPurchasePaymentSettled = settledCallback.Handler(s.T(), func(t *testing.T, charge creditpurchase.Charge) {
566559
assert.Equal(t, charge.Intent.Settlement.Type(), creditpurchase.SettlementTypeInvoice)
567560
assert.NotNil(t, charge.Realizations.InvoiceSettlement, "invoice settlement should be set")
568561

569562
// Authorized transaction group ID should still be set from the authorized phase
570-
assert.Equal(t, authorizedTrnsID, charge.Realizations.InvoiceSettlement.Authorized.TransactionGroupID)
563+
assert.Equal(t, authorizedCallback.id, charge.Realizations.InvoiceSettlement.Authorized.TransactionGroupID)
571564
assert.Equal(t, creditpurchase.StatusActive, charge.Status, "charge status should be active")
572565
})
573566

@@ -594,6 +587,7 @@ func (s *CreditPurchaseTestSuite) TestStandardInvoiceCreditPurchase() {
594587
creditPurchaseCharge, err := charge.AsCreditPurchaseCharge()
595588
s.NoError(err)
596589

590+
s.Equal(1, authorizedCallback.nrInvocations)
597591
s.Equal(settledCallback.id, creditPurchaseCharge.Realizations.InvoiceSettlement.Settled.TransactionGroupID)
598592
s.Equal(payment.StatusSettled, creditPurchaseCharge.Realizations.InvoiceSettlement.Status)
599593
s.Equal(creditpurchase.StatusFinal, creditPurchaseCharge.Status)

0 commit comments

Comments
 (0)