Skip to content

Commit d1f4cd5

Browse files
authored
feat: make detailed lines reusable (#4186)
1 parent b90ad6d commit d1f4cd5

130 files changed

Lines changed: 34627 additions & 10788 deletions

File tree

Some content is hidden

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

.agents/skills/billing/SKILL.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ The adapter's `diffInvoiceLines` then compares each line's current state against
7272

7373
`DetailedLine` and `GatheringLine` carry `ChildUniqueReferenceID string` for idempotent upserts. When recalculating pricing, new detailed lines (without IDs) are matched to existing DB rows via this field through `StandardLine.DetailedLinesWithIDReuse()`, avoiding unnecessary delete/re-create cycles.
7474

75+
### Shared Detailed-Line Base
76+
77+
The invoice-agnostic detailed-line domain shape lives in `openmeter/billing/models/stddetailedline`.
78+
79+
Rules:
80+
- keep shared detailed-line fields on `stddetailedline.Base`
81+
- keep invoice-only fields such as `InvoiceID` on `billing.DetailedLineBase`
82+
- when adding charge-owned detailed-line wrappers, embed `stddetailedline.Base` instead of copying the common fields again
83+
- when shared detailed-line mapping helpers exist, reuse them from billing and charges adapters instead of rebuilding the common base-field mapping inline
84+
7585
### InvoiceAt vs Period vs CollectionAt
7686

7787
- `Period`: when the service was actually rendered (usage window)

.agents/skills/charges/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ 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.
39+
3840
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.
3941

4042
Important layers:

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,8 @@ github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
14071407
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
14081408
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
14091409
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
1410+
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
1411+
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
14101412
github.com/oliveagle/jsonpath v0.1.4 h1:Sr/ffH5YSyQKjSNfvDFkQqAqh3kn/QxF/7j2jjpfOAI=
14111413
github.com/oliveagle/jsonpath v0.1.4/go.mod h1:diWEHhuLqib29heQcHYHyaLcxFC3KpKa/5ihkZBs1Z8=
14121414
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=

openmeter/billing/adapter/invoice.go

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/openmeterio/openmeter/api"
1414
"github.com/openmeterio/openmeter/openmeter/app"
1515
"github.com/openmeterio/openmeter/openmeter/billing"
16+
"github.com/openmeterio/openmeter/openmeter/billing/models/externalid"
1617
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
1718
"github.com/openmeterio/openmeter/openmeter/customer"
1819
"github.com/openmeterio/openmeter/openmeter/ent/db"
@@ -538,10 +539,7 @@ func (a *adapter) UpdateStandardInvoice(ctx context.Context, in billing.UpdateSt
538539
ClearCustomerAddressPhoneNumber()
539540
}
540541

541-
// ExternalIDs
542-
updateQuery = updateQuery.
543-
SetOrClearInvoicingAppExternalID(lo.EmptyableToPtr(in.ExternalIDs.Invoicing)).
544-
SetOrClearPaymentAppExternalID(lo.EmptyableToPtr(in.ExternalIDs.Payment))
542+
updateQuery = externalid.UpdateInvoiceExternalID(updateQuery, in.ExternalIDs)
545543

546544
_, err = updateQuery.Save(ctx)
547545
if err != nil {
@@ -699,10 +697,7 @@ func (a *adapter) mapStandardInvoiceBaseFromDB(invoice *db.BillingInvoice) billi
699697
CollectionAt: lo.ToPtr(invoice.CollectionAt.In(time.UTC)),
700698
PaymentProcessingEnteredAt: convert.TimePtrIn(invoice.PaymentProcessingEnteredAt, time.UTC),
701699

702-
ExternalIDs: billing.InvoiceExternalIDs{
703-
Invoicing: lo.FromPtr(invoice.InvoicingAppExternalID),
704-
Payment: lo.FromPtr(invoice.PaymentAppExternalID),
705-
},
700+
ExternalIDs: externalid.MapInvoiceExternalIDFromDB(invoice),
706701

707702
SchemaLevel: invoice.SchemaLevel,
708703
}

openmeter/billing/adapter/stdinvoicelinediff_test.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/stretchr/testify/require"
1010

1111
"github.com/openmeterio/openmeter/openmeter/billing"
12+
"github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline"
1213
"github.com/openmeterio/openmeter/pkg/clock"
1314
"github.com/openmeterio/openmeter/pkg/entitydiff"
1415
"github.com/openmeterio/openmeter/pkg/models"
@@ -52,17 +53,21 @@ func TestInvoiceLineDiffing(t *testing.T) {
5253
DetailedLines: billing.DetailedLines{
5354
{
5455
DetailedLineBase: billing.DetailedLineBase{
55-
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
56-
ID: "2.1",
57-
}),
56+
Base: stddetailedline.Base{
57+
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
58+
ID: "2.1",
59+
}),
60+
},
5861
},
5962
AmountDiscounts: newDetailedLineAmountDiscountsWithIDs("D2.1.1"),
6063
},
6164
{
6265
DetailedLineBase: billing.DetailedLineBase{
63-
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
64-
ID: "2.2",
65-
}),
66+
Base: stddetailedline.Base{
67+
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
68+
ID: "2.2",
69+
}),
70+
},
6671
},
6772
},
6873
},

openmeter/billing/adapter/stdinvoicelinemapper.go

Lines changed: 67 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88

99
"github.com/openmeterio/openmeter/openmeter/app"
1010
"github.com/openmeterio/openmeter/openmeter/billing"
11+
"github.com/openmeterio/openmeter/openmeter/billing/models/externalid"
12+
"github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline"
1113
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
1214
"github.com/openmeterio/openmeter/openmeter/ent/db"
1315
"github.com/openmeterio/openmeter/openmeter/productcatalog"
@@ -102,9 +104,7 @@ func (a *adapter) mapStandardInvoiceLineWithoutReferences(dbLine *db.BillingInvo
102104
RateCardDiscounts: lo.FromPtr(dbLine.RatecardDiscounts),
103105
CreditsApplied: creditsApplied,
104106
Totals: totals.FromDB(dbLine),
105-
ExternalIDs: billing.LineExternalIDs{
106-
Invoicing: lo.FromPtr(dbLine.InvoicingAppExternalID),
107-
},
107+
ExternalIDs: externalid.MapLineExternalIDFromDB(dbLine),
108108
},
109109
}
110110

@@ -163,41 +163,37 @@ func (a *adapter) mapStandardInvoiceDetailedLineFromDB(dbLine *db.BillingInvoice
163163
}
164164

165165
detailedLineBase := billing.DetailedLineBase{
166-
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
167-
Namespace: dbLine.Namespace,
168-
ID: dbLine.ID,
169-
CreatedAt: dbLine.CreatedAt.In(time.UTC),
170-
UpdatedAt: dbLine.UpdatedAt.In(time.UTC),
171-
DeletedAt: convert.TimePtrIn(dbLine.DeletedAt, time.UTC),
172-
Name: dbLine.Name,
173-
Description: dbLine.Description,
174-
}),
175-
176-
InvoiceID: dbLine.InvoiceID,
177-
ChildUniqueReferenceID: dbLine.ChildUniqueReferenceID,
178-
FeeLineConfigID: dbLine.Edges.FlatFeeLine.ID,
179-
180-
ServicePeriod: timeutil.ClosedPeriod{
181-
From: dbLine.PeriodStart.In(time.UTC),
182-
To: dbLine.PeriodEnd.In(time.UTC),
183-
},
184-
PerUnitAmount: dbLine.Edges.FlatFeeLine.PerUnitAmount,
185-
Quantity: lo.FromPtr(dbLine.Quantity),
186-
Category: dbLine.Edges.FlatFeeLine.Category,
187-
PaymentTerm: dbLine.Edges.FlatFeeLine.PaymentTerm,
188-
Index: dbLine.Edges.FlatFeeLine.Index,
189-
190-
Currency: dbLine.Currency,
191-
192-
CreditsApplied: creditsApplied,
193-
TaxConfig: backfillTaxConfigReferences(
194-
lo.EmptyableToPtr(dbLine.TaxConfig),
195-
dbLine.TaxBehavior,
196-
taxCodeFromInvoiceLineEdge(dbLine),
197-
),
198-
Totals: totals.FromDB(dbLine),
199-
ExternalIDs: billing.LineExternalIDs{
200-
Invoicing: lo.FromPtr(dbLine.InvoicingAppExternalID),
166+
InvoiceID: dbLine.InvoiceID,
167+
FeeLineConfigID: dbLine.Edges.FlatFeeLine.ID,
168+
Base: stddetailedline.Base{
169+
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
170+
Namespace: dbLine.Namespace,
171+
ID: dbLine.ID,
172+
CreatedAt: dbLine.CreatedAt.In(time.UTC),
173+
UpdatedAt: dbLine.UpdatedAt.In(time.UTC),
174+
DeletedAt: convert.TimePtrIn(dbLine.DeletedAt, time.UTC),
175+
Name: dbLine.Name,
176+
Description: dbLine.Description,
177+
}),
178+
ChildUniqueReferenceID: dbLine.ChildUniqueReferenceID,
179+
ServicePeriod: timeutil.ClosedPeriod{
180+
From: dbLine.PeriodStart.In(time.UTC),
181+
To: dbLine.PeriodEnd.In(time.UTC),
182+
},
183+
PerUnitAmount: dbLine.Edges.FlatFeeLine.PerUnitAmount,
184+
Quantity: lo.FromPtr(dbLine.Quantity),
185+
Category: dbLine.Edges.FlatFeeLine.Category,
186+
PaymentTerm: dbLine.Edges.FlatFeeLine.PaymentTerm,
187+
Index: dbLine.Edges.FlatFeeLine.Index,
188+
Currency: dbLine.Currency,
189+
CreditsApplied: creditsApplied,
190+
TaxConfig: backfillTaxConfigReferences(
191+
lo.EmptyableToPtr(dbLine.TaxConfig),
192+
dbLine.TaxBehavior,
193+
taxCodeFromInvoiceLineEdge(dbLine),
194+
),
195+
Totals: totals.FromDB(dbLine),
196+
ExternalIDs: externalid.MapLineExternalIDFromDB(dbLine),
201197
},
202198
}
203199

@@ -219,40 +215,36 @@ func (a *adapter) mapStandardInvoiceDetailedLineV2FromDB(dbLine *db.BillingStand
219215
}
220216

221217
detailedLineBase := billing.DetailedLineBase{
222-
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
223-
Namespace: dbLine.Namespace,
224-
ID: dbLine.ID,
225-
CreatedAt: dbLine.CreatedAt.In(time.UTC),
226-
UpdatedAt: dbLine.UpdatedAt.In(time.UTC),
227-
DeletedAt: convert.TimePtrIn(dbLine.DeletedAt, time.UTC),
228-
Name: dbLine.Name,
229-
Description: dbLine.Description,
230-
}),
231-
232-
InvoiceID: dbLine.InvoiceID,
233-
ChildUniqueReferenceID: dbLine.ChildUniqueReferenceID,
234-
235-
ServicePeriod: timeutil.ClosedPeriod{
236-
From: dbLine.ServicePeriodStart.In(time.UTC),
237-
To: dbLine.ServicePeriodEnd.In(time.UTC),
238-
},
239-
PerUnitAmount: dbLine.PerUnitAmount,
240-
Quantity: dbLine.Quantity,
241-
Category: dbLine.Category,
242-
PaymentTerm: dbLine.PaymentTerm,
243-
Index: dbLine.Index,
244-
245-
Currency: dbLine.Currency,
246-
247-
CreditsApplied: creditsApplied,
248-
TaxConfig: backfillTaxConfigReferences(
249-
lo.EmptyableToPtr(dbLine.TaxConfig),
250-
dbLine.TaxBehavior,
251-
taxCodeFromDetailedLineV2Edge(dbLine),
252-
),
253-
Totals: totals.FromDB(dbLine),
254-
ExternalIDs: billing.LineExternalIDs{
255-
Invoicing: lo.FromPtr(dbLine.InvoicingAppExternalID),
218+
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(
242+
lo.EmptyableToPtr(dbLine.TaxConfig),
243+
dbLine.TaxBehavior,
244+
taxCodeFromDetailedLineV2Edge(dbLine),
245+
),
246+
Totals: totals.FromDB(dbLine),
247+
ExternalIDs: externalid.MapLineExternalIDFromDB(dbLine),
256248
},
257249
}
258250

@@ -271,9 +263,7 @@ func (a *adapter) mapStandardInvoiceLineUsageDiscountFromDB(dbDiscount *db.Billi
271263
base := billing.LineDiscountBase{
272264
Description: dbDiscount.Description,
273265
ChildUniqueReferenceID: dbDiscount.ChildUniqueReferenceID,
274-
ExternalIDs: billing.LineExternalIDs{
275-
Invoicing: lo.FromPtr(dbDiscount.InvoicingAppExternalID),
276-
},
266+
ExternalIDs: externalid.MapLineExternalIDFromDB(dbDiscount),
277267
}
278268

279269
if dbDiscount.Reason == billing.MaximumSpendDiscountReason && dbDiscount.ReasonDetails == nil {
@@ -309,9 +299,7 @@ func (a *adapter) mapStandardInvoiceLineAmountDiscountFromDB(dbDiscount *db.Bill
309299
base := billing.LineDiscountBase{
310300
Description: dbDiscount.Description,
311301
ChildUniqueReferenceID: dbDiscount.ChildUniqueReferenceID,
312-
ExternalIDs: billing.LineExternalIDs{
313-
Invoicing: lo.FromPtr(dbDiscount.InvoicingAppExternalID),
314-
},
302+
ExternalIDs: externalid.MapLineExternalIDFromDB(dbDiscount),
315303
}
316304

317305
if dbDiscount.Reason == billing.MaximumSpendDiscountReason && dbDiscount.SourceDiscount == nil {
@@ -408,9 +396,7 @@ func (a *adapter) mapStandardInvoiceDetailedLineAmountDiscountFromDB(dbDiscount
408396
base := billing.LineDiscountBase{
409397
Description: dbDiscount.Description,
410398
ChildUniqueReferenceID: dbDiscount.ChildUniqueReferenceID,
411-
ExternalIDs: billing.LineExternalIDs{
412-
Invoicing: lo.FromPtr(dbDiscount.InvoicingAppExternalID),
413-
},
399+
ExternalIDs: externalid.MapLineExternalIDFromDB(dbDiscount),
414400
}
415401

416402
if dbDiscount.Reason == billing.MaximumSpendDiscountReason && dbDiscount.SourceDiscount == nil {

openmeter/billing/adapter/stdinvoicelines.go

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/samber/lo"
1212

1313
"github.com/openmeterio/openmeter/openmeter/billing"
14+
"github.com/openmeterio/openmeter/openmeter/billing/models/externalid"
1415
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
1516
"github.com/openmeterio/openmeter/openmeter/customer"
1617
"github.com/openmeterio/openmeter/openmeter/ent/db"
@@ -101,10 +102,9 @@ func (a *adapter) UpsertInvoiceLines(ctx context.Context, inputIn billing.Upsert
101102
SetCurrency(line.Currency).
102103
SetMetadata(line.Metadata).
103104
SetAnnotations(line.Annotations).
104-
SetNillableChildUniqueReferenceID(line.ChildUniqueReferenceID).
105-
// ExternalIDs
106-
SetNillableInvoicingAppExternalID(lo.EmptyableToPtr(line.ExternalIDs.Invoicing))
105+
SetNillableChildUniqueReferenceID(line.ChildUniqueReferenceID)
107106

107+
create = externalid.CreateLineExternalID(create, line.ExternalIDs)
108108
create = totals.Set(create, line.Totals)
109109

110110
if len(line.CreditsApplied) > 0 {
@@ -206,9 +206,9 @@ func (a *adapter) UpsertInvoiceLines(ctx context.Context, inputIn billing.Upsert
206206
SetNillablePreLinePeriodQuantity(discount.PreLinePeriodQuantity).
207207
SetNillableDeletedAt(discount.DeletedAt).
208208
SetNillableChildUniqueReferenceID(discount.ChildUniqueReferenceID).
209-
SetNillableDescription(discount.Description).
210-
// ExternalIDs
211-
SetNillableInvoicingAppExternalID(lo.EmptyableToPtr(discount.ExternalIDs.Invoicing))
209+
SetNillableDescription(discount.Description)
210+
211+
create = externalid.CreateLineExternalID(create, discount.ExternalIDs)
212212

213213
return create, nil
214214
},
@@ -318,10 +318,9 @@ func (a *adapter) upsertDetailedLines(ctx context.Context, in detailedLineDiff)
318318
SetName(line.Name).
319319
SetNillableDescription(line.Description).
320320
SetCurrency(line.Currency).
321-
SetNillableChildUniqueReferenceID(line.ChildUniqueReferenceID).
322-
// ExternalIDs
323-
SetNillableInvoicingAppExternalID(lo.EmptyableToPtr(line.ExternalIDs.Invoicing))
321+
SetNillableChildUniqueReferenceID(line.ChildUniqueReferenceID)
324322

323+
create = externalid.CreateLineExternalID(create, line.ExternalIDs)
325324
create = totals.Set(create, line.Totals)
326325

327326
if line.TaxConfig != nil {
@@ -384,9 +383,9 @@ func (a *adapter) upsertDetailedLineAmountDiscounts(ctx context.Context, in deta
384383
SetNillableRoundingAmount(lo.EmptyableToPtr(discount.RoundingAmount)).
385384
SetNillableDeletedAt(discount.DeletedAt).
386385
SetNillableChildUniqueReferenceID(discount.ChildUniqueReferenceID).
387-
SetNillableDescription(discount.Description).
388-
// ExternalIDs
389-
SetNillableInvoicingAppExternalID(lo.EmptyableToPtr(discount.ExternalIDs.Invoicing))
386+
SetNillableDescription(discount.Description)
387+
388+
create = externalid.CreateLineExternalID(create, discount.ExternalIDs)
390389

391390
return create, nil
392391
},
@@ -434,10 +433,9 @@ func (a *adapter) upsertDetailedLinesV2(ctx context.Context, in detailedLineDiff
434433
SetNillableChildUniqueReferenceID(line.ChildUniqueReferenceID).
435434
SetCategory(line.Category).
436435
SetPaymentTerm(line.PaymentTerm).
437-
SetNillableIndex(line.Index).
438-
// ExternalIDs
439-
SetNillableInvoicingAppExternalID(lo.EmptyableToPtr(line.ExternalIDs.Invoicing))
436+
SetNillableIndex(line.Index)
440437

438+
create = externalid.CreateLineExternalID(create, line.ExternalIDs)
441439
create = totals.Set(create, line.Totals)
442440

443441
if len(line.CreditsApplied) > 0 {
@@ -499,9 +497,9 @@ func (a *adapter) upsertDetailedLineAmountDiscountsV2(ctx context.Context, in de
499497
SetNillableRoundingAmount(lo.EmptyableToPtr(discount.RoundingAmount)).
500498
SetNillableDeletedAt(discount.DeletedAt).
501499
SetNillableChildUniqueReferenceID(discount.ChildUniqueReferenceID).
502-
SetNillableDescription(discount.Description).
503-
// ExternalIDs
504-
SetNillableInvoicingAppExternalID(lo.EmptyableToPtr(discount.ExternalIDs.Invoicing))
500+
SetNillableDescription(discount.Description)
501+
502+
create = externalid.CreateLineExternalID(create, discount.ExternalIDs)
505503

506504
return create, nil
507505
},

0 commit comments

Comments
 (0)