Skip to content

Commit ba556a2

Browse files
committed
feat(customcurrencies): support credit purchases
1 parent 8cdf8ad commit ba556a2

22 files changed

Lines changed: 256 additions & 70 deletions

File tree

.agents/skills/charges/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ Use these conventions for lifecycle tests:
642642
- 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
643643
- for flat fee credit-only tests, use `mustAdvanceFlatFeeCharges(...)` helper — it filters the advance result to flat fee charges only
644644
- 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
645+
- 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
645646
- when testing timestamp truncation, use sub-second fixtures and assert the persisted charge/run fields are second-aligned after create/advance
646647
- `time.Time` fields on domain models are value typed; use `s.False(ts.IsZero())` instead of `s.NotNil(ts)` when asserting they are populated
647648
- cover the temporary shrink/extend remap path as well; it synthesizes new intents and must normalize the replacement period ends before re-create
@@ -716,6 +717,8 @@ Credit purchase handler interface (`creditpurchase.Handler`):
716717
When changing credit purchase charges:
717718

718719
- `creditpurchase.ChargeBase` stores base-row data: `ManagedResource`, `Intent`, `Status` (own `creditpurchase.Status` type); `State` exists but is an empty struct
720+
- `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.
721+
- 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.
719722
- `creditpurchase.Charge` embeds `ChargeBase` + `Realizations` — all lifecycle outcomes live in `Realizations`, not `State`
720723
- `creditpurchase.Realizations` holds `CreditGrantRealization`, `ExternalPaymentSettlement`, and `InvoiceSettlement` (all loaded from edge tables)
721724
- `CreditGrantRealization` is stored in its own `charge_credit_purchase_credit_grants` table, not on the base row

openmeter/billing/charges/creditpurchase/charge.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/openmeterio/openmeter/openmeter/currencies"
1515
"github.com/openmeterio/openmeter/openmeter/customer"
1616
"github.com/openmeterio/openmeter/pkg/clock"
17+
"github.com/openmeterio/openmeter/pkg/currencyx"
1718
"github.com/openmeterio/openmeter/pkg/models"
1819
"github.com/openmeterio/openmeter/pkg/timeutil"
1920
)
@@ -225,12 +226,12 @@ func (i Intent) Validate() error {
225226
switch i.Settlement.Type() {
226227
case SettlementTypeInvoice:
227228
settlement, err := i.Settlement.AsInvoiceSettlement()
228-
if err == nil && settlement.Currency != i.Currency.GetCode() {
229+
if err == nil && !i.Currency.IsCustom() && currencyx.Code(settlement.Currency) != i.Currency.GetCode() {
229230
errs = append(errs, fmt.Errorf("settlement currency %q must match credit currency %q", settlement.Currency, i.Currency.GetCode()))
230231
}
231232
case SettlementTypeExternal:
232233
settlement, err := i.Settlement.AsExternalSettlement()
233-
if err == nil && settlement.Currency != i.Currency.GetCode() {
234+
if err == nil && !i.Currency.IsCustom() && currencyx.Code(settlement.Currency) != i.Currency.GetCode() {
234235
errs = append(errs, fmt.Errorf("settlement currency %q must match credit currency %q", settlement.Currency, i.Currency.GetCode()))
235236
}
236237
}

openmeter/billing/charges/creditpurchase/service/create.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
1111
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
1212
"github.com/openmeterio/openmeter/openmeter/productcatalog"
13-
"github.com/openmeterio/openmeter/pkg/currencyx"
1413
"github.com/openmeterio/openmeter/pkg/framework/transaction"
1514
"github.com/openmeterio/openmeter/pkg/models"
1615
)
@@ -21,7 +20,7 @@ func (s *service) Create(ctx context.Context, input creditpurchase.CreateInput)
2120
}
2221

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

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

9695
// Total cost = credit amount * cost basis (e.g., 100 credits * $0.5 = $50)
9796
totalCost := intent.CreditAmount.Mul(invoiceSettlement.CostBasis)
98-
invoiceCurrency := currencyx.FiatCode(invoiceSettlement.Currency)
97+
invoiceCurrency := invoiceSettlement.Currency
9998
calc, err := invoiceCurrency.AsFiatCurrency()
10099
if err != nil {
101100
return billing.GatheringLine{}, fmt.Errorf("creating currency calculator: %w", err)
@@ -119,7 +118,7 @@ func (s *service) buildInvoiceCreditPurchaseGatheringLine(charge creditpurchase.
119118
GatheringLineBase: billing.GatheringLineBase{
120119
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
121120
Namespace: charge.Namespace,
122-
Name: intent.Name,
121+
Name: fmt.Sprintf("%s (%s %s credits)", intent.Name, intent.CreditAmount, intent.Currency.GetCode()),
123122
Description: intent.Description,
124123
}),
125124
Metadata: intent.Metadata.Clone(),

openmeter/billing/charges/creditpurchase/service/external_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ func newExternalStateMachineTestChargeWithInput(t *testing.T, input externalStat
623623
FeatureFilters: input.featureFilters,
624624
Settlement: creditpurchase.NewSettlement(creditpurchase.ExternalSettlement{
625625
GenericSettlement: creditpurchase.GenericSettlement{
626-
Currency: currencyx.Code("USD"),
626+
Currency: currencyx.FiatCode("USD"),
627627
CostBasis: input.costBasis,
628628
},
629629
InitialStatus: input.initialStatus,

openmeter/billing/charges/creditpurchase/service/service.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package service
33
import (
44
"errors"
55
"fmt"
6+
"sync/atomic"
7+
"testing"
68

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

6567
type service struct {
66-
adapter creditpurchase.Adapter
67-
metaAdapter meta.Adapter
68-
handler creditpurchase.Handler
69-
lineage lineage.Service
70-
realizations *creditpurchaserealizations.Service
68+
adapter creditpurchase.Adapter
69+
metaAdapter meta.Adapter
70+
handler creditpurchase.Handler
71+
lineage lineage.Service
72+
realizations *creditpurchaserealizations.Service
73+
enableCustomCurrency atomic.Bool
74+
}
75+
76+
func (s *service) SetEnableCustomCurrency(t *testing.T, enabled bool) error {
77+
if t == nil {
78+
return errors.New("testing is nil")
79+
}
80+
81+
t.Helper()
82+
s.enableCustomCurrency.Store(enabled)
83+
return nil
7184
}

openmeter/billing/charges/creditpurchase/settlement.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (s SettlementType) Values() []string {
3636
}
3737

3838
type GenericSettlement struct {
39-
Currency currencyx.Code `json:"currency"`
39+
Currency currencyx.FiatCode `json:"currency"`
4040
CostBasis alpacadecimal.Decimal `json:"costBasis"`
4141
}
4242

openmeter/billing/charges/creditpurchase/settlement_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package creditpurchase
22

33
import (
4+
"encoding/json"
45
"testing"
56

67
"github.com/alpacahq/alpacadecimal"
@@ -33,7 +34,7 @@ func TestGenericSettlementValidateRequiresPositiveCostBasis(t *testing.T) {
3334
} {
3435
t.Run(tc.name, func(t *testing.T) {
3536
settlement := GenericSettlement{
36-
Currency: currencyx.Code("USD"),
37+
Currency: currencyx.FiatCode("USD"),
3738
CostBasis: tc.costBasis,
3839
}
3940

@@ -50,3 +51,37 @@ func TestGenericSettlementValidateRequiresPositiveCostBasis(t *testing.T) {
5051
})
5152
}
5253
}
54+
55+
func TestGenericSettlementRejectsCustomCurrencyCode(t *testing.T) {
56+
settlement := GenericSettlement{
57+
Currency: currencyx.FiatCode("TOKENS"),
58+
CostBasis: alpacadecimal.NewFromFloat(0.5),
59+
}
60+
61+
err := settlement.Validate()
62+
63+
require.Error(t, err)
64+
require.ErrorContains(t, err, "invalid fiat currency code: TOKENS")
65+
require.True(t, models.IsGenericValidationError(err))
66+
}
67+
68+
func TestSettlementJSONRoundTripPreservesFiatCurrencyCode(t *testing.T) {
69+
settlement := NewSettlement(InvoiceSettlement{
70+
GenericSettlement: GenericSettlement{
71+
Currency: currencyx.FiatCode("USD"),
72+
CostBasis: alpacadecimal.NewFromFloat(0.5),
73+
},
74+
})
75+
76+
data, err := json.Marshal(settlement)
77+
require.NoError(t, err)
78+
require.JSONEq(t, `{"type":"invoice","currency":"USD","costBasis":"0.5"}`, string(data))
79+
80+
var decoded Settlement
81+
require.NoError(t, json.Unmarshal(data, &decoded))
82+
83+
invoiceSettlement, err := decoded.AsInvoiceSettlement()
84+
require.NoError(t, err)
85+
require.Equal(t, currencyx.FiatCode("USD"), invoiceSettlement.Currency)
86+
require.Equal(t, float64(0.5), invoiceSettlement.CostBasis.InexactFloat64())
87+
}

openmeter/billing/charges/flatfee/service/create.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@ func (s *service) Create(ctx context.Context, input flatfee.CreateInput) ([]flat
3232
now := clock.Now().UTC()
3333
// Let's create all the flat fee charges in bulk
3434
intentsWithStatus, err := slicesx.MapWithErr(input.Intents, func(intent flatfee.Intent) (flatfee.IntentWithInitialStatus, error) {
35-
if intent.Currency.IsCustom() &&
36-
intent.SettlementMode == productcatalog.CreditThenInvoiceSettlementMode &&
37-
!s.enableCustomCurrency.Load() {
35+
if intent.Currency.IsCustom() && !s.enableCustomCurrency.Load() {
3836
return flatfee.IntentWithInitialStatus{}, fmt.Errorf("creating flat fee charge with custom currency %q: %w", intent.Currency.GetCode(), meta.ErrCustomCurrencyNotSupported)
3937
}
4038

0 commit comments

Comments
 (0)