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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/app/accounts/bank-account-update-request.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateStatusResponse | ApplicationError> => {
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 }
}
1 change: 1 addition & 0 deletions src/app/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/domain/api-keys/scope-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const apiKeyScopeForField: Readonly<Record<string, ApiKeyFieldAccess>> =
quizCompleted: "BLOCKED",
deviceNotificationTokenCreate: "BLOCKED",
businessAccountUpgradeRequest: "BLOCKED",
bankAccountUpdateRequest: "BLOCKED",
accountDelete: "BLOCKED",
feedbackSubmit: "BLOCKED",
idDocumentUploadUrlGenerate: "BLOCKED",
Expand Down
2 changes: 2 additions & 0 deletions src/graphql/public/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, GraphQLPublicContext> }
export const mutationFields = {
Expand Down Expand Up @@ -115,6 +116,7 @@ export const mutationFields = {
accountUpdateDefaultWalletId: AccountUpdateDefaultWalletIdMutation,
accountUpdateDisplayCurrency: AccountUpdateDisplayCurrencyMutation,
businessAccountUpgradeRequest: BusinessAccountUpgradeRequestMutation,
bankAccountUpdateRequest: BankAccountUpdateRequestMutation,
accountEnableNotificationCategory: AccountEnableNotificationCategoryMutation,
accountDisableNotificationCategory: AccountDisableNotificationCategoryMutation,
accountEnableNotificationChannel: AccountEnableNotificationChannelMutation,
Expand Down
79 changes: 79 additions & 0 deletions src/graphql/public/root/mutation/bank-account-update-request.ts
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,11 @@
"""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

Check notice on line 350 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'pendingUpdate' was added to object type 'BankAccount'

Field 'pendingUpdate' was added to object type 'BankAccount'

Check notice on line 350 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccount.pendingUpdate' has 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.'

Field 'BankAccount.pendingUpdate' has 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.'
}

input BankAccountInput {
Expand All @@ -353,6 +358,52 @@
currency: String!
}

"""
A pending request to change the details of an approved bank account, awaiting admin review.
"""
type BankAccountUpdateRequest {

Check notice on line 364 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Type 'BankAccountUpdateRequest' was added

Type 'BankAccountUpdateRequest' was added

Check notice on line 364 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Object type 'BankAccountUpdateRequest' has description 'A pending request to change the details of an approved bank account, awaiting admin review.'

Object type 'BankAccountUpdateRequest' has description 'A pending request to change the details of an approved bank account, awaiting admin review.'
"""Proposed new account number"""
accountNumber: String!

Check notice on line 366 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'accountNumber' was added to object type 'BankAccountUpdateRequest'

Field 'accountNumber' was added to object type 'BankAccountUpdateRequest'

Check notice on line 366 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccountUpdateRequest.accountNumber' has description 'Proposed new account number'

Field 'BankAccountUpdateRequest.accountNumber' has description 'Proposed new account number'

"""Proposed new account type"""
accountType: String!

Check notice on line 369 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'accountType' was added to object type 'BankAccountUpdateRequest'

Field 'accountType' was added to object type 'BankAccountUpdateRequest'

Check notice on line 369 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccountUpdateRequest.accountType' has description 'Proposed new account type'

Field 'BankAccountUpdateRequest.accountType' has description 'Proposed new account type'

"""Proposed new bank branch"""
bankBranch: String!

Check notice on line 372 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'bankBranch' was added to object type 'BankAccountUpdateRequest'

Field 'bankBranch' was added to object type 'BankAccountUpdateRequest'

Check notice on line 372 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccountUpdateRequest.bankBranch' has description 'Proposed new bank branch'

Field 'BankAccountUpdateRequest.bankBranch' has description 'Proposed new bank branch'

"""Proposed new bank name"""
bankName: String!

Check notice on line 375 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'bankName' was added to object type 'BankAccountUpdateRequest'

Field 'bankName' was added to object type 'BankAccountUpdateRequest'

Check notice on line 375 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccountUpdateRequest.bankName' has description 'Proposed new bank name'

Field 'BankAccountUpdateRequest.bankName' has description 'Proposed new bank name'

"""Account currency (unchanged from the current account)"""
currency: String!

Check notice on line 378 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'currency' was added to object type 'BankAccountUpdateRequest'

Field 'currency' was added to object type 'BankAccountUpdateRequest'

Check notice on line 378 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccountUpdateRequest.currency' has description 'Account currency (unchanged from the current account)'

Field 'BankAccountUpdateRequest.currency' has description 'Account currency (unchanged from the current account)'

"""Reviewer note, set when status is Rejected"""
rejectionReason: String

Check notice on line 381 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'rejectionReason' was added to object type 'BankAccountUpdateRequest'

Field 'rejectionReason' was added to object type 'BankAccountUpdateRequest'

Check notice on line 381 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccountUpdateRequest.rejectionReason' has description 'Reviewer note, set when status is Rejected'

Field 'BankAccountUpdateRequest.rejectionReason' has description 'Reviewer note, set when status is Rejected'

"""Pending | Approved | Rejected | Closed"""
status: String!

Check notice on line 384 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'status' was added to object type 'BankAccountUpdateRequest'

Field 'status' was added to object type 'BankAccountUpdateRequest'

Check notice on line 384 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccountUpdateRequest.status' has description 'Pending | Approved | Rejected | Closed'

Field 'BankAccountUpdateRequest.status' has description 'Pending | Approved | Rejected | Closed'
}

input BankAccountUpdateRequestInput {

Check notice on line 387 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Type 'BankAccountUpdateRequestInput' was added

Type 'BankAccountUpdateRequestInput' was added
accountNumber: AccountNumber!

Check notice on line 388 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'accountNumber' of type 'AccountNumber!' was added to input object type 'BankAccountUpdateRequestInput'

The field is being added to a new type.
accountType: String!

Check notice on line 389 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'accountType' of type 'String!' was added to input object type 'BankAccountUpdateRequestInput'

The field is being added to a new type.

"""ERPNext identifier of the account to update"""
bankAccountId: ID!

Check notice on line 392 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'bankAccountId' of type 'ID!' was added to input object type 'BankAccountUpdateRequestInput'

The field is being added to a new type.

Check notice on line 392 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'BankAccountUpdateRequestInput.bankAccountId' has description 'ERPNext identifier of the account to update'

Input field 'BankAccountUpdateRequestInput.bankAccountId' has description 'ERPNext identifier of the account to update'
bankBranch: String!

Check notice on line 393 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'bankBranch' of type 'String!' was added to input object type 'BankAccountUpdateRequestInput'

The field is being added to a new type.
bankName: String!

Check notice on line 394 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'bankName' of type 'String!' was added to input object type 'BankAccountUpdateRequestInput'

The field is being added to a new type.

"""Must match the account's current currency (currency is locked)"""
currency: String!

Check notice on line 397 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'currency' of type 'String!' was added to input object type 'BankAccountUpdateRequestInput'

The field is being added to a new type.

Check notice on line 397 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Input field 'BankAccountUpdateRequestInput.currency' has description 'Must match the account\'s current currency (currency is locked)'

Input field 'BankAccountUpdateRequestInput.currency' has description 'Must match the account\'s current currency (currency is locked)'
}

type BankAccountUpdateRequestPayload {

Check notice on line 400 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Type 'BankAccountUpdateRequestPayload' was added

Type 'BankAccountUpdateRequestPayload' was added
errors: [Error]

Check notice on line 401 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'errors' was added to object type 'BankAccountUpdateRequestPayload'

Field 'errors' was added to object type 'BankAccountUpdateRequestPayload'

"""Status of the created request (Pending on success)"""
status: String

Check notice on line 404 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'status' was added to object type 'BankAccountUpdateRequestPayload'

Field 'status' was added to object type 'BankAccountUpdateRequestPayload'

Check notice on line 404 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'BankAccountUpdateRequestPayload.status' has description 'Status of the created request (Pending on success)'

Field 'BankAccountUpdateRequestPayload.status' has description 'Status of the created request (Pending on success)'
}

type BridgeAddExternalAccountPayload {
errors: [Error!]!
externalAccount: BridgeExternalAccountLink
Expand Down Expand Up @@ -1170,6 +1221,7 @@
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!

Check notice on line 1224 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Field 'bankAccountUpdateRequest' was added to object type 'Mutation'

Field 'bankAccountUpdateRequest' was added to object type 'Mutation'

Check notice on line 1224 in src/graphql/public/schema.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector

Argument 'input: BankAccountUpdateRequestInput!' added to field 'Mutation.bankAccountUpdateRequest'

Argument 'input: BankAccountUpdateRequestInput!' added to field 'Mutation.bankAccountUpdateRequest'
bridgeAddExternalAccount: BridgeAddExternalAccountPayload!
bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload!
bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload!
Expand Down
49 changes: 49 additions & 0 deletions src/graphql/public/types/object/bank-account-update-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { GT } from "@graphql/index"
import { GraphQLObjectType } from "graphql"
import { BankAccountUpdateRequest } from "@services/frappe/models/BankAccountUpdateRequest"

const GraphQLBankAccountUpdateRequest: GraphQLObjectType<BankAccountUpdateRequest> =
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
17 changes: 17 additions & 0 deletions src/graphql/public/types/object/bank-account.ts
Original file line number Diff line number Diff line change
@@ -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<BankAccount> = GT.Object({
name: "BankAccount",
fields: () => ({
Expand Down Expand Up @@ -39,6 +42,20 @@ const GraphQLBankAccount: GraphQLObjectType<BankAccount> = 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
},
},
}),
})

Expand Down
Loading
Loading