From 3c35487057142b7d05141661cc270ec741409746 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:30:17 +0000 Subject: [PATCH 1/9] Initial plan From 75dc1e31c85aa56e30c518ec3cf4378c4d306dfa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:34:28 +0000 Subject: [PATCH 2/9] fix: normalize nanos overflow in MoneyHelper.toProto() Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.test.ts | 168 ++++++++++++++++++++++++++++++++++++ src/lib/utils/money.ts | 18 +++- 2 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 src/lib/utils/money.test.ts diff --git a/src/lib/utils/money.test.ts b/src/lib/utils/money.test.ts new file mode 100644 index 0000000..5388c4b --- /dev/null +++ b/src/lib/utils/money.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { MoneyHelper } from './money'; + +describe('MoneyHelper', () => { + describe('toProto()', () => { + it('should handle normal values', () => { + const money = MoneyHelper.fromAmount(123.45, 'USD'); + const proto = money.toProto(); + + expect(proto.currencyCode).toBe('USD'); + expect(proto.units).toBe('123'); + expect(proto.nanos).toBe(450_000_000); + }); + + it('should normalize when nanos rounds to 1_000_000_000', () => { + // Create a value that when rounded will produce exactly 1_000_000_000 nanos + // 0.9999999995 should round to 1.0 when multiplied by 1_000_000_000 + const money = MoneyHelper.fromAmount(0.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('1'); + expect(proto.nanos).toBe(0); + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + }); + + it('should normalize when nanos rounds to -1_000_000_000', () => { + // Create a value that when rounded will produce exactly -1_000_000_000 nanos + const money = MoneyHelper.fromAmount(-0.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('-1'); + expect(proto.nanos).toBe(0); + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + }); + + it('should handle positive values close to rounding boundary', () => { + const money = MoneyHelper.fromAmount(5.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('6'); + expect(proto.nanos).toBe(0); + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + }); + + it('should handle negative values close to rounding boundary', () => { + const money = MoneyHelper.fromAmount(-5.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('-6'); + expect(proto.nanos).toBe(0); + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + }); + + it('should handle zero', () => { + const money = MoneyHelper.fromAmount(0, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('0'); + expect(proto.nanos).toBe(0); + }); + + it('should handle large positive values', () => { + const money = MoneyHelper.fromAmount(999999.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('1000000'); + expect(proto.nanos).toBe(0); + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + }); + + it('should handle large negative values', () => { + const money = MoneyHelper.fromAmount(-999999.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('-1000000'); + expect(proto.nanos).toBe(0); + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + }); + + it('should maintain consistency: from -> toProto -> from should preserve value', () => { + const originalAmount = 123.456789012; + const money1 = MoneyHelper.fromAmount(originalAmount, 'USD'); + const proto = money1.toProto(); + const money2 = MoneyHelper.from(proto); + + // The round-trip should preserve the value within reasonable precision + expect(money2.toNumber()).toBeCloseTo(originalAmount, 9); + }); + + it('should handle arithmetic results that may round to boundary', () => { + // Test a division that might produce a value close to rounding boundary + const money = MoneyHelper.fromAmount(10, 'USD'); + const result = money.div(3).mul(3); // 10/3*3 may have precision issues + const proto = result.toProto(); + + // Should not throw and should have valid nanos + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + }); + }); + + describe('from()', () => { + it('should construct from valid proto', () => { + const proto = { + currencyCode: 'USD', + units: '123', + nanos: 450_000_000, + }; + const money = MoneyHelper.from(proto); + + expect(money.toNumber()).toBeCloseTo(123.45, 9); + expect(money.currency).toBe('USD'); + }); + + it('should handle null/undefined', () => { + const money1 = MoneyHelper.from(null); + expect(money1.toNumber()).toBe(0); + + const money2 = MoneyHelper.from(undefined); + expect(money2.toNumber()).toBe(0); + }); + }); + + describe('arithmetic operations', () => { + it('should add correctly', () => { + const m1 = MoneyHelper.fromAmount(100.50, 'USD'); + const m2 = MoneyHelper.fromAmount(50.25, 'USD'); + const result = m1.add(m2); + + expect(result.toNumber()).toBeCloseTo(150.75, 9); + }); + + it('should subtract correctly', () => { + const m1 = MoneyHelper.fromAmount(100.50, 'USD'); + const m2 = MoneyHelper.fromAmount(50.25, 'USD'); + const result = m1.sub(m2); + + expect(result.toNumber()).toBeCloseTo(50.25, 9); + }); + + it('should multiply correctly', () => { + const money = MoneyHelper.fromAmount(100, 'USD'); + const result = money.mul(1.5); + + expect(result.toNumber()).toBeCloseTo(150, 9); + }); + + it('should divide correctly', () => { + const money = MoneyHelper.fromAmount(100, 'USD'); + const result = money.div(4); + + expect(result.toNumber()).toBeCloseTo(25, 9); + }); + + it('should throw on currency mismatch', () => { + const m1 = MoneyHelper.fromAmount(100, 'USD'); + const m2 = MoneyHelper.fromAmount(100, 'EUR'); + + expect(() => m1.add(m2)).toThrow('Currency mismatch'); + }); + }); +}); diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index 05aa1ea..5355a75 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -61,13 +61,23 @@ export class MoneyHelper { * 对应 Go 的 ToEntityValues */ toProto(): MoneyProto { - const unitsDec = this.amount.trunc(); - const nanosDec = this.amount.minus(unitsDec).times(NANOS_MOD).round(); + let units = this.amount.trunc(); + let nanos = this.amount.minus(units).times(NANOS_MOD).round(); + + // Normalize: if nanos rounds to ±1_000_000_000, carry into units + const nanosNum = nanos.toNumber(); + if (nanosNum >= NANOS_MOD) { + units = units.plus(1); + nanos = nanos.minus(NANOS_MOD); + } else if (nanosNum <= -NANOS_MOD) { + units = units.minus(1); + nanos = nanos.plus(NANOS_MOD); + } return { currencyCode: this.currency, - units: unitsDec.toString(), - nanos: nanosDec.toNumber(), + units: units.toString(), + nanos: nanos.toNumber(), }; } From 12350dae65a139562f2e4592eb5ca92a9497ef7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:35:29 +0000 Subject: [PATCH 3/9] refactor: use while loop for more robust nanos normalization Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index 5355a75..24cf123 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -64,14 +64,19 @@ export class MoneyHelper { let units = this.amount.trunc(); let nanos = this.amount.minus(units).times(NANOS_MOD).round(); - // Normalize: if nanos rounds to ±1_000_000_000, carry into units - const nanosNum = nanos.toNumber(); - if (nanosNum >= NANOS_MOD) { - units = units.plus(1); - nanos = nanos.minus(NANOS_MOD); - } else if (nanosNum <= -NANOS_MOD) { - units = units.minus(1); - nanos = nanos.plus(NANOS_MOD); + // Normalize: carry overflow/underflow nanos into units + // This handles cases where rounding produces values outside [-999,999,999, 999,999,999] + let nanosNum = nanos.toNumber(); + while (nanosNum >= NANOS_MOD || nanosNum <= -NANOS_MOD) { + if (nanosNum >= NANOS_MOD) { + units = units.plus(1); + nanos = nanos.minus(NANOS_MOD); + nanosNum -= NANOS_MOD; + } else if (nanosNum <= -NANOS_MOD) { + units = units.minus(1); + nanos = nanos.plus(NANOS_MOD); + nanosNum += NANOS_MOD; + } } return { From 7f8ccbcc2d2ad470c8ea4cf8ae2ba9c9d5bb2a69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:36:07 +0000 Subject: [PATCH 4/9] refactor: simplify nanos normalization logic Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index 24cf123..c166f97 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -65,18 +65,14 @@ export class MoneyHelper { let nanos = this.amount.minus(units).times(NANOS_MOD).round(); // Normalize: carry overflow/underflow nanos into units - // This handles cases where rounding produces values outside [-999,999,999, 999,999,999] - let nanosNum = nanos.toNumber(); - while (nanosNum >= NANOS_MOD || nanosNum <= -NANOS_MOD) { - if (nanosNum >= NANOS_MOD) { - units = units.plus(1); - nanos = nanos.minus(NANOS_MOD); - nanosNum -= NANOS_MOD; - } else if (nanosNum <= -NANOS_MOD) { - units = units.minus(1); - nanos = nanos.plus(NANOS_MOD); - nanosNum += NANOS_MOD; - } + // Rounding can produce at most ±NANOS_MOD, so a single check suffices + const nanosNum = nanos.toNumber(); + if (nanosNum >= NANOS_MOD) { + units = units.plus(1); + nanos = nanos.minus(NANOS_MOD); + } else if (nanosNum <= -NANOS_MOD) { + units = units.minus(1); + nanos = nanos.plus(NANOS_MOD); } return { From 7c5ad8bc0f57e4d5b8fb0c09d62bd6bbdd134a98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:36:36 +0000 Subject: [PATCH 5/9] refactor: use Decimal comparison methods for cleaner code Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index c166f97..c5a576c 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -66,11 +66,10 @@ export class MoneyHelper { // Normalize: carry overflow/underflow nanos into units // Rounding can produce at most ±NANOS_MOD, so a single check suffices - const nanosNum = nanos.toNumber(); - if (nanosNum >= NANOS_MOD) { + if (nanos.gte(NANOS_MOD)) { units = units.plus(1); nanos = nanos.minus(NANOS_MOD); - } else if (nanosNum <= -NANOS_MOD) { + } else if (nanos.lte(-NANOS_MOD)) { units = units.minus(1); nanos = nanos.plus(NANOS_MOD); } From 1f9786f9d4ceca7da90569f55e1f6e07521cb130 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:37:12 +0000 Subject: [PATCH 6/9] refactor: use exact equality checks for nanos normalization Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index c5a576c..4faf08d 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -65,11 +65,11 @@ export class MoneyHelper { let nanos = this.amount.minus(units).times(NANOS_MOD).round(); // Normalize: carry overflow/underflow nanos into units - // Rounding can produce at most ±NANOS_MOD, so a single check suffices - if (nanos.gte(NANOS_MOD)) { + // Rounding can produce exactly ±NANOS_MOD, which needs to be carried to units + if (nanos.eq(NANOS_MOD)) { units = units.plus(1); nanos = nanos.minus(NANOS_MOD); - } else if (nanos.lte(-NANOS_MOD)) { + } else if (nanos.eq(-NANOS_MOD)) { units = units.minus(1); nanos = nanos.plus(NANOS_MOD); } From 6afa310023e2afe38723a4d7a0634f23cae5a53f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:38:09 +0000 Subject: [PATCH 7/9] refactor: optimize nanos normalization and clean up tests Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.test.ts | 12 ------------ src/lib/utils/money.ts | 4 ++-- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/lib/utils/money.test.ts b/src/lib/utils/money.test.ts index 5388c4b..477bd3e 100644 --- a/src/lib/utils/money.test.ts +++ b/src/lib/utils/money.test.ts @@ -20,8 +20,6 @@ describe('MoneyHelper', () => { expect(proto.units).toBe('1'); expect(proto.nanos).toBe(0); - expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); - expect(proto.nanos).toBeLessThanOrEqual(999_999_999); }); it('should normalize when nanos rounds to -1_000_000_000', () => { @@ -31,8 +29,6 @@ describe('MoneyHelper', () => { expect(proto.units).toBe('-1'); expect(proto.nanos).toBe(0); - expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); - expect(proto.nanos).toBeLessThanOrEqual(999_999_999); }); it('should handle positive values close to rounding boundary', () => { @@ -41,8 +37,6 @@ describe('MoneyHelper', () => { expect(proto.units).toBe('6'); expect(proto.nanos).toBe(0); - expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); - expect(proto.nanos).toBeLessThanOrEqual(999_999_999); }); it('should handle negative values close to rounding boundary', () => { @@ -51,8 +45,6 @@ describe('MoneyHelper', () => { expect(proto.units).toBe('-6'); expect(proto.nanos).toBe(0); - expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); - expect(proto.nanos).toBeLessThanOrEqual(999_999_999); }); it('should handle zero', () => { @@ -69,8 +61,6 @@ describe('MoneyHelper', () => { expect(proto.units).toBe('1000000'); expect(proto.nanos).toBe(0); - expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); - expect(proto.nanos).toBeLessThanOrEqual(999_999_999); }); it('should handle large negative values', () => { @@ -79,8 +69,6 @@ describe('MoneyHelper', () => { expect(proto.units).toBe('-1000000'); expect(proto.nanos).toBe(0); - expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); - expect(proto.nanos).toBeLessThanOrEqual(999_999_999); }); it('should maintain consistency: from -> toProto -> from should preserve value', () => { diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index 4faf08d..794bc54 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -68,10 +68,10 @@ export class MoneyHelper { // Rounding can produce exactly ±NANOS_MOD, which needs to be carried to units if (nanos.eq(NANOS_MOD)) { units = units.plus(1); - nanos = nanos.minus(NANOS_MOD); + nanos = new Decimal(0); } else if (nanos.eq(-NANOS_MOD)) { units = units.minus(1); - nanos = nanos.plus(NANOS_MOD); + nanos = new Decimal(0); } return { From b42a9d261c9034573016f0feb3e5b68cb8ab0b30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:39:17 +0000 Subject: [PATCH 8/9] refactor: use defensive >= and <= checks for nanos normalization Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index 794bc54..955e064 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -65,13 +65,13 @@ export class MoneyHelper { let nanos = this.amount.minus(units).times(NANOS_MOD).round(); // Normalize: carry overflow/underflow nanos into units - // Rounding can produce exactly ±NANOS_MOD, which needs to be carried to units - if (nanos.eq(NANOS_MOD)) { + // Use >= and <= for defensive programming, though rounding should only produce exactly ±NANOS_MOD + if (nanos.gte(NANOS_MOD)) { units = units.plus(1); - nanos = new Decimal(0); - } else if (nanos.eq(-NANOS_MOD)) { + nanos = nanos.minus(NANOS_MOD); + } else if (nanos.lte(-NANOS_MOD)) { units = units.minus(1); - nanos = new Decimal(0); + nanos = nanos.plus(NANOS_MOD); } return { From dee02c531c7cfb60e29a9a43baa7ba7f3079ad7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 05:40:58 +0000 Subject: [PATCH 9/9] test: add assertions to verify nanos always within valid range Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/lib/utils/money.test.ts b/src/lib/utils/money.test.ts index 477bd3e..11461ba 100644 --- a/src/lib/utils/money.test.ts +++ b/src/lib/utils/money.test.ts @@ -75,6 +75,11 @@ describe('MoneyHelper', () => { const originalAmount = 123.456789012; const money1 = MoneyHelper.fromAmount(originalAmount, 'USD'); const proto = money1.toProto(); + + // Verify proto has valid nanos (this is the bug fix test) + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + const money2 = MoneyHelper.from(proto); // The round-trip should preserve the value within reasonable precision @@ -91,6 +96,23 @@ describe('MoneyHelper', () => { expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); expect(proto.nanos).toBeLessThanOrEqual(999_999_999); }); + + it('should always produce valid nanos for any amount', () => { + // Test various random amounts to ensure nanos is always valid + const testAmounts = [ + 0.9999999995, -0.9999999995, + 1.9999999995, -1.9999999995, + 999.9999999995, -999.9999999995, + 0.123456789, -0.123456789, + 12345.6789, -12345.6789, + ]; + + for (const amount of testAmounts) { + const proto = MoneyHelper.fromAmount(amount, 'USD').toProto(); + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + } + }); }); describe('from()', () => {