Skip to content

Commit 7bd337f

Browse files
committed
feat: skaffold progressive billing support
1 parent 45cefa9 commit 7bd337f

17 files changed

Lines changed: 1250 additions & 139 deletions

File tree

.agents/skills/charges/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ Important rules:
124124
- usage-based exposes its billing line engine from `usagebased.Service.GetLineEngine()`; register that returned engine instead of reusing the service type directly
125125
- flat-fee now follows the same pattern: `flatfee.Service.GetLineEngine()` returns the engine owned by the service package
126126
- 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`
127+
- for usage-based realization creation, validate at the run-creation boundary (`usagebased/service/run.CreateRatedRunInput.Validate`) that `Charge.State.CurrentRealizationRunID` is nil before creating a new run; keep the line-engine-side early return too so `InvoicePendingLines` fails with the charge-specific validation error at the billing boundary. In both places, key the guard off `CurrentRealizationRunID`, not a specific status prefix such as `partial_invoice`
128+
- usage-based payment handling is intentionally different from flat-fee and credit-purchase: the usage-based state machine owns realization only, while the usage-based line engine/run service records payment authorization/settlement directly on historical runs and only re-enters the state machine through an aggregate trigger (for example `all_payments_settled`) once all invoiced runs on the charge are settled. Do not apply this rule generically to flat-fee or credit-purchase; those charge types may still keep payment states inside their own state machines.
129+
- usage-based invoice branches should read in this order: `started -> waiting_for_collection -> processing -> issuing -> completed`, then auto-advance out of the branch. Keep `invoice_issued` as the boundary between `processing` and `issuing`, run `FinalizeInvoiceRun(...)` from the `issuing` state, and let `completed` be the last branch-local status before `next` returns a partial invoice to `active` or moves a final invoice to `active.awaiting_payment_settlement`
130+
- when adding or renaming usage-based detailed statuses, remember that `status_detailed` is an Ent enum for `ChargeUsageBased`; run `make generate` so the generated enum validators and migrate schema include the new values before trusting state-machine changes
127131

128132
Operational consequence:
129133

openmeter/billing/charges/meta/triggers.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import "github.com/qmuntal/stateless"
55
type Trigger = stateless.Trigger
66

77
var (
8-
TriggerNext Trigger = "next"
9-
TriggerInvoiceCreated Trigger = "invoice_created"
10-
TriggerCollectionCompleted Trigger = "collection_completed"
11-
TriggerInvoiceIssued Trigger = "invoice_issued"
12-
TriggerInvoicePaymentAuthorized Trigger = "invoice_payment_authorized"
13-
TriggerInvoicePaymentSettled Trigger = "invoice_payment_settled"
8+
TriggerNext Trigger = "next"
9+
TriggerPartialInvoiceCreated Trigger = "partial_invoice_created"
10+
TriggerFinalInvoiceCreated Trigger = "final_invoice_created"
11+
TriggerCollectionCompleted Trigger = "collection_completed"
12+
TriggerInvoiceIssued Trigger = "invoice_issued"
13+
TriggerAllPaymentsSettled Trigger = "all_payments_settled"
1414
)

openmeter/billing/charges/service/invoicable_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,7 +1191,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceLifecycle()
11911191
}, invoice.Totals)
11921192

11931193
usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
1194-
s.Equal(usagebased.StatusActiveFinalRealizationCompleted, usageBasedCharge.Status)
1194+
s.Equal(usagebased.StatusActiveFinalRealizationProcessing, usageBasedCharge.Status)
11951195
s.NotNil(usageBasedCharge.State.CurrentRealizationRunID)
11961196
s.Len(usageBasedCharge.Realizations, 1)
11971197

@@ -1236,7 +1236,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceLifecycle()
12361236
s.Equal(1, invoiceUsageAccruedCallback.nrInvocations)
12371237

12381238
usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
1239-
s.Equal(usagebased.StatusActivePaymentPending, usageBasedCharge.Status)
1239+
s.Equal(usagebased.StatusActiveAwaitingPaymentSettlement, usageBasedCharge.Status)
12401240
s.Nil(usageBasedCharge.State.CurrentRealizationRunID)
12411241
s.Nil(usageBasedCharge.State.AdvanceAfter)
12421242
s.Len(usageBasedCharge.Realizations, 1)
@@ -1258,7 +1258,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceLifecycle()
12581258
s.Equal(invoiceUsageAccruedCallback.id, finalRun.InvoiceUsage.LedgerTransaction.TransactionGroupID)
12591259
})
12601260

1261-
s.Run("#7 payment authorization moves charge to active authorized", func() {
1261+
s.Run("#7 payment authorization keeps charge awaiting settlement", func() {
12621262
defer s.UsageBasedTestHandler.Reset()
12631263

12641264
authorizedCallback := newCountedLedgerTransactionCallback[usagebased.OnPaymentAuthorizedInput]()
@@ -1279,7 +1279,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceLifecycle()
12791279
s.Equal(1, authorizedCallback.nrInvocations)
12801280

12811281
usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
1282-
s.Equal(usagebased.StatusActiveAuthorized, usageBasedCharge.Status)
1282+
s.Equal(usagebased.StatusActiveAwaitingPaymentSettlement, usageBasedCharge.Status)
12831283
s.Len(usageBasedCharge.Realizations, 1)
12841284

12851285
finalRun := usageBasedCharge.Realizations[0]
@@ -1465,7 +1465,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceFullyCredite
14651465
}, invoice.Totals)
14661466

14671467
usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
1468-
s.Equal(usagebased.StatusActiveFinalRealizationCompleted, usageBasedCharge.Status)
1468+
s.Equal(usagebased.StatusActiveFinalRealizationProcessing, usageBasedCharge.Status)
14691469
s.NotNil(usageBasedCharge.State.CurrentRealizationRunID)
14701470
s.Len(usageBasedCharge.Realizations, 1)
14711471

@@ -1491,7 +1491,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceFullyCredite
14911491
s.Equal(0, invoiceUsageAccruedCallback.nrInvocations)
14921492

14931493
usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
1494-
s.Equal(usagebased.StatusActivePaymentPending, usageBasedCharge.Status)
1494+
s.Equal(usagebased.StatusActiveAwaitingPaymentSettlement, usageBasedCharge.Status)
14951495
s.Nil(usageBasedCharge.State.CurrentRealizationRunID)
14961496
s.Nil(usageBasedCharge.State.AdvanceAfter)
14971497
s.Len(usageBasedCharge.Realizations, 1)

0 commit comments

Comments
 (0)