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
1 change: 0 additions & 1 deletion openmeter/billing/adapter/stdinvoicelinemapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,6 @@ func (a *adapter) mapStandardInvoiceDetailedLineFromDB(dbLine *db.BillingInvoice
Category: dbLine.Edges.FlatFeeLine.Category,
PaymentTerm: dbLine.Edges.FlatFeeLine.PaymentTerm,
Index: dbLine.Edges.FlatFeeLine.Index,
Currency: dbLine.Currency,
CreditsApplied: creditsApplied,
Totals: totals.FromDB(dbLine),
ExternalIDs: externalid.MapLineExternalIDFromDB(dbLine),
Expand Down
2 changes: 1 addition & 1 deletion openmeter/billing/adapter/stdinvoicelines.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ func (a *adapter) upsertDetailedLines(ctx context.Context, in detailedLineDiff)
SetType(billing.InvoiceLineAdapterTypeFee).
SetName(line.Name).
SetNillableDescription(line.Description).
SetCurrency(line.Currency).
SetCurrency(lineWithParent.Parent.Currency).
SetNillableChildUniqueReferenceID(lo.EmptyableToPtr(line.ChildUniqueReferenceID))

create = externalid.CreateLineExternalID(create, line.ExternalIDs)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,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.Type() == currencyx.CurrencyTypeCustom {
if input.Intent.Currency.IsCustom() && input.Intent.Settlement.Type() != creditpurchase.SettlementTypePromotional {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return creditpurchase.ChargeWithGatheringLine{}, fmt.Errorf("custom currency %s is not supported for credit purchases: %w", input.Intent.Currency.GetCode(), meta.ErrCustomCurrencyNotSupported)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,6 @@ func (s *FlatFeeDetailedLineAdapterSuite) newDetailedLine(input newDetailedLineI
Description: input.Description,
}),
ServicePeriod: input.ServicePeriod,
Currency: input.Charge.Intent.GetCurrency().GetCode(),
ChildUniqueReferenceID: input.ChildUniqueReferenceID,
PaymentTerm: baseIntent.PaymentTerm,
PerUnitAmount: alpacadecimal.NewFromFloat(0.1),
Expand Down
2 changes: 1 addition & 1 deletion openmeter/billing/charges/flatfee/service/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (s *service) Create(ctx context.Context, input flatfee.CreateInput) ([]flat
return transaction.Run(ctx, s.adapter, func(ctx context.Context) ([]flatfee.ChargeWithGatheringLine, error) {
// 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() {
if intent.Currency.IsCustom() && intent.SettlementMode != productcatalog.CreditOnlySettlementMode {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return flatfee.IntentWithInitialStatus{}, fmt.Errorf("creating flat fee charge with custom currency %q: %w", intent.Currency.GetCode(), meta.ErrCustomCurrencyNotSupported)
}

Expand Down
37 changes: 37 additions & 0 deletions openmeter/billing/charges/service/base_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
flatfeeadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/adapter"
flatfeeservice "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee/service"
"github.com/openmeterio/openmeter/openmeter/billing/charges/invoiceupdater"
"github.com/openmeterio/openmeter/openmeter/billing/charges/lineage"
lineageadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/adapter"
lineageservice "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/service"
chargeslinerouter "github.com/openmeterio/openmeter/openmeter/billing/charges/linerouter"
Expand All @@ -29,6 +30,7 @@ import (
usagebasedadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/adapter"
usagebasedservice "github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased/service"
billingratingservice "github.com/openmeterio/openmeter/openmeter/billing/rating/service"
"github.com/openmeterio/openmeter/openmeter/currencies"
currencyadapter "github.com/openmeterio/openmeter/openmeter/currencies/adapter"
"github.com/openmeterio/openmeter/openmeter/currencies/currencyresolver"
currencyservice "github.com/openmeterio/openmeter/openmeter/currencies/service"
Expand Down Expand Up @@ -56,6 +58,14 @@ type BaseSuite struct {

Charges *service
UsageBasedService usagebased.Service
CurrencyService currencies.Service
MetaAdapter meta.Adapter
LineageService lineage.Service
Locker *lockr.Locker
InvoiceUpdater invoiceupdater.Updater
FlatFeeAdapter flatfee.Adapter
CreditPurchaseAdapter creditpurchase.Adapter
UsageBasedAdapter usagebased.Adapter
FlatFeeTestHandler *flatFeeTestHandler
CreditPurchaseTestHandler *creditPurchaseTestHandler
UsageBasedTestHandler *usageBasedTestHandler
Expand All @@ -73,11 +83,13 @@ func (s *BaseSuite) SetupSuite() {
Logger: slog.Default(),
})
s.NoError(err)
s.MetaAdapter = metaAdapter

locker, err := lockr.NewLocker(&lockr.LockerConfig{
Logger: slog.Default(),
})
s.NoError(err)
s.Locker = locker

lineageAdapter, err := lineageadapter.New(lineageadapter.Config{
Client: s.DBClient,
Expand All @@ -88,13 +100,15 @@ func (s *BaseSuite) SetupSuite() {
Adapter: lineageAdapter,
})
s.NoError(err)
s.LineageService = lineageService

flatFeeAdapter, err := flatfeeadapter.New(flatfeeadapter.Config{
Client: s.DBClient,
Logger: slog.Default(),
MetaAdapter: metaAdapter,
})
s.NoError(err)
s.FlatFeeAdapter = flatFeeAdapter

flatFeeService, err := flatfeeservice.New(flatfeeservice.Config{
Adapter: flatFeeAdapter,
Expand All @@ -115,12 +129,14 @@ func (s *BaseSuite) SetupSuite() {
MetaAdapter: metaAdapter,
})
s.NoError(err)
s.UsageBasedAdapter = usageBasedAdapter

invoiceUpdater, err := invoiceupdater.New(invoiceupdater.Config{
BillingService: s.BillingService,
Logger: slog.Default(),
})
s.NoError(err)
s.InvoiceUpdater = invoiceUpdater

usageBasedService, err := usagebasedservice.New(usagebasedservice.Config{
Adapter: usageBasedAdapter,
Expand All @@ -146,6 +162,7 @@ func (s *BaseSuite) SetupSuite() {
MetaAdapter: metaAdapter,
})
s.NoError(err)
s.CreditPurchaseAdapter = creditPurchaseAdapter

creditPurchaseService, err := creditpurchaseservice.New(creditpurchaseservice.Config{
Adapter: creditPurchaseAdapter,
Expand Down Expand Up @@ -185,6 +202,7 @@ func (s *BaseSuite) SetupSuite() {
s.NoError(err)
currencyService, err := currencyservice.New(currencyAdapter)
s.NoError(err)
s.CurrencyService = currencyService
currencyResolver, err := currencyresolver.New(currencyService)
s.NoError(err)

Expand Down Expand Up @@ -216,6 +234,25 @@ func (s *BaseSuite) TearDownTest() {
clock.ResetTime()
}

func (s *BaseSuite) createTestCustomCurrency(ctx context.Context, namespace string) currencies.Currency {
s.T().Helper()

currency, err := s.CurrencyService.CreateCurrency(ctx, currencies.CreateCurrencyInput{
Namespace: namespace,
CurrencyDetails: currencyx.CurrencyDetails{
Code: "TOKENS",
Name: "Tokens",
Symbol: "T",
Precision: 3,
DecimalMark: ".",
ThousandsSeparator: ",",
},
})
s.Require().NoError(err)

return currency
}

type createMockChargeIntentInput struct {
customer customer.CustomerID
currency currencyx.Code
Expand Down
115 changes: 114 additions & 1 deletion openmeter/billing/charges/service/creditpurchase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ import (
"github.com/invopop/gobl/currency"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"

appcustominvoicing "github.com/openmeterio/openmeter/openmeter/app/custominvoicing"
"github.com/openmeterio/openmeter/openmeter/billing"
"github.com/openmeterio/openmeter/openmeter/billing/charges"
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
creditpurchaseservice "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service"
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment"
"github.com/openmeterio/openmeter/openmeter/currencies"
currenciestestutils "github.com/openmeterio/openmeter/openmeter/currencies/testutils/currency"
"github.com/openmeterio/openmeter/openmeter/customer"
"github.com/openmeterio/openmeter/pkg/clock"
Expand Down Expand Up @@ -98,6 +101,117 @@ func (s *CreditPurchaseTestSuite) TestPromotionalCreditPurchase() {
s.Equal(creditpurchase.StatusFinal, updatedCPCharge.Status)
}

func (s *CreditPurchaseTestSuite) TestPromotionalCreditPurchaseWithCustomCurrency() {
ctx := s.T().Context()
ns := s.GetUniqueNamespace("charges-service-promotional-credit-purchase-custom-currency")

var customCurrency currencies.Currency
var customerID string
var createdCharge creditpurchase.Charge
var promotionalTransactionGroupID string

s.Run("#1 setup customer and custom currency", func() {
// given:
// - a customer and a persisted custom currency
s.ProvisionDefaultTaxCodes(ctx, ns)

cust := s.CreateTestCustomer(ns, "test-subject")
s.NotEmpty(cust.ID)
customerID = cust.ID
customCurrency = s.createTestCustomCurrency(ctx, ns)
})

s.Run("#2 create promotional credit purchase", func() {
// given:
// - a promotional credit-purchase intent in the custom currency
// - mocked ledger and lineage callbacks
// when:
// - the charge is created through the root charges service
// then:
// - the callbacks run once and the charge reaches final with a persisted realization
servicePeriod := timeutil.ClosedPeriod{
From: datetime.MustParseTimeInLocation(s.T(), "2026-01-01T00:00:00Z", time.UTC).AsTime(),
To: datetime.MustParseTimeInLocation(s.T(), "2026-02-01T00:00:00Z", time.UTC).AsTime(),
}
intent := charges.NewChargeIntent(creditpurchase.Intent{
Intent: meta.Intent{
ManagedBy: billing.ManuallyManagedLine,
CustomerID: customerID,
Currency: customCurrency,
},
IntentMutableFields: creditpurchase.IntentMutableFields{
IntentMutableFields: meta.IntentMutableFields{
Name: "Custom Currency Credit Purchase",
ServicePeriod: servicePeriod,
BillingPeriod: servicePeriod,
FullServicePeriod: servicePeriod,
},
CreditAmount: alpacadecimal.NewFromFloat(100.1234),
Settlement: creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}),
},
})

promotionalCallback := newCountedLedgerTransactionCallback[creditpurchase.Charge]()
promotionalTransactionGroupID = promotionalCallback.id
s.CreditPurchaseTestHandler.onPromotionalCreditPurchase = promotionalCallback.Handler(s.T(), func(t *testing.T, charge creditpurchase.Charge) {
assert.Equal(t, creditpurchase.SettlementTypePromotional, charge.Intent.Settlement.Type())
assert.True(t, charge.Intent.Currency.IsCustom())
assert.Equal(t, customCurrency.ID, charge.Intent.Currency.ID)
assert.Nil(t, charge.Realizations.CreditGrantRealization)
})

lineageMock := &mockLineageService{Service: s.LineageService}
lineageMock.On("BackfillAdvanceLineageSegments", mock.Anything, mock.Anything).
Return(nil).
Once()

customCurrencyCreditPurchaseService, err := creditpurchaseservice.New(creditpurchaseservice.Config{
Adapter: s.CreditPurchaseAdapter,
Handler: s.CreditPurchaseTestHandler,
Lineage: lineageMock,
MetaAdapter: s.MetaAdapter,
})
s.Require().NoError(err)
originalCreditPurchaseService := s.Charges.creditPurchaseService
s.Charges.creditPurchaseService = customCurrencyCreditPurchaseService
defer func() {
s.Charges.creditPurchaseService = originalCreditPurchaseService
}()

created, err := s.Charges.Create(ctx, charges.CreateInput{
Namespace: ns,
Intents: charges.ChargeIntents{intent},
})
s.Require().NoError(err)
s.Require().Len(created, 1)
s.Equal(1, promotionalCallback.nrInvocations)
lineageMock.AssertExpectations(s.T())

createdCharge, err = created[0].AsCreditPurchaseCharge()
s.Require().NoError(err)
s.Equal(creditpurchase.StatusFinal, createdCharge.Status)
s.True(createdCharge.Intent.Currency.IsCustom())
s.Equal(customCurrency.ID, createdCharge.Intent.Currency.ID)
s.Equal(float64(100.123), createdCharge.Intent.CreditAmount.InexactFloat64())
s.Require().NotNil(createdCharge.Realizations.CreditGrantRealization)
s.Equal(promotionalTransactionGroupID, createdCharge.Realizations.CreditGrantRealization.TransactionGroupID)
})

s.Run("#3 reload persisted charge", func() {
// when:
// - the charge is loaded again from Postgres
// then:
// - its final state, custom currency, and realization are preserved
persisted, err := s.mustGetChargeByID(createdCharge.GetChargeID()).AsCreditPurchaseCharge()
s.Require().NoError(err)
s.Equal(creditpurchase.StatusFinal, persisted.Status)
s.True(persisted.Intent.Currency.IsCustom())
s.Equal(customCurrency.ID, persisted.Intent.Currency.ID)
s.Require().NotNil(persisted.Realizations.CreditGrantRealization)
s.Equal(promotionalTransactionGroupID, persisted.Realizations.CreditGrantRealization.TransactionGroupID)
})
}

func (s *CreditPurchaseTestSuite) TestCreditPurchaseRejectsMismatchedSettlementCurrency() {
ctx := context.Background()
ns := s.GetUniqueNamespace("charges-service-credit-purchase-mismatched-settlement-currency")
Expand Down Expand Up @@ -725,7 +839,6 @@ func (s *CreditPurchaseTestSuite) TestStandardInvoiceCreditPurchase() {

detailedLine := line.DetailedLines[0]

s.Equal(USD, detailedLine.Currency)
s.Equal(alpacadecimal.NewFromFloat(50), detailedLine.PerUnitAmount)
s.Equal(alpacadecimal.NewFromFloat(1), detailedLine.Quantity)
s.Equal(alpacadecimal.NewFromFloat(50), detailedLine.Totals.Amount)
Expand Down
19 changes: 19 additions & 0 deletions openmeter/billing/charges/service/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,33 @@ import (

"github.com/alpacahq/alpacadecimal"
"github.com/oklog/ulid/v2"
"github.com/stretchr/testify/mock"

"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
"github.com/openmeterio/openmeter/openmeter/billing/charges/lineage"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction"
"github.com/openmeterio/openmeter/openmeter/billing/charges/usagebased"
)

type mockLineageService struct {
lineage.Service
mock.Mock
}

func (m *mockLineageService) CreateInitialLineages(ctx context.Context, input lineage.CreateInitialLineagesInput) error {
return m.Called(ctx, input).Error(0)
}

func (m *mockLineageService) PersistCorrectionLineageSegments(ctx context.Context, input lineage.PersistCorrectionLineageSegmentsInput) error {
return m.Called(ctx, input).Error(0)
}

func (m *mockLineageService) BackfillAdvanceLineageSegments(ctx context.Context, input lineage.BackfillAdvanceLineageSegmentsInput) error {
return m.Called(ctx, input).Error(0)
}

var _ flatfee.Handler = (*flatFeeTestHandler)(nil)

type flatFeeTestHandler struct {
Expand Down
Loading
Loading