From 07428f4967616d0965cbbedafbce9d6df29c5bd6 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Wed, 22 Apr 2026 14:30:46 -0500 Subject: [PATCH 1/5] refactor(core): replace force flag with skipChecks in AccountValidator and payment flows - Add skipChecks option to AccountValidator to bypass status validation - Rename cancelIfPositiveBalance to skipChecks in markAccountForDeletion - Propagate skipChecks through intraledgerPaymentSendWalletId and withSpendingLimits - Keep bypassMaxDeletions as a separate param for deletion count checks - Add unit tests for skipChecks behavior in AccountValidator and spending limits --- .../app/accounts/mark-account-for-deletion.ts | 34 ++++++++++++++++--- core/api/src/app/admin/update-user-email.ts | 2 +- core/api/src/app/admin/update-user-phone.ts | 2 +- core/api/src/app/payments/send-intraledger.ts | 15 +++++--- core/api/src/app/payments/spending-limits.ts | 7 ++++ core/api/src/app/wallets/index.types.d.ts | 1 + core/api/src/debug/force-delete-account.ts | 2 +- .../src/domain/accounts/account-validator.ts | 9 +++-- .../root/mutation/account-force-delete.ts | 8 ++--- .../unit/app/payments/spending-limits.spec.ts | 23 +++++++++++++ .../domain/accounts/account-validator.spec.ts | 12 +++++++ 11 files changed, 96 insertions(+), 19 deletions(-) diff --git a/core/api/src/app/accounts/mark-account-for-deletion.ts b/core/api/src/app/accounts/mark-account-for-deletion.ts index 80d7e6c6e4..6740839c0e 100644 --- a/core/api/src/app/accounts/mark-account-for-deletion.ts +++ b/core/api/src/app/accounts/mark-account-for-deletion.ts @@ -4,6 +4,8 @@ import { deleteMerchantByUsername } from "@/app/merchants" import { getBalanceForWallet, listWalletsByAccountId } from "@/app/wallets" +import { intraledgerPaymentSendWalletId } from "@/app/payments" + import { AccountStatus, AccountValidator, @@ -17,19 +19,22 @@ import { AccountsRepository, UsersRepository } from "@/services/mongoose" export const markAccountForDeletion = async ({ accountId, - cancelIfPositiveBalance = false, + skipChecks = false, updatedByPrivilegedClientId, bypassMaxDeletions = false, + destinationAccountId, }: { accountId: AccountId - cancelIfPositiveBalance?: boolean + skipChecks?: boolean updatedByPrivilegedClientId?: PrivilegedClientId bypassMaxDeletions?: boolean + destinationAccountId?: AccountId }): Promise => { const accountsRepo = AccountsRepository() const account = await accountsRepo.findById(accountId) if (account instanceof Error) return account - const accountValidator = AccountValidator(account) + + const accountValidator = AccountValidator(account, { skipChecks }) if (accountValidator instanceof Error) return accountValidator const wallets = await listWalletsByAccountId(account.id) @@ -38,11 +43,30 @@ export const markAccountForDeletion = async ({ for (const wallet of wallets) { const balance = await getBalanceForWallet({ walletId: wallet.id }) if (balance instanceof Error) return balance - if (balance > 0 && cancelIfPositiveBalance) { + if (balance > 0 && !skipChecks) { return new AccountHasPositiveBalanceError( - `The new phone is associated with an account with a non empty wallet. walletId: ${wallet.id}, balance: ${balance}, accountId: ${account.id}, cancelIfPositiveBalance: ${cancelIfPositiveBalance}`, + `The new phone is associated with an account with a non empty wallet. walletId: ${wallet.id}, balance: ${balance}, accountId: ${account.id}`, ) } + + const destinationWallets = await listWalletsByAccountId(account.id) + if (destinationWallets instanceof Error) return destinationWallets + + if (balance > 0 && destinationAccountId) { + const destinationAccount = await accountsRepo.findById(destinationAccountId) + if (destinationAccount instanceof Error) return destinationAccount + + const payment = await intraledgerPaymentSendWalletId({ + senderWalletId: wallet.id, + recipientWalletId: destinationAccount.defaultWalletId, + amount: balance, + memo: `Closing settlement: ${wallet.currency} balance payout for Account ${account.id}`, + senderAccount: account, + skipChecks: true, + }) + if (payment instanceof Error) return payment + } + addEventToCurrentSpan(`deleting_wallet`, { walletId: wallet.id, currency: wallet.currency, diff --git a/core/api/src/app/admin/update-user-email.ts b/core/api/src/app/admin/update-user-email.ts index 89b2e7ca6a..363bec65a8 100644 --- a/core/api/src/app/admin/update-user-email.ts +++ b/core/api/src/app/admin/update-user-email.ts @@ -41,7 +41,7 @@ export const updateUserEmail = async ({ const result = await markAccountForDeletion({ accountId: newAccount.id, - cancelIfPositiveBalance: true, + skipChecks: false, bypassMaxDeletions: true, updatedByPrivilegedClientId, }) diff --git a/core/api/src/app/admin/update-user-phone.ts b/core/api/src/app/admin/update-user-phone.ts index 999606dab8..bdad2b630f 100644 --- a/core/api/src/app/admin/update-user-phone.ts +++ b/core/api/src/app/admin/update-user-phone.ts @@ -37,7 +37,7 @@ export const updateUserPhone = async ({ const result = await markAccountForDeletion({ accountId: newAccount.id, - cancelIfPositiveBalance: true, + skipChecks: false, bypassMaxDeletions: true, updatedByPrivilegedClientId, }) diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index d4736b1dbc..95b25fea29 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -48,17 +48,19 @@ import { NotificationsService } from "@/services/notifications" const dealer = DealerPriceService() -const intraledgerPaymentSendWalletId = async ({ +export const intraledgerPaymentSendWalletId = async ({ recipientWalletId: uncheckedRecipientWalletId, senderAccount, amount: uncheckedAmount, memo, senderWalletId: uncheckedSenderWalletId, apiKeyId, + skipChecks = false, }: IntraLedgerPaymentSendWalletIdArgs): Promise => { const validatedPaymentInputs = await validateIntraledgerPaymentInputs({ uncheckedSenderWalletId, uncheckedRecipientWalletId, + skipChecks, }) if (validatedPaymentInputs instanceof Error) return validatedPaymentInputs @@ -131,6 +133,7 @@ const intraledgerPaymentSendWalletId = async ({ senderUser, memo, apiKeyId, + skipChecks, }) if (paymentSendResult instanceof Error) return paymentSendResult @@ -165,9 +168,11 @@ export const intraledgerPaymentSendWalletIdForUsdWallet = async ( const validateIntraledgerPaymentInputs = async ({ uncheckedSenderWalletId, uncheckedRecipientWalletId, + skipChecks = false, }: { uncheckedSenderWalletId: string uncheckedRecipientWalletId: string + skipChecks: boolean }): Promise< | { senderWallet: Wallet @@ -187,7 +192,7 @@ const validateIntraledgerPaymentInputs = async ({ const senderAccount = await AccountsRepository().findById(senderWallet.accountId) if (senderAccount instanceof Error) return senderAccount - const senderAccountValidator = AccountValidator(senderAccount) + const senderAccountValidator = AccountValidator(senderAccount, { skipChecks }) if (senderAccountValidator instanceof Error) return senderAccountValidator const recipientWalletId = checkedToWalletId(uncheckedRecipientWalletId) @@ -209,6 +214,7 @@ const validateIntraledgerPaymentInputs = async ({ if (senderUser instanceof Error) return senderUser addAttributesToCurrentSpan({ + "payment.intraLedger.skipChecks": skipChecks, "payment.intraLedger.senderWalletId": senderWalletId, "payment.intraLedger.senderWalletCurrency": senderWallet.currency, "payment.intraLedger.recipientWalletId": recipientWalletId, @@ -236,6 +242,7 @@ const executePaymentViaIntraledger = async < senderUser, memo, apiKeyId, + skipChecks, }: { paymentFlow: PaymentFlow senderAccount: Account @@ -245,6 +252,7 @@ const executePaymentViaIntraledger = async < senderUser: User memo: string | null apiKeyId?: ApiKeyId + skipChecks: boolean }): Promise => { addAttributesToCurrentSpan({ "payment.settlement_method": SettlementMethod.IntraLedger, @@ -286,17 +294,16 @@ const executePaymentViaIntraledger = async < priceRatioForLimits, apiKeyId, btcPaymentAmount: paymentFlow.btcPaymentAmount, + skipChecks, execute: async () => { const journalId = await LockService().lockWalletId(senderWalletId, async (signal) => lockedPaymentViaIntraledgerSteps({ signal, - paymentFlow, senderDisplayCurrency: senderAccount.displayCurrency, senderUsername: senderAccount.username, recipientDisplayCurrency: recipientAccount.displayCurrency, recipientUsername: recipientAccount.username, - memo, }), ) diff --git a/core/api/src/app/payments/spending-limits.ts b/core/api/src/app/payments/spending-limits.ts index 3d08c97286..ad2365a3c9 100644 --- a/core/api/src/app/payments/spending-limits.ts +++ b/core/api/src/app/payments/spending-limits.ts @@ -91,6 +91,7 @@ export const withSpendingLimits = async ({ priceRatioForLimits, apiKeyId, btcPaymentAmount, + skipChecks = false, execute, }: { settlementMethod: SettlementMethod @@ -100,8 +101,14 @@ export const withSpendingLimits = async ({ priceRatioForLimits: WalletPriceRatio apiKeyId?: ApiKeyId btcPaymentAmount: BtcPaymentAmount + skipChecks?: boolean execute: () => Promise }): Promise => { + if (skipChecks) { + const executionResult = await execute() + return executionResult.result + } + const checkLimit = getLimitCheck({ settlementMethod, accountId, recipientAccountId }) const limitCheck = await checkLimit({ diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index 2dee0f6d3f..047d3ef60a 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -120,6 +120,7 @@ type IntraLedgerPaymentSendUsernameArgs = PaymentSendArgs & { type IntraLedgerPaymentSendWalletIdArgs = PaymentSendArgs & { recipientWalletId: WalletId amount: number + skipChecks?: boolean } type PayAllOnChainByWalletIdArgs = { diff --git a/core/api/src/debug/force-delete-account.ts b/core/api/src/debug/force-delete-account.ts index e8e2c6d6cb..7e64bada2a 100644 --- a/core/api/src/debug/force-delete-account.ts +++ b/core/api/src/debug/force-delete-account.ts @@ -16,7 +16,7 @@ const main = async () => { const result = await Accounts.markAccountForDeletion({ accountId, - cancelIfPositiveBalance: true, + skipChecks: true, bypassMaxDeletions: true, updatedByPrivilegedClientId: "admin" as PrivilegedClientId, }) diff --git a/core/api/src/domain/accounts/account-validator.ts b/core/api/src/domain/accounts/account-validator.ts index a296c3b555..8065278adf 100644 --- a/core/api/src/domain/accounts/account-validator.ts +++ b/core/api/src/domain/accounts/account-validator.ts @@ -4,10 +4,13 @@ import { InactiveAccountError, InvalidWalletId } from "@/domain/errors" export const AccountValidator = ( account: Account, + { skipChecks = false }: { skipChecks?: boolean } = {}, ): AccountValidator | ValidationError => { - const allowedStatuses: AccountStatus[] = [AccountStatus.Active, AccountStatus.Invited] - if (!allowedStatuses.includes(account.status)) { - return new InactiveAccountError(account.id) + if (!skipChecks) { + const allowedStatuses: AccountStatus[] = [AccountStatus.Active, AccountStatus.Invited] + if (!allowedStatuses.includes(account.status)) { + return new InactiveAccountError(account.id) + } } const validateWalletForAccount = ( diff --git a/core/api/src/graphql/admin/root/mutation/account-force-delete.ts b/core/api/src/graphql/admin/root/mutation/account-force-delete.ts index 012e5c61ba..a04bec2c22 100644 --- a/core/api/src/graphql/admin/root/mutation/account-force-delete.ts +++ b/core/api/src/graphql/admin/root/mutation/account-force-delete.ts @@ -11,7 +11,7 @@ const AccountForceDeleteInput = GT.Input({ accountId: { type: GT.NonNull(AccountId), }, - cancelIfPositiveBalance: { + skipChecks: { type: GT.Boolean, defaultValue: true, }, @@ -24,7 +24,7 @@ const AccountForceDeleteMutation = GT.Field< { input: { accountId: AccountId | Error - cancelIfPositiveBalance?: boolean + skipChecks?: boolean } } >({ @@ -36,14 +36,14 @@ const AccountForceDeleteMutation = GT.Field< input: { type: GT.NonNull(AccountForceDeleteInput) }, }, resolve: async (_, args, { privilegedClientId }) => { - const { accountId, cancelIfPositiveBalance = true } = args.input + const { accountId, skipChecks = true } = args.input if (accountId instanceof Error) return { errors: [{ message: accountId.message }], success: false } const result = await Accounts.markAccountForDeletion({ accountId, - cancelIfPositiveBalance, + skipChecks, bypassMaxDeletions: true, updatedByPrivilegedClientId: privilegedClientId, }) diff --git a/core/api/test/unit/app/payments/spending-limits.spec.ts b/core/api/test/unit/app/payments/spending-limits.spec.ts index 104de2535e..373835f32a 100644 --- a/core/api/test/unit/app/payments/spending-limits.spec.ts +++ b/core/api/test/unit/app/payments/spending-limits.spec.ts @@ -290,6 +290,29 @@ describe("withSpendingLimits", () => { expect(mockCheckTradeIntraAccountLimits).not.toHaveBeenCalled() }) + it("bypasses all limits and executes directly when skipChecks=true", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.IntraLedger, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + skipChecks: true, + execute: async () => + recordSettlement({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendSuccessResult) + expect(mockCheckIntraledgerLimits).not.toHaveBeenCalled() + expect(mockCheckTradeIntraAccountLimits).not.toHaveBeenCalled() + expect(mockCheckWithdrawalLimits).not.toHaveBeenCalled() + expect(mockApiKeys.checkAndLockSpending).not.toHaveBeenCalled() + }) + it("checks withdrawal limits for non-intraledger settlement method", async () => { const result = await withSpendingLimits({ settlementMethod: SettlementMethod.OnChain, diff --git a/core/api/test/unit/domain/accounts/account-validator.spec.ts b/core/api/test/unit/domain/accounts/account-validator.spec.ts index 8cf9f74258..d811c40c8b 100644 --- a/core/api/test/unit/domain/accounts/account-validator.spec.ts +++ b/core/api/test/unit/domain/accounts/account-validator.spec.ts @@ -42,6 +42,18 @@ describe("AccountValidator", () => { expect(result).toHaveProperty("validateWalletForAccount") }) + it("returns validator object for inactive account when skipChecks=true", () => { + const inactiveAccount = { + ...baseAccountProps, + id: "account-id-3" as AccountId, + status: AccountStatus.Locked, + } + + const result = AccountValidator(inactiveAccount, { skipChecks: true }) + expect(result).not.toBeInstanceOf(Error) + expect(result).toHaveProperty("validateWalletForAccount") + }) + it("returns error if account status is not active or invited", () => { const inactiveAccount = { ...baseAccountProps, From a9a5cc11ce67a6c83a96e2f51c2f05d051189dc4 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Wed, 22 Apr 2026 15:23:02 -0500 Subject: [PATCH 2/5] fix(accounts): safe balance sweep on account deletion - Resolve destination account (provided or bankowner fallback) before the wallet loop; build currency-to-walletId map to match wallets correctly, falling back to defaultWalletId when no currency match exists - Block deletion with AccountHasPositiveBalanceError when skipChecks=false; sweep via intraledgerPaymentSendWalletId (admin args) when skipChecks=true - Wrap sweep failures in InvalidAccountForDeletionError with full context - Remove skipChecks from IntraLedgerPaymentSendWalletIdArgs; introduce IntraLedgerPaymentSendWalletIdAdminArgs for internal/privileged use; ForBtcWallet/ForUsdWallet wrappers now explicitly pass skipChecks:false - Guard withSpendingLimits early-return so skipChecks=true with an apiKeyId still runs the lock/settle path - Default GraphQL skipChecks to false in account-force-delete mutation - Add code comments documenting skipChecks as admin-only privileged flag - Update/rewrite unit tests to match new behaviour --- .../app/accounts/mark-account-for-deletion.ts | 76 +++- core/api/src/app/payments/send-intraledger.ts | 12 +- core/api/src/app/payments/spending-limits.ts | 2 +- core/api/src/app/wallets/index.types.d.ts | 5 +- .../root/mutation/account-force-delete.ts | 4 +- .../mark-account-for-deletion.spec.ts | 393 ++++++++++++++++++ .../unit/app/payments/spending-limits.spec.ts | 24 +- 7 files changed, 485 insertions(+), 31 deletions(-) create mode 100644 core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts diff --git a/core/api/src/app/accounts/mark-account-for-deletion.ts b/core/api/src/app/accounts/mark-account-for-deletion.ts index 6740839c0e..3768250655 100644 --- a/core/api/src/app/accounts/mark-account-for-deletion.ts +++ b/core/api/src/app/accounts/mark-account-for-deletion.ts @@ -1,11 +1,9 @@ import { getDefaultAccountsConfig } from "@/config" +import { intraledgerPaymentSendWalletId } from "@/app/payments" import { deleteMerchantByUsername } from "@/app/merchants" - import { getBalanceForWallet, listWalletsByAccountId } from "@/app/wallets" -import { intraledgerPaymentSendWalletId } from "@/app/payments" - import { AccountStatus, AccountValidator, @@ -13,12 +11,21 @@ import { } from "@/domain/accounts" import { AccountHasPositiveBalanceError } from "@/domain/authentication/errors" +import { + AccountsRepository, + UsersRepository, + WalletsRepository, +} from "@/services/mongoose" import { IdentityRepository } from "@/services/kratos" import { addEventToCurrentSpan } from "@/services/tracing" -import { AccountsRepository, UsersRepository } from "@/services/mongoose" +import { getBankOwnerWalletId } from "@/services/ledger/caching" export const markAccountForDeletion = async ({ accountId, + // skipChecks is a privileged admin-only flag. When true it bypasses account + // status validation (allowing deletion of locked/inactive accounts) and + // spending limits on the balance sweep payment. Must never be set by + // end-user-facing code paths. skipChecks = false, updatedByPrivilegedClientId, bypassMaxDeletions = false, @@ -40,31 +47,56 @@ export const markAccountForDeletion = async ({ const wallets = await listWalletsByAccountId(account.id) if (wallets instanceof Error) return wallets + let resolvedDestinationAccountId = destinationAccountId + if (!resolvedDestinationAccountId) { + const bankOwnerWalletId = await getBankOwnerWalletId() + const bankOwnerWallet = await WalletsRepository().findById(bankOwnerWalletId) + if (bankOwnerWallet instanceof Error) return bankOwnerWallet + resolvedDestinationAccountId = bankOwnerWallet.accountId + } + + const destinationAccount = await accountsRepo.findById(resolvedDestinationAccountId) + if (destinationAccount instanceof Error) return destinationAccount + + const destinationWallets = await listWalletsByAccountId(resolvedDestinationAccountId) + if (destinationWallets instanceof Error) return destinationWallets + + const destinationWalletByCurrency = new Map( + destinationWallets.map((w) => [w.currency, w.id] as [WalletCurrency, WalletId]), + ) + for (const wallet of wallets) { const balance = await getBalanceForWallet({ walletId: wallet.id }) if (balance instanceof Error) return balance - if (balance > 0 && !skipChecks) { + + // Wallets with zero or negative balance are skipped. Negative balances + // (e.g. overdrafts) are not swept — they remain as ledger entries and are + // handled separately by the operator if needed. + if (balance <= 0) continue + + if (!skipChecks) { return new AccountHasPositiveBalanceError( - `The new phone is associated with an account with a non empty wallet. walletId: ${wallet.id}, balance: ${balance}, accountId: ${account.id}`, + `Cannot delete account with non-empty wallet. walletId: ${wallet.id}, balance: ${balance}, accountId: ${account.id}`, ) } - const destinationWallets = await listWalletsByAccountId(account.id) - if (destinationWallets instanceof Error) return destinationWallets - - if (balance > 0 && destinationAccountId) { - const destinationAccount = await accountsRepo.findById(destinationAccountId) - if (destinationAccount instanceof Error) return destinationAccount - - const payment = await intraledgerPaymentSendWalletId({ - senderWalletId: wallet.id, - recipientWalletId: destinationAccount.defaultWalletId, - amount: balance, - memo: `Closing settlement: ${wallet.currency} balance payout for Account ${account.id}`, - senderAccount: account, - skipChecks: true, - }) - if (payment instanceof Error) return payment + const recipientWalletId = + destinationWalletByCurrency.get(wallet.currency) || + destinationAccount.defaultWalletId + + const payment = await intraledgerPaymentSendWalletId({ + senderAccount: account, + senderWalletId: wallet.id, + recipientWalletId, + amount: balance, + memo: `Closing settlement: ${wallet.currency} balance payout for Account ${account.id}`, + skipChecks: true, + }) + + if (payment instanceof Error) { + return new InvalidAccountForDeletionError( + `Failed to sweep ${wallet.currency} wallet ${wallet.id} (balance: ${balance}) to destination account ${destinationAccount.id}: ${payment.message}`, + ) } addEventToCurrentSpan(`deleting_wallet`, { diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index 95b25fea29..33926e930d 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -56,7 +56,9 @@ export const intraledgerPaymentSendWalletId = async ({ senderWalletId: uncheckedSenderWalletId, apiKeyId, skipChecks = false, -}: IntraLedgerPaymentSendWalletIdArgs): Promise => { +}: IntraLedgerPaymentSendWalletIdAdminArgs): Promise< + PaymentSendResult | ApplicationError +> => { const validatedPaymentInputs = await validateIntraledgerPaymentInputs({ uncheckedSenderWalletId, uncheckedRecipientWalletId, @@ -155,14 +157,18 @@ export const intraledgerPaymentSendWalletIdForBtcWallet = async ( args: IntraLedgerPaymentSendWalletIdArgs, ): Promise => { const validated = await validateIsBtcWallet(args.senderWalletId) - return validated instanceof Error ? validated : intraledgerPaymentSendWalletId(args) + return validated instanceof Error + ? validated + : intraledgerPaymentSendWalletId({ ...args, skipChecks: false }) } export const intraledgerPaymentSendWalletIdForUsdWallet = async ( args: IntraLedgerPaymentSendWalletIdArgs, ): Promise => { const validated = await validateIsUsdWallet(args.senderWalletId) - return validated instanceof Error ? validated : intraledgerPaymentSendWalletId(args) + return validated instanceof Error + ? validated + : intraledgerPaymentSendWalletId({ ...args, skipChecks: false }) } const validateIntraledgerPaymentInputs = async ({ diff --git a/core/api/src/app/payments/spending-limits.ts b/core/api/src/app/payments/spending-limits.ts index ad2365a3c9..39bb1de0cb 100644 --- a/core/api/src/app/payments/spending-limits.ts +++ b/core/api/src/app/payments/spending-limits.ts @@ -104,7 +104,7 @@ export const withSpendingLimits = async ({ skipChecks?: boolean execute: () => Promise }): Promise => { - if (skipChecks) { + if (skipChecks && !apiKeyId) { const executionResult = await execute() return executionResult.result } diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index 047d3ef60a..dc9bb32e25 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -120,7 +120,10 @@ type IntraLedgerPaymentSendUsernameArgs = PaymentSendArgs & { type IntraLedgerPaymentSendWalletIdArgs = PaymentSendArgs & { recipientWalletId: WalletId amount: number - skipChecks?: boolean +} + +type IntraLedgerPaymentSendWalletIdAdminArgs = IntraLedgerPaymentSendWalletIdArgs & { + skipChecks: boolean } type PayAllOnChainByWalletIdArgs = { diff --git a/core/api/src/graphql/admin/root/mutation/account-force-delete.ts b/core/api/src/graphql/admin/root/mutation/account-force-delete.ts index a04bec2c22..09fd96dea4 100644 --- a/core/api/src/graphql/admin/root/mutation/account-force-delete.ts +++ b/core/api/src/graphql/admin/root/mutation/account-force-delete.ts @@ -13,7 +13,7 @@ const AccountForceDeleteInput = GT.Input({ }, skipChecks: { type: GT.Boolean, - defaultValue: true, + defaultValue: false, }, }), }) @@ -36,7 +36,7 @@ const AccountForceDeleteMutation = GT.Field< input: { type: GT.NonNull(AccountForceDeleteInput) }, }, resolve: async (_, args, { privilegedClientId }) => { - const { accountId, skipChecks = true } = args.input + const { accountId, skipChecks = false } = args.input if (accountId instanceof Error) return { errors: [{ message: accountId.message }], success: false } diff --git a/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts b/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts new file mode 100644 index 0000000000..5ac3a230df --- /dev/null +++ b/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts @@ -0,0 +1,393 @@ +jest.mock("@/config", () => ({ + getDefaultAccountsConfig: jest.fn().mockReturnValue({ maxDeletions: 2 }), +})) + +jest.mock("@/app/merchants", () => ({ + deleteMerchantByUsername: jest.fn().mockResolvedValue(true), +})) + +jest.mock("@/app/wallets", () => ({ + getBalanceForWallet: jest.fn(), + listWalletsByAccountId: jest.fn(), +})) + +jest.mock("@/app/payments", () => ({ + intraledgerPaymentSendWalletId: jest.fn(), +})) + +jest.mock("@/domain/accounts", () => ({ + AccountStatus: { Closed: "closed" }, + AccountValidator: jest.fn(), + InvalidAccountForDeletionError: class InvalidAccountForDeletionError extends Error { + constructor(msg?: string) { + super(msg ?? "invalid account for deletion") + } + }, +})) + +jest.mock("@/domain/authentication/errors", () => ({ + AccountHasPositiveBalanceError: class AccountHasPositiveBalanceError extends Error { + constructor(msg: string) { + super(msg) + } + }, +})) + +jest.mock("@/services/kratos", () => ({ + IdentityRepository: jest.fn().mockReturnValue({ + deleteIdentity: jest.fn().mockResolvedValue(true), + }), +})) + +jest.mock("@/services/tracing", () => ({ + addEventToCurrentSpan: jest.fn(), +})) + +jest.mock("@/services/ledger/caching", () => ({ + getBankOwnerWalletId: jest.fn(), +})) + +jest.mock("@/services/mongoose", () => ({ + AccountsRepository: jest.fn(), + UsersRepository: jest.fn(), + WalletsRepository: jest.fn(), +})) + +import { markAccountForDeletion } from "@/app/accounts/mark-account-for-deletion" +import { getBalanceForWallet, listWalletsByAccountId } from "@/app/wallets" +import { intraledgerPaymentSendWalletId } from "@/app/payments" +import { AccountValidator, InvalidAccountForDeletionError } from "@/domain/accounts" +import { AccountHasPositiveBalanceError } from "@/domain/authentication/errors" +import { getBankOwnerWalletId } from "@/services/ledger/caching" +import { + AccountsRepository, + UsersRepository, + WalletsRepository, +} from "@/services/mongoose" +import { IdentityRepository } from "@/services/kratos" + +const mockListWalletsByAccountId = listWalletsByAccountId as jest.Mock +const mockGetBalanceForWallet = getBalanceForWallet as jest.Mock +const mockSendWalletId = intraledgerPaymentSendWalletId as jest.Mock +const mockAccountValidator = AccountValidator as jest.Mock +const mockGetBankOwnerWalletId = getBankOwnerWalletId as jest.Mock + +const mockAccountsRepo = { + findById: jest.fn(), + update: jest.fn(), +} + +const mockUsersRepo = { + findById: jest.fn(), + update: jest.fn(), + findByDeletedPhones: jest.fn(), +} + +const mockWalletsRepo = { + findById: jest.fn(), +} + +const mockIdentities = { + deleteIdentity: jest.fn(), +} + +describe("markAccountForDeletion", () => { + const accountId = "account-id" as AccountId + const destinationAccountId = "destination-account-id" as AccountId + const bankOwnerWalletId = "bank-owner-wallet-id" as WalletId + const bankOwnerAccountId = "bank-owner-account-id" as AccountId + + const baseAccount = { + id: accountId, + kratosUserId: "kratos-user-id", + defaultWalletId: "default-wallet-id" as WalletId, + statusHistory: [], + username: undefined, + displayCurrency: "USD", + } + + const baseUser = { + id: "kratos-user-id", + phone: "+15550000000" as PhoneNumber, + deletedPhones: [], + } + + const btcWallet = { + id: "btc-wallet-id" as WalletId, + currency: "BTC" as WalletCurrency, + accountId, + } + + const usdWallet = { + id: "usd-wallet-id" as WalletId, + currency: "USD" as WalletCurrency, + accountId, + } + + const bankOwnerWallet = { + id: bankOwnerWalletId, + currency: "BTC" as WalletCurrency, + accountId: bankOwnerAccountId, + } + + const bankOwnerAccount = { + id: bankOwnerAccountId, + defaultWalletId: bankOwnerWalletId, + } + + const bankOwnerBtcWallet = { + id: bankOwnerWalletId, + currency: "BTC" as WalletCurrency, + accountId: bankOwnerAccountId, + } + + const bankOwnerUsdWallet = { + id: "bank-owner-usd-wallet-id" as WalletId, + currency: "USD" as WalletCurrency, + accountId: bankOwnerAccountId, + } + + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue(mockAccountsRepo) + ;(UsersRepository as jest.Mock).mockReturnValue(mockUsersRepo) + ;(WalletsRepository as jest.Mock).mockReturnValue(mockWalletsRepo) + ;(IdentityRepository as jest.Mock).mockReturnValue(mockIdentities) + + mockAccountsRepo.findById.mockResolvedValue(baseAccount) + mockAccountsRepo.update.mockResolvedValue(baseAccount) + mockUsersRepo.findById.mockResolvedValue(baseUser) + mockUsersRepo.update.mockResolvedValue(baseUser) + mockUsersRepo.findByDeletedPhones.mockResolvedValue([]) + mockIdentities.deleteIdentity.mockResolvedValue(true) + mockListWalletsByAccountId.mockResolvedValue([btcWallet]) + mockGetBalanceForWallet.mockResolvedValue(0) + mockAccountValidator.mockReturnValue({}) + mockGetBankOwnerWalletId.mockResolvedValue(bankOwnerWalletId) + mockWalletsRepo.findById.mockResolvedValue(bankOwnerWallet) + // by default destination is bankowner + mockAccountsRepo.findById.mockImplementation((id: AccountId) => { + if (id === accountId) return Promise.resolve(baseAccount) + if (id === bankOwnerAccountId) return Promise.resolve(bankOwnerAccount) + return Promise.resolve(new Error("not found")) + }) + mockListWalletsByAccountId.mockImplementation((id: AccountId) => { + if (id === accountId) return Promise.resolve([btcWallet]) + if (id === bankOwnerAccountId) + return Promise.resolve([bankOwnerBtcWallet, bankOwnerUsdWallet]) + return Promise.resolve([]) + }) + }) + + describe("without skipChecks", () => { + it("validates account status before proceeding", async () => { + const validationError = new Error("invalid account status") + mockAccountValidator.mockReturnValue(validationError) + + const result = await markAccountForDeletion({ accountId }) + + expect(result).toBe(validationError) + expect(mockAccountValidator).toHaveBeenCalledWith(baseAccount, { + skipChecks: false, + }) + }) + + it("returns error when wallet has positive balance", async () => { + mockGetBalanceForWallet.mockResolvedValue(100) + + const result = await markAccountForDeletion({ accountId }) + + expect(result).toBeInstanceOf(AccountHasPositiveBalanceError) + }) + + it("returns error when max deletions exceeded", async () => { + mockUsersRepo.findByDeletedPhones.mockResolvedValue([ + { id: "user1" }, + { id: "user2" }, + ]) + + const result = await markAccountForDeletion({ accountId }) + + expect(result).toBeInstanceOf(InvalidAccountForDeletionError) + }) + + it("marks account for deletion when balance is zero", async () => { + const result = await markAccountForDeletion({ accountId }) + + expect(result).toBe(true) + expect(mockAccountValidator).toHaveBeenCalledWith(baseAccount, { + skipChecks: false, + }) + expect(mockAccountsRepo.update).toHaveBeenCalled() + expect(mockIdentities.deleteIdentity).toHaveBeenCalledWith(baseAccount.kratosUserId) + }) + }) + + describe("with skipChecks=true", () => { + it("still calls AccountValidator with skipChecks=true (does not skip the call)", async () => { + const validationError = new Error("invalid account status") + mockAccountValidator.mockReturnValue(validationError) + + const result = await markAccountForDeletion({ accountId, skipChecks: true }) + + expect(mockAccountValidator).toHaveBeenCalledWith(baseAccount, { skipChecks: true }) + // AccountValidator with skipChecks=true bypasses status check internally, so result depends on validator mock + expect(result).toBe(validationError) + }) + + it("skips max deletions check when bypassMaxDeletions=true", async () => { + mockUsersRepo.findByDeletedPhones.mockResolvedValue([ + { id: "user1" }, + { id: "user2" }, + { id: "user3" }, + ]) + + const result = await markAccountForDeletion({ + accountId, + skipChecks: true, + bypassMaxDeletions: true, + }) + + expect(result).toBe(true) + expect(mockUsersRepo.findByDeletedPhones).not.toHaveBeenCalled() + }) + + it("sweeps BTC balance to bankowner when no destinationAccountId", async () => { + mockGetBalanceForWallet.mockResolvedValue(500) + mockSendWalletId.mockResolvedValue({ status: "success" }) + + const result = await markAccountForDeletion({ accountId, skipChecks: true }) + + expect(result).toBe(true) + expect(mockSendWalletId).toHaveBeenCalledWith( + expect.objectContaining({ + senderWalletId: btcWallet.id, + recipientWalletId: bankOwnerBtcWallet.id, + amount: 500, + senderAccount: baseAccount, + skipChecks: true, + }), + ) + }) + + it("falls back to destination defaultWalletId when no matching currency wallet", async () => { + // bankowner only has BTC — no USD wallet + mockListWalletsByAccountId.mockImplementation((id: AccountId) => { + if (id === accountId) return Promise.resolve([usdWallet]) + if (id === bankOwnerAccountId) return Promise.resolve([bankOwnerBtcWallet]) + return Promise.resolve([]) + }) + mockGetBalanceForWallet.mockResolvedValue(200) + mockSendWalletId.mockResolvedValue({ status: "success" }) + + const result = await markAccountForDeletion({ accountId, skipChecks: true }) + + expect(result).toBe(true) + expect(mockSendWalletId).toHaveBeenCalledWith( + expect.objectContaining({ + senderWalletId: usdWallet.id, + recipientWalletId: bankOwnerAccount.defaultWalletId, + }), + ) + }) + + it("returns InvalidAccountForDeletionError if sweep payment fails", async () => { + mockGetBalanceForWallet.mockResolvedValue(500) + mockSendWalletId.mockResolvedValue(new Error("payment failed")) + + const result = await markAccountForDeletion({ accountId, skipChecks: true }) + + expect(result).toBeInstanceOf(InvalidAccountForDeletionError) + }) + + it("skips sweep when balance is zero", async () => { + mockGetBalanceForWallet.mockResolvedValue(0) + + const result = await markAccountForDeletion({ accountId, skipChecks: true }) + + expect(result).toBe(true) + expect(mockSendWalletId).not.toHaveBeenCalled() + }) + }) + + describe("with destinationAccountId", () => { + const destinationAccount = { + id: destinationAccountId, + defaultWalletId: "dest-default-wallet-id" as WalletId, + } + + const destBtcWallet = { + id: "dest-btc-wallet-id" as WalletId, + currency: "BTC" as WalletCurrency, + accountId: destinationAccountId, + } + + const destUsdWallet = { + id: "dest-usd-wallet-id" as WalletId, + currency: "USD" as WalletCurrency, + accountId: destinationAccountId, + } + + beforeEach(() => { + mockAccountsRepo.findById.mockImplementation((id: AccountId) => { + if (id === accountId) return Promise.resolve(baseAccount) + if (id === destinationAccountId) return Promise.resolve(destinationAccount) + return Promise.resolve(new Error("not found")) + }) + mockListWalletsByAccountId.mockImplementation((id: AccountId) => { + if (id === accountId) return Promise.resolve([btcWallet]) + if (id === destinationAccountId) + return Promise.resolve([destBtcWallet, destUsdWallet]) + return Promise.resolve([]) + }) + mockSendWalletId.mockResolvedValue({ status: "success" }) + }) + + it("sweeps BTC balance to matching currency wallet in destination account", async () => { + mockGetBalanceForWallet.mockResolvedValue(200) + + const result = await markAccountForDeletion({ + accountId, + skipChecks: true, + destinationAccountId, + }) + + expect(result).toBe(true) + expect(mockSendWalletId).toHaveBeenCalledWith( + expect.objectContaining({ + senderWalletId: btcWallet.id, + recipientWalletId: destBtcWallet.id, + amount: 200, + senderAccount: baseAccount, + skipChecks: true, + }), + ) + }) + + it("returns InvalidAccountForDeletionError if sweep payment fails", async () => { + mockGetBalanceForWallet.mockResolvedValue(200) + mockSendWalletId.mockResolvedValue(new Error("payment failed")) + + const result = await markAccountForDeletion({ + accountId, + skipChecks: true, + destinationAccountId, + }) + + expect(result).toBeInstanceOf(InvalidAccountForDeletionError) + }) + + it("skips sweep when balance is zero", async () => { + mockGetBalanceForWallet.mockResolvedValue(0) + + const result = await markAccountForDeletion({ + accountId, + skipChecks: true, + destinationAccountId, + }) + + expect(result).toBe(true) + expect(mockSendWalletId).not.toHaveBeenCalled() + }) + }) +}) diff --git a/core/api/test/unit/app/payments/spending-limits.spec.ts b/core/api/test/unit/app/payments/spending-limits.spec.ts index 373835f32a..ca5e43f97c 100644 --- a/core/api/test/unit/app/payments/spending-limits.spec.ts +++ b/core/api/test/unit/app/payments/spending-limits.spec.ts @@ -290,13 +290,12 @@ describe("withSpendingLimits", () => { expect(mockCheckTradeIntraAccountLimits).not.toHaveBeenCalled() }) - it("bypasses all limits and executes directly when skipChecks=true", async () => { + it("bypasses all limits and executes directly when skipChecks=true and no apiKeyId", async () => { const result = await withSpendingLimits({ settlementMethod: SettlementMethod.IntraLedger, accountId: "sender-account-id" as AccountId, usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, priceRatioForLimits: {} as WalletPriceRatio, - apiKeyId, btcPaymentAmount, skipChecks: true, execute: async () => @@ -313,6 +312,27 @@ describe("withSpendingLimits", () => { expect(mockApiKeys.checkAndLockSpending).not.toHaveBeenCalled() }) + it("still checks limits when skipChecks=true but apiKeyId is present", async () => { + const result = await withSpendingLimits({ + settlementMethod: SettlementMethod.IntraLedger, + accountId: "sender-account-id" as AccountId, + usdPaymentAmount: { amount: 1000n, currency: "USD" } as UsdPaymentAmount, + priceRatioForLimits: {} as WalletPriceRatio, + apiKeyId, + btcPaymentAmount, + skipChecks: true, + execute: async () => + recordSettlement({ + result: paymentSendSuccessResult, + settlementTransactionId: journalId, + }), + }) + + expect(result).toEqual(paymentSendSuccessResult) + expect(mockCheckIntraledgerLimits).toHaveBeenCalled() + expect(mockApiKeys.checkAndLockSpending).toHaveBeenCalled() + }) + it("checks withdrawal limits for non-intraledger settlement method", async () => { const result = await withSpendingLimits({ settlementMethod: SettlementMethod.OnChain, From 20c753bcbb0824b39297d50129749fca783aa806 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Wed, 22 Apr 2026 17:21:47 -0500 Subject: [PATCH 3/5] fix(accounts): add self-transfer guard, privileged bypass tracing, and retry idempotency docs - Return InvalidAccountForDeletionError when destinationAccountId equals the account being deleted, preventing a no-op intra-account sweep that would leave funds in a closed account - Emit addAttributesToCurrentSpan with privilegedBypass, accountId, and updatedByPrivilegedClientId when skipChecks=true for audit visibility - Add JSDoc documenting retry/idempotency semantics on partial sweep failure - Rename IntraLedgerPaymentSendWalletIdAdminArgs to IntraLedgerPaymentSendWalletIdInternalArgs - Add unit tests covering self-transfer guard and span attribute emission --- apps/admin-panel/generated.ts | 4 +- .../app/accounts/mark-account-for-deletion.ts | 39 +++++++---- core/api/src/app/payments/send-intraledger.ts | 2 +- core/api/src/app/wallets/index.types.d.ts | 2 +- core/api/src/debug/force-delete-account.ts | 7 +- core/api/src/graphql/admin/schema.graphql | 2 +- .../mark-account-for-deletion.spec.ts | 65 +++++++++++++++++++ 7 files changed, 104 insertions(+), 17 deletions(-) diff --git a/apps/admin-panel/generated.ts b/apps/admin-panel/generated.ts index e96b6e50d0..0099431a41 100644 --- a/apps/admin-panel/generated.ts +++ b/apps/admin-panel/generated.ts @@ -67,7 +67,7 @@ export type AccountDetailPayload = { export type AccountForceDeleteInput = { readonly accountId: Scalars['AccountId']['input']; - readonly cancelIfPositiveBalance?: InputMaybe; + readonly skipChecks?: InputMaybe; }; export type AccountForceDeletePayload = { @@ -511,10 +511,12 @@ export const NotificationIcon = { export type NotificationIcon = typeof NotificationIcon[keyof typeof NotificationIcon]; export type OpenDeepLinkInput = { readonly action?: InputMaybe; + readonly label?: InputMaybe; readonly screen?: InputMaybe; }; export type OpenExternalUrlInput = { + readonly label?: InputMaybe; readonly url: Scalars['ExternalUrl']['input']; }; diff --git a/core/api/src/app/accounts/mark-account-for-deletion.ts b/core/api/src/app/accounts/mark-account-for-deletion.ts index 3768250655..6c6b880380 100644 --- a/core/api/src/app/accounts/mark-account-for-deletion.ts +++ b/core/api/src/app/accounts/mark-account-for-deletion.ts @@ -17,9 +17,22 @@ import { WalletsRepository, } from "@/services/mongoose" import { IdentityRepository } from "@/services/kratos" -import { addEventToCurrentSpan } from "@/services/tracing" +import { addAttributesToCurrentSpan, addEventToCurrentSpan } from "@/services/tracing" import { getBankOwnerWalletId } from "@/services/ledger/caching" +/** + * Marks an account for deletion, sweeping any positive wallet balances to a + * destination account before closing. + * + * **Retry / idempotency**: if the function returns an error mid-sweep (e.g. a + * payment failure on one wallet), already-swept wallets will have a zero + * balance and will be skipped on re-invocation (`balance <= 0` guard). It is + * safe to call this function again after a partial failure — it will pick up + * from the first wallet that still has a positive balance. + * + * Note: no atomic rollback is performed. A partial failure leaves the account + * open and some wallets already swept. Re-invoke to complete the operation. + */ export const markAccountForDeletion = async ({ accountId, // skipChecks is a privileged admin-only flag. When true it bypasses account @@ -41,6 +54,13 @@ export const markAccountForDeletion = async ({ const account = await accountsRepo.findById(accountId) if (account instanceof Error) return account + addAttributesToCurrentSpan({ + "markAccountForDeletion.privilegedBypass": skipChecks, + "markAccountForDeletion.accountId": account.id, + "markAccountForDeletion.updatedByPrivilegedClientId": + updatedByPrivilegedClientId ?? "unknown", + }) + const accountValidator = AccountValidator(account, { skipChecks }) if (accountValidator instanceof Error) return accountValidator @@ -55,6 +75,12 @@ export const markAccountForDeletion = async ({ resolvedDestinationAccountId = bankOwnerWallet.accountId } + if (resolvedDestinationAccountId === account.id) { + return new InvalidAccountForDeletionError( + `Destination account cannot be the same as the account being deleted: ${account.id}`, + ) + } + const destinationAccount = await accountsRepo.findById(resolvedDestinationAccountId) if (destinationAccount instanceof Error) return destinationAccount @@ -68,10 +94,6 @@ export const markAccountForDeletion = async ({ for (const wallet of wallets) { const balance = await getBalanceForWallet({ walletId: wallet.id }) if (balance instanceof Error) return balance - - // Wallets with zero or negative balance are skipped. Negative balances - // (e.g. overdrafts) are not swept — they remain as ledger entries and are - // handled separately by the operator if needed. if (balance <= 0) continue if (!skipChecks) { @@ -92,12 +114,7 @@ export const markAccountForDeletion = async ({ memo: `Closing settlement: ${wallet.currency} balance payout for Account ${account.id}`, skipChecks: true, }) - - if (payment instanceof Error) { - return new InvalidAccountForDeletionError( - `Failed to sweep ${wallet.currency} wallet ${wallet.id} (balance: ${balance}) to destination account ${destinationAccount.id}: ${payment.message}`, - ) - } + if (payment instanceof Error) return payment addEventToCurrentSpan(`deleting_wallet`, { walletId: wallet.id, diff --git a/core/api/src/app/payments/send-intraledger.ts b/core/api/src/app/payments/send-intraledger.ts index 33926e930d..94a2bcbfcc 100644 --- a/core/api/src/app/payments/send-intraledger.ts +++ b/core/api/src/app/payments/send-intraledger.ts @@ -56,7 +56,7 @@ export const intraledgerPaymentSendWalletId = async ({ senderWalletId: uncheckedSenderWalletId, apiKeyId, skipChecks = false, -}: IntraLedgerPaymentSendWalletIdAdminArgs): Promise< +}: IntraLedgerPaymentSendWalletIdInternalArgs): Promise< PaymentSendResult | ApplicationError > => { const validatedPaymentInputs = await validateIntraledgerPaymentInputs({ diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index dc9bb32e25..29ca5e9d80 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -122,7 +122,7 @@ type IntraLedgerPaymentSendWalletIdArgs = PaymentSendArgs & { amount: number } -type IntraLedgerPaymentSendWalletIdAdminArgs = IntraLedgerPaymentSendWalletIdArgs & { +type IntraLedgerPaymentSendWalletIdInternalArgs = IntraLedgerPaymentSendWalletIdArgs & { skipChecks: boolean } diff --git a/core/api/src/debug/force-delete-account.ts b/core/api/src/debug/force-delete-account.ts index 7e64bada2a..3c61c9b906 100644 --- a/core/api/src/debug/force-delete-account.ts +++ b/core/api/src/debug/force-delete-account.ts @@ -1,9 +1,10 @@ /** * how to run: * - * pnpm tsx src/debug/force-delete-account.ts + * pnpm tsx src/debug/force-delete-account.ts [destination account id] * * : ID of the account to force delete (bypasses max deletions limit) + * [destination account id]: optional account to sweep remaining balance to before deletion */ import { Accounts } from "@/app" @@ -11,14 +12,16 @@ import { Accounts } from "@/app" import { setupMongoConnection } from "@/services/mongodb" const main = async () => { - const args = process.argv.slice(-1) + const args = process.argv.slice(-2) const accountId = args[0] as AccountId + const destinationAccountId = args[1] as AccountId | undefined const result = await Accounts.markAccountForDeletion({ accountId, skipChecks: true, bypassMaxDeletions: true, updatedByPrivilegedClientId: "admin" as PrivilegedClientId, + destinationAccountId, }) if (result instanceof Error) { diff --git a/core/api/src/graphql/admin/schema.graphql b/core/api/src/graphql/admin/schema.graphql index fd1d8b606d..625c203702 100644 --- a/core/api/src/graphql/admin/schema.graphql +++ b/core/api/src/graphql/admin/schema.graphql @@ -5,7 +5,7 @@ type AccountDetailPayload { input AccountForceDeleteInput { accountId: AccountId! - cancelIfPositiveBalance: Boolean = true + skipChecks: Boolean = false } type AccountForceDeletePayload { diff --git a/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts b/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts index 5ac3a230df..f6787d68f4 100644 --- a/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts +++ b/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts @@ -40,6 +40,7 @@ jest.mock("@/services/kratos", () => ({ })) jest.mock("@/services/tracing", () => ({ + addAttributesToCurrentSpan: jest.fn(), addEventToCurrentSpan: jest.fn(), })) @@ -65,12 +66,14 @@ import { WalletsRepository, } from "@/services/mongoose" import { IdentityRepository } from "@/services/kratos" +import { addAttributesToCurrentSpan } from "@/services/tracing" const mockListWalletsByAccountId = listWalletsByAccountId as jest.Mock const mockGetBalanceForWallet = getBalanceForWallet as jest.Mock const mockSendWalletId = intraledgerPaymentSendWalletId as jest.Mock const mockAccountValidator = AccountValidator as jest.Mock const mockGetBankOwnerWalletId = getBankOwnerWalletId as jest.Mock +const mockAddAttributesToCurrentSpan = addAttributesToCurrentSpan as jest.Mock const mockAccountsRepo = { findById: jest.fn(), @@ -308,6 +311,68 @@ describe("markAccountForDeletion", () => { expect(result).toBe(true) expect(mockSendWalletId).not.toHaveBeenCalled() }) + + it("returns InvalidAccountForDeletionError when destinationAccountId equals the account being deleted", async () => { + mockGetBalanceForWallet.mockResolvedValue(500) + // Override destination resolution: bank owner wallet resolves to the same account + mockWalletsRepo.findById.mockResolvedValue({ ...bankOwnerWallet, accountId }) + mockAccountsRepo.findById.mockImplementation((id: AccountId) => { + if (id === accountId) return Promise.resolve(baseAccount) + return Promise.resolve(new Error("not found")) + }) + + const result = await markAccountForDeletion({ accountId, skipChecks: true }) + + expect(result).toBeInstanceOf(InvalidAccountForDeletionError) + expect((result as Error).message).toMatch( + /Destination account cannot be the same as the account being deleted/, + ) + expect(mockSendWalletId).not.toHaveBeenCalled() + }) + + it("returns InvalidAccountForDeletionError when explicit destinationAccountId equals the account being deleted", async () => { + mockGetBalanceForWallet.mockResolvedValue(500) + + const result = await markAccountForDeletion({ + accountId, + skipChecks: true, + destinationAccountId: accountId, + }) + + expect(result).toBeInstanceOf(InvalidAccountForDeletionError) + expect((result as Error).message).toMatch( + /Destination account cannot be the same as the account being deleted/, + ) + expect(mockSendWalletId).not.toHaveBeenCalled() + }) + + it("emits privilegedBypass span attributes when skipChecks=true", async () => { + mockGetBalanceForWallet.mockResolvedValue(0) + + await markAccountForDeletion({ + accountId, + skipChecks: true, + updatedByPrivilegedClientId: "admin-client" as PrivilegedClientId, + }) + + expect(mockAddAttributesToCurrentSpan).toHaveBeenCalledWith( + expect.objectContaining({ + "markAccountForDeletion.privilegedBypass": true, + "markAccountForDeletion.accountId": accountId, + "markAccountForDeletion.updatedByPrivilegedClientId": "admin-client", + }), + ) + }) + + it("does not emit privilegedBypass span attributes when skipChecks=false", async () => { + mockGetBalanceForWallet.mockResolvedValue(0) + + await markAccountForDeletion({ accountId }) + + expect(mockAddAttributesToCurrentSpan).not.toHaveBeenCalledWith( + expect.objectContaining({ "markAccountForDeletion.privilegedBypass": true }), + ) + }) }) describe("with destinationAccountId", () => { From 122b2695c3f4f55c73139bb49e7f79d971bf97e7 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Wed, 22 Apr 2026 17:38:02 -0500 Subject: [PATCH 4/5] test(accounts): fix sweep payment error assertions in mark-account-for-deletion --- .../app/accounts/mark-account-for-deletion.spec.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts b/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts index f6787d68f4..ca5a396e12 100644 --- a/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts +++ b/core/api/test/unit/app/accounts/mark-account-for-deletion.spec.ts @@ -294,13 +294,14 @@ describe("markAccountForDeletion", () => { ) }) - it("returns InvalidAccountForDeletionError if sweep payment fails", async () => { + it("returns payment error directly if sweep payment fails", async () => { mockGetBalanceForWallet.mockResolvedValue(500) - mockSendWalletId.mockResolvedValue(new Error("payment failed")) + const paymentError = new Error("payment failed") + mockSendWalletId.mockResolvedValue(paymentError) const result = await markAccountForDeletion({ accountId, skipChecks: true }) - expect(result).toBeInstanceOf(InvalidAccountForDeletionError) + expect(result).toBe(paymentError) }) it("skips sweep when balance is zero", async () => { @@ -429,9 +430,10 @@ describe("markAccountForDeletion", () => { ) }) - it("returns InvalidAccountForDeletionError if sweep payment fails", async () => { + it("returns payment error directly if sweep payment fails", async () => { mockGetBalanceForWallet.mockResolvedValue(200) - mockSendWalletId.mockResolvedValue(new Error("payment failed")) + const paymentError = new Error("payment failed") + mockSendWalletId.mockResolvedValue(paymentError) const result = await markAccountForDeletion({ accountId, @@ -439,7 +441,7 @@ describe("markAccountForDeletion", () => { destinationAccountId, }) - expect(result).toBeInstanceOf(InvalidAccountForDeletionError) + expect(result).toBe(paymentError) }) it("skips sweep when balance is zero", async () => { From c5298ade7980dbb1de3fe6bf0de7b6f9415682ca Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Fri, 24 Apr 2026 13:18:57 -0500 Subject: [PATCH 5/5] feat(debug): add script to upgrade device account to level 1 --- core/api/src/debug/upgrade-device-account.ts | 82 ++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 core/api/src/debug/upgrade-device-account.ts diff --git a/core/api/src/debug/upgrade-device-account.ts b/core/api/src/debug/upgrade-device-account.ts new file mode 100644 index 0000000000..08af95be2c --- /dev/null +++ b/core/api/src/debug/upgrade-device-account.ts @@ -0,0 +1,82 @@ +/** + * how to run: + * + * pnpm tsx src/debug/upgrade-device-account.ts + * + * : ID of the device account to upgrade to level 1 + * : Phone number to associate with the account (e.g. +15555550100) + */ + +import { upgradeAccountFromDeviceToPhone } from "@/app/accounts" +import { getPhoneMetadata } from "@/app/authentication/get-phone-metadata" + +import { AccountLevel } from "@/domain/accounts" +import { PhoneAlreadyExistsError } from "@/domain/authentication/errors" +import { InvalidAccountLevelError } from "@/domain/errors" + +import { + AuthWithUsernamePasswordDeviceIdService, + IdentityRepository, +} from "@/services/kratos" +import { AccountsRepository } from "@/services/mongoose" +import { setupMongoConnection } from "@/services/mongodb" + +const upgradeDeviceAccount = async ({ + accountId, + phone, +}: { + accountId: AccountId + phone: PhoneNumber +}) => { + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + if (account.level !== AccountLevel.Zero) return new InvalidAccountLevelError() + + const identities = IdentityRepository() + const userId = await identities.getUserIdFromIdentifier(phone) + if (!(userId instanceof Error)) { + return new PhoneAlreadyExistsError() + } + + const phoneMetadata = await getPhoneMetadata({ phone }) + if (phoneMetadata instanceof Error) return phoneMetadata + + const upgraded = await AuthWithUsernamePasswordDeviceIdService().upgradeToPhoneSchema({ + phone, + userId: account.kratosUserId, + }) + if (upgraded instanceof Error) return upgraded + + const result = await upgradeAccountFromDeviceToPhone({ + userId: account.kratosUserId, + phone, + phoneMetadata, + }) + if (result instanceof Error) return result + + return true +} + +const main = async () => { + const args = process.argv.slice(-2) + const params = { + accountId: args[0] as AccountId, + phone: args[1] as PhoneNumber, + } + const result = await upgradeDeviceAccount(params) + if (result instanceof Error) { + console.error("Error:", result) + return + } + console.log( + `Successfully upgraded account ${params.accountId} to level 1 with phone ${params.phone}`, + ) +} + +setupMongoConnection() + .then(async (mongoose) => { + await main() + if (mongoose) await mongoose.connection.close() + }) + .catch((err) => console.log(err))