Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/skills/charges/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ Important rules:
- usage-based exposes its billing line engine from `usagebased.Service.GetLineEngine()`; register that returned engine instead of reusing the service type directly
- flat-fee now follows the same pattern: `flatfee.Service.GetLineEngine()` returns the engine owned by the service package
- 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`
- 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`
- 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.
- 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`
- 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

Operational consequence:

Expand Down
12 changes: 6 additions & 6 deletions openmeter/billing/charges/meta/triggers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import "github.com/qmuntal/stateless"
type Trigger = stateless.Trigger

var (
TriggerNext Trigger = "next"
TriggerInvoiceCreated Trigger = "invoice_created"
TriggerCollectionCompleted Trigger = "collection_completed"
TriggerInvoiceIssued Trigger = "invoice_issued"
TriggerInvoicePaymentAuthorized Trigger = "invoice_payment_authorized"
TriggerInvoicePaymentSettled Trigger = "invoice_payment_settled"
TriggerNext Trigger = "next"
TriggerPartialInvoiceCreated Trigger = "partial_invoice_created"
TriggerFinalInvoiceCreated Trigger = "final_invoice_created"
TriggerCollectionCompleted Trigger = "collection_completed"
TriggerInvoiceIssued Trigger = "invoice_issued"
TriggerAllPaymentsSettled Trigger = "all_payments_settled"
)
12 changes: 6 additions & 6 deletions openmeter/billing/charges/service/invoicable_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1191,7 +1191,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceLifecycle()
}, invoice.Totals)

usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
s.Equal(usagebased.StatusActiveFinalRealizationCompleted, usageBasedCharge.Status)
s.Equal(usagebased.StatusActiveFinalRealizationProcessing, usageBasedCharge.Status)
s.NotNil(usageBasedCharge.State.CurrentRealizationRunID)
s.Len(usageBasedCharge.Realizations, 1)

Expand Down Expand Up @@ -1236,7 +1236,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceLifecycle()
s.Equal(1, invoiceUsageAccruedCallback.nrInvocations)

usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
s.Equal(usagebased.StatusActivePaymentPending, usageBasedCharge.Status)
s.Equal(usagebased.StatusActiveAwaitingPaymentSettlement, usageBasedCharge.Status)
s.Nil(usageBasedCharge.State.CurrentRealizationRunID)
s.Nil(usageBasedCharge.State.AdvanceAfter)
s.Len(usageBasedCharge.Realizations, 1)
Expand All @@ -1258,7 +1258,7 @@ func (s *InvoicableChargesTestSuite) TestUsageBasedCreditThenInvoiceLifecycle()
s.Equal(invoiceUsageAccruedCallback.id, finalRun.InvoiceUsage.LedgerTransaction.TransactionGroupID)
})

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

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

usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
s.Equal(usagebased.StatusActiveAuthorized, usageBasedCharge.Status)
s.Equal(usagebased.StatusActiveAwaitingPaymentSettlement, usageBasedCharge.Status)
s.Len(usageBasedCharge.Realizations, 1)

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

usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
s.Equal(usagebased.StatusActiveFinalRealizationCompleted, usageBasedCharge.Status)
s.Equal(usagebased.StatusActiveFinalRealizationProcessing, usageBasedCharge.Status)
s.NotNil(usageBasedCharge.State.CurrentRealizationRunID)
s.Len(usageBasedCharge.Realizations, 1)

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

usageBasedCharge := s.mustGetUsageBasedChargeByID(usageBasedChargeID)
s.Equal(usagebased.StatusActivePaymentPending, usageBasedCharge.Status)
s.Equal(usagebased.StatusActiveAwaitingPaymentSettlement, usageBasedCharge.Status)
s.Nil(usageBasedCharge.State.CurrentRealizationRunID)
s.Nil(usageBasedCharge.State.AdvanceAfter)
s.Len(usageBasedCharge.Realizations, 1)
Expand Down
Loading
Loading