Skip to content

Commit 4abeb7f

Browse files
committed
feat(charges): support custom currency credits-only charges
1 parent 723e57f commit 4abeb7f

52 files changed

Lines changed: 679 additions & 191 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

openmeter/billing/adapter/stdinvoicelinemapper.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,6 @@ func (a *adapter) mapStandardInvoiceDetailedLineFromDB(dbLine *db.BillingInvoice
187187
Category: dbLine.Edges.FlatFeeLine.Category,
188188
PaymentTerm: dbLine.Edges.FlatFeeLine.PaymentTerm,
189189
Index: dbLine.Edges.FlatFeeLine.Index,
190-
Currency: dbLine.Currency,
191190
CreditsApplied: creditsApplied,
192191
Totals: totals.FromDB(dbLine),
193192
ExternalIDs: externalid.MapLineExternalIDFromDB(dbLine),

openmeter/billing/adapter/stdinvoicelines.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ func (a *adapter) upsertDetailedLines(ctx context.Context, in detailedLineDiff)
327327
SetType(billing.InvoiceLineAdapterTypeFee).
328328
SetName(line.Name).
329329
SetNillableDescription(line.Description).
330-
SetCurrency(line.Currency).
330+
SetCurrency(lineWithParent.Parent.Currency).
331331
SetNillableChildUniqueReferenceID(lo.EmptyableToPtr(line.ChildUniqueReferenceID))
332332

333333
create = externalid.CreateLineExternalID(create, line.ExternalIDs)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func (s *service) Create(ctx context.Context, input creditpurchase.CreateInput)
2121
}
2222

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

openmeter/billing/charges/flatfee/adapter/detailedline_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,6 @@ func (s *FlatFeeDetailedLineAdapterSuite) newDetailedLine(input newDetailedLineI
271271
Description: input.Description,
272272
}),
273273
ServicePeriod: input.ServicePeriod,
274-
Currency: input.Charge.Intent.GetCurrency().GetCode(),
275274
ChildUniqueReferenceID: input.ChildUniqueReferenceID,
276275
PaymentTerm: baseIntent.PaymentTerm,
277276
PerUnitAmount: alpacadecimal.NewFromFloat(0.1),

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func (s *service) Create(ctx context.Context, input flatfee.CreateInput) ([]flat
2929
return transaction.Run(ctx, s.adapter, func(ctx context.Context) ([]flatfee.ChargeWithGatheringLine, error) {
3030
// Let's create all the flat fee charges in bulk
3131
intentsWithStatus, err := slicesx.MapWithErr(input.Intents, func(intent flatfee.Intent) (flatfee.IntentWithInitialStatus, error) {
32-
if intent.Currency.IsCustom() {
32+
if intent.Currency.IsCustom() && intent.SettlementMode != productcatalog.CreditOnlySettlementMode {
3333
return flatfee.IntentWithInitialStatus{}, fmt.Errorf("creating flat fee charge with custom currency %q: %w", intent.Currency.GetCode(), meta.ErrCustomCurrencyNotSupported)
3434
}
3535

openmeter/billing/charges/service/base_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
flatfeeadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/adapter"
2121
flatfeeservice "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service"
2222
"github.com/openmeterio/openmeter/openmeter/billing/charges/invoiceupdater"
23+
"github.com/openmeterio/openmeter/openmeter/billing/charges/lineage"
2324
lineageadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/adapter"
2425
lineageservice "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/service"
2526
chargeslinerouter "github.com/openmeterio/openmeter/openmeter/billing/charges/linerouter"
@@ -29,6 +30,7 @@ import (
2930
usagebasedadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/adapter"
3031
usagebasedservice "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/service"
3132
billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service"
33+
"github.com/openmeterio/openmeter/openmeter/currencies"
3234
currencyadapter "github.com/openmeterio/openmeter/openmeter/currencies/adapter"
3335
"github.com/openmeterio/openmeter/openmeter/currencies/currencyresolver"
3436
currencyservice "github.com/openmeterio/openmeter/openmeter/currencies/service"
@@ -56,6 +58,14 @@ type BaseSuite struct {
5658

5759
Charges *service
5860
UsageBasedService usagebased.Service
61+
CurrencyService currencies.Service
62+
MetaAdapter meta.Adapter
63+
LineageService lineage.Service
64+
Locker *lockr.Locker
65+
InvoiceUpdater invoiceupdater.Updater
66+
FlatFeeAdapter flatfee.Adapter
67+
CreditPurchaseAdapter creditpurchase.Adapter
68+
UsageBasedAdapter usagebased.Adapter
5969
FlatFeeTestHandler *flatFeeTestHandler
6070
CreditPurchaseTestHandler *creditPurchaseTestHandler
6171
UsageBasedTestHandler *usageBasedTestHandler
@@ -73,11 +83,13 @@ func (s *BaseSuite) SetupSuite() {
7383
Logger: slog.Default(),
7484
})
7585
s.NoError(err)
86+
s.MetaAdapter = metaAdapter
7687

7788
locker, err := lockr.NewLocker(&lockr.LockerConfig{
7889
Logger: slog.Default(),
7990
})
8091
s.NoError(err)
92+
s.Locker = locker
8193

8294
lineageAdapter, err := lineageadapter.New(lineageadapter.Config{
8395
Client: s.DBClient,
@@ -88,13 +100,15 @@ func (s *BaseSuite) SetupSuite() {
88100
Adapter: lineageAdapter,
89101
})
90102
s.NoError(err)
103+
s.LineageService = lineageService
91104

92105
flatFeeAdapter, err := flatfeeadapter.New(flatfeeadapter.Config{
93106
Client: s.DBClient,
94107
Logger: slog.Default(),
95108
MetaAdapter: metaAdapter,
96109
})
97110
s.NoError(err)
111+
s.FlatFeeAdapter = flatFeeAdapter
98112

99113
flatFeeService, err := flatfeeservice.New(flatfeeservice.Config{
100114
Adapter: flatFeeAdapter,
@@ -115,12 +129,14 @@ func (s *BaseSuite) SetupSuite() {
115129
MetaAdapter: metaAdapter,
116130
})
117131
s.NoError(err)
132+
s.UsageBasedAdapter = usageBasedAdapter
118133

119134
invoiceUpdater, err := invoiceupdater.New(invoiceupdater.Config{
120135
BillingService: s.BillingService,
121136
Logger: slog.Default(),
122137
})
123138
s.NoError(err)
139+
s.InvoiceUpdater = invoiceUpdater
124140

125141
usageBasedService, err := usagebasedservice.New(usagebasedservice.Config{
126142
Adapter: usageBasedAdapter,
@@ -146,6 +162,7 @@ func (s *BaseSuite) SetupSuite() {
146162
MetaAdapter: metaAdapter,
147163
})
148164
s.NoError(err)
165+
s.CreditPurchaseAdapter = creditPurchaseAdapter
149166

150167
creditPurchaseService, err := creditpurchaseservice.New(creditpurchaseservice.Config{
151168
Adapter: creditPurchaseAdapter,
@@ -185,6 +202,7 @@ func (s *BaseSuite) SetupSuite() {
185202
s.NoError(err)
186203
currencyService, err := currencyservice.New(currencyAdapter)
187204
s.NoError(err)
205+
s.CurrencyService = currencyService
188206
currencyResolver, err := currencyresolver.New(currencyService)
189207
s.NoError(err)
190208

@@ -216,6 +234,25 @@ func (s *BaseSuite) TearDownTest() {
216234
clock.ResetTime()
217235
}
218236

237+
func (s *BaseSuite) createTestCustomCurrency(ctx context.Context, namespace string) currencies.Currency {
238+
s.T().Helper()
239+
240+
currency, err := s.CurrencyService.CreateCurrency(ctx, currencies.CreateCurrencyInput{
241+
Namespace: namespace,
242+
CurrencyDetails: currencyx.CurrencyDetails{
243+
Code: "TOKENS",
244+
Name: "Tokens",
245+
Symbol: "T",
246+
Precision: 3,
247+
DecimalMark: ".",
248+
ThousandsSeparator: ",",
249+
},
250+
})
251+
s.Require().NoError(err)
252+
253+
return currency
254+
}
255+
219256
type createMockChargeIntentInput struct {
220257
customer customer.CustomerID
221258
currency currencyx.Code

openmeter/billing/charges/service/creditpurchase_test.go

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,18 @@ import (
1111
"github.com/invopop/gobl/currency"
1212
"github.com/samber/lo"
1313
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/mock"
1415
"github.com/stretchr/testify/require"
1516
"github.com/stretchr/testify/suite"
1617

1718
appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing"
1819
"github.com/openmeterio/openmeter/openmeter/billing"
1920
"github.com/openmeterio/openmeter/openmeter/billing/charges"
2021
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
22+
creditpurchaseservice "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service"
2123
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
2224
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
25+
"github.com/openmeterio/openmeter/openmeter/currencies"
2326
currenciestestutils "github.com/openmeterio/openmeter/openmeter/currencies/testutils/currency"
2427
"github.com/openmeterio/openmeter/openmeter/customer"
2528
"github.com/openmeterio/openmeter/pkg/clock"
@@ -98,6 +101,118 @@ func (s *CreditPurchaseTestSuite) TestPromotionalCreditPurchase() {
98101
s.Equal(creditpurchase.StatusFinal, updatedCPCharge.Status)
99102
}
100103

104+
func (s *CreditPurchaseTestSuite) TestPromotionalCreditPurchaseWithCustomCurrency() {
105+
ctx := s.T().Context()
106+
ns := s.GetUniqueNamespace("charges-service-promotional-credit-purchase-custom-currency")
107+
108+
var customCurrency currencies.Currency
109+
var customerID string
110+
var createdCharge creditpurchase.Charge
111+
var promotionalTransactionGroupID string
112+
113+
s.Run("#1 setup customer and custom currency", func() {
114+
// given:
115+
// - a customer and a persisted custom currency
116+
s.ProvisionDefaultTaxCodes(ctx, ns)
117+
118+
cust := s.CreateTestCustomer(ns, "test-subject")
119+
s.NotEmpty(cust.ID)
120+
customerID = cust.ID
121+
customCurrency = s.createTestCustomCurrency(ctx, ns)
122+
123+
})
124+
125+
s.Run("#2 create promotional credit purchase", func() {
126+
// given:
127+
// - a promotional credit-purchase intent in the custom currency
128+
// - mocked ledger and lineage callbacks
129+
// when:
130+
// - the charge is created through the root charges service
131+
// then:
132+
// - the callbacks run once and the charge reaches final with a persisted realization
133+
servicePeriod := timeutil.ClosedPeriod{
134+
From: datetime.MustParseTimeInLocation(s.T(), "2026-01-01T00:00:00Z", time.UTC).AsTime(),
135+
To: datetime.MustParseTimeInLocation(s.T(), "2026-02-01T00:00:00Z", time.UTC).AsTime(),
136+
}
137+
intent := charges.NewChargeIntent(creditpurchase.Intent{
138+
Intent: meta.Intent{
139+
ManagedBy: billing.ManuallyManagedLine,
140+
CustomerID: customerID,
141+
Currency: customCurrency,
142+
},
143+
IntentMutableFields: creditpurchase.IntentMutableFields{
144+
IntentMutableFields: meta.IntentMutableFields{
145+
Name: "Custom Currency Credit Purchase",
146+
ServicePeriod: servicePeriod,
147+
BillingPeriod: servicePeriod,
148+
FullServicePeriod: servicePeriod,
149+
},
150+
CreditAmount: alpacadecimal.NewFromFloat(100.1234),
151+
Settlement: creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}),
152+
},
153+
})
154+
155+
promotionalCallback := newCountedLedgerTransactionCallback[creditpurchase.Charge]()
156+
promotionalTransactionGroupID = promotionalCallback.id
157+
s.CreditPurchaseTestHandler.onPromotionalCreditPurchase = promotionalCallback.Handler(s.T(), func(t *testing.T, charge creditpurchase.Charge) {
158+
assert.Equal(t, creditpurchase.SettlementTypePromotional, charge.Intent.Settlement.Type())
159+
assert.True(t, charge.Intent.Currency.IsCustom())
160+
assert.Equal(t, customCurrency.ID, charge.Intent.Currency.ID)
161+
assert.Nil(t, charge.Realizations.CreditGrantRealization)
162+
})
163+
164+
lineageMock := &mockLineageService{Service: s.LineageService}
165+
lineageMock.On("BackfillAdvanceLineageSegments", mock.Anything, mock.Anything).
166+
Return(nil).
167+
Once()
168+
169+
customCurrencyCreditPurchaseService, err := creditpurchaseservice.New(creditpurchaseservice.Config{
170+
Adapter: s.CreditPurchaseAdapter,
171+
Handler: s.CreditPurchaseTestHandler,
172+
Lineage: lineageMock,
173+
MetaAdapter: s.MetaAdapter,
174+
})
175+
s.Require().NoError(err)
176+
originalCreditPurchaseService := s.Charges.creditPurchaseService
177+
s.Charges.creditPurchaseService = customCurrencyCreditPurchaseService
178+
defer func() {
179+
s.Charges.creditPurchaseService = originalCreditPurchaseService
180+
}()
181+
182+
created, err := s.Charges.Create(ctx, charges.CreateInput{
183+
Namespace: ns,
184+
Intents: charges.ChargeIntents{intent},
185+
})
186+
s.Require().NoError(err)
187+
s.Require().Len(created, 1)
188+
s.Equal(1, promotionalCallback.nrInvocations)
189+
lineageMock.AssertExpectations(s.T())
190+
191+
createdCharge, err = created[0].AsCreditPurchaseCharge()
192+
s.Require().NoError(err)
193+
s.Equal(creditpurchase.StatusFinal, createdCharge.Status)
194+
s.True(createdCharge.Intent.Currency.IsCustom())
195+
s.Equal(customCurrency.ID, createdCharge.Intent.Currency.ID)
196+
s.Equal(float64(100.123), createdCharge.Intent.CreditAmount.InexactFloat64())
197+
s.Require().NotNil(createdCharge.Realizations.CreditGrantRealization)
198+
s.Equal(promotionalTransactionGroupID, createdCharge.Realizations.CreditGrantRealization.TransactionGroupID)
199+
})
200+
201+
s.Run("#3 reload persisted charge", func() {
202+
// when:
203+
// - the charge is loaded again from Postgres
204+
// then:
205+
// - its final state, custom currency, and realization are preserved
206+
persisted, err := s.mustGetChargeByID(createdCharge.GetChargeID()).AsCreditPurchaseCharge()
207+
s.Require().NoError(err)
208+
s.Equal(creditpurchase.StatusFinal, persisted.Status)
209+
s.True(persisted.Intent.Currency.IsCustom())
210+
s.Equal(customCurrency.ID, persisted.Intent.Currency.ID)
211+
s.Require().NotNil(persisted.Realizations.CreditGrantRealization)
212+
s.Equal(promotionalTransactionGroupID, persisted.Realizations.CreditGrantRealization.TransactionGroupID)
213+
})
214+
}
215+
101216
func (s *CreditPurchaseTestSuite) TestCreditPurchaseRejectsMismatchedSettlementCurrency() {
102217
ctx := context.Background()
103218
ns := s.GetUniqueNamespace("charges-service-credit-purchase-mismatched-settlement-currency")
@@ -725,7 +840,6 @@ func (s *CreditPurchaseTestSuite) TestStandardInvoiceCreditPurchase() {
725840

726841
detailedLine := line.DetailedLines[0]
727842

728-
s.Equal(USD, detailedLine.Currency)
729843
s.Equal(alpacadecimal.NewFromFloat(50), detailedLine.PerUnitAmount)
730844
s.Equal(alpacadecimal.NewFromFloat(1), detailedLine.Quantity)
731845
s.Equal(alpacadecimal.NewFromFloat(50), detailedLine.Totals.Amount)

openmeter/billing/charges/service/handlers_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,33 @@ import (
77

88
"github.com/alpacahq/alpacadecimal"
99
"github.com/oklog/ulid/v2"
10+
"github.com/stretchr/testify/mock"
1011

1112
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
1213
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
14+
"github.com/openmeterio/openmeter/openmeter/billing/charges/lineage"
1315
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization"
1416
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction"
1517
"github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased"
1618
)
1719

20+
type mockLineageService struct {
21+
lineage.Service
22+
mock.Mock
23+
}
24+
25+
func (m *mockLineageService) CreateInitialLineages(ctx context.Context, input lineage.CreateInitialLineagesInput) error {
26+
return m.Called(ctx, input).Error(0)
27+
}
28+
29+
func (m *mockLineageService) PersistCorrectionLineageSegments(ctx context.Context, input lineage.PersistCorrectionLineageSegmentsInput) error {
30+
return m.Called(ctx, input).Error(0)
31+
}
32+
33+
func (m *mockLineageService) BackfillAdvanceLineageSegments(ctx context.Context, input lineage.BackfillAdvanceLineageSegmentsInput) error {
34+
return m.Called(ctx, input).Error(0)
35+
}
36+
1837
var _ flatfee.Handler = (*flatFeeTestHandler)(nil)
1938

2039
type flatFeeTestHandler struct {

0 commit comments

Comments
 (0)