From 6edbad4f40c94424c6a62a47ac9896ef87b1fe19 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 15 Jul 2026 10:02:16 -0700 Subject: [PATCH 1/3] refactor(shared): remove dead MoneyAmount twins (USD/JMD/BTC) [ENG-518] The top-level src/domain/shared/MoneyAmount.ts defined USDAmount, JMDAmount, and BtcAmount that were dead: the @domain/shared barrel resolves those from ./money (export * from "./money") and takes only USDTAmount from this file. The duplicates shadowed the live ./money classes, which let the cashout JMD rate fix land on the wrong copy (#449 patched here; #450 fixed the real ./money one). Delete the three dead classes; scope this file and MoneyAmount.from() to the base + USDTAmount, its only live exports. Comment added so no currency-amount class is re-added here. Verified: tsc --noEmit clean project-wide; Money.spec.ts 22/22 (it imports JMDAmount from the barrel = the live ./money class, and exercises MoneyAmount.from(Usdt)/fromJSON via the trimmed factory). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/domain/shared/MoneyAmount.ts | 148 +++---------------------------- 1 file changed, 11 insertions(+), 137 deletions(-) diff --git a/src/domain/shared/MoneyAmount.ts b/src/domain/shared/MoneyAmount.ts index 48797f45a..5356454bc 100644 --- a/src/domain/shared/MoneyAmount.ts +++ b/src/domain/shared/MoneyAmount.ts @@ -2,6 +2,13 @@ import { Money, Round, PRECISION_M } from "./bigint-money" import { BigIntConversionError, UnsupportedCurrencyError } from "./errors" import { WalletCurrency } from "./primitives" +// This file is the canonical home of the base `MoneyAmount` and `USDTAmount` ONLY. +// The USD / JMD / BTC amount classes live in ./money and are what the `@domain/shared` +// barrel resolves to: ./index.ts does `export * from "./money"` and takes only +// `USDTAmount` from here. Duplicate USD/JMD/BTC classes previously lived here too and +// shadowed the ./money ones, which let a fix land on the dead copy (ENG-518; cashout +// #449 → #450). Do NOT re-add currency-amount classes here — extend the ./money ones. + export abstract class MoneyAmount { readonly money: Money readonly currencyCode: WalletCurrency @@ -58,144 +65,11 @@ export abstract class MoneyAmount { } static from(amount: number | string, currency: WalletCurrency): MoneyAmount | Error { - if (currency === WalletCurrency.Usd) return USDAmount.cents(amount.toString()) - else if (currency === WalletCurrency.Jmd) return JMDAmount.cents(amount.toString()) - else if (currency === WalletCurrency.Usdt) + // Scoped to USDT — the only amount type this file owns. USD/JMD/BTC construction + // goes through the ./money classes (and ./money/toMoneyAmount). + if (currency === WalletCurrency.Usdt) return USDTAmount.smallestUnits(amount.toString()) - else return new UnsupportedCurrencyError(`Could not read currency: ${currency}`) - } -} - -export class USDAmount extends MoneyAmount { - static currencyId: IbexCurrencyId = 3 as IbexCurrencyId - - private constructor(amount: Money | bigint | string | number) { - super(amount, WalletCurrency.Usd) - } - - static cents(cents: string | bigint): USDAmount | BigIntConversionError { - try { - return new USDAmount(cents) - } catch (error) { - return new BigIntConversionError( - error instanceof Error ? error.message : String(error), - ) - } - } - - // convert dollars to cents - static dollars(d: number | string): USDAmount | BigIntConversionError { - try { - const dollarAmt = new Money(d.toString(), "USDollars", Round.HALF_TO_EVEN) - const cents = USDAmount.cents(100n) - if (cents instanceof BigIntConversionError) return cents // should never happen - return new USDAmount(cents.money.multiply(dollarAmt).toFixed(2)) - } catch (error) { - return new BigIntConversionError( - error instanceof Error ? error.message : String(error), - ) - } - } - - static ZERO = new USDAmount(0) - - asCents(precision: number = 0): string { - return this.money.toFixed(precision) - } - - asDollars(precision: number = 2): string { - return this.money.divide(100).toFixed(precision) - } - - // const jmdLiability = { - // amount: BigInt(usdLiability.asCents()) * exchangeRate / 100n, - // currency: "JMD", - // } - // Rate is the ratio at which one currency can be exchanged for another. - // T:USD - convertAtRate(rate: T): T { - const converted = rate.money.multiply(this.money).divide(100) - return rate.getInstance(converted) - } - - toIbex(): number { - return Number(this.asDollars(8)) - } - - getInstance(amount: Money): this { - return new USDAmount(amount) as this - } -} - -export class JMDAmount extends MoneyAmount { - currencyCode = WalletCurrency.Jmd as WalletCurrency - - private constructor(amount: Money | bigint | string | number) { - super(amount, WalletCurrency.Jmd) - } - - static cents(c: string): JMDAmount | BigIntConversionError { - try { - return new JMDAmount(c) - } catch (error) { - return new BigIntConversionError( - error instanceof Error ? error.message : String(error), - ) - } - } - - static dollars(d: number): JMDAmount | BigIntConversionError { - try { - // Mirror USDAmount.dollars: use the Money lib for a decimal-safe conversion. - // BigInt(d) throws on any fractional rate (e.g. an NCB rate of 155.5), which - // is what the exchange-rate value virtually always is. - const dollarAmt = new Money(d.toString(), "JMDollars", Round.HALF_TO_EVEN) - const cents = JMDAmount.cents("100") - if (cents instanceof BigIntConversionError) return cents // should never happen - return new JMDAmount(cents.money.multiply(dollarAmt).toFixed(2)) - } catch (error) { - return new BigIntConversionError( - error instanceof Error ? error.message : String(error), - ) - } - } - - asCents(precision: number = 0): string { - return this.money.toFixed(precision) - } - - asDollars(precision: number = 2): string { - return this.money.divide(100).toFixed(precision) - } - - getInstance(amount: Money): this { - return new JMDAmount(amount) as this - } -} - -export class BtcAmount extends MoneyAmount { - currencyCode = WalletCurrency.Btc as WalletCurrency - - private constructor(amount: Money | bigint | string | number) { - super(amount, WalletCurrency.Btc) - } - - static sats(c: string): BtcAmount | BigIntConversionError { - try { - return new BtcAmount(c) - } catch (error) { - return new BigIntConversionError( - error instanceof Error ? error.message : String(error), - ) - } - } - - asSats(precision: number = 0): string { - return this.money.toFixed(precision) - } - - getInstance(amount: Money): this { - return new BtcAmount(amount) as this + return new UnsupportedCurrencyError(`Could not read currency: ${currency}`) } } From 0a7f2f1a69c016500a468a212ff37ccb4fdb5f39 Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 15 Jul 2026 16:27:08 -0700 Subject: [PATCH 2/3] docs(shared): correct MoneyAmount header comment [ENG-518] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous header claimed this file was "the canonical home of the base MoneyAmount". It is not: the @domain/shared barrel does `export * from "./money"`, so ./money/MoneyAmount.ts is the base the app resolves as `MoneyAmount`; this file only contributes `USDTAmount`. Since USDTAmount extends this file's (separate) base, `x instanceof MoneyAmount` (the barrel's) does not match it — the comment now states that and points at the OffersSerde `|| instanceof USDTAmount` guard, and flags the two-base consolidation as the real follow-up. Also notes in Money.spec.ts why it imports MoneyAmount from the file path rather than the barrel. Comment-only; no behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/domain/shared/MoneyAmount.ts | 17 +++++++++++------ test/flash/unit/domain/shared/Money.spec.ts | 3 +++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/domain/shared/MoneyAmount.ts b/src/domain/shared/MoneyAmount.ts index 5356454bc..528f49b27 100644 --- a/src/domain/shared/MoneyAmount.ts +++ b/src/domain/shared/MoneyAmount.ts @@ -2,12 +2,17 @@ import { Money, Round, PRECISION_M } from "./bigint-money" import { BigIntConversionError, UnsupportedCurrencyError } from "./errors" import { WalletCurrency } from "./primitives" -// This file is the canonical home of the base `MoneyAmount` and `USDTAmount` ONLY. -// The USD / JMD / BTC amount classes live in ./money and are what the `@domain/shared` -// barrel resolves to: ./index.ts does `export * from "./money"` and takes only -// `USDTAmount` from here. Duplicate USD/JMD/BTC classes previously lived here too and -// shadowed the ./money ones, which let a fix land on the dead copy (ENG-518; cashout -// #449 → #450). Do NOT re-add currency-amount classes here — extend the ./money ones. +// A USDT-specific `MoneyAmount` base + `USDTAmount` — nothing else lives here. +// +// This is NOT the base the app sees as `MoneyAmount`. The `@domain/shared` barrel does +// `export * from "./money"`, so ./money/MoneyAmount.ts is the canonical base the rest of +// the codebase resolves; the barrel takes only `{ USDTAmount }` from this file. Because +// `USDTAmount` extends THIS base and not ./money's, `x instanceof MoneyAmount` (the +// barrel's) does NOT match a USDTAmount — call sites that need to catch it add an explicit +// `|| x instanceof USDTAmount` (e.g. app/offers/storage/OffersSerde.ts). The two bases +// should be consolidated onto ./money (follow-up); until then, do NOT re-add USD/JMD/BTC +// classes here — those live in ./money, and duplicating them here is exactly what let a +// cashout fix land on a dead copy (ENG-518; #449 → #450). export abstract class MoneyAmount { readonly money: Money diff --git a/test/flash/unit/domain/shared/Money.spec.ts b/test/flash/unit/domain/shared/Money.spec.ts index 349ae7c70..313a26585 100644 --- a/test/flash/unit/domain/shared/Money.spec.ts +++ b/test/flash/unit/domain/shared/Money.spec.ts @@ -1,4 +1,7 @@ import { JMDAmount, USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +// Imported from the file directly, NOT the @domain/shared barrel (which resolves +// `MoneyAmount` to ./money's base): the tests below exercise THIS file's USDT-scoped +// base and its `from`/`fromJSON`. import { MoneyAmount } from "@domain/shared/MoneyAmount" describe("Money Amount", () => { From 8b14004bcde5dfc4bf2b032f05ed874a8e7e075c Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 15 Jul 2026 21:12:36 -0700 Subject: [PATCH 3/3] docs(shared): point MoneyAmount base-dedup follow-up at ENG-523 [ENG-518] Co-Authored-By: Claude Opus 4.8 (1M context) --- src/domain/shared/MoneyAmount.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/shared/MoneyAmount.ts b/src/domain/shared/MoneyAmount.ts index 528f49b27..458fb8207 100644 --- a/src/domain/shared/MoneyAmount.ts +++ b/src/domain/shared/MoneyAmount.ts @@ -10,7 +10,7 @@ import { WalletCurrency } from "./primitives" // `USDTAmount` extends THIS base and not ./money's, `x instanceof MoneyAmount` (the // barrel's) does NOT match a USDTAmount — call sites that need to catch it add an explicit // `|| x instanceof USDTAmount` (e.g. app/offers/storage/OffersSerde.ts). The two bases -// should be consolidated onto ./money (follow-up); until then, do NOT re-add USD/JMD/BTC +// should be consolidated onto ./money (follow-up: ENG-523); until then, do NOT re-add USD/JMD/BTC // classes here — those live in ./money, and duplicating them here is exactly what let a // cashout fix land on a dead copy (ENG-518; #449 → #450).