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
3 changes: 3 additions & 0 deletions .agents/skills/charges/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ Use these conventions for lifecycle tests:
- 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
- for flat fee credit-only tests, use `mustAdvanceFlatFeeCharges(...)` helper — it filters the advance result to flat fee charges only
- for credit-purchase state-machine unit tests, use testify `mock.Mock` with `On(...).Run(...).Return(...).Once()` for expected handler callbacks so missing or unexpected calls fail; in service-suite tests, leave callbacks unset when validating that a flow fails before callbacks, because the shared `CreditPurchaseTestHandler` already errors if an unset callback is invoked
- keep custom-currency support disabled on default charge service instances so tests continue covering the production unsupported boundary; when a test needs it enabled, use a semantic suite helper that constructs and enables the service instead of repeating `SetEnableCustomCurrency` interface assertions at call sites
- when testing timestamp truncation, use sub-second fixtures and assert the persisted charge/run fields are second-aligned after create/advance
- `time.Time` fields on domain models are value typed; use `s.False(ts.IsZero())` instead of `s.NotNil(ts)` when asserting they are populated
- cover the temporary shrink/extend remap path as well; it synthesizes new intents and must normalize the replacement period ends before re-create
Expand Down Expand Up @@ -716,6 +717,8 @@ Credit purchase handler interface (`creditpurchase.Handler`):
When changing credit purchase charges:

- `creditpurchase.ChargeBase` stores base-row data: `ManagedResource`, `Intent`, `Status` (own `creditpurchase.Status` type); `State` exists but is an empty struct
- `creditpurchase.Intent.Currency` is the currency being purchased. For external and invoice settlements, `GenericSettlement.Currency` is the fiat settlement currency (`currencyx.FiatCode`), so it intentionally differs from the intent currency when purchasing a custom currency.
- Custom-currency gathering lines must use the settlement fiat currency and convert the purchased credit amount using the settlement cost basis. Keep the purchased amount and currency visible in the line description so downstream billing retains both sides of the purchase.
- `creditpurchase.Charge` embeds `ChargeBase` + `Realizations` — all lifecycle outcomes live in `Realizations`, not `State`
- `creditpurchase.Realizations` holds `CreditGrantRealization`, `ExternalPaymentSettlement`, and `InvoiceSettlement` (all loaded from edge tables)
- `CreditGrantRealization` is stored in its own `charge_credit_purchase_credit_grants` table, not on the base row
Expand Down
5 changes: 3 additions & 2 deletions openmeter/billing/charges/creditpurchase/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/openmeterio/openmeter/openmeter/currencies"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/pkg/clock"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/timeutil"
)
Expand Down Expand Up @@ -225,12 +226,12 @@ func (i Intent) Validate() error {
switch i.Settlement.Type() {
case SettlementTypeInvoice:
settlement, err := i.Settlement.AsInvoiceSettlement()
if err == nil && settlement.Currency != i.Currency.GetCode() {
if err == nil && !i.Currency.IsCustom() && currencyx.Code(settlement.Currency) != i.Currency.GetCode() {
errs = append(errs, fmt.Errorf("settlement currency %q must match credit currency %q", settlement.Currency, i.Currency.GetCode()))
}
case SettlementTypeExternal:
settlement, err := i.Settlement.AsExternalSettlement()
if err == nil && settlement.Currency != i.Currency.GetCode() {
if err == nil && !i.Currency.IsCustom() && currencyx.Code(settlement.Currency) != i.Currency.GetCode() {
errs = append(errs, fmt.Errorf("settlement currency %q must match credit currency %q", settlement.Currency, i.Currency.GetCode()))
}
}
Expand Down
7 changes: 3 additions & 4 deletions openmeter/billing/charges/creditpurchase/service/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
"github.com/openmeterio/openmeter/openmeter/productcatalog"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/framework/transaction"
"github.com/openmeterio/openmeter/pkg/models"
)
Expand All @@ -21,7 +20,7 @@ func (s *service) Create(ctx context.Context, input creditpurchase.CreateInput)
}

return transaction.Run(ctx, s.adapter, func(ctx context.Context) (creditpurchase.ChargeWithGatheringLine, error) {
if input.Intent.Currency.IsCustom() && input.Intent.Settlement.Type() != creditpurchase.SettlementTypePromotional {
if input.Intent.Currency.IsCustom() && !s.enableCustomCurrency.Load() {
return creditpurchase.ChargeWithGatheringLine{}, fmt.Errorf("custom currency %s is not supported for credit purchases: %w", input.Intent.Currency.GetCode(), meta.ErrCustomCurrencyNotSupported)
}

Expand Down Expand Up @@ -95,7 +94,7 @@ func (s *service) buildInvoiceCreditPurchaseGatheringLine(charge creditpurchase.

// Total cost = credit amount * cost basis (e.g., 100 credits * $0.5 = $50)
totalCost := intent.CreditAmount.Mul(invoiceSettlement.CostBasis)
invoiceCurrency := currencyx.FiatCode(invoiceSettlement.Currency)
invoiceCurrency := invoiceSettlement.Currency
calc, err := invoiceCurrency.AsFiatCurrency()
if err != nil {
return billing.GatheringLine{}, fmt.Errorf("creating currency calculator: %w", err)
Expand All @@ -119,7 +118,7 @@ func (s *service) buildInvoiceCreditPurchaseGatheringLine(charge creditpurchase.
GatheringLineBase: billing.GatheringLineBase{
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
Namespace: charge.Namespace,
Name: intent.Name,
Name: fmt.Sprintf("%s (%s %s credits)", intent.Name, intent.CreditAmount, intent.Currency.GetCode()),
Description: intent.Description,
}),
Metadata: intent.Metadata.Clone(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ func newExternalStateMachineTestChargeWithInput(t *testing.T, input externalStat
FeatureFilters: input.featureFilters,
Settlement: creditpurchase.NewSettlement(creditpurchase.ExternalSettlement{
GenericSettlement: creditpurchase.GenericSettlement{
Currency: currencyx.Code("USD"),
Currency: currencyx.FiatCode("USD"),
CostBasis: input.costBasis,
},
InitialStatus: input.initialStatus,
Expand Down
23 changes: 18 additions & 5 deletions openmeter/billing/charges/creditpurchase/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package service
import (
"errors"
"fmt"
"sync/atomic"
"testing"

"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
creditpurchaserealizations "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service/realizations"
Expand Down Expand Up @@ -63,9 +65,20 @@ func New(config Config) (creditpurchase.Service, error) {
}

type service struct {
adapter creditpurchase.Adapter
metaAdapter meta.Adapter
handler creditpurchase.Handler
lineage lineage.Service
realizations *creditpurchaserealizations.Service
adapter creditpurchase.Adapter
metaAdapter meta.Adapter
handler creditpurchase.Handler
lineage lineage.Service
realizations *creditpurchaserealizations.Service
enableCustomCurrency atomic.Bool
}

func (s *service) SetEnableCustomCurrency(t *testing.T, enabled bool) error {
if t == nil {
return errors.New("testing is nil")
}

t.Helper()
s.enableCustomCurrency.Store(enabled)
return nil
}
2 changes: 1 addition & 1 deletion openmeter/billing/charges/creditpurchase/settlement.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (s SettlementType) Values() []string {
}

type GenericSettlement struct {
Currency currencyx.Code `json:"currency"`
Currency currencyx.FiatCode `json:"currency"`
CostBasis alpacadecimal.Decimal `json:"costBasis"`
}

Expand Down
37 changes: 36 additions & 1 deletion openmeter/billing/charges/creditpurchase/settlement_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package creditpurchase

import (
"encoding/json"
"testing"

"github.com/alpacahq/alpacadecimal"
Expand Down Expand Up @@ -33,7 +34,7 @@ func TestGenericSettlementValidateRequiresPositiveCostBasis(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
settlement := GenericSettlement{
Currency: currencyx.Code("USD"),
Currency: currencyx.FiatCode("USD"),
CostBasis: tc.costBasis,
}

Expand All @@ -50,3 +51,37 @@ func TestGenericSettlementValidateRequiresPositiveCostBasis(t *testing.T) {
})
}
}

func TestGenericSettlementRejectsCustomCurrencyCode(t *testing.T) {
settlement := GenericSettlement{
Currency: currencyx.FiatCode("TOKENS"),
CostBasis: alpacadecimal.NewFromFloat(0.5),
}

err := settlement.Validate()

require.Error(t, err)
require.ErrorContains(t, err, "invalid fiat currency code: TOKENS")
require.True(t, models.IsGenericValidationError(err))
}

func TestSettlementJSONRoundTripPreservesFiatCurrencyCode(t *testing.T) {
settlement := NewSettlement(InvoiceSettlement{
GenericSettlement: GenericSettlement{
Currency: currencyx.FiatCode("USD"),
CostBasis: alpacadecimal.NewFromFloat(0.5),
},
})

data, err := json.Marshal(settlement)
require.NoError(t, err)
require.JSONEq(t, `{"type":"invoice","currency":"USD","costBasis":"0.5"}`, string(data))

var decoded Settlement
require.NoError(t, json.Unmarshal(data, &decoded))

invoiceSettlement, err := decoded.AsInvoiceSettlement()
require.NoError(t, err)
require.Equal(t, currencyx.FiatCode("USD"), invoiceSettlement.Currency)
require.Equal(t, float64(0.5), invoiceSettlement.CostBasis.InexactFloat64())
}
4 changes: 1 addition & 3 deletions openmeter/billing/charges/flatfee/service/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ func (s *service) Create(ctx context.Context, input flatfee.CreateInput) ([]flat
now := clock.Now().UTC()
// Let's create all the flat fee charges in bulk
intentsWithStatus, err := slicesx.MapWithErr(input.Intents, func(intent flatfee.Intent) (flatfee.IntentWithInitialStatus, error) {
if intent.Currency.IsCustom() &&
intent.SettlementMode == productcatalog.CreditThenInvoiceSettlementMode &&
!s.enableCustomCurrency.Load() {
if intent.Currency.IsCustom() && !s.enableCustomCurrency.Load() {
return flatfee.IntentWithInitialStatus{}, fmt.Errorf("creating flat fee charge with custom currency %q: %w", intent.Currency.GetCode(), meta.ErrCustomCurrencyNotSupported)
}

Expand Down
Loading
Loading