Skip to content

Commit fa82c04

Browse files
authored
feat(customcurrencies): support usage-based credit then invoice (#4780)
1 parent 6457e4b commit fa82c04

29 files changed

Lines changed: 1753 additions & 161 deletions

.agents/skills/charges/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,7 @@ Use these conventions for lifecycle tests:
637637
- when testing service-period cutoffs, remember the event-time window is half-open: an event with `event_time == ServicePeriodTo` is excluded
638638
- prefer `streamingtestutils.NewMockStreamingConnector(...)` plus the real billing rating service when a usage-based rating test should exercise production quantity lookup, pricing, discounts, or commitments end-to-end
639639
- prefer `clock.FreezeTime(...)` for exact `StoredAtLT` / `AllocateAt` assertions
640+
- define fixed UTC test-fixture timestamps with `datetime.MustParseTimeInLocation(t, "...Z", time.UTC).AsTime()` so inline lifecycle variants use the same readable, failure-reporting representation as the surrounding charge tests
640641
- rely on the default billing profile unless the test explicitly needs customer-specific override behavior
641642
- for credit-only charges (usage-based or flat fee), `Create(...)` itself may return an already-advanced charge — assert the returned charge's status, do not assume it will be `created`
642643
- for credit-only charges (usage-based or flat fee), handler callbacks must not return credit allocations above the requested amount; exact allocation paths must return allocations that sum to the requested amount
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package meta
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/alpacahq/alpacadecimal"
7+
8+
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis"
9+
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
10+
"github.com/openmeterio/openmeter/openmeter/currencies"
11+
"github.com/openmeterio/openmeter/pkg/currencyx"
12+
)
13+
14+
type ConvertCustomCurrencyOverageToFiatInput struct {
15+
Currency currencies.Currency
16+
CostBasisIntent *costbasis.Intent
17+
ResolvedCostBasis *costbasis.State
18+
Totals totals.Totals
19+
}
20+
21+
type FiatOverage struct {
22+
Currency *currencyx.FiatCurrency
23+
Amount alpacadecimal.Decimal
24+
}
25+
26+
// ConvertCustomCurrencyOverageToFiat converts the post-allocation total of a
27+
// custom-currency realization into its invoice currency using the persisted
28+
// cost basis.
29+
func ConvertCustomCurrencyOverageToFiat(input ConvertCustomCurrencyOverageToFiatInput) (FiatOverage, error) {
30+
if err := input.Currency.Validate(); err != nil {
31+
return FiatOverage{}, fmt.Errorf("currency: %w", err)
32+
}
33+
34+
if !input.Currency.IsCustom() {
35+
return FiatOverage{}, fmt.Errorf("currency must be custom")
36+
}
37+
38+
if err := input.Totals.ValidateTotalNonNegative(); err != nil {
39+
return FiatOverage{}, fmt.Errorf("totals: %w", err)
40+
}
41+
42+
if !input.Currency.IsRoundedToPrecision(input.Totals.Total) {
43+
return FiatOverage{}, fmt.Errorf("totals total must be rounded to custom currency precision")
44+
}
45+
46+
if input.CostBasisIntent == nil {
47+
return FiatOverage{}, fmt.Errorf("cost basis intent is required")
48+
}
49+
50+
if err := input.CostBasisIntent.Validate(); err != nil {
51+
return FiatOverage{}, fmt.Errorf("cost basis intent: %w", err)
52+
}
53+
54+
fiatCurrency, err := input.CostBasisIntent.GetFiatCurrency()
55+
if err != nil {
56+
return FiatOverage{}, fmt.Errorf("get cost basis fiat currency: %w", err)
57+
}
58+
59+
if input.ResolvedCostBasis == nil {
60+
return FiatOverage{}, fmt.Errorf("resolved cost basis is required")
61+
}
62+
63+
if err := input.ResolvedCostBasis.Validate(); err != nil {
64+
return FiatOverage{}, fmt.Errorf("resolved cost basis: %w", err)
65+
}
66+
67+
return FiatOverage{
68+
Currency: fiatCurrency,
69+
Amount: fiatCurrency.RoundToPrecision(
70+
input.Totals.Total.Mul(input.ResolvedCostBasis.CostBasis),
71+
),
72+
}, nil
73+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package meta
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/alpacahq/alpacadecimal"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis"
11+
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
12+
"github.com/openmeterio/openmeter/openmeter/currencies"
13+
"github.com/openmeterio/openmeter/pkg/currencyx"
14+
)
15+
16+
func TestConvertCustomCurrencyOverageToFiat(t *testing.T) {
17+
customCurrency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeCustom).
18+
WithCode("TOKENS").
19+
WithName("Tokens").
20+
WithPrecision(2).
21+
Build()
22+
require.NoError(t, err)
23+
24+
fiatCurrency, err := currencyx.NewFiatCurrency("USD")
25+
require.NoError(t, err)
26+
27+
costBasisIntent := costbasis.NewIntent(costbasis.ManualIntent{
28+
FiatCurrency: fiatCurrency,
29+
Rate: alpacadecimal.NewFromFloat(1.5),
30+
})
31+
resolvedCostBasis := costbasis.State{
32+
CostBasis: alpacadecimal.NewFromFloat(1.5),
33+
ResolvedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
34+
}
35+
validInput := ConvertCustomCurrencyOverageToFiatInput{
36+
Currency: currencies.Currency{
37+
Currency: customCurrency,
38+
},
39+
CostBasisIntent: &costBasisIntent,
40+
ResolvedCostBasis: &resolvedCostBasis,
41+
Totals: totals.Totals{
42+
Total: alpacadecimal.NewFromFloat(1.23),
43+
},
44+
}
45+
46+
t.Run("converts the post-allocation total and rounds to fiat precision", func(t *testing.T) {
47+
result, err := ConvertCustomCurrencyOverageToFiat(validInput)
48+
require.NoError(t, err)
49+
require.Equal(t, currencyx.Code("USD"), result.Currency.Details().Code)
50+
require.Equal(t, float64(1.85), result.Amount.InexactFloat64())
51+
})
52+
53+
t.Run("rejects a non-custom source currency", func(t *testing.T) {
54+
input := validInput
55+
input.Currency = currencies.Currency{
56+
Currency: fiatCurrency,
57+
}
58+
59+
_, err := ConvertCustomCurrencyOverageToFiat(input)
60+
require.ErrorContains(t, err, "currency must be custom")
61+
})
62+
63+
t.Run("rejects a negative overage", func(t *testing.T) {
64+
input := validInput
65+
input.Totals.Total = alpacadecimal.NewFromInt(-1)
66+
67+
_, err := ConvertCustomCurrencyOverageToFiat(input)
68+
require.ErrorContains(t, err, "total is negative")
69+
})
70+
71+
t.Run("rejects an overage not rounded to source precision", func(t *testing.T) {
72+
input := validInput
73+
input.Totals.Total = alpacadecimal.NewFromFloat(1.234)
74+
75+
_, err := ConvertCustomCurrencyOverageToFiat(input)
76+
require.ErrorContains(t, err, "must be rounded to custom currency precision")
77+
})
78+
79+
t.Run("requires the cost basis intent", func(t *testing.T) {
80+
input := validInput
81+
input.CostBasisIntent = nil
82+
83+
_, err := ConvertCustomCurrencyOverageToFiat(input)
84+
require.ErrorContains(t, err, "cost basis intent is required")
85+
})
86+
87+
t.Run("requires the resolved cost basis", func(t *testing.T) {
88+
input := validInput
89+
input.ResolvedCostBasis = nil
90+
91+
_, err := ConvertCustomCurrencyOverageToFiat(input)
92+
require.ErrorContains(t, err, "resolved cost basis is required")
93+
})
94+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package creditpurchase
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/alpacahq/alpacadecimal"
8+
"github.com/samber/lo"
9+
10+
"github.com/openmeterio/openmeter/openmeter/billing"
11+
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis"
12+
"github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline"
13+
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
14+
"github.com/openmeterio/openmeter/openmeter/currencies"
15+
"github.com/openmeterio/openmeter/openmeter/productcatalog"
16+
"github.com/openmeterio/openmeter/pkg/currencyx"
17+
"github.com/openmeterio/openmeter/pkg/models"
18+
"github.com/openmeterio/openmeter/pkg/timeutil"
19+
)
20+
21+
const CreditPurchaseChildUniqueReferenceID = "credit-purchase"
22+
23+
type NewDetailedLineInput struct {
24+
Namespace string
25+
InvoiceID string
26+
Name string
27+
ServicePeriod timeutil.ClosedPeriod
28+
29+
CustomCurrency currencies.Currency
30+
CustomCurrencyAmount alpacadecimal.Decimal
31+
ResolvedCostBasis *costbasis.State
32+
33+
FiatCurrency *currencyx.FiatCurrency
34+
FiatAmount alpacadecimal.Decimal
35+
}
36+
37+
func (i NewDetailedLineInput) Validate() error {
38+
var errs []error
39+
40+
if i.Namespace == "" {
41+
errs = append(errs, errors.New("namespace is required"))
42+
}
43+
44+
if i.InvoiceID == "" {
45+
errs = append(errs, errors.New("invoice ID is required"))
46+
}
47+
48+
if i.Name == "" {
49+
errs = append(errs, errors.New("name is required"))
50+
}
51+
52+
if err := i.ServicePeriod.Validate(); err != nil {
53+
errs = append(errs, fmt.Errorf("service period: %w", err))
54+
}
55+
56+
if err := i.CustomCurrency.Validate(); err != nil {
57+
errs = append(errs, fmt.Errorf("custom currency: %w", err))
58+
}
59+
60+
if !i.CustomCurrency.IsCustom() {
61+
errs = append(errs, errors.New("custom currency must be custom"))
62+
}
63+
64+
if i.CustomCurrencyAmount.IsNegative() {
65+
errs = append(errs, errors.New("custom currency amount must be positive or zero"))
66+
}
67+
68+
if i.ResolvedCostBasis == nil {
69+
errs = append(errs, errors.New("resolved cost basis is required"))
70+
} else if err := i.ResolvedCostBasis.Validate(); err != nil {
71+
errs = append(errs, fmt.Errorf("resolved cost basis: %w", err))
72+
}
73+
74+
if err := i.FiatCurrency.Validate(); err != nil {
75+
errs = append(errs, fmt.Errorf("fiat currency: %w", err))
76+
}
77+
78+
if i.FiatAmount.IsNegative() {
79+
errs = append(errs, errors.New("fiat amount must be positive or zero"))
80+
}
81+
82+
if i.FiatCurrency != nil && !i.FiatCurrency.IsRoundedToPrecision(i.FiatAmount) {
83+
errs = append(errs, errors.New("fiat amount must be rounded to fiat currency precision"))
84+
}
85+
86+
return models.NewNillableGenericValidationError(errors.Join(errs...))
87+
}
88+
89+
// NewDetailedLine represents a custom-currency purchase as a fiat invoice
90+
// line. The quantity preserves the custom-currency amount, the unit amount
91+
// preserves the exact cost basis, and totals preserve the already-rounded
92+
// fiat outcome.
93+
func NewDetailedLine(input NewDetailedLineInput) (billing.DetailedLine, error) {
94+
if err := input.Validate(); err != nil {
95+
return billing.DetailedLine{}, err
96+
}
97+
98+
detailedLine := billing.DetailedLine{
99+
DetailedLineBase: billing.DetailedLineBase{
100+
Base: stddetailedline.Base{
101+
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
102+
Namespace: input.Namespace,
103+
Name: input.Name,
104+
}),
105+
Category: stddetailedline.CategoryRegular,
106+
ChildUniqueReferenceID: CreditPurchaseChildUniqueReferenceID,
107+
Index: lo.ToPtr(0),
108+
PaymentTerm: productcatalog.InArrearsPaymentTerm,
109+
ServicePeriod: input.ServicePeriod,
110+
PerUnitAmount: input.ResolvedCostBasis.CostBasis,
111+
Quantity: input.CustomCurrency.RoundToPrecision(input.CustomCurrencyAmount),
112+
Totals: totals.Totals{
113+
Amount: input.FiatAmount,
114+
Total: input.FiatAmount,
115+
},
116+
},
117+
InvoiceID: input.InvoiceID,
118+
},
119+
}
120+
121+
if err := detailedLine.Validate(); err != nil {
122+
return billing.DetailedLine{}, fmt.Errorf("detailed line: %w", err)
123+
}
124+
125+
if err := detailedLine.Totals.Validate(); err != nil {
126+
return billing.DetailedLine{}, fmt.Errorf("totals: %w", err)
127+
}
128+
129+
calculatedAmount := input.FiatCurrency.RoundToPrecision(
130+
detailedLine.Quantity.Mul(detailedLine.PerUnitAmount),
131+
)
132+
if !detailedLine.Totals.Amount.Equal(calculatedAmount) {
133+
return billing.DetailedLine{}, fmt.Errorf(
134+
"totals amount does not match quantity and cost basis: expected %s, got %s",
135+
calculatedAmount,
136+
detailedLine.Totals.Amount,
137+
)
138+
}
139+
140+
calculatedTotal := detailedLine.Totals.CalculateTotal()
141+
if !detailedLine.Totals.Total.Equal(calculatedTotal) {
142+
return billing.DetailedLine{}, fmt.Errorf(
143+
"totals total does not match its components: expected %s, got %s",
144+
calculatedTotal,
145+
detailedLine.Totals.Total,
146+
)
147+
}
148+
149+
return detailedLine, nil
150+
}

0 commit comments

Comments
 (0)