Skip to content

Commit 605f03f

Browse files
authored
feat: persist detailed lines (#4202)
1 parent 9066703 commit 605f03f

55 files changed

Lines changed: 2006 additions & 233 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/charges/SKILL.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Primary packages:
3535

3636
`openmeter/billing/charges` is the root facade for charge operations.
3737

38-
For charge-owned detailed lines, the shared invoice-agnostic base belongs in `openmeter/billing/models/stddetailedline`. Keep charge wrappers thin by adding only `charge_id` / `run_id`-style ownership fields around `stddetailedline.Base`, and reuse shared detailed-line base mapping helpers instead of duplicating the common field assembly in charge adapters.
38+
For charge-owned detailed lines, the shared invoice-agnostic base belongs in `openmeter/billing/models/stddetailedline`. Prefer using `type DetailedLine = stddetailedline.Base` in charge packages and keep ownership implicit through containment in the parent aggregate (`flatfee.Realizations` or `usagebased.RealizationRun`) rather than duplicating `charge_id` / `run_id` fields in the domain type. Reuse the shared detailed-line base mapping and create helpers instead of duplicating common field assembly in charge adapters.
3939

4040
Charge-backed invoicing no longer relies on a charges-side `InvoicePendingLines(...)` wrapper. Billing owns invoice creation and dispatches gathering lines by `billing.LineEngineType`, while charge packages provide charge-specific line engines where needed.
4141

@@ -73,6 +73,7 @@ Important types:
7373
- `CreditRealizations`
7474
- `AccruedUsage`
7575
- `Payment`
76+
- `DetailedLines`
7677
- `flatfee.Intent.CalculateAmountAfterProration()` computes the prorated amount from `AmountBeforeProration`, `ServicePeriod/FullServicePeriod` ratio, and `ProRating` config, with currency-precision rounding
7778
- Charge-backed targets do not use invoice-style semantic proration or empty-period filtering; the charge stack materializes and prorates state itself, and the flat fee charge is responsible for omitting empty lines
7879
- `usagebased.Intent` carries `FeatureKey`, `Price`, `SettlementMode`, `InvoiceAt`, and `ServicePeriod`
@@ -86,6 +87,15 @@ Important types:
8687
- `CollectionEnd`
8788
- `MeterValue`
8889
- `Totals`
90+
- `usagebased.RealizationRun` can expand:
91+
- `DetailedLines`
92+
93+
Detailed-line expansion rules:
94+
95+
- `meta.ExpandDetailedLines` is not standalone for charge reads; it requires `meta.ExpandRealizations`
96+
- usage-based detailed lines live under `RealizationRun.DetailedLines`
97+
- flat-fee detailed lines also live under `Charge.Realizations.DetailedLines`, not on the root charge
98+
- `mo.None()` means detailed lines were not expanded; present options mean expanded data, even when the underlying slice is nil/empty
8999

90100
## Billing Line Engines
91101

@@ -99,7 +109,7 @@ Current engine values:
99109

100110
Current implementations:
101111

102-
- flat fee line engine: `openmeter/billing/charges/flatfee/lineengine`
112+
- flat fee line engine: `openmeter/billing/charges/flatfee/service/lineengine.go`
103113
- credit purchase line engine: `openmeter/billing/charges/creditpurchase/lineengine`
104114
- usage-based line engine: `openmeter/billing/charges/usagebased/service/lineengine.go`
105115

@@ -112,6 +122,8 @@ Important rules:
112122
- charge test setups must also register those engines explicitly; keep this in `openmeter/billing/charges/testutils`
113123
- if a charge create path stamps a new `LineEngineType`, app wiring and charge test wiring must register a matching implementation in the same change
114124
- usage-based exposes its billing line engine from `usagebased.Service.GetLineEngine()`; register that returned engine instead of reusing the service type directly
125+
- flat-fee now follows the same pattern: `flatfee.Service.GetLineEngine()` returns the engine owned by the service package
126+
- 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`
115127

116128
Operational consequence:
117129

app/common/charges.go

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import (
1313
creditpurchaseservice "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service"
1414
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
1515
flatfeeadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/adapter"
16-
flatfeelineengine "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/lineengine"
1716
flatfeeservice "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service"
1817
"github.com/openmeterio/openmeter/openmeter/billing/charges/lineage"
1918
lineageadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/adapter"
@@ -146,13 +145,15 @@ func NewChargesFlatFeeService(
146145
lineageService lineage.Service,
147146
metaAdapter meta.Adapter,
148147
locker *lockr.Locker,
148+
ratingService rating.Service,
149149
) (flatfee.Service, error) {
150150
flatFeeSvc, err := flatfeeservice.New(flatfeeservice.Config{
151-
Adapter: flatFeeAdapter,
152-
Handler: flatFeeHandler,
153-
Lineage: lineageService,
154-
MetaAdapter: metaAdapter,
155-
Locker: locker,
151+
Adapter: flatFeeAdapter,
152+
Handler: flatFeeHandler,
153+
Lineage: lineageService,
154+
MetaAdapter: metaAdapter,
155+
Locker: locker,
156+
RatingService: ratingService,
156157
})
157158
if err != nil {
158159
return nil, fmt.Errorf("failed to create charges flat fee service: %w", err)
@@ -325,20 +326,12 @@ func newChargesRegistry(
325326
return nil, err
326327
}
327328

328-
flatFeeSvc, err := NewChargesFlatFeeService(flatFeeAdapter, flatFeeHandler, lineageService, metaAdapter, locker)
329+
flatFeeSvc, err := NewChargesFlatFeeService(flatFeeAdapter, flatFeeHandler, lineageService, metaAdapter, locker, ratingService)
329330
if err != nil {
330331
return nil, err
331332
}
332333

333-
flatFeeLineEngine, err := flatfeelineengine.New(flatfeelineengine.Config{
334-
FlatFeeService: flatFeeSvc,
335-
RatingService: ratingService,
336-
})
337-
if err != nil {
338-
return nil, fmt.Errorf("failed to create charges flat fee line engine: %w", err)
339-
}
340-
341-
if err := billingService.RegisterLineEngine(flatFeeLineEngine); err != nil {
334+
if err := billingService.RegisterLineEngine(flatFeeSvc.GetLineEngine()); err != nil {
342335
return nil, fmt.Errorf("failed to register charges flat fee line engine: %w", err)
343336
}
344337

openmeter/billing/adapter/stdinvoicelinemapper.go

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -209,43 +209,16 @@ func (a *adapter) mapStandardInvoiceDetailedLineFromDB(dbLine *db.BillingInvoice
209209
}
210210

211211
func (a *adapter) mapStandardInvoiceDetailedLineV2FromDB(dbLine *db.BillingStandardInvoiceDetailedLine) (billing.DetailedLine, error) {
212-
creditsApplied := lo.FromPtr(dbLine.CreditsApplied)
213-
if len(creditsApplied) == 0 {
214-
creditsApplied = nil
215-
}
216-
217212
detailedLineBase := billing.DetailedLineBase{
218213
InvoiceID: dbLine.InvoiceID,
219-
Base: stddetailedline.Base{
220-
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
221-
Namespace: dbLine.Namespace,
222-
ID: dbLine.ID,
223-
CreatedAt: dbLine.CreatedAt.In(time.UTC),
224-
UpdatedAt: dbLine.UpdatedAt.In(time.UTC),
225-
DeletedAt: convert.TimePtrIn(dbLine.DeletedAt, time.UTC),
226-
Name: dbLine.Name,
227-
Description: dbLine.Description,
228-
}),
229-
ChildUniqueReferenceID: dbLine.ChildUniqueReferenceID,
230-
ServicePeriod: timeutil.ClosedPeriod{
231-
From: dbLine.ServicePeriodStart.In(time.UTC),
232-
To: dbLine.ServicePeriodEnd.In(time.UTC),
233-
},
234-
PerUnitAmount: dbLine.PerUnitAmount,
235-
Quantity: dbLine.Quantity,
236-
Category: dbLine.Category,
237-
PaymentTerm: dbLine.PaymentTerm,
238-
Index: dbLine.Index,
239-
Currency: dbLine.Currency,
240-
CreditsApplied: creditsApplied,
241-
TaxConfig: backfillTaxConfigReferences(
214+
Base: stddetailedline.FromDB(
215+
dbLine,
216+
backfillTaxConfigReferences(
242217
lo.EmptyableToPtr(dbLine.TaxConfig),
243218
dbLine.TaxBehavior,
244219
taxCodeFromDetailedLineV2Edge(dbLine),
245220
),
246-
Totals: totals.FromDB(dbLine),
247-
ExternalIDs: externalid.MapLineExternalIDFromDB(dbLine),
248-
},
221+
),
249222
}
250223

251224
discounts, err := slicesx.MapWithErr(dbLine.Edges.AmountDiscounts, a.mapStandardInvoiceDetailedLineAmountDiscountFromDB)

openmeter/billing/adapter/stdinvoicelines.go

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/openmeterio/openmeter/openmeter/billing"
1414
"github.com/openmeterio/openmeter/openmeter/billing/models/externalid"
15+
"github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline"
1516
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
1617
"github.com/openmeterio/openmeter/openmeter/customer"
1718
"github.com/openmeterio/openmeter/openmeter/ent/db"
@@ -421,22 +422,9 @@ func (a *adapter) upsertDetailedLinesV2(ctx context.Context, in detailedLineDiff
421422
SetID(line.ID).
422423
SetNamespace(line.Namespace).
423424
SetInvoiceID(line.InvoiceID).
424-
SetServicePeriodStart(line.ServicePeriod.From.In(time.UTC)).
425-
SetServicePeriodEnd(line.ServicePeriod.To.In(time.UTC)).
426-
SetParentLineID(lineWithParent.Parent.ID).
427-
SetNillableDeletedAt(line.DeletedAt).
428-
SetName(line.Name).
429-
SetNillableDescription(line.Description).
430-
SetCurrency(line.Currency).
431-
SetQuantity(line.Quantity).
432-
SetPerUnitAmount(line.PerUnitAmount).
433-
SetChildUniqueReferenceID(line.ChildUniqueReferenceID).
434-
SetCategory(line.Category).
435-
SetPaymentTerm(line.PaymentTerm).
436-
SetNillableIndex(line.Index)
425+
SetParentLineID(lineWithParent.Parent.ID)
437426

438-
create = externalid.CreateLineExternalID(create, line.ExternalIDs)
439-
create = totals.Set(create, line.Totals)
427+
create = stddetailedline.Create(create, line.Base)
440428

441429
if len(line.CreditsApplied) > 0 {
442430
create = create.SetCreditsApplied(&line.CreditsApplied)

openmeter/billing/charges/flatfee/adapter.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
type Adapter interface {
1919
ChargeAdapter
20+
ChargeDetailedLineAdapter
2021
ChargeCreditAllocationAdapter
2122
ChargeInvoicedUsageAdapter
2223
ChargePaymentAdapter
@@ -32,6 +33,11 @@ type ChargeAdapter interface {
3233
GetByID(ctx context.Context, id GetByIDInput) (Charge, error)
3334
}
3435

36+
type ChargeDetailedLineAdapter interface {
37+
UpsertDetailedLines(ctx context.Context, chargeID meta.ChargeID, lines DetailedLines) error
38+
FetchDetailedLines(ctx context.Context, charge Charge) (Charge, error)
39+
}
40+
3541
type ChargeInvoicedUsageAdapter interface {
3642
CreateInvoicedUsage(ctx context.Context, chargeID meta.ChargeID, invoicedUsage invoicedusage.AccruedUsage) (invoicedusage.AccruedUsage, error)
3743
}
@@ -89,7 +95,7 @@ func (i GetByIDsInput) Validate() error {
8995
}
9096
}
9197

92-
if err := i.Expands.Validate(); err != nil {
98+
if err := validateExpands(i.Expands); err != nil {
9399
errs = append(errs, fmt.Errorf("expands: %w", err))
94100
}
95101

@@ -107,7 +113,7 @@ func (i GetByIDInput) Validate() error {
107113
errs = append(errs, fmt.Errorf("charge ID: %w", err))
108114
}
109115

110-
if err := i.Expands.Validate(); err != nil {
116+
if err := validateExpands(i.Expands); err != nil {
111117
errs = append(errs, fmt.Errorf("expands: %w", err))
112118
}
113119

@@ -134,3 +140,15 @@ func (i CreateChargesInput) Validate() error {
134140

135141
return models.NewNillableGenericValidationError(errors.Join(errs...))
136142
}
143+
144+
func validateExpands(expands meta.Expands) error {
145+
if err := expands.Validate(); err != nil {
146+
return err
147+
}
148+
149+
if expands.Has(meta.ExpandDetailedLines) && !expands.Has(meta.ExpandRealizations) {
150+
return fmt.Errorf("%q requires %q", meta.ExpandDetailedLines, meta.ExpandRealizations)
151+
}
152+
153+
return nil
154+
}

openmeter/billing/charges/flatfee/adapter/charge.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,12 @@ func (a *adapter) GetByIDs(ctx context.Context, input flatfee.GetByIDsInput) ([]
195195
return nil, err
196196
}
197197

198+
if input.Expands.Has(meta.ExpandDetailedLines) {
199+
return slicesx.MapWithErr(out, func(charge flatfee.Charge) (flatfee.Charge, error) {
200+
return tx.FetchDetailedLines(ctx, charge)
201+
})
202+
}
203+
198204
return out, nil
199205
})
200206
}
@@ -227,6 +233,10 @@ func (a *adapter) GetByID(ctx context.Context, input flatfee.GetByIDInput) (flat
227233
return flatfee.Charge{}, err
228234
}
229235

236+
if input.Expands.Has(meta.ExpandDetailedLines) {
237+
return tx.FetchDetailedLines(ctx, charge)
238+
}
239+
230240
return charge, nil
231241
})
232242
}

0 commit comments

Comments
 (0)