Skip to content
Open
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
4 changes: 3 additions & 1 deletion apps/admin-panel/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export type AccountDetailPayload = {

export type AccountForceDeleteInput = {
readonly accountId: Scalars['AccountId']['input'];
readonly cancelIfPositiveBalance?: InputMaybe<Scalars['Boolean']['input']>;
readonly skipChecks?: InputMaybe<Scalars['Boolean']['input']>;
};

export type AccountForceDeletePayload = {
Expand Down Expand Up @@ -511,10 +511,12 @@ export const NotificationIcon = {
export type NotificationIcon = typeof NotificationIcon[keyof typeof NotificationIcon];
export type OpenDeepLinkInput = {
readonly action?: InputMaybe<DeepLinkAction>;
readonly label?: InputMaybe<Scalars['String']['input']>;
readonly screen?: InputMaybe<DeepLinkScreen>;
};

export type OpenExternalUrlInput = {
readonly label?: InputMaybe<Scalars['String']['input']>;
readonly url: Scalars['ExternalUrl']['input'];
};

Expand Down
91 changes: 82 additions & 9 deletions core/api/src/app/accounts/mark-account-for-deletion.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getDefaultAccountsConfig } from "@/config"

import { intraledgerPaymentSendWalletId } from "@/app/payments"
import { deleteMerchantByUsername } from "@/app/merchants"

import { getBalanceForWallet, listWalletsByAccountId } from "@/app/wallets"

import {
Expand All @@ -11,38 +11,111 @@ 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 { 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,
cancelIfPositiveBalance = false,
// 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,
destinationAccountId,
}: {
accountId: AccountId
cancelIfPositiveBalance?: boolean
skipChecks?: boolean
updatedByPrivilegedClientId?: PrivilegedClientId
bypassMaxDeletions?: boolean
destinationAccountId?: AccountId
}): Promise<true | ApplicationError> => {
const accountsRepo = AccountsRepository()
const account = await accountsRepo.findById(accountId)
if (account instanceof Error) return account
const accountValidator = AccountValidator(account)

addAttributesToCurrentSpan({
"markAccountForDeletion.privilegedBypass": skipChecks,
"markAccountForDeletion.accountId": account.id,
"markAccountForDeletion.updatedByPrivilegedClientId":
updatedByPrivilegedClientId ?? "unknown",
})

const accountValidator = AccountValidator(account, { skipChecks })
if (accountValidator instanceof Error) return accountValidator

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
}

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

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 && cancelIfPositiveBalance) {
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}, cancelIfPositiveBalance: ${cancelIfPositiveBalance}`,
`Cannot delete account with non-empty wallet. walletId: ${wallet.id}, balance: ${balance}, accountId: ${account.id}`,
)
}

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 payment

addEventToCurrentSpan(`deleting_wallet`, {
walletId: wallet.id,
currency: wallet.currency,
Expand Down
2 changes: 1 addition & 1 deletion core/api/src/app/admin/update-user-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const updateUserEmail = async ({

const result = await markAccountForDeletion({
accountId: newAccount.id,
cancelIfPositiveBalance: true,
skipChecks: false,
bypassMaxDeletions: true,
updatedByPrivilegedClientId,
})
Expand Down
2 changes: 1 addition & 1 deletion core/api/src/app/admin/update-user-phone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const updateUserPhone = async ({

const result = await markAccountForDeletion({
accountId: newAccount.id,
cancelIfPositiveBalance: true,
skipChecks: false,
bypassMaxDeletions: true,
updatedByPrivilegedClientId,
})
Expand Down
27 changes: 20 additions & 7 deletions core/api/src/app/payments/send-intraledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,21 @@ import { NotificationsService } from "@/services/notifications"

const dealer = DealerPriceService()

const intraledgerPaymentSendWalletId = async ({
export const intraledgerPaymentSendWalletId = async ({
recipientWalletId: uncheckedRecipientWalletId,
senderAccount,
amount: uncheckedAmount,
memo,
senderWalletId: uncheckedSenderWalletId,
apiKeyId,
}: IntraLedgerPaymentSendWalletIdArgs): Promise<PaymentSendResult | ApplicationError> => {
skipChecks = false,
}: IntraLedgerPaymentSendWalletIdInternalArgs): Promise<
PaymentSendResult | ApplicationError
> => {
const validatedPaymentInputs = await validateIntraledgerPaymentInputs({
uncheckedSenderWalletId,
uncheckedRecipientWalletId,
skipChecks,
})
if (validatedPaymentInputs instanceof Error) return validatedPaymentInputs

Expand Down Expand Up @@ -131,6 +135,7 @@ const intraledgerPaymentSendWalletId = async ({
senderUser,
memo,
apiKeyId,
skipChecks,
})

if (paymentSendResult instanceof Error) return paymentSendResult
Expand All @@ -152,22 +157,28 @@ export const intraledgerPaymentSendWalletIdForBtcWallet = async (
args: IntraLedgerPaymentSendWalletIdArgs,
): Promise<PaymentSendResult | ApplicationError> => {
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<PaymentSendResult | ApplicationError> => {
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 ({
uncheckedSenderWalletId,
uncheckedRecipientWalletId,
skipChecks = false,
}: {
uncheckedSenderWalletId: string
uncheckedRecipientWalletId: string
skipChecks: boolean
}): Promise<
| {
senderWallet: Wallet
Expand All @@ -187,7 +198,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)
Expand All @@ -209,6 +220,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,
Expand Down Expand Up @@ -236,6 +248,7 @@ const executePaymentViaIntraledger = async <
senderUser,
memo,
apiKeyId,
skipChecks,
}: {
paymentFlow: PaymentFlow<S, R>
senderAccount: Account
Expand All @@ -245,6 +258,7 @@ const executePaymentViaIntraledger = async <
senderUser: User
memo: string | null
apiKeyId?: ApiKeyId
skipChecks: boolean
}): Promise<PaymentSendResult | ApplicationError> => {
addAttributesToCurrentSpan({
"payment.settlement_method": SettlementMethod.IntraLedger,
Expand Down Expand Up @@ -286,17 +300,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,
}),
)
Expand Down
7 changes: 7 additions & 0 deletions core/api/src/app/payments/spending-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const withSpendingLimits = async ({
priceRatioForLimits,
apiKeyId,
btcPaymentAmount,
skipChecks = false,
execute,
}: {
settlementMethod: SettlementMethod
Expand All @@ -100,8 +101,14 @@ export const withSpendingLimits = async ({
priceRatioForLimits: WalletPriceRatio
apiKeyId?: ApiKeyId
btcPaymentAmount: BtcPaymentAmount
skipChecks?: boolean
execute: () => Promise<SpendingLimitsExecutionResult>
}): Promise<PaymentSendResult | ApplicationError> => {
if (skipChecks && !apiKeyId) {
const executionResult = await execute()
return executionResult.result
}

const checkLimit = getLimitCheck({ settlementMethod, accountId, recipientAccountId })

const limitCheck = await checkLimit({
Expand Down
4 changes: 4 additions & 0 deletions core/api/src/app/wallets/index.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ type IntraLedgerPaymentSendWalletIdArgs = PaymentSendArgs & {
amount: number
}

type IntraLedgerPaymentSendWalletIdInternalArgs = IntraLedgerPaymentSendWalletIdArgs & {
skipChecks: boolean
}

type PayAllOnChainByWalletIdArgs = {
senderWalletId: WalletId
senderAccount: Account
Expand Down
9 changes: 6 additions & 3 deletions core/api/src/debug/force-delete-account.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
/**
* how to run:
*
* pnpm tsx src/debug/force-delete-account.ts <account id>
* pnpm tsx src/debug/force-delete-account.ts <account id> [destination account id]
*
* <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"

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,
cancelIfPositiveBalance: true,
skipChecks: true,
bypassMaxDeletions: true,
updatedByPrivilegedClientId: "admin" as PrivilegedClientId,
destinationAccountId,
})

if (result instanceof Error) {
Expand Down
Loading
Loading