From 1698170a8df46d60599da03b92e04f25879337dc Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Wed, 6 May 2026 15:31:43 -0500 Subject: [PATCH 1/4] feat: switch cashout to Cashout DocType in ERPNext --- .../Create AccountUpgradeRequest.bru | 2 +- .../token/mutations/setUsername.bru | 3 +- dev/config/base-config.yaml | 4 +- src/app/offers/OffersManager.ts | 7 + src/app/offers/types.ts | 2 +- src/domain/accounts/index.types.d.ts | 2 +- .../root/mutation/offers/request-cash-out.ts | 18 ++- .../public/types/object/cashout-offer.ts | 2 +- src/services/frappe/ErpNext.ts | 120 +++++++++++------- 9 files changed, 105 insertions(+), 55 deletions(-) diff --git a/dev/bruno/Flash GraphQL API/token/mutations/Create AccountUpgradeRequest.bru b/dev/bruno/Flash GraphQL API/token/mutations/Create AccountUpgradeRequest.bru index 973747f31..db3670a9c 100644 --- a/dev/bruno/Flash GraphQL API/token/mutations/Create AccountUpgradeRequest.bru +++ b/dev/bruno/Flash GraphQL API/token/mutations/Create AccountUpgradeRequest.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{graphqlUrl}} + url: {{flashGraphqlUrl}} body: graphql auth: inherit } diff --git a/dev/bruno/Flash GraphQL API/token/mutations/setUsername.bru b/dev/bruno/Flash GraphQL API/token/mutations/setUsername.bru index 87c541471..c1db30fe2 100644 --- a/dev/bruno/Flash GraphQL API/token/mutations/setUsername.bru +++ b/dev/bruno/Flash GraphQL API/token/mutations/setUsername.bru @@ -30,7 +30,7 @@ body:graphql { body:graphql:vars { { "input": { - "username": "Bob" + "username": "jane" } } @@ -38,4 +38,5 @@ body:graphql:vars { settings { encodeUrl: true + timeout: 0 } diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index 58b90337f..b9eda1f7b 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -37,8 +37,8 @@ cashout: # credentials and accounts defined in the erpnext restore script frappe: - url: http://frontend.local:8080 - sitename: frontend # default sitename for frappe docker container + url: http://flashapp.me.localhost:8000 + sitename: flashapp.me.localhost # default sitename for frappe docker container credentials: apiKey: "2701937b221364c" apiSecret: "a05ab1998d20828" diff --git a/src/app/offers/OffersManager.ts b/src/app/offers/OffersManager.ts index b2a35e985..1c73be312 100644 --- a/src/app/offers/OffersManager.ts +++ b/src/app/offers/OffersManager.ts @@ -9,6 +9,10 @@ import { decodeInvoice, PaymentSendStatus } from "@domain/bitcoin/lightning" import { Cashout, ExchangeRates } from "@config" import PersistedOffer from "./storage/PersistedOffer" import { EmailService } from "@services/email" +import { AccountsRepository, WalletsRepository } from "@services/mongoose" +import { RepositoryError } from "@domain/errors" +import ErpNext from "@services/frappe/ErpNext" +import { BankAccount } from "@services/frappe/models/BankAccount" const config = { ...Cashout.OfferConfig, @@ -19,6 +23,7 @@ const OffersManager = { createCashoutOffer: async ( walletId: WalletId, userPayment: USDAmount, + bank: BankAccount, ): Promise => { const flashWallet = await getBankOwnerIbexAccount() @@ -38,6 +43,8 @@ const OffersManager = { const exchangeRate = config.jmd.sell // todo: get from price server const jmdLiability = usdLiability.convertAtRate(exchangeRate) + + const validated = await ValidOffer.from({ ibexTrx: { userAcct: walletId, diff --git a/src/app/offers/types.ts b/src/app/offers/types.ts index d5fd9f5d4..ecdf5edf1 100644 --- a/src/app/offers/types.ts +++ b/src/app/offers/types.ts @@ -13,7 +13,7 @@ export type CashoutDetails = { readonly usd: USDAmount, readonly jmd: JMDAmount, } - readonly fee: USDAmount + readonly fee: USDAmount readonly exchangeRate: JMDAmount, }, } diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index 13460e2c4..1f4de02b8 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -83,7 +83,7 @@ type Account = { displayCurrency: DisplayCurrency // temp role?: string - erpParty?: string // Lookup key to Supplier (or Customer) in ERPNext. Required for Account level > 1 + erpParty?: string // Lookup key to Customer in ERPNext. Required for Account level > 1 } // deprecated diff --git a/src/graphql/public/root/mutation/offers/request-cash-out.ts b/src/graphql/public/root/mutation/offers/request-cash-out.ts index 93817b8bc..7c47ad80d 100644 --- a/src/graphql/public/root/mutation/offers/request-cash-out.ts +++ b/src/graphql/public/root/mutation/offers/request-cash-out.ts @@ -7,7 +7,10 @@ import USDCentsScalar from "@graphql/shared/types/scalar/usd-cents" import WalletId from "@graphql/shared/types/scalar/wallet-id" import dedent from "dedent" import { Cashout } from "@config" -import { NotImplementedError } from "@domain/errors" +import { NotImplementedError, RepositoryError } from "@domain/errors" +import ErpNext from "@services/frappe/ErpNext" +import { AccountsRepository, WalletsRepository } from "@services/mongoose" +import { ValidationError } from "@domain/shared" const RequestCashoutInput = GT.Input({ name: "RequestCashoutInput", @@ -56,9 +59,22 @@ const RequestCashoutMutation = GT.Field({ } } + + // For now, I want to surface the bank selection, + // but eventually move out of graphql resolver + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof RepositoryError) return new ValidationError(wallet) + const account = await AccountsRepository().findById(wallet.accountId) + if (account instanceof RepositoryError) return new ValidationError(account) + if (!account.erpParty) return new Error("Could not find erpParty for account") + const banks = await ErpNext.getBankAccountsByCustomer(account.erpParty) + if (banks instanceof Error) return banks + if (!banks.length) return Error(`Could not find banks for customer: ${account.erpParty}`) + const offer = await (OffersManager.createCashoutOffer( walletId, amount, + banks[0], // todo: allow user to select bank account )) if (offer instanceof Error) return { errors: mapToGqlErrorList(offer) } diff --git a/src/graphql/public/types/object/cashout-offer.ts b/src/graphql/public/types/object/cashout-offer.ts index 72f330f4c..baba9c1a3 100644 --- a/src/graphql/public/types/object/cashout-offer.ts +++ b/src/graphql/public/types/object/cashout-offer.ts @@ -46,7 +46,7 @@ const CashoutOffer: GraphQLObjectType = GT resolve: (o) => o.details.flash.fee // Number(src.details.flash.fee.asCents(2)), }, expiresAt: { - type: GT.NonNull(Timestamp), + type: GT.NonNull(Timestamp), description: "The time at which this offer is no longer accepted by Flash", resolve: (o) => o.details.ibexTrx.invoice.expiresAt.getTime(), }, diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index 6343c7217..60f8c7c92 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -23,6 +23,7 @@ import { import { Bank } from "./models/Bank" import { BankAccount } from "./models/BankAccount" import { Filter } from "./SearchFilters" +import ibex from "@services/ibex" export type AccountUpgradeRequestFilters = { username?: Filter, status?: Filter } type ErpNextFilter = [string, string, string, string[]] @@ -55,60 +56,85 @@ class ErpNext { const { ibexTrx, flash } = offer.details const { liability } = flash const flashFee = flash.fee - const journalEntry = { - doctype: "Journal Entry", - company: "Flash", - multi_currency: 1, - posting_date: new Date().toISOString().split("T")[0], - remark: `${JSON.stringify({ paymentHash: ibexTrx.invoice.paymentHash, userWalletId: ibexTrx.userAcct })}`, - accounts: [ - { - account: FrappeConfig.erpnext.accounts.ibex.operating, - account_currency: "USD", - debit_in_account_currency: erpUsd(ibexTrx.usd), - debit: erpUsd(ibexTrx.usd), - exchange_rate: 1, - }, - { - account: FrappeConfig.erpnext.accounts.cashout, - account_currency: "JMD", - credit_in_account_currency: Number(liability.jmd.asCents(2)), - credit: erpUsd(liability.usd), - exchange_rate: erpUsd(liability.usd) / Number(liability.jmd.asCents(2)), - party_type: "Customer", - party, - }, - { - account: FrappeConfig.erpnext.accounts.serviceFees, - account_currency: "USD", - credit_in_account_currency: erpUsd(flashFee), - credit: erpUsd(flashFee), - exchange_rate: 1, - }, - ], - } + // const journalEntry = { + // doctype: "Journal Entry", + // company: "Flash", + // multi_currency: 1, + // posting_date: new Date().toISOString().split("T")[0], + // remark: `${JSON.stringify({ paymentHash: ibexTrx.invoice.paymentHash, userWalletId: ibexTrx.userAcct })}`, + // accounts: [ + // { + // account: FrappeConfig.erpnext.accounts.ibex.operating, + // account_currency: "USD", + // debit_in_account_currency: erpUsd(ibexTrx.usd), + // debit: erpUsd(ibexTrx.usd), + // exchange_rate: 1, + // }, + // { + // account: FrappeConfig.erpnext.accounts.cashout, + // account_currency: "JMD", + // credit_in_account_currency: Number(liability.jmd.asCents(2)), + // credit: erpUsd(liability.usd), + // exchange_rate: erpUsd(liability.usd) / Number(liability.jmd.asCents(2)), + // party_type: "Customer", + // party, + // }, + // { + // account: FrappeConfig.erpnext.accounts.serviceFees, + // account_currency: "USD", + // credit_in_account_currency: erpUsd(flashFee), + // credit: erpUsd(flashFee), + // exchange_rate: 1, + // }, + // ], + // } + + // try { + // const resp = await axios.post( + // `${this.url}/api/resource/Journal Entry`, + // journalEntry, + // { headers: this.headers }, + // ) + // const titleResp = this.updateTitle( + // resp.data.data.name, + // `Open cashout ${ibexTrx.invoice.paymentHash.substring(0, 5)}`, + // ) + // if (titleResp instanceof JournalEntryTitleError) { + // baseLogger.error({ err: titleResp }, "Error updating JE title in ERPNext") + // } + + // return { + // journalId: resp.data.data.name, + // voided: false, + // transactionIds: [], + // } as LedgerJournal try { - const resp = await axios.post( - `${this.url}/api/resource/Journal Entry`, - journalEntry, + const response = await axios.post( + `${this.url}/api/resource/Cashout`, + { + customer: party, + // bank_account: offer.details.bank, + amount: 100.00, + transaction_id: ibexTrx.invoice.paymentHash, + wallet_id: ibexTrx.userAcct, + flash_wallet: ibexTrx.flashAcct, + // invoice: ibexTrx.invoice.paymentHash, + usd_liability: liability.usd, + jmd_liability: liability.jmd, + exchange_rate: flash.exchangeRate, + flash_fee: flashFee, + }, { headers: this.headers }, - ) - const titleResp = this.updateTitle( - resp.data.data.name, - `Open cashout ${ibexTrx.invoice.paymentHash.substring(0, 5)}`, - ) - if (titleResp instanceof JournalEntryTitleError) { - baseLogger.error({ err: titleResp }, "Error updating JE title in ERPNext") - } - + ); + console.log("Cashout response:", response.data); return { - journalId: resp.data.data.name, + journalId: response.data.data.name, voided: false, transactionIds: [], - } as LedgerJournal + } } catch (err) { - baseLogger.error({ err, journalEntry }, "Error drafting JE in ERPNext") + baseLogger.error({ err }, "Error drafting Cashout in ERPNext") return new JournalEntryDraftError(err) } } From 90a7816e47c84946d4dd6e584be8366cb926bda7 Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Sat, 9 May 2026 09:29:27 -0500 Subject: [PATCH 2/4] Execute Cashout using DocType --- dev/apollo-federation/supergraph.graphql | 23 +++- .../token/mutations/RequestCashout.bru | 3 +- dev/config/base-config.yaml | 8 +- .../{OffersManager.ts => CashoutManager.ts} | 46 ++++--- src/app/offers/ValidOffer.ts | 38 +++--- src/app/offers/Validator.ts | 40 ++++-- src/app/offers/index.ts | 12 +- src/app/offers/storage/Redis.ts | 8 +- src/app/offers/types.ts | 16 +-- src/config/schema.ts | 9 +- src/config/schema.types.d.ts | 10 +- src/config/yaml.ts | 1 + .../root/mutation/offers/initiate-cash-out.ts | 8 +- .../root/mutation/offers/request-cash-out.ts | 32 ++--- src/graphql/public/schema.graphql | 23 +++- .../public/types/object/cashout-offer.ts | 34 ++--- src/services/email/templates/cashout.ts | 22 ++-- src/services/frappe/ErpNext.ts | 121 ++++-------------- src/services/frappe/errors.ts | 1 + .../integration/offers/execute-offer.spec.ts | 7 +- .../offers/make-cashout-offer.spec.ts | 4 +- 21 files changed, 195 insertions(+), 271 deletions(-) rename src/app/offers/{OffersManager.ts => CashoutManager.ts} (64%) diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index c41e24b83..fa5d54963 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -209,9 +209,11 @@ type BankAccount { accountName: String! accountNumber: String! + accountType: String! """Name of the bank institution""" bank: String! + branchCode: String! """Account currency (e.g. JMD, USD)""" currency: String! @@ -357,22 +359,26 @@ type CashoutOffer @join__type(graph: PUBLIC) { """The rate used when withdrawing to a JMD bank account""" - exchangeRate: JMDCents! + exchangeRate: JMDCents """The time at which this offer is no longer accepted by Flash""" expiresAt: Timestamp! - """The amount that Flash is charging for it's services""" + """The amount that Flash is charging for its services""" flashFee: USDCents! """ID of the offer""" offerId: ID! - """The amount Flash owes to the user denominated in JMD as cents""" - receiveJmd: JMDCents! + """ + The amount Flash owes to the user denominated in JMD cents (null for USD payouts) + """ + receiveJmd: JMDCents - """The amount Flash owes to the user denominated in USD as cents""" - receiveUsd: USDCents! + """ + The amount Flash owes to the user denominated in USD cents (null for JMD payouts) + """ + receiveUsd: USDCents """The amount the user is sending to flash""" send: USDCents! @@ -628,7 +634,7 @@ type InitiatedCashoutResponse @join__type(graph: PUBLIC) { errors: [Error!]! - journalId: ID + id: ID } union InitiationVia @@ -1597,6 +1603,9 @@ input RequestCashoutInput """Amount in USD cents.""" amount: USDCents! + """ERPNext bank account identifier to receive the cashout.""" + bankAccountId: ID! + """ID for a USD wallet belonging to the current user.""" walletId: WalletId! } diff --git a/dev/bruno/Flash GraphQL API/token/mutations/RequestCashout.bru b/dev/bruno/Flash GraphQL API/token/mutations/RequestCashout.bru index 97cda3fc4..4e614fe61 100644 --- a/dev/bruno/Flash GraphQL API/token/mutations/RequestCashout.bru +++ b/dev/bruno/Flash GraphQL API/token/mutations/RequestCashout.bru @@ -43,7 +43,8 @@ body:graphql:vars { { "input": { "walletId": "{{walletId}}", - "amount": 1 + "amount": 1, + "bankAccountId": "John Smith JMD - First Global" } } } diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index b9eda1f7b..eb7eaf3ce 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -21,6 +21,7 @@ exchangeRates: cashout: enabled: false + skipPayment: true minimum: amount: 0 currency: USD @@ -35,19 +36,12 @@ cashout: from: "notifications@getflash.io" subject: "New Cashout" -# credentials and accounts defined in the erpnext restore script frappe: url: http://flashapp.me.localhost:8000 sitename: flashapp.me.localhost # default sitename for frappe docker container credentials: apiKey: "2701937b221364c" apiSecret: "a05ab1998d20828" - erpnext: - accounts: - ibex: - operating: "Ibex Operating - F" - cashout: "Cashout Payables (JMD) - F" - serviceFees: "Service Fees - F" sendgrid: apiKey: "" diff --git a/src/app/offers/OffersManager.ts b/src/app/offers/CashoutManager.ts similarity index 64% rename from src/app/offers/OffersManager.ts rename to src/app/offers/CashoutManager.ts index 1c73be312..60770ef74 100644 --- a/src/app/offers/OffersManager.ts +++ b/src/app/offers/CashoutManager.ts @@ -12,18 +12,18 @@ import { EmailService } from "@services/email" import { AccountsRepository, WalletsRepository } from "@services/mongoose" import { RepositoryError } from "@domain/errors" import ErpNext from "@services/frappe/ErpNext" -import { BankAccount } from "@services/frappe/models/BankAccount" +import { BankAccountQueryError } from "@services/frappe/errors" const config = { ...Cashout.OfferConfig, ...ExchangeRates, } -const OffersManager = { - createCashoutOffer: async ( +const CashoutManager = { + createOffer: async ( walletId: WalletId, userPayment: USDAmount, - bank: BankAccount, + bankAccountId: string, ): Promise => { const flashWallet = await getBankOwnerIbexAccount() @@ -38,29 +38,35 @@ const OffersManager = { const invoice = decodeInvoice(invoiceResp.invoice.bolt11) if (invoice instanceof Error) return invoice - const flashFee = userPayment.multiplyBips(config.fee) - const usdLiability = userPayment.subtract(flashFee) + const serviceFee = userPayment.multiplyBips(config.fee) + const usdPayout = userPayment.subtract(serviceFee) const exchangeRate = config.jmd.sell // todo: get from price server - const jmdLiability = usdLiability.convertAtRate(exchangeRate) + const jmdPayout = usdPayout.convertAtRate(exchangeRate) + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof RepositoryError) return new ValidationError(wallet) + const account = await AccountsRepository().findById(wallet.accountId) + if (account instanceof RepositoryError) return new ValidationError(account) + if (!account.erpParty) return new Error("Could not find erpParty for account") + const bankAccounts = await ErpNext.getBankAccountsByCustomer(account.erpParty!) + if (bankAccounts instanceof BankAccountQueryError) return bankAccounts + const bankAccount = bankAccounts.find(b => b.name === bankAccountId) + if (!bankAccount) 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 } const validated = await ValidOffer.from({ - ibexTrx: { + payment: { userAcct: walletId, flashAcct: flashWallet, invoice, - usd: userPayment, - // currency: "USD", - }, - flash: { - liability: { - usd: usdLiability, - jmd: jmdLiability, - }, - exchangeRate, - fee: flashFee, + amount: userPayment, }, + payout, }) if (validated instanceof ValidationError) return validated @@ -73,7 +79,7 @@ const OffersManager = { const offer = await Storage.get(id) if (offer instanceof Error) return offer - if (walletId !== offer.details.ibexTrx.userAcct) return new ValidationError("Offer is not good for provided wallet.") + if (walletId !== offer.details.payment.userAcct) return new ValidationError("Offer is not good for provided wallet.") const validOffer = await ValidOffer.from(offer.details) if (validOffer instanceof Error) return validOffer @@ -88,4 +94,4 @@ const OffersManager = { } -export default OffersManager \ No newline at end of file +export default CashoutManager \ No newline at end of file diff --git a/src/app/offers/ValidOffer.ts b/src/app/offers/ValidOffer.ts index 6027a332f..f9fceae22 100644 --- a/src/app/offers/ValidOffer.ts +++ b/src/app/offers/ValidOffer.ts @@ -7,10 +7,11 @@ import { AccountsRepository, WalletsRepository } from "@services/mongoose" import Ibex from "@services/ibex/client" import { EmailService } from "@services/email" import { CashoutDetails, ValidationInputs } from "./types" -import ErpNext from "@services/frappe/ErpNext" -import { JournalEntryDraftError, JournalEntrySubmitError } from "@services/frappe/errors" +import ErpNext, { CashoutId } from "@services/frappe/ErpNext" +import { JournalEntryDraftError, CashoutSubmitError } from "@services/frappe/errors" import { baseLogger } from "@services/logger" import { IbexError } from "@services/ibex/errors" +import { Cashout } from "@config" // Only way to construct a ValidOffer is using the static method which contains validations class ValidOffer extends Offer { @@ -27,7 +28,7 @@ class ValidOffer extends Offer { static from = async ( details: CashoutDetails, ): Promise => { - const wallet = await WalletsRepository().findById(details.ibexTrx.userAcct) + const wallet = await WalletsRepository().findById(details.payment.userAcct) if (wallet instanceof RepositoryError) return new ValidationError(wallet) const account = await AccountsRepository().findById(wallet.accountId) @@ -42,25 +43,28 @@ class ValidOffer extends Offer { } async execute(): Promise { - const journal = await ErpNext.draftCashout(this) - if (journal instanceof JournalEntryDraftError) return journal - const id = journal.journalId + const cashoutId = await ErpNext.draftCashout(this) + if (cashoutId instanceof JournalEntryDraftError) return cashoutId - const resp = await Ibex.payInvoice({ - accountId: this.details.ibexTrx.userAcct, - invoice: this.details.ibexTrx.invoice.paymentRequest as unknown as Bolt11, - }) - if (resp instanceof IbexError) { - ErpNext.delete(id) // clean up accounting entry - return resp + if (!Cashout.SkipPayment) { + const resp = await Ibex.payInvoice({ + accountId: this.details.payment.userAcct, + invoice: this.details.payment.invoice.paymentRequest as unknown as Bolt11, + }) + if (resp instanceof IbexError) { + baseLogger.error({ resp }, "Failed to pay invoice for cashout") + return resp + } + } else { + baseLogger.warn({ cashoutId }, "Skipping Ibex payment (skipPayment=true)") } - const submitted = await ErpNext.submit(id) - if (submitted instanceof JournalEntrySubmitError) { + const submitted = await ErpNext.submitCashout(cashoutId) + if (submitted instanceof CashoutSubmitError) { baseLogger.error({ submitted }, "Failed to submit journal after payment sent") } - return new InitiatedCashout(this, id) + return new InitiatedCashout(this, cashoutId) } } @@ -70,5 +74,5 @@ export default ValidOffer export class InitiatedCashout { readonly status = PaymentSendStatus.Pending - constructor(readonly offer: ValidOffer, readonly journalId: LedgerJournalId) {} + constructor(readonly offer: ValidOffer, readonly cashoutId: CashoutId) {} } diff --git a/src/app/offers/Validator.ts b/src/app/offers/Validator.ts index 6faa001aa..316e7531b 100644 --- a/src/app/offers/Validator.ts +++ b/src/app/offers/Validator.ts @@ -1,55 +1,66 @@ import { getBalanceForWallet } from "@app/wallets"; import { Cashout } from "@config"; import { AccountValidator, hasErpParty, isActiveAccount, walletBelongsToAccount } from "@domain/accounts"; -import { USDAmount, ValidationError, ValidationFn, validator } from "@domain/shared"; +import { JMDAmount, USDAmount, ValidationError, ValidationFn, validator } from "@domain/shared"; import { ValidationInputs } from "./types"; +import ErpNext from "@services/frappe/ErpNext"; const config = Cashout.validations const isBeforeExpiry = async (o: ValidationInputs): Promise => { const now = new Date() - if (now > o.ibexTrx.invoice.expiresAt) return new ValidationError("Offer has expired") + if (now > o.payment.invoice.expiresAt) return new ValidationError("Offer has expired") else return true } const cashoutMin = async (o: ValidationInputs): Promise => { const min = USDAmount.cents(config.minimum.amount) if (min instanceof Error) return new ValidationError(min) - if (o.ibexTrx.usd.isLesserThan(min)) + if (o.payment.amount.isLesserThan(min)) return new ValidationError(`Minimum cashout is $${min.asDollars()}`) - else return true + else return true } const cashoutMax: ValidationFn = async (o: ValidationInputs): Promise => { const max = USDAmount.cents(config.maximum.amount) if (max instanceof Error) return new ValidationError(max) - if (o.ibexTrx.usd.isGreaterThan(max) ) + if (o.payment.amount.isGreaterThan(max)) return new ValidationError(`Maximum cashout is $${max.asDollars()}`) - else return true -} + else return true +} const isUsd = async (o: ValidationInputs) => { - // if (o.ibexTrx.currency !== "USD") - // return new ValidationError("Cash out only supports USD") - if (o.wallet.currency !== "USD") + if (o.wallet.currency !== "USD") return new ValidationError("Cash out only supports withdrawals from USD wallets") return true } const hasSufficientBalance = async (o: ValidationInputs): Promise => { const balance = await getBalanceForWallet({ walletId: o.wallet.id }) - if (balance instanceof Error) - return new ValidationError(balance) - else if (o.ibexTrx.usd.isGreaterThan(balance)) + if (balance instanceof Error) + return new ValidationError(balance) + else if (o.payment.amount.isGreaterThan(balance)) return new ValidationError("Transfer amount is greater than wallet balance.") else return true } - const accountLevel = async (o: ValidationInputs) => { return AccountValidator(o.account).isLevel(config.accountLevel) } +// Much of this logic is checked server-side in erpnext, but we want to catch it as early as possible +const verifyBankAccount = async (o: ValidationInputs): Promise => { + const erpParty = o.account.erpParty + if (!erpParty) return new ValidationError("Account does not have an associated erpParty") + const banks = await ErpNext.getBankAccountsByCustomer(erpParty) + if (banks instanceof Error) return new ValidationError("Could not confirm bank account for user") + const bankAccount = banks.find(b => b.name === o.payout.bankAccountId) + if (!bankAccount) return new ValidationError("Bank account does not belong to user") + const payoutCurrency = o.payout.amount instanceof JMDAmount ? "JMD" : "USD" + if (bankAccount.currency !== payoutCurrency) + return new ValidationError(`Bank account currency (${bankAccount.currency}) does not match payout currency (${payoutCurrency})`) + return true +} export const CashoutValidator = validator([ isUsd, @@ -61,5 +72,6 @@ export const CashoutValidator = validator([ hasSufficientBalance, isBeforeExpiry, hasErpParty, + verifyBankAccount, // TODO daily/weekly/monthly volume limits ]) \ No newline at end of file diff --git a/src/app/offers/index.ts b/src/app/offers/index.ts index 5a580bee8..789660ac9 100644 --- a/src/app/offers/index.ts +++ b/src/app/offers/index.ts @@ -1,10 +1,2 @@ -export * from "./OffersManager" -export * from "./types" -// export interface IOffersManager { -// makeCashoutOffer( -// walletId: WalletId, -// sendFlash: Amount<"USD">, -// ): Promise - -// executeOffer(id: OfferId): Promise -// } \ No newline at end of file +export * from "./CashoutManager" +export * from "./types" \ No newline at end of file diff --git a/src/app/offers/storage/Redis.ts b/src/app/offers/storage/Redis.ts index 169e5503e..13dedb5f6 100644 --- a/src/app/offers/storage/Redis.ts +++ b/src/app/offers/storage/Redis.ts @@ -20,17 +20,13 @@ const OffersSerde = { }); }, - // todo: Find better way to identify MoneyAmount deserialize: (json: string) => { return JSON.parse( - json, + json, (key: string, value: any) => { - if (['usd', 'jmd', 'fee'].includes(key.toLowerCase()) && Array.isArray(value)) { + if (['amount', 'servicefee', 'exchangerate'].includes(key.toLowerCase()) && Array.isArray(value)) { return toMoneyAmountFromJSON(value as [string, string]) } - if (key.toLowerCase() === 'amount' && typeof value === 'string') { - return BigInt(value); - } return value; }) } diff --git a/src/app/offers/types.ts b/src/app/offers/types.ts index ecdf5edf1..107df62ed 100644 --- a/src/app/offers/types.ts +++ b/src/app/offers/types.ts @@ -2,19 +2,17 @@ import { USDAmount, JMDAmount } from "@domain/shared" // Full details in a cashout transaction export type CashoutDetails = { - readonly ibexTrx: { + readonly payment: { readonly userAcct: WalletId, readonly flashAcct: WalletId, readonly invoice: LnInvoice, - readonly usd: USDAmount, + readonly amount: USDAmount, }, - readonly flash: { - readonly liability: { - readonly usd: USDAmount, - readonly jmd: JMDAmount, - } - readonly fee: USDAmount - readonly exchangeRate: JMDAmount, + readonly payout: { + readonly bankAccountId: string, + readonly amount: USDAmount | JMDAmount, + readonly serviceFee: USDAmount, + readonly exchangeRate?: JMDAmount, }, } diff --git a/src/config/schema.ts b/src/config/schema.ts index 1786c4fbf..4f75d2c97 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -649,6 +649,7 @@ export const configSchema = { type: "object", properties: { enabled: { type: "boolean" }, + skipPayment: { type: "boolean", default: false }, }, required: ["enabled", "minimum", "maximum", "accountLevel"], default: { enabled: true }, @@ -658,14 +659,6 @@ export const configSchema = { properties: { url: { type: "string" }, credentials: { type: "object" }, - erpnext: { - type: "object", - properties: { - accounts: { - type: "object", - } - }, - } }, required: ["url", "credentials"], }, diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index a0ab634e6..e49908962 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -175,6 +175,7 @@ type YamlSchema = { exchangeRates: StaticRates cashout: { enabled: boolean + skipPayment?: boolean minimum: { amount: number currency: string @@ -205,15 +206,6 @@ type FrappeConfig = { url: string sitename: string credentials: FrappeCredentials - erpnext: { - accounts: { - ibex: { - operating: string - } - cashout: string - serviceFees: string - } - } } type CurrencyCode = string diff --git a/src/config/yaml.ts b/src/config/yaml.ts index 64e7478ef..0ac041840 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -364,6 +364,7 @@ export const ExchangeRates = { export const Cashout = { Enabled: yamlConfig.cashout.enabled as boolean, + SkipPayment: (yamlConfig.cashout.skipPayment ?? false) as boolean, OfferConfig: { fee: BigInt(yamlConfig.cashout.fee) as BasisPoints, duration: yamlConfig.cashout.duration as Seconds, diff --git a/src/graphql/public/root/mutation/offers/initiate-cash-out.ts b/src/graphql/public/root/mutation/offers/initiate-cash-out.ts index c415f5273..e53818355 100644 --- a/src/graphql/public/root/mutation/offers/initiate-cash-out.ts +++ b/src/graphql/public/root/mutation/offers/initiate-cash-out.ts @@ -1,5 +1,5 @@ -import OffersManager from "@app/offers/OffersManager" +import CashoutManager from "@app/offers/CashoutManager" import { Cashout } from "@config" import { NotImplementedError } from "@domain/errors" import { ErrorLevel } from "@domain/shared" @@ -28,7 +28,7 @@ const InitiatedCashoutResponse = GT.Object({ errors: { type: GT.NonNullList(IError), }, - journalId: { + id: { type: GT.ID, }, }), @@ -54,13 +54,13 @@ const InitiateCashoutMutation = GT.Field({ if (f instanceof Error) return { errors: [{ message: f.message, success: false }] } } - const offer = await (OffersManager.executeCashout(offerId, walletId)) + const offer = await (CashoutManager.executeCashout(offerId, walletId)) if (offer instanceof Error) { recordExceptionInCurrentSpan({ error: offer, level: ErrorLevel.Critical, attributes: { offerId} }) return new InternalServerError({ message: "Server error. Please contact support", logger: baseLogger }) } - return { errors: [], journalId: offer.journalId } + return { errors: [], id: offer.cashoutId } }, }) diff --git a/src/graphql/public/root/mutation/offers/request-cash-out.ts b/src/graphql/public/root/mutation/offers/request-cash-out.ts index 7c47ad80d..5b9f0185e 100644 --- a/src/graphql/public/root/mutation/offers/request-cash-out.ts +++ b/src/graphql/public/root/mutation/offers/request-cash-out.ts @@ -1,4 +1,4 @@ -import OffersManager from "@app/offers/OffersManager" +import CashoutManager from "@app/offers/CashoutManager" import { mapToGqlErrorList } from "@graphql/error-map" import { GT } from "@graphql/index" import CashoutOffer from "@graphql/public/types/object/cashout-offer" @@ -20,8 +20,12 @@ const RequestCashoutInput = GT.Input({ description: "ID for a USD wallet belonging to the current user.", }, amount: { - type: GT.NonNull(USDCentsScalar), - description: "Amount in USD cents." + type: GT.NonNull(USDCentsScalar), + description: "Amount in USD cents." + }, + bankAccountId: { + type: GT.NonNull(GT.ID), + description: "ERPNext bank account identifier to receive the cashout.", }, }), }) @@ -52,29 +56,17 @@ const RequestCashoutMutation = GT.Field({ if (!Cashout.Enabled) return new NotImplementedError("Cashout feature is not enabled") - const { walletId, amount } = args.input - for (const input of [walletId, amount]) { + const { walletId, amount, bankAccountId } = args.input + for (const input of [walletId, amount, bankAccountId]) { if (input instanceof Error) { return { errors: [{ message: input.message }] } } } - - // For now, I want to surface the bank selection, - // but eventually move out of graphql resolver - const wallet = await WalletsRepository().findById(walletId) - if (wallet instanceof RepositoryError) return new ValidationError(wallet) - const account = await AccountsRepository().findById(wallet.accountId) - if (account instanceof RepositoryError) return new ValidationError(account) - if (!account.erpParty) return new Error("Could not find erpParty for account") - const banks = await ErpNext.getBankAccountsByCustomer(account.erpParty) - if (banks instanceof Error) return banks - if (!banks.length) return Error(`Could not find banks for customer: ${account.erpParty}`) - - const offer = await (OffersManager.createCashoutOffer( + const offer = await (CashoutManager.createOffer( walletId, - amount, - banks[0], // todo: allow user to select bank account + amount, + bankAccountId, )) if (offer instanceof Error) return { errors: mapToGqlErrorList(offer) } diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 80023feb5..3bb2e5982 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -198,9 +198,11 @@ type Bank { type BankAccount { accountName: String! accountNumber: String! + accountType: String! """Name of the bank institution""" bank: String! + branchCode: String! """Account currency (e.g. JMD, USD)""" currency: String! @@ -273,22 +275,26 @@ input CaptchaRequestAuthCodeInput { type CashoutOffer { """The rate used when withdrawing to a JMD bank account""" - exchangeRate: JMDCents! + exchangeRate: JMDCents """The time at which this offer is no longer accepted by Flash""" expiresAt: Timestamp! - """The amount that Flash is charging for it's services""" + """The amount that Flash is charging for its services""" flashFee: USDCents! """ID of the offer""" offerId: ID! - """The amount Flash owes to the user denominated in JMD as cents""" - receiveJmd: JMDCents! + """ + The amount Flash owes to the user denominated in JMD cents (null for USD payouts) + """ + receiveJmd: JMDCents - """The amount Flash owes to the user denominated in USD as cents""" - receiveUsd: USDCents! + """ + The amount Flash owes to the user denominated in USD cents (null for JMD payouts) + """ + receiveUsd: USDCents """The amount the user is sending to flash""" send: USDCents! @@ -495,7 +501,7 @@ input InitiateCashoutInput { type InitiatedCashoutResponse { errors: [Error!]! - journalId: ID + id: ID } union InitiationVia = InitiationViaIntraLedger | InitiationViaLn | InitiationViaOnChain @@ -1257,6 +1263,9 @@ input RequestCashoutInput { """Amount in USD cents.""" amount: USDCents! + """ERPNext bank account identifier to receive the cashout.""" + bankAccountId: ID! + """ID for a USD wallet belonging to the current user.""" walletId: WalletId! } diff --git a/src/graphql/public/types/object/cashout-offer.ts b/src/graphql/public/types/object/cashout-offer.ts index baba9c1a3..25b03983a 100644 --- a/src/graphql/public/types/object/cashout-offer.ts +++ b/src/graphql/public/types/object/cashout-offer.ts @@ -1,11 +1,11 @@ import { GT } from "@graphql/index" import WalletId from "@graphql/shared/types/scalar/wallet-id" import Timestamp from "@graphql/shared/types/scalar/timestamp" -import { CashoutOffer } from "@app/offers" import PersistedOffer from "@app/offers/storage/PersistedOffer" import { GraphQLObjectType } from "graphql" import USDCentsScalar from "@graphql/shared/types/scalar/usd-cents" import JMDCentsScalar from "@graphql/shared/types/scalar/jmd-cent-amount" +import { JMDAmount, USDAmount } from "@domain/shared" const CashoutOffer: GraphQLObjectType = GT.Object({ name: "CashoutOffer", @@ -18,37 +18,37 @@ const CashoutOffer: GraphQLObjectType = GT walletId: { type: GT.NonNull(WalletId), description: "ID for the users USD wallet to send from", - resolve: (o) => o.details.ibexTrx.userAcct, + resolve: (o) => o.details.payment.userAcct, }, send: { - type: GT.NonNull(USDCentsScalar), - description: "The amount the user is sending to flash" , - resolve: (o) => o.details.ibexTrx.usd // Number(src.details.ibexTrx.usd.asCents(2)), + type: GT.NonNull(USDCentsScalar), + description: "The amount the user is sending to flash", + resolve: (o) => o.details.payment.amount, }, receiveUsd: { - type: GT.NonNull(USDCentsScalar), - description: "The amount Flash owes to the user denominated in USD as cents", - resolve: (o) => o.details.flash.liability.usd // Number(src.details.flash.liability.usd.asCents(0)), + type: USDCentsScalar, + description: "The amount Flash owes to the user denominated in USD cents (null for JMD payouts)", + resolve: (o) => o.details.payout.amount instanceof USDAmount ? o.details.payout.amount : null, }, receiveJmd: { - type: GT.NonNull(JMDCentsScalar), - description: "The amount Flash owes to the user denominated in JMD as cents", - resolve: (o) => o.details.flash.liability.jmd, + type: JMDCentsScalar, + description: "The amount Flash owes to the user denominated in JMD cents (null for USD payouts)", + resolve: (o) => o.details.payout.amount instanceof JMDAmount ? o.details.payout.amount : null, }, exchangeRate: { - type: GT.NonNull(JMDCentsScalar), + type: JMDCentsScalar, description: "The rate used when withdrawing to a JMD bank account", - resolve: (o) => o.details.flash.exchangeRate, + resolve: (o) => o.details.payout.exchangeRate ?? null, }, flashFee: { - type: GT.NonNull(USDCentsScalar), - description: "The amount that Flash is charging for it's services", - resolve: (o) => o.details.flash.fee // Number(src.details.flash.fee.asCents(2)), + type: GT.NonNull(USDCentsScalar), + description: "The amount that Flash is charging for its services", + resolve: (o) => o.details.payout.serviceFee, }, expiresAt: { type: GT.NonNull(Timestamp), description: "The time at which this offer is no longer accepted by Flash", - resolve: (o) => o.details.ibexTrx.invoice.expiresAt.getTime(), + resolve: (o) => o.details.payment.invoice.expiresAt.getTime(), }, }), }) diff --git a/src/services/email/templates/cashout.ts b/src/services/email/templates/cashout.ts index fc1a0877b..697335f0c 100644 --- a/src/services/email/templates/cashout.ts +++ b/src/services/email/templates/cashout.ts @@ -1,10 +1,11 @@ import { CashoutDetails } from "@app/offers" +import { JMDAmount } from "@domain/shared" type CashoutBodyArgs = CashoutDetails & { username: Username, formattedDate: string } export const CashoutBody = (args: CashoutBodyArgs) => { - const usdString = `${args.flash.liability.usd.asDollars()} USD` - const jmdString = `${args.flash.liability.jmd.asDollars()} JMD` + const currency = args.payout.amount instanceof JMDAmount ? "JMD" : "USD" + const payoutString = `${args.payout.amount.asDollars()} ${currency}` return { html: ` @@ -29,15 +30,11 @@ export const CashoutBody = (args: CashoutBodyArgs) => { - + - + @@ -54,7 +51,7 @@ export const CashoutBody = (args: CashoutBodyArgs) => { - +
Ibex Payment${args.ibexTrx.invoice.paymentHash}${args.payment.invoice.paymentHash}
Owed to user - ${usdString} - OR - ${jmdString} - ${payoutString}
Date & Time
Wallet ID${args.ibexTrx.userAcct}${args.payment.userAcct}
@@ -73,15 +70,14 @@ export const CashoutBody = (args: CashoutBodyArgs) => { Transaction Details: -------------------- - Ibex Payment: ${args.ibexTrx.invoice.paymentHash} - Amount owed: ${usdString} - OR ${jmdString} + Ibex Payment: ${args.payment.invoice.paymentHash} + Amount owed: ${payoutString} Date & Time: ${args.formattedDate} User Information: ------------------- Username: ${args.username} - Wallet ID: ${args.ibexTrx.userAcct} + Wallet ID: ${args.payment.userAcct} ---------------------- This is an automated notification from Flash. Please do not reply to this email. diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index 60f8c7c92..c9d0e297b 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -1,13 +1,13 @@ import ValidOffer from "@app/offers/ValidOffer" import { FrappeConfig } from "@config" -import { USDAmount, Validated } from "@domain/shared" +import { JMDAmount, USDAmount, Validated } from "@domain/shared" import { baseLogger } from "@services/logger" import { recordExceptionInCurrentSpan } from "@services/tracing" import axios, { isAxiosError } from "axios" import { JournalEntryDraftError, - JournalEntrySubmitError, + CashoutSubmitError, JournalEntryTitleError, JournalEntryDeleteError, UpgradeRequestCreateError, @@ -23,7 +23,6 @@ import { import { Bank } from "./models/Bank" import { BankAccount } from "./models/BankAccount" import { Filter } from "./SearchFilters" -import ibex from "@services/ibex" export type AccountUpgradeRequestFilters = { username?: Filter, status?: Filter } type ErpNextFilter = [string, string, string, string[]] @@ -37,6 +36,8 @@ export const toJson = (filters: AccountUpgradeRequestFilters): string => { // Move to MoneyAmount const erpUsd = (usd: USDAmount): number => Number(usd.asCents(2)) +export type CashoutId = string & { readonly brand: unique symbol } + class ErpNext { url: string headers: Record @@ -50,122 +51,48 @@ class ErpNext { } } - async draftCashout(offer: ValidOffer): Promise { + async draftCashout(offer: ValidOffer): Promise { const party = offer.account.erpParty if (!party) return new JournalEntryDraftError("Account missing erpParty field") - const { ibexTrx, flash } = offer.details - const { liability } = flash - const flashFee = flash.fee - // const journalEntry = { - // doctype: "Journal Entry", - // company: "Flash", - // multi_currency: 1, - // posting_date: new Date().toISOString().split("T")[0], - // remark: `${JSON.stringify({ paymentHash: ibexTrx.invoice.paymentHash, userWalletId: ibexTrx.userAcct })}`, - // accounts: [ - // { - // account: FrappeConfig.erpnext.accounts.ibex.operating, - // account_currency: "USD", - // debit_in_account_currency: erpUsd(ibexTrx.usd), - // debit: erpUsd(ibexTrx.usd), - // exchange_rate: 1, - // }, - // { - // account: FrappeConfig.erpnext.accounts.cashout, - // account_currency: "JMD", - // credit_in_account_currency: Number(liability.jmd.asCents(2)), - // credit: erpUsd(liability.usd), - // exchange_rate: erpUsd(liability.usd) / Number(liability.jmd.asCents(2)), - // party_type: "Customer", - // party, - // }, - // { - // account: FrappeConfig.erpnext.accounts.serviceFees, - // account_currency: "USD", - // credit_in_account_currency: erpUsd(flashFee), - // credit: erpUsd(flashFee), - // exchange_rate: 1, - // }, - // ], - // } - - // try { - // const resp = await axios.post( - // `${this.url}/api/resource/Journal Entry`, - // journalEntry, - // { headers: this.headers }, - // ) - // const titleResp = this.updateTitle( - // resp.data.data.name, - // `Open cashout ${ibexTrx.invoice.paymentHash.substring(0, 5)}`, - // ) - // if (titleResp instanceof JournalEntryTitleError) { - // baseLogger.error({ err: titleResp }, "Error updating JE title in ERPNext") - // } - - // return { - // journalId: resp.data.data.name, - // voided: false, - // transactionIds: [], - // } as LedgerJournal + const { payment, payout } = offer.details try { const response = await axios.post( `${this.url}/api/resource/Cashout`, { customer: party, - // bank_account: offer.details.bank, - amount: 100.00, - transaction_id: ibexTrx.invoice.paymentHash, - wallet_id: ibexTrx.userAcct, - flash_wallet: ibexTrx.flashAcct, - // invoice: ibexTrx.invoice.paymentHash, - usd_liability: liability.usd, - jmd_liability: liability.jmd, - exchange_rate: flash.exchangeRate, - flash_fee: flashFee, + bank_account: payout.bankAccountId, + transaction_id: payment.invoice.paymentHash, + wallet_id: payment.userAcct, + flash_wallet: payment.flashAcct, + user_receives: Number(payout.amount.asDollars()), + user_pays: Number(payment.amount.asDollars()), + currency: payout.amount.currencyCode, + exchange_rate: Number(payout.exchangeRate?.asDollars()), + flash_fee: Number(payout.serviceFee.asDollars()), }, { headers: this.headers }, ); console.log("Cashout response:", response.data); - return { - journalId: response.data.data.name, - voided: false, - transactionIds: [], - } + return response.data.data.name as CashoutId } catch (err) { baseLogger.error({ err }, "Error drafting Cashout in ERPNext") return new JournalEntryDraftError(err) } } - private async updateTitle( - jeName: string, - title: string, - ): Promise { - try { - const resp = await axios.put( - `${this.url}/api/resource/Journal Entry/${jeName}`, - { title }, - { headers: this.headers }, - ) - return resp.data - } catch (err) { - return new JournalEntryTitleError(err) - } - } - - async submit(jeName: string): Promise { + async submitCashout(cashoutId: CashoutId): Promise { try { - const resp = await axios.put( - `${this.url}/api/resource/Journal Entry/${jeName}`, - { docstatus: 1 }, + await axios.post( + `${this.url}/api/method/admin_panel.admin_panel.doctype.cashout.cashout.submit_cashout`, + { name: cashoutId }, { headers: this.headers }, ) - return resp.data + return true } catch (err) { - baseLogger.error({ err }, "Error submitting JE in ERPNext") - return new JournalEntrySubmitError(err) + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error({ err, responseData }, "Error submitting Cashout in ERPNext") + return new CashoutSubmitError(err) } } diff --git a/src/services/frappe/errors.ts b/src/services/frappe/errors.ts index 2392acca1..504616d51 100644 --- a/src/services/frappe/errors.ts +++ b/src/services/frappe/errors.ts @@ -4,6 +4,7 @@ export class ErpNextError extends DomainError {} export class JournalEntryDraftError extends ErpNextError {} export class JournalEntryTitleError extends JournalEntryDraftError {} export class JournalEntrySubmitError extends ErpNextError {} +export class CashoutSubmitError extends ErpNextError {} export class JournalEntryDeleteError extends ErpNextError {} export class UpgradeRequestCreateError extends ErpNextError {} export class UpgradeRequestQueryError extends ErpNextError {} diff --git a/test/flash/integration/offers/execute-offer.spec.ts b/test/flash/integration/offers/execute-offer.spec.ts index f723a96ef..f8d8ed109 100644 --- a/test/flash/integration/offers/execute-offer.spec.ts +++ b/test/flash/integration/offers/execute-offer.spec.ts @@ -1,4 +1,4 @@ -import OffersManager from "@app/offers/OffersManager" +import CashoutManager from "@app/offers/CashoutManager" // import { mockedIbex } from "../jest.setup" import { USDAmount } from "@domain/shared" @@ -36,11 +36,12 @@ afterEach(async () => { describe("Offers", () => { it("successfully makes and executes an offer", async () => { - const offer = await OffersManager.createCashoutOffer(alice.usdWalletD.id, send) + const manager = new CashoutManager() + const offer = await manager.makeCashoutOffer(alice.usdWalletD.id, send) if (offer instanceof Error) throw offer const { id } = offer - const status = await OffersManager.executeCashout(id, alice.usdWalletD.id) + const status = await CashoutManager.executeCashout(id, alice.usdWalletD.id) // make assertions against ledger expect(status).toBeDefined() diff --git a/test/flash/integration/offers/make-cashout-offer.spec.ts b/test/flash/integration/offers/make-cashout-offer.spec.ts index ca59ffe19..17560c04d 100644 --- a/test/flash/integration/offers/make-cashout-offer.spec.ts +++ b/test/flash/integration/offers/make-cashout-offer.spec.ts @@ -1,4 +1,4 @@ -import OffersManager from "@app/offers/OffersManager" +import CashoutManager from "@app/offers/CashoutManager" // import { mockedIbex } from "../jest.setup" import Ibex from "@services/ibex/client" @@ -41,7 +41,7 @@ afterEach(async () => { describe("Offers", () => { it("successfully makes and persists an offer using default config", async () => { - const offer = await OffersManager.createCashoutOffer(alice.usdWalletD.id, send) + const offer = await (new CashoutManager().makeCashoutOffer(alice.usdWalletD.id, send)) if (offer instanceof Error) throw offer expect(offer.details.ibexTrx.usd.asCents()).toEqual(send.asCents()) From 506a6ef2271521c55e1a68098f1dfadfd5696d9e Mon Sep 17 00:00:00 2001 From: Benjamin Hindman Date: Mon, 11 May 2026 13:47:40 -0500 Subject: [PATCH 3/4] fix: retry submitCashout on failure and fix NaN exchange_rate for USD payouts --- src/app/offers/ValidOffer.ts | 11 +++++++---- src/services/frappe/ErpNext.ts | 3 +-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/offers/ValidOffer.ts b/src/app/offers/ValidOffer.ts index f9fceae22..a33553137 100644 --- a/src/app/offers/ValidOffer.ts +++ b/src/app/offers/ValidOffer.ts @@ -5,7 +5,6 @@ import { CashoutValidator } from "./Validator" import { RepositoryError } from "@domain/errors" import { AccountsRepository, WalletsRepository } from "@services/mongoose" import Ibex from "@services/ibex/client" -import { EmailService } from "@services/email" import { CashoutDetails, ValidationInputs } from "./types" import ErpNext, { CashoutId } from "@services/frappe/ErpNext" import { JournalEntryDraftError, CashoutSubmitError } from "@services/frappe/errors" @@ -59,12 +58,16 @@ class ValidOffer extends Offer { baseLogger.warn({ cashoutId }, "Skipping Ibex payment (skipPayment=true)") } - const submitted = await ErpNext.submitCashout(cashoutId) + let submitted = await ErpNext.submitCashout(cashoutId) if (submitted instanceof CashoutSubmitError) { - baseLogger.error({ submitted }, "Failed to submit journal after payment sent") + baseLogger.warn({ cashoutId }, "submitCashout failed, retrying") + submitted = await ErpNext.submitCashout(cashoutId) + if (submitted instanceof CashoutSubmitError) { + baseLogger.error({ cashoutId }, "submitCashout failed after retry — manual intervention required") + } } - return new InitiatedCashout(this, cashoutId) + return new InitiatedCashout(this, cashoutId) } } diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index c9d0e297b..016301e9a 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -68,12 +68,11 @@ class ErpNext { user_receives: Number(payout.amount.asDollars()), user_pays: Number(payment.amount.asDollars()), currency: payout.amount.currencyCode, - exchange_rate: Number(payout.exchangeRate?.asDollars()), + exchange_rate: payout.exchangeRate ? Number(payout.exchangeRate.asDollars()) : undefined, flash_fee: Number(payout.serviceFee.asDollars()), }, { headers: this.headers }, ); - console.log("Cashout response:", response.data); return response.data.data.name as CashoutId } catch (err) { baseLogger.error({ err }, "Error drafting Cashout in ERPNext") From 21226ea41c17daf8d9366395e2885855c331558f Mon Sep 17 00:00:00 2001 From: forge0x Date: Mon, 11 May 2026 15:25:53 -0400 Subject: [PATCH 4/4] fix: make frappe restore target compose service names --- dev/erpnext/restore.sh | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/dev/erpnext/restore.sh b/dev/erpnext/restore.sh index 427a37c1f..e2b978554 100755 --- a/dev/erpnext/restore.sh +++ b/dev/erpnext/restore.sh @@ -1,7 +1,11 @@ #!/bin/bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) # Check if backup file is provided as argument -if [ -z "$1" ]; then +if [ -z "${1:-}" ]; then echo "Usage: $0 " echo "Example: $0 backups/20260122_062420-frontend-database.sql.gz" exit 1 @@ -9,6 +13,9 @@ fi BACKUP_FILE="$1" DB_PASSWORD="admin" # defined in docker compose +FRAPPE_BACKEND_SERVICE="frappe-backend" +SITE_NAME="frontend" +RESTORE_DIR="/tmp/restore" # Check if backup file exists if [ ! -f "$BACKUP_FILE" ]; then @@ -16,23 +23,26 @@ if [ ! -f "$BACKUP_FILE" ]; then exit 1 fi -# Get just the filename from the path +# Get absolute backup path before switching to repo root for docker compose. +BACKUP_FILE=$(cd "$(dirname "$BACKUP_FILE")" && pwd)/$(basename "$BACKUP_FILE") BACKUP_FILENAME=$(basename "$BACKUP_FILE") +cd "$REPO_ROOT" + # Copy the backup file from host to container restore directory -docker exec flash-frappe-backend-1 mkdir -p /tmp/restore -docker cp "$BACKUP_FILE" flash-frappe-backend-1:/tmp/restore/"$BACKUP_FILENAME" +docker compose exec -T "$FRAPPE_BACKEND_SERVICE" mkdir -p "$RESTORE_DIR" +docker compose cp "$BACKUP_FILE" "$FRAPPE_BACKEND_SERVICE:$RESTORE_DIR/$BACKUP_FILENAME" # Remove stale locks if present (e.g. from frappe-create-site) -docker exec flash-frappe-backend-1 rm -f /home/frappe/frappe-bench/sites/frontend/locks/*.lock 2>/dev/null || true +docker compose exec -T "$FRAPPE_BACKEND_SERVICE" rm -f "/home/frappe/frappe-bench/sites/$SITE_NAME/locks/"*.lock 2>/dev/null || true # Restore the database inside the container with the password -docker exec flash-frappe-backend-1 bench --site frontend restore --db-root-password "$DB_PASSWORD" /tmp/restore/"$BACKUP_FILENAME" +docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" restore --db-root-password "$DB_PASSWORD" "$RESTORE_DIR/$BACKUP_FILENAME" # Run migrate to sync database schema with current code -echo "Migrating frontend" -docker exec flash-frappe-backend-1 bench --site frontend migrate || { +echo "Migrating $SITE_NAME" +docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" migrate || { echo "Migration failed, retrying..." sleep 5 - docker exec flash-frappe-backend-1 bench --site frontend migrate + docker compose exec -T "$FRAPPE_BACKEND_SERVICE" bench --site "$SITE_NAME" migrate }