Skip to content

Commit db0e352

Browse files
authored
feat: rewrite financial-accounting adapters and PostingService for asset-agnostic amounts (#2117)
* feat: rewrite financial-accounting adapters and PostingService for asset-agnostic amounts - Add InstrumentAmountConverter with InstrumentResolver dependency for proper instrument metadata resolution (dimension, precision) from Reference Data, with fallback to legacy ParseCurrency - Export ToProtoInstrumentAmount for use by other packages - Redesign DepositEvent from cent-based (AmountCents int64, Currency) to decimal string-based (Amount string, InstrumentCode string) for asset-agnostic support (KWH, TONNE_CO2E, GPU_HOUR, etc.) - Add InstrumentResolver to PostingService and PostingServiceConfig - Rewrite buildDepositPostings to use resolveInstrument() which tries InstrumentResolver first, falls back to legacy currency lookup - Update deposit consumer to convert proto AmountCents to decimal string - Add RecordPostingAmountFloat for asset-agnostic metrics - Remove decimalFromCents (no longer needed) * fix: address review feedback on asset-agnostic posting layer - Fix critical: deposit consumer no longer divides by 100; passes raw AmountMinorUnit to PostingService which converts using resolved instrument precision (e.g., /1000 for 3dp KWH, /1 for 0dp JPY) - Fix major: use separate Prometheus counter (posting_amount_major_total) for float-based major-unit amounts to avoid mixing with legacy cents counter - Fix major: tighten InstrumentAmountConverter fallback - when resolver is configured but fails, only fall back to ParseCurrency for known ISO 4217 codes; unknown instrument codes now error instead of silently inferring precision - Add DepositEvent.AmountMinorUnit field for proto consumer path (mutually exclusive with Amount string for direct callers) - Pass resolved amount to metrics instead of raw event data - Add tests for minor-unit conversion (KWH 3dp, JPY 0dp) - Add test for rejection of unknown non-currency instruments --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 69a34f1 commit db0e352

8 files changed

Lines changed: 652 additions & 157 deletions

File tree

services/financial-accounting/adapters/messaging/deposit_consumer.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"buf.build/go/protovalidate"
1111
"github.com/google/uuid"
12+
1213
eventsv1 "github.com/meridianhub/meridian/api/proto/meridian/events/v1"
1314
"github.com/meridianhub/meridian/services/financial-accounting/service"
1415
"github.com/meridianhub/meridian/shared/pkg/idempotency"
@@ -217,12 +218,16 @@ func (dc *DepositConsumer) processAndStoreResult(ctx context.Context, event *eve
217218
return fmt.Errorf("%w: %v", ErrInvalidCurrency, event.InstrumentCode)
218219
}
219220

221+
// Pass the raw minor-unit value as a string. The PostingService will
222+
// resolve instrument precision and convert from minor to major units.
223+
// This avoids hardcoding a /100 divisor that would be wrong for
224+
// non-2dp instruments (e.g., JPY=0dp, KWH=3dp).
220225
depositEvent := service.DepositEvent{
221-
AccountID: event.AccountId,
222-
AmountCents: event.AmountCents,
223-
Currency: currencyCode,
224-
CorrelationID: event.CorrelationId,
225-
ValueDate: event.ValueDate.AsTime(),
226+
AccountID: event.AccountId,
227+
AmountMinorUnit: event.AmountCents,
228+
InstrumentCode: currencyCode,
229+
CorrelationID: event.CorrelationId,
230+
ValueDate: event.ValueDate.AsTime(),
226231
}
227232

228233
if err := dc.postingService.ProcessDeposit(ctx, depositEvent); err != nil {

services/financial-accounting/observability/metrics.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ var (
8484
[]string{"direction", "currency"},
8585
)
8686

87+
postingAmountMajorTotal = promauto.NewCounterVec(
88+
prometheus.CounterOpts{
89+
Name: "financial_accounting_posting_amount_major_total",
90+
Help: "Total amount posted in major instrument units by instrument and direction",
91+
},
92+
[]string{"direction", "instrument"},
93+
)
94+
8795
// Booking log metrics
8896
bookingLogsTotal = promauto.NewCounterVec(
8997
prometheus.CounterOpts{
@@ -237,6 +245,13 @@ func RecordPostingAmount(direction, currency string, amountCents int64) {
237245
postingAmountTotal.WithLabelValues(direction, currency).Add(float64(amountCents))
238246
}
239247

248+
// RecordPostingAmountFloat records the amount of a posting in major units for tracking total volume.
249+
// This uses a separate counter from RecordPostingAmount (which tracks minor units/cents)
250+
// to avoid mixing units in the same metric series.
251+
func RecordPostingAmountFloat(direction, instrumentCode string, amount float64) {
252+
postingAmountMajorTotal.WithLabelValues(direction, instrumentCode).Add(amount)
253+
}
254+
240255
// RecordBookingLog records a booking log creation with status.
241256
func RecordBookingLog(status string) {
242257
bookingLogsTotal.WithLabelValues(status).Inc()

services/financial-accounting/service/adapters.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package service
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67

@@ -12,6 +13,7 @@ import (
1213
financialaccountingv1 "github.com/meridianhub/meridian/api/proto/meridian/financial_accounting/v1"
1314
quantityv1 "github.com/meridianhub/meridian/api/proto/meridian/quantity/v1"
1415
"github.com/meridianhub/meridian/services/financial-accounting/domain"
16+
"github.com/meridianhub/meridian/shared/pkg/refdata"
1517
)
1618

1719
var (
@@ -21,8 +23,67 @@ var (
2123

2224
// ErrNilInstrumentAmount is returned when proto InstrumentAmount is nil
2325
ErrNilInstrumentAmount = errors.New("instrument amount cannot be nil")
26+
27+
// ErrEmptyInstrumentCode is returned when the instrument code is empty
28+
ErrEmptyInstrumentCode = errors.New("instrument code cannot be empty")
2429
)
2530

31+
// InstrumentAmountConverter converts between proto InstrumentAmount and domain Money
32+
// using an InstrumentResolver for proper instrument metadata lookup.
33+
type InstrumentAmountConverter struct {
34+
resolver refdata.InstrumentResolver
35+
}
36+
37+
// NewInstrumentAmountConverter creates a converter with the given resolver.
38+
// If resolver is nil, conversions fall back to legacy currency-based resolution.
39+
func NewInstrumentAmountConverter(resolver refdata.InstrumentResolver) *InstrumentAmountConverter {
40+
return &InstrumentAmountConverter{resolver: resolver}
41+
}
42+
43+
// FromProto converts protobuf InstrumentAmount to domain Money using the InstrumentResolver.
44+
// When a resolver is configured, it is the primary path. Legacy ParseCurrency fallback
45+
// only applies when the resolver is nil (not configured) or for known ISO 4217 currencies
46+
// when the resolver returns ErrUnknownInstrument.
47+
func (c *InstrumentAmountConverter) FromProto(ctx context.Context, ia *quantityv1.InstrumentAmount) (domain.Money, error) {
48+
if ia == nil {
49+
return domain.Money{}, ErrNilInstrumentAmount
50+
}
51+
if ia.InstrumentCode == "" {
52+
return domain.Money{}, ErrEmptyInstrumentCode
53+
}
54+
55+
amount, err := decimal.NewFromString(ia.Amount)
56+
if err != nil {
57+
return domain.Money{}, fmt.Errorf("invalid amount: %w", err)
58+
}
59+
60+
// Try resolver first for proper instrument metadata
61+
if c.resolver != nil {
62+
props, resolveErr := c.resolver.Resolve(ctx, ia.InstrumentCode)
63+
if resolveErr == nil {
64+
inst, instErr := domain.NewInstrument(props.Code, uint32(ia.Version), props.Dimension, props.Precision)
65+
if instErr == nil {
66+
return domain.NewMoney(amount, inst), nil
67+
}
68+
return domain.Money{}, fmt.Errorf("invalid instrument metadata for %q: %w", ia.InstrumentCode, instErr)
69+
}
70+
// Only fall back to legacy for known currencies; non-currency codes must
71+
// come from the resolver to avoid silently guessed precision.
72+
if _, currErr := domain.ParseCurrency(ia.InstrumentCode); currErr != nil {
73+
return domain.Money{}, fmt.Errorf("failed to resolve instrument %q: %w", ia.InstrumentCode, resolveErr)
74+
}
75+
}
76+
77+
// Legacy fallback: resolver is nil or code is a known ISO 4217 currency
78+
return fromProtoInstrumentAmount(ia)
79+
}
80+
81+
// ToProtoInstrumentAmount converts domain Money to protobuf InstrumentAmount.
82+
// Preserves full decimal precision via string representation.
83+
func ToProtoInstrumentAmount(m domain.Money) *quantityv1.InstrumentAmount {
84+
return toProtoInstrumentAmount(m)
85+
}
86+
2687
// parseUUID parses and validates a UUID string.
2788
//
2889
// Returns ErrEmptyUUID if the string is empty.

services/financial-accounting/service/adapters_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package service
22

33
import (
4+
"context"
45
"testing"
56
"time"
67

@@ -12,6 +13,7 @@ import (
1213
commonv1 "github.com/meridianhub/meridian/api/proto/meridian/common/v1"
1314
quantityv1 "github.com/meridianhub/meridian/api/proto/meridian/quantity/v1"
1415
"github.com/meridianhub/meridian/services/financial-accounting/domain"
16+
"github.com/meridianhub/meridian/shared/pkg/refdata"
1517
)
1618

1719
func TestFromProtoPostingDirection(t *testing.T) {
@@ -259,3 +261,168 @@ func TestParseUUID_Invalid(t *testing.T) {
259261
require.Error(t, err)
260262
assert.Contains(t, err.Error(), "invalid UUID format")
261263
}
264+
265+
// =============================================================================
266+
// InstrumentAmountConverter tests
267+
// =============================================================================
268+
269+
// adapterMockInstrumentResolver implements refdata.InstrumentResolver for testing.
270+
type adapterMockInstrumentResolver struct {
271+
instruments map[string]refdata.InstrumentProperties
272+
err error
273+
}
274+
275+
func (m *adapterMockInstrumentResolver) Resolve(_ context.Context, code string) (refdata.InstrumentProperties, error) {
276+
if m.err != nil {
277+
return refdata.InstrumentProperties{}, m.err
278+
}
279+
if props, ok := m.instruments[code]; ok {
280+
return props, nil
281+
}
282+
return refdata.InstrumentProperties{}, refdata.ErrUnknownInstrument
283+
}
284+
285+
func TestInstrumentAmountConverter_FromProto_WithResolver(t *testing.T) {
286+
resolver := &adapterMockInstrumentResolver{
287+
instruments: map[string]refdata.InstrumentProperties{
288+
"GBP": {Code: "GBP", Dimension: "CURRENCY", Precision: 2, RoundingMode: "HALF_EVEN"},
289+
"KWH": {Code: "KWH", Dimension: "ENERGY", Precision: 3, RoundingMode: "HALF_UP"},
290+
"TONNE_CO2E": {Code: "TONNE_CO2E", Dimension: "CARBON", Precision: 6, RoundingMode: "HALF_EVEN"},
291+
"GPU_HOUR": {Code: "GPU_HOUR", Dimension: "COMPUTE", Precision: 4, RoundingMode: "HALF_EVEN"},
292+
},
293+
}
294+
converter := NewInstrumentAmountConverter(resolver)
295+
ctx := context.Background()
296+
297+
tests := []struct {
298+
name string
299+
instrumentCode string
300+
amount string
301+
wantCode string
302+
wantDimension string
303+
wantPrecision int
304+
}{
305+
{"GBP currency", "GBP", "100.50", "GBP", "CURRENCY", 2},
306+
{"KWH energy", "KWH", "123.456", "KWH", "ENERGY", 3},
307+
{"TONNE_CO2E carbon", "TONNE_CO2E", "0.001234", "TONNE_CO2E", "CARBON", 6},
308+
{"GPU_HOUR compute", "GPU_HOUR", "24.5000", "GPU_HOUR", "COMPUTE", 4},
309+
}
310+
311+
for _, tt := range tests {
312+
t.Run(tt.name, func(t *testing.T) {
313+
ia := &quantityv1.InstrumentAmount{
314+
Amount: tt.amount,
315+
InstrumentCode: tt.instrumentCode,
316+
Version: 1,
317+
}
318+
319+
result, err := converter.FromProto(ctx, ia)
320+
require.NoError(t, err)
321+
assert.Equal(t, tt.wantCode, result.Instrument.Code)
322+
assert.Equal(t, tt.wantDimension, result.Instrument.Dimension)
323+
assert.Equal(t, tt.wantPrecision, result.Instrument.Precision)
324+
})
325+
}
326+
}
327+
328+
func TestInstrumentAmountConverter_FromProto_NilInput(t *testing.T) {
329+
converter := NewInstrumentAmountConverter(nil)
330+
_, err := converter.FromProto(context.Background(), nil)
331+
require.ErrorIs(t, err, ErrNilInstrumentAmount)
332+
}
333+
334+
func TestInstrumentAmountConverter_FromProto_EmptyInstrumentCode(t *testing.T) {
335+
converter := NewInstrumentAmountConverter(nil)
336+
ia := &quantityv1.InstrumentAmount{Amount: "100", InstrumentCode: ""}
337+
_, err := converter.FromProto(context.Background(), ia)
338+
require.ErrorIs(t, err, ErrEmptyInstrumentCode)
339+
}
340+
341+
func TestInstrumentAmountConverter_FromProto_InvalidAmount(t *testing.T) {
342+
converter := NewInstrumentAmountConverter(nil)
343+
ia := &quantityv1.InstrumentAmount{Amount: "not-a-number", InstrumentCode: "GBP"}
344+
_, err := converter.FromProto(context.Background(), ia)
345+
require.Error(t, err)
346+
assert.Contains(t, err.Error(), "invalid amount")
347+
}
348+
349+
func TestInstrumentAmountConverter_FromProto_FallbackToLegacy(t *testing.T) {
350+
// Resolver returns error - should fall back to ParseCurrency for GBP
351+
resolver := &adapterMockInstrumentResolver{err: refdata.ErrUnknownInstrument}
352+
converter := NewInstrumentAmountConverter(resolver)
353+
354+
ia := &quantityv1.InstrumentAmount{
355+
Amount: "100.00",
356+
InstrumentCode: "GBP",
357+
Version: 1,
358+
}
359+
360+
result, err := converter.FromProto(context.Background(), ia)
361+
require.NoError(t, err)
362+
assert.Equal(t, "GBP", result.Instrument.Code)
363+
assert.Equal(t, "100", result.Amount.String())
364+
}
365+
366+
func TestInstrumentAmountConverter_FromProto_NilResolver(t *testing.T) {
367+
// No resolver - should use legacy fromProtoInstrumentAmount
368+
converter := NewInstrumentAmountConverter(nil)
369+
370+
ia := &quantityv1.InstrumentAmount{
371+
Amount: "50.25",
372+
InstrumentCode: "USD",
373+
Version: 1,
374+
}
375+
376+
result, err := converter.FromProto(context.Background(), ia)
377+
require.NoError(t, err)
378+
assert.Equal(t, "USD", result.Instrument.Code)
379+
assert.Equal(t, "50.25", result.Amount.String())
380+
}
381+
382+
func TestInstrumentAmountConverter_Roundtrip(t *testing.T) {
383+
resolver := &adapterMockInstrumentResolver{
384+
instruments: map[string]refdata.InstrumentProperties{
385+
"KWH": {Code: "KWH", Dimension: "ENERGY", Precision: 3, RoundingMode: "HALF_UP"},
386+
},
387+
}
388+
converter := NewInstrumentAmountConverter(resolver)
389+
390+
ia := &quantityv1.InstrumentAmount{
391+
Amount: "123.456",
392+
InstrumentCode: "KWH",
393+
Version: 1,
394+
}
395+
396+
money, err := converter.FromProto(context.Background(), ia)
397+
require.NoError(t, err)
398+
399+
roundtripped := ToProtoInstrumentAmount(money)
400+
assert.Equal(t, "123.456", roundtripped.Amount)
401+
assert.Equal(t, "KWH", roundtripped.InstrumentCode)
402+
}
403+
404+
func TestInstrumentAmountConverter_FromProto_RejectsUnknownNonCurrency(t *testing.T) {
405+
// Resolver is configured but doesn't know "UNKNOWN_ASSET". Since it's not
406+
// a known ISO 4217 currency, it should error rather than infer precision.
407+
resolver := &adapterMockInstrumentResolver{err: refdata.ErrUnknownInstrument}
408+
converter := NewInstrumentAmountConverter(resolver)
409+
410+
ia := &quantityv1.InstrumentAmount{
411+
Amount: "100.00",
412+
InstrumentCode: "UNKNOWN_ASSET",
413+
Version: 1,
414+
}
415+
416+
_, err := converter.FromProto(context.Background(), ia)
417+
require.Error(t, err)
418+
assert.Contains(t, err.Error(), "failed to resolve instrument")
419+
}
420+
421+
func TestToProtoInstrumentAmount_Exported(t *testing.T) {
422+
inst := domain.MustCurrencyToInstrument(domain.CurrencyGBP)
423+
m := domain.NewMoney(decimal.NewFromInt(42), inst)
424+
425+
result := ToProtoInstrumentAmount(m)
426+
assert.Equal(t, "GBP", result.InstrumentCode)
427+
assert.Equal(t, "42", result.Amount)
428+
}

0 commit comments

Comments
 (0)