Skip to content

Commit cd53383

Browse files
authored
feat(charges): add intent override support (#4542)
1 parent d1f5f53 commit cd53383

33 files changed

Lines changed: 11934 additions & 485 deletions

api/v3/handlers/customers/charges/convert.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ func convertTaxCodeConfigToAPI(cfg productcatalog.TaxCodeConfig) *api.BillingTax
378378
out := &api.BillingTaxConfig{
379379
Behavior: (*api.BillingTaxBehavior)(cfg.Behavior),
380380
}
381+
381382
if cfg.TaxCodeID != "" {
382383
out.TaxCode = &api.TaxCodeReference{Id: cfg.TaxCodeID}
383384
out.TaxCodeId = lo.ToPtr(cfg.TaxCodeID)

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
1111
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
1212
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/chargemeta"
13+
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/intentoverride"
1314
"github.com/openmeterio/openmeter/openmeter/ent/db"
1415
dbchargeflatfee "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfee"
1516
dbchargeflatfeerun "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfeerun"
@@ -70,6 +71,11 @@ func (a *adapter) UpdateCharge(ctx context.Context, charge flatfee.ChargeBase) (
7071
return flatfee.ChargeBase{}, err
7172
}
7273

74+
update, err = intentoverride.UpdateFlatFee(update, charge.IntentOverride)
75+
if err != nil {
76+
return flatfee.ChargeBase{}, err
77+
}
78+
7379
dbUpdatedChargeBase, err := update.Save(ctx)
7480
if err != nil {
7581
return flatfee.ChargeBase{}, err
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
package adapter
2+
3+
import (
4+
"log/slog"
5+
"testing"
6+
"time"
7+
8+
"github.com/alpacahq/alpacadecimal"
9+
"github.com/samber/lo"
10+
"github.com/samber/mo"
11+
"github.com/stretchr/testify/require"
12+
"github.com/stretchr/testify/suite"
13+
14+
"github.com/openmeterio/openmeter/openmeter/billing"
15+
"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
16+
chargesmeta "github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
17+
metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter"
18+
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/intentoverride"
19+
entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
20+
"github.com/openmeterio/openmeter/openmeter/productcatalog"
21+
taxcodetestutils "github.com/openmeterio/openmeter/openmeter/taxcode/testutils"
22+
"github.com/openmeterio/openmeter/openmeter/testutils"
23+
"github.com/openmeterio/openmeter/pkg/currencyx"
24+
"github.com/openmeterio/openmeter/pkg/models"
25+
"github.com/openmeterio/openmeter/pkg/timeutil"
26+
"github.com/openmeterio/openmeter/tools/migrate"
27+
)
28+
29+
func TestFlatFeeIntentOverrideAdapter(t *testing.T) {
30+
suite.Run(t, new(FlatFeeIntentOverrideAdapterSuite))
31+
}
32+
33+
type FlatFeeIntentOverrideAdapterSuite struct {
34+
suite.Suite
35+
36+
testDB *testutils.TestDB
37+
dbClient *entdb.Client
38+
adapter flatfee.Adapter
39+
40+
taxCodeEnv *taxcodetestutils.TestEnv
41+
}
42+
43+
func (s *FlatFeeIntentOverrideAdapterSuite) SetupSuite() {
44+
t := s.T()
45+
46+
s.testDB = testutils.InitPostgresDB(t)
47+
s.dbClient = entdb.NewClient(entdb.Driver(s.testDB.EntDriver.Driver()))
48+
49+
migrator, err := migrate.New(migrate.MigrateOptions{
50+
ConnectionString: s.testDB.URL,
51+
Migrations: migrate.OMMigrationsConfig,
52+
Logger: slog.Default(),
53+
})
54+
require.NoError(t, err)
55+
defer migrator.CloseOrLogError()
56+
require.NoError(t, migrator.Up())
57+
58+
metaAdapter, err := metaadapter.New(metaadapter.Config{
59+
Client: s.dbClient,
60+
Logger: slog.Default(),
61+
})
62+
require.NoError(t, err)
63+
64+
a, err := New(Config{
65+
Client: s.dbClient,
66+
Logger: slog.Default(),
67+
MetaAdapter: metaAdapter,
68+
})
69+
require.NoError(t, err)
70+
71+
s.taxCodeEnv = taxcodetestutils.NewTestEnvFromClient(t, s.dbClient, slog.Default())
72+
s.adapter = a
73+
}
74+
75+
func (s *FlatFeeIntentOverrideAdapterSuite) TearDownSuite() {
76+
s.dbClient.Close()
77+
s.testDB.EntDriver.Close()
78+
s.testDB.PGDriver.Close()
79+
}
80+
81+
func (s *FlatFeeIntentOverrideAdapterSuite) TestUpdateAndReadIntentOverride() {
82+
ctx := s.T().Context()
83+
namespace := "flatfee-intentoverride-adapter"
84+
charge := s.createCharge(namespace)
85+
overrideTaxCodeID := s.taxCodeEnv.CreateTaxCode(s.T(), namespace).ID
86+
87+
overrideServicePeriod := timeutil.ClosedPeriod{
88+
From: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC),
89+
To: time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC),
90+
}
91+
overrideFullServicePeriod := timeutil.ClosedPeriod{
92+
From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
93+
To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
94+
}
95+
overrideBillingPeriod := timeutil.ClosedPeriod{
96+
From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
97+
To: time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC),
98+
}
99+
amountBeforeProration := alpacadecimal.NewFromInt(42)
100+
paymentTerm := productcatalog.InAdvancePaymentTerm
101+
proRating := productcatalog.ProRatingConfig{
102+
Enabled: true,
103+
Mode: productcatalog.ProRatingModeProratePrices,
104+
}
105+
106+
charge.IntentOverride = &intentoverride.FlatFee{
107+
OverrideBase: intentoverride.OverrideBase{
108+
Kind: intentoverride.KindEdit,
109+
Name: lo.ToPtr("manual flat fee"),
110+
Description: mo.Some(lo.ToPtr("manual description")),
111+
Metadata: &models.Metadata{
112+
"source": "manual",
113+
},
114+
TaxBehavior: mo.Some(lo.ToPtr(productcatalog.InclusiveTaxBehavior)),
115+
TaxCodeID: &overrideTaxCodeID,
116+
ServicePeriod: &overrideServicePeriod,
117+
FullServicePeriod: &overrideFullServicePeriod,
118+
BillingPeriod: &overrideBillingPeriod,
119+
},
120+
FeatureKey: mo.Some(lo.ToPtr("manual-feature")),
121+
PaymentTerm: &paymentTerm,
122+
ProRating: &proRating,
123+
AmountBeforeProration: &amountBeforeProration,
124+
PercentageDiscounts: mo.Some(lo.ToPtr(productcatalog.PercentageDiscount{
125+
Percentage: models.NewPercentage(10),
126+
})),
127+
}
128+
129+
updated, err := s.adapter.UpdateCharge(ctx, charge.ChargeBase)
130+
s.Require().NoError(err)
131+
s.requireOverrideMatches(updated.IntentOverride, overrideServicePeriod, overrideFullServicePeriod, overrideBillingPeriod, overrideTaxCodeID)
132+
133+
fetched, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{
134+
ChargeID: charge.GetChargeID(),
135+
})
136+
s.Require().NoError(err)
137+
s.requireOverrideMatches(fetched.IntentOverride, overrideServicePeriod, overrideFullServicePeriod, overrideBillingPeriod, overrideTaxCodeID)
138+
139+
fetchedByIDs, err := s.adapter.GetByIDs(ctx, flatfee.GetByIDsInput{
140+
Namespace: namespace,
141+
IDs: []string{charge.ID},
142+
})
143+
s.Require().NoError(err)
144+
s.Require().Len(fetchedByIDs, 1)
145+
s.requireOverrideMatches(fetchedByIDs[0].IntentOverride, overrideServicePeriod, overrideFullServicePeriod, overrideBillingPeriod, overrideTaxCodeID)
146+
147+
fetched.ChargeBase.IntentOverride = &intentoverride.FlatFee{
148+
OverrideBase: intentoverride.OverrideBase{
149+
Kind: intentoverride.KindEdit,
150+
Description: mo.Some((*string)(nil)),
151+
TaxBehavior: mo.Some((*productcatalog.TaxBehavior)(nil)),
152+
},
153+
FeatureKey: mo.Some((*string)(nil)),
154+
PercentageDiscounts: mo.Some((*productcatalog.PercentageDiscount)(nil)),
155+
}
156+
clearedValues, err := s.adapter.UpdateCharge(ctx, fetched.ChargeBase)
157+
s.Require().NoError(err)
158+
s.requireExplicitClearOverrideMatches(clearedValues.IntentOverride)
159+
160+
fetchedClearedValues, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{
161+
ChargeID: charge.GetChargeID(),
162+
})
163+
s.Require().NoError(err)
164+
s.requireExplicitClearOverrideMatches(fetchedClearedValues.IntentOverride)
165+
166+
rawClearedValues, err := s.dbClient.ChargeFlatFee.Get(ctx, charge.ID)
167+
s.Require().NoError(err)
168+
s.Nil(rawClearedValues.OverrideName)
169+
s.Require().NotNil(rawClearedValues.OverrideDescription)
170+
s.Empty(*rawClearedValues.OverrideDescription)
171+
s.Nil(rawClearedValues.OverrideMetadata)
172+
s.Require().NotNil(rawClearedValues.OverrideTaxBehavior)
173+
s.Empty(*rawClearedValues.OverrideTaxBehavior)
174+
s.Nil(rawClearedValues.OverrideTaxCodeID)
175+
s.Nil(rawClearedValues.OverrideServicePeriodFrom)
176+
s.Nil(rawClearedValues.OverrideServicePeriodTo)
177+
s.Nil(rawClearedValues.OverrideFullServicePeriodFrom)
178+
s.Nil(rawClearedValues.OverrideFullServicePeriodTo)
179+
s.Nil(rawClearedValues.OverrideBillingPeriodFrom)
180+
s.Nil(rawClearedValues.OverrideBillingPeriodTo)
181+
s.Require().NotNil(rawClearedValues.OverrideFeatureKey)
182+
s.Empty(*rawClearedValues.OverrideFeatureKey)
183+
s.Nil(rawClearedValues.OverridePaymentTerm)
184+
s.Nil(rawClearedValues.OverrideProRating)
185+
s.Nil(rawClearedValues.OverrideAmountBeforeProration)
186+
s.Require().NotNil(rawClearedValues.OverridePercentageDiscounts)
187+
s.Nil(rawClearedValues.OverridePercentageDiscounts.Value)
188+
189+
fetchedClearedValues.ChargeBase.IntentOverride = nil
190+
cleared, err := s.adapter.UpdateCharge(ctx, fetchedClearedValues.ChargeBase)
191+
s.Require().NoError(err)
192+
s.Nil(cleared.IntentOverride)
193+
194+
fetchedAfterClear, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{
195+
ChargeID: charge.GetChargeID(),
196+
})
197+
s.Require().NoError(err)
198+
s.Nil(fetchedAfterClear.IntentOverride)
199+
}
200+
201+
func (s *FlatFeeIntentOverrideAdapterSuite) TestNilKindIgnoresStaleOverrideColumns() {
202+
ctx := s.T().Context()
203+
namespace := "flatfee-intentoverride-stale"
204+
charge := s.createCharge(namespace)
205+
206+
_, err := s.dbClient.ChargeFlatFee.UpdateOneID(charge.ID).
207+
SetOverrideName("stale manual name").
208+
SetOverrideFeatureKey("stale-feature").
209+
Save(ctx)
210+
s.Require().NoError(err)
211+
212+
fetched, err := s.adapter.GetByID(ctx, flatfee.GetByIDInput{
213+
ChargeID: charge.GetChargeID(),
214+
})
215+
s.Require().NoError(err)
216+
s.Nil(fetched.IntentOverride)
217+
}
218+
219+
func (s *FlatFeeIntentOverrideAdapterSuite) requireOverrideMatches(
220+
override *intentoverride.FlatFee,
221+
servicePeriod timeutil.ClosedPeriod,
222+
fullServicePeriod timeutil.ClosedPeriod,
223+
billingPeriod timeutil.ClosedPeriod,
224+
taxCodeID string,
225+
) {
226+
s.T().Helper()
227+
228+
s.Require().NotNil(override)
229+
s.Equal(intentoverride.KindEdit, override.Kind)
230+
s.Require().NotNil(override.Name)
231+
s.Equal("manual flat fee", *override.Name)
232+
s.True(override.Description.IsPresent())
233+
s.Equal("manual description", lo.FromPtr(override.Description.OrEmpty()))
234+
s.Require().NotNil(override.Metadata)
235+
s.Equal(models.Metadata{"source": "manual"}, *override.Metadata)
236+
s.True(override.TaxBehavior.IsPresent())
237+
s.Require().NotNil(override.TaxBehavior.OrEmpty())
238+
s.Equal(productcatalog.InclusiveTaxBehavior, *override.TaxBehavior.OrEmpty())
239+
s.Equal(taxCodeID, lo.FromPtr(override.TaxCodeID))
240+
s.True(override.FeatureKey.IsPresent())
241+
s.Equal("manual-feature", lo.FromPtr(override.FeatureKey.OrEmpty()))
242+
s.Require().NotNil(override.ServicePeriod)
243+
s.Equal(servicePeriod, *override.ServicePeriod)
244+
s.Require().NotNil(override.FullServicePeriod)
245+
s.Equal(fullServicePeriod, *override.FullServicePeriod)
246+
s.Require().NotNil(override.BillingPeriod)
247+
s.Equal(billingPeriod, *override.BillingPeriod)
248+
s.Require().NotNil(override.PaymentTerm)
249+
s.Equal(productcatalog.InAdvancePaymentTerm, *override.PaymentTerm)
250+
s.Require().NotNil(override.ProRating)
251+
s.True(override.ProRating.Enabled)
252+
s.Equal(productcatalog.ProRatingModeProratePrices, override.ProRating.Mode)
253+
s.Require().NotNil(override.AmountBeforeProration)
254+
s.Equal(float64(42), override.AmountBeforeProration.InexactFloat64())
255+
s.True(override.PercentageDiscounts.IsPresent())
256+
s.Require().NotNil(override.PercentageDiscounts.OrEmpty())
257+
s.Equal(models.NewPercentage(10), override.PercentageDiscounts.OrEmpty().Percentage)
258+
}
259+
260+
func (s *FlatFeeIntentOverrideAdapterSuite) requireExplicitClearOverrideMatches(override *intentoverride.FlatFee) {
261+
s.T().Helper()
262+
263+
s.Require().NotNil(override)
264+
s.Equal(intentoverride.KindEdit, override.Kind)
265+
s.Nil(override.Name)
266+
s.True(override.Description.IsPresent())
267+
s.Nil(override.Description.OrEmpty())
268+
s.Nil(override.Metadata)
269+
s.True(override.TaxBehavior.IsPresent())
270+
s.Nil(override.TaxBehavior.OrEmpty())
271+
s.Nil(override.TaxCodeID)
272+
s.Nil(override.ServicePeriod)
273+
s.Nil(override.FullServicePeriod)
274+
s.Nil(override.BillingPeriod)
275+
s.True(override.FeatureKey.IsPresent())
276+
s.Nil(override.FeatureKey.OrEmpty())
277+
s.Nil(override.PaymentTerm)
278+
s.Nil(override.ProRating)
279+
s.Nil(override.AmountBeforeProration)
280+
s.True(override.PercentageDiscounts.IsPresent())
281+
s.Nil(override.PercentageDiscounts.OrEmpty())
282+
}
283+
284+
func (s *FlatFeeIntentOverrideAdapterSuite) createCharge(namespace string) flatfee.Charge {
285+
s.T().Helper()
286+
287+
customerID := s.createCustomer(namespace)
288+
taxCodeID := s.taxCodeEnv.CreateTaxCode(s.T(), namespace).ID
289+
servicePeriod := timeutil.ClosedPeriod{
290+
From: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
291+
To: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
292+
}
293+
294+
createdCharges, err := s.adapter.CreateCharges(s.T().Context(), flatfee.CreateChargesInput{
295+
Namespace: namespace,
296+
Intents: []flatfee.IntentWithInitialStatus{
297+
{
298+
Intent: flatfee.Intent{
299+
Intent: chargesmeta.Intent{
300+
Name: "flat-fee-charge",
301+
ManagedBy: billing.SubscriptionManagedLine,
302+
CustomerID: customerID,
303+
Currency: currencyx.Code("USD"),
304+
TaxConfig: productcatalog.TaxCodeConfig{
305+
TaxCodeID: taxCodeID,
306+
},
307+
ServicePeriod: servicePeriod,
308+
FullServicePeriod: servicePeriod,
309+
BillingPeriod: servicePeriod,
310+
},
311+
InvoiceAt: servicePeriod.To,
312+
SettlementMode: productcatalog.CreditThenInvoiceSettlementMode,
313+
PaymentTerm: productcatalog.InAdvancePaymentTerm,
314+
AmountBeforeProration: alpacadecimal.NewFromInt(10),
315+
ProRating: productcatalog.ProRatingConfig{
316+
Enabled: false,
317+
Mode: productcatalog.ProRatingModeProratePrices,
318+
},
319+
},
320+
InitialStatus: flatfee.StatusCreated,
321+
AmountAfterProration: alpacadecimal.NewFromInt(10),
322+
},
323+
},
324+
})
325+
s.Require().NoError(err)
326+
s.Require().Len(createdCharges, 1)
327+
s.Nil(createdCharges[0].IntentOverride)
328+
329+
return createdCharges[0]
330+
}
331+
332+
func (s *FlatFeeIntentOverrideAdapterSuite) createCustomer(namespace string) string {
333+
s.T().Helper()
334+
335+
customer, err := s.dbClient.Customer.Create().
336+
SetNamespace(namespace).
337+
SetName("test-customer").
338+
Save(s.T().Context())
339+
s.Require().NoError(err)
340+
341+
return customer.ID
342+
}

0 commit comments

Comments
 (0)