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/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/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/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..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://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" - erpnext: - accounts: - ibex: - operating: "Ibex Operating - F" - cashout: "Cashout Payables (JMD) - F" - serviceFees: "Service Fees - F" sendgrid: apiKey: "" 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 } diff --git a/src/app/offers/OffersManager.ts b/src/app/offers/CashoutManager.ts similarity index 59% rename from src/app/offers/OffersManager.ts rename to src/app/offers/CashoutManager.ts index b2a35e985..60770ef74 100644 --- a/src/app/offers/OffersManager.ts +++ b/src/app/offers/CashoutManager.ts @@ -9,16 +9,21 @@ 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 { BankAccountQueryError } from "@services/frappe/errors" const config = { ...Cashout.OfferConfig, ...ExchangeRates, } -const OffersManager = { - createCashoutOffer: async ( +const CashoutManager = { + createOffer: async ( walletId: WalletId, userPayment: USDAmount, + bankAccountId: string, ): Promise => { const flashWallet = await getBankOwnerIbexAccount() @@ -33,27 +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 @@ -66,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 @@ -81,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..a33553137 100644 --- a/src/app/offers/ValidOffer.ts +++ b/src/app/offers/ValidOffer.ts @@ -5,12 +5,12 @@ 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 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 +27,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 +42,32 @@ 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) { - baseLogger.error({ submitted }, "Failed to submit journal after payment sent") + let submitted = await ErpNext.submitCashout(cashoutId) + if (submitted instanceof CashoutSubmitError) { + 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, id) + return new InitiatedCashout(this, cashoutId) } } @@ -70,5 +77,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 d5fd9f5d4..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/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/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 93817b8bc..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" @@ -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", @@ -17,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.", }, }), }) @@ -49,16 +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 }] } } } - const offer = await (OffersManager.createCashoutOffer( + const offer = await (CashoutManager.createOffer( walletId, - amount, + 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 72f330f4c..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), + 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 6343c7217..016301e9a 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, @@ -36,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 @@ -49,97 +51,47 @@ 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, - }, - ], - } + const { payment, payout } = offer.details 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: 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: payout.exchangeRate ? Number(payout.exchangeRate.asDollars()) : undefined, + flash_fee: Number(payout.serviceFee.asDollars()), + }, { 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 + ); + return response.data.data.name as CashoutId } catch (err) { - baseLogger.error({ err, journalEntry }, "Error drafting JE in ERPNext") + baseLogger.error({ err }, "Error drafting Cashout in ERPNext") return new JournalEntryDraftError(err) } } - private async updateTitle( - jeName: string, - title: string, - ): Promise { + async submitCashout(cashoutId: CashoutId): Promise { try { - const resp = await axios.put( - `${this.url}/api/resource/Journal Entry/${jeName}`, - { title }, + 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) { - return new JournalEntryTitleError(err) - } - } - - async submit(jeName: string): Promise { - try { - const resp = await axios.put( - `${this.url}/api/resource/Journal Entry/${jeName}`, - { docstatus: 1 }, - { headers: this.headers }, - ) - return resp.data - } 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())