Skip to content

Commit 1202a9b

Browse files
authored
feat: Update current-account persistence layer for dimension-agnostic Amount (#1251)
* feat: Add precision column migrations for account, lien, withdrawal tables Add precision column to account table (default 2 for existing CURRENCY accounts) and instrument_code/dimension/precision columns to lien and withdrawal tables. Follows CockroachDB rules: separate migrations for DDL and DML. * feat: Update persistence entities and repositories for dimension-agnostic Amount Add Precision field to CurrentAccountEntity, instrument_code/dimension/precision fields to LienEntity and WithdrawalEntity. Update toDomain/toEntity functions to use entity.Precision instead of hardcoded 0, enabling correct Amount reconstruction for non-CURRENCY instruments (ENERGY, CARBON, COMPUTE). Legacy currency column preserved for backward compatibility; instrument_code is the canonical source with fallback to currency for pre-migration rows. * test: Update test table schemas for precision columns; add multi-asset round-trip tests Update inline table creation SQL in all persistence test helpers to include precision, instrument_code, and dimension columns. Add multi_asset_repository_test.go with round-trip tests for ENERGY (KWH/precision=3), CARBON (CARBON_CREDIT/precision=4), COMPUTE (GPU_HOUR/precision=6) accounts, liens, and withdrawals. Includes backward compatibility test for legacy currency column fallback. * fix: Add check constraints and correct backfill logic for precision migrations Address CodeRabbit feedback: - Add CHECK constraints to prevent negative precision on account, lien, withdrawal - Fix backfill to handle JPY exception (precision=0) using CASE expression - Fix lien/withdrawal backfill to JOIN account for authoritative dimension/precision values instead of hardcoding CURRENCY/2 * fix: Use instrument_code for lien consistency check instead of legacy currency column COUNT(DISTINCT currency) returns 2 for a mix of CURRENCY and non-CURRENCY liens because non-CURRENCY liens store empty string in currency. Use instrument_code instead for accurate multi-asset consistency validation. * fix: Add instrument_code non-empty constraints after backfill The instrument_code non-empty check constraints must be in migration 004 (after the backfill) rather than 003, because existing rows have the default empty string until backfilled. Adding these constraints before the backfill would reject existing rows. Addresses CodeRabbit feedback on missing integrity constraints. * fix: Update schema validation test entities to include instrument_code The new chk_lien_instrument_code_non_empty and chk_withdrawal_instrument_code_non_empty constraints require instrument_code to be non-empty. Update testLienEntity and testWithdrawalEntity to populate InstrumentCode, Dimension, and Precision fields on all created test rows. * fix: Add precision/instrument_code columns to service-layer test DDL Four service integration tests use manual DDL to create test schemas. Add precision INT NOT NULL DEFAULT 2 to all account table definitions, and add instrument_code/dimension/precision columns to lien table definitions, matching the schema added in migration 20260226000003. * chore: Upgrade golang.org/x/net to v0.51.0 (GO-2026-4559) Fixes vulnerability GO-2026-4559: sending certain HTTP/2 frames can cause a server to panic in golang.org/x/net. Upgrade from v0.50.0 to v0.51.0 which contains the fix. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 472263c commit 1202a9b

21 files changed

Lines changed: 523 additions & 83 deletions

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.26.0
44

55
require (
66
ariga.io/atlas-provider-gorm v0.6.0
7+
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1
78
buf.build/go/protovalidate v1.1.3
89
connectrpc.com/connect v1.19.1
910
connectrpc.com/vanguard v0.3.0
@@ -54,7 +55,6 @@ require (
5455
)
5556

5657
require (
57-
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect
5858
github.com/beorn7/perks v1.0.1 // indirect
5959
github.com/bits-and-blooms/bitset v1.24.2 // indirect
6060
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -129,7 +129,7 @@ require (
129129
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
130130
github.com/googleapis/go-gorm-spanner v1.8.6 // indirect
131131
github.com/googleapis/go-sql-spanner v1.17.0 // indirect
132-
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
132+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0
133133
github.com/hashicorp/golang-lru/v2 v2.0.7
134134
github.com/jackc/pgpassfile v1.0.0 // indirect
135135
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -172,14 +172,14 @@ require (
172172
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
173173
golang.org/x/crypto v0.48.0 // indirect
174174
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
175-
golang.org/x/net v0.50.0
175+
golang.org/x/net v0.51.0
176176
golang.org/x/oauth2 v0.35.0 // indirect
177177
golang.org/x/sync v0.19.0
178178
golang.org/x/sys v0.41.0 // indirect
179179
golang.org/x/text v0.34.0 // indirect
180180
golang.org/x/time v0.14.0
181181
google.golang.org/api v0.249.0 // indirect
182-
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
182+
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57
183183
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
184184
gopkg.in/yaml.v3 v3.0.1
185185
gorm.io/driver/mysql v1.5.7 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,8 +1402,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
14021402
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
14031403
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
14041404
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
1405-
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
1406-
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
1405+
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
1406+
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
14071407
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
14081408
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
14091409
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

services/current-account/adapters/persistence/account_entity.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type CurrentAccountEntity struct {
2727
AccountType string `gorm:"column:account_type;type:varchar(50);not null"` // current, savings, etc. (legacy)
2828
InstrumentCode string `gorm:"column:instrument_code;type:varchar(32);not null;default:'GBP'"` // Instrument code (e.g. GBP, kWh)
2929
Dimension string `gorm:"column:dimension;type:varchar(20);not null;default:'CURRENCY'"` // Asset dimension (e.g. CURRENCY, ELECTRICITY)
30+
Precision int `gorm:"column:precision;not null;default:2"` // Decimal places for the instrument (2 for GBP, 3 for kWh, 4 for CARBON_CREDIT)
3031
Status string `gorm:"column:status;type:varchar(20);not null;default:'active'"`
3132
PartyID uuid.UUID `gorm:"column:party_id;type:uuid;not null;index"`
3233
OrgPartyID *uuid.UUID `gorm:"column:org_party_id;type:uuid"` // NULL for personal accounts, set for org-scoped accounts

services/current-account/adapters/persistence/lien_entity.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ type LienEntity struct {
2323
AccountID uuid.UUID `gorm:"not null;index:idx_lien_account_status"`
2424

2525
// Monetary amount
26-
AmountCents int64 `gorm:"not null;check:amount_cents > 0"`
27-
Currency string `gorm:"not null;size:3"`
26+
AmountCents int64 `gorm:"column:amount_cents;not null;check:amount_cents > 0"`
27+
Currency string `gorm:"column:currency;not null;size:3"` // Legacy column - kept for backward compatibility
28+
InstrumentCode string `gorm:"column:instrument_code;not null;size:32"` // Instrument code (e.g. GBP, kWh)
29+
Dimension string `gorm:"column:dimension;not null;size:20"` // Asset dimension (e.g. CURRENCY, ENERGY)
30+
Precision int `gorm:"column:precision;not null;default:2"` // Decimal places for the instrument
2831

2932
// Bucket identifier for bucket-aware reservations (fungibility key)
3033
// Empty string represents the default bucket for backward compatibility

services/current-account/adapters/persistence/lien_repository.go

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/google/uuid"
1111
"github.com/meridianhub/meridian/services/current-account/domain"
1212
"github.com/meridianhub/meridian/shared/platform/db"
13+
"github.com/meridianhub/meridian/shared/platform/quantity"
1314
"gorm.io/gorm"
1415
"gorm.io/gorm/clause"
1516
)
@@ -251,11 +252,12 @@ func (r *LienRepository) SumActiveAmountByAccountID(ctx context.Context, account
251252
var totalCents int64
252253

253254
err := r.withTenantTransaction(ctx, func(tx *gorm.DB) error {
254-
// First, check for currency consistency (defensive check for data corruption)
255+
// First, check for instrument consistency (defensive check for data corruption).
256+
// Use instrument_code (not legacy currency) to correctly handle non-CURRENCY liens.
255257
countResult := tx.Model(&LienEntity{}).
256258
Where("account_id = ? AND status = ? AND (expires_at IS NULL OR expires_at > ?)",
257259
accountID, string(domain.LienStatusActive), now).
258-
Select("COUNT(DISTINCT currency)").
260+
Select("COUNT(DISTINCT instrument_code)").
259261
Scan(&currencyCount)
260262

261263
if countResult.Error != nil {
@@ -293,11 +295,12 @@ func (r *LienRepository) SumActiveAmountByAccountIDAndBucket(ctx context.Context
293295
var totalCents int64
294296

295297
err := r.withTenantTransaction(ctx, func(tx *gorm.DB) error {
296-
// First, check for currency consistency (defensive check for data corruption)
298+
// First, check for instrument consistency (defensive check for data corruption).
299+
// Use instrument_code (not legacy currency) to correctly handle non-CURRENCY liens.
297300
countResult := tx.Model(&LienEntity{}).
298301
Where("account_id = ? AND bucket_id = ? AND status = ? AND (expires_at IS NULL OR expires_at > ?)",
299302
accountID, bucketID, string(domain.LienStatusActive), now).
300-
Select("COUNT(DISTINCT currency)").
303+
Select("COUNT(DISTINCT instrument_code)").
301304
Scan(&currencyCount)
302305

303306
if countResult.Error != nil {
@@ -328,11 +331,21 @@ func (r *LienRepository) SumActiveAmountByAccountIDAndBucket(ctx context.Context
328331
func toLienEntity(lien *domain.Lien) *LienEntity {
329332
// ToMinorUnitsUnchecked is safe here: domain layer validates amounts before persistence,
330333
// so overflow (>92 quadrillion cents) cannot occur for valid liens
334+
// Legacy currency column only holds 3-char ISO codes; populate only for CURRENCY dimension.
335+
// Non-CURRENCY instruments use instrument_code/dimension/precision columns exclusively.
336+
legacyCurrency := ""
337+
if lien.Amount.Dimension() == quantity.DimensionCurrency {
338+
legacyCurrency = lien.Amount.InstrumentCode()
339+
}
340+
331341
entity := &LienEntity{
332342
ID: lien.ID,
333343
AccountID: lien.AccountID,
334344
AmountCents: lien.Amount.ToMinorUnitsUnchecked(),
335-
Currency: lien.Amount.InstrumentCode(),
345+
Currency: legacyCurrency,
346+
InstrumentCode: lien.Amount.InstrumentCode(),
347+
Dimension: lien.Amount.Dimension(),
348+
Precision: lien.Amount.Precision(),
336349
BucketID: lien.BucketID,
337350
Status: string(lien.Status),
338351
PaymentOrderReference: lien.PaymentOrderReference,
@@ -363,7 +376,14 @@ func toLienEntity(lien *domain.Lien) *LienEntity {
363376

364377
// toLienDomain converts database entity to domain model
365378
func toLienDomain(entity *LienEntity) (*domain.Lien, error) {
366-
amount, err := domain.NewMoney(entity.Currency, entity.AmountCents)
379+
// Prefer instrument_code/dimension/precision (new columns) over legacy currency column.
380+
// instrument_code is populated for all rows via backfill migration; fall back to currency
381+
// only for rows that pre-date the migration (instrument_code would be empty string).
382+
instrumentCode := entity.InstrumentCode
383+
if instrumentCode == "" {
384+
instrumentCode = entity.Currency
385+
}
386+
amount, err := domain.NewAmountFromInstrument(instrumentCode, entity.Dimension, entity.Precision, entity.AmountCents)
367387
if err != nil {
368388
return nil, fmt.Errorf("failed to create lien amount from database: %w", err)
369389
}

services/current-account/adapters/persistence/lien_repository_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ func setupLienTestDB(t *testing.T) (*gorm.DB, context.Context, func()) {
3838
account_id UUID NOT NULL,
3939
amount_cents BIGINT NOT NULL,
4040
currency VARCHAR(3) NOT NULL,
41+
instrument_code VARCHAR(32) NOT NULL DEFAULT '',
42+
dimension VARCHAR(20) NOT NULL DEFAULT 'CURRENCY',
43+
precision INT NOT NULL DEFAULT 2,
4144
bucket_id VARCHAR(255) NOT NULL DEFAULT '',
4245
status VARCHAR(20) NOT NULL,
4346
payment_order_reference VARCHAR(255) NOT NULL UNIQUE,
@@ -662,6 +665,9 @@ func setupMultiTenantLienTestDB(t *testing.T, tenantIDs ...string) (*gorm.DB, ma
662665
account_id UUID NOT NULL,
663666
amount_cents BIGINT NOT NULL,
664667
currency VARCHAR(3) NOT NULL,
668+
instrument_code VARCHAR(32) NOT NULL DEFAULT '',
669+
dimension VARCHAR(20) NOT NULL DEFAULT 'CURRENCY',
670+
precision INT NOT NULL DEFAULT 2,
665671
bucket_id VARCHAR(255) NOT NULL DEFAULT '',
666672
status VARCHAR(20) NOT NULL,
667673
payment_order_reference VARCHAR(255) NOT NULL UNIQUE,

0 commit comments

Comments
 (0)