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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ For TypeSpec-specific coding constraints, update `api/spec/AGENTS.md` instead of
- Do not extract helper functions only to hide a couple of simple operations or short guard checks. If the helper would only wrap 2-4 lines and its name does not add meaningful domain or business intent, keep the code inline even when there is some duplication. Readers can inspect the function body to see what the code does; prefer function names that explain the domain reason for the call over names that merely restate the implementation steps. When you encounter a leftover pass-through wrapper that only calls another function without adding behavior, remove it and call the underlying function directly, even if it is outside the immediate change area.
- Do not hide non-trivial branching or domain translation inside local inline functions. If a closure performs type switching, validation, persistence mapping, or meaningful domain conversion, make it a named helper near the code that uses it so it is discoverable, testable, and grep-friendly. Reserve inline closures for tiny callbacks where the surrounding API requires a function literal and the logic is obvious at the call site.
- For `Validate() error` methods, prefer collecting all validation issues into `var errs []error` and returning `models.NewNillableGenericValidationError(errors.Join(errs...))` instead of returning on the first invalid field. Preserve field context with wrapped errors like `fmt.Errorf("field: %w", err)` and use plain `errors.New(...)` for simple local checks.
- Credit-purchase charges can carry custom ledger currency codes even though standard invoice and settlement currencies stay fiat-only. When persisting credit-purchase charge metadata through shared helpers such as `chargemeta.Create` or `chargemeta.Update`, use the credit-purchase-specific intent validator instead of falling back to generic `meta.Intent.Validate`, which intentionally rejects non-fiat codes.
- Do not introduce `context.Background()` or `context.TODO()` to sidestep missing context propagation in application code. Either propagate the caller's context through the full call path, or remove the unused `context.Context` parameter from the API if the operation is purely local and does not need cancellation, deadlines, or request-scoped values.
- Never use `panic` in non-test code paths. If a new failure mode is possible, change the function signature to return an error and propagate it explicitly.
- In production constructors and initialization, do not use `slog.Default()` as a fallback dependency. Require a `*slog.Logger` in config/provider inputs and inject it explicitly.
Expand Down
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
2 changes: 2 additions & 0 deletions openmeter/billing/charges/creditpurchase/adapter/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func (a *adapter) UpdateCharge(ctx context.Context, charge creditpurchase.Charge
Intent: charge.Intent.Intent,
IntentMutableFields: charge.Intent.IntentMutableFields.IntentMutableFields,
Status: metaStatus,
ValidateIntent: charge.Intent.Validate,
})
if err != nil {
return creditpurchase.ChargeBase{}, err
Expand Down Expand Up @@ -85,6 +86,7 @@ func (a *adapter) CreateCharge(ctx context.Context, in creditpurchase.CreateChar
Intent: in.Intent.Intent,
IntentMutableFields: in.Intent.IntentMutableFields.IntentMutableFields,
Status: metaStatus,
ValidateIntent: in.Intent.Validate,
})
if err != nil {
return creditpurchase.Charge{}, err
Expand Down
28 changes: 26 additions & 2 deletions openmeter/billing/charges/creditpurchase/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package creditpurchase
import (
"errors"
"fmt"
"slices"
"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/ledgertransaction"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
Expand Down Expand Up @@ -217,8 +219,30 @@ func (i Intent) CalculateEffectiveAt() time.Time {
func (i Intent) Validate() error {
var errs []error

if err := i.Intent.Validate(); err != nil {
errs = append(errs, fmt.Errorf("intent meta: %w", err))
if !slices.Contains(billing.InvoiceLineManagedBy("").Values(), string(i.ManagedBy)) {
errs = append(errs, fmt.Errorf("intent meta: invalid managed by %s", i.ManagedBy))
}

if i.CustomerID == "" {
errs = append(errs, fmt.Errorf("intent meta: customer ID is required"))
}

if err := i.Currency.ValidateFormat(); err != nil {
errs = append(errs, fmt.Errorf("intent meta: currency: %w", err))
}

if err := i.TaxConfig.Validate(); err != nil {
errs = append(errs, fmt.Errorf("intent meta: tax config: %w", err))
}

if i.Subscription != nil {
if err := i.Subscription.Validate(); err != nil {
errs = append(errs, fmt.Errorf("intent meta: subscription: %w", err))
}
}

if i.UniqueReferenceID != nil && *i.UniqueReferenceID == "" {
errs = append(errs, fmt.Errorf("intent meta: unique reference ID cannot be empty"))
}

if err := i.IntentMutableFields.Validate(); err != nil {
Expand Down
17 changes: 17 additions & 0 deletions openmeter/billing/charges/creditpurchase/charge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"github.com/stretchr/testify/require"

"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/timeutil"
)

Expand Down Expand Up @@ -71,3 +73,18 @@ func TestFeatureFiltersValidateAsFeatureFilter(t *testing.T) {
require.Error(t, FeatureFilters([]string{""}).ValidateAsFeatureFilter())
})
}

func TestListFundedCreditActivitiesInputValidateAllowsCustomCurrency(t *testing.T) {
currency := currencyx.Code("CREDITS")

input := ListFundedCreditActivitiesInput{
Customer: customer.CustomerID{
Namespace: "ns",
ID: "customer-id",
},
Limit: 1,
Currency: &currency,
}

require.NoError(t, input.Validate())
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (i ListFundedCreditActivitiesInput) Validate() error {
}

if i.Currency != nil {
if err := i.Currency.Validate(); err != nil {
if err := i.Currency.ValidateFormat(); err != nil {
errs = append(errs, fmt.Errorf("currency: %w", err))
}
}
Expand Down
4 changes: 3 additions & 1 deletion openmeter/billing/charges/creditpurchase/settlement.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ type GenericSettlement struct {
func (s GenericSettlement) Validate() error {
var errs []error

if err := s.Currency.Validate(); err != nil {
if err := s.Currency.ValidateFormat(); err != nil {
errs = append(errs, fmt.Errorf("settlement currency: %w", err))
} else if !s.Currency.IsKnownFiat() {
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 @@ -166,7 +166,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
4 changes: 2 additions & 2 deletions openmeter/billing/charges/lineage/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func (i BackfillAdvanceLineageSegmentsInput) Validate() error {
if i.CustomerID == "" {
errs = append(errs, errors.New("customer id is required"))
}
if err := i.Currency.Validate(); err != nil {
if err := i.Currency.ValidateFormat(); err != nil {
errs = append(errs, fmt.Errorf("currency: %w", err))
}
if !i.Amount.IsPositive() {
Expand Down Expand Up @@ -138,7 +138,7 @@ func (i LoadLineagesByCustomerInput) Validate() error {
if i.CustomerID == "" {
errs = append(errs, errors.New("customer id is required"))
}
if err := i.Currency.Validate(); err != nil {
if err := i.Currency.ValidateFormat(); err != nil {
errs = append(errs, fmt.Errorf("currency: %w", err))
}

Expand Down
24 changes: 17 additions & 7 deletions 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 Expand Up @@ -117,8 +117,9 @@ type CreateInput struct {
Intent meta.Intent
IntentMutableFields meta.IntentMutableFields

Status meta.ChargeStatus
AdvanceAfter *time.Time
Status meta.ChargeStatus
AdvanceAfter *time.Time
ValidateIntent func() error
}

type Creator[T any] interface {
Expand Down Expand Up @@ -170,7 +171,11 @@ func Create[T Creator[T]](creator Creator[T], in CreateInput) (T, error) {
in.IntentMutableFields = in.IntentMutableFields.Normalized()
in.AdvanceAfter = meta.NormalizeOptionalTimestamp(in.AdvanceAfter)

if err := in.Intent.Validate(); err != nil {
validateIntent := in.Intent.Validate
if in.ValidateIntent != nil {
validateIntent = in.ValidateIntent
}
if err := validateIntent(); err != nil {
var empty T
return empty, err
}
Expand Down Expand Up @@ -223,8 +228,9 @@ type UpdateInput struct {
Intent meta.Intent
IntentMutableFields meta.IntentMutableFields

Status meta.ChargeStatus
AdvanceAfter *time.Time
Status meta.ChargeStatus
AdvanceAfter *time.Time
ValidateIntent func() error
}

func Update[T Updater[T]](updater Updater[T], in UpdateInput) (T, error) {
Expand All @@ -236,7 +242,11 @@ func Update[T Updater[T]](updater Updater[T], in UpdateInput) (T, error) {
return empty, err
}

if err := in.Intent.Validate(); err != nil {
validateIntent := in.Intent.Validate
if in.ValidateIntent != nil {
validateIntent = in.ValidateIntent
}
if err := validateIntent(); err != nil {
var empty T
return empty, err
}
Expand Down
33 changes: 21 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,29 @@ 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{
ManagedBy: billing.ManuallyManagedLine,
CustomerID: cust.ID,
Currency: USD,
},
IntentMutableFields: creditpurchase.IntentMutableFields{
IntentMutableFields: meta.IntentMutableFields{
Name: "Credit Purchase",
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 +159,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
11 changes: 6 additions & 5 deletions openmeter/billing/charges/service/lineage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,14 +238,15 @@ func (s *CreditRealizationLineageTestSuite) TestBackfillAdvanceLineageSegmentsFi

ns := s.GetUniqueNamespace("charges-service-lineage-feature-backfill")
customerID := ulid.Make().String()
apiLineageID := s.createAdvanceLineageForBackfill(ctx, ns, customerID, []string{"api-calls"}, alpacadecimal.NewFromInt(40))
storageLineageID := s.createAdvanceLineageForBackfill(ctx, ns, customerID, []string{"storage"}, alpacadecimal.NewFromInt(30))
customCurrency := currencyx.Code("CREDITS")
apiLineageID := s.createAdvanceLineageForBackfill(ctx, ns, customerID, customCurrency, []string{"api-calls"}, alpacadecimal.NewFromInt(40))
storageLineageID := s.createAdvanceLineageForBackfill(ctx, ns, customerID, customCurrency, []string{"storage"}, alpacadecimal.NewFromInt(30))
backingTransactionGroupID := ulid.Make().String()

err = service.BackfillAdvanceLineageSegments(ctx, lineage.BackfillAdvanceLineageSegmentsInput{
Namespace: ns,
CustomerID: customerID,
Currency: currencyx.Code(currency.USD),
Currency: customCurrency,
Amount: alpacadecimal.NewFromInt(50),
BackingTransactionGroupID: backingTransactionGroupID,
FeatureFilters: []string{"api-calls"},
Expand Down Expand Up @@ -439,7 +440,7 @@ func (s *CreditRealizationLineageTestSuite) mustListLineages(namespace string, r
return out
}

func (s *CreditRealizationLineageTestSuite) createAdvanceLineageForBackfill(ctx context.Context, namespace string, customerID string, advanceFeatures []string, amount alpacadecimal.Decimal) string {
func (s *CreditRealizationLineageTestSuite) createAdvanceLineageForBackfill(ctx context.Context, namespace string, customerID string, currencyCode currencyx.Code, advanceFeatures []string, amount alpacadecimal.Decimal) string {
s.T().Helper()

chargeID := ulid.Make().String()
Expand All @@ -458,7 +459,7 @@ func (s *CreditRealizationLineageTestSuite) createAdvanceLineageForBackfill(ctx
SetChargeID(chargeID).
SetRootRealizationID(ulid.Make().String()).
SetCustomerID(customerID).
SetCurrency(currencyx.Code(currency.USD)).
SetCurrency(currencyCode).
SetOriginKind(creditrealization.LineageOriginKindAdvance).
SetAdvanceFeatures(pq.StringArray(advanceFeatures)).
Save(ctx)
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 @@ -110,6 +110,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
Loading
Loading