Skip to content

Commit da4b7ed

Browse files
authored
feat: add expand option to currency list api (#4771)
1 parent aba3ec4 commit da4b7ed

4 files changed

Lines changed: 203 additions & 35 deletions

File tree

openmeter/currencies/adapter/currencies.go

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,12 @@ func (a *adapter) ListCustomCurrencies(ctx context.Context, params currencies.Li
168168
q = filter.ApplyToQuery(q, params.Code, customcurrency.FieldCode)
169169
}
170170

171+
now := clock.Now()
172+
173+
if params.CurrencyExpandOptions.CostBasis {
174+
q = WithCostBasis(q, now)
175+
}
176+
171177
order := entutils.GetOrdering(sortx.OrderDefault)
172178
if !params.Order.IsDefaultValue() {
173179
order = entutils.GetOrdering(params.Order)
@@ -327,7 +333,7 @@ func (a *adapter) GetCurrency(ctx context.Context, params currencies.GetCurrency
327333
return entutils.TransactingRepo(ctx, a, func(ctx context.Context, tx *adapter) (currencies.Currency, error) {
328334
at := clock.Now()
329335

330-
qetQuery := tx.db.CustomCurrency.Query().
336+
q := tx.db.CustomCurrency.Query().
331337
Where(
332338
customcurrency.Namespace(params.Namespace),
333339
customcurrency.ID(params.ID),
@@ -337,7 +343,11 @@ func (a *adapter) GetCurrency(ctx context.Context, params currencies.GetCurrency
337343
),
338344
)
339345

340-
c, err := qetQuery.First(ctx)
346+
if params.CostBasis {
347+
q = WithCostBasis(q, at)
348+
}
349+
350+
c, err := q.First(ctx)
341351
if err != nil {
342352
if entdb.IsNotFound(err) {
343353
return currencies.Currency{}, models.NewGenericNotFoundError(
@@ -353,42 +363,42 @@ func (a *adapter) GetCurrency(ctx context.Context, params currencies.GetCurrency
353363
return currencies.Currency{}, fmt.Errorf("failed to map currency from database: %w", err)
354364
}
355365

356-
if params.CostBasis {
357-
if c.DeletedAt != nil {
358-
at = *c.DeletedAt
359-
}
366+
return curr, nil
367+
})
368+
}
360369

361-
costBasisQuery := tx.db.CurrencyCostBasis.Query().
362-
Where(
363-
currencycostbasis.Namespace(params.Namespace),
364-
currencycostbasis.CurrencyID(params.ID),
365-
currencycostbasis.EffectiveFromLTE(at),
366-
currencycostbasis.Or(
367-
currencycostbasis.EffectiveToIsNil(),
368-
currencycostbasis.EffectiveToGT(at),
370+
func WithCostBasis(q *entdb.CustomCurrencyQuery, at time.Time) *entdb.CustomCurrencyQuery {
371+
return q.WithCostBasisHistory(func(query *entdb.CurrencyCostBasisQuery) {
372+
query.Where(func(s *sql.Selector) {
373+
ct := sql.Table(customcurrency.Table)
374+
375+
s.Join(ct).On(ct.C(customcurrency.FieldID), s.C(currencycostbasis.FieldCurrencyID))
376+
377+
s.Where(
378+
sql.Or(
379+
sql.And(
380+
sql.NotNull(ct.C(customcurrency.FieldDeletedAt)),
381+
sql.ColumnsEQ(s.C(currencycostbasis.FieldDeletedAt), ct.C(currencycostbasis.FieldDeletedAt)),
382+
sql.ColumnsLTE(s.C(currencycostbasis.FieldEffectiveFrom), ct.C(customcurrency.FieldDeletedAt)),
383+
sql.Or(
384+
sql.IsNull(s.C(currencycostbasis.FieldEffectiveTo)),
385+
sql.ColumnsGT(s.C(currencycostbasis.FieldEffectiveTo), ct.C(customcurrency.FieldDeletedAt)),
386+
),
387+
),
388+
sql.And(
389+
sql.IsNull(ct.C(customcurrency.FieldDeletedAt)),
390+
sql.Or(
391+
sql.IsNull(s.C(currencycostbasis.FieldDeletedAt)),
392+
sql.GT(s.C(currencycostbasis.FieldDeletedAt), at),
393+
),
394+
sql.LTE(s.C(currencycostbasis.FieldEffectiveFrom), at),
395+
sql.Or(
396+
sql.IsNull(s.C(currencycostbasis.FieldEffectiveTo)),
397+
sql.GT(s.C(currencycostbasis.FieldEffectiveTo), at),
398+
),
369399
),
370-
)
371-
372-
cbs, err := costBasisQuery.All(ctx)
373-
if err != nil {
374-
if entdb.IsNotFound(err) {
375-
return currencies.Currency{}, models.NewGenericNotFoundError(
376-
fmt.Errorf("currency with id %s not found", params.ID),
377-
)
378-
}
379-
380-
return currencies.Currency{}, fmt.Errorf("failed to get currency: %w", err)
381-
}
382-
383-
curr.CostBasis = lo.ToPtr(
384-
lo.Map[*entdb.CurrencyCostBasis, currencies.CostBasis](cbs,
385-
func(item *entdb.CurrencyCostBasis, _ int) currencies.CostBasis {
386-
return mapCostBasisFromDB(item)
387-
},
388400
),
389401
)
390-
}
391-
392-
return curr, nil
402+
})
393403
})
394404
}

openmeter/currencies/currencyresolver/resolver.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,9 @@ func (r *resolver) BatchResolveCurrencies(ctx context.Context, namespace string,
118118
FilteringOptions: currencies.FilteringOptions{
119119
Union: true,
120120
},
121+
CurrencyExpandOptions: currencies.CurrencyExpandOptions{
122+
CostBasis: true,
123+
},
121124
Namespace: namespace,
122125
ID: idFilter,
123126
Code: codeFilter,

openmeter/currencies/service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type FilteringOptions struct {
6161
type ListCurrenciesInput struct {
6262
pagination.Page
6363
FilteringOptions
64+
CurrencyExpandOptions
6465

6566
Namespace string `json:"namespace"`
6667

openmeter/currencies/service/service_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,72 @@ func TestCurrenciesService(t *testing.T) {
190190
})
191191
})
192192

193+
t.Run("ListWithCostBasis", func(t *testing.T) {
194+
// given:
195+
// - a custom currency with an active USD cost basis
196+
testCases := []struct {
197+
name string
198+
currencyType *currencies.CurrencyType
199+
expectedTotal int
200+
expectFiatResult bool
201+
}{
202+
{
203+
name: "custom currencies",
204+
currencyType: lo.ToPtr(currencies.CurrencyTypeCustom),
205+
expectedTotal: 1,
206+
},
207+
{
208+
name: "custom and fiat currencies",
209+
expectedTotal: 2,
210+
expectFiatResult: true,
211+
},
212+
}
213+
214+
for _, testCase := range testCases {
215+
t.Run(testCase.name, func(t *testing.T) {
216+
// when:
217+
// - currencies are listed with cost-basis data expanded
218+
result, err := env.Service.ListCurrencies(t.Context(), currencies.ListCurrenciesInput{
219+
Page: pagination.NewPage(1, 10),
220+
Namespace: namespace,
221+
CurrencyType: testCase.currencyType,
222+
Code: &filter.FilterString{
223+
In: lo.ToPtr([]string{"TOKENS", "USD"}),
224+
},
225+
CurrencyExpandOptions: currencies.CurrencyExpandOptions{
226+
CostBasis: true,
227+
},
228+
})
229+
230+
// then:
231+
// - custom currencies include active cost-basis data while fiat currencies do not
232+
require.NoError(t, err)
233+
require.Equal(t, testCase.expectedTotal, result.TotalCount)
234+
require.Len(t, result.Items, testCase.expectedTotal)
235+
236+
customCurrencies := lo.Filter(result.Items, func(item currencies.Currency, _ int) bool {
237+
return item.Type() == currencyx.CurrencyTypeCustom
238+
})
239+
require.Len(t, customCurrencies, 1)
240+
assert.Equal(t, createdCurrency.ID, customCurrencies[0].ID)
241+
require.NotNil(t, customCurrencies[0].CostBasis)
242+
require.Len(t, *customCurrencies[0].CostBasis, 1)
243+
assert.Equal(t, usd.ID, (*customCurrencies[0].CostBasis)[0].ID)
244+
245+
fiatCurrencies := lo.Filter(result.Items, func(item currencies.Currency, _ int) bool {
246+
return item.Type() == currencyx.CurrencyTypeFiat
247+
})
248+
if testCase.expectFiatResult {
249+
require.Len(t, fiatCurrencies, 1)
250+
assert.Equal(t, currencyx.Code("USD"), fiatCurrencies[0].Details().Code)
251+
assert.Nil(t, fiatCurrencies[0].CostBasis)
252+
} else {
253+
assert.Empty(t, fiatCurrencies)
254+
}
255+
})
256+
}
257+
})
258+
193259
t.Run("Multiple", func(t *testing.T) {
194260
// given:
195261
// - a custom currency with an active USD cost basis
@@ -318,6 +384,94 @@ func TestCurrenciesService(t *testing.T) {
318384
})
319385
})
320386

387+
t.Run("ListDeletedCurrencyWithCostBasis", func(t *testing.T) {
388+
// given:
389+
// - a deleted custom currency whose cost-basis history contains entries that were expired, active, and future at deletion
390+
deletedNamespace := currenciestestutils.NewTestNamespace(t)
391+
deletedCurrency, err := env.Service.CreateCurrency(t.Context(), currencies.CreateCurrencyInput{
392+
Namespace: deletedNamespace,
393+
CurrencyDetails: currencyx.CurrencyDetails{
394+
Code: "CREDITS",
395+
Name: "Credits",
396+
Symbol: "C",
397+
Precision: 2,
398+
DecimalMark: ".",
399+
ThousandsSeparator: ",",
400+
},
401+
})
402+
require.NoError(t, err)
403+
404+
deletedAt := now.Add(-24 * time.Hour)
405+
costBasisFixtures := []struct {
406+
fiatCode currencyx.Code
407+
effectiveFrom time.Time
408+
effectiveTo *time.Time
409+
active bool
410+
}{
411+
{
412+
fiatCode: "EUR",
413+
effectiveFrom: deletedAt.Add(-48 * time.Hour),
414+
effectiveTo: lo.ToPtr(deletedAt.Add(-time.Hour)),
415+
},
416+
{
417+
fiatCode: "USD",
418+
effectiveFrom: deletedAt.Add(-time.Hour),
419+
effectiveTo: lo.ToPtr(deletedAt.Add(time.Hour)),
420+
active: true,
421+
},
422+
{
423+
fiatCode: "GBP",
424+
effectiveFrom: deletedAt.Add(time.Hour),
425+
},
426+
}
427+
428+
var activeCostBasisID string
429+
for _, fixture := range costBasisFixtures {
430+
costBasis, err := env.Client.CurrencyCostBasis.Create().
431+
SetNamespace(deletedNamespace).
432+
SetCurrencyID(deletedCurrency.ID).
433+
SetFiatCode(fixture.fiatCode).
434+
SetRate(alpacadecimal.RequireFromString("1")).
435+
SetEffectiveFrom(fixture.effectiveFrom).
436+
SetNillableEffectiveTo(fixture.effectiveTo).
437+
SetDeletedAt(deletedAt).
438+
Save(t.Context())
439+
require.NoError(t, err)
440+
441+
if fixture.active {
442+
activeCostBasisID = costBasis.ID
443+
}
444+
}
445+
require.NotEmpty(t, activeCostBasisID)
446+
447+
_, err = env.Client.CustomCurrency.UpdateOneID(deletedCurrency.ID).
448+
SetDeletedAt(deletedAt).
449+
Save(t.Context())
450+
require.NoError(t, err)
451+
452+
// when:
453+
// - the deleted currency is listed with cost-basis data expanded
454+
result, err := env.Service.ListCurrencies(t.Context(), currencies.ListCurrenciesInput{
455+
Page: pagination.NewPage(1, 10),
456+
Namespace: deletedNamespace,
457+
CurrencyType: lo.ToPtr(currencies.CurrencyTypeCustom),
458+
Code: &filter.FilterString{
459+
In: lo.ToPtr([]string{"CREDITS"}),
460+
},
461+
CurrencyExpandOptions: currencies.CurrencyExpandOptions{
462+
CostBasis: true,
463+
},
464+
})
465+
466+
// then:
467+
// - only the cost basis effective at the currency's deletion time is returned
468+
require.NoError(t, err)
469+
require.Len(t, result.Items, 1)
470+
require.NotNil(t, result.Items[0].CostBasis)
471+
require.Len(t, *result.Items[0].CostBasis, 1)
472+
assert.Equal(t, activeCostBasisID, (*result.Items[0].CostBasis)[0].ID)
473+
})
474+
321475
t.Run("List", func(t *testing.T) {
322476
// given:
323477
// - independently persisted custom currencies

0 commit comments

Comments
 (0)