Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .agents/skills/billing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ openmeter/billing/worker/asyncadvance/ # Event-driven advance handler
test/billing/ # Shared test suite base (BaseSuite, SubscriptionMixin)
```

## Currency Boundary

Billing invoices, invoice lines, split-line groups, and standard detailed lines use fiat invoice currencies only. Do not widen billing invoice currency columns or treat custom/non-fiat credit units as invoice currency. Convert or materialize custom-unit economics before creating billing invoice artifacts; billing should only persist the fiat money-of-account as `currency`.

## Core Type Patterns

### Union Types (Invoice, InvoiceLine)
Expand Down
40 changes: 40 additions & 0 deletions .agents/skills/currencyx/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
name: currencyx
description: Work on OpenMeter currency primitives in pkg/currencyx for fiat and custom currency codes, rounding rules, calculators, allocation precision, fiat/custom boundaries, and callers in billing, charges, ledger, product catalog, subscriptions, or currency registry code.
---

# Currencyx

Use this skill when changes touch `pkg/currencyx` or any caller that depends on currency code shape, fiat/custom classification, calculator behavior, rounding, allocation, or invoice/ledger currency boundaries.

Also load the package skill for each touched area: `billing`, `charges`, `ledger`, `subscription`, `api`, `ent`, `db-migration`, and `test`.

## Boundary Model

- **Currency code**: durable identifier. ISO fiat codes and namespace-scoped custom codes share the same structural validation in `currencyx.Code`.
- **Currency interface**: shared behavior contract for modules that need `CurrencyCode`, `CurrencyType`, `Rounding`, `Calculator`, and `Validate`.
- **Fiat calculator**: uses the ISO definition for precision and preserves existing fiat rounding behavior.
- **Custom calculator**: uses configured custom rounding; default is whole-unit half-even bankers rounding.
- **Registry boundary**: owns custom currency definition, activation/archive rules, fiat-code collision checks, and future persisted rounding configuration.
- **Finance boundary**: snapshots fiat basis and applies fiat rounding when custom units become fiat amounts.
- **Ledger boundary**: records durable currency codes and balanced single-currency legs. Use calculator rounding only before posting when the upstream domain owns normalization.
- **Invoice boundary**: invoice currency stays fiat. Custom units must be materialized to fiat before billing invoice artifacts.

## Process

1. Name the surface before editing: code validation, rounding, calculator, allocation, registry, ledger fact, fiat materialization, or invoice boundary.
2. Keep `pkg/currencyx` free of imports from `openmeter/...`; callers can implement `currencyx.Currency` to supply configured custom rounding.
3. Do not add `ValidateFiat`, `IsFiat`, or similar split helpers. Use `CurrencyType()` at the boundary that truly requires fiat or custom.
4. Preserve fiat behavior unless the request explicitly changes fiat money rounding.
5. For custom currencies, default missing rounding config to half-even bankers rounding.
6. Keep allocation deterministic: use precision for units and largest-remainder distribution; do not silently preserve extra fractional custom units after rounding rules exist.
7. Verify with focused `pkg/currencyx` tests and caller package tests for every touched boundary.

## Review Checks

- Custom and fiat currencies share the `currencyx.Currency` interface.
- `Calculator.RoundToPrecision` applies the effective rounding rule.
- Invalid rounding precision or mode fails validation.
- Billing/invoice code rejects custom invoice currency explicitly instead of relying on `currencyx` to reject it.
- Ledger code accepts structurally valid custom codes and keeps transaction groups balanced per currency.
- Tests cover banker ties, configured custom precision, fiat regression behavior, invalid rounding config, and allocation precision.
4 changes: 4 additions & 0 deletions .agents/skills/currencyx/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "Currencyx"
short_description: "Work on fiat and custom currency primitives"
default_prompt: "Use $currencyx to update OpenMeter currency code, rounding, calculator, or fiat/custom boundary behavior."
3 changes: 3 additions & 0 deletions openmeter/app/stripe/calculator.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ func NewStripeCalculator(currency currencyx.Code) (StripeCalculator, error) {
if err != nil {
return StripeCalculator{}, fmt.Errorf("failed to get stripe calculator: %w", err)
}
if calculator.CurrencyType() != currencyx.CurrencyTypeFiat {
return StripeCalculator{}, fmt.Errorf("stripe currency must be a known fiat currency: %s", currency)
}

return StripeCalculator{
calculator: calculator,
Expand Down
17 changes: 0 additions & 17 deletions openmeter/billing/charges/creditpurchase/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,23 +168,6 @@ func (i Intent) Validate() error {
errs = append(errs, fmt.Errorf("feature filters: %w", err))
}

switch i.Settlement.Type() {
case SettlementTypeInvoice:
settlement, err := i.Settlement.AsInvoiceSettlement()
if err != nil {
errs = append(errs, fmt.Errorf("settlement: %w", err))
} else if settlement.Currency != i.Currency {
errs = append(errs, fmt.Errorf("settlement currency %q must match credit currency %q", settlement.Currency, i.Currency))
}
case SettlementTypeExternal:
settlement, err := i.Settlement.AsExternalSettlement()
if err != nil {
errs = append(errs, fmt.Errorf("settlement: %w", err))
} else if settlement.Currency != i.Currency {
errs = append(errs, fmt.Errorf("settlement currency %q must match credit currency %q", settlement.Currency, i.Currency))
}
}

if i.EffectiveAt != nil {
return errors.New("effective at is not yet supported")
}
Expand Down
2 changes: 2 additions & 0 deletions openmeter/billing/charges/creditpurchase/settlement.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func (s GenericSettlement) Validate() error {

if err := s.Currency.Validate(); err != nil {
errs = append(errs, fmt.Errorf("settlement currency: %w", err))
} else if s.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
errs = append(errs, fmt.Errorf("settlement currency must be a known fiat currency"))
}

if !s.CostBasis.IsPositive() {
Expand Down
36 changes: 36 additions & 0 deletions openmeter/billing/charges/creditpurchase/settlement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,39 @@ func TestGenericSettlementValidateRequiresPositiveCostBasis(t *testing.T) {
})
}
}

func TestGenericSettlementValidateRequiresFiatCurrency(t *testing.T) {
for _, tc := range []struct {
name string
currency currencyx.Code
wantErr bool
}{
{
name: "fiat",
currency: currencyx.Code("USD"),
},
{
name: "custom",
currency: currencyx.Code("CREDITS"),
wantErr: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
settlement := GenericSettlement{
Currency: tc.currency,
CostBasis: alpacadecimal.NewFromFloat(0.5),
}

err := settlement.Validate()

if tc.wantErr {
require.Error(t, err)
require.ErrorContains(t, err, "settlement currency must be a known fiat currency")
require.True(t, models.IsGenericValidationError(err))
return
}

require.NoError(t, err)
})
}
}
2 changes: 1 addition & 1 deletion openmeter/billing/charges/flatfee/charge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func TestCalculateAmountAfterProration(t *testing.T) {

t.Run("invalid currency returns error", func(t *testing.T) {
intent := baseIntent()
intent.Currency = currencyx.Code("INVALID")
intent.Currency = currencyx.Code("BAD|CODE")

_, err := intent.CalculateAmountAfterProration()
require.Error(t, err)
Expand Down
2 changes: 1 addition & 1 deletion openmeter/billing/charges/models/chargemeta/mixin.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func (metaMixin) Fields() []ent.Field {
NotEmpty().
Immutable().
SchemaType(map[string]string{
dialect.Postgres: "varchar(3)",
dialect.Postgres: currencyx.PostgresCodeSchemaType,
}),

field.Enum("managed_by").
Expand Down
29 changes: 17 additions & 12 deletions openmeter/billing/charges/service/creditpurchase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"time"

"github.com/alpacahq/alpacadecimal"
"github.com/invopop/gobl/currency"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -97,9 +96,9 @@ func (s *CreditPurchaseTestSuite) TestPromotionalCreditPurchase() {
s.Equal(creditpurchase.StatusFinal, updatedCPCharge.Status)
}

func (s *CreditPurchaseTestSuite) TestCreditPurchaseRejectsMismatchedSettlementCurrency() {
func (s *CreditPurchaseTestSuite) TestCreditPurchaseRejectsCustomSettlementCurrency() {
ctx := context.Background()
ns := s.GetUniqueNamespace("charges-service-credit-purchase-mismatched-settlement-currency")
ns := s.GetUniqueNamespace("charges-service-credit-purchase-custom-settlement-currency")
s.ProvisionDefaultTaxCodes(ctx, ns)

cust := s.CreateTestCustomer(ns, "test-subject")
Expand All @@ -119,7 +118,7 @@ func (s *CreditPurchaseTestSuite) TestCreditPurchaseRejectsMismatchedSettlementC
settlement: creditpurchase.NewSettlement(creditpurchase.ExternalSettlement{
InitialStatus: creditpurchase.CreatedInitialPaymentSettlementStatus,
GenericSettlement: creditpurchase.GenericSettlement{
Currency: currencyx.Code(currency.EUR),
Currency: currencyx.Code("CREDITS"),
CostBasis: alpacadecimal.NewFromFloat(0.5),
},
}),
Expand All @@ -128,19 +127,25 @@ func (s *CreditPurchaseTestSuite) TestCreditPurchaseRejectsMismatchedSettlementC
name: "invoice",
settlement: creditpurchase.NewSettlement(creditpurchase.InvoiceSettlement{
GenericSettlement: creditpurchase.GenericSettlement{
Currency: currencyx.Code(currency.EUR),
Currency: currencyx.Code("CREDITS"),
CostBasis: alpacadecimal.NewFromFloat(0.5),
},
}),
},
} {
s.Run(tc.name, func() {
intent := CreateCreditPurchaseIntent(s.T(), createCreditPurchaseIntentInput{
customer: cust.GetID(),
currency: USD,
amount: alpacadecimal.NewFromFloat(100),
servicePeriod: servicePeriod,
settlement: tc.settlement,
intent := charges.NewChargeIntent(creditpurchase.Intent{
Intent: meta.Intent{
Name: "Credit Purchase",
ManagedBy: billing.ManuallyManagedLine,
CustomerID: cust.ID,
Currency: USD,
ServicePeriod: servicePeriod,
BillingPeriod: servicePeriod,
FullServicePeriod: servicePeriod,
},
CreditAmount: alpacadecimal.NewFromFloat(100),
Settlement: tc.settlement,
})

res, err := s.Charges.Create(ctx, charges.CreateInput{
Expand All @@ -150,7 +155,7 @@ func (s *CreditPurchaseTestSuite) TestCreditPurchaseRejectsMismatchedSettlementC
},
})
s.Error(err)
s.ErrorContains(err, `settlement currency "EUR" must match credit currency "USD"`)
s.ErrorContains(err, "settlement currency must be a known fiat currency")
s.Empty(res)
})
}
Expand Down
2 changes: 2 additions & 0 deletions openmeter/billing/creditgrant/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ func (i CreateInput) Validate() error {
if i.Purchase != nil {
if err := i.Purchase.Currency.Validate(); err != nil {
errs = append(errs, fmt.Errorf("purchase currency: %w", err))
} else if i.Purchase.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
errs = append(errs, errors.New("purchase currency must be a known fiat currency"))
}

if i.Purchase.PerUnitCostBasis != nil && !i.Purchase.PerUnitCostBasis.IsPositive() {
Expand Down
8 changes: 8 additions & 0 deletions openmeter/billing/gatheringinvoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ func (g GatheringInvoiceBase) Validate() error {

if err := g.Currency.Validate(); err != nil {
errs = append(errs, err)
} else if g.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
errs = append(errs, errors.New("currency must be a known fiat currency"))
}

if err := g.ServicePeriod.Validate(); err != nil {
Expand Down Expand Up @@ -440,6 +442,8 @@ func (i GatheringLineBase) Validate() error {

if err := i.Currency.Validate(); err != nil {
errs = append(errs, fmt.Errorf("currency: %w", err))
} else if i.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
errs = append(errs, errors.New("currency must be a known fiat currency"))
}

if !slices.Contains(InvoiceLineManagedBy("").Values(), string(i.ManagedBy)) {
Expand Down Expand Up @@ -786,6 +790,8 @@ func (c CreatePendingInvoiceLinesInput) Validate() error {

if err := c.Currency.Validate(); err != nil {
errs = append(errs, fmt.Errorf("currency: %w", err))
} else if c.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
errs = append(errs, errors.New("currency must be a known fiat currency"))
}

for id, line := range c.Lines {
Expand Down Expand Up @@ -837,6 +843,8 @@ func (c CreateGatheringInvoiceAdapterInput) Validate() error {

if err := c.Currency.Validate(); err != nil {
errs = append(errs, fmt.Errorf("currency: %w", err))
} else if c.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
errs = append(errs, errors.New("currency must be a known fiat currency"))
}

if c.Number == "" {
Expand Down
8 changes: 8 additions & 0 deletions openmeter/billing/invoicelinesplitgroup.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ func (i SplitLineGroupCreate) Validate() error {

if i.Currency == "" {
errs = append(errs, errors.New("currency is required"))
} else if err := i.Currency.Validate(); err != nil {
errs = append(errs, fmt.Errorf("currency: %w", err))
} else if i.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
errs = append(errs, errors.New("currency must be a known fiat currency"))
}

if i.UniqueReferenceID != nil && *i.UniqueReferenceID == "" {
Expand Down Expand Up @@ -146,6 +150,10 @@ func (i SplitLineGroup) Validate() error {

if i.Currency == "" {
errs = append(errs, errors.New("currency is required"))
} else if err := i.Currency.Validate(); err != nil {
errs = append(errs, fmt.Errorf("currency: %w", err))
} else if i.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
errs = append(errs, errors.New("currency must be a known fiat currency"))
}

return errors.Join(errs...)
Expand Down
50 changes: 47 additions & 3 deletions openmeter/billing/models/stddetailedline/mixin.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,55 @@ import (
)

type Mixin struct {
entutils.RecursiveMixin[mixinBase]
mixin.Schema

CurrencyPostgresSchemaType string
}

func (m Mixin) base() mixinBase {
return mixinBase{
currencyPostgresSchemaType: m.CurrencyPostgresSchemaType,
}
}

func (m Mixin) Fields() []ent.Field {
base := m.base()
fields := base.Fields()

for _, mixin := range base.Mixin() {
fields = append(fields, mixin.Fields()...)
}

return fields
}

func (m Mixin) Indexes() []ent.Index {
base := m.base()
indexes := base.Indexes()

for _, mixin := range base.Mixin() {
indexes = append(indexes, mixin.Indexes()...)
}

return indexes
}

func (m Mixin) Annotations() []schema.Annotation {
return m.base().Annotations()
}

type mixinBase struct {
mixin.Schema

currencyPostgresSchemaType string
}

func (m mixinBase) currencySchemaType() string {
if m.currencyPostgresSchemaType != "" {
return m.currencyPostgresSchemaType
}

return currencyx.PostgresCodeSchemaType
}

func (mixinBase) Mixin() []ent.Mixin {
Expand All @@ -33,14 +77,14 @@ func (mixinBase) Mixin() []ent.Mixin {
}
}

func (mixinBase) Fields() []ent.Field {
func (m mixinBase) Fields() []ent.Field {
return []ent.Field{
field.String("currency").
GoType(currencyx.Code("")).
NotEmpty().
Immutable().
SchemaType(map[string]string{
dialect.Postgres: "varchar(3)",
dialect.Postgres: m.currencySchemaType(),
}),

// TODO: remove these deprecated detailed-line tax fields after the parent-line
Expand Down
4 changes: 4 additions & 0 deletions openmeter/billing/seq.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ func (i SequenceGenerationInput) Validate() error {

if i.Currency == "" {
return fmt.Errorf("currency is required")
} else if err := i.Currency.Validate(); err != nil {
return fmt.Errorf("currency: %w", err)
} else if i.Currency.CurrencyType() != currencyx.CurrencyTypeFiat {
return fmt.Errorf("currency must be a known fiat currency")
}

if i.Namespace == "" {
Expand Down
Loading