From 0ee4ed95829f81b6720704ab4ee3e22aac09e263 Mon Sep 17 00:00:00 2001 From: Dread Date: Tue, 14 Jul 2026 17:38:55 -0700 Subject: [PATCH] fix(cashout): make JMD exchange-rate conversion decimal-safe [ENG-509] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `JMDAmount.dollars(d)` did `new JMDAmount(BigInt(d) * 100n)`, and `BigInt(d)` throws on any non-integer `d`. The cashout offer wraps the live NCB rate (essentially always fractional — 152.7, 155.5, ...) via this method, so `getCashoutExchangeRate` returned `ExchangeRateQueryError` for every real rate and `requestCashout` failed. Rewrote it to mirror `USDAmount.dollars` (Money-lib, HALF_TO_EVEN) so fractional rates convert correctly. Compounding it, `error-map.ts` had no `ExchangeRateQueryError` case, so the failure fell through to the exhaustiveness fallback ("This should never compile ...") and returned a `data: null` top-level GraphQL error — the app therefore showed no message, just a spinner. Added a mapped case. Only surfaces post-#444/#445 (live NCB rate); a deploy would break JMD cashout in every env until this lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/domain/shared/MoneyAmount.ts | 8 +++++++- src/graphql/error-map.ts | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/domain/shared/MoneyAmount.ts b/src/domain/shared/MoneyAmount.ts index 6b89ab13e..48797f45a 100644 --- a/src/domain/shared/MoneyAmount.ts +++ b/src/domain/shared/MoneyAmount.ts @@ -146,7 +146,13 @@ export class JMDAmount extends MoneyAmount { static dollars(d: number): JMDAmount | BigIntConversionError { try { - return new JMDAmount(BigInt(d) * 100n) + // 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), diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 1905dfd8c..67b752890 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -921,6 +921,11 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "No upgrade request found for this account" return new NotFoundError({ message, logger: baseLogger }) + case "ExchangeRateQueryError": + message = + "Cashout is temporarily unavailable while we refresh the exchange rate. Please try again shortly." + return new UnexpectedClientError({ message, logger: baseLogger }) + case "InvalidLnurlError": return new InvalidLnurlError({ message: error.message, logger: baseLogger })