diff --git a/.agents/skills/charges/SKILL.md b/.agents/skills/charges/SKILL.md index f3ae16e94c..d697ca1581 100644 --- a/.agents/skills/charges/SKILL.md +++ b/.agents/skills/charges/SKILL.md @@ -637,6 +637,7 @@ Use these conventions for lifecycle tests: - when testing service-period cutoffs, remember the event-time window is half-open: an event with `event_time == ServicePeriodTo` is excluded - 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 - prefer `clock.FreezeTime(...)` for exact `StoredAtLT` / `AllocateAt` assertions +- 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 - rely on the default billing profile unless the test explicitly needs customer-specific override behavior - 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` - 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 diff --git a/openmeter/billing/charges/meta/customcurrency.go b/openmeter/billing/charges/meta/customcurrency.go new file mode 100644 index 0000000000..565427b2f6 --- /dev/null +++ b/openmeter/billing/charges/meta/customcurrency.go @@ -0,0 +1,73 @@ +package meta + +import ( + "fmt" + + "github.com/alpacahq/alpacadecimal" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/pkg/currencyx" +) + +type ConvertCustomCurrencyOverageToFiatInput struct { + Currency currencies.Currency + CostBasisIntent *costbasis.Intent + ResolvedCostBasis *costbasis.State + Totals totals.Totals +} + +type FiatOverage struct { + Currency *currencyx.FiatCurrency + Amount alpacadecimal.Decimal +} + +// ConvertCustomCurrencyOverageToFiat converts the post-allocation total of a +// custom-currency realization into its invoice currency using the persisted +// cost basis. +func ConvertCustomCurrencyOverageToFiat(input ConvertCustomCurrencyOverageToFiatInput) (FiatOverage, error) { + if err := input.Currency.Validate(); err != nil { + return FiatOverage{}, fmt.Errorf("currency: %w", err) + } + + if !input.Currency.IsCustom() { + return FiatOverage{}, fmt.Errorf("currency must be custom") + } + + if err := input.Totals.ValidateTotalNonNegative(); err != nil { + return FiatOverage{}, fmt.Errorf("totals: %w", err) + } + + if !input.Currency.IsRoundedToPrecision(input.Totals.Total) { + return FiatOverage{}, fmt.Errorf("totals total must be rounded to custom currency precision") + } + + if input.CostBasisIntent == nil { + return FiatOverage{}, fmt.Errorf("cost basis intent is required") + } + + if err := input.CostBasisIntent.Validate(); err != nil { + return FiatOverage{}, fmt.Errorf("cost basis intent: %w", err) + } + + fiatCurrency, err := input.CostBasisIntent.GetFiatCurrency() + if err != nil { + return FiatOverage{}, fmt.Errorf("get cost basis fiat currency: %w", err) + } + + if input.ResolvedCostBasis == nil { + return FiatOverage{}, fmt.Errorf("resolved cost basis is required") + } + + if err := input.ResolvedCostBasis.Validate(); err != nil { + return FiatOverage{}, fmt.Errorf("resolved cost basis: %w", err) + } + + return FiatOverage{ + Currency: fiatCurrency, + Amount: fiatCurrency.RoundToPrecision( + input.Totals.Total.Mul(input.ResolvedCostBasis.CostBasis), + ), + }, nil +} diff --git a/openmeter/billing/charges/meta/customcurrency_test.go b/openmeter/billing/charges/meta/customcurrency_test.go new file mode 100644 index 0000000000..38cf202f96 --- /dev/null +++ b/openmeter/billing/charges/meta/customcurrency_test.go @@ -0,0 +1,94 @@ +package meta + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/pkg/currencyx" +) + +func TestConvertCustomCurrencyOverageToFiat(t *testing.T) { + customCurrency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeCustom). + WithCode("TOKENS"). + WithName("Tokens"). + WithPrecision(2). + Build() + require.NoError(t, err) + + fiatCurrency, err := currencyx.NewFiatCurrency("USD") + require.NoError(t, err) + + costBasisIntent := costbasis.NewIntent(costbasis.ManualIntent{ + FiatCurrency: fiatCurrency, + Rate: alpacadecimal.NewFromFloat(1.5), + }) + resolvedCostBasis := costbasis.State{ + CostBasis: alpacadecimal.NewFromFloat(1.5), + ResolvedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + validInput := ConvertCustomCurrencyOverageToFiatInput{ + Currency: currencies.Currency{ + Currency: customCurrency, + }, + CostBasisIntent: &costBasisIntent, + ResolvedCostBasis: &resolvedCostBasis, + Totals: totals.Totals{ + Total: alpacadecimal.NewFromFloat(1.23), + }, + } + + t.Run("converts the post-allocation total and rounds to fiat precision", func(t *testing.T) { + result, err := ConvertCustomCurrencyOverageToFiat(validInput) + require.NoError(t, err) + require.Equal(t, currencyx.Code("USD"), result.Currency.Details().Code) + require.Equal(t, float64(1.85), result.Amount.InexactFloat64()) + }) + + t.Run("rejects a non-custom source currency", func(t *testing.T) { + input := validInput + input.Currency = currencies.Currency{ + Currency: fiatCurrency, + } + + _, err := ConvertCustomCurrencyOverageToFiat(input) + require.ErrorContains(t, err, "currency must be custom") + }) + + t.Run("rejects a negative overage", func(t *testing.T) { + input := validInput + input.Totals.Total = alpacadecimal.NewFromInt(-1) + + _, err := ConvertCustomCurrencyOverageToFiat(input) + require.ErrorContains(t, err, "total is negative") + }) + + t.Run("rejects an overage not rounded to source precision", func(t *testing.T) { + input := validInput + input.Totals.Total = alpacadecimal.NewFromFloat(1.234) + + _, err := ConvertCustomCurrencyOverageToFiat(input) + require.ErrorContains(t, err, "must be rounded to custom currency precision") + }) + + t.Run("requires the cost basis intent", func(t *testing.T) { + input := validInput + input.CostBasisIntent = nil + + _, err := ConvertCustomCurrencyOverageToFiat(input) + require.ErrorContains(t, err, "cost basis intent is required") + }) + + t.Run("requires the resolved cost basis", func(t *testing.T) { + input := validInput + input.ResolvedCostBasis = nil + + _, err := ConvertCustomCurrencyOverageToFiat(input) + require.ErrorContains(t, err, "resolved cost basis is required") + }) +} diff --git a/openmeter/billing/charges/models/creditpurchase/detailedline.go b/openmeter/billing/charges/models/creditpurchase/detailedline.go new file mode 100644 index 0000000000..17424a16fa --- /dev/null +++ b/openmeter/billing/charges/models/creditpurchase/detailedline.go @@ -0,0 +1,150 @@ +package creditpurchase + +import ( + "errors" + "fmt" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis" + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +const CreditPurchaseChildUniqueReferenceID = "credit-purchase" + +type NewDetailedLineInput struct { + Namespace string + InvoiceID string + Name string + ServicePeriod timeutil.ClosedPeriod + + CustomCurrency currencies.Currency + CustomCurrencyAmount alpacadecimal.Decimal + ResolvedCostBasis *costbasis.State + + FiatCurrency *currencyx.FiatCurrency + FiatAmount alpacadecimal.Decimal +} + +func (i NewDetailedLineInput) Validate() error { + var errs []error + + if i.Namespace == "" { + errs = append(errs, errors.New("namespace is required")) + } + + if i.InvoiceID == "" { + errs = append(errs, errors.New("invoice ID is required")) + } + + if i.Name == "" { + errs = append(errs, errors.New("name is required")) + } + + if err := i.ServicePeriod.Validate(); err != nil { + errs = append(errs, fmt.Errorf("service period: %w", err)) + } + + if err := i.CustomCurrency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("custom currency: %w", err)) + } + + if !i.CustomCurrency.IsCustom() { + errs = append(errs, errors.New("custom currency must be custom")) + } + + if i.CustomCurrencyAmount.IsNegative() { + errs = append(errs, errors.New("custom currency amount must be positive or zero")) + } + + if i.ResolvedCostBasis == nil { + errs = append(errs, errors.New("resolved cost basis is required")) + } else if err := i.ResolvedCostBasis.Validate(); err != nil { + errs = append(errs, fmt.Errorf("resolved cost basis: %w", err)) + } + + if err := i.FiatCurrency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("fiat currency: %w", err)) + } + + if i.FiatAmount.IsNegative() { + errs = append(errs, errors.New("fiat amount must be positive or zero")) + } + + if i.FiatCurrency != nil && !i.FiatCurrency.IsRoundedToPrecision(i.FiatAmount) { + errs = append(errs, errors.New("fiat amount must be rounded to fiat currency precision")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +// NewDetailedLine represents a custom-currency purchase as a fiat invoice +// line. The quantity preserves the custom-currency amount, the unit amount +// preserves the exact cost basis, and totals preserve the already-rounded +// fiat outcome. +func NewDetailedLine(input NewDetailedLineInput) (billing.DetailedLine, error) { + if err := input.Validate(); err != nil { + return billing.DetailedLine{}, err + } + + detailedLine := billing.DetailedLine{ + DetailedLineBase: billing.DetailedLineBase{ + Base: stddetailedline.Base{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + Namespace: input.Namespace, + Name: input.Name, + }), + Category: stddetailedline.CategoryRegular, + ChildUniqueReferenceID: CreditPurchaseChildUniqueReferenceID, + Index: lo.ToPtr(0), + PaymentTerm: productcatalog.InArrearsPaymentTerm, + ServicePeriod: input.ServicePeriod, + PerUnitAmount: input.ResolvedCostBasis.CostBasis, + Quantity: input.CustomCurrency.RoundToPrecision(input.CustomCurrencyAmount), + Totals: totals.Totals{ + Amount: input.FiatAmount, + Total: input.FiatAmount, + }, + }, + InvoiceID: input.InvoiceID, + }, + } + + if err := detailedLine.Validate(); err != nil { + return billing.DetailedLine{}, fmt.Errorf("detailed line: %w", err) + } + + if err := detailedLine.Totals.Validate(); err != nil { + return billing.DetailedLine{}, fmt.Errorf("totals: %w", err) + } + + calculatedAmount := input.FiatCurrency.RoundToPrecision( + detailedLine.Quantity.Mul(detailedLine.PerUnitAmount), + ) + if !detailedLine.Totals.Amount.Equal(calculatedAmount) { + return billing.DetailedLine{}, fmt.Errorf( + "totals amount does not match quantity and cost basis: expected %s, got %s", + calculatedAmount, + detailedLine.Totals.Amount, + ) + } + + calculatedTotal := detailedLine.Totals.CalculateTotal() + if !detailedLine.Totals.Total.Equal(calculatedTotal) { + return billing.DetailedLine{}, fmt.Errorf( + "totals total does not match its components: expected %s, got %s", + calculatedTotal, + detailedLine.Totals.Total, + ) + } + + return detailedLine, nil +} diff --git a/openmeter/billing/charges/models/creditpurchase/detailedline_test.go b/openmeter/billing/charges/models/creditpurchase/detailedline_test.go new file mode 100644 index 0000000000..a3e54a4667 --- /dev/null +++ b/openmeter/billing/charges/models/creditpurchase/detailedline_test.go @@ -0,0 +1,124 @@ +package creditpurchase + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis" + "github.com/openmeterio/openmeter/openmeter/currencies" + "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestNewDetailedLine(t *testing.T) { + customCurrency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeCustom). + WithCode("TOKENS"). + WithName("Tokens"). + WithPrecision(4). + Build() + require.NoError(t, err) + + fiatCurrency, err := currencyx.NewFiatCurrency("USD") + require.NoError(t, err) + + servicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + costBasis := alpacadecimal.NewFromInt(2123456).Shift(-6) + customCurrencyAmount := alpacadecimal.NewFromInt(314159).Shift(-5) + roundedCustomCurrencyAmount := customCurrency.RoundToPrecision(customCurrencyAmount) + fiatOverage := fiatCurrency.RoundToPrecision(roundedCustomCurrencyAmount.Mul(costBasis)) + + line, err := NewDetailedLine(NewDetailedLineInput{ + Namespace: "namespace", + InvoiceID: "invoice-id", + Name: "usage (overage)", + ServicePeriod: servicePeriod, + CustomCurrency: currencies.Currency{Currency: customCurrency}, + CustomCurrencyAmount: customCurrencyAmount, + ResolvedCostBasis: &costbasis.State{ + CostBasis: costBasis, + ResolvedAt: servicePeriod.From, + }, + FiatCurrency: fiatCurrency, + FiatAmount: fiatOverage, + }) + require.NoError(t, err) + + require.Equal(t, "usage (overage)", line.Name) + require.Equal(t, roundedCustomCurrencyAmount, line.Quantity) + require.Equal(t, costBasis, line.PerUnitAmount) + require.Equal(t, fiatOverage, line.Totals.Amount) + require.Equal(t, fiatOverage, line.Totals.Total) + require.NoError(t, line.Totals.Validate()) + require.NoError(t, line.Validate()) +} + +func TestNewDetailedLineRequiresName(t *testing.T) { + customCurrency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeCustom). + WithCode("TOKENS"). + WithName("Tokens"). + WithPrecision(0). + Build() + require.NoError(t, err) + + fiatCurrency, err := currencyx.NewFiatCurrency("USD") + require.NoError(t, err) + + servicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + _, err = NewDetailedLine(NewDetailedLineInput{ + Namespace: "namespace", + InvoiceID: "invoice-id", + ServicePeriod: servicePeriod, + CustomCurrency: currencies.Currency{Currency: customCurrency}, + CustomCurrencyAmount: alpacadecimal.NewFromInt(3), + ResolvedCostBasis: &costbasis.State{ + CostBasis: alpacadecimal.NewFromInt(2), + ResolvedAt: servicePeriod.From, + }, + FiatCurrency: fiatCurrency, + FiatAmount: alpacadecimal.NewFromInt(6), + }) + require.ErrorContains(t, err, "name is required") +} + +func TestNewDetailedLineRejectsInconsistentFiatAmount(t *testing.T) { + customCurrency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeCustom). + WithCode("TOKENS"). + WithName("Tokens"). + WithPrecision(0). + Build() + require.NoError(t, err) + + fiatCurrency, err := currencyx.NewFiatCurrency("USD") + require.NoError(t, err) + + servicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + _, err = NewDetailedLine(NewDetailedLineInput{ + Namespace: "namespace", + InvoiceID: "invoice-id", + Name: "usage (overage)", + ServicePeriod: servicePeriod, + CustomCurrency: currencies.Currency{Currency: customCurrency}, + CustomCurrencyAmount: alpacadecimal.NewFromInt(3), + ResolvedCostBasis: &costbasis.State{ + CostBasis: alpacadecimal.NewFromInt(2), + ResolvedAt: servicePeriod.From, + }, + FiatCurrency: fiatCurrency, + FiatAmount: alpacadecimal.NewFromInt(5), + }) + require.ErrorContains(t, err, "totals amount does not match") +} diff --git a/openmeter/billing/charges/service/base_test.go b/openmeter/billing/charges/service/base_test.go index bccdefb9f5..d963022acc 100644 --- a/openmeter/billing/charges/service/base_test.go +++ b/openmeter/billing/charges/service/base_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log/slog" + "testing" "github.com/alpacahq/alpacadecimal" "github.com/invopop/gobl/currency" @@ -71,6 +72,18 @@ type BaseSuite struct { UsageBasedTestHandler *usageBasedTestHandler } +type customCurrencyEnabler interface { + SetEnableCustomCurrency(t *testing.T, enabled bool) error +} + +func (s *BaseSuite) setUsageBasedCustomCurrencyEnabled(enabled bool) { + s.T().Helper() + + enabler, ok := s.Charges.usageBasedService.(customCurrencyEnabler) + s.Require().True(ok) + s.Require().NoError(enabler.SetEnableCustomCurrency(s.T(), enabled)) +} + func (s *BaseSuite) SetupSuite() { s.BaseSuite.SetupSuite() diff --git a/openmeter/billing/charges/service/flatfee_costbasis_test.go b/openmeter/billing/charges/service/flatfee_costbasis_test.go index b1c4d2d08c..6eb7d7a474 100644 --- a/openmeter/billing/charges/service/flatfee_costbasis_test.go +++ b/openmeter/billing/charges/service/flatfee_costbasis_test.go @@ -30,10 +30,6 @@ type FlatFeeCostBasisCreateSuite struct { BaseSuite } -type customCurrencyEnabler interface { - SetEnableCustomCurrency(t *testing.T, enabled bool) error -} - func (s *FlatFeeCostBasisCreateSuite) SetupSuite() { s.BaseSuite.SetupSuite() } diff --git a/openmeter/billing/charges/service/handlers_test.go b/openmeter/billing/charges/service/handlers_test.go index eabfff1d06..a1a7c0160d 100644 --- a/openmeter/billing/charges/service/handlers_test.go +++ b/openmeter/billing/charges/service/handlers_test.go @@ -154,6 +154,7 @@ var _ usagebased.Handler = (*usageBasedTestHandler)(nil) type usageBasedTestHandler struct { onInvoiceUsageAccrued func(ctx context.Context, input usagebased.OnInvoiceUsageAccruedInput) (ledgertransaction.GroupReference, error) + onCustomCurrencyOverageAccrued func(ctx context.Context, input usagebased.OnCustomCurrencyOverageAccruedInput) (usagebased.OnCustomCurrencyOverageAccruedResult, error) onPaymentAuthorized func(ctx context.Context, input usagebased.OnPaymentAuthorizedInput) (ledgertransaction.GroupReference, error) onPaymentSettled func(ctx context.Context, input usagebased.OnPaymentSettledInput) (ledgertransaction.GroupReference, error) onCreditsOnlyUsageAccrued func(ctx context.Context, input usagebased.CreditsOnlyUsageAccruedInput) (creditrealization.CreateAllocationInputs, error) @@ -172,6 +173,14 @@ func (h *usageBasedTestHandler) OnInvoiceUsageAccrued(ctx context.Context, input return h.onInvoiceUsageAccrued(ctx, input) } +func (h *usageBasedTestHandler) OnCustomCurrencyOverageAccrued(ctx context.Context, input usagebased.OnCustomCurrencyOverageAccruedInput) (usagebased.OnCustomCurrencyOverageAccruedResult, error) { + if h.onCustomCurrencyOverageAccrued == nil { + return usagebased.OnCustomCurrencyOverageAccruedResult{}, errors.New("onCustomCurrencyOverageAccrued is not set") + } + + return h.onCustomCurrencyOverageAccrued(ctx, input) +} + func (h *usageBasedTestHandler) OnPaymentAuthorized(ctx context.Context, input usagebased.OnPaymentAuthorizedInput) (ledgertransaction.GroupReference, error) { if h.onPaymentAuthorized == nil { return ledgertransaction.GroupReference{}, errors.New("onPaymentAuthorized is not set") diff --git a/openmeter/billing/charges/service/usagebased_costbasis_test.go b/openmeter/billing/charges/service/usagebased_costbasis_test.go index f5a2980c0b..b33ed9bcd9 100644 --- a/openmeter/billing/charges/service/usagebased_costbasis_test.go +++ b/openmeter/billing/charges/service/usagebased_costbasis_test.go @@ -38,7 +38,7 @@ func (s *UsageBasedCostBasisCreateSuite) SetupSuite() { } func (s *UsageBasedCostBasisCreateSuite) TearDownTest() { - s.setCustomCurrencyEnabled(false) + s.setUsageBasedCustomCurrencyEnabled(false) s.BaseSuite.TearDownTest() } @@ -106,7 +106,7 @@ func (s *UsageBasedCostBasisCreateSuite) TestCreatePersistsManualPinnedAndDynami FiatCurrency: fiatCurrency, }) - s.setCustomCurrencyEnabled(true) + s.setUsageBasedCustomCurrencyEnabled(true) created, err := s.Charges.usageBasedService.Create(ctx, usagebased.CreateInput{ Namespace: namespace, Intents: []usagebased.Intent{ @@ -191,7 +191,7 @@ func (s *UsageBasedCostBasisCreateSuite) TestSetResolvedDynamicCostBasisIsRetryS dynamicIntent := costbasis.NewIntent(costbasis.DynamicIntent{ FiatCurrency: s.newFiatCurrency("USD"), }) - s.setCustomCurrencyEnabled(true) + s.setUsageBasedCustomCurrencyEnabled(true) created, err := s.Charges.usageBasedService.Create(ctx, usagebased.CreateInput{ Namespace: namespace, Intents: []usagebased.Intent{ @@ -277,7 +277,7 @@ func (s *UsageBasedCostBasisCreateSuite) TestDynamicCostBasisResolvesWhenChargeB dynamicIntent := costbasis.NewIntent(costbasis.DynamicIntent{ FiatCurrency: s.newFiatCurrency("USD"), }) - s.setCustomCurrencyEnabled(true) + s.setUsageBasedCustomCurrencyEnabled(true) clock.FreezeTime(activationAt) defer clock.UnFreeze() created, err := s.Charges.usageBasedService.Create(ctx, usagebased.CreateInput{ @@ -353,7 +353,7 @@ func (s *UsageBasedCostBasisCreateSuite) TestPinnedCostBasisMustMatchCurrencyAnd {name: "fiat currency", costBasisID: eurCostBasis.ID, errorText: "currency cost basis fiat currency mismatch"}, } - s.setCustomCurrencyEnabled(true) + s.setUsageBasedCustomCurrencyEnabled(true) for _, test := range tests { s.Run(test.name, func() { // given: @@ -398,7 +398,7 @@ func (s *UsageBasedCostBasisCreateSuite) TestCreateRollsBackCostBasesWhenChargeC Rate: alpacadecimal.NewFromInt(2), }) - s.setCustomCurrencyEnabled(true) + s.setUsageBasedCustomCurrencyEnabled(true) _, err := s.Charges.usageBasedService.Create(ctx, usagebased.CreateInput{ Namespace: namespace, Intents: []usagebased.Intent{ @@ -418,7 +418,7 @@ func (s *UsageBasedCostBasisCreateSuite) TestCreateWithoutCostBasisLeavesChargeR customer := s.CreateTestCustomer(namespace, "usage-based-without-cost-basis") currency := s.createTestCustomCurrency(ctx, namespace) featureMeters := s.createFeatureMeters(ctx, namespace, "credit-only-feature") - s.setCustomCurrencyEnabled(true) + s.setUsageBasedCustomCurrencyEnabled(true) created, err := s.Charges.usageBasedService.Create(ctx, usagebased.CreateInput{ Namespace: namespace, @@ -439,13 +439,6 @@ func (s *UsageBasedCostBasisCreateSuite) TestCreateWithoutCostBasisLeavesChargeR s.Require().Equal(0, s.countCostBases(namespace)) } -func (s *UsageBasedCostBasisCreateSuite) setCustomCurrencyEnabled(enabled bool) { - s.T().Helper() - enabler, ok := s.Charges.usageBasedService.(customCurrencyEnabler) - s.Require().True(ok) - s.Require().NoError(enabler.SetEnableCustomCurrency(s.T(), enabled)) -} - func (s *UsageBasedCostBasisCreateSuite) countCostBases(namespace string) int { s.T().Helper() diff --git a/openmeter/billing/charges/service/usagebased_test.go b/openmeter/billing/charges/service/usagebased_test.go index ca4a89bfa1..36130f7ae5 100644 --- a/openmeter/billing/charges/service/usagebased_test.go +++ b/openmeter/billing/charges/service/usagebased_test.go @@ -1,20 +1,30 @@ package service import ( + "context" "testing" "time" "github.com/alpacahq/alpacadecimal" + "github.com/oklog/ulid/v2" "github.com/samber/lo" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing" "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/charges" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" + usagebasedservice "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/service" + billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service" "github.com/openmeterio/openmeter/openmeter/productcatalog" + streamingtestutils "github.com/openmeterio/openmeter/openmeter/streaming/testutils" "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/datetime" "github.com/openmeterio/openmeter/pkg/timeutil" billingtest "github.com/openmeterio/openmeter/test/billing" @@ -36,6 +46,673 @@ func (s *UsageBasedChargesTestSuite) TearDownTest() { s.BaseSuite.TearDownTest() } +func (s *UsageBasedChargesTestSuite) TestUsageBasedCustomCurrencyCreditThenInvoiceCreatesFiatOveragePlaceholder() { + ctx := s.T().Context() + ns := s.GetUniqueNamespace("charges-service-usage-based-custom-currency") + defaults := s.ProvisionDefaultTaxCodes(ctx, ns) + customInvoicing := s.SetupCustomInvoicing(ns) + customer := s.CreateTestCustomer(ns, "test-subject") + _ = s.ProvisionBillingProfile(ctx, ns, customInvoicing.App.GetID()) + + feature := s.SetupApiRequestsTotalFeature(ctx, ns) + customCurrency := s.createTestCustomCurrency(ctx, ns) + fiatCurrency, err := currencyx.NewFiatCurrency(USD) + s.Require().NoError(err) + + servicePeriod := timeutil.ClosedPeriod{ + From: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, time.February, 1, 0, 0, 0, 0, time.UTC), + } + price := productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromInt(2), + }) + costBasisIntent := costbasis.NewIntent(costbasis.DynamicIntent{ + FiatCurrency: fiatCurrency, + }) + + s.setUsageBasedCustomCurrencyEnabled(true) + defer s.setUsageBasedCustomCurrencyEnabled(false) + + var charge usagebased.Charge + + s.Run("create custom currency credit then invoice charge", func() { + // given: + // - a custom-currency usage-based charge settled through a fiat invoice + // when: + // - the charge is created through the charge service + // then: + // - the usage-based charge is returned + created, err := s.Charges.Create(ctx, charges.CreateInput{ + Namespace: ns, + Intents: []charges.ChargeIntent{ + charges.NewChargeIntent(usagebased.Intent{ + Intent: meta.Intent{ + ManagedBy: billing.SubscriptionManagedLine, + UniqueReferenceID: lo.ToPtr("usage-based-custom-currency"), + CustomerID: customer.ID, + Currency: customCurrency, + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: defaults.InvoicingTaxCodeID, + }, + }, + IntentMutableFields: usagebased.IntentMutableFields{ + IntentMutableFields: meta.IntentMutableFields{ + Name: "usage-based-custom-currency", + ServicePeriod: servicePeriod, + FullServicePeriod: servicePeriod, + BillingPeriod: servicePeriod, + }, + InvoiceAt: servicePeriod.To, + Price: *price, + }, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + FeatureKey: feature.Feature.Key, + CostBasis: &costBasisIntent, + }), + }, + }) + s.Require().NoError(err) + s.Require().Len(created, 1) + + charge, err = created[0].AsUsageBasedCharge() + s.Require().NoError(err) + }) + + s.Run("persist fiat overage gathering line placeholder", func() { + // given: + // - a custom-currency credit-then-invoice usage-based charge + // when: + // - its active gathering lines are loaded + // then: + // - billing has persisted a fiat overage placeholder owned by the usage-based charge engine + lines := activeGatheringLinesForCharge(&s.BaseSuite, ns, customer.ID, charge.ID) + s.Require().Len(lines, 1) + + line := lines[0] + s.Equal(currencyx.FiatCode("USD"), line.Currency) + s.True(line.Price.Equal(price)) + s.Equal(billing.LineEngineTypeChargeUsageBased, line.Engine) + s.Require().NotNil(line.ChargeID) + s.Equal(charge.ID, *line.ChargeID) + s.Equal( + billing.AnnotationValueReasonOveragePlaceholder, + line.Annotations[billing.AnnotationKeyReason], + ) + }) +} + +type customCurrencyCreditThenInvoiceRealizationVariant struct { + invoiceAt time.Time + expectedCollectionEnd time.Time + enableProgressiveBilling bool + expectedRunType usagebased.RealizationRunType + expectedChargeStatusAfterPayment usagebased.Status + expectRemainingGatheringLine bool +} + +func (s *UsageBasedChargesTestSuite) TestUsageBasedCustomCurrencyCreditThenInvoiceLifecycle() { + s.runUsageBasedCustomCurrencyCreditThenInvoiceLifecycle(customCurrencyCreditThenInvoiceRealizationVariant{ + invoiceAt: datetime.MustParseTimeInLocation(s.T(), "2025-02-01T00:00:00Z", time.UTC).AsTime(), + expectedCollectionEnd: datetime.MustParseTimeInLocation(s.T(), "2025-02-02T00:01:00Z", time.UTC).AsTime(), + expectedRunType: usagebased.RealizationRunTypeFinalRealization, + expectedChargeStatusAfterPayment: usagebased.StatusFinal, + }) +} + +func (s *UsageBasedChargesTestSuite) TestUsageBasedCustomCurrencyCreditThenInvoiceProgressiveLifecycle() { + s.runUsageBasedCustomCurrencyCreditThenInvoiceLifecycle(customCurrencyCreditThenInvoiceRealizationVariant{ + invoiceAt: datetime.MustParseTimeInLocation(s.T(), "2025-01-16T00:00:00Z", time.UTC).AsTime(), + expectedCollectionEnd: datetime.MustParseTimeInLocation(s.T(), "2025-01-16T00:01:00Z", time.UTC).AsTime(), + enableProgressiveBilling: true, + expectedRunType: usagebased.RealizationRunTypePartialInvoice, + expectedChargeStatusAfterPayment: usagebased.StatusActive, + expectRemainingGatheringLine: true, + }) +} + +func (s *UsageBasedChargesTestSuite) runUsageBasedCustomCurrencyCreditThenInvoiceLifecycle( + realizationVariant customCurrencyCreditThenInvoiceRealizationVariant, +) { + type runPhase struct { + // usageAdded is the usage that becomes visible during this pass. + usageAdded float64 + // creditsAllocated is the additional TOKENS allocation returned during this pass. + creditsAllocated float64 + // expectRunTotals contains the realization run totals in TOKENS. + expectRunTotals billingtest.ExpectedTotals + // expectInvoiceTotals contains the invoice line totals in USD. + expectInvoiceTotals billingtest.ExpectedTotals + } + + type tc struct { + name string + skip string + + onRunCreated runPhase + onCollectionComplete runPhase + + expectLineDeleted bool + expectPaymentSettled bool + } + + // setup: + // - F1 is a metered feature owned by customer C1 + // - the billing profile has a 1 day collection period + // - the charge covers [2025-01-01T00:00:00Z, 2025-02-01T00:00:00Z) + // - usage is priced at 2 TOKENS per unit and settled with credit then invoice + // - TOKENS use a manual USD cost basis of 0.5 + tests := []tc{ + // given: + // - 5 metered units produce 10 TOKENS and no credits are allocated + // when: + // - the realization run is collected and invoiced + // then: + // - the full 10 TOKENS overage is settled as 5 USD + { + name: "happy path", + onRunCreated: runPhase{ + usageAdded: 5, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{Amount: 10, Total: 10}, + expectInvoiceTotals: billingtest.ExpectedTotals{Amount: 5, Total: 5}, + }, + onCollectionComplete: runPhase{ + usageAdded: 0, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{Amount: 10, Total: 10}, + expectInvoiceTotals: billingtest.ExpectedTotals{Amount: 5, Total: 5}, + }, + expectPaymentSettled: true, + }, + // given: + // - 5 metered units produce 10 TOKENS and 2 TOKENS are allocated when the run is created + // when: + // - the realization run is collected and invoiced + // then: + // - the remaining 8 TOKENS overage is settled as 4 USD + { + name: "happy path with credit allocation", + onRunCreated: runPhase{ + usageAdded: 5, + creditsAllocated: 2, + expectRunTotals: billingtest.ExpectedTotals{Amount: 10, CreditsTotal: 2, Total: 8}, + expectInvoiceTotals: billingtest.ExpectedTotals{Amount: 4, Total: 4}, + }, + onCollectionComplete: runPhase{ + usageAdded: 0, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{Amount: 10, CreditsTotal: 2, Total: 8}, + expectInvoiceTotals: billingtest.ExpectedTotals{Amount: 4, Total: 4}, + }, + expectPaymentSettled: true, + }, + // given: + // - 5 metered units produce 10 TOKENS and 10 TOKENS are allocated when the run is created + // when: + // - the zero-overage run is collected and invoiced + // then: + // - the empty overage line is removed and no payment is booked + { + name: "fully covered by credits", + skip: "TODO: delete the custom-currency overage line when credits reduce its fiat total to zero", + onRunCreated: runPhase{ + usageAdded: 5, + creditsAllocated: 10, + expectRunTotals: billingtest.ExpectedTotals{Amount: 10, CreditsTotal: 10}, + expectInvoiceTotals: billingtest.ExpectedTotals{}, + }, + onCollectionComplete: runPhase{ + usageAdded: 0, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{Amount: 10, CreditsTotal: 10}, + expectInvoiceTotals: billingtest.ExpectedTotals{}, + }, + expectLineDeleted: true, + }, + // given: + // - 5 metered units are initially covered by 10 TOKENS of allocated credits + // when: + // - 1 late metered unit becomes visible during collection + // then: + // - the resulting 2 TOKENS overage is settled as 1 USD + { + name: "fully covered by credits with late overage", + onRunCreated: runPhase{ + usageAdded: 5, + creditsAllocated: 10, + expectRunTotals: billingtest.ExpectedTotals{Amount: 10, CreditsTotal: 10}, + expectInvoiceTotals: billingtest.ExpectedTotals{}, + }, + onCollectionComplete: runPhase{ + usageAdded: 1, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{Amount: 12, CreditsTotal: 10, Total: 2}, + expectInvoiceTotals: billingtest.ExpectedTotals{Amount: 1, Total: 1}, + }, + expectPaymentSettled: true, + }, + // given: + // - no usage is visible when the run is created + // when: + // - 2 metered units become visible during collection and no credits are allocated + // then: + // - the resulting 4 TOKENS overage is settled as 2 USD + { + name: "collection usage is billed without initial usage", + onRunCreated: runPhase{ + usageAdded: 0, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{}, + expectInvoiceTotals: billingtest.ExpectedTotals{}, + }, + onCollectionComplete: runPhase{ + usageAdded: 2, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{Amount: 4, Total: 4}, + expectInvoiceTotals: billingtest.ExpectedTotals{Amount: 2, Total: 2}, + }, + expectPaymentSettled: true, + }, + // given: + // - no usage is visible when the run is created + // when: + // - 2 metered units become visible during collection and 4 TOKENS are allocated + // then: + // - credits cover the full amount and the empty overage line is removed + { + name: "collection usage is fully covered by credits without initial usage", + skip: "TODO: delete the custom-currency overage line when collection-time credits cover the full amount", + onRunCreated: runPhase{ + usageAdded: 0, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{}, + expectInvoiceTotals: billingtest.ExpectedTotals{}, + }, + onCollectionComplete: runPhase{ + usageAdded: 2, + creditsAllocated: 4, + expectRunTotals: billingtest.ExpectedTotals{Amount: 4, CreditsTotal: 4}, + expectInvoiceTotals: billingtest.ExpectedTotals{}, + }, + expectLineDeleted: true, + }, + // given: + // - no usage is visible when the run is created + // when: + // - collection completes without any usage becoming visible + // then: + // - the empty overage line is removed + { + name: "no usage deletes the overage line", + skip: "TODO: delete the custom-currency overage line when no usage is realized", + onRunCreated: runPhase{ + usageAdded: 0, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{}, + expectInvoiceTotals: billingtest.ExpectedTotals{}, + }, + onCollectionComplete: runPhase{ + usageAdded: 0, + creditsAllocated: 0, + expectRunTotals: billingtest.ExpectedTotals{}, + expectInvoiceTotals: billingtest.ExpectedTotals{}, + }, + expectLineDeleted: true, + }, + } + + // TODO: use the real lineage service once it supports custom currencies. + lineageMock := &mockLineageService{Service: s.LineageService} + lineageMock.On("CreateInitialLineages", mock.Anything, mock.Anything). + Return(nil). + Maybe() + lineageMock.On("PersistCorrectionLineageSegments", mock.Anything, mock.Anything). + Return(nil). + Maybe() + lineageMock.On("BackfillAdvanceLineageSegments", mock.Anything, mock.Anything). + Return(nil). + Maybe() + + customCurrencyUsageBasedService, err := usagebasedservice.New(usagebasedservice.Config{ + Adapter: s.UsageBasedAdapter, + Handler: s.UsageBasedTestHandler, + Lineage: lineageMock, + Locker: s.Locker, + MetaAdapter: s.MetaAdapter, + InvoiceUpdater: s.InvoiceUpdater, + CustomerOverrideService: s.BillingService, + FeatureService: s.FeatureService, + RatingService: billingratingservice.New(billingratingservice.Config{UnitConfigEnabled: s.UnitConfigEnabled}), + Currencies: s.CurrencyService, + StreamingConnector: s.MockStreamingConnector, + }) + s.Require().NoError(err) + + originalUsageBasedService := s.Charges.usageBasedService + s.Charges.usageBasedService = customCurrencyUsageBasedService + s.Require().NoError(s.BillingService.DeregisterLineEngine(billing.LineEngineTypeChargeUsageBased)) + s.Require().NoError(s.BillingService.RegisterLineEngine(customCurrencyUsageBasedService.GetLineEngine())) + defer func() { + s.Charges.usageBasedService = originalUsageBasedService + s.Require().NoError(s.BillingService.DeregisterLineEngine(billing.LineEngineTypeChargeUsageBased)) + s.Require().NoError(s.BillingService.RegisterLineEngine(originalUsageBasedService.GetLineEngine())) + }() + + for _, test := range tests { + s.Run(test.name, func() { + if test.skip != "" { + s.T().Skip(test.skip) + } + + ctx := s.T().Context() + ns := s.GetUniqueNamespace("charges-service-usage-based-custom-currency-lifecycle") + + s.UsageBasedTestHandler.Reset() + defer s.UsageBasedTestHandler.Reset() + s.MockStreamingConnector.Reset() + defer s.MockStreamingConnector.Reset() + clock.UnFreeze() + defer clock.UnFreeze() + + defaults := s.ProvisionDefaultTaxCodes(ctx, ns) + sandboxApp := s.InstallSandboxApp(s.T(), ns) + customer := s.CreateTestCustomer(ns, "customer-c1") + profileOptions := []billingtest.BillingProfileProvisionOption{ + billingtest.WithCollectionInterval(datetime.MustParseDuration(s.T(), "P1D")), + billingtest.WithManualApproval(), + } + if realizationVariant.enableProgressiveBilling { + profileOptions = append(profileOptions, billingtest.WithProgressiveBilling()) + } + _ = s.ProvisionBillingProfile( + ctx, + ns, + sandboxApp.GetID(), + profileOptions..., + ) + + feature := s.SetupApiRequestsTotalFeature(ctx, ns) + defer feature.Cleanup() + customCurrency := s.createTestCustomCurrency(ctx, ns) + fiatCurrency, err := currencyx.NewFiatCurrency(USD) + s.Require().NoError(err) + + createAt := datetime.MustParseTimeInLocation(s.T(), "2024-12-01T00:00:00Z", time.UTC).AsTime() + servicePeriod := timeutil.ClosedPeriod{ + From: datetime.MustParseTimeInLocation(s.T(), "2025-01-01T00:00:00Z", time.UTC).AsTime(), + To: datetime.MustParseTimeInLocation(s.T(), "2025-02-01T00:00:00Z", time.UTC).AsTime(), + } + usageAt := datetime.MustParseTimeInLocation(s.T(), "2025-01-15T00:00:00Z", time.UTC).AsTime() + lateUsageAt := usageAt.Add(12 * time.Hour) + lateUsageStoredAt := realizationVariant.invoiceAt.Add(-time.Second) + + s.setUsageBasedCustomCurrencyEnabled(true) + defer s.setUsageBasedCustomCurrencyEnabled(false) + clock.FreezeTime(createAt) + + var ( + chargeID meta.ChargeID + invoice billing.StandardInvoice + + customCurrencyOverageAccruedInvocations int + authorizedCallback *countedLedgerTransactionCallback[usagebased.OnPaymentAuthorizedInput] + settledCallback *countedLedgerTransactionCallback[usagebased.OnPaymentSettledInput] + ) + + s.Run("create realization run and fiat overage line", func() { + costBasisIntent := costbasis.NewIntent(costbasis.ManualIntent{ + FiatCurrency: fiatCurrency, + Rate: alpacadecimal.NewFromFloat(0.5), + }) + price := productcatalog.NewPriceFrom(productcatalog.UnitPrice{ + Amount: alpacadecimal.NewFromInt(2), + }) + + created, err := s.Charges.Create(ctx, charges.CreateInput{ + Namespace: ns, + Intents: []charges.ChargeIntent{ + charges.NewChargeIntent(usagebased.Intent{ + Intent: meta.Intent{ + ManagedBy: billing.SubscriptionManagedLine, + UniqueReferenceID: lo.ToPtr("usage-based-custom-currency-lifecycle"), + CustomerID: customer.ID, + Currency: customCurrency, + TaxConfig: productcatalog.TaxCodeConfig{ + TaxCodeID: defaults.InvoicingTaxCodeID, + }, + }, + IntentMutableFields: usagebased.IntentMutableFields{ + IntentMutableFields: meta.IntentMutableFields{ + Name: "usage-based-custom-currency", + ServicePeriod: servicePeriod, + FullServicePeriod: servicePeriod, + BillingPeriod: servicePeriod, + }, + InvoiceAt: servicePeriod.To, + Price: *price, + }, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + FeatureKey: feature.Feature.Key, + CostBasis: &costBasisIntent, + }), + }, + }) + s.Require().NoError(err) + s.Require().Len(created, 1) + + charge, err := created[0].AsUsageBasedCharge() + s.Require().NoError(err) + chargeID = charge.GetChargeID() + + s.UsageBasedTestHandler.onCreditsOnlyUsageAccrued, _ = newCappedCreditAllocator(test.onRunCreated.creditsAllocated) + if test.onRunCreated.usageAdded > 0 { + s.MockStreamingConnector.AddSimpleEvent( + feature.Feature.Key, + test.onRunCreated.usageAdded, + usageAt, + streamingtestutils.WithStoredAt(usageAt), + ) + } + + clock.FreezeTime(realizationVariant.invoiceAt) + invoices, err := s.BillingService.InvoicePendingLines(ctx, billing.InvoicePendingLinesInput{ + Customer: customer.GetID(), + AsOf: lo.ToPtr(realizationVariant.invoiceAt), + }) + s.Require().NoError(err) + s.Require().Len(invoices, 1) + invoice = invoices[0] + s.Require().NotNil(invoice.CollectionAt) + s.True(realizationVariant.expectedCollectionEnd.Equal(*invoice.CollectionAt)) + s.Require().Len(invoice.Lines.OrEmpty(), 1) + s.requireCustomCurrencyOverageLine(requireCustomCurrencyOverageLineInput{ + line: invoice.Lines.OrEmpty()[0], + expectTokenOverage: test.onRunCreated.expectRunTotals.Total, + expectCostBasis: 0.5, + expectFiatTotals: test.onRunCreated.expectInvoiceTotals, + }) + + charge = s.mustGetUsageBasedChargeByID(chargeID) + s.Equal(usagebased.StatusActiveRealizationWaitingForCollection, charge.Status) + initialRun, err := charge.GetCurrentRealizationRun() + s.Require().NoError(err) + s.Equal(realizationVariant.expectedRunType, initialRun.Type) + s.True(realizationVariant.invoiceAt.Equal(initialRun.ServicePeriodTo)) + s.Equal(test.onRunCreated.usageAdded, initialRun.MeteredQuantity.InexactFloat64()) + s.RequireTotals(test.onRunCreated.expectRunTotals, initialRun.Totals) + }) + + s.Run("collect realization run and settle fiat overage", func() { + s.UsageBasedTestHandler.onCreditsOnlyUsageAccrued, _ = newCappedCreditAllocator(test.onCollectionComplete.creditsAllocated) + if test.onCollectionComplete.usageAdded > 0 { + s.MockStreamingConnector.AddSimpleEvent( + feature.Feature.Key, + test.onCollectionComplete.usageAdded, + lateUsageAt, + streamingtestutils.WithStoredAt(lateUsageStoredAt), + ) + } + + clock.FreezeTime(realizationVariant.expectedCollectionEnd) + invoice, err = s.BillingService.AdvanceInvoice(ctx, invoice.GetInvoiceID()) + s.Require().NoError(err) + s.Equal(billing.StandardInvoiceStatusDraftManualApprovalNeeded, invoice.Status) + s.Require().NotNil(invoice.CollectionAt) + s.True(realizationVariant.expectedCollectionEnd.Equal(*invoice.CollectionAt)) + + charge := s.mustGetUsageBasedChargeByID(chargeID) + s.Equal(usagebased.StatusActiveRealizationProcessing, charge.Status) + collectedRun, err := charge.GetCurrentRealizationRun() + s.Require().NoError(err) + s.Equal(realizationVariant.expectedRunType, collectedRun.Type) + s.Equal(test.onRunCreated.usageAdded+test.onCollectionComplete.usageAdded, collectedRun.MeteredQuantity.InexactFloat64()) + s.RequireTotals(test.onCollectionComplete.expectRunTotals, collectedRun.Totals) + + if test.expectLineDeleted { + s.Empty(invoice.Lines.OrEmpty()) + } else { + s.Require().Len(invoice.Lines.OrEmpty(), 1) + s.requireCustomCurrencyOverageLine(requireCustomCurrencyOverageLineInput{ + line: invoice.Lines.OrEmpty()[0], + expectTokenOverage: test.onCollectionComplete.expectRunTotals.Total, + expectCostBasis: 0.5, + expectFiatTotals: test.onCollectionComplete.expectInvoiceTotals, + }) + } + + if test.expectPaymentSettled { + s.UsageBasedTestHandler.onCustomCurrencyOverageAccrued = func(_ context.Context, input usagebased.OnCustomCurrencyOverageAccruedInput) (usagebased.OnCustomCurrencyOverageAccruedResult, error) { + customCurrencyOverageAccruedInvocations++ + s.Equal(chargeID.ID, input.Charge.ID) + s.Equal(test.onCollectionComplete.expectRunTotals.Total, input.GetCustomCurrencyAmountAccrued().InexactFloat64()) + + resolvedCostBasis, err := input.GetCostBasis() + s.Require().NoError(err) + s.Equal(float64(0.5), resolvedCostBasis.InexactFloat64()) + + resolvedFiatCurrency, err := input.GetFiatCurrency() + s.Require().NoError(err) + s.Equal(USD, resolvedFiatCurrency.Details().Code) + + return usagebased.OnCustomCurrencyOverageAccruedResult{ + TransactionGroup: ledgertransaction.GroupReference{ + TransactionGroupID: ulid.Make().String(), + }, + TotalFiatAmount: alpacadecimal.NewFromFloat(test.onCollectionComplete.expectInvoiceTotals.Total), + }, nil + } + + authorizedCallback = newCountedLedgerTransactionCallback[usagebased.OnPaymentAuthorizedInput]() + s.UsageBasedTestHandler.onPaymentAuthorized = authorizedCallback.Handler(s.T(), func(_ *testing.T, input usagebased.OnPaymentAuthorizedInput) { + s.Equal(chargeID.ID, input.Charge.ID) + s.Equal(test.onCollectionComplete.expectInvoiceTotals.Total, input.FiatAmount.InexactFloat64()) + }) + + settledCallback = newCountedLedgerTransactionCallback[usagebased.OnPaymentSettledInput]() + s.UsageBasedTestHandler.onPaymentSettled = settledCallback.Handler(s.T(), func(_ *testing.T, input usagebased.OnPaymentSettledInput) { + s.Equal(chargeID.ID, input.Charge.ID) + s.Equal(test.onCollectionComplete.expectInvoiceTotals.Total, input.FiatAmount.InexactFloat64()) + }) + } + + invoice, err = s.BillingService.ApproveInvoice(ctx, invoice.GetInvoiceID()) + s.Require().NoError(err) + s.Equal(billing.StandardInvoiceStatusPaid, invoice.Status) + s.Require().NotNil(invoice.CollectionAt) + s.True(realizationVariant.expectedCollectionEnd.Equal(*invoice.CollectionAt)) + }) + + s.Run("reload realized charge and invoice state", func() { + charge := s.mustGetUsageBasedChargeByID(chargeID) + s.Equal(realizationVariant.expectedChargeStatusAfterPayment, charge.Status) + s.Nil(charge.State.CurrentRealizationRunID) + s.Require().Len(charge.Realizations, 1) + realizedRun, ok := charge.Realizations.Latest() + s.Require().True(ok) + s.Equal(realizationVariant.expectedRunType, realizedRun.Type) + s.Require().NotNil(realizedRun.InvoiceUsage) + + if test.expectPaymentSettled { + s.Equal(1, customCurrencyOverageAccruedInvocations) + s.Require().NotNil(authorizedCallback) + s.Equal(1, authorizedCallback.nrInvocations) + s.Require().NotNil(settledCallback) + s.Equal(1, settledCallback.nrInvocations) + s.Require().NotNil(realizedRun.Payment) + s.Equal(payment.StatusSettled, realizedRun.Payment.Status) + s.Equal(test.onCollectionComplete.expectInvoiceTotals.Total, realizedRun.Payment.FiatAmount.InexactFloat64()) + s.False(realizedRun.NoFiatTransactionRequired) + } else { + s.Zero(customCurrencyOverageAccruedInvocations) + s.Nil(realizedRun.Payment) + s.True(realizedRun.NoFiatTransactionRequired) + } + + activeInvoice, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ + Invoice: invoice.GetInvoiceID(), + Expand: billing.StandardInvoiceExpandAll, + }) + s.Require().NoError(err) + s.Require().NotNil(activeInvoice.CollectionAt) + s.True(realizationVariant.expectedCollectionEnd.Equal(*activeInvoice.CollectionAt)) + + if test.expectLineDeleted { + s.Empty(activeInvoice.Lines.OrEmpty()) + + invoiceWithDeletedLine, err := s.BillingService.GetStandardInvoiceById(ctx, billing.GetStandardInvoiceByIdInput{ + Invoice: invoice.GetInvoiceID(), + Expand: billing.StandardInvoiceExpandAll.With( + billing.StandardInvoiceExpandDeletedLines, + ), + }) + s.Require().NoError(err) + s.Require().NotNil(invoiceWithDeletedLine.CollectionAt) + s.True(realizationVariant.expectedCollectionEnd.Equal(*invoiceWithDeletedLine.CollectionAt)) + s.Require().Len(invoiceWithDeletedLine.Lines.OrEmpty(), 1) + deletedLine := invoiceWithDeletedLine.Lines.OrEmpty()[0] + s.Require().NotNil(deletedLine.DeletedAt) + s.requireCustomCurrencyOverageLine(requireCustomCurrencyOverageLineInput{ + line: deletedLine, + expectTokenOverage: test.onCollectionComplete.expectRunTotals.Total, + expectCostBasis: 0.5, + expectFiatTotals: test.onCollectionComplete.expectInvoiceTotals, + }) + + // TODO: delete the standard invoice when zero overage removes its only line. + s.Nil(invoiceWithDeletedLine.DeletedAt) + } else { + s.Require().Len(activeInvoice.Lines.OrEmpty(), 1) + s.requireCustomCurrencyOverageLine(requireCustomCurrencyOverageLineInput{ + line: activeInvoice.Lines.OrEmpty()[0], + expectTokenOverage: test.onCollectionComplete.expectRunTotals.Total, + expectCostBasis: 0.5, + expectFiatTotals: test.onCollectionComplete.expectInvoiceTotals, + }) + } + + if realizationVariant.expectRemainingGatheringLine { + gatheringInvoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ + Namespaces: []string{ns}, + Customers: []string{customer.ID}, + Currencies: []currencyx.FiatCode{currencyx.FiatCode(USD)}, + Expand: []billing.GatheringInvoiceExpand{billing.GatheringInvoiceExpandLines}, + }) + s.Require().NoError(err) + s.Require().Len(gatheringInvoices.Items, 1) + + remainingLines := gatheringInvoices.Items[0].Lines.OrEmpty() + s.Require().Len(remainingLines, 1) + remainingLine := remainingLines[0] + s.Require().NotNil(remainingLine.ChargeID) + s.Equal(chargeID.ID, *remainingLine.ChargeID) + s.True(realizationVariant.invoiceAt.Equal(remainingLine.ServicePeriod.From)) + s.True(servicePeriod.To.Equal(remainingLine.ServicePeriod.To)) + } + }) + }) + } +} + func (s *UsageBasedChargesTestSuite) TestUsageBasedCreditThenInvoicePartialInvoiceLifecycle() { ctx := s.T().Context() ns := s.GetUniqueNamespace("charges-service-usage-based-partial-invoice-lifecycle") @@ -709,3 +1386,38 @@ func (s *UsageBasedChargesTestSuite) mustGetUsageBasedChargeByID(chargeID meta.C return usageBasedCharge } + +type requireCustomCurrencyOverageLineInput struct { + line *billing.StandardLine + expectTokenOverage float64 + expectCostBasis float64 + expectFiatTotals billingtest.ExpectedTotals +} + +func (s *UsageBasedChargesTestSuite) requireCustomCurrencyOverageLine(in requireCustomCurrencyOverageLineInput) { + s.T().Helper() + + s.Equal(currencyx.FiatCode(USD), in.line.Currency) + switch reason := in.line.Annotations[billing.AnnotationKeyReason].(type) { + case string: + s.Equal(billing.AnnotationValueReasonOverage, reason) + case *string: + s.Require().NotNil(reason) + s.Equal(billing.AnnotationValueReasonOverage, *reason) + default: + s.Fail("overage reason annotation has an unexpected type") + } + + s.Require().NotNil(in.line.UsageBased) + s.Require().NotNil(in.line.UsageBased.Price) + flatPrice, err := in.line.UsageBased.Price.AsFlat() + s.Require().NoError(err) + s.Equal(in.expectFiatTotals.Amount, flatPrice.Amount.InexactFloat64()) + + s.Require().Len(in.line.DetailedLines, 1) + detailedLine := in.line.DetailedLines[0] + s.Equal(in.expectTokenOverage, detailedLine.Quantity.InexactFloat64()) + s.Equal(in.expectCostBasis, detailedLine.PerUnitAmount.InexactFloat64()) + s.RequireTotals(in.expectFiatTotals, detailedLine.Totals) + s.RequireTotals(in.expectFiatTotals, in.line.Totals) +} diff --git a/openmeter/billing/charges/testutils/handlers.go b/openmeter/billing/charges/testutils/handlers.go index 361ee6ac0b..a112e94608 100644 --- a/openmeter/billing/charges/testutils/handlers.go +++ b/openmeter/billing/charges/testutils/handlers.go @@ -99,6 +99,23 @@ func (mockUsageBasedHandler) OnInvoiceUsageAccrued(context.Context, usagebased.O return newMockLedgerTransactionGroupReference(), nil } +func (mockUsageBasedHandler) OnCustomCurrencyOverageAccrued(_ context.Context, input usagebased.OnCustomCurrencyOverageAccruedInput) (usagebased.OnCustomCurrencyOverageAccruedResult, error) { + costBasis, err := input.GetCostBasis() + if err != nil { + return usagebased.OnCustomCurrencyOverageAccruedResult{}, err + } + + fiatCurrency, err := input.GetFiatCurrency() + if err != nil { + return usagebased.OnCustomCurrencyOverageAccruedResult{}, err + } + + return usagebased.OnCustomCurrencyOverageAccruedResult{ + TransactionGroup: newMockLedgerTransactionGroupReference(), + TotalFiatAmount: fiatCurrency.RoundToPrecision(input.GetCustomCurrencyAmountAccrued().Mul(costBasis)), + }, nil +} + func (mockUsageBasedHandler) OnPaymentAuthorized(context.Context, usagebased.OnPaymentAuthorizedInput) (ledgertransaction.GroupReference, error) { return newMockLedgerTransactionGroupReference(), nil } diff --git a/openmeter/billing/charges/usagebased/charge.go b/openmeter/billing/charges/usagebased/charge.go index 1d066ca0a6..eaef5012bd 100644 --- a/openmeter/billing/charges/usagebased/charge.go +++ b/openmeter/billing/charges/usagebased/charge.go @@ -169,6 +169,15 @@ func (c Charge) GetCurrentRealizationRun() (RealizationRun, error) { return c.Realizations.GetByID(*c.State.CurrentRealizationRunID) } +func (c Charge) ConvertCustomCurrencyOverageToFiat(creditCurrencyTotals totals.Totals) (meta.FiatOverage, error) { + return meta.ConvertCustomCurrencyOverageToFiat(meta.ConvertCustomCurrencyOverageToFiatInput{ + Currency: c.Intent.GetCurrency(), + CostBasisIntent: c.Intent.GetCostBasisIntent(), + ResolvedCostBasis: c.State.ResolvedCostBasis, + Totals: creditCurrencyTotals, + }) +} + func (c Charge) GetCustomerID() customer.CustomerID { return customer.CustomerID{ Namespace: c.Namespace, diff --git a/openmeter/billing/charges/usagebased/handler.go b/openmeter/billing/charges/usagebased/handler.go index e996b440f0..189e472577 100644 --- a/openmeter/billing/charges/usagebased/handler.go +++ b/openmeter/billing/charges/usagebased/handler.go @@ -11,6 +11,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" + "github.com/openmeterio/openmeter/openmeter/currencies" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/models" "github.com/openmeterio/openmeter/pkg/timeutil" @@ -126,13 +127,90 @@ func (i OnInvoiceUsageAccruedInput) Validate() error { return models.NewNillableGenericValidationError(errors.Join(errs...)) } -type RunEventInput struct { - Charge Charge `json:"charge"` - Run RealizationRun `json:"run"` - EventAt time.Time `json:"eventAt"` +type OnCustomCurrencyOverageAccruedInput struct { + Charge Charge `json:"charge"` + Run RealizationRun `json:"run"` } -func (i RunEventInput) Validate() error { +func (i OnCustomCurrencyOverageAccruedInput) CustomCurrency() currencies.Currency { + return i.Charge.Intent.GetEffectiveIntent().Currency +} + +func (i OnCustomCurrencyOverageAccruedInput) GetFiatCurrency() (*currencyx.FiatCurrency, error) { + return i.Charge.Intent.GetEffectiveIntent().CostBasis.GetFiatCurrency() +} + +func (i OnCustomCurrencyOverageAccruedInput) GetCostBasis() (alpacadecimal.Decimal, error) { + if i.Charge.State.ResolvedCostBasis == nil { + return alpacadecimal.Decimal{}, fmt.Errorf("cost basis is not resolved") + } + + return i.Charge.State.ResolvedCostBasis.CostBasis, nil +} + +func (i OnCustomCurrencyOverageAccruedInput) GetCustomCurrencyAmountAccrued() alpacadecimal.Decimal { + return i.Run.Totals.Total +} + +func (i OnCustomCurrencyOverageAccruedInput) Validate() error { + var errs []error + + if err := i.Charge.Validate(); err != nil { + errs = append(errs, fmt.Errorf("charge: %w", err)) + } + + if err := i.Run.Validate(); err != nil { + errs = append(errs, fmt.Errorf("run: %w", err)) + } + + effectiveIntent := i.Charge.Intent.GetEffectiveIntent() + + if err := effectiveIntent.Currency.Validate(); err != nil { + errs = append(errs, fmt.Errorf("custom currency: %w", err)) + } + + if !effectiveIntent.Currency.IsCustom() { + errs = append(errs, fmt.Errorf("custom currency must be custom typed currency")) + } + + if !i.GetCustomCurrencyAmountAccrued().IsPositive() { + errs = append(errs, fmt.Errorf("amount must be positive")) + } + + if _, err := i.GetCostBasis(); err != nil { + errs = append(errs, fmt.Errorf("cost basis: %w", err)) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type OnCustomCurrencyOverageAccruedResult struct { + TransactionGroup ledgertransaction.GroupReference `json:"transactionGroup"` + TotalFiatAmount alpacadecimal.Decimal `json:"totalFiatAmount"` +} + +func (r OnCustomCurrencyOverageAccruedResult) Validate() error { + var errs []error + + if err := r.TransactionGroup.Validate(); err != nil { + errs = append(errs, fmt.Errorf("transaction group: %w", err)) + } + + if r.TotalFiatAmount.IsNegative() { + errs = append(errs, fmt.Errorf("total fiat amount cannot be negative")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + +type PaymentEventInput struct { + Charge Charge `json:"charge"` + Run RealizationRun `json:"run"` + EventAt time.Time `json:"eventAt"` + FiatAmount alpacadecimal.Decimal `json:"fiatAmount"` +} + +func (i PaymentEventInput) Validate() error { var errs []error if err := i.Charge.Validate(); err != nil { @@ -147,12 +225,16 @@ func (i RunEventInput) Validate() error { errs = append(errs, fmt.Errorf("event at is required")) } + if !i.FiatAmount.IsPositive() { + errs = append(errs, fmt.Errorf("fiat amount must be positive")) + } + return models.NewNillableGenericValidationError(errors.Join(errs...)) } type ( - OnPaymentAuthorizedInput = RunEventInput - OnPaymentSettledInput = RunEventInput + OnPaymentAuthorizedInput = PaymentEventInput + OnPaymentSettledInput = PaymentEventInput ) type Handler interface { @@ -165,6 +247,10 @@ type Handler interface { // OnPaymentSettled is called when an invoice-backed usage-based run payment is settled. OnPaymentSettled(ctx context.Context, input OnPaymentSettledInput) (ledgertransaction.GroupReference, error) + // OnCustomCurrencyOverageAccrued is called when uncovered custom-currency usage is accrued in fiat. + // This must be modeled as a credit purchase flow from the ledger point of view. + OnCustomCurrencyOverageAccrued(ctx context.Context, input OnCustomCurrencyOverageAccruedInput) (OnCustomCurrencyOverageAccruedResult, error) + // OnCreditsOnlyUsageAccrued is called when a credit-only usage-based charge needs to be allocated as credits fully. OnCreditsOnlyUsageAccrued(ctx context.Context, input CreditsOnlyUsageAccruedInput) (creditrealization.CreateAllocationInputs, error) @@ -180,6 +266,10 @@ func (h UnimplementedHandler) OnInvoiceUsageAccrued(ctx context.Context, input O return ledgertransaction.GroupReference{}, errors.New("not implemented") } +func (h UnimplementedHandler) OnCustomCurrencyOverageAccrued(ctx context.Context, input OnCustomCurrencyOverageAccruedInput) (OnCustomCurrencyOverageAccruedResult, error) { + return OnCustomCurrencyOverageAccruedResult{}, errors.New("not implemented") +} + func (h UnimplementedHandler) OnPaymentAuthorized(ctx context.Context, input OnPaymentAuthorizedInput) (ledgertransaction.GroupReference, error) { return ledgertransaction.GroupReference{}, errors.New("not implemented") } diff --git a/openmeter/billing/charges/usagebased/service/create.go b/openmeter/billing/charges/usagebased/service/create.go index ffcefada1a..5d04bb4443 100644 --- a/openmeter/billing/charges/usagebased/service/create.go +++ b/openmeter/billing/charges/usagebased/service/create.go @@ -111,6 +111,14 @@ func gatheringLineFromUsageBasedChargeForPeriod(charge usagebased.Charge, servic return usagebased.ChargeWithGatheringLine{}, fmt.Errorf("cloning annotations: %w", err) } + if intent.Currency.IsCustom() { + // TODO: This should be a different typed gathering line, but for now we don't have that. + if clonedAnnotations == nil { + clonedAnnotations = models.Annotations{} + } + clonedAnnotations[billing.AnnotationKeyReason] = lo.ToPtr(billing.AnnotationValueReasonOveragePlaceholder) + } + var unitConfig *productcatalog.UnitConfig if intent.UnitConfig != nil { unitConfig = lo.ToPtr(intent.UnitConfig.Clone()) diff --git a/openmeter/billing/charges/usagebased/service/creditheninvoice.go b/openmeter/billing/charges/usagebased/service/creditheninvoice.go index b25147890a..d991243c4c 100644 --- a/openmeter/billing/charges/usagebased/service/creditheninvoice.go +++ b/openmeter/billing/charges/usagebased/service/creditheninvoice.go @@ -802,7 +802,6 @@ func (s *CreditThenInvoiceStateMachine) StartInvoiceRun( ServicePeriodTo: servicePeriodTo, LineID: lo.ToPtr(input.LineID), InvoiceID: lo.ToPtr(input.InvoiceID), - CreditAllocation: usagebasedrun.CreditAllocationAvailable, CurrencyCalculator: s.CurrencyCalculator, }) if err != nil { @@ -875,12 +874,22 @@ func (s *CreditThenInvoiceStateMachine) SnapshotInvoiceUsage(ctx context.Context } currentRun.DetailedLines = mo.Some(ratingResult.DetailedLines) + noFiatTransactionRequired := currentTotals.Total.IsZero() + if s.Charge.Intent.GetCurrency().IsCustom() { + fiatOverage, err := s.Charge.ConvertCustomCurrencyOverageToFiat(currentTotals) + if err != nil { + return fmt.Errorf("convert custom currency overage to fiat: %w", err) + } + + noFiatTransactionRequired = fiatOverage.Amount.IsZero() + } + currentRunBase, err := s.Adapter.UpdateRealizationRun(ctx, usagebased.UpdateRealizationRunInput{ ID: currentRun.ID, StoredAtLT: mo.Some(storedAtLT), MeteredQuantity: mo.Some(ratingResult.Quantity), Totals: mo.Some(currentTotals), - NoFiatTransactionRequired: mo.Some(currentTotals.Total.IsZero()), + NoFiatTransactionRequired: mo.Some(noFiatTransactionRequired), }) if err != nil { return fmt.Errorf("update realization run: %w", err) diff --git a/openmeter/billing/charges/usagebased/service/creditsonly.go b/openmeter/billing/charges/usagebased/service/creditsonly.go index 4588215381..ec925c03d6 100644 --- a/openmeter/billing/charges/usagebased/service/creditsonly.go +++ b/openmeter/billing/charges/usagebased/service/creditsonly.go @@ -307,15 +307,13 @@ func (s *CreditsOnlyStateMachine) StartFinalRealizationRun(ctx context.Context) } result, err := s.Runs.CreateRatedRun(ctx, usagebasedrun.CreateRatedRunInput{ - Charge: s.Charge, - CustomerOverride: s.CustomerOverride, - FeatureMeter: s.FeatureMeter, - Type: usagebased.RealizationRunTypeFinalRealization, - StoredAtLT: storedAtLT, - ServicePeriodTo: meta.NormalizeTimestamp(s.Charge.Intent.GetEffectiveServicePeriod().To), - CreditAllocation: usagebasedrun.CreditAllocationExact, - CurrencyCalculator: s.CurrencyCalculator, - NoFiatTransactionRequired: true, + Charge: s.Charge, + CustomerOverride: s.CustomerOverride, + FeatureMeter: s.FeatureMeter, + Type: usagebased.RealizationRunTypeFinalRealization, + StoredAtLT: storedAtLT, + ServicePeriodTo: meta.NormalizeTimestamp(s.Charge.Intent.GetEffectiveServicePeriod().To), + CurrencyCalculator: s.CurrencyCalculator, }) if err != nil { return err diff --git a/openmeter/billing/charges/usagebased/service/lineengine.go b/openmeter/billing/charges/usagebased/service/lineengine.go index cef78fc813..7620e00cd9 100644 --- a/openmeter/billing/charges/usagebased/service/lineengine.go +++ b/openmeter/billing/charges/usagebased/service/lineengine.go @@ -153,8 +153,8 @@ func (e *LineEngine) BuildStandardLinesForGatheringPreview(ctx context.Context, } if err := populateStandardLineFromRun(stdLine, populateStandardLineFromRunInput{ - Run: previewResult.Run, - Runs: previewResult.Runs, + Charge: charge, + Run: previewResult.Run, }); err != nil { return nil, fmt.Errorf("populating gathering preview line[%s] from run: %w", stdLine.ID, err) } @@ -254,8 +254,8 @@ func (e *LineEngine) OnStandardInvoiceCreated(ctx context.Context, input billing } if err := populateStandardLineFromRun(stdLine, populateStandardLineFromRunInput{ - Run: currentRun, - Runs: charge.Realizations, + Charge: charge, + Run: currentRun, }); err != nil { return nil, fmt.Errorf("populating standard line from run for charge[%s]: %w", charge.ID, err) } @@ -308,8 +308,8 @@ func (e *LineEngine) OnCollectionCompleted(ctx context.Context, input billing.On } if err := populateStandardLineFromRun(stdLine, populateStandardLineFromRunInput{ - Run: currentRun, - Runs: charge.Realizations, + Charge: charge, + Run: currentRun, }); err != nil { return nil, fmt.Errorf("populating standard line from run for charge[%s]: %w", charge.ID, err) } @@ -515,8 +515,8 @@ func (e *LineEngine) attachManualStandardLine(ctx context.Context, standardInvoi standardLine.ManagedBy = billing.ManuallyManagedLine if err := populateStandardLineFromRun(&standardLine, populateStandardLineFromRunInput{ - Run: currentRun, - Runs: charge.Realizations, + Charge: charge, + Run: currentRun, }); err != nil { return nil, fmt.Errorf("populating standard line from run for charge[%s]: %w", charge.ID, err) } diff --git a/openmeter/billing/charges/usagebased/service/linemapper.go b/openmeter/billing/charges/usagebased/service/linemapper.go index 82265a7c55..0743e73904 100644 --- a/openmeter/billing/charges/usagebased/service/linemapper.go +++ b/openmeter/billing/charges/usagebased/service/linemapper.go @@ -5,16 +5,19 @@ import ( "fmt" "time" + "github.com/alpacahq/alpacadecimal" "github.com/samber/lo" "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditpurchase" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" billingrating "github.com/openmeterio/openmeter/openmeter/billing/rating" "github.com/openmeterio/openmeter/openmeter/billing/rating/service/mutator" "github.com/openmeterio/openmeter/openmeter/currencies" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/pkg/currencyx" + "github.com/openmeterio/openmeter/pkg/models" ) func intentFromManualCreatedLine( @@ -122,8 +125,8 @@ func intentFromManualCreatedLine( } type populateStandardLineFromRunInput struct { - Run usagebased.RealizationRun - Runs usagebased.RealizationRuns + Charge usagebased.Charge + Run usagebased.RealizationRun } func populateStandardLineFromRun(stdLine *billing.StandardLine, input populateStandardLineFromRunInput) error { @@ -136,12 +139,19 @@ func populateStandardLineFromRun(stdLine *billing.StandardLine, input populateSt return fmt.Errorf("creating currency calculator: %w", err) } - billingMeteredQuantity, err := input.Runs.MapToBillingMeteredQuantity(input.Run) + // Wait until StoredAtLT plus the internal collection period before collecting the line. + // This ensures the usage snapshot bounded by StoredAtLT is no longer changing when billing reads it. + stdLine.OverrideCollectionPeriodEnd = lo.ToPtr(input.Run.StoredAtLT.Add(usagebased.InternalCollectionPeriod)) + + if input.Charge.Intent.GetCurrency().IsCustom() { + return populateCustomCurrencyOverageFromRun(stdLine, input, cur) + } + + billingMeteredQuantity, err := input.Charge.Realizations.MapToBillingMeteredQuantity(input.Run) if err != nil { return fmt.Errorf("mapping run metered quantity to billing: %w", err) } - stdLine.OverrideCollectionPeriodEnd = lo.ToPtr(input.Run.StoredAtLT.Add(usagebased.InternalCollectionPeriod)) stdLine.UsageBased.MeteredQuantity = lo.ToPtr(billingMeteredQuantity.LinePeriod) stdLine.UsageBased.MeteredPreLinePeriodQuantity = lo.ToPtr(billingMeteredQuantity.PreLinePeriod) @@ -194,6 +204,87 @@ func populateStandardLineFromRun(stdLine *billing.StandardLine, input populateSt return nil } +func populateCustomCurrencyOverageFromRun( + stdLine *billing.StandardLine, + input populateStandardLineFromRunInput, + invoiceCurrency currencyx.Currency, +) error { + charge := input.Charge + run := input.Run + + fiatOverage, err := charge.ConvertCustomCurrencyOverageToFiat(run.Totals) + if err != nil { + return fmt.Errorf("custom currency charge[%s] converting overage to fiat: %w", charge.ID, err) + } + + if stdLine.Currency != fiatOverage.Currency.GetFiatCode() { + return fmt.Errorf( + "custom currency charge[%s] invoice currency mismatch: %s != %s", + charge.ID, + stdLine.Currency, + fiatOverage.Currency.Details().Code, + ) + } + + if stdLine.Annotations == nil { + stdLine.Annotations = models.Annotations{} + } + stdLine.Annotations[billing.AnnotationKeyReason] = lo.ToPtr(billing.AnnotationValueReasonOverage) + + stdLine.RateCardDiscounts = billing.Discounts{} + stdLine.Discounts = billing.StandardLineDiscounts{} + stdLine.CreditsApplied = nil + + stdLine.UsageBased = &billing.UsageBasedLine{ + ConfigID: stdLine.UsageBased.ConfigID, + FeatureKey: stdLine.UsageBased.FeatureKey, + Price: productcatalog.NewPriceFrom(productcatalog.FlatPrice{ + Amount: fiatOverage.Amount, + PaymentTerm: productcatalog.InArrearsPaymentTerm, + }), + MeteredQuantity: lo.ToPtr(alpacadecimal.NewFromInt(1)), + Quantity: lo.ToPtr(alpacadecimal.NewFromInt(1)), + MeteredPreLinePeriodQuantity: lo.ToPtr(alpacadecimal.Zero), + PreLinePeriodQuantity: lo.ToPtr(alpacadecimal.Zero), + } + + name := "overage" + if stdLine.Name != "" { + name = fmt.Sprintf("%s (overage)", stdLine.Name) + } + + detailedLine, err := creditpurchase.NewDetailedLine(creditpurchase.NewDetailedLineInput{ + Namespace: stdLine.Namespace, + InvoiceID: stdLine.InvoiceID, + Name: name, + ServicePeriod: stdLine.Period, + CustomCurrency: charge.Intent.GetCurrency(), + CustomCurrencyAmount: run.Totals.Total, + ResolvedCostBasis: charge.State.ResolvedCostBasis, + FiatCurrency: fiatOverage.Currency, + FiatAmount: fiatOverage.Amount, + }) + if err != nil { + return fmt.Errorf("creating custom currency overage detail: %w", err) + } + + stdLine.DetailedLines = stdLine.DetailedLinesWithIDReuse(billing.DetailedLines{detailedLine}) + stdLine.Totals = stdLine.DetailedLines.SumTotals().RoundToPrecision(invoiceCurrency) + + if !stdLine.Totals.Total.Equal(fiatOverage.Amount) { + return fmt.Errorf( + "custom currency charge[%s] mapped overage total mismatch [line_id=%s run_id=%s line_total=%s overage_total=%s]", + charge.ID, + stdLine.ID, + run.ID.ID, + stdLine.Totals.Total.String(), + fiatOverage.Amount.String(), + ) + } + + return nil +} + func mapUsageBasedDetailedLines( stdLine *billing.StandardLine, run usagebased.RealizationRun, diff --git a/openmeter/billing/charges/usagebased/service/linemapper_test.go b/openmeter/billing/charges/usagebased/service/linemapper_test.go index e864458f3f..f8eed5cb31 100644 --- a/openmeter/billing/charges/usagebased/service/linemapper_test.go +++ b/openmeter/billing/charges/usagebased/service/linemapper_test.go @@ -10,11 +10,15 @@ import ( "github.com/stretchr/testify/require" "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditpurchase" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" "github.com/openmeterio/openmeter/openmeter/billing/models/totals" + "github.com/openmeterio/openmeter/openmeter/currencies" "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/models" @@ -49,6 +53,7 @@ func TestPopulateUsageBasedStandardLineFromRunProjectsDetailsAndCredits(t *testi Namespace: line.Namespace, ID: "run-id", }, + Type: usagebased.RealizationRunTypeFinalRealization, StoredAtLT: period.To, ServicePeriodTo: period.To, MeteredQuantity: alpacadecimal.NewFromInt(20), @@ -98,8 +103,10 @@ func TestPopulateUsageBasedStandardLineFromRunProjectsDetailsAndCredits(t *testi } err := populateStandardLineFromRun(line, populateStandardLineFromRunInput{ - Run: run, - Runs: usagebased.RealizationRuns{priorRun, run}, + Charge: usagebased.Charge{ + Realizations: usagebased.RealizationRuns{priorRun, run}, + }, + Run: run, }) require.NoError(t, err) @@ -140,6 +147,7 @@ func TestPopulateUsageBasedStandardLineFromRunAppliesUsageDiscount(t *testing.T) Namespace: line.Namespace, ID: "run-id", }, + Type: usagebased.RealizationRunTypeFinalRealization, StoredAtLT: period.To, ServicePeriodTo: period.To, MeteredQuantity: alpacadecimal.NewFromInt(20), @@ -167,8 +175,10 @@ func TestPopulateUsageBasedStandardLineFromRunAppliesUsageDiscount(t *testing.T) } err := populateStandardLineFromRun(line, populateStandardLineFromRunInput{ - Run: run, - Runs: usagebased.RealizationRuns{priorRun, run}, + Charge: usagebased.Charge{ + Realizations: usagebased.RealizationRuns{priorRun, run}, + }, + Run: run, }) require.NoError(t, err) @@ -202,6 +212,7 @@ func TestPopulateUsageBasedStandardLineFromRunRequiresExpandedDetails(t *testing Namespace: line.Namespace, ID: "run-id", }, + Type: usagebased.RealizationRunTypeFinalRealization, StoredAtLT: period.To, ServicePeriodTo: period.To, MeteredQuantity: alpacadecimal.NewFromInt(20), @@ -213,12 +224,136 @@ func TestPopulateUsageBasedStandardLineFromRunRequiresExpandedDetails(t *testing } err := populateStandardLineFromRun(line, populateStandardLineFromRunInput{ - Run: run, - Runs: usagebased.RealizationRuns{run}, + Charge: usagebased.Charge{ + Realizations: usagebased.RealizationRuns{run}, + }, + Run: run, }) require.ErrorContains(t, err, "detailed lines must be expanded") } +func TestPopulateUsageBasedStandardLineFromCustomCurrencyRunCreatesFiatOverage(t *testing.T) { + period := timeutil.ClosedPeriod{ + From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + } + + customCurrency, err := currencyx.NewCurrencyBuilder(currencyx.CurrencyTypeCustom). + WithCode("TOKENS"). + WithName("Tokens"). + WithPrecision(4). + Build() + require.NoError(t, err) + + fiatCurrency, err := currencyx.NewFiatCurrency("USD") + require.NoError(t, err) + + costBasisIntent := costbasis.NewIntent(costbasis.ManualIntent{ + FiatCurrency: fiatCurrency, + Rate: alpacadecimal.NewFromInt(2), + }) + charge := usagebased.Charge{ + ChargeBase: usagebased.ChargeBase{ + ManagedResource: meta.ManagedResource{ + NamespacedModel: models.NamespacedModel{ + Namespace: "namespace", + }, + ID: "charge-id", + }, + Intent: usagebased.Intent{ + Intent: meta.Intent{ + Currency: currencies.Currency{ + Currency: customCurrency, + }, + }, + SettlementMode: productcatalog.CreditThenInvoiceSettlementMode, + CostBasis: &costBasisIntent, + }.AsOverridableIntent(), + State: usagebased.State{ + ResolvedCostBasis: &costbasis.State{ + CostBasis: alpacadecimal.NewFromInt(2), + ResolvedAt: period.From, + }, + }, + }, + } + + line := newUsageBasedStandardLineForTest(period) + line.Annotations = models.Annotations{ + billing.AnnotationKeyReason: lo.ToPtr(billing.AnnotationValueReasonOveragePlaceholder), + } + line.UsageBased.UnitConfig = &productcatalog.UnitConfig{ + Operation: productcatalog.UnitConfigOperationDivide, + ConversionFactor: alpacadecimal.NewFromInt(100), + } + line.RateCardDiscounts = billing.Discounts{ + Usage: &billing.UsageDiscount{}, + } + line.Discounts = billing.StandardLineDiscounts{ + Usage: billing.UsageLineDiscountsManaged{{}}, + } + line.CreditsApplied = billing.CreditsApplied{{}} + line.DetailedLines = billing.DetailedLines{ + { + DetailedLineBase: billing.DetailedLineBase{ + Base: stddetailedline.Base{ + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ + ID: "existing-overage-id", + Namespace: line.Namespace, + Name: "existing overage", + }), + ChildUniqueReferenceID: creditpurchase.CreditPurchaseChildUniqueReferenceID, + }, + }, + }, + } + + run := usagebased.RealizationRun{ + RealizationRunBase: usagebased.RealizationRunBase{ + ID: usagebased.RealizationRunID{ + Namespace: line.Namespace, + ID: "run-id", + }, + Type: usagebased.RealizationRunTypeFinalRealization, + StoredAtLT: period.To, + Totals: totals.Totals{ + Total: alpacadecimal.NewFromInt(3), + }, + }, + } + charge.Realizations = usagebased.RealizationRuns{run} + + err = populateStandardLineFromRun(line, populateStandardLineFromRunInput{ + Charge: charge, + Run: run, + }) + require.NoError(t, err) + + require.Equal(t, productcatalog.FlatPriceType, line.UsageBased.Price.Type()) + require.Equal(t, "feature", line.UsageBased.FeatureKey) + require.Nil(t, line.UsageBased.UnitConfig) + require.Equal(t, float64(1), lo.FromPtr(line.UsageBased.MeteredQuantity).InexactFloat64()) + require.Equal(t, float64(1), lo.FromPtr(line.UsageBased.Quantity).InexactFloat64()) + require.Equal(t, float64(0), lo.FromPtr(line.UsageBased.MeteredPreLinePeriodQuantity).InexactFloat64()) + require.Equal(t, float64(0), lo.FromPtr(line.UsageBased.PreLinePeriodQuantity).InexactFloat64()) + require.True(t, line.RateCardDiscounts.IsEmpty()) + require.Empty(t, line.Discounts.Usage) + require.Empty(t, line.CreditsApplied) + require.Equal(t, period.To.Add(usagebased.InternalCollectionPeriod), *line.OverrideCollectionPeriodEnd) + + require.Len(t, line.DetailedLines, 1) + require.Equal(t, "existing-overage-id", line.DetailedLines[0].ID) + require.NoError(t, line.Validate()) + + err = populateStandardLineFromRun(line, populateStandardLineFromRunInput{ + Charge: charge, + Run: run, + }) + require.NoError(t, err) + require.Len(t, line.DetailedLines, 1) + require.Equal(t, "existing-overage-id", line.DetailedLines[0].ID) +} + func newUsageBasedStandardLineForTest(period timeutil.ClosedPeriod) *billing.StandardLine { price := productcatalog.NewPriceFrom(productcatalog.UnitPrice{ Amount: alpacadecimal.NewFromInt(1), diff --git a/openmeter/billing/charges/usagebased/service/run/create.go b/openmeter/billing/charges/usagebased/service/run/create.go index 822c81fb4f..fffd42752a 100644 --- a/openmeter/billing/charges/usagebased/service/run/create.go +++ b/openmeter/billing/charges/usagebased/service/run/create.go @@ -11,6 +11,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" usagebasedrating "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/service/rating" + "github.com/openmeterio/openmeter/openmeter/productcatalog" "github.com/openmeterio/openmeter/openmeter/productcatalog/feature" "github.com/openmeterio/openmeter/pkg/currencyx" "github.com/openmeterio/openmeter/pkg/models" @@ -25,11 +26,7 @@ type CreateRatedRunInput struct { ServicePeriodTo time.Time LineID *string InvoiceID *string - CreditAllocation CreditAllocationMode CurrencyCalculator currencyx.Currency - // NoFiatTransactionRequired is set if either there's no fiat-based - // settlement is expected (credits_only) or if the run totals are zero. - NoFiatTransactionRequired bool } func (i CreateRatedRunInput) Validate() error { @@ -78,10 +75,6 @@ func (i CreateRatedRunInput) Validate() error { return fmt.Errorf("invoice id if set, must be non-empty") } - if err := i.CreditAllocation.Validate(); err != nil { - return fmt.Errorf("credit allocation: %w", err) - } - if i.CurrencyCalculator == nil { return fmt.Errorf("currency calculator is required") } @@ -155,7 +148,9 @@ func (s *Service) CreateRatedRun(ctx context.Context, in CreateRatedRunInput) (C "charge_id": in.Charge.ID, }) } - noFiatTransactionRequired := in.NoFiatTransactionRequired || runTotals.Total.IsZero() + + settlementMode := in.Charge.Intent.GetSettlementMode() + isCreditsOnlySettlementMode := settlementMode == productcatalog.CreditOnlySettlementMode updatedCharge, err := s.createNewRealizationRun(ctx, in.Charge, usagebased.CreateRealizationRunInput{ FeatureID: in.Charge.State.FeatureID, @@ -166,7 +161,7 @@ func (s *Service) CreateRatedRun(ctx context.Context, in CreateRatedRunInput) (C InvoiceID: in.InvoiceID, MeteredQuantity: ratingResult.Quantity, Totals: runTotals, - NoFiatTransactionRequired: noFiatTransactionRequired, + NoFiatTransactionRequired: isCreditsOnlySettlementMode, }) if err != nil { return CreateRatedRunResult{}, fmt.Errorf("create new realization run: %w", err) @@ -182,40 +177,46 @@ func (s *Service) CreateRatedRun(ctx context.Context, in CreateRatedRunInput) (C } currentRun.DetailedLines = mo.Some(ratingResult.DetailedLines) - if in.CreditAllocation != CreditAllocationNone { - allocationResult, err := s.allocate(ctx, allocateCreditRealizationsInput{ - Charge: updatedCharge, - Run: currentRun, - AllocateAt: in.ServicePeriodTo, - AmountToAllocate: runTotals.Total, - CurrencyCalculator: in.CurrencyCalculator, - Exact: in.CreditAllocation == CreditAllocationExact, - }) - if err != nil { - return CreateRatedRunResult{}, fmt.Errorf("allocate credits: %w", err) - } + allocationResult, err := s.allocate(ctx, allocateCreditRealizationsInput{ + Charge: updatedCharge, + Run: currentRun, + AllocateAt: in.ServicePeriodTo, + AmountToAllocate: runTotals.Total, + CurrencyCalculator: in.CurrencyCalculator, + Exact: isCreditsOnlySettlementMode, + }) + if err != nil { + return CreateRatedRunResult{}, fmt.Errorf("allocate credits: %w", err) + } - currentRun.CreditsAllocated = allocationResult.Realizations - runTotals.CreditsTotal = runTotals.CreditsTotal.Add(allocationResult.Allocated) - runTotals.Total = in.CurrencyCalculator.RoundToPrecision(runTotals.Total.Sub(allocationResult.Allocated)) - noFiatTransactionRequired = in.NoFiatTransactionRequired || runTotals.Total.IsZero() + currentRun.CreditsAllocated = allocationResult.Realizations + runTotals.CreditsTotal = runTotals.CreditsTotal.Add(allocationResult.Allocated) + runTotals.Total = in.CurrencyCalculator.RoundToPrecision(runTotals.Total.Sub(allocationResult.Allocated)) - currentRunBase, err := s.adapter.UpdateRealizationRun(ctx, usagebased.UpdateRealizationRunInput{ - ID: currentRun.ID, - Totals: mo.Some(runTotals), - NoFiatTransactionRequired: mo.Some(noFiatTransactionRequired), - }) + // Let's calculate if the current run at this point requires a fiat transaction. + noFiatTransactionRequired := isCreditsOnlySettlementMode || runTotals.Total.IsZero() + if !noFiatTransactionRequired && updatedCharge.Intent.GetCurrency().IsCustom() { + // For credit then invoice, if the overage totals is zero due to rounding, we still don't require + // a fiat transaction. + fiatOverage, err := updatedCharge.ConvertCustomCurrencyOverageToFiat(runTotals) if err != nil { - return CreateRatedRunResult{}, fmt.Errorf("update realization run: %w", err) + return CreateRatedRunResult{}, fmt.Errorf("convert custom currency overage to fiat: %w", err) } - currentRun.RealizationRunBase = currentRunBase + noFiatTransactionRequired = fiatOverage.Amount.IsZero() + } - if err := updatedCharge.Realizations.SetRealizationRun(currentRun); err != nil { - return CreateRatedRunResult{}, fmt.Errorf("update realization run: %w", err) - } + currentRunBase, err := s.adapter.UpdateRealizationRun(ctx, usagebased.UpdateRealizationRunInput{ + ID: currentRun.ID, + Totals: mo.Some(runTotals), + NoFiatTransactionRequired: mo.Some(noFiatTransactionRequired), + }) + if err != nil { + return CreateRatedRunResult{}, fmt.Errorf("update realization run: %w", err) } + currentRun.RealizationRunBase = currentRunBase + if err := updatedCharge.Realizations.SetRealizationRun(currentRun); err != nil { return CreateRatedRunResult{}, fmt.Errorf("update realization run detailed lines: %w", err) } diff --git a/openmeter/billing/charges/usagebased/service/run/invoice.go b/openmeter/billing/charges/usagebased/service/run/invoice.go index da1a92dde2..37528dcfec 100644 --- a/openmeter/billing/charges/usagebased/service/run/invoice.go +++ b/openmeter/billing/charges/usagebased/service/run/invoice.go @@ -6,6 +6,7 @@ import ( "github.com/openmeterio/openmeter/openmeter/billing" "github.com/openmeterio/openmeter/openmeter/billing/charges/models/invoicedusage" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" ) @@ -78,21 +79,49 @@ func (s *Service) BookAccruedInvoiceUsage(ctx context.Context, in BookAccruedInv }, nil } - input := usagebased.OnInvoiceUsageAccruedInput{ - Charge: in.Charge, - Run: in.Run, - ServicePeriod: in.Line.Period, - BookedAt: in.Line.Period.To, - Amount: in.Line.Totals.Total, - } + var ledgerTransactionRef ledgertransaction.GroupReference + if in.Charge.Intent.GetCurrency().IsCustom() { + input := usagebased.OnCustomCurrencyOverageAccruedInput{ + Charge: in.Charge, + Run: in.Run, + } + if err := input.Validate(); err != nil { + return BookAccruedInvoiceUsageResult{}, fmt.Errorf("validate on custom currency overage accrued input: %w", err) + } - if err := input.Validate(); err != nil { - return BookAccruedInvoiceUsageResult{}, fmt.Errorf("validate on invoice usage accrued input: %w", err) - } + result, err := s.handler.OnCustomCurrencyOverageAccrued(ctx, input) + if err != nil { + return BookAccruedInvoiceUsageResult{}, fmt.Errorf("on usage-based custom currency overage accrued: %w", err) + } + if err := result.Validate(); err != nil { + return BookAccruedInvoiceUsageResult{}, fmt.Errorf("validate on custom currency overage accrued result: %w", err) + } + if !result.TotalFiatAmount.Equal(in.Line.Totals.Total) { + return BookAccruedInvoiceUsageResult{}, fmt.Errorf( + "custom currency overage booked fiat amount does not match line total: %s != %s", + result.TotalFiatAmount, + in.Line.Totals.Total, + ) + } - ledgerTransactionRef, err := s.handler.OnInvoiceUsageAccrued(ctx, input) - if err != nil { - return BookAccruedInvoiceUsageResult{}, fmt.Errorf("on usage-based invoice usage accrued: %w", err) + ledgerTransactionRef = result.TransactionGroup + } else { + input := usagebased.OnInvoiceUsageAccruedInput{ + Charge: in.Charge, + Run: in.Run, + ServicePeriod: in.Line.Period, + BookedAt: in.Line.Period.To, + Amount: in.Line.Totals.Total, + } + if err := input.Validate(); err != nil { + return BookAccruedInvoiceUsageResult{}, fmt.Errorf("validate on invoice usage accrued input: %w", err) + } + + var err error + ledgerTransactionRef, err = s.handler.OnInvoiceUsageAccrued(ctx, input) + if err != nil { + return BookAccruedInvoiceUsageResult{}, fmt.Errorf("on usage-based invoice usage accrued: %w", err) + } } if ledgerTransactionRef.TransactionGroupID == "" { diff --git a/openmeter/billing/charges/usagebased/service/run/payment.go b/openmeter/billing/charges/usagebased/service/run/payment.go index 6c689bf512..06b3f81342 100644 --- a/openmeter/billing/charges/usagebased/service/run/payment.go +++ b/openmeter/billing/charges/usagebased/service/run/payment.go @@ -60,7 +60,8 @@ func (s *Service) BookInvoicedPaymentAuthorized(ctx context.Context, in BookInvo return BookInvoicedPaymentAuthorizedResult{}, err } - if in.Run.NoFiatTransactionRequired { + fiatAmount := in.Line.Totals.Total + if in.Run.NoFiatTransactionRequired || fiatAmount.IsZero() { return BookInvoicedPaymentAuthorizedResult{ Run: in.Run, }, nil @@ -68,9 +69,10 @@ func (s *Service) BookInvoicedPaymentAuthorized(ctx context.Context, in BookInvo eventAt := clock.Now() input := usagebased.OnPaymentAuthorizedInput{ - Charge: in.Charge, - Run: in.Run, - EventAt: eventAt, + Charge: in.Charge, + Run: in.Run, + EventAt: eventAt, + FiatAmount: fiatAmount, } if err := input.Validate(); err != nil { return BookInvoicedPaymentAuthorizedResult{}, fmt.Errorf("validate on payment authorized input: %w", err) @@ -87,7 +89,7 @@ func (s *Service) BookInvoicedPaymentAuthorized(ctx context.Context, in BookInvo InvoiceID: in.Invoice.ID, Base: payment.Base{ ServicePeriod: in.Line.Period, - FiatAmount: in.Line.Totals.Total, + FiatAmount: fiatAmount, Authorized: &ledgertransaction.TimedGroupReference{ GroupReference: ledgertransaction.GroupReference{ TransactionGroupID: ledgerTransactionRef.TransactionGroupID, @@ -170,7 +172,8 @@ func (s *Service) SettleInvoicedPayment(ctx context.Context, in SettleInvoicedPa return SettleInvoicedPaymentResult{}, err } - if in.Run.NoFiatTransactionRequired { + fiatAmount := in.Line.Totals.Total + if in.Run.NoFiatTransactionRequired || fiatAmount.IsZero() { return SettleInvoicedPaymentResult{ Run: in.Run, }, nil @@ -178,9 +181,10 @@ func (s *Service) SettleInvoicedPayment(ctx context.Context, in SettleInvoicedPa eventAt := clock.Now() input := usagebased.OnPaymentSettledInput{ - Charge: in.Charge, - Run: in.Run, - EventAt: eventAt, + Charge: in.Charge, + Run: in.Run, + EventAt: eventAt, + FiatAmount: fiatAmount, } if err := input.Validate(); err != nil { return SettleInvoicedPaymentResult{}, fmt.Errorf("validate on payment settled input: %w", err) diff --git a/openmeter/billing/charges/usagebased/service/run/payment_test.go b/openmeter/billing/charges/usagebased/service/run/payment_test.go index 453273752e..57b598cc0a 100644 --- a/openmeter/billing/charges/usagebased/service/run/payment_test.go +++ b/openmeter/billing/charges/usagebased/service/run/payment_test.go @@ -58,6 +58,16 @@ func TestBookInvoicedPaymentAuthorizedInputValidate(t *testing.T) { }) } +func TestBookInvoicedPaymentAuthorizedSkipsZeroFiatAmount(t *testing.T) { + in := newBookPaymentAuthorizedInput(t) + in.Line.Totals = totals.Totals{} + service := Service{handler: &usagebased.UnimplementedHandler{}} + + result, err := service.BookInvoicedPaymentAuthorized(t.Context(), in) + require.NoError(t, err) + require.Nil(t, result.Payment) +} + func TestSettleInvoicedPaymentInputValidate(t *testing.T) { valid := newSettlePaymentInput(t) require.NoError(t, valid.Validate()) @@ -94,6 +104,16 @@ func TestSettleInvoicedPaymentInputValidate(t *testing.T) { }) } +func TestSettleInvoicedPaymentSkipsZeroFiatAmount(t *testing.T) { + in := newSettlePaymentInput(t) + in.Line.Totals = totals.Totals{} + service := Service{handler: &usagebased.UnimplementedHandler{}} + + result, err := service.SettleInvoicedPayment(t.Context(), in) + require.NoError(t, err) + require.Nil(t, result.Payment) +} + func newBookPaymentAuthorizedInput(t testing.TB) BookInvoicedPaymentAuthorizedInput { t.Helper() diff --git a/openmeter/billing/charges/usagebased/service/run/service.go b/openmeter/billing/charges/usagebased/service/run/service.go index 81ad3646c8..a64d73ca18 100644 --- a/openmeter/billing/charges/usagebased/service/run/service.go +++ b/openmeter/billing/charges/usagebased/service/run/service.go @@ -2,12 +2,10 @@ package run import ( "errors" - "fmt" "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage" "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased" usagebasedrating "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/service/rating" - "github.com/openmeterio/openmeter/pkg/models" ) // Service owns usage-based realization run mechanics: rating snapshots, @@ -62,24 +60,3 @@ func New(config Config) (*Service, error) { lineage: config.Lineage, }, nil } - -type CreditAllocationMode string - -const ( - // CreditAllocationNone means no credits are allocated to the run. - CreditAllocationNone CreditAllocationMode = "none" - // CreditAllocationExact means the total's exact amount of credits is allocated to the run. - CreditAllocationExact CreditAllocationMode = "exact" - // CreditAllocationAvailable means credits should be allocated up to the total's amount of credits, but it's not an - // error if the total's amount of credits is not available. - CreditAllocationAvailable CreditAllocationMode = "available" -) - -func (m CreditAllocationMode) Validate() error { - switch m { - case CreditAllocationNone, CreditAllocationExact, CreditAllocationAvailable: - return nil - default: - return models.NewGenericValidationError(fmt.Errorf("invalid credit allocation mode: %s", m)) - } -} diff --git a/openmeter/billing/consts.go b/openmeter/billing/consts.go index 302e7c7635..09ae1c849f 100644 --- a/openmeter/billing/consts.go +++ b/openmeter/billing/consts.go @@ -15,4 +15,9 @@ const ( const ( // AnnotationValueReasonCreditPurchase indicates the line is for a credit purchase. AnnotationValueReasonCreditPurchase = "credit-purchase" + // AnnotationValueReasonOveragePlaceholder indicates the line reserves invoice scheduling + // for a custom-currency overage whose fiat amount is not materialized yet. + AnnotationValueReasonOveragePlaceholder = "overage-placeholder" + // AnnotationValueReasonOverage indicates the line contains usage not covered by credits. + AnnotationValueReasonOverage = "overage" ) diff --git a/openmeter/billing/service/invoicecalc/collectionat.go b/openmeter/billing/service/invoicecalc/collectionat.go index f414a40de9..3e186454d4 100644 --- a/openmeter/billing/service/invoicecalc/collectionat.go +++ b/openmeter/billing/service/invoicecalc/collectionat.go @@ -77,8 +77,12 @@ func StandardInvoiceCollectionAt(i *billing.StandardInvoice) error { return errors.New("lines must be expanded") } + // Invoice collection waits only for metered lines or if a line defines an explicit deadline. + // Flat-fee lines normally do not participate, but custom-currency usage overage is represented + // on the invoice as a flat fiat line and retains the usage charge's late-event collection deadline. collectableLines := lo.Filter(i.Lines.OrEmpty(), func(line *billing.StandardLine, _ int) bool { - return line.DeletedAt == nil && line.DependsOnMeteredQuantity() + return line.DeletedAt == nil && + (line.DependsOnMeteredQuantity() || line.OverrideCollectionPeriodEnd != nil) }) if len(collectableLines) == 0 { diff --git a/openmeter/billing/service/invoicecalc/collectionat_test.go b/openmeter/billing/service/invoicecalc/collectionat_test.go index 0d9f8785da..52b50f48e1 100644 --- a/openmeter/billing/service/invoicecalc/collectionat_test.go +++ b/openmeter/billing/service/invoicecalc/collectionat_test.go @@ -194,6 +194,9 @@ func TestGatheringInvoiceCollectionAt(t *testing.T) { } func TestStandardInvoiceCollectionAt(t *testing.T) { + flatFeeLineWithOverride := newFlatFeeStandardLine(t, "2025-06-20T12:00:00Z") + flatFeeLineWithOverride.OverrideCollectionPeriodEnd = lo.ToPtr(mustTime(t, "2025-06-20T12:05:00Z")) + tests := []struct { name string invoice billing.StandardInvoice @@ -220,6 +223,15 @@ func TestStandardInvoiceCollectionAt(t *testing.T) { ), want: mustTime(t, "2025-06-15T13:00:00Z"), }, + { + name: "uses an explicit collection period end for a flat fee line", + invoice: newStandardInvoice( + mustTime(t, "2025-06-10T00:00:00Z"), + datetime.MustParseDuration(t, "PT1H"), + flatFeeLineWithOverride, + ), + want: mustTime(t, "2025-06-20T12:05:00Z"), + }, { name: "ignores deleted lines when resolving latest invoice at", invoice: newStandardInvoice( diff --git a/openmeter/ledger/chargeadapter/usagebased.go b/openmeter/ledger/chargeadapter/usagebased.go index 33c344da1d..6d86e9724d 100644 --- a/openmeter/ledger/chargeadapter/usagebased.go +++ b/openmeter/ledger/chargeadapter/usagebased.go @@ -111,6 +111,7 @@ func (h *usageBasedHandler) OnPaymentAuthorized(ctx context.Context, input usage intent := input.Charge.Intent if intent.GetCurrency().IsCustom() { + // TODO[implement]: FiatAmount contains the amount paid in the fiat currency. return ledgertransaction.GroupReference{}, fmt.Errorf("usage based charge with custom currency: %w", meta.ErrCustomCurrencyNotSupported) } @@ -175,6 +176,15 @@ func (h *usageBasedHandler) OnPaymentAuthorized(ctx context.Context, input usage }, nil } +func (h *usageBasedHandler) OnCustomCurrencyOverageAccrued(ctx context.Context, input usagebased.OnCustomCurrencyOverageAccruedInput) (usagebased.OnCustomCurrencyOverageAccruedResult, error) { + if err := input.Validate(); err != nil { + return usagebased.OnCustomCurrencyOverageAccruedResult{}, err + } + + // TODO[implement]: Book the fiat overage in the customer's outstanding account. + return usagebased.OnCustomCurrencyOverageAccruedResult{}, fmt.Errorf("implement OnCustomCurrencyOverageAccrued: %w", meta.ErrCustomCurrencyNotSupported) +} + func (h *usageBasedHandler) OnPaymentSettled(ctx context.Context, input usagebased.OnPaymentSettledInput) (ledgertransaction.GroupReference, error) { if err := input.Validate(); err != nil { return ledgertransaction.GroupReference{}, err @@ -183,6 +193,7 @@ func (h *usageBasedHandler) OnPaymentSettled(ctx context.Context, input usagebas intent := input.Charge.Intent if input.Charge.Intent.GetCurrency().IsCustom() { + // TODO[implement]: FiatAmount contains the amount paid in the fiat currency. return ledgertransaction.GroupReference{}, fmt.Errorf("usage based charge with custom currency: %w", meta.ErrCustomCurrencyNotSupported) } diff --git a/openmeter/ledger/chargeadapter/usagebased_test.go b/openmeter/ledger/chargeadapter/usagebased_test.go index 3236c39961..548adb0825 100644 --- a/openmeter/ledger/chargeadapter/usagebased_test.go +++ b/openmeter/ledger/chargeadapter/usagebased_test.go @@ -419,9 +419,10 @@ func TestOnUsageBasedPaymentAuthorized(t *testing.T) { defer clock.UnFreeze() ref, err := env.handler.OnPaymentAuthorized(t.Context(), chargeusagebased.OnPaymentAuthorizedInput{ - Charge: charge, - Run: env.newRunWithInvoiceUsage("line-1", total), - EventAt: env.Now(), + Charge: charge, + Run: env.newRunWithInvoiceUsage("line-1", total), + EventAt: env.Now(), + FiatAmount: total, }) require.NoError(t, err) require.NotEmpty(t, ref.TransactionGroupID) @@ -440,15 +441,16 @@ func TestOnUsageBasedPaymentAuthorized(t *testing.T) { } }) - t.Run("zero invoice usage is a no-op", func(t *testing.T) { + t.Run("zero fiat amount is rejected", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnPaymentAuthorized(t.Context(), chargeusagebased.OnPaymentAuthorizedInput{ - Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), - Run: env.newRunWithInvoiceUsage("line-1", alpacadecimal.Zero), - EventAt: env.Now(), + Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), + Run: env.newRunWithInvoiceUsage("line-1", alpacadecimal.Zero), + EventAt: env.Now(), + FiatAmount: alpacadecimal.Zero, }) - require.NoError(t, err) + require.ErrorContains(t, err, "fiat amount must be positive") require.Empty(t, ref.TransactionGroupID) }) @@ -456,9 +458,10 @@ func TestOnUsageBasedPaymentAuthorized(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnPaymentAuthorized(t.Context(), chargeusagebased.OnPaymentAuthorizedInput{ - Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), - Run: env.newRunWithInvoiceUsage("line-1", alpacadecimal.NewFromInt(10)), - EventAt: time.Time{}, + Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), + Run: env.newRunWithInvoiceUsage("line-1", alpacadecimal.NewFromInt(10)), + EventAt: time.Time{}, + FiatAmount: alpacadecimal.NewFromInt(10), }) require.ErrorContains(t, err, "event at is required") require.Empty(t, ref.TransactionGroupID) @@ -484,9 +487,10 @@ func TestOnUsageBasedPaymentSettled(t *testing.T) { intent.InvoiceAt = env.Now().Add(-24 * time.Hour) }) _, err = env.handler.OnPaymentAuthorized(t.Context(), chargeusagebased.OnPaymentAuthorizedInput{ - Charge: authorizedCharge, - Run: env.newRunWithInvoiceUsage("line-1", total), - EventAt: env.Now(), + Charge: authorizedCharge, + Run: env.newRunWithInvoiceUsage("line-1", total), + EventAt: env.Now(), + FiatAmount: total, }) require.NoError(t, err) @@ -499,9 +503,10 @@ func TestOnUsageBasedPaymentSettled(t *testing.T) { defer clock.UnFreeze() ref, err := env.handler.OnPaymentSettled(t.Context(), chargeusagebased.OnPaymentSettledInput{ - Charge: settledCharge, - Run: env.newRunWithAuthorizedPayment("line-1", total), - EventAt: eventTime, + Charge: settledCharge, + Run: env.newRunWithAuthorizedPayment("line-1", total), + EventAt: eventTime, + FiatAmount: total, }) require.NoError(t, err) require.NotEmpty(t, ref.TransactionGroupID) @@ -516,15 +521,16 @@ func TestOnUsageBasedPaymentSettled(t *testing.T) { } }) - t.Run("zero invoice usage is a no-op", func(t *testing.T) { + t.Run("zero fiat amount is rejected", func(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnPaymentSettled(t.Context(), chargeusagebased.OnPaymentSettledInput{ - Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), - Run: env.newRunWithAuthorizedPaymentAndInvoiceUsage("line-1", alpacadecimal.NewFromInt(1), alpacadecimal.Zero), - EventAt: env.Now(), + Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), + Run: env.newRunWithAuthorizedPaymentAndInvoiceUsage("line-1", alpacadecimal.NewFromInt(1), alpacadecimal.Zero), + EventAt: env.Now(), + FiatAmount: alpacadecimal.Zero, }) - require.NoError(t, err) + require.ErrorContains(t, err, "fiat amount must be positive") require.Empty(t, ref.TransactionGroupID) }) @@ -532,9 +538,10 @@ func TestOnUsageBasedPaymentSettled(t *testing.T) { env := newUsageBasedHandlerTestEnv(t) ref, err := env.handler.OnPaymentSettled(t.Context(), chargeusagebased.OnPaymentSettledInput{ - Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), - Run: env.newRunWithAuthorizedPayment("line-1", alpacadecimal.NewFromInt(10)), - EventAt: time.Time{}, + Charge: env.newCharge(productcatalog.CreditThenInvoiceSettlementMode), + Run: env.newRunWithAuthorizedPayment("line-1", alpacadecimal.NewFromInt(10)), + EventAt: time.Time{}, + FiatAmount: alpacadecimal.NewFromInt(10), }) require.ErrorContains(t, err, "event at is required") require.Empty(t, ref.TransactionGroupID)