Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions src/app/offers/CashoutManager.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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: {
Expand Down
37 changes: 36 additions & 1 deletion src/services/frappe/ErpNext.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,6 +13,7 @@ import {
BridgeTransferRequestUpsertError,
CashoutDraftError,
CashoutSubmitError,
ExchangeRateQueryError,
JournalEntryDeleteError,
SetDocTypeValueError,
UpgradeRequestCreateError,
Expand Down Expand Up @@ -291,6 +292,40 @@ export class ErpNext {
}
}

async getCashoutExchangeRate(): Promise<JMDAmount | ExchangeRateQueryError> {
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<Bank[] | BanksQueryError> {
try {
const resp = await axios.get(`${this.url}/api/resource/Bank`, {
Expand Down
1 change: 1 addition & 0 deletions src/services/frappe/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
6 changes: 5 additions & 1 deletion test/flash/integration/offers/execute-offer.spec.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -53,6 +53,7 @@ jest.mock("@services/frappe/ErpNext", () => ({
__esModule: true,
default: {
getBankAccountsByCustomer: jest.fn(),
getCashoutExchangeRate: jest.fn(),
draftCashout: jest.fn(),
submitCashout: jest.fn(),
},
Expand All @@ -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)
})
Expand Down
6 changes: 5 additions & 1 deletion test/flash/integration/offers/make-cashout-offer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -57,6 +57,7 @@ jest.mock("@services/frappe/ErpNext", () => ({
__esModule: true,
default: {
getBankAccountsByCustomer: jest.fn(),
getCashoutExchangeRate: jest.fn(),
},
}))
let mockedIbex: jest.Mocked<typeof Ibex>
Expand All @@ -73,6 +74,9 @@ beforeEach(async () => {
"lnurl1dp68gurn8ghj7um9dej8xct5w3skccne9e3k7mf0d3h82unvwqhkxun0wa5kgct5v93kzmmfd3skjmn0wvhxcmmv9u",
})
mockedErpNext.getBankAccountsByCustomer.mockResolvedValue([bankAccount])
mockedErpNext.getCashoutExchangeRate.mockResolvedValue(
JMDAmount.dollars(160) as JMDAmount,
)
})

afterEach(async () => {
Expand Down
Loading