diff --git a/src/app/offers/CashoutManager.ts b/src/app/offers/CashoutManager.ts index abf30ba82..a0ef85771 100644 --- a/src/app/offers/CashoutManager.ts +++ b/src/app/offers/CashoutManager.ts @@ -1,5 +1,5 @@ import { resolveCashoutWalletSelection } from "@app/cash-wallet-cutover/cashout-routing" -import { Cashout, ExchangeRates } from "@config" +import { Cashout } from "@config" import { decodeInvoice } from "@domain/bitcoin/lightning" import { CacheServiceError } from "@domain/cache" import { USDAmount, USDTAmount, ValidationError } from "@domain/shared" @@ -10,16 +10,16 @@ import { getBankOwnerIbexAccount } from "@services/ledger/caching" import { RepositoryError } from "@domain/errors" import { EmailService } from "@services/email" import ErpNext from "@services/frappe/ErpNext" -import { BankAccountQueryError } from "@services/frappe/errors" +import { BankAccountQueryError, ExchangeRateQueryError } from "@services/frappe/errors" import { AccountsRepository, WalletsRepository } from "@services/mongoose" import PersistedOffer from "./storage/PersistedOffer" import Storage from "./storage/Redis" +import { CashoutDetails } from "./types" import ValidOffer, { InitiatedCashout } from "./ValidOffer" const config = { ...Cashout.OfferConfig, - ...ExchangeRates, } const CashoutManager = { @@ -68,8 +68,6 @@ const CashoutManager = { const serviceFee = userPayment.multiplyBips(config.fee) const usdPayout = userPayment.subtract(serviceFee) - const exchangeRate = config.jmd.sell // todo: get from price server - const jmdPayout = usdPayout.convertAtRate(exchangeRate) const bankAccounts = await ErpNext.getBankAccountsByCustomer(account.erpParty!) if (bankAccounts instanceof BankAccountQueryError) return bankAccounts @@ -78,9 +76,22 @@ const CashoutManager = { return new ValidationError(`Bank account not found: ${bankAccountId}`) const isJmdPayout = bankAccount.currency === "JMD" - const payout = isJmdPayout - ? { bankAccountId, amount: jmdPayout, serviceFee, exchangeRate } - : { bankAccountId, amount: usdPayout, serviceFee } + + // JMD cashout settles at NCB's buy rate, scraped weekly into ERPNext. Fail + // closed: never build a JMD offer at a guessed rate if the live rate is missing. + let payout: CashoutDetails["payout"] + if (isJmdPayout) { + const exchangeRate = await ErpNext.getCashoutExchangeRate() + if (exchangeRate instanceof ExchangeRateQueryError) return exchangeRate + payout = { + bankAccountId, + amount: usdPayout.convertAtRate(exchangeRate), + serviceFee, + exchangeRate, + } + } else { + payout = { bankAccountId, amount: usdPayout, serviceFee } + } const validated = await ValidOffer.from({ payment: { diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index 3a22c6e49..c0f9303f4 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -1,6 +1,6 @@ import ValidOffer from "@app/offers/ValidOffer" import { FrappeConfig } from "@config" -import { USDTAmount, Validated } from "@domain/shared" +import { JMDAmount, USDTAmount, Validated } from "@domain/shared" import { baseLogger } from "@services/logger" import { recordExceptionInCurrentSpan } from "@services/tracing" import axios, { isAxiosError } from "axios" @@ -13,6 +13,7 @@ import { BridgeTransferRequestUpsertError, CashoutDraftError, CashoutSubmitError, + ExchangeRateQueryError, JournalEntryDeleteError, SetDocTypeValueError, UpgradeRequestCreateError, @@ -291,6 +292,40 @@ export class ErpNext { } } + async getCashoutExchangeRate(): Promise { + try { + // Cashout is money-out, so it settles at the bank's *buy* side (for_buying). + // The weekly NCB scrape upserts Currency Exchange records; the newest one is + // the live rate (ERPNext serves the most recent rate <= posting date). + const filters = `[["from_currency","=","USD"],["to_currency","=","JMD"],["for_buying","=",1]]` + const fields = `["exchange_rate","date"]` + const resp = await axios.get( + `${this.url}/api/resource/Currency%20Exchange?filters=${filters}&fields=${fields}&order_by=date%20desc&limit_page_length=1`, + { headers: this.headers }, + ) + + const rate = resp.data?.data?.[0]?.exchange_rate + if (typeof rate !== "number" || !(rate > 0)) { + return new ExchangeRateQueryError("No USD->JMD for_buying rate found in ERPNext") + } + + const jmdRate = JMDAmount.dollars(rate) + if (jmdRate instanceof Error) return new ExchangeRateQueryError(jmdRate) + return jmdRate + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error( + { err, responseData }, + "Error querying Currency Exchange from ERPNext", + ) + recordExceptionInCurrentSpan({ + error: err, + attributes: { "erpnext.exception": responseData?.exception }, + }) + return new ExchangeRateQueryError(err) + } + } + async listBanks(): Promise { try { const resp = await axios.get(`${this.url}/api/resource/Bank`, { diff --git a/src/services/frappe/errors.ts b/src/services/frappe/errors.ts index 592ece967..6dd8de8ab 100644 --- a/src/services/frappe/errors.ts +++ b/src/services/frappe/errors.ts @@ -11,4 +11,5 @@ export class BanksQueryError extends ErpNextError {} export class BankAccountQueryError extends ErpNextError {} export class BankAccountUpdateRequestCreateError extends ErpNextError {} export class BankAccountUpdateRequestQueryError extends ErpNextError {} +export class ExchangeRateQueryError extends ErpNextError {} export class BridgeTransferRequestUpsertError extends ErpNextError {} diff --git a/test/flash/integration/offers/execute-offer.spec.ts b/test/flash/integration/offers/execute-offer.spec.ts index 81019be13..b298ecf3d 100644 --- a/test/flash/integration/offers/execute-offer.spec.ts +++ b/test/flash/integration/offers/execute-offer.spec.ts @@ -1,6 +1,6 @@ import CashoutManager from "@app/offers/CashoutManager" -import { USDAmount } from "@domain/shared" +import { JMDAmount, USDAmount } from "@domain/shared" import ErpNext, { CashoutId } from "@services/frappe/ErpNext" import Ibex from "@services/ibex/client" @@ -53,6 +53,7 @@ jest.mock("@services/frappe/ErpNext", () => ({ __esModule: true, default: { getBankAccountsByCustomer: jest.fn(), + getCashoutExchangeRate: jest.fn(), draftCashout: jest.fn(), submitCashout: jest.fn(), }, @@ -77,6 +78,9 @@ beforeEach(async () => { }) mockedIbex.payInvoice.mockResolvedValue(Mocks.payInvoiceV2.response) mockedErpNext.getBankAccountsByCustomer.mockResolvedValue([bankAccount]) + mockedErpNext.getCashoutExchangeRate.mockResolvedValue( + JMDAmount.dollars(160) as JMDAmount, + ) mockedErpNext.draftCashout.mockResolvedValue("cashout-test-id" as CashoutId) mockedErpNext.submitCashout.mockResolvedValue(true) }) diff --git a/test/flash/integration/offers/make-cashout-offer.spec.ts b/test/flash/integration/offers/make-cashout-offer.spec.ts index 82eaa756a..697c99a37 100644 --- a/test/flash/integration/offers/make-cashout-offer.spec.ts +++ b/test/flash/integration/offers/make-cashout-offer.spec.ts @@ -2,7 +2,7 @@ import CashoutManager from "@app/offers/CashoutManager" import ErpNext from "@services/frappe/ErpNext" import Ibex from "@services/ibex/client" -import { USDAmount } from "@domain/shared" +import { JMDAmount, USDAmount } from "@domain/shared" import { alice } from "../jest.setup" @@ -57,6 +57,7 @@ jest.mock("@services/frappe/ErpNext", () => ({ __esModule: true, default: { getBankAccountsByCustomer: jest.fn(), + getCashoutExchangeRate: jest.fn(), }, })) let mockedIbex: jest.Mocked @@ -73,6 +74,9 @@ beforeEach(async () => { "lnurl1dp68gurn8ghj7um9dej8xct5w3skccne9e3k7mf0d3h82unvwqhkxun0wa5kgct5v93kzmmfd3skjmn0wvhxcmmv9u", }) mockedErpNext.getBankAccountsByCustomer.mockResolvedValue([bankAccount]) + mockedErpNext.getCashoutExchangeRate.mockResolvedValue( + JMDAmount.dollars(160) as JMDAmount, + ) }) afterEach(async () => {