Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/charges/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions openmeter/billing/charges/meta/customcurrency.go
Original file line number Diff line number Diff line change
@@ -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
}
94 changes: 94 additions & 0 deletions openmeter/billing/charges/meta/customcurrency_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
150 changes: 150 additions & 0 deletions openmeter/billing/charges/models/creditpurchase/detailedline.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading