Skip to content

Commit 69baee2

Browse files
authored
feat: book invoice accrued for usagebased items (#4144)
1 parent 6ac2c95 commit 69baee2

37 files changed

Lines changed: 1299 additions & 326 deletions

File tree

.agents/skills/charges/SKILL.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ Current shared contract details:
126126
- optional split outputs use pointers; `SplitGatheringLineResult.PostSplitAtLine` is `*billing.GatheringLine`
127127
- use `billing.ValidateStandardLineIDsMatchExactly(...)` when a charge-side test or helper needs to assert that returned standard-line identities are preserved across a line-engine boundary
128128
- collection-time errors should be normalized through `billing.NewLineEngineValidationError(...)` instead of rebuilding the validation-issue wrapper at each billing callsite
129+
- 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
130+
- 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
129131

130132
## Timestamp Normalization
131133

@@ -193,6 +195,25 @@ Placement guidance:
193195
- do not mutate whole intents inside adapters just to normalize currency; if an adapter needs a persistence backstop, keep it local to the `Set*` write
194196
- when ledger logic derives monetary values from balances, entries, or its own calculations, round those values in ledger code as well; do not rely only on upstream callers
195197

198+
## Zero Invoice Accrual
199+
200+
Invoice accrual uses a non-negative, no-op-aware contract.
201+
202+
Rules:
203+
204+
- negative invoice-accrual amounts are invalid
205+
- zero invoice-accrual amounts are valid no-ops
206+
- positive invoice-accrual amounts must produce a non-empty ledger transaction group reference
207+
- charges-side services should short-circuit zero before calling persistence that expects a real ledger transaction
208+
- do not persist `invoicedusage.AccruedUsage` rows with an empty `LedgerTransaction.TransactionGroupID`
209+
210+
Current expected behavior:
211+
212+
- `usagebased.OnInvoiceUsageAccruedInput.Validate()` allows zero and rejects only negatives
213+
- usage-based and flat-fee service/orchestration layers should skip invoice-accrual persistence when the invoice line total is zero
214+
- ledger handlers may still defensively tolerate zero and return `ledgertransaction.GroupReference{}`
215+
- when a service proceeds with non-zero invoice accrual, it must require a non-empty transaction group reference before storing accrued usage
216+
196217
## Realization Helper Subpackages
197218

198219
Use small type-specific realization helper subpackages to keep charge services and state machines from becoming kitchen-sink orchestration layers.

app/common/charges.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,16 @@ func NewChargesCreditPurchaseHandler(
8585
return ledgerchargeadapter.NewCreditPurchaseHandler(ledgerService, accountResolver, accountService)
8686
}
8787

88-
func NewChargesUsageBasedHandler(collectorService ledgercollector.Service) usagebased.Handler {
89-
return ledgerchargeadapter.NewUsageBasedHandler(collectorService)
88+
func NewChargesUsageBasedHandler(
89+
ledgerService ledger.Ledger,
90+
accountResolver ledger.AccountResolver,
91+
accountService ledgeraccount.Service,
92+
collectorService ledgercollector.Service,
93+
) usagebased.Handler {
94+
return ledgerchargeadapter.NewUsageBasedHandler(ledgerService, transactions.ResolverDependencies{
95+
AccountService: accountResolver,
96+
SubAccountService: accountService,
97+
}, collectorService)
9098
}
9199

92100
func NewChargesFlatFeeAdapter(
@@ -309,8 +317,8 @@ func newChargesRegistry(
309317

310318
collectorService := NewChargesCollectorService(ledgerService, accountResolver, accountService)
311319
flatFeeHandler := NewChargesFlatFeeHandler(ledgerService, accountResolver, accountService, collectorService)
320+
usageBasedHandler := NewChargesUsageBasedHandler(ledgerService, accountResolver, accountService, collectorService)
312321
creditPurchaseHandler := NewChargesCreditPurchaseHandler(ledgerService, accountResolver, accountService)
313-
usageBasedHandler := NewChargesUsageBasedHandler(collectorService)
314322

315323
flatFeeAdapter, err := NewChargesFlatFeeAdapter(db, logger, metaAdapter)
316324
if err != nil {

app/common/customerbalance.go

Lines changed: 2 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,19 @@
11
package common
22

33
import (
4-
"context"
54
"log/slog"
65

76
"github.com/google/wire"
87

98
"github.com/openmeterio/openmeter/app/config"
10-
"github.com/openmeterio/openmeter/openmeter/billing/charges"
11-
chargeadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/adapter"
12-
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
13-
flatfeeadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/adapter"
14-
flatfeeservice "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service"
15-
lineageadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/adapter"
16-
lineageservice "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/service"
17-
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
18-
metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter"
19-
"github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased"
20-
usagebasedadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/adapter"
21-
usagebasedservice "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/service"
229
"github.com/openmeterio/openmeter/openmeter/billing/rating"
2310
entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
2411
"github.com/openmeterio/openmeter/openmeter/ledger"
2512
ledgeraccount "github.com/openmeterio/openmeter/openmeter/ledger/account"
26-
ledgerchargeadapter "github.com/openmeterio/openmeter/openmeter/ledger/chargeadapter"
27-
ledgercollector "github.com/openmeterio/openmeter/openmeter/ledger/collector"
2813
"github.com/openmeterio/openmeter/openmeter/ledger/customerbalance"
29-
"github.com/openmeterio/openmeter/openmeter/ledger/transactions"
3014
"github.com/openmeterio/openmeter/openmeter/productcatalog/feature"
3115
"github.com/openmeterio/openmeter/openmeter/streaming"
3216
"github.com/openmeterio/openmeter/pkg/framework/lockr"
33-
"github.com/openmeterio/openmeter/pkg/pagination"
3417
)
3518

3619
var CustomerBalance = wire.NewSet(
@@ -55,165 +38,14 @@ func NewCustomerBalanceService(
5538
return customerbalance.NewNoopService(), nil
5639
}
5740

58-
metaAdapter, err := metaadapter.New(metaadapter.Config{
59-
Client: db,
60-
Logger: logger,
61-
})
62-
if err != nil {
63-
return nil, err
64-
}
65-
66-
lineageAdapter, err := lineageadapter.New(lineageadapter.Config{
67-
Client: db,
68-
})
69-
if err != nil {
70-
return nil, err
71-
}
72-
73-
lineageService, err := lineageservice.New(lineageservice.Config{
74-
Adapter: lineageAdapter,
75-
})
76-
if err != nil {
77-
return nil, err
78-
}
79-
80-
searchAdapter, err := chargeadapter.New(chargeadapter.Config{
81-
Client: db,
82-
Logger: logger,
83-
})
84-
if err != nil {
85-
return nil, err
86-
}
87-
88-
flatFeeAdapter, err := flatfeeadapter.New(flatfeeadapter.Config{
89-
Client: db,
90-
Logger: logger,
91-
MetaAdapter: metaAdapter,
92-
})
93-
if err != nil {
94-
return nil, err
95-
}
96-
97-
collectorService := ledgercollector.NewService(ledgercollector.Config{
98-
Ledger: historicalLedger,
99-
Dependencies: transactions.ResolverDependencies{
100-
AccountService: accountResolver,
101-
SubAccountService: accountService,
102-
},
103-
})
104-
105-
flatFeeService, err := flatfeeservice.New(flatfeeservice.Config{
106-
Adapter: flatFeeAdapter,
107-
Handler: ledgerchargeadapter.NewFlatFeeHandler(historicalLedger, transactions.ResolverDependencies{AccountService: accountResolver, SubAccountService: accountService}, collectorService),
108-
Lineage: lineageService,
109-
MetaAdapter: metaAdapter,
110-
Locker: locker,
111-
})
112-
if err != nil {
113-
return nil, err
114-
}
115-
116-
usageAdapter, err := usagebasedadapter.New(usagebasedadapter.Config{
117-
Client: db,
118-
Logger: logger,
119-
MetaAdapter: metaAdapter,
120-
})
121-
if err != nil {
122-
return nil, err
123-
}
124-
125-
usageService, err := usagebasedservice.New(usagebasedservice.Config{
126-
Adapter: usageAdapter,
127-
Handler: ledgerchargeadapter.NewUsageBasedHandler(collectorService),
128-
Lineage: lineageService,
129-
Locker: locker,
130-
MetaAdapter: metaAdapter,
131-
CustomerOverrideService: billingRegistry.Billing,
132-
FeatureService: featureConnector,
133-
RatingService: ratingService,
134-
StreamingConnector: streamingConnector,
135-
})
136-
if err != nil {
137-
return nil, err
138-
}
139-
14041
return customerbalance.New(customerbalance.Config{
14142
AccountResolver: accountResolver,
14243
SubAccountService: accountService,
143-
ChargesService: customerBalanceChargeStore{search: searchAdapter, flatFeeService: flatFeeService, usageBasedService: usageService},
144-
UsageBasedService: usageService,
44+
ChargesService: billingRegistry.Charges.Service,
45+
UsageBasedService: billingRegistry.Charges.UsageBasedService,
14546
})
14647
}
14748

14849
func NewCustomerBalanceFacade(service customerbalance.FacadeService) (*customerbalance.Facade, error) {
14950
return customerbalance.NewFacade(service)
15051
}
151-
152-
type customerBalanceChargeStore struct {
153-
search charges.ChargesSearchAdapter
154-
flatFeeService flatfee.Service
155-
usageBasedService usagebased.Service
156-
}
157-
158-
func (s customerBalanceChargeStore) ListCharges(ctx context.Context, input charges.ListChargesInput) (pagination.Result[charges.Charge], error) {
159-
searchResult, err := s.search.ListCharges(ctx, input)
160-
if err != nil {
161-
return pagination.Result[charges.Charge]{}, err
162-
}
163-
164-
flatFeeIDs := make([]string, 0, len(searchResult.Items))
165-
usageBasedIDs := make([]string, 0, len(searchResult.Items))
166-
167-
for _, item := range searchResult.Items {
168-
switch item.Type {
169-
case meta.ChargeTypeFlatFee:
170-
flatFeeIDs = append(flatFeeIDs, item.ID.ID)
171-
case meta.ChargeTypeUsageBased:
172-
usageBasedIDs = append(usageBasedIDs, item.ID.ID)
173-
}
174-
}
175-
176-
flatFeeCharges, err := s.flatFeeService.GetByIDs(ctx, flatfee.GetByIDsInput{
177-
Namespace: input.Namespace,
178-
IDs: flatFeeIDs,
179-
Expands: input.Expands,
180-
})
181-
if err != nil {
182-
return pagination.Result[charges.Charge]{}, err
183-
}
184-
185-
usageBasedCharges, err := s.usageBasedService.GetByIDs(ctx, usagebased.GetByIDsInput{
186-
Namespace: input.Namespace,
187-
IDs: usageBasedIDs,
188-
Expands: input.Expands,
189-
})
190-
if err != nil {
191-
return pagination.Result[charges.Charge]{}, err
192-
}
193-
194-
chargesByID := make(map[string]charges.Charge, len(flatFeeCharges)+len(usageBasedCharges))
195-
196-
for _, charge := range flatFeeCharges {
197-
chargesByID[charge.ID] = charges.NewCharge(charge)
198-
}
199-
200-
for _, charge := range usageBasedCharges {
201-
chargesByID[charge.ID] = charges.NewCharge(charge)
202-
}
203-
204-
items := make([]charges.Charge, 0, len(searchResult.Items))
205-
for _, item := range searchResult.Items {
206-
charge, ok := chargesByID[item.ID.ID]
207-
if !ok {
208-
continue
209-
}
210-
211-
items = append(items, charge)
212-
}
213-
214-
return pagination.Result[charges.Charge]{
215-
Page: searchResult.Page,
216-
TotalCount: searchResult.TotalCount,
217-
Items: items,
218-
}, nil
219-
}

openmeter/billing/adapter/invoice.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,8 @@ func (a *adapter) IsAppUsed(ctx context.Context, appID app.AppID) error {
846846
billing.StandardInvoiceStatusGathering,
847847
billing.StandardInvoiceStatusIssuingSyncing,
848848
billing.StandardInvoiceStatusIssuingSyncFailed,
849+
billing.StandardInvoiceStatusIssuingChargeBooking,
850+
billing.StandardInvoiceStatusIssuingChargeBookingFailed,
849851
billing.StandardInvoiceStatusIssued,
850852
billing.StandardInvoiceStatusPaymentProcessingPending,
851853
billing.StandardInvoiceStatusPaymentProcessingFailed,

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ func (e *Engine) OnStandardInvoiceCreated(_ context.Context, input billing.OnSta
8383
return input.Lines, nil
8484
}
8585

86+
func (e *Engine) OnInvoiceIssued(_ context.Context, _ billing.OnInvoiceIssuedInput) error {
87+
return nil
88+
}
89+
8690
func (e *Engine) CalculateLines(input billing.CalculateLinesInput) (billing.StandardLines, error) {
8791
if input.Invoice.ID == "" {
8892
return nil, fmt.Errorf("invoice id is required")

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

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,18 @@ func (e *Engine) BuildStandardInvoiceLines(ctx context.Context, input billing.Bu
7979
return nil, err
8080
}
8181

82-
gatheringLinesByID := make(map[string]billing.GatheringLine, len(input.GatheringLines))
83-
for _, gatheringLine := range input.GatheringLines {
84-
gatheringLinesByID[gatheringLine.ID] = gatheringLine
82+
return e.CalculateLines(billing.CalculateLinesInput{
83+
Invoice: input.Invoice,
84+
Lines: stdLines,
85+
})
86+
}
87+
88+
func (e *Engine) OnStandardInvoiceCreated(ctx context.Context, input billing.OnStandardInvoiceCreatedInput) (billing.StandardLines, error) {
89+
if err := input.Validate(); err != nil {
90+
return nil, fmt.Errorf("validating input: %w", err)
8591
}
8692

87-
for _, stdLine := range stdLines {
93+
for _, stdLine := range input.Lines {
8894
chargeID := stdLine.ChargeID
8995
if chargeID == nil {
9096
return nil, fmt.Errorf("flat fee standard line[%s]: charge id is required", stdLine.ID)
@@ -103,12 +109,7 @@ func (e *Engine) BuildStandardInvoiceLines(ctx context.Context, input billing.Bu
103109
return nil, fmt.Errorf("getting flat fee charge for line[%s]: %w", stdLine.ID, err)
104110
}
105111

106-
sourceGatheringLine, ok := gatheringLinesByID[stdLine.ID]
107-
if !ok {
108-
return nil, fmt.Errorf("flat fee standard line[%s]: source gathering line not found", stdLine.ID)
109-
}
110-
111-
realizations, err := e.flatFeeService.PostLineAssignedToInvoice(ctx, charge, sourceGatheringLine)
112+
realizations, err := e.flatFeeService.PostLineAssignedToInvoice(ctx, charge, *stdLine)
112113
if err != nil {
113114
return nil, fmt.Errorf("allocating credits for line[%s]: %w", stdLine.ID, err)
114115
}
@@ -118,18 +119,15 @@ func (e *Engine) BuildStandardInvoiceLines(ctx context.Context, input billing.Bu
118119
}
119120
}
120121

121-
return e.CalculateLines(billing.CalculateLinesInput{
122-
Invoice: input.Invoice,
123-
Lines: stdLines,
124-
})
122+
return e.CalculateLines(billing.CalculateLinesInput(input))
125123
}
126124

127125
func (e *Engine) OnCollectionCompleted(_ context.Context, input billing.OnCollectionCompletedInput) (billing.StandardLines, error) {
128126
return input.Lines, nil
129127
}
130128

131-
func (e *Engine) OnStandardInvoiceCreated(_ context.Context, input billing.OnStandardInvoiceCreatedInput) (billing.StandardLines, error) {
132-
return input.Lines, nil
129+
func (e *Engine) OnInvoiceIssued(_ context.Context, _ billing.OnInvoiceIssuedInput) error {
130+
return nil
133131
}
134132

135133
func (e *Engine) CalculateLines(input billing.CalculateLinesInput) (billing.StandardLines, error) {

openmeter/billing/charges/flatfee/service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type FlatFeeService interface {
2626
}
2727

2828
type InvoiceLifecycleHooks interface {
29-
PostLineAssignedToInvoice(ctx context.Context, charge Charge, line billing.GatheringLine) (creditrealization.Realizations, error)
29+
PostLineAssignedToInvoice(ctx context.Context, charge Charge, line billing.StandardLine) (creditrealization.Realizations, error)
3030
PostInvoiceIssued(ctx context.Context, charge Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error
3131
PostInvoicePaymentAuthorized(ctx context.Context, charge Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error
3232
PostInvoicePaymentSettled(ctx context.Context, charge Charge, lineWithHeader billing.StandardLineWithInvoiceHeader) error

openmeter/billing/charges/flatfee/service/invoice.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import (
1313
"github.com/openmeterio/openmeter/pkg/framework/transaction"
1414
)
1515

16-
// TODO: Once we have proper UBP handling this should happen on the already converted StandardLine but for now we should be fine with this approach.
17-
func (s *service) PostLineAssignedToInvoice(ctx context.Context, charge flatfee.Charge, line billing.GatheringLine) (creditrealization.Realizations, error) {
16+
func (s *service) PostLineAssignedToInvoice(ctx context.Context, charge flatfee.Charge, line billing.StandardLine) (creditrealization.Realizations, error) {
1817
if charge.State.AmountAfterProration.IsZero() {
1918
return nil, nil
2019
}
@@ -27,7 +26,7 @@ func (s *service) PostLineAssignedToInvoice(ctx context.Context, charge flatfee.
2726

2827
input := flatfee.OnAssignedToInvoiceInput{
2928
Charge: charge,
30-
ServicePeriod: line.ServicePeriod,
29+
ServicePeriod: line.Period.ToClosedPeriod(),
3130
PreTaxTotalAmount: currencyCalculator.RoundToPrecision(charge.State.AmountAfterProration),
3231
}
3332
if err := input.Validate(); err != nil {
@@ -64,6 +63,10 @@ func (s *service) PostInvoiceIssued(ctx context.Context, charge flatfee.Charge,
6463
return fmt.Errorf("postInvoiceIssued: line is nil")
6564
}
6665

66+
if lineWithHeader.Line.Totals.Total.IsZero() {
67+
return nil
68+
}
69+
6770
ledgerTransactionRef, err := s.handler.OnInvoiceUsageAccrued(ctx, flatfee.OnInvoiceUsageAccruedInput{
6871
Charge: charge,
6972
ServicePeriod: lineWithHeader.Line.Period.ToClosedPeriod(),

openmeter/billing/charges/meta/triggers.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ var (
88
TriggerNext Trigger = "next"
99
TriggerInvoiceCreated Trigger = "invoice_created"
1010
TriggerCollectionCompleted Trigger = "collection_completed"
11+
TriggerInvoiceIssued Trigger = "invoice_issued"
1112
)

0 commit comments

Comments
 (0)