Skip to content

Commit c9f6db1

Browse files
authored
feat: Enable multi-asset position keeping for kWh support (#1457)
* feat: Enable multi-asset position keeping for non-fiat instruments Add NewMoneyFromInstrumentCode to accept both ISO 4217 currency codes (GBP, USD) and non-currency instrument codes (KWH, GPU_HOUR) in position-keeping. This removes the fiat-only restriction that blocked kWh meter read deposits. Changes: - domain/quantity.go: Add NewMoneyFromInstrumentCode that falls through from currency validation to generic instrument creation - service/initiate.go: Update googleMoneyToDomain to use the new function - persistence/postgres_repository.go: Update all Money read paths to accept non-currency instrument codes via NewMoneyFromInstrumentCode - cmd/seed-demo/main.go: Enable kWh meter read deposits with double-entry posting (CREDIT customer, DEBIT GSP inventory) * fix: Document CHAR(3) persistence constraint on instrument codes Address review feedback: add docstring noting that transaction_log_entry currency column is CHAR(3), so codes must be 3 characters. Replace GPU_HOUR test case with GAS (3 chars) to avoid implying longer codes work end-to-end through persistence. * fix: Update docstring example to use 3-char instrument code * fix: address review feedback for multi-asset position keeping - Add banker's rounding in decimalToCents to prevent silent truncation - Add 3-char length validation for non-currency instrument codes to fail fast before CHAR(3) column constraint * refactor: generalize multi-asset support to be instrument-agnostic - Replace hardcoded ENERGY dimension with COUNT as generic placeholder since the dimension is not persisted and only the code matters - Broaden test coverage: KWH, CO2, GPU, GAS all tested as first-class instrument codes at both domain and service layers - Add rejection tests for overlength and single-char codes - Remove kWH-specific naming from tests and docstrings --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 9a01700 commit c9f6db1

6 files changed

Lines changed: 281 additions & 18 deletions

File tree

cmd/seed-demo/main.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -731,9 +731,14 @@ func seedCustomerBalances(ctx context.Context, client currentaccountv1.CurrentAc
731731
return fmt.Errorf("deposit GBP for %s day %d: %w", acct.customerName, day, err)
732732
}
733733

734-
// KWH meter read deposits are skipped for now — position-keeping service
735-
// does not yet support non-fiat instrument codes in InitiateLog.
736-
// TODO: Enable after multi-asset position keeping is implemented.
734+
// KWH meter read deposit — CREDIT customer kWh account, DEBIT GSP inventory account
735+
if err := depositIdempotent(ctx, client, acct.kwhAccountID, dailyKWH, "KWH",
736+
fmt.Sprintf("Meter read %s: %.2f kWh", date.Format("2006-01-02"), dailyKWH),
737+
fmt.Sprintf("METER-%s-%s", acct.partyID, date.Format("20060102")),
738+
acct.gspKwhAccountID, // GSP inventory account is the debit (clearing) side
739+
); err != nil {
740+
return fmt.Errorf("deposit KWH for %s day %d: %w", acct.customerName, day, err)
741+
}
737742
}
738743

739744
fmt.Printf(" %s: %.1f kWh consumed, £%.2f billed (30 days)\n", acct.customerName, totalKWH, totalGBP)

services/position-keeping/adapters/persistence/postgres_repository.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -328,12 +328,12 @@ func (r *PostgresRepository) FindByID(ctx context.Context, logID uuid.UUID) (*do
328328

329329
log.StatusTracking = &statusTracking
330330

331-
// Parse opening balance
331+
// Parse opening balance (supports both currency and non-currency codes)
332332
// Trim spaces since PostgreSQL CHAR(3) may pad with spaces
333333
openingBalanceCurrency = strings.TrimSpace(openingBalanceCurrency)
334-
openingBalance, err := domain.NewMoney(openingBalanceAmount, domain.Currency(openingBalanceCurrency))
334+
openingBalance, err := domain.NewMoneyFromInstrumentCode(openingBalanceAmount, openingBalanceCurrency)
335335
if err != nil {
336-
return fmt.Errorf("failed to create opening balance Money: %w", err)
336+
return fmt.Errorf("failed to create opening balance Money for instrument %q: %w", openingBalanceCurrency, err)
337337
}
338338
log.OpeningBalance = openingBalance
339339
if openingBalanceRecordedAt.Valid {
@@ -819,7 +819,7 @@ func (r *PostgresRepository) insertTransactionLogEntries(ctx context.Context, tx
819819
userID := audit.GetUserFromContext(ctx)
820820

821821
for _, entry := range entries {
822-
// Convert decimal amount to cents (int64)
822+
// Convert decimal amount to cents (int64) - uses 2 decimal places for all instruments
823823
amountCents := decimalToCents(entry.Amount.Amount)
824824

825825
batch.Queue(query,
@@ -958,11 +958,12 @@ func (r *PostgresRepository) loadTransactionLogEntriesTx(ctx context.Context, tx
958958
return fmt.Errorf("failed to scan transaction log entry: %w", err)
959959
}
960960

961-
// Convert cents to decimal and create Money
961+
// Convert cents to decimal and create Money (supports both currency and non-currency codes)
962962
amount := centsToDecimal(amountCents)
963-
money, err := domain.NewMoney(amount, domain.Currency(currency))
963+
currency = strings.TrimSpace(currency)
964+
money, err := domain.NewMoneyFromInstrumentCode(amount, currency)
964965
if err != nil {
965-
return fmt.Errorf("failed to create Money value: %w", err)
966+
return fmt.Errorf("failed to create Money value for instrument %q: %w", currency, err)
966967
}
967968
entry.Amount = money
968969
entry.Direction = domain.ParsePostingDirection(direction)
@@ -1165,11 +1166,12 @@ func (r *PostgresRepository) loadTransactionLogEntriesBatchTx(ctx context.Contex
11651166
return fmt.Errorf("failed to scan transaction log entry in batch: %w", err)
11661167
}
11671168

1168-
// Convert cents to decimal and create Money
1169+
// Convert cents to decimal and create Money (supports both currency and non-currency codes)
11691170
amount := centsToDecimal(amountCents)
1170-
money, err := domain.NewMoney(amount, domain.Currency(currency))
1171+
currency = strings.TrimSpace(currency)
1172+
money, err := domain.NewMoneyFromInstrumentCode(amount, currency)
11711173
if err != nil {
1172-
return fmt.Errorf("failed to create Money value in batch: %w", err)
1174+
return fmt.Errorf("failed to create Money value for instrument %q in batch: %w", currency, err)
11731175
}
11741176
entry.Amount = money
11751177
entry.Direction = domain.ParsePostingDirection(direction)
@@ -1384,12 +1386,12 @@ func (r *PostgresRepository) scanLogsTx(ctx context.Context, tx pgx.Tx, rows pgx
13841386

13851387
log.StatusTracking = &statusTracking
13861388

1387-
// Parse opening balance
1389+
// Parse opening balance (supports both currency and non-currency codes)
13881390
// Trim spaces since PostgreSQL CHAR(3) may pad with spaces
13891391
openingBalanceCurrency = strings.TrimSpace(openingBalanceCurrency)
1390-
openingBalance, err := domain.NewMoney(openingBalanceAmount, domain.Currency(openingBalanceCurrency))
1392+
openingBalance, err := domain.NewMoneyFromInstrumentCode(openingBalanceAmount, openingBalanceCurrency)
13911393
if err != nil {
1392-
return nil, fmt.Errorf("failed to create opening balance Money: %w", err)
1394+
return nil, fmt.Errorf("failed to create opening balance Money for instrument %q: %w", openingBalanceCurrency, err)
13931395
}
13941396
log.OpeningBalance = openingBalance
13951397
if openingBalanceRecordedAt.Valid {
@@ -1509,7 +1511,7 @@ func openingBalanceCurrencyCode(m domain.Money) string {
15091511
// domain.Currency constants.
15101512
// Example: 123.45 -> 12345 cents
15111513
func decimalToCents(d decimal.Decimal) int64 {
1512-
cents := d.Mul(decimal.NewFromInt(100))
1514+
cents := d.Mul(decimal.NewFromInt(100)).RoundBank(0)
15131515
return cents.IntPart()
15141516
}
15151517

services/position-keeping/domain/money_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,3 +373,152 @@ func TestMoney_ToMinorUnits(t *testing.T) {
373373
})
374374
}
375375
}
376+
377+
func TestNewMoneyFromInstrumentCode(t *testing.T) {
378+
tests := []struct {
379+
name string
380+
amount decimal.Decimal
381+
code string
382+
wantErr bool
383+
expectedCode string
384+
expectedAmount decimal.Decimal
385+
}{
386+
{
387+
name: "valid currency GBP",
388+
amount: decimal.NewFromFloat(100.50),
389+
code: "GBP",
390+
expectedCode: "GBP",
391+
expectedAmount: decimal.NewFromFloat(100.50),
392+
},
393+
{
394+
name: "valid currency USD",
395+
amount: decimal.NewFromFloat(42.00),
396+
code: "USD",
397+
expectedCode: "USD",
398+
expectedAmount: decimal.NewFromFloat(42.00),
399+
},
400+
{
401+
name: "energy instrument KWH",
402+
amount: decimal.NewFromFloat(8.54),
403+
code: "KWH",
404+
expectedCode: "KWH",
405+
expectedAmount: decimal.NewFromFloat(8.54),
406+
},
407+
{
408+
name: "gas instrument",
409+
amount: decimal.NewFromFloat(1.25),
410+
code: "GAS",
411+
expectedCode: "GAS",
412+
expectedAmount: decimal.NewFromFloat(1.25),
413+
},
414+
{
415+
name: "carbon credit instrument",
416+
amount: decimal.NewFromFloat(50.00),
417+
code: "CO2",
418+
expectedCode: "CO2",
419+
expectedAmount: decimal.NewFromFloat(50.00),
420+
},
421+
{
422+
name: "compute instrument",
423+
amount: decimal.NewFromFloat(3.75),
424+
code: "GPU",
425+
expectedCode: "GPU",
426+
expectedAmount: decimal.NewFromFloat(3.75),
427+
},
428+
{
429+
name: "empty code",
430+
amount: decimal.NewFromInt(100),
431+
code: "",
432+
wantErr: true,
433+
},
434+
{
435+
name: "overlength code rejected",
436+
amount: decimal.NewFromInt(100),
437+
code: "KWHR",
438+
wantErr: true,
439+
},
440+
{
441+
name: "single char code rejected",
442+
amount: decimal.NewFromInt(100),
443+
code: "X",
444+
wantErr: true,
445+
},
446+
{
447+
name: "zero amount with non-currency",
448+
amount: decimal.Zero,
449+
code: "CO2",
450+
expectedCode: "CO2",
451+
expectedAmount: decimal.Zero,
452+
},
453+
{
454+
name: "negative amount with non-currency",
455+
amount: decimal.NewFromFloat(-5.00),
456+
code: "GAS",
457+
expectedCode: "GAS",
458+
expectedAmount: decimal.NewFromFloat(-5.00),
459+
},
460+
}
461+
462+
for _, tt := range tests {
463+
t.Run(tt.name, func(t *testing.T) {
464+
money, err := NewMoneyFromInstrumentCode(tt.amount, tt.code)
465+
466+
if tt.wantErr {
467+
if err == nil {
468+
t.Errorf("Expected error but got nil")
469+
}
470+
return
471+
}
472+
473+
if err != nil {
474+
t.Fatalf("Unexpected error: %v", err)
475+
}
476+
477+
if money.Instrument.Code != tt.expectedCode {
478+
t.Errorf("Expected instrument code %q, got %q", tt.expectedCode, money.Instrument.Code)
479+
}
480+
481+
if !money.Amount.Equal(tt.expectedAmount) {
482+
t.Errorf("Expected amount %v, got %v", tt.expectedAmount, money.Amount)
483+
}
484+
})
485+
}
486+
}
487+
488+
func TestNewMoneyFromInstrumentCode_RoundTrip(t *testing.T) {
489+
// Verify that non-currency instruments round-trip through the same paths as fiat
490+
instruments := []struct {
491+
code string
492+
amount decimal.Decimal
493+
}{
494+
{"KWH", decimal.NewFromFloat(8.54)},
495+
{"CO2", decimal.NewFromFloat(50.00)},
496+
{"GPU", decimal.NewFromFloat(3.75)},
497+
{"GAS", decimal.NewFromFloat(1.25)},
498+
}
499+
500+
for _, inst := range instruments {
501+
t.Run(inst.code, func(t *testing.T) {
502+
m, err := NewMoneyFromInstrumentCode(inst.amount, inst.code)
503+
if err != nil {
504+
t.Fatalf("NewMoneyFromInstrumentCode(%s) error = %v", inst.code, err)
505+
}
506+
507+
if MoneyCurrency(m) != Currency(inst.code) {
508+
t.Errorf("MoneyCurrency() = %q, want %q", MoneyCurrency(m), inst.code)
509+
}
510+
511+
if !m.IsPositive() {
512+
t.Errorf("Expected positive %s amount", inst.code)
513+
}
514+
515+
zero, err := NewMoneyFromInstrumentCode(decimal.Zero, inst.code)
516+
if err != nil {
517+
t.Fatalf("NewMoneyFromInstrumentCode(zero, %s) error = %v", inst.code, err)
518+
}
519+
if !zero.IsZero() {
520+
t.Errorf("Expected zero %s amount", inst.code)
521+
}
522+
})
523+
}
524+
}

services/position-keeping/domain/quantity.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
package domain
1313

1414
import (
15+
"fmt"
16+
1517
"github.com/meridianhub/meridian/shared/domain/money"
1618
sharedamount "github.com/meridianhub/meridian/shared/pkg/amount"
1719
"github.com/meridianhub/meridian/shared/platform/quantity"
@@ -288,6 +290,42 @@ func ParseCurrency(s string) (Currency, error) {
288290
return money.ParseCurrency(s)
289291
}
290292

293+
// NewMoneyFromInstrumentCode creates a Money value from any instrument code (currency or non-currency).
294+
// For valid ISO 4217 currencies (GBP, USD, etc.), it uses the standard currency path with correct precision.
295+
// For non-currency instrument codes, it creates a Money value using the code directly, bypassing
296+
// currency validation. This enables position-keeping to track any registered instrument while
297+
// reusing the same Money type for persistence and domain logic.
298+
//
299+
// Persistence constraint: the transaction_log_entry.currency column is CHAR(3), so codes must be
300+
// exactly 3 characters to persist correctly.
301+
func NewMoneyFromInstrumentCode(amount decimal.Decimal, code string) (Money, error) {
302+
if code == "" {
303+
return Money{}, ErrEmptyCode
304+
}
305+
306+
// Try currency path first (preserves correct precision for fiat)
307+
cur := Currency(code)
308+
if cur.IsValid() {
309+
return NewMoney(amount, cur)
310+
}
311+
312+
// Fail fast: the transaction_log_entry.currency column is CHAR(3), so non-currency
313+
// codes must also be exactly 3 characters to persist correctly.
314+
if len(code) != 3 {
315+
return Money{}, fmt.Errorf("%w: non-currency instrument code %q must be exactly 3 characters for persistence", ErrInvalidCodeFormat, code)
316+
}
317+
318+
// Non-currency instrument: use precision 2 to match the persistence layer's
319+
// decimalToCents/centsToDecimal which assumes 2 decimal places for all instruments.
320+
// Use COUNT as a dimension-agnostic placeholder; the dimension is not stored in the
321+
// transaction_log_entry table and only the code matters for persistence round-trips.
322+
inst, err := quantity.NewInstrument(code, 1, "COUNT", 2)
323+
if err != nil {
324+
return Money{}, err
325+
}
326+
return quantity.NewMoney(amount, inst), nil
327+
}
328+
291329
// =============================================================================
292330
// Money Accessor Functions (backward compatibility helpers)
293331
// =============================================================================

services/position-keeping/service/initiate.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ func validateInitiateRequest(req *positionkeepingv1.InitiateFinancialPositionLog
159159

160160
// googleMoneyToDomain converts google.type.Money to domain.Money.
161161
// This is the shared conversion logic for all proto-to-domain money conversions.
162+
// Accepts both ISO 4217 currency codes (GBP, USD) and non-currency instrument codes (KWH, GAS).
162163
func googleMoneyToDomain(m *money.Money) (domain.Money, error) {
163164
if m == nil {
164165
return domain.Money{}, nil
@@ -169,7 +170,7 @@ func googleMoneyToDomain(m *money.Money) (domain.Money, error) {
169170
nanos := decimal.NewFromInt(int64(m.Nanos)).Div(decimal.NewFromInt(1_000_000_000))
170171
totalAmount := amount.Add(nanos)
171172

172-
return domain.NewMoney(totalAmount, domain.Currency(m.CurrencyCode))
173+
return domain.NewMoneyFromInstrumentCode(totalAmount, m.CurrencyCode)
173174
}
174175

175176
// protoEntryToDomain converts a proto TransactionLogEntry to domain.

services/position-keeping/service/initiate_validation_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,74 @@ func TestWithAccountValidator_Option(t *testing.T) {
388388
})
389389
}
390390

391+
func TestInitiateFinancialPositionLog_NonCurrencyInstrumentCodes(t *testing.T) {
392+
instruments := []struct {
393+
code string
394+
units int64
395+
nanos int32
396+
desc string
397+
}{
398+
{"KWH", 8, 540000000, "energy meter read"},
399+
{"CO2", 50, 0, "carbon credit purchase"},
400+
{"GPU", 3, 750000000, "compute allocation"},
401+
{"GAS", 1, 250000000, "gas meter read"},
402+
}
403+
404+
for _, inst := range instruments {
405+
t.Run(inst.code, func(t *testing.T) {
406+
ctx := context.Background()
407+
mockRepo := new(MockRepository)
408+
mockEventPublisher := domain.NewInMemoryEventPublisher()
409+
mockIdempotency := new(MockIdempotencyService)
410+
mockMeasurementRepo := new(MockMeasurementRepository)
411+
412+
svc, err := service.NewPositionKeepingService(
413+
mockRepo,
414+
mockMeasurementRepo,
415+
mockEventPublisher,
416+
mockIdempotency,
417+
newTestOutboxPublisher(t),
418+
)
419+
require.NoError(t, err)
420+
421+
accountID := inst.code + "-account-001"
422+
req := &positionkeepingv1.InitiateFinancialPositionLogRequest{
423+
AccountId: accountID,
424+
InitialEntry: &positionkeepingv1.TransactionLogEntry{
425+
EntryId: uuid.NewString(),
426+
TransactionId: uuid.NewString(),
427+
AccountId: accountID,
428+
Amount: &commonv1.MoneyAmount{
429+
Amount: &money.Money{
430+
CurrencyCode: inst.code,
431+
Units: inst.units,
432+
Nanos: inst.nanos,
433+
},
434+
},
435+
Direction: commonv1.PostingDirection_POSTING_DIRECTION_CREDIT,
436+
Description: inst.desc,
437+
},
438+
}
439+
440+
mockRepo.On("CreateWithOutbox", ctx, mock.AnythingOfType("*domain.FinancialPositionLog")).Return(nil)
441+
442+
resp, err := svc.InitiateFinancialPositionLog(ctx, req)
443+
444+
require.NoError(t, err, "instrument %s should be accepted", inst.code)
445+
require.NotNil(t, resp)
446+
assert.Equal(t, accountID, resp.Log.AccountId)
447+
448+
require.NotEmpty(t, resp.Log.TransactionLogEntries)
449+
entry := resp.Log.TransactionLogEntries[0]
450+
assert.Equal(t, inst.code, entry.Amount.Amount.CurrencyCode)
451+
assert.Equal(t, inst.units, entry.Amount.Amount.Units)
452+
assert.Equal(t, inst.nanos, entry.Amount.Amount.Nanos)
453+
454+
mockRepo.AssertExpectations(t)
455+
})
456+
}
457+
}
458+
391459
func TestWithAccountValidationEnabled_Option(t *testing.T) {
392460
mockRepo := new(MockRepository)
393461
mockEventPublisher := domain.NewInMemoryEventPublisher()

0 commit comments

Comments
 (0)