Skip to content

Commit 1c7c3b1

Browse files
authored
feat: base support for billing credits (#3901)
1 parent efd4cfe commit 1c7c3b1

Some content is hidden

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

45 files changed

+2194
-58
lines changed

openmeter/billing/adapter/gatheringinvoice.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ func (a *adapter) CreateGatheringInvoice(ctx context.Context, input billing.Crea
6565
// Totals
6666
SetAmount(alpacadecimal.Zero).
6767
SetChargesTotal(alpacadecimal.Zero).
68+
SetCreditsTotal(alpacadecimal.Zero).
6869
SetDiscountsTotal(alpacadecimal.Zero).
6970
SetTaxesTotal(alpacadecimal.Zero).
7071
SetTaxesExclusiveTotal(alpacadecimal.Zero).
@@ -129,6 +130,7 @@ func (a *adapter) UpdateGatheringInvoice(ctx context.Context, in billing.Gatheri
129130
// Totals
130131
SetAmount(alpacadecimal.Zero).
131132
SetChargesTotal(alpacadecimal.Zero).
133+
SetCreditsTotal(alpacadecimal.Zero).
132134
SetDiscountsTotal(alpacadecimal.Zero).
133135
SetTaxesTotal(alpacadecimal.Zero).
134136
SetTaxesExclusiveTotal(alpacadecimal.Zero).

openmeter/billing/adapter/gatheringlines.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ func (a *adapter) updateGatheringLines(ctx context.Context, lines billing.Gather
202202
// Totals
203203
SetAmount(alpacadecimal.Zero).
204204
SetChargesTotal(alpacadecimal.Zero).
205+
SetCreditsTotal(alpacadecimal.Zero).
205206
SetDiscountsTotal(alpacadecimal.Zero).
206207
SetTaxesTotal(alpacadecimal.Zero).
207208
SetTaxesInclusiveTotal(alpacadecimal.Zero).

openmeter/billing/adapter/invoice.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ func (a *adapter) CreateInvoice(ctx context.Context, input billing.CreateInvoice
339339
// Totals
340340
SetAmount(input.Totals.Amount).
341341
SetChargesTotal(input.Totals.ChargesTotal).
342+
SetCreditsTotal(input.Totals.CreditsTotal).
342343
SetDiscountsTotal(input.Totals.DiscountsTotal).
343344
SetTaxesTotal(input.Totals.TaxesTotal).
344345
SetTaxesExclusiveTotal(input.Totals.TaxesExclusiveTotal).
@@ -490,6 +491,7 @@ func (a *adapter) UpdateStandardInvoice(ctx context.Context, in billing.UpdateSt
490491
// Totals
491492
SetAmount(in.Totals.Amount).
492493
SetChargesTotal(in.Totals.ChargesTotal).
494+
SetCreditsTotal(in.Totals.CreditsTotal).
493495
SetDiscountsTotal(in.Totals.DiscountsTotal).
494496
SetTaxesTotal(in.Totals.TaxesTotal).
495497
SetTaxesExclusiveTotal(in.Totals.TaxesExclusiveTotal).
@@ -731,6 +733,7 @@ func (a *adapter) mapStandardInvoiceFromDB(ctx context.Context, invoice *db.Bill
731733
TaxesTotal: invoice.TaxesTotal,
732734
TaxesExclusiveTotal: invoice.TaxesExclusiveTotal,
733735
TaxesInclusiveTotal: invoice.TaxesInclusiveTotal,
736+
CreditsTotal: invoice.CreditsTotal,
734737
Total: invoice.Total,
735738
},
736739

openmeter/billing/adapter/stdinvoicelinemapper.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ func (a *adapter) mapStandardInvoiceLinesFromDB(schemaLevelByInvoiceID map[strin
5252
}
5353

5454
func (a *adapter) mapStandardInvoiceLineWithoutReferences(dbLine *db.BillingInvoiceLine) (*billing.StandardLine, error) {
55+
creditsApplied := lo.FromPtr(dbLine.CreditsApplied)
56+
if len(creditsApplied) == 0 {
57+
creditsApplied = nil
58+
}
59+
5560
invoiceLine := &billing.StandardLine{
5661
StandardLineBase: billing.StandardLineBase{
5762
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
@@ -84,8 +89,10 @@ func (a *adapter) mapStandardInvoiceLineWithoutReferences(dbLine *db.BillingInvo
8489

8590
TaxConfig: lo.EmptyableToPtr(dbLine.TaxConfig),
8691
RateCardDiscounts: lo.FromPtr(dbLine.RatecardDiscounts),
92+
CreditsApplied: creditsApplied,
8793
Totals: billing.Totals{
8894
Amount: dbLine.Amount,
95+
CreditsTotal: dbLine.CreditsTotal,
8996
ChargesTotal: dbLine.ChargesTotal,
9097
DiscountsTotal: dbLine.DiscountsTotal,
9198
TaxesInclusiveTotal: dbLine.TaxesInclusiveTotal,
@@ -156,6 +163,11 @@ func (a *adapter) mapStandardInvoiceDetailedLineFromDB(dbLine *db.BillingInvoice
156163
return billing.DetailedLine{}, fmt.Errorf("detailed line parent line ID is required [detailed_line_id=%s]", dbLine.ID)
157164
}
158165

166+
creditsApplied := lo.FromPtr(dbLine.CreditsApplied)
167+
if len(creditsApplied) == 0 {
168+
creditsApplied = nil
169+
}
170+
159171
detailedLineBase := billing.DetailedLineBase{
160172
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
161173
Namespace: dbLine.Namespace,
@@ -183,9 +195,11 @@ func (a *adapter) mapStandardInvoiceDetailedLineFromDB(dbLine *db.BillingInvoice
183195

184196
Currency: dbLine.Currency,
185197

186-
TaxConfig: lo.EmptyableToPtr(dbLine.TaxConfig),
198+
CreditsApplied: creditsApplied,
199+
TaxConfig: lo.EmptyableToPtr(dbLine.TaxConfig),
187200
Totals: billing.Totals{
188201
Amount: dbLine.Amount,
202+
CreditsTotal: dbLine.CreditsTotal,
189203
ChargesTotal: dbLine.ChargesTotal,
190204
DiscountsTotal: dbLine.DiscountsTotal,
191205
TaxesInclusiveTotal: dbLine.TaxesInclusiveTotal,
@@ -210,6 +224,11 @@ func (a *adapter) mapStandardInvoiceDetailedLineFromDB(dbLine *db.BillingInvoice
210224
}
211225

212226
func (a *adapter) mapStandardInvoiceDetailedLineV2FromDB(dbLine *db.BillingStandardInvoiceDetailedLine) (billing.DetailedLine, error) {
227+
creditsApplied := lo.FromPtr(dbLine.CreditsApplied)
228+
if len(creditsApplied) == 0 {
229+
creditsApplied = nil
230+
}
231+
213232
detailedLineBase := billing.DetailedLineBase{
214233
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
215234
Namespace: dbLine.Namespace,
@@ -236,10 +255,12 @@ func (a *adapter) mapStandardInvoiceDetailedLineV2FromDB(dbLine *db.BillingStand
236255

237256
Currency: dbLine.Currency,
238257

239-
TaxConfig: lo.EmptyableToPtr(dbLine.TaxConfig),
258+
CreditsApplied: creditsApplied,
259+
TaxConfig: lo.EmptyableToPtr(dbLine.TaxConfig),
240260
Totals: billing.Totals{
241261
Amount: dbLine.Amount,
242262
ChargesTotal: dbLine.ChargesTotal,
263+
CreditsTotal: dbLine.CreditsTotal,
243264
DiscountsTotal: dbLine.DiscountsTotal,
244265
TaxesInclusiveTotal: dbLine.TaxesInclusiveTotal,
245266
TaxesExclusiveTotal: dbLine.TaxesExclusiveTotal,

openmeter/billing/adapter/stdinvoicelines.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ func (a *adapter) UpsertInvoiceLines(ctx context.Context, inputIn billing.Upsert
102102
// Totals
103103
SetAmount(line.Totals.Amount).
104104
SetChargesTotal(line.Totals.ChargesTotal).
105+
SetCreditsTotal(line.Totals.CreditsTotal).
105106
SetDiscountsTotal(line.Totals.DiscountsTotal).
106107
SetTaxesTotal(line.Totals.TaxesTotal).
107108
SetTaxesInclusiveTotal(line.Totals.TaxesInclusiveTotal).
@@ -110,6 +111,10 @@ func (a *adapter) UpsertInvoiceLines(ctx context.Context, inputIn billing.Upsert
110111
// ExternalIDs
111112
SetNillableInvoicingAppExternalID(lo.EmptyableToPtr(line.ExternalIDs.Invoicing))
112113

114+
if len(line.CreditsApplied) > 0 {
115+
create = create.SetCreditsApplied(&line.CreditsApplied)
116+
}
117+
113118
if line.Subscription != nil {
114119
create = create.SetSubscriptionID(line.Subscription.SubscriptionID).
115120
SetSubscriptionPhaseID(line.Subscription.PhaseID).
@@ -144,6 +149,7 @@ func (a *adapter) UpsertInvoiceLines(ctx context.Context, inputIn billing.Upsert
144149
// TODO[OM-1416]: all nillable fileds must be listed explicitly
145150
UpdateQuantity().
146151
UpdateChildUniqueReferenceID().
152+
UpdateCreditsApplied().
147153
Exec(ctx)
148154
},
149155
MarkDeleted: func(ctx context.Context, line *billing.StandardLine) (*billing.StandardLine, error) {
@@ -357,6 +363,7 @@ func (a *adapter) upsertDetailedLines(ctx context.Context, in detailedLineDiff)
357363
// Totals
358364
SetAmount(line.Totals.Amount).
359365
SetChargesTotal(line.Totals.ChargesTotal).
366+
SetCreditsTotal(line.Totals.CreditsTotal).
360367
SetDiscountsTotal(line.Totals.DiscountsTotal).
361368
SetTaxesTotal(line.Totals.TaxesTotal).
362369
SetTaxesInclusiveTotal(line.Totals.TaxesInclusiveTotal).
@@ -369,6 +376,10 @@ func (a *adapter) upsertDetailedLines(ctx context.Context, in detailedLineDiff)
369376
create = create.SetTaxConfig(*line.TaxConfig)
370377
}
371378

379+
if len(line.CreditsApplied) > 0 {
380+
create = create.SetCreditsApplied(&line.CreditsApplied)
381+
}
382+
372383
create = create.SetQuantity(line.Quantity).
373384
SetFlatFeeLineID(line.FeeLineConfigID).
374385
SetNillableUsageBasedLineID(nil)
@@ -385,6 +396,7 @@ func (a *adapter) upsertDetailedLines(ctx context.Context, in detailedLineDiff)
385396
})).
386397
UpdateQuantity().
387398
UpdateChildUniqueReferenceID().
399+
UpdateCreditsApplied().
388400
Exec(ctx)
389401
},
390402
MarkDeleted: func(ctx context.Context, line detailedLineWithParent) (detailedLineWithParent, error) {
@@ -469,6 +481,7 @@ func (a *adapter) upsertDetailedLinesV2(ctx context.Context, in detailedLineDiff
469481
// Totals
470482
SetAmount(line.Totals.Amount).
471483
SetChargesTotal(line.Totals.ChargesTotal).
484+
SetCreditsTotal(line.Totals.CreditsTotal).
472485
SetDiscountsTotal(line.Totals.DiscountsTotal).
473486
SetTaxesTotal(line.Totals.TaxesTotal).
474487
SetTaxesInclusiveTotal(line.Totals.TaxesInclusiveTotal).
@@ -478,6 +491,10 @@ func (a *adapter) upsertDetailedLinesV2(ctx context.Context, in detailedLineDiff
478491
// ExternalIDs
479492
SetNillableInvoicingAppExternalID(lo.EmptyableToPtr(line.ExternalIDs.Invoicing))
480493

494+
if len(line.CreditsApplied) > 0 {
495+
create = create.SetCreditsApplied(&line.CreditsApplied)
496+
}
497+
481498
if line.TaxConfig != nil {
482499
create = create.SetTaxConfig(*line.TaxConfig)
483500
}
@@ -586,7 +603,8 @@ func (a *adapter) upsertUsageBasedConfig(ctx context.Context, lineDiffs entitydi
586603
OnConflict(
587604
sql.ConflictColumns(billinginvoiceusagebasedlineconfig.FieldID),
588605
sql.ResolveWithNewValues(),
589-
).Exec(ctx)
606+
).
607+
Exec(ctx)
590608
},
591609
})
592610
}

openmeter/billing/derived.gen.go

Lines changed: 30 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

openmeter/billing/errors.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ var (
6060
ErrInvoiceDiscountNoWildcardDiscountOnGatheringInvoices = NewValidationError("invoice_discount_no_wildcard_discount_on_gathering_invoices", "wildcard discount on gathering invoices is not allowed")
6161

6262
ErrNamespaceLocked = NewValidationError("namespace_locked", "namespace is locked")
63+
64+
ErrInvoiceLineCreditsNotConsumedFully = NewValidationError("invoice_line_credits_not_consumed_fully", "credits not consumed fully")
6365
)
6466

6567
const (

openmeter/billing/invoicedetailedline.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ type DetailedLineBase struct {
6565
// FeeLineConfigID contains the ID of the fee configuration in the DB, this should go away
6666
// as soon as we split the ubp/flatfee db parts
6767
FeeLineConfigID string `json:"feeLineConfigID,omitempty"`
68+
69+
// CreditsApplied is the list of credits that are applied to the line (credits are pre-tax)
70+
CreditsApplied CreditsApplied `json:"creditsApplied,omitempty"`
6871
}
6972

7073
var _ models.Validator = (*DetailedLineBase)(nil)
@@ -100,6 +103,10 @@ func (l DetailedLineBase) Validate() error {
100103
errs = append(errs, fmt.Errorf("currency: %w", err))
101104
}
102105

106+
if err := l.CreditsApplied.Validate(); err != nil {
107+
errs = append(errs, fmt.Errorf("credits applied: %w", err))
108+
}
109+
103110
return errors.Join(errs...)
104111
}
105112

@@ -110,6 +117,10 @@ func (l DetailedLineBase) Clone() DetailedLineBase {
110117
l.TaxConfig = &taxConfig
111118
}
112119

120+
if len(l.CreditsApplied) > 0 {
121+
l.CreditsApplied = l.CreditsApplied.Clone()
122+
}
123+
113124
return l
114125
}
115126

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package lineservice
2+
3+
import (
4+
"github.com/alpacahq/alpacadecimal"
5+
6+
"github.com/openmeterio/openmeter/openmeter/billing"
7+
)
8+
9+
type creditsMutator struct{}
10+
11+
var _ PostCalculationMutator = (*creditsMutator)(nil)
12+
13+
func (m *creditsMutator) Mutate(i PricerCalculateInput, pricerResult newDetailedLinesInput) (newDetailedLinesInput, error) {
14+
for _, creditToApply := range i.line.CreditsApplied {
15+
creditValueRemaining := i.currency.RoundToPrecision(creditToApply.Amount)
16+
17+
for idx := range pricerResult {
18+
if creditValueRemaining.IsZero() {
19+
break
20+
}
21+
22+
totalAmount := pricerResult[idx].TotalAmount(i.currency)
23+
24+
if totalAmount.LessThanOrEqual(creditValueRemaining) {
25+
creditValueRemaining = creditValueRemaining.Sub(totalAmount)
26+
pricerResult[idx].CreditsApplied = append(pricerResult[idx].CreditsApplied, billing.CreditApplied{
27+
Amount: totalAmount,
28+
Description: creditToApply.Description,
29+
})
30+
} else {
31+
pricerResult[idx].CreditsApplied = append(pricerResult[idx].CreditsApplied, billing.CreditApplied{
32+
Amount: creditValueRemaining,
33+
Description: creditToApply.Description,
34+
})
35+
36+
creditValueRemaining = alpacadecimal.Zero
37+
break
38+
}
39+
}
40+
41+
if creditValueRemaining.IsPositive() {
42+
// TODO: Error code/validation error?
43+
// This is critical, as it means that charges/ledger has allocated more credits than the line is worth
44+
// thus we would charge the customer more credits that we actually have usage for.
45+
46+
return pricerResult, billing.ErrInvoiceLineCreditsNotConsumedFully
47+
}
48+
}
49+
50+
return pricerResult, nil
51+
}

0 commit comments

Comments
 (0)