diff --git a/src/app/accounts/bank-account-update-request.ts b/src/app/accounts/bank-account-update-request.ts new file mode 100644 index 000000000..14cbcb238 --- /dev/null +++ b/src/app/accounts/bank-account-update-request.ts @@ -0,0 +1,106 @@ +import { AccountsRepository } from "@services/mongoose" +import ErpNext from "@services/frappe/ErpNext" +import { BankAccount } from "@services/frappe/models/BankAccount" +import { BankAccountUpdateRequest } from "@services/frappe/models/BankAccountUpdateRequest" +import { RequestStatus } from "@services/frappe/models/AccountUpgradeRequest" +import { ValidationError } from "@domain/shared" +import { + BankAccountQueryError, + BankAccountUpdateRequestQueryError, +} from "@services/frappe/errors" + +type UpdateStatusResponse = { + id: string + status: RequestStatus +} + +export type BankAccountUpdateInput = { + bankAccountId: string + // Proposed new values for the account. + bankAccount: BankAccount +} + +// Submits a request to change the details of an already-approved bank account. +// The change does NOT take effect immediately: a human reviews it and, on +// approval, the ERPNext Bank Account is patched in place. Until then cashouts +// continue to settle to the account's current details. +export const createBankAccountUpdateRequest = async ( + accountId: AccountId, + input: BankAccountUpdateInput, +): Promise => { + const accountsRepo = AccountsRepository() + + const account = await accountsRepo.findById(accountId) + if (account instanceof Error) return account + + const erpParty = account.erpParty + if (!erpParty) { + return new ValidationError("This account has no bank accounts to update.") + } + + // Verify the target account exists and belongs to this user, and capture its + // current currency (which is locked — see below). + const bankAccounts = await ErpNext.getBankAccountsByCustomer(erpParty) + if (bankAccounts instanceof BankAccountQueryError) return bankAccounts + + const current = bankAccounts.find((b) => b.name === input.bankAccountId) + if (!current) { + return new ValidationError("Bank account not found for this user.") + } + + // Validate the proposed values server-side — do not trust the client. Empty or + // out-of-set values would otherwise reach ERPNext as an opaque Link/insert + // failure, or blank the live account when an admin approves the request. + const proposed = input.bankAccount + const allowedAccountTypes = ["Chequing", "Savings"] + if (!proposed.bank || proposed.bank.trim().length < 2) { + return new ValidationError("Bank name is required.") + } + if (!proposed.branch_code || proposed.branch_code.trim().length < 2) { + return new ValidationError("Bank branch is required.") + } + if (!allowedAccountTypes.includes(proposed.account_type)) { + return new ValidationError("Account type must be Chequing or Savings.") + } + if (!proposed.bank_account_no || proposed.bank_account_no.trim().length < 4) { + return new ValidationError("A valid account number is required.") + } + + // v1: currency is locked. It drives the JMD-vs-USD cashout payout branch and + // the account's grouping in the app, so a currency change is "add a new + // account", not "update this one". + if (input.bankAccount.currency !== current.currency) { + return new ValidationError( + "Changing the account currency is not supported. Please add a new account instead.", + ) + } + + // Snapshot prior open requests, but close them only AFTER the replacement is + // created — closing first would leave the user with no open request at all if + // the create then failed. + const priorOpen = await ErpNext.getOpenBankAccountUpdateRequestsForAccount( + input.bankAccountId, + ) + if (priorOpen instanceof BankAccountUpdateRequestQueryError) return priorOpen + + const req = new BankAccountUpdateRequest( + "", // name — assigned by ERPNext + erpParty, + input.bankAccountId, + RequestStatus.Pending, + input.bankAccount, + ) + + const result = await ErpNext.postBankAccountUpdateRequest(req) + if (result instanceof Error) return result + + // Best-effort supersede of the now-stale prior requests. A failure here only + // leaves an extra Pending request — which the admin approval path also closes — + // and the new request is already live, so we do not fail the mutation for it. + const priorNames = priorOpen.map((r) => r.name).filter((name) => name !== result.name) + if (priorNames.length > 0) { + await ErpNext.closeBankAccountUpdateRequests(priorNames) + } + + return { id: result.name, status: RequestStatus.Pending } +} diff --git a/src/app/accounts/index.ts b/src/app/accounts/index.ts index 02edee835..7249b4008 100644 --- a/src/app/accounts/index.ts +++ b/src/app/accounts/index.ts @@ -14,6 +14,7 @@ export * from "./set-username" export * from "./update-account-ip" export * from "./update-account-level" export * from "./business-account-upgrade-request" +export * from "./bank-account-update-request" export * from "./get-account-upgrade-request" export * from "./get-supported-banks" export * from "./update-account-status" diff --git a/src/domain/api-keys/scope-map.ts b/src/domain/api-keys/scope-map.ts index 1dcb875ca..0ea01c591 100644 --- a/src/domain/api-keys/scope-map.ts +++ b/src/domain/api-keys/scope-map.ts @@ -40,6 +40,7 @@ export const apiKeyScopeForField: Readonly> = quizCompleted: "BLOCKED", deviceNotificationTokenCreate: "BLOCKED", businessAccountUpgradeRequest: "BLOCKED", + bankAccountUpdateRequest: "BLOCKED", accountDelete: "BLOCKED", feedbackSubmit: "BLOCKED", idDocumentUploadUrlGenerate: "BLOCKED", diff --git a/src/graphql/public/mutations.ts b/src/graphql/public/mutations.ts index 90641c594..a02f23055 100644 --- a/src/graphql/public/mutations.ts +++ b/src/graphql/public/mutations.ts @@ -74,6 +74,7 @@ import BridgeCancelWithdrawalRequestMutation from "./root/mutation/bridge-cancel import ApiKeyCreateMutation from "./root/mutation/api-key-create" import ApiKeyRevokeMutation from "./root/mutation/api-key-revoke" import ApiKeyRotateMutation from "./root/mutation/api-key-rotate" +import BankAccountUpdateRequestMutation from "./root/mutation/bank-account-update-request" // TODO: // const fields: { [key: string]: GraphQLFieldConfig } export const mutationFields = { @@ -115,6 +116,7 @@ export const mutationFields = { accountUpdateDefaultWalletId: AccountUpdateDefaultWalletIdMutation, accountUpdateDisplayCurrency: AccountUpdateDisplayCurrencyMutation, businessAccountUpgradeRequest: BusinessAccountUpgradeRequestMutation, + bankAccountUpdateRequest: BankAccountUpdateRequestMutation, accountEnableNotificationCategory: AccountEnableNotificationCategoryMutation, accountDisableNotificationCategory: AccountDisableNotificationCategoryMutation, accountEnableNotificationChannel: AccountEnableNotificationChannelMutation, diff --git a/src/graphql/public/root/mutation/bank-account-update-request.ts b/src/graphql/public/root/mutation/bank-account-update-request.ts new file mode 100644 index 000000000..10d92c5e4 --- /dev/null +++ b/src/graphql/public/root/mutation/bank-account-update-request.ts @@ -0,0 +1,79 @@ +import { Accounts } from "@app" +import { GT } from "@graphql/index" +import { mapToGqlErrorList } from "@graphql/error-map" +import AccountNumber from "@graphql/shared/types/scalar/account-number" +import IError from "@graphql/shared/types/abstract/error" + +const BankAccountUpdateRequestInput = GT.Input({ + name: "BankAccountUpdateRequestInput", + fields: () => ({ + bankAccountId: { + type: GT.NonNull(GT.ID), + description: "ERPNext identifier of the account to update", + }, + bankName: { type: GT.NonNull(GT.String) }, + bankBranch: { type: GT.NonNull(GT.String) }, + accountType: { type: GT.NonNull(GT.String) }, + currency: { + type: GT.NonNull(GT.String), + description: "Must match the account's current currency (currency is locked)", + }, + accountNumber: { type: GT.NonNull(AccountNumber) }, + }), +}) + +type BankAccountUpdateRequestInputType = { + bankAccountId: string + bankName: string + bankBranch: string + accountType: string + currency: string + accountNumber: string +} + +const Response = GT.Object({ + name: "BankAccountUpdateRequestPayload", + fields: () => ({ + errors: { + type: GT.List(IError), + }, + status: { + type: GT.String, + description: "Status of the created request (Pending on success)", + }, + }), +}) + +const BankAccountUpdateRequestMutation = GT.Field({ + extensions: { + complexity: 120, + }, + type: GT.NonNull(Response), + args: { + input: { type: GT.NonNull(BankAccountUpdateRequestInput) }, + }, + resolve: async ( + _, + args: { input: BankAccountUpdateRequestInputType }, + { domainAccount }: { domainAccount: Account }, + ) => { + const { bankAccountId, bankName, bankBranch, accountType, currency, accountNumber } = + args.input + + const result = await Accounts.createBankAccountUpdateRequest(domainAccount.id, { + bankAccountId, + bankAccount: { + bank: bankName, + branch_code: bankBranch, + account_type: accountType, + currency, + bank_account_no: accountNumber, + }, + }) + + if (result instanceof Error) return { errors: mapToGqlErrorList(result) } + return { errors: [], status: result.status } + }, +}) + +export default BankAccountUpdateRequestMutation diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index ea6ff11d1..e94c19a1f 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -343,6 +343,11 @@ type BankAccount { """ERPNext bank account identifier""" id: ID isDefault: Boolean! + + """ + The account's in-flight update request when it needs the user's attention — Pending (awaiting review) or Rejected (declined). Null once approved/closed, or when none exists. + """ + pendingUpdate: BankAccountUpdateRequest } input BankAccountInput { @@ -353,6 +358,52 @@ input BankAccountInput { currency: String! } +""" +A pending request to change the details of an approved bank account, awaiting admin review. +""" +type BankAccountUpdateRequest { + """Proposed new account number""" + accountNumber: String! + + """Proposed new account type""" + accountType: String! + + """Proposed new bank branch""" + bankBranch: String! + + """Proposed new bank name""" + bankName: String! + + """Account currency (unchanged from the current account)""" + currency: String! + + """Reviewer note, set when status is Rejected""" + rejectionReason: String + + """Pending | Approved | Rejected | Closed""" + status: String! +} + +input BankAccountUpdateRequestInput { + accountNumber: AccountNumber! + accountType: String! + + """ERPNext identifier of the account to update""" + bankAccountId: ID! + bankBranch: String! + bankName: String! + + """Must match the account's current currency (currency is locked)""" + currency: String! +} + +type BankAccountUpdateRequestPayload { + errors: [Error] + + """Status of the created request (Pending on success)""" + status: String +} + type BridgeAddExternalAccountPayload { errors: [Error!]! externalAccount: BridgeExternalAccountLink @@ -1170,6 +1221,7 @@ type Mutation { Rotate an API key: a replacement with a new secret (and keyId) is created with the same name, scopes, and expiry, and the old key is revoked. The new raw key is only shown once. """ apiKeyRotate(input: ApiKeyRotateInput!): ApiKeyRotatePayload! + bankAccountUpdateRequest(input: BankAccountUpdateRequestInput!): BankAccountUpdateRequestPayload! bridgeAddExternalAccount: BridgeAddExternalAccountPayload! bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! diff --git a/src/graphql/public/types/object/bank-account-update-request.ts b/src/graphql/public/types/object/bank-account-update-request.ts new file mode 100644 index 000000000..beb7181e2 --- /dev/null +++ b/src/graphql/public/types/object/bank-account-update-request.ts @@ -0,0 +1,49 @@ +import { GT } from "@graphql/index" +import { GraphQLObjectType } from "graphql" +import { BankAccountUpdateRequest } from "@services/frappe/models/BankAccountUpdateRequest" + +const GraphQLBankAccountUpdateRequest: GraphQLObjectType = + GT.Object({ + name: "BankAccountUpdateRequest", + description: + "A pending request to change the details of an approved bank account, awaiting admin review.", + fields: () => ({ + status: { + type: GT.NonNull(GT.String), + description: "Pending | Approved | Rejected | Closed", + resolve: (o) => o.status, + }, + bankName: { + type: GT.NonNull(GT.String), + description: "Proposed new bank name", + resolve: (o) => o.newBankAccount.bank, + }, + bankBranch: { + type: GT.NonNull(GT.String), + description: "Proposed new bank branch", + resolve: (o) => o.newBankAccount.branch_code, + }, + accountType: { + type: GT.NonNull(GT.String), + description: "Proposed new account type", + resolve: (o) => o.newBankAccount.account_type, + }, + accountNumber: { + type: GT.NonNull(GT.String), + description: "Proposed new account number", + resolve: (o) => o.newBankAccount.bank_account_no, + }, + currency: { + type: GT.NonNull(GT.String), + description: "Account currency (unchanged from the current account)", + resolve: (o) => o.newBankAccount.currency, + }, + rejectionReason: { + type: GT.String, + description: "Reviewer note, set when status is Rejected", + resolve: (o) => o.supportNote || null, + }, + }), + }) + +export default GraphQLBankAccountUpdateRequest diff --git a/src/graphql/public/types/object/bank-account.ts b/src/graphql/public/types/object/bank-account.ts index 01ee61761..68c399293 100644 --- a/src/graphql/public/types/object/bank-account.ts +++ b/src/graphql/public/types/object/bank-account.ts @@ -1,7 +1,10 @@ import { GT } from "@graphql/index" import { BankAccount } from "@services/frappe/models/BankAccount" +import ErpNext from "@services/frappe/ErpNext" import { GraphQLObjectType } from "graphql" +import GraphQLBankAccountUpdateRequest from "./bank-account-update-request" + const GraphQLBankAccount: GraphQLObjectType = GT.Object({ name: "BankAccount", fields: () => ({ @@ -39,6 +42,20 @@ const GraphQLBankAccount: GraphQLObjectType = GT.Object({ type: GT.NonNull(GT.Boolean), resolve: (o) => o.is_default === 1, }, + pendingUpdate: { + type: GraphQLBankAccountUpdateRequest, + description: + "The account's in-flight update request when it needs the user's attention — Pending (awaiting review) or Rejected (declined). Null once approved/closed, or when none exists.", + resolve: async (o) => { + if (!o.name) return null + const latest = await ErpNext.getLatestBankAccountUpdateRequestForAccount(o.name) + if (latest instanceof Error) return null + if (latest && (latest.status === "Pending" || latest.status === "Rejected")) { + return latest + } + return null + }, + }, }), }) diff --git a/src/services/frappe/ErpNext.ts b/src/services/frappe/ErpNext.ts index 18d964bb1..3a22c6e49 100644 --- a/src/services/frappe/ErpNext.ts +++ b/src/services/frappe/ErpNext.ts @@ -7,6 +7,8 @@ import axios, { isAxiosError } from "axios" import { BankAccountQueryError, + BankAccountUpdateRequestCreateError, + BankAccountUpdateRequestQueryError, BanksQueryError, BridgeTransferRequestUpsertError, CashoutDraftError, @@ -19,6 +21,10 @@ import { import { AccountUpgradeRequest, RequestStatus } from "./models/AccountUpgradeRequest" import { Bank } from "./models/Bank" import { BankAccount } from "./models/BankAccount" +import { + BankAccountUpdateRequest, + ErpNextBankAccountUpdateRequestData, +} from "./models/BankAccountUpdateRequest" import { BridgeTransferRequest } from "./models/BridgeTransferRequest" import { Filter } from "./SearchFilters" @@ -211,13 +217,22 @@ export class ErpNext { } } - closeAccountUpgradeRequests = this.setStatusForRequests(RequestStatus.Closed) + closeAccountUpgradeRequests = this.setStatusForRequests( + AccountUpgradeRequest.doctype, + RequestStatus.Closed, + ) + + closeBankAccountUpdateRequests = this.setStatusForRequests( + BankAccountUpdateRequest.doctype, + RequestStatus.Closed, + ) - private setStatusForRequests(status: RequestStatus) { + private setStatusForRequests(doctype: string, status: RequestStatus) { return async (names: string[]): Promise => { + if (names.length === 0) return try { const docs = names.map((name) => ({ - doctype: AccountUpgradeRequest.doctype, + doctype, docname: name, status, })) @@ -297,6 +312,116 @@ export class ErpNext { } } + async postBankAccountUpdateRequest( + req: BankAccountUpdateRequest, + ): Promise<{ name: string } | BankAccountUpdateRequestCreateError> { + try { + const resp = await axios.post( + `${this.url}/api/resource/${encodeURIComponent(BankAccountUpdateRequest.doctype)}`, + req.toErpnext(), + { headers: this.headers }, + ) + return { name: resp.data.data.name } + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error( + { err, responseData, ...req.toErpnext() }, + "Error creating Bank Account Update Request in ERPNext", + ) + recordExceptionInCurrentSpan({ + error: err, + attributes: { "erpnext.exception": responseData?.exception }, + }) + return new BankAccountUpdateRequestCreateError(err) + } + } + + async getOpenBankAccountUpdateRequestsForAccount( + bankAccountId: string, + ): Promise { + try { + const filters = JSON.stringify([ + ["bank_account", "=", bankAccountId], + ["status", "=", RequestStatus.Pending], + ]) + const fields = JSON.stringify([ + "name", + "party", + "bank_account", + "status", + "bank_name", + "bank_branch", + "account_type", + "currency", + "account_number", + "support_note", + ]) + const resp = await axios.get( + `${this.url}/api/resource/${encodeURIComponent(BankAccountUpdateRequest.doctype)}`, + { + params: { filters, fields, order_by: "creation desc" }, + headers: this.headers, + }, + ) + const rows: ErpNextBankAccountUpdateRequestData[] = resp.data?.data ?? [] + return rows.map((r) => BankAccountUpdateRequest.fromErpnext(r)) + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error( + { err, responseData, bankAccountId }, + "Error querying Bank Account Update Request from ERPNext", + ) + recordExceptionInCurrentSpan({ + error: err, + attributes: { "erpnext.exception": responseData?.exception }, + }) + return new BankAccountUpdateRequestQueryError(err) + } + } + + // Most-recent request for an account (any status), so the API can surface + // both "Pending" (under review) and "Rejected" (needs the user's attention), + // and fall silent once the latest request is Approved or Closed. + async getLatestBankAccountUpdateRequestForAccount( + bankAccountId: string, + ): Promise { + try { + const filters = JSON.stringify([["bank_account", "=", bankAccountId]]) + const fields = JSON.stringify([ + "name", + "party", + "bank_account", + "status", + "bank_name", + "bank_branch", + "account_type", + "currency", + "account_number", + "support_note", + ]) + const resp = await axios.get( + `${this.url}/api/resource/${encodeURIComponent(BankAccountUpdateRequest.doctype)}`, + { + params: { filters, fields, order_by: "creation desc", limit_page_length: 1 }, + headers: this.headers, + }, + ) + const rows: ErpNextBankAccountUpdateRequestData[] = resp.data?.data ?? [] + return rows.length ? BankAccountUpdateRequest.fromErpnext(rows[0]) : undefined + } catch (err) { + const responseData = isAxiosError(err) ? err.response?.data : undefined + baseLogger.error( + { err, responseData, bankAccountId }, + "Error querying latest Bank Account Update Request from ERPNext", + ) + recordExceptionInCurrentSpan({ + error: err, + attributes: { "erpnext.exception": responseData?.exception }, + }) + return new BankAccountUpdateRequestQueryError(err) + } + } + async upsertBridgeTransferRequest( request: BridgeTransferRequest, ): Promise { diff --git a/src/services/frappe/errors.ts b/src/services/frappe/errors.ts index a64833018..592ece967 100644 --- a/src/services/frappe/errors.ts +++ b/src/services/frappe/errors.ts @@ -9,4 +9,6 @@ export class UpgradeRequestQueryError extends ErpNextError {} export class SetDocTypeValueError extends ErpNextError {} export class BanksQueryError extends ErpNextError {} export class BankAccountQueryError extends ErpNextError {} +export class BankAccountUpdateRequestCreateError extends ErpNextError {} +export class BankAccountUpdateRequestQueryError extends ErpNextError {} export class BridgeTransferRequestUpsertError extends ErpNextError {} diff --git a/src/services/frappe/models/BankAccountUpdateRequest.ts b/src/services/frappe/models/BankAccountUpdateRequest.ts new file mode 100644 index 000000000..78058b08a --- /dev/null +++ b/src/services/frappe/models/BankAccountUpdateRequest.ts @@ -0,0 +1,82 @@ +import { BankAccount } from "./BankAccount" + +export type ErpNextBankAccountUpdateRequestData = { + name: string + party?: string + bank_account: string + status: string + bank_name: string + bank_branch: string + account_type: string + currency: string + account_number: string + support_note?: string + creation?: string + modified?: string +} + +// Core model representing a request to change the details of an already-approved +// ERPNext Bank Account. The request is reviewed by an admin; on approval the +// existing Bank Account DocType is patched in place, preserving its `name` (the +// identifier cashout offers and the Cashout DocType reference). +export class BankAccountUpdateRequest { + static doctype = "Bank Account Update Request" + + readonly name: string + readonly party: string + readonly bankAccountId: string + readonly status: string + readonly newBankAccount: BankAccount + readonly supportNote?: string + + constructor( + name: string, + party: string, + bankAccountId: string, + status: string, + newBankAccount: BankAccount, + supportNote?: string, + ) { + this.name = name + this.party = party + this.bankAccountId = bankAccountId + this.status = status + this.newBankAccount = newBankAccount + this.supportNote = supportNote + } + + toErpnext() { + return { + doctype: BankAccountUpdateRequest.doctype, + name: this.name, + party: this.party, + bank_account: this.bankAccountId, + status: this.status, + bank_name: this.newBankAccount.bank, + bank_branch: this.newBankAccount.branch_code, + account_type: this.newBankAccount.account_type, + currency: this.newBankAccount.currency, + account_number: this.newBankAccount.bank_account_no, + support_note: this.supportNote, + } + } + + static fromErpnext( + data: ErpNextBankAccountUpdateRequestData, + ): BankAccountUpdateRequest { + return new BankAccountUpdateRequest( + data.name, + data.party ?? "", + data.bank_account, + data.status, + { + bank: data.bank_name, + branch_code: data.bank_branch, + account_type: data.account_type, + currency: data.currency, + bank_account_no: data.account_number, + }, + data.support_note, + ) + } +} diff --git a/test/flash/unit/app/accounts/bank-account-update-request.spec.ts b/test/flash/unit/app/accounts/bank-account-update-request.spec.ts new file mode 100644 index 000000000..45d779a98 --- /dev/null +++ b/test/flash/unit/app/accounts/bank-account-update-request.spec.ts @@ -0,0 +1,171 @@ +jest.mock("@services/mongoose", () => { + const findById = jest.fn() + return { AccountsRepository: () => ({ findById }) } +}) + +jest.mock("@services/frappe/ErpNext", () => ({ + __esModule: true, + default: { + getBankAccountsByCustomer: jest.fn(), + getOpenBankAccountUpdateRequestsForAccount: jest.fn(), + closeBankAccountUpdateRequests: jest.fn(), + postBankAccountUpdateRequest: jest.fn(), + }, +})) + +import { AccountsRepository } from "@services/mongoose" +import ErpNext from "@services/frappe/ErpNext" +import { createBankAccountUpdateRequest } from "@app/accounts/bank-account-update-request" +import { ValidationError } from "@domain/shared" +import { RequestStatus } from "@services/frappe/models/AccountUpgradeRequest" +import { BankAccountQueryError } from "@services/frappe/errors" + +const { findById } = AccountsRepository() as unknown as { findById: jest.Mock } +const erp = ErpNext as unknown as { + getBankAccountsByCustomer: jest.Mock + getOpenBankAccountUpdateRequestsForAccount: jest.Mock + closeBankAccountUpdateRequests: jest.Mock + postBankAccountUpdateRequest: jest.Mock +} + +const ACCOUNT_ID = "acct-1" as AccountId + +const ownedAccount = { id: ACCOUNT_ID, erpParty: "CUST-1" } as unknown as Account + +const currentBank = { + name: "BANK-ACC-1", + bank: "NCB", + branch_code: "Old Branch", + account_type: "Savings", + currency: "JMD", + bank_account_no: "111111", +} + +const newValues = { + bank: "Scotiabank", + branch_code: "New Branch", + account_type: "Chequing", + currency: "JMD", + bank_account_no: "222222", +} + +describe("createBankAccountUpdateRequest", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("rejects when the account has no erpParty", async () => { + findById.mockResolvedValue({ id: ACCOUNT_ID } as unknown as Account) + + const result = await createBankAccountUpdateRequest(ACCOUNT_ID, { + bankAccountId: "BANK-ACC-1", + bankAccount: newValues, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect(erp.getBankAccountsByCustomer).not.toHaveBeenCalled() + }) + + it("rejects when the target account is not owned by the user", async () => { + findById.mockResolvedValue(ownedAccount) + erp.getBankAccountsByCustomer.mockResolvedValue([currentBank]) + + const result = await createBankAccountUpdateRequest(ACCOUNT_ID, { + bankAccountId: "SOMEONE-ELSES-ACC", + bankAccount: newValues, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect(erp.postBankAccountUpdateRequest).not.toHaveBeenCalled() + }) + + it("rejects a currency change", async () => { + findById.mockResolvedValue(ownedAccount) + erp.getBankAccountsByCustomer.mockResolvedValue([currentBank]) + + const result = await createBankAccountUpdateRequest(ACCOUNT_ID, { + bankAccountId: "BANK-ACC-1", + bankAccount: { ...newValues, currency: "USD" }, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect(erp.postBankAccountUpdateRequest).not.toHaveBeenCalled() + }) + + it("propagates a bank-account lookup error", async () => { + findById.mockResolvedValue(ownedAccount) + const err = new BankAccountQueryError("boom") + erp.getBankAccountsByCustomer.mockResolvedValue(err) + + const result = await createBankAccountUpdateRequest(ACCOUNT_ID, { + bankAccountId: "BANK-ACC-1", + bankAccount: newValues, + }) + + expect(result).toBe(err) + expect(erp.postBankAccountUpdateRequest).not.toHaveBeenCalled() + }) + + it("supersedes prior pending requests and creates a new one", async () => { + findById.mockResolvedValue(ownedAccount) + erp.getBankAccountsByCustomer.mockResolvedValue([currentBank]) + erp.getOpenBankAccountUpdateRequestsForAccount.mockResolvedValue([ + { name: "REQ-OLD" }, + ]) + erp.closeBankAccountUpdateRequests.mockResolvedValue(undefined) + erp.postBankAccountUpdateRequest.mockResolvedValue({ name: "REQ-NEW" }) + + const result = await createBankAccountUpdateRequest(ACCOUNT_ID, { + bankAccountId: "BANK-ACC-1", + bankAccount: newValues, + }) + + expect(erp.closeBankAccountUpdateRequests).toHaveBeenCalledWith(["REQ-OLD"]) + expect(erp.postBankAccountUpdateRequest).toHaveBeenCalledTimes(1) + expect(result).toEqual({ id: "REQ-NEW", status: RequestStatus.Pending }) + }) + + it("does not close prior requests when the create fails", async () => { + findById.mockResolvedValue(ownedAccount) + erp.getBankAccountsByCustomer.mockResolvedValue([currentBank]) + erp.getOpenBankAccountUpdateRequestsForAccount.mockResolvedValue([ + { name: "REQ-OLD" }, + ]) + const createErr = new Error("erpnext down") + erp.postBankAccountUpdateRequest.mockResolvedValue(createErr) + + const result = await createBankAccountUpdateRequest(ACCOUNT_ID, { + bankAccountId: "BANK-ACC-1", + bankAccount: newValues, + }) + + expect(result).toBe(createErr) + expect(erp.closeBankAccountUpdateRequests).not.toHaveBeenCalled() + }) + + it("rejects an empty account number before creating anything", async () => { + findById.mockResolvedValue(ownedAccount) + erp.getBankAccountsByCustomer.mockResolvedValue([currentBank]) + + const result = await createBankAccountUpdateRequest(ACCOUNT_ID, { + bankAccountId: "BANK-ACC-1", + bankAccount: { ...newValues, bank_account_no: "" }, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect(erp.postBankAccountUpdateRequest).not.toHaveBeenCalled() + }) + + it("rejects an account type outside the allowed set", async () => { + findById.mockResolvedValue(ownedAccount) + erp.getBankAccountsByCustomer.mockResolvedValue([currentBank]) + + const result = await createBankAccountUpdateRequest(ACCOUNT_ID, { + bankAccountId: "BANK-ACC-1", + bankAccount: { ...newValues, account_type: "Current" }, + }) + + expect(result).toBeInstanceOf(ValidationError) + expect(erp.postBankAccountUpdateRequest).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/services/frappe/ErpNext.bankAccountUpdate.spec.ts b/test/flash/unit/services/frappe/ErpNext.bankAccountUpdate.spec.ts new file mode 100644 index 000000000..b6db6e72c --- /dev/null +++ b/test/flash/unit/services/frappe/ErpNext.bankAccountUpdate.spec.ts @@ -0,0 +1,160 @@ +jest.mock("axios", () => ({ + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + isAxiosError: jest.fn((err) => Boolean(err?.isAxiosError)), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), +})) + +jest.mock("@config", () => ({ + FrappeConfig: undefined, +})) + +import axios from "axios" +import { ErpNext } from "@services/frappe/ErpNext" +import { BankAccountUpdateRequest } from "@services/frappe/models/BankAccountUpdateRequest" +import { RequestStatus } from "@services/frappe/models/AccountUpgradeRequest" +import { BankAccountUpdateRequestQueryError } from "@services/frappe/errors" + +const mockedAxios = axios as unknown as { + get: jest.Mock + post: jest.Mock + put: jest.Mock +} + +const client = new ErpNext("https://erp.example", "erp.example", { + apiKey: "key", + apiSecret: "secret", +}) + +const makeRequest = () => + new BankAccountUpdateRequest("", "CUST-1", "BANK-ACC-1", RequestStatus.Pending, { + bank: "NCB", + branch_code: "Half Way Tree", + account_type: "Savings", + currency: "JMD", + bank_account_no: "123456", + }) + +describe("ErpNext bank account update requests", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("posts a create to the Bank Account Update Request resource", async () => { + mockedAxios.post.mockResolvedValue({ data: { data: { name: "BAUR-1" } } }) + + const result = await client.postBankAccountUpdateRequest(makeRequest()) + + expect(result).toEqual({ name: "BAUR-1" }) + expect(mockedAxios.post).toHaveBeenCalledWith( + "https://erp.example/api/resource/Bank%20Account%20Update%20Request", + expect.objectContaining({ + bank_account: "BANK-ACC-1", + bank_name: "NCB", + account_number: "123456", + status: "Pending", + }), + expect.any(Object), + ) + }) + + it("hydrates open requests for an account", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + name: "BAUR-1", + party: "CUST-1", + bank_account: "BANK-ACC-1", + status: "Pending", + bank_name: "NCB", + bank_branch: "Half Way Tree", + account_type: "Savings", + currency: "JMD", + account_number: "123456", + }, + ], + }, + }) + + const result = await client.getOpenBankAccountUpdateRequestsForAccount("BANK-ACC-1") + + expect(Array.isArray(result)).toBe(true) + const list = result as BankAccountUpdateRequest[] + expect(list).toHaveLength(1) + expect(list[0].bankAccountId).toBe("BANK-ACC-1") + expect(list[0].newBankAccount.bank).toBe("NCB") + }) + + it("returns a query error when the lookup fails", async () => { + mockedAxios.get.mockRejectedValue({ isAxiosError: true, response: { data: {} } }) + + const result = await client.getOpenBankAccountUpdateRequestsForAccount("BANK-ACC-1") + + expect(result).toBeInstanceOf(BankAccountUpdateRequestQueryError) + }) + + it("bulk-closes prior requests", async () => { + mockedAxios.post.mockResolvedValue({ data: { message: { failed_docs: [] } } }) + + const result = await client.closeBankAccountUpdateRequests(["BAUR-1", "BAUR-2"]) + + expect(result).toBeUndefined() + expect(mockedAxios.post).toHaveBeenCalledWith( + "https://erp.example/api/method/frappe.client.bulk_update", + expect.objectContaining({ docs: expect.stringContaining("BAUR-1") }), + expect.any(Object), + ) + }) + + it("no-ops close when there are no names", async () => { + const result = await client.closeBankAccountUpdateRequests([]) + + expect(result).toBeUndefined() + expect(mockedAxios.post).not.toHaveBeenCalled() + }) + + it("fetches the most recent request for an account, any status", async () => { + mockedAxios.get.mockResolvedValue({ + data: { + data: [ + { + name: "BAUR-9", + party: "CUST-1", + bank_account: "BANK-ACC-1", + status: "Rejected", + bank_name: "NCB", + bank_branch: "Half Way Tree", + account_type: "Savings", + currency: "JMD", + account_number: "123456", + support_note: "account number did not match", + }, + ], + }, + }) + + const result = await client.getLatestBankAccountUpdateRequestForAccount("BANK-ACC-1") + + expect((result as BankAccountUpdateRequest).status).toBe("Rejected") + expect((result as BankAccountUpdateRequest).supportNote).toBe( + "account number did not match", + ) + }) + + it("returns undefined when the account has no requests", async () => { + mockedAxios.get.mockResolvedValue({ data: { data: [] } }) + + const result = await client.getLatestBankAccountUpdateRequestForAccount("BANK-ACC-1") + + expect(result).toBeUndefined() + }) +}) diff --git a/test/flash/unit/services/frappe/models/BankAccountUpdateRequest.spec.ts b/test/flash/unit/services/frappe/models/BankAccountUpdateRequest.spec.ts new file mode 100644 index 000000000..b7f1ca927 --- /dev/null +++ b/test/flash/unit/services/frappe/models/BankAccountUpdateRequest.spec.ts @@ -0,0 +1,74 @@ +import { BankAccount } from "@services/frappe/models/BankAccount" +import { + BankAccountUpdateRequest, + ErpNextBankAccountUpdateRequestData, +} from "@services/frappe/models/BankAccountUpdateRequest" +import { RequestStatus } from "@services/frappe/models/AccountUpgradeRequest" + +const newBankAccount: BankAccount = { + bank: "Scotiabank", + branch_code: "New Kingston", + account_type: "Chequing", + currency: "JMD", + bank_account_no: "0987654321", +} + +const erpResponse: ErpNextBankAccountUpdateRequestData = { + name: "BAUR-0001", + party: "CUST-042", + bank_account: "BANK-ACC-7", + status: "Pending", + bank_name: "Scotiabank", + bank_branch: "New Kingston", + account_type: "Chequing", + currency: "JMD", + account_number: "0987654321", + support_note: "", +} + +describe("BankAccountUpdateRequest", () => { + describe("toErpnext", () => { + it("serializes to the ERPNext shape", () => { + const req = new BankAccountUpdateRequest( + "", + "CUST-042", + "BANK-ACC-7", + RequestStatus.Pending, + newBankAccount, + ) + + expect(req.toErpnext()).toMatchObject({ + doctype: "Bank Account Update Request", + party: "CUST-042", + bank_account: "BANK-ACC-7", + status: "Pending", + bank_name: "Scotiabank", + bank_branch: "New Kingston", + account_type: "Chequing", + currency: "JMD", + account_number: "0987654321", + }) + }) + }) + + describe("fromErpnext", () => { + it("deserializes from the ERPNext shape", () => { + const req = BankAccountUpdateRequest.fromErpnext(erpResponse) + + expect(req.name).toBe("BAUR-0001") + expect(req.party).toBe("CUST-042") + expect(req.bankAccountId).toBe("BANK-ACC-7") + expect(req.status).toBe("Pending") + expect(req.newBankAccount).toEqual(newBankAccount) + }) + + it("defaults party to empty string when absent", () => { + const req = BankAccountUpdateRequest.fromErpnext({ + ...erpResponse, + party: undefined, + }) + + expect(req.party).toBe("") + }) + }) +})