diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index 3cbc37bee..88fb2ada0 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -18,7 +18,8 @@ bridge: enabled: true apiKey: "sk-test-3bd6463c9cd77c3d8858c60b9997d0c6" baseUrl: "https://api.sandbox.bridge.xyz/v0" - minWithdrawalAmount: 10 + minWithdrawalAmount: 2 + timeoutMs: 15000 webhook: port: 4009 replaySecret: "also-not-so-secret" diff --git a/src/app/bridge/send-withdrawal-notification.ts b/src/app/bridge/send-withdrawal-notification.ts new file mode 100644 index 000000000..8adf90f30 --- /dev/null +++ b/src/app/bridge/send-withdrawal-notification.ts @@ -0,0 +1,112 @@ +import { getI18nInstance } from "@config" +import { checkedToAccountId } from "@domain/accounts" +import { getLanguageOrDefault } from "@domain/locale" +import { + DeviceTokensNotRegisteredNotificationsServiceError, + FlashNotificationCategories, + NotificationsServiceError, +} from "@domain/notifications" +import { removeDeviceTokens } from "@app/users/remove-device-tokens" +import { baseLogger } from "@services/logger" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" + +const i18n = getI18nInstance() + +const formatWithdrawalAmount = (amount: string, currency: string): string => + `${amount} ${currency.toUpperCase()}` + +export type BridgeWithdrawalNotificationOutcome = "completed" | "failed" + +export const sendBridgeWithdrawalNotification = async ({ + accountId: accountIdRaw, + amount, + currency, + outcome, + failureReason, +}: { + accountId: string + amount: string + currency: string + outcome: BridgeWithdrawalNotificationOutcome + failureReason?: string +}): Promise => { + const accountId = checkedToAccountId(accountIdRaw) + if (accountId instanceof Error) return accountId + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return account + + const user = await UsersRepository().findById(account.kratosUserId) + if (user instanceof Error) return user + + const locale = getLanguageOrDefault(user.language) + const formattedAmount = formatWithdrawalAmount(amount, currency) + const phraseBase = `notification.bridgeWithdrawal.${outcome}` + + const title = i18n.__({ phrase: `${phraseBase}.title`, locale }) + const bodyPhrase = + outcome === "failed" && failureReason + ? `${phraseBase}.bodyWithReason` + : `${phraseBase}.body` + const body = i18n.__( + { phrase: bodyPhrase, locale }, + { + amount: formattedAmount, + reason: failureReason ?? "", + }, + ) + + const result = await PushNotificationsService().sendFilteredNotification({ + deviceTokens: user.deviceTokens, + title, + body, + notificationCategory: FlashNotificationCategories.Cashout, + notificationSettings: account.notificationSettings, + data: { + type: `bridge_withdrawal_${outcome}`, + amount, + currency, + ...(failureReason ? { failureReason } : {}), + }, + }) + + if (result instanceof NotificationsServiceError) return result + + if (result.status === SendFilteredPushNotificationStatus.Filtered) { + return true + } + + return true +} + +export const sendBridgeWithdrawalNotificationBestEffort = async ( + args: Parameters[0], +): Promise => { + const result = await sendBridgeWithdrawalNotification(args) + + if (result instanceof DeviceTokensNotRegisteredNotificationsServiceError) { + const accountId = checkedToAccountId(args.accountId) + if (accountId instanceof Error) return + + const account = await AccountsRepository().findById(accountId) + if (account instanceof Error) return + + await removeDeviceTokens({ + userId: account.kratosUserId, + deviceTokens: result.tokens, + }) + return + } + + if (result instanceof Error) { + baseLogger.warn( + { accountId: args.accountId, outcome: args.outcome, error: result }, + "Failed to send Bridge withdrawal push notification", + ) + } +} diff --git a/src/config/locales/en.json b/src/config/locales/en.json index c9bd32b95..55eafc82d 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -40,6 +40,17 @@ "cashout": { "body": "Your cashout of {{amount}} has been deposited to your bank account.", "title": "Cashout" + }, + "bridgeWithdrawal": { + "completed": { + "body": "Your withdrawal of {{amount}} has been sent to your bank account.", + "title": "Withdrawal complete" + }, + "failed": { + "body": "Your withdrawal of {{amount}} could not be completed.", + "bodyWithReason": "Your withdrawal of {{amount}} could not be completed: {{reason}}.", + "title": "Withdrawal failed" + } } } } \ No newline at end of file diff --git a/src/config/locales/es.json b/src/config/locales/es.json index 122b16b47..7325c9fe0 100644 --- a/src/config/locales/es.json +++ b/src/config/locales/es.json @@ -36,6 +36,17 @@ "bodyDisplayCurrency": "+{{baseCurrencyAmount}}{{baseCurrencyName}} ({{displayCurrencyAmount}})", "title": "Transacción {{walletCurrency}}" } + }, + "bridgeWithdrawal": { + "completed": { + "body": "Su retiro de {{amount}} se envió a su cuenta bancaria.", + "title": "Retiro completado" + }, + "failed": { + "body": "No se pudo completar su retiro de {{amount}}.", + "bodyWithReason": "No se pudo completar su retiro de {{amount}}: {{reason}}.", + "title": "Retiro fallido" + } } } } diff --git a/src/config/schema.ts b/src/config/schema.ts index 0d673fd2b..c4e6077c2 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -649,6 +649,8 @@ export const configSchema = { enabled: { type: "boolean" }, apiKey: { type: "string" }, baseUrl: { type: "string" }, + minWithdrawalAmount: { type: "number" }, + timeoutMs: { type: "integer", default: 10000 }, webhook: { type: "object", properties: { diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 4f480b86f..20c1952a4 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -49,6 +49,7 @@ type BridgeConfig = { apiKey: string baseUrl: string minWithdrawalAmount: number + timeoutMs?: number webhook: BridgeWebhook } diff --git a/src/domain/accounts/index.types.d.ts b/src/domain/accounts/index.types.d.ts index 3fed86428..1b39d3c32 100644 --- a/src/domain/accounts/index.types.d.ts +++ b/src/domain/accounts/index.types.d.ts @@ -88,7 +88,7 @@ type Account = { erpParty?: string // Lookup key to Customer in ERPNext. Required for Account level > 1 // Bridge integration: bridgeCustomerId?: BridgeCustomerId - bridgeKycStatus?: "pending" | "approved" | "rejected" + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string } @@ -135,10 +135,10 @@ type LimitsCheckerFn = (args: LimiterCheckInputs) => Promise[]) => Promise< | { - volumeTotalLimit: UsdPaymentAmount - volumeUsed: UsdPaymentAmount - volumeRemaining: UsdPaymentAmount - } + volumeTotalLimit: UsdPaymentAmount + volumeUsed: UsdPaymentAmount + volumeRemaining: UsdPaymentAmount + } | ValidationError > @@ -150,10 +150,10 @@ type AccountLimitsChecker = { type AccountLimitsVolumes = | { - volumesIntraledger: LimitsVolumesFn - volumesWithdrawal: LimitsVolumesFn - volumesTradeIntraAccount: LimitsVolumesFn - } + volumesIntraledger: LimitsVolumesFn + volumesWithdrawal: LimitsVolumesFn + volumesTradeIntraAccount: LimitsVolumesFn + } | ValidationError type AccountValidator = { @@ -179,7 +179,7 @@ interface IAccountsRepository { id: AccountId, fields: { bridgeCustomerId?: BridgeCustomerId - bridgeKycStatus?: "pending" | "approved" | "rejected" + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string }, ): Promise diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index c53a4b37f..9f93e8875 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -501,6 +501,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "KYC verification was rejected" return new ValidationInternalError({ message, logger: baseLogger }) + case "BridgeKycOffboardedError": + message = "Your account has been offboarded from Bridge. Please contact support." + return new ValidationInternalError({ message, logger: baseLogger }) + case "BridgeCustomerNotFoundError": message = "Bridge customer not found" return new ValidationInternalError({ message, logger: baseLogger }) @@ -517,6 +521,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "Request timed out" return new ValidationInternalError({ message, logger: baseLogger }) + case "BridgeTransferFailedError": + message = error.message || "Transfer failed" + return new ValidationInternalError({ message, logger: baseLogger }) + case "BridgeWebhookValidationError": message = "Invalid webhook signature" return new ValidationInternalError({ message, logger: baseLogger }) diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index 0ed531044..e2f607717 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -300,6 +300,7 @@ type BridgeWithdrawal { amount: String! createdAt: String! currency: String! + failureReason: String id: ID! status: String! } diff --git a/src/graphql/public/types/object/bridge-withdrawal.ts b/src/graphql/public/types/object/bridge-withdrawal.ts index f29ba250d..0c6298215 100644 --- a/src/graphql/public/types/object/bridge-withdrawal.ts +++ b/src/graphql/public/types/object/bridge-withdrawal.ts @@ -7,6 +7,7 @@ const BridgeWithdrawal = GT.Object({ amount: { type: GT.NonNull(GT.String) }, currency: { type: GT.NonNull(GT.String) }, status: { type: GT.NonNull(GT.String) }, + failureReason: { type: GT.String }, createdAt: { type: GT.NonNull(GT.String) }, }), }) diff --git a/src/services/bridge/client.ts b/src/services/bridge/client.ts index e5503fccf..d6d07db65 100644 --- a/src/services/bridge/client.ts +++ b/src/services/bridge/client.ts @@ -7,7 +7,8 @@ import crypto from "crypto" import { BridgeConfig } from "@config" -import { BridgeCustomerId, BridgeTransferId } from "@domain/primitives/bridge" +import { BridgeCustomerId, BridgeTransferId, BridgeVirtualAccountId } from "@domain/primitives/bridge" +import { BridgeTimeoutError } from "./errors" // ============ Error Handling ============ @@ -66,15 +67,15 @@ export interface Customer { id: string type: "individual" | "business" status?: - | "active" - | "awaiting_questionnaire" - | "rejected" - | "paused" - | "under_review" - | "offboarded" - | "awaiting_ubo" - | "incomplete" - | "not_started" + | "active" + | "awaiting_questionnaire" + | "rejected" + | "paused" + | "under_review" + | "offboarded" + | "awaiting_ubo" + | "incomplete" + | "not_started" has_accepted_terms_of_service?: string created_at: string updated_at: string @@ -365,23 +366,37 @@ export class BridgeClient { } } - const response = await fetch(url, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - }) - - const responseData = await response.json().catch(() => null) + const timeoutMs = BridgeConfig.timeoutMs ?? 15_000 + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeoutMs) + + try { + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + + const responseData = await response.json().catch(() => null) + + if (!response.ok) { + throw new BridgeApiError( + `Bridge API error: ${response.status} ${response.statusText}`, + response.status, + responseData, + ) + } - if (!response.ok) { - throw new BridgeApiError( - `Bridge API error: ${response.status} ${response.statusText}`, - response.status, - responseData, - ) + return responseData as T + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new BridgeTimeoutError() + } + throw err + } finally { + clearTimeout(timeoutId) } - - return responseData as T } // ============ Customers ============ @@ -425,6 +440,28 @@ export class BridgeClient { ) } + async getVirtualAccount( + customerId: BridgeCustomerId, virtualAccountId: BridgeVirtualAccountId, idempotencyKey?: string, + ): Promise { + return this.request( + "GET", + `/customers/${customerId}/virtual_accounts/${virtualAccountId}`, + undefined, + idempotencyKey, + ) + } + + + async getVirtualAccountByCustomerId(customerId: BridgeCustomerId): Promise { + const response = await this.request<{ data: VirtualAccount[] }>( + "GET", + `/customers/${customerId}/virtual_accounts`, + ) + + return response.data as VirtualAccount[] + + + } // ============ External Accounts ============ async createExternalAccount( diff --git a/src/services/bridge/errors.ts b/src/services/bridge/errors.ts index 6f6572b0c..3cef54938 100644 --- a/src/services/bridge/errors.ts +++ b/src/services/bridge/errors.ts @@ -45,6 +45,12 @@ export class BridgeKycRejectedError extends BridgeError { } } +export class BridgeKycOffboardedError extends BridgeError { + constructor(message: string = "Your account has been offboarded from Bridge. Please contact support.") { + super(message) + } +} + export class BridgeInsufficientFundsError extends BridgeError { constructor(message: string = "Insufficient funds for withdrawal") { super(message) @@ -77,6 +83,12 @@ export class BridgeDisabledError extends BridgeError { } } +export class BridgeTransferFailedError extends BridgeError { + constructor(reason: string = "Transfer failed") { + super(reason) + } +} + export class BridgeWebhookValidationError extends BridgeError { constructor(message: string = "Invalid webhook signature") { super(message) diff --git a/src/services/bridge/index.ts b/src/services/bridge/index.ts index f5f51e2da..ba0fab74e 100644 --- a/src/services/bridge/index.ts +++ b/src/services/bridge/index.ts @@ -14,7 +14,7 @@ import { wrapAsyncFunctionsToRunInSpan } from "@services/tracing" import { baseLogger } from "@services/logger" import { RepositoryError } from "@domain/errors" -import { toBridgeCustomerId } from "@domain/primitives/bridge" +import { toBridgeCustomerId, toBridgeVirtualAccountId } from "@domain/primitives/bridge" import { getBalanceForWallet } from "@app/wallets/get-balance-for-wallet" import { USDTAmount, WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" @@ -30,9 +30,11 @@ import { BridgeAccountLevelError, BridgeKycPendingError, BridgeKycRejectedError, + BridgeKycOffboardedError, BridgeCustomerNotFoundError, } from "./errors" -import BridgeApiClient from "./client" +import BridgeApiClient, { BridgeClient } from "./client" +import { BridgeVirtualAccount } from "@services/mongoose/schema" // ============ Types ============ @@ -70,7 +72,7 @@ type WithdrawalResult = { createdAt: string } -type KycStatusResult = "pending" | "approved" | "rejected" | null +type KycStatusResult = "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" | null type VirtualAccountResult = { bridgeVirtualAccountId: string @@ -199,7 +201,7 @@ const initiateKyc = async ({ const updateResult = await AccountsRepository().updateBridgeFields(accountId, { bridgeCustomerId: customerId, - bridgeKycStatus: "pending", + bridgeKycStatus: "open", }) if (updateResult instanceof Error) { @@ -216,6 +218,14 @@ const initiateKyc = async ({ const bridgeError = error as { statusCode?: number; response?: { existing_kyc_link?: { kyc_link: string; customer_id: string; tos_link: string } } } if (bridgeError?.statusCode === 400 && bridgeError.response?.existing_kyc_link) { + + // store the customer id and the kyc status + const customerId = toBridgeCustomerId(bridgeError.response.existing_kyc_link.customer_id) + const updateResult = await AccountsRepository().updateBridgeFields(accountId, { + bridgeCustomerId: customerId, + bridgeKycStatus: "not_started", + }) + return { kycLink: bridgeError.response.existing_kyc_link.kyc_link, customerId: bridgeError.response.existing_kyc_link.customer_id, @@ -252,22 +262,53 @@ const createVirtualAccount = async ( const account = await checkAccountLevel(accountId) if (account instanceof Error) return account + + const PENDING_BRIDGE_STATUSES = new Set([ + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", + ]) + try { - const customerId = account.bridgeCustomerId - if (!customerId) { + + if (!account.bridgeCustomerId) { return new BridgeCustomerNotFoundError( "Account has no Bridge customer ID. Complete KYC first.", ) } + const customerId = toBridgeCustomerId(account.bridgeCustomerId) + if (!customerId && !account.bridgeKycStatus) { + return new BridgeCustomerNotFoundError( + "Account has no Bridge customer ID. Complete KYC first.", + ) + } + + const customer = await BridgeApiClient.getCustomer(customerId); + + if (customer instanceof Error) { + baseLogger.error( + { accountId, error: customer }, + "Failed to retrieve Bridge customer status" + ) + return customer + } + + let kycStatus = customer.status + // Check KYC status - if (account.bridgeKycStatus === "pending") { - return new BridgeKycPendingError() + if (kycStatus === "offboarded") { + return new BridgeKycOffboardedError() } - if (account.bridgeKycStatus === "rejected") { + if (kycStatus === "rejected") { return new BridgeKycRejectedError() } - if (account.bridgeKycStatus !== "approved") { + if (PENDING_BRIDGE_STATUSES.has(kycStatus!) || kycStatus as string === "open") { + return new BridgeKycPendingError() + } + if (kycStatus !== "active" && kycStatus as string !== "approved") { return new BridgeKycPendingError("KYC not yet completed") } @@ -310,12 +351,7 @@ const createVirtualAccount = async ( ethereumAddress = receiveInfo.data.address } - // Deterministic key so Bridge deduplicates on their side if two calls race past - // the check above before either has written to the repo. - const vaIdempotencyKey = crypto - .createHash("sha256") - .update(`va:${accountId}`) - .digest("hex") + const vaIdempotencyKey = `${accountId}:${crypto.randomUUID()}` // Create Bridge virtual account const virtualAccount = await BridgeApiClient.createVirtualAccount( @@ -593,14 +629,123 @@ const getKycStatus = async (accountId: AccountId): Promise va.destination.address === account.bridgeEthereumAddress) + + if (relatedVa?.status === "activated") { + + // if there's a related VA on Bridge side but it's not in our repo, create it in our repo to keep them in sync + if (existingVa instanceof RepositoryError) { + const repoResult = await BridgeAccountsRepo.createVirtualAccount({ + accountId: accountId as string, + bridgeVirtualAccountId: relatedVa.id, + bankName: relatedVa.source_deposit_instructions.bank_name || "", + routingNumber: relatedVa.source_deposit_instructions.bank_routing_number || "", + accountNumber: relatedVa.source_deposit_instructions.bank_account_number || "", + accountNumberLast4: relatedVa.source_deposit_instructions.bank_account_number?.slice(-4) || "", + }) + if (repoResult instanceof Error) { + baseLogger.error( + { accountId, operation: "getKycStatus", error: repoResult }, + "Failed to create virtual account in repo after KYC approval", + ) + } else { + baseLogger.info( + { accountId, operation: "getKycStatus", virtualAccountId: relatedVa.id }, + "Proactively updated virtual account in repo after KYC approval", + ) + } + } + } else { + + const vaResult = await createVirtualAccount(accountId) + if (vaResult instanceof Error) { + baseLogger.error( + { accountId, operation: "getKycStatus", error: vaResult }, + "Failed to create virtual account after KYC approval", + ) + } else { + baseLogger.info( + { accountId, operation: "getKycStatus", virtualAccountId: vaResult.virtualAccountId }, + "Proactively created virtual account after KYC approval", + ) + } + } + } + + baseLogger.info( + { accountId, operation: "getKycStatus", kycStatus: kycStatus }, + "Bridge operation completed", + ) + + return kycStatus + } catch (error) { + baseLogger.error( + { accountId, operation: "getKycStatus", error }, + "Bridge operation failed", + ) + return error instanceof Error ? error : new Error(String(error)) + } + + } /** @@ -634,6 +779,27 @@ const getVirtualAccount = async ( return null } + // check if the virtual account still exists on Bridge side - if not, delete it from our repo and return null + const bridgeVa = await BridgeApiClient.getVirtualAccount(account.bridgeCustomerId!, toBridgeVirtualAccountId(virtualAccount.bridgeVirtualAccountId!)) + + if (bridgeVa instanceof Error) { + return new BridgeError(`Failed to retrieve virtual account from Bridge: ${bridgeVa.message}`) + } + + // check if the virtual account is still activated on Bridge side - if not, delete it from our repo and return null + if (bridgeVa.status !== "activated") { + + // delete the virtual account from our repo since it's no longer valid + + const deleteResult = await BridgeVirtualAccount.deleteOne({ + bridgeVirtualAccountId: virtualAccount.bridgeVirtualAccountId! as string + }) + + + return null + } + + const result: VirtualAccountResult = { bridgeVirtualAccountId: virtualAccount.bridgeVirtualAccountId!, bankName: virtualAccount.bankName, diff --git a/src/services/bridge/index.types.d.ts b/src/services/bridge/index.types.d.ts index 430e4b601..728ee499e 100644 --- a/src/services/bridge/index.types.d.ts +++ b/src/services/bridge/index.types.d.ts @@ -48,7 +48,20 @@ interface BridgeTransfer { readonly destinationAccountId: BridgeVirtualAccountId | BridgeExternalAccountId readonly amount: number readonly currency: string - readonly status: "pending" | "processing" | "completed" | "failed" | "cancelled" + readonly status: + | "awaiting_funds" + | "in_review" + | "funds_received" + | "payment_submitted" + | "payment_processed" + | "undeliverable" + | "returned" + | "refund_in_flight" + | "refunded" + | "refund_failed" + | "missing_return_policy" + | "error" + | "canceled" readonly description?: string readonly createdAt: string readonly updatedAt: string @@ -95,21 +108,43 @@ interface BridgeDepositCompletedEvent extends BridgeWebhookEvent { interface BridgeTransferCompletedEvent extends BridgeWebhookEvent { readonly type: "transfer.completed" readonly data: { - readonly transferId: BridgeTransferId + readonly transfer_id: BridgeTransferId readonly customerId: BridgeCustomerId - readonly amount: number + readonly state: "payment_processed" + readonly amount: string + readonly currency: string + } +} + +interface BridgeTransferPaymentProcessedEvent extends BridgeWebhookEvent { + readonly type: "transfer.payment_processed" + readonly data: { + readonly transfer_id: BridgeTransferId + readonly customerId: BridgeCustomerId + readonly state: "payment_processed" + readonly amount: string readonly currency: string - readonly completedAt: string } } interface BridgeTransferFailedEvent extends BridgeWebhookEvent { readonly type: "transfer.failed" readonly data: { - readonly transferId: BridgeTransferId + readonly transfer_id: BridgeTransferId readonly customerId: BridgeCustomerId - readonly reason: string - readonly failedAt: string + readonly state: + | "undeliverable" + | "returned" + | "refunded" + | "refund_in_flight" + | "refund_failed" + | "missing_return_policy" + | "error" + | "canceled" + readonly reason?: string + readonly return_reason?: string + readonly amount: string + readonly currency: string } } @@ -118,4 +153,5 @@ type BridgeWebhookEventType = | BridgeKycRejectedEvent | BridgeDepositCompletedEvent | BridgeTransferCompletedEvent + | BridgeTransferPaymentProcessedEvent | BridgeTransferFailedEvent diff --git a/src/services/bridge/reconciliation.ts b/src/services/bridge/reconciliation.ts index 891229e10..851ebce90 100644 --- a/src/services/bridge/reconciliation.ts +++ b/src/services/bridge/reconciliation.ts @@ -1,10 +1,10 @@ import { baseLogger } from "@services/logger" -import { findIbexCryptoReceiveLogsSince } from "@services/mongoose/ibex-crypto-receive-log" +import { findIbexCryptoReceivesSince } from "@services/mongoose/ibex-crypto-receive-log" import { upsertBridgeReconciliationOrphan, resolveOrphansByTxHash, } from "@services/mongoose/bridge-reconciliation-orphan" -import { BridgeDepositLog, IbexCryptoReceiveLog } from "@services/mongoose/schema" +import { BridgeDeposits, IbexCryptoReceive } from "@services/mongoose/schema" import { PubSubService } from "@services/pubsub" import { PubSubDefaultTriggers } from "@domain/pubsub" @@ -50,14 +50,14 @@ export const reconcileBridgeAndIbexDeposits = async ({ const now = new Date() const since = new Date(now.getTime() - windowMs) - const bridgeDeposits = (await BridgeDepositLog.find({ + const bridgeDeposits = (await BridgeDeposits.find({ createdAt: { $gte: since, $lte: now }, state: "payment_processed", }) .lean() .exec()) as BridgeDepositLike[] - const ibexReceivesResult = await findIbexCryptoReceiveLogsSince({ since, until: now }) + const ibexReceivesResult = await findIbexCryptoReceivesSince({ since, until: now }) if (ibexReceivesResult instanceof Error) return ibexReceivesResult const ibexReceives = ibexReceivesResult as IbexReceiveLike[] @@ -180,13 +180,13 @@ export const reconcileByTxHash = async ({ try { const [bridgeDeposit, ibexReceive] = await Promise.all([ - BridgeDepositLog.findOne({ + BridgeDeposits.findOne({ destinationTxHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") }, state: "payment_processed", }) .lean() .exec(), - IbexCryptoReceiveLog.findOne({ + IbexCryptoReceive.findOne({ txHash: { $regex: new RegExp(`^${normalizedHash}$`, "i") }, }) .lean() diff --git a/src/services/bridge/webhook-server/routes/deposit.ts b/src/services/bridge/webhook-server/routes/deposit.ts index a87352b1a..c5d02b4a1 100644 --- a/src/services/bridge/webhook-server/routes/deposit.ts +++ b/src/services/bridge/webhook-server/routes/deposit.ts @@ -9,25 +9,17 @@ import { Request, Response } from "express" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" -import { createBridgeDepositLog } from "@services/mongoose/bridge-deposit-log" +import { createBridgeDeposit } from "@services/mongoose/bridge-deposit-log" import { reconcileByTxHash } from "@services/bridge/reconciliation" export const depositHandler = async (req: Request, res: Response) => { const { event_id, event_object } = req.body const { id, state, amount, currency, on_behalf_of, receipt } = event_object ?? {} - if (!id || !event_id) { + if (!id || !event_id || !amount || !on_behalf_of) { return res.status(400).json({ error: "Invalid payload" }) } - // Idempotency: lock on the transfer id + state so each state transition is processed once - const lockKey = `bridge-deposit:${id}:${state}` - const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) - if (lockResult instanceof Error) { - baseLogger.info({ event_id, id, state }, "Duplicate Bridge deposit webhook") - return res.status(200).json({ status: "already_processed" }) - } - try { baseLogger.info( { @@ -48,10 +40,10 @@ export const depositHandler = async (req: Request, res: Response) => { "Bridge deposit event", ) - const depositLog = await createBridgeDepositLog({ + const depositLog = await createBridgeDeposit({ eventId: event_id, transferId: id, - customerId: on_behalf_of ?? "", + customerId: on_behalf_of, state, amount: String(amount), currency, @@ -78,6 +70,15 @@ export const depositHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to persist deposit log" }) } + // Idempotency: lock on event_id after successful DB write — aligns with the DB unique + // constraint on eventId and ensures Bridge retries are not blocked on transient failures + const lockKey = `bridge-deposit:${event_id}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ event_id, id, state }, "Duplicate Bridge deposit webhook") + return res.status(200).json({ status: "already_processed" }) + } + if (state === "payment_processed" && receipt?.destination_tx_hash) { reconcileByTxHash({ txHash: receipt.destination_tx_hash }).catch((err) => baseLogger.error({ err, event_id, id }, "Real-time reconciliation failed"), diff --git a/src/services/bridge/webhook-server/routes/kyc.ts b/src/services/bridge/webhook-server/routes/kyc.ts index 2706b7499..965b38c1f 100644 --- a/src/services/bridge/webhook-server/routes/kyc.ts +++ b/src/services/bridge/webhook-server/routes/kyc.ts @@ -1,6 +1,14 @@ /** * Bridge KYC Webhook Handler - * Handles kyc.approved and kyc.rejected events from Bridge.xyz + * Handles all kyc.* events from Bridge.xyz + * + * Bridge kyc_status → internal bridgeKycStatus mapping: + * not_started → "open" + * incomplete | awaiting_questionnaire | awaiting_ubo + * | under_review | paused → "pending" + * approved → "approved" + * rejected → "rejected" + * offboarded → "offboarded" */ import { Request, Response } from "express" @@ -18,14 +26,6 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(400).json({ error: "Invalid payload" }) } - // Idempotency check using customer_id + event as lock key - const lockKey = `bridge-kyc:${customer_id}:${event_object.kyc_status}:${event_object.id}` - const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) - if (lockResult instanceof Error) { - baseLogger.info({ customer_id, event_id }, "Duplicate Bridge KYC webhook") - return res.status(200).json({ status: "already_processed" }) - } - try { const bridgeCustomerId = toBridgeCustomerId(customer_id) const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId) @@ -37,8 +37,55 @@ export const kycHandler = async (req: Request, res: Response) => { return res.status(503).json({ error: "Account not ready" }) } + // Idempotency check — acquire lock after account is found so 503 retries are not blocked + const lockKey = `bridge-kyc:${event_id}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ customer_id, event_id }, "Duplicate Bridge KYC webhook") + return res.status(200).json({ status: "already_processed" }) + } + + const PENDING_BRIDGE_STATUSES = new Set([ + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "paused", + ]) + // Update KYC status based on event - if (kyc_status === "approved") { + if (kyc_status === "not_started") { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "not_started", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.info({ accountId: account.id, customer_id }, "Bridge KYC not started") + } else if (PENDING_BRIDGE_STATUSES.has(kyc_status)) { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: kyc_status, + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.info( + { accountId: account.id, customer_id, kyc_status }, + "Bridge KYC moved to pending", + ) + } else if (kyc_status === "approved") { const result = await AccountsRepository().updateBridgeFields(account.id, { bridgeKycStatus: "approved", }) @@ -86,6 +133,23 @@ export const kycHandler = async (req: Request, res: Response) => { }, "Bridge KYC rejected", ) + } else if (kyc_status === "offboarded") { + const result = await AccountsRepository().updateBridgeFields(account.id, { + bridgeKycStatus: "offboarded", + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, error: result }, + "Failed to update KYC status", + ) + return res.status(500).json({ error: "Failed to update status" }) + } + + baseLogger.warn( + { accountId: account.id, customer_id }, + "Bridge KYC offboarded", + ) } return res.status(200).json({ status: "success" }) diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts index 6316e2c8c..33b499bd9 100644 --- a/src/services/bridge/webhook-server/routes/replay.ts +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -6,7 +6,12 @@ import { BridgeConfig } from "@config" import { baseLogger } from "@services/logger" -import { createBridgeReplayLog } from "@services/mongoose/bridge-replay-log" +import { createBridgeReplay } from "@services/mongoose/bridge-replay-log" + +import { + isOutboundBridgeWithdrawal, + transferReplayEventTypeForStatus, +} from "../transfer-direction" import { depositHandler } from "./deposit" import { kycHandler } from "./kyc" @@ -41,18 +46,35 @@ const toRouteKey = (bridgeEventType: string): RouteKey | null => { const resolveReplayEventType = ({ eventType, eventObjectStatus, + eventObject, }: { eventType: string eventObjectStatus?: string + eventObject?: Record }): string => { const routeFromEventType = toRouteKey(eventType) if (routeFromEventType) return eventType if (eventObjectStatus && DEPOSIT_EVENT_TYPES.has(eventObjectStatus)) { + if (isOutboundBridgeWithdrawal(eventObject)) { + const transferEvent = transferReplayEventTypeForStatus(eventObjectStatus) + if (transferEvent) return transferEvent + } return eventObjectStatus } - if (eventObjectStatus === "approved" || eventObjectStatus === "rejected") { + const KYC_BRIDGE_STATUSES = new Set([ + "not_started", + "incomplete", + "awaiting_questionnaire", + "awaiting_ubo", + "under_review", + "approved", + "rejected", + "paused", + "offboarded", + ]) + if (eventObjectStatus && KYC_BRIDGE_STATUSES.has(eventObjectStatus)) { return `kyc.${eventObjectStatus}` } @@ -82,6 +104,8 @@ const toHandlerBody = ({ state: eventObject.state, amount: eventObject.amount, currency: eventObject.currency, + reason: eventObject.reason, + return_reason: eventObject.return_reason, }, } } @@ -138,10 +162,13 @@ export const replayHandler = async (req: Request, res: Response) => { return res.status(400).json({ error: "event_object must be an object" }) } + const eventObjectTyped = event_object as Record + const normalizedEventType = resolveReplayEventType({ eventType: event_type, eventObjectStatus: typeof event_object_status === "string" ? event_object_status : undefined, + eventObject: eventObjectTyped, }) const routeKey = toRouteKey(normalizedEventType) @@ -149,8 +176,6 @@ export const replayHandler = async (req: Request, res: Response) => { if (!routeKey) { return res.status(400).json({ error: "Unsupported event_type for replay" }) } - - const eventObjectTyped = event_object as Record const eventId: string = typeof event_id === "string" ? event_id @@ -173,7 +198,7 @@ export const replayHandler = async (req: Request, res: Response) => { } if (dry_run) { - const dryRunLog = await createBridgeReplayLog({ + const dryRunLog = await createBridgeReplay({ ...logBase, httpStatus: 0, httpResponse: { dry_run: true }, @@ -217,7 +242,7 @@ export const replayHandler = async (req: Request, res: Response) => { await HANDLERS[routeKey](fakeReq, fakeRes) - const logResult = await createBridgeReplayLog({ + const logResult = await createBridgeReplay({ ...logBase, httpStatus: handlerStatus, httpResponse: handlerBody, diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts index 0863a074b..a24a32fdc 100644 --- a/src/services/bridge/webhook-server/routes/transfer.ts +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -4,38 +4,88 @@ */ import { Request, Response } from "express" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" import { LockService } from "@services/lock" import { baseLogger } from "@services/logger" import { toBridgeTransferId } from "@domain/primitives/bridge" +const TERMINAL_FAILURE_STATES = new Set([ + "undeliverable", + "returned", + "refunded", + "refund_failed", + "missing_return_policy", + "error", + "canceled", +]) + +const TRANSIENT_STATES = new Set(["refund_in_flight"]) + +const transferLockKey = ( + transferId: string, + event: string, + state: string | undefined, +): IdempotencyKey => + `bridge-transfer:${transferId}:${event}:${state ?? ""}` as IdempotencyKey + +const markProcessed = async ( + transferId: string, + event: string, + state: string | undefined, +): Promise<"success" | "already_processed"> => { + const lockResult = await LockService().lockIdempotencyKey( + transferLockKey(transferId, event, state), + ) + if (lockResult instanceof Error) return "already_processed" + return "success" +} + export const transferHandler = async (req: Request, res: Response) => { const { event, data } = req.body - const { transfer_id, state, amount, currency } = data + const { transfer_id, state, amount, currency, reason, return_reason } = data if (!transfer_id || !event) { return res.status(400).json({ error: "Invalid payload" }) } - // Idempotency check using transfer_id as lock key - const lockKey = `bridge-transfer:${transfer_id}` - const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) - if (lockResult instanceof Error) { - baseLogger.info({ transfer_id }, "Duplicate Bridge transfer webhook") - return res.status(200).json({ status: "already_processed" }) - } - try { const bridgeTransferId = toBridgeTransferId(transfer_id) - // Update withdrawal status based on event - if (event === "transfer.completed") { + if (TRANSIENT_STATES.has(state)) { + baseLogger.info( + { transfer_id, state, event }, + "Bridge transfer in transient state — awaiting terminal event", + ) + return res.status(200).json({ status: "ignored_transient_state" }) + } + + const isCompletion = + event === "transfer.completed" || + event === "transfer.payment_processed" || + state === "payment_processed" + + const isFailure = event === "transfer.failed" || TERMINAL_FAILURE_STATES.has(state) + + if (!isCompletion && !isFailure) { + baseLogger.info({ transfer_id, state, event }, "Bridge transfer event not handled") + return res.status(200).json({ status: "ignored" }) + } + + if (isCompletion) { const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, "completed", ) if (result instanceof Error) { + if (result.message === BridgeAccountsRepo.BRIDGE_WITHDRAWAL_NOT_FOUND) { + baseLogger.warn( + { transfer_id }, + "Withdrawal not found for transfer webhook — Bridge may retry after bridgeTransferId is written", + ) + return res.status(503).json({ error: "Withdrawal not ready" }) + } baseLogger.error( { transfer_id, error: result }, "Failed to update withdrawal status", @@ -43,23 +93,56 @@ export const transferHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } + const lockStatus = await markProcessed(transfer_id, event, state) + if (lockStatus === "already_processed") { + return res.status(200).json({ status: "already_processed" }) + } + baseLogger.info( { transfer_id, + state, amount, currency, }, "Bridge transfer completed", ) - // TODO: Send push notification to user - } else if (event === "transfer.failed") { + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: result.accountId, + amount: result.amount, + currency: result.currency, + outcome: "completed", + }) + } else if (isFailure) { + const failureReason = + state === "refund_failed" + ? (return_reason as string | undefined) + : event === "transfer.failed" + ? (reason as string | undefined) + : ((reason as string | undefined) ?? (return_reason as string | undefined)) + const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, "failed", + failureReason, ) if (result instanceof Error) { + if (result.message === BridgeAccountsRepo.BRIDGE_WITHDRAWAL_NOT_FOUND) { + baseLogger.warn( + { transfer_id }, + "Withdrawal not found for transfer webhook — Bridge may retry after bridgeTransferId is written", + ) + return res.status(503).json({ error: "Withdrawal not ready" }) + } + if (result.message.startsWith("Withdrawal already ")) { + baseLogger.info( + { transfer_id, state, error: result.message }, + "Ignoring Bridge transfer failure — withdrawal already terminal", + ) + return res.status(200).json({ status: "already_terminal" }) + } baseLogger.error( { transfer_id, error: result }, "Failed to update withdrawal status", @@ -67,17 +150,30 @@ export const transferHandler = async (req: Request, res: Response) => { return res.status(500).json({ error: "Failed to update status" }) } + const lockStatus = await markProcessed(transfer_id, event, state) + if (lockStatus === "already_processed") { + return res.status(200).json({ status: "already_processed" }) + } + baseLogger.warn( { transfer_id, state, amount, currency, + reason, + return_reason, }, "Bridge transfer failed", ) - // TODO: Send push notification to user + await sendBridgeWithdrawalNotificationBestEffort({ + accountId: result.accountId, + amount: result.amount, + currency: result.currency, + outcome: "failed", + failureReason: result.failureReason ?? failureReason, + }) } return res.status(200).json({ status: "success" }) diff --git a/src/services/bridge/webhook-server/transfer-direction.ts b/src/services/bridge/webhook-server/transfer-direction.ts new file mode 100644 index 000000000..6e785d0ea --- /dev/null +++ b/src/services/bridge/webhook-server/transfer-direction.ts @@ -0,0 +1,29 @@ +/** + * Distinguishes Bridge outbound withdrawals (USDT → ACH) from inbound deposits + * when replaying status_transitioned events that share the same state names. + */ +export const isOutboundBridgeWithdrawal = ( + eventObject: Record | undefined, +): boolean => { + if (!eventObject) return false + const source = eventObject.source as Record | undefined + const destination = eventObject.destination as Record | undefined + return source?.payment_rail === "ethereum" && destination?.payment_rail === "ach" +} + +const OUTBOUND_WITHDRAWAL_REPLAY_STATUSES = new Set([ + "payment_processed", + "undeliverable", + "returned", + "refunded", + "refund_failed", + "missing_return_policy", + "error", + "canceled", +]) + +export const transferReplayEventTypeForStatus = (status: string): string | null => { + if (!OUTBOUND_WITHDRAWAL_REPLAY_STATUSES.has(status)) return null + if (status === "payment_processed") return "transfer.payment_processed" + return "transfer.failed" +} diff --git a/src/services/ibex/webhook-server/routes/crypto-receive.ts b/src/services/ibex/webhook-server/routes/crypto-receive.ts index 83282f9a5..d03e72676 100644 --- a/src/services/ibex/webhook-server/routes/crypto-receive.ts +++ b/src/services/ibex/webhook-server/routes/crypto-receive.ts @@ -1,6 +1,6 @@ import express, { Request, Response } from "express" import { AccountsRepository } from "@services/mongoose/accounts" -import { createIbexCryptoReceiveLog } from "@services/mongoose/ibex-crypto-receive-log" +import { createIbexCryptoReceive } from "@services/mongoose/ibex-crypto-receive-log" import { listWalletsByAccountId } from "@app/wallets" import { WalletCurrency, USDTAmount } from "@domain/shared" import { baseLogger } from "@services/logger" @@ -49,7 +49,7 @@ const cryptoReceiveHandler = async (req: Request, res: Response) => { return { status: "error", code: "account_not_found" } as CryptoReceiveResult } - const ibexLog = await createIbexCryptoReceiveLog({ + const ibexLog = await createIbexCryptoReceive({ txHash: String(tx_hash), address: String(address), amount: String(amount), diff --git a/src/services/mongoose/accounts.ts b/src/services/mongoose/accounts.ts index 853a7a93b..4cc8fb43f 100644 --- a/src/services/mongoose/accounts.ts +++ b/src/services/mongoose/accounts.ts @@ -176,7 +176,7 @@ export const AccountsRepository = (): IAccountsRepository => { id: AccountId, fields: { bridgeCustomerId?: BridgeCustomerId - bridgeKycStatus?: "pending" | "approved" | "rejected" + bridgeKycStatus?: "open" | "not_started" | "incomplete" | "awaiting_questionnaire" | "awaiting_ubo" | "under_review" | "paused" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string }, ): Promise => { @@ -291,6 +291,6 @@ const translateToAccount = (result: AccountRecord): Account => ({ kratosUserId: result.kratosUserId as UserId, displayCurrency: (result.displayCurrency || UsdDisplayCurrency) as DisplayCurrency, bridgeCustomerId: result.bridgeCustomerId as BridgeCustomerId | undefined, - bridgeKycStatus: result.bridgeKycStatus, + bridgeKycStatus: (result.bridgeKycStatus === "pending" ? "open" : result.bridgeKycStatus) as Account["bridgeKycStatus"], bridgeEthereumAddress: result.bridgeEthereumAddress, }) diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index 234304c2c..c045295be 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -95,6 +95,18 @@ export const updateExternalAccountStatus = async ( // ============ Withdrawals ============ +export const BRIDGE_WITHDRAWAL_NOT_FOUND = "Withdrawal not found" + +export const BRIDGE_FAILURE_REASON_MAX_LENGTH = 512 + +export const truncateBridgeFailureReason = ( + reason: string | undefined, +): string | undefined => { + if (reason === undefined) return undefined + if (reason.length <= BRIDGE_FAILURE_REASON_MAX_LENGTH) return reason + return `${reason.slice(0, BRIDGE_FAILURE_REASON_MAX_LENGTH - 3)}...` +} + export const createWithdrawal = async (data: { accountId: string bridgeTransferId?: string @@ -171,14 +183,29 @@ export const findWithdrawalsByAccountId = async (accountId: string) => { export const updateWithdrawalStatus = async ( bridgeTransferId: BridgeTransferId, status: "pending" | "completed" | "failed", + failureReason?: string, ) => { try { + const update: Record = { status, updatedAt: new Date() } + const truncatedReason = truncateBridgeFailureReason(failureReason) + if (truncatedReason !== undefined) update.failureReason = truncatedReason + const record = await BridgeWithdrawal.findOneAndUpdate( - { bridgeTransferId }, - { status, updatedAt: new Date() }, + { bridgeTransferId, status: "pending" }, + update, { new: true }, ) - return record || new RepositoryError("Withdrawal not found") + if (record) return record + + const existing = await BridgeWithdrawal.findOne({ bridgeTransferId }) + if (!existing) return new RepositoryError(BRIDGE_WITHDRAWAL_NOT_FOUND) + + // Idempotent: duplicate webhook after we already reached this terminal status. + if (existing.status === status) return existing + + return new RepositoryError( + `Withdrawal already ${existing.status}, cannot transition to ${status}`, + ) } catch (error) { return new RepositoryError(String(error)) } diff --git a/src/services/mongoose/bridge-deposit-log.ts b/src/services/mongoose/bridge-deposit-log.ts index f71f37abd..953cd8a3d 100644 --- a/src/services/mongoose/bridge-deposit-log.ts +++ b/src/services/mongoose/bridge-deposit-log.ts @@ -1,6 +1,6 @@ -import { BridgeDepositLog } from "./schema" +import { BridgeDeposits } from "./schema" -export const createBridgeDepositLog = async (data: { +export const createBridgeDeposit = async (data: { eventId: string transferId: string customerId: string @@ -14,7 +14,7 @@ export const createBridgeDepositLog = async (data: { destinationTxHash?: string }): Promise<{ id: string } | Error> => { try { - const log = await BridgeDepositLog.create(data) + const log = await BridgeDeposits.create(data) return { id: log._id.toString() } } catch (error) { return error instanceof Error ? error : new Error(String(error)) diff --git a/src/services/mongoose/bridge-replay-log.ts b/src/services/mongoose/bridge-replay-log.ts index d1842be8c..361994991 100644 --- a/src/services/mongoose/bridge-replay-log.ts +++ b/src/services/mongoose/bridge-replay-log.ts @@ -1,6 +1,6 @@ -import { BridgeReplayLog } from "./schema" +import { BridgeReplay } from "./schema" -export const createBridgeReplayLog = async (data: { +export const createBridgeReplay = async (data: { eventId: string eventType: string eventPayload: Record @@ -14,7 +14,7 @@ export const createBridgeReplayLog = async (data: { dryRun?: boolean }): Promise<{ id: string } | Error> => { try { - const log = await BridgeReplayLog.create(data) + const log = await BridgeReplay.create(data) return { id: log._id.toString() } } catch (error) { @@ -22,7 +22,7 @@ export const createBridgeReplayLog = async (data: { } } -export const findBridgeReplayLogs = async (filter?: { +export const findBridgeReplays = async (filter?: { eventType?: string dryRun?: boolean limit?: number @@ -32,7 +32,7 @@ export const findBridgeReplayLogs = async (filter?: { if (filter?.eventType !== undefined) queryFilter.eventType = filter.eventType if (filter?.dryRun !== undefined) queryFilter.dryRun = filter.dryRun - return await BridgeReplayLog.find(queryFilter) + return await BridgeReplay.find(queryFilter) .sort({ replayedAt: -1 }) .limit(filter?.limit ?? 100) .lean() diff --git a/src/services/mongoose/ibex-crypto-receive-log.ts b/src/services/mongoose/ibex-crypto-receive-log.ts index e657c9e89..490330f09 100644 --- a/src/services/mongoose/ibex-crypto-receive-log.ts +++ b/src/services/mongoose/ibex-crypto-receive-log.ts @@ -1,6 +1,6 @@ -import { IbexCryptoReceiveLog } from "./schema" +import { IbexCryptoReceive } from "./schema" -export const createIbexCryptoReceiveLog = async (data: { +export const createIbexCryptoReceive = async (data: { txHash: string address: string amount: string @@ -9,7 +9,7 @@ export const createIbexCryptoReceiveLog = async (data: { accountId?: string }): Promise<{ id: string } | Error> => { try { - const log = await IbexCryptoReceiveLog.findOneAndUpdate( + const log = await IbexCryptoReceive.findOneAndUpdate( { txHash: data.txHash }, { ...data, receivedAt: new Date() }, { upsert: true, new: true, setDefaultsOnInsert: true }, @@ -21,7 +21,7 @@ export const createIbexCryptoReceiveLog = async (data: { } } -export const findIbexCryptoReceiveLogsSince = async ({ +export const findIbexCryptoReceivesSince = async ({ since, until = new Date(), }: { @@ -40,7 +40,7 @@ export const findIbexCryptoReceiveLogsSince = async ({ | Error > => { try { - return await IbexCryptoReceiveLog.find({ + return await IbexCryptoReceive.find({ receivedAt: { $gte: since, $lte: until }, }) .sort({ receivedAt: -1 }) diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 15a1fd2f5..ef7b0ebf9 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -48,6 +48,7 @@ interface IBridgeWithdrawalRecord { amount: string currency: string status: "pending" | "completed" | "failed" + failureReason?: string externalAccountId: string createdAt: Date updatedAt: Date @@ -338,7 +339,7 @@ const AccountSchema = new Schema( }, bridgeKycStatus: { type: String, - enum: ["pending", "approved", "rejected"], + enum: ["open", "not_started", "incomplete", "awaiting_questionnaire", "awaiting_ubo", "under_review", "paused", "approved", "rejected", "offboarded"], required: false, }, bridgeEthereumAddress: { @@ -653,6 +654,7 @@ const BridgeWithdrawalSchema = new Schema({ amount: { type: String, required: true }, currency: { type: String, required: true }, status: { type: String, enum: ["pending", "completed", "failed"], default: "pending" }, + failureReason: { type: String, maxlength: 512 }, externalAccountId: { type: String, required: true }, createdAt: { type: Date, default: Date.now }, updatedAt: { type: Date, default: Date.now }, @@ -670,7 +672,7 @@ BridgeWithdrawalSchema.index( }, ) -const BridgeDepositLogSchema = new Schema({ +const BridgeDepositsSchema = new Schema({ eventId: { type: String, required: true, unique: true }, transferId: { type: String, required: true }, customerId: { type: String, required: true }, @@ -685,12 +687,12 @@ const BridgeDepositLogSchema = new Schema({ createdAt: { type: Date, default: Date.now }, }) -BridgeDepositLogSchema.index({ transferId: 1 }) -BridgeDepositLogSchema.index({ customerId: 1, createdAt: -1 }) +BridgeDepositsSchema.index({ transferId: 1 }) +BridgeDepositsSchema.index({ customerId: 1, createdAt: -1 }) -export const BridgeDepositLog = mongoose.model("BridgeDepositLog", BridgeDepositLogSchema) +export const BridgeDeposits = mongoose.model("BridgeDeposits", BridgeDepositsSchema) -const IbexCryptoReceiveLogSchema = new Schema({ +const IbexCryptoReceiveSchema = new Schema({ txHash: { type: String, required: true, unique: true }, address: { type: String, required: true }, amount: { type: String, required: true }, @@ -700,12 +702,12 @@ const IbexCryptoReceiveLogSchema = new Schema({ receivedAt: { type: Date, default: Date.now }, }) -IbexCryptoReceiveLogSchema.index({ receivedAt: -1 }) -IbexCryptoReceiveLogSchema.index({ address: 1, receivedAt: -1 }) +IbexCryptoReceiveSchema.index({ receivedAt: -1 }) +IbexCryptoReceiveSchema.index({ address: 1, receivedAt: -1 }) -export const IbexCryptoReceiveLog = mongoose.model( - "IbexCryptoReceiveLog", - IbexCryptoReceiveLogSchema, +export const IbexCryptoReceive = mongoose.model( + "IbexCryptoReceive", + IbexCryptoReceiveSchema, ) const BridgeReconciliationOrphanSchema = new Schema({ @@ -742,7 +744,7 @@ export const BridgeReconciliationOrphan = mongoose.model( BridgeReconciliationOrphanSchema, ) -const BridgeReplayLogSchema = new Schema({ +const BridgeReplaySchema = new Schema({ eventId: { type: String, required: true }, eventType: { type: String, required: true }, eventPayload: { type: Schema.Types.Mixed, required: true }, @@ -756,11 +758,11 @@ const BridgeReplayLogSchema = new Schema({ dryRun: { type: Boolean, required: true, default: false }, }) -BridgeReplayLogSchema.index({ eventId: 1 }) -BridgeReplayLogSchema.index({ replayedAt: -1 }) -BridgeReplayLogSchema.index({ eventType: 1, replayedAt: -1 }) +BridgeReplaySchema.index({ eventId: 1 }) +BridgeReplaySchema.index({ replayedAt: -1 }) +BridgeReplaySchema.index({ eventType: 1, replayedAt: -1 }) -export const BridgeReplayLog = mongoose.model("BridgeReplayLog", BridgeReplayLogSchema) +export const BridgeReplay = mongoose.model("BridgeReplay", BridgeReplaySchema) export const BridgeVirtualAccount = mongoose.model( "BridgeVirtualAccount", diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index 51c86757c..39d5a3da9 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -96,7 +96,7 @@ interface AccountRecord { // Bridge integration: bridgeCustomerId?: string - bridgeKycStatus?: "pending" | "approved" | "rejected" + bridgeKycStatus?: "open" | "pending" | "approved" | "rejected" | "offboarded" bridgeEthereumAddress?: string // mongoose in-built functions diff --git a/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts new file mode 100644 index 000000000..d00d89dfa --- /dev/null +++ b/test/flash/unit/app/bridge/send-withdrawal-notification.spec.ts @@ -0,0 +1,122 @@ +import { sendBridgeWithdrawalNotification } from "@app/bridge/send-withdrawal-notification" +import { AccountsRepository } from "@services/mongoose/accounts" +import { UsersRepository } from "@services/mongoose/users" +import { + PushNotificationsService, + SendFilteredPushNotificationStatus, +} from "@services/notifications/push-notifications" +import { getI18nInstance } from "@config" + +jest.mock("@services/mongoose/accounts", () => ({ + AccountsRepository: jest.fn(), +})) + +jest.mock("@services/mongoose/users", () => ({ + UsersRepository: jest.fn(), +})) + +jest.mock("@services/notifications/push-notifications", () => ({ + PushNotificationsService: jest.fn(), + SendFilteredPushNotificationStatus: { + Filtered: "filtered", + Sent: "sent", + }, +})) + +jest.mock("@app/users/remove-device-tokens", () => ({ + removeDeviceTokens: jest.fn(), +})) + +jest.mock("@config", () => { + const mockI18n = { + __: jest.fn().mockImplementation(({ phrase }, options) => `${phrase} ${JSON.stringify(options)}`), + } + return { + getI18nInstance: jest.fn(() => mockI18n), + } +}) + +describe("sendBridgeWithdrawalNotification", () => { + const accountId = "507f1f77bcf86cd799439011" + const mockAccount = { + id: accountId, + kratosUserId: "user-id", + notificationSettings: { push: { enabled: true, disabledCategories: [] } }, + } + const mockUser = { + deviceTokens: ["token-1"], + language: "en", + } + + const sendFilteredNotification = jest.fn().mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Sent, + }) + const mockI18n = getI18nInstance() + + beforeEach(() => { + jest.clearAllMocks() + ;(AccountsRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockAccount), + }) + ;(UsersRepository as jest.Mock).mockReturnValue({ + findById: jest.fn().mockResolvedValue(mockUser), + }) + ;(PushNotificationsService as jest.Mock).mockReturnValue({ + sendFilteredNotification, + }) + ;(getI18nInstance as jest.Mock).mockReturnValue(mockI18n) + }) + + it("sends a completed withdrawal notification with Cashout category", async () => { + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "100.00", + currency: "usdt", + outcome: "completed", + }) + + expect(result).toBe(true) + expect(sendFilteredNotification).toHaveBeenCalledWith( + expect.objectContaining({ + deviceTokens: mockUser.deviceTokens, + notificationCategory: "Cashout", + data: expect.objectContaining({ type: "bridge_withdrawal_completed" }), + }), + ) + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ phrase: "notification.bridgeWithdrawal.completed.title" }), + ) + }) + + it("uses bodyWithReason when a failure reason is provided", async () => { + await sendBridgeWithdrawalNotification({ + accountId, + amount: "50.00", + currency: "usdt", + outcome: "failed", + failureReason: "ACH return", + }) + + expect(mockI18n.__).toHaveBeenCalledWith( + expect.objectContaining({ + phrase: "notification.bridgeWithdrawal.failed.bodyWithReason", + }), + expect.objectContaining({ reason: "ACH return" }), + ) + }) + + it("returns true when notification is filtered by user settings", async () => { + sendFilteredNotification.mockResolvedValue({ + status: SendFilteredPushNotificationStatus.Filtered, + }) + + const result = await sendBridgeWithdrawalNotification({ + accountId, + amount: "10.00", + currency: "usdt", + outcome: "completed", + }) + + expect(result).toBe(true) + }) +}) diff --git a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts index ea6c9610b..9b9988b64 100644 --- a/test/flash/unit/services/bridge/webhook-server/replay.spec.ts +++ b/test/flash/unit/services/bridge/webhook-server/replay.spec.ts @@ -176,6 +176,38 @@ describe("replayHandler", () => { ;(ReplayLog.createBridgeReplayLog as jest.Mock).mockResolvedValue({ id: "log-001" }) }) + it("routes outbound withdrawal payment_processed replay to transfer handler", async () => { + ;(transferHandler as jest.Mock).mockImplementation((_req: Request, res: Response) => { + ;(res.status as jest.Mock)(200) + ;(res.json as jest.Mock)({ status: "success" }) + return Promise.resolve(res) + }) + ;(ReplayLog.createBridgeReplayLog as jest.Mock).mockResolvedValue({ id: "log-wd-001" }) + + const res = makeRes() + await replayHandler( + makeReq({ + ...BASE_BODY, + event_type: "updated.status_transitioned", + event_object_status: "payment_processed", + event_object: { + id: "tr-withdraw-001", + state: "payment_processed", + amount: "100.00", + currency: "usd", + source: { payment_rail: "ethereum", currency: "usdt" }, + destination: { payment_rail: "ach", currency: "usd" }, + }, + }), + res, + ) + + expect(transferHandler).toHaveBeenCalledTimes(1) + expect(depositHandler).not.toHaveBeenCalled() + const handlerReq = (transferHandler as jest.Mock).mock.calls[0][0] + expect(handlerReq.body.event).toBe("transfer.payment_processed") + }) + test.each(cases)( "%s is routed to the correct handler", async (eventType, handler) => { diff --git a/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts new file mode 100644 index 000000000..30bb87f31 --- /dev/null +++ b/test/flash/unit/services/bridge/webhook-server/transfer.spec.ts @@ -0,0 +1,189 @@ +jest.mock("@services/lock", () => ({ + LockService: jest.fn(), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +jest.mock("@services/mongoose/bridge-accounts", () => ({ + BRIDGE_WITHDRAWAL_NOT_FOUND: "Withdrawal not found", + updateWithdrawalStatus: jest.fn(), +})) + +jest.mock("@app/bridge/send-withdrawal-notification", () => ({ + sendBridgeWithdrawalNotificationBestEffort: jest.fn().mockResolvedValue(undefined), +})) + +import { Request, Response } from "express" +import { LockService } from "@services/lock" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" +import { sendBridgeWithdrawalNotificationBestEffort } from "@app/bridge/send-withdrawal-notification" +import { transferHandler } from "@services/bridge/webhook-server/routes/transfer" + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn() } as unknown as Response + ;(res.status as jest.Mock).mockReturnValue(res) + ;(res.json as jest.Mock).mockReturnValue(res) + return res +} + +const makeReq = (body: Record) => ({ body }) as unknown as Request + +const WITHDRAWAL_RECORD = { id: "wd-1", status: "pending", bridgeTransferId: "tr-abc" } + +const updateFn = BridgeAccountsRepo.updateWithdrawalStatus as jest.Mock +let lockFn: jest.Mock + +beforeEach(() => { + jest.clearAllMocks() + lockFn = jest.fn().mockResolvedValue({}) + updateFn.mockResolvedValue({ ...WITHDRAWAL_RECORD, status: "completed" }) + ;(LockService as jest.Mock).mockReturnValue({ lockIdempotencyKey: lockFn }) +}) + +describe("transferHandler", () => { + it("returns 503 when withdrawal row is not found yet (retryable)", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue(new RepositoryError("Withdrawal not found")) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.failed", + data: { transfer_id: "tr-early", state: "canceled", reason: "rejected" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(503) + expect(lockFn).not.toHaveBeenCalled() + }) + + it("acquires idempotency lock only after a successful status update", async () => { + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.completed", + data: { transfer_id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(updateFn).toHaveBeenCalled() + expect(lockFn).toHaveBeenCalledWith("bridge-transfer:tr-abc:transfer.completed:payment_processed") + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + }) + + it("does not acquire lock when status update fails", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue(new RepositoryError("mongo error")) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.failed", + data: { transfer_id: "tr-abc", state: "canceled" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(500) + expect(lockFn).not.toHaveBeenCalled() + }) + + it("ignores refund_in_flight without updating withdrawal status", async () => { + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.failed", + data: { transfer_id: "tr-abc", state: "refund_in_flight" }, + }), + res, + ) + + expect(updateFn).not.toHaveBeenCalled() + expect(lockFn).not.toHaveBeenCalled() + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ + status: "ignored_transient_state", + }) + }) + + it("returns already_processed when lock is held after a prior successful run", async () => { + lockFn.mockResolvedValue(new Error("already locked")) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.completed", + data: { transfer_id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ status: "already_processed" }) + }) + + it("sends a push notification after a successful completion", async () => { + updateFn.mockResolvedValue({ + ...WITHDRAWAL_RECORD, + status: "completed", + accountId: "acct-1", + amount: "25.00", + currency: "usdt", + }) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.completed", + data: { transfer_id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(sendBridgeWithdrawalNotificationBestEffort).toHaveBeenCalledWith({ + accountId: "acct-1", + amount: "25.00", + currency: "usdt", + outcome: "completed", + }) + }) + + it("does not send a push notification when the idempotency lock is already held", async () => { + lockFn.mockResolvedValue(new Error("already locked")) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.completed", + data: { transfer_id: "tr-abc", state: "payment_processed" }, + }), + res, + ) + + expect(sendBridgeWithdrawalNotificationBestEffort).not.toHaveBeenCalled() + }) + + it("returns 200 already_terminal when failure arrives after completion", async () => { + const { RepositoryError } = jest.requireActual("@domain/errors") + updateFn.mockResolvedValue( + new RepositoryError("Withdrawal already completed, cannot transition to failed"), + ) + + const res = makeRes() + await transferHandler( + makeReq({ + event: "transfer.failed", + data: { transfer_id: "tr-abc", state: "returned", reason: "ACH return" }, + }), + res, + ) + + expect(res.status as jest.Mock).toHaveBeenCalledWith(200) + expect((res.json as jest.Mock).mock.calls[0][0]).toEqual({ status: "already_terminal" }) + expect(lockFn).not.toHaveBeenCalled() + }) +})