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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/app/bridge/send-deposit-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ const i18n = getI18nInstance()
const formatDepositAmount = (amount: string, currency: string): string =>
`${amount} ${currency.toUpperCase()}`

export type BridgeDepositNotificationOutcome = "received" | "processing" | "completed"

export const sendBridgeDepositNotification = async ({
accountId: accountIdRaw,
amount,
currency,
outcome = "completed",
}: {
accountId: string
amount: string
currency: string
outcome?: BridgeDepositNotificationOutcome
}): Promise<true | ApplicationError> => {
const accountId = checkedToAccountId(accountIdRaw)
if (accountId instanceof Error) return accountId
Expand All @@ -40,7 +44,7 @@ export const sendBridgeDepositNotification = async ({

const locale = getLanguageOrDefault(user.language)
const formattedAmount = formatDepositAmount(amount, currency)
const phraseBase = "notification.bridgeDeposit"
const phraseBase = `notification.bridgeDeposit.${outcome}`

const title = i18n.__({ phrase: `${phraseBase}.title`, locale })
const body = i18n.__(
Expand All @@ -55,7 +59,7 @@ export const sendBridgeDepositNotification = async ({
notificationCategory: FlashNotificationCategories.Payments,
notificationSettings: account.notificationSettings,
data: {
type: "bridge_deposit_completed",
type: `bridge_deposit_${outcome}`,
amount,
currency: currency == "usdt" ? "USD" : currency.toUpperCase(),
},
Expand Down Expand Up @@ -91,7 +95,7 @@ export const sendBridgeDepositNotificationBestEffort = async (

if (result instanceof Error) {
baseLogger.warn(
{ accountId: args.accountId, error: result },
{ accountId: args.accountId, outcome: args.outcome ?? "completed", error: result },
"Failed to send Bridge deposit push notification",
)
}
Expand Down
180 changes: 180 additions & 0 deletions src/app/bridge/send-kyc-notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
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 { toBridgeCustomerId } from "@domain/primitives/bridge"
import BridgeApiClient from "@services/bridge/client"
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 PENDING_KYC_STATUSES = new Set<NonNullable<Account["bridgeKycStatus"]>>([
"open",
"awaiting_questionnaire",
"awaiting_ubo",
"under_review",
"paused",
])

export type BridgeKycNotificationOutcome =
| "approved"
| "rejected"
| "in_review"
| "incomplete"
| "offboarded"

export const isBridgeKycInitiated = (
status: Account["bridgeKycStatus"],
): status is NonNullable<Account["bridgeKycStatus"]> =>
status !== undefined && status !== null

export const toBridgeKycNotificationOutcome = (
status: Account["bridgeKycStatus"],
): BridgeKycNotificationOutcome | null => {
if (!status || status === "not_started") return null
if (status === "approved") return "approved"
if (status === "rejected") return "rejected"
if (status === "offboarded") return "offboarded"
if (status === "incomplete") return "incomplete"
if (PENDING_KYC_STATUSES.has(status)) return "in_review"
return null
}

const formatRejectionReason = (rejectionReasons: unknown[]): string | undefined => {
const reasons = rejectionReasons
.map((reason) => {
if (typeof reason === "string") return reason
if (reason && typeof reason === "object" && "reason" in reason) {
return String((reason as { reason: unknown }).reason)
}
return null
})
.filter((reason): reason is string => Boolean(reason))

return reasons.length > 0 ? reasons.join(", ") : undefined
}

const fetchIncompleteKycLinks = async (
account: Account,
): Promise<{ kycLink?: string; tosLink?: string }> => {
if (!account.bridgeCustomerId) {
baseLogger.warn(
{ accountId: account.id },
"Cannot attach KYC links to incomplete notification: no Bridge customer ID",
)
return {}
}

try {
const customerId = toBridgeCustomerId(account.bridgeCustomerId)
const kycLinkResult = await BridgeApiClient.getKycLatestLink(customerId)
return {
kycLink: kycLinkResult.kyc_link,
tosLink: kycLinkResult.tos_link,
}
} catch (error) {
baseLogger.warn(
{ accountId: account.id, error },
"Failed to fetch KYC links for incomplete notification",
)
return {}
}
}

export const sendBridgeKycNotification = async ({
accountId: accountIdRaw,
outcome,
kycStatus,
rejectionReasons = [],
}: {
accountId: string
outcome: BridgeKycNotificationOutcome
kycStatus: NonNullable<Account["bridgeKycStatus"]>
rejectionReasons?: unknown[]
}): Promise<true | ApplicationError> => {
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 phraseBase = `notification.bridgeKyc.${outcome}`
const rejectionReason = formatRejectionReason(rejectionReasons)

const title = i18n.__({ phrase: `${phraseBase}.title`, locale })
const bodyPhrase =
outcome === "rejected" && rejectionReason
? `${phraseBase}.bodyWithReason`
: `${phraseBase}.body`
const body = i18n.__(
{ phrase: bodyPhrase, locale },
{ reason: rejectionReason ?? "" },
)

const incompleteKycLinks =
outcome === "incomplete" ? await fetchIncompleteKycLinks(account) : {}

const result = await PushNotificationsService().sendFilteredNotification({
deviceTokens: user.deviceTokens,
title,
body,
notificationCategory: FlashNotificationCategories.Payments,
notificationSettings: account.notificationSettings,
data: {
type: `bridge_kyc_${outcome}`,
status: kycStatus,
...(rejectionReason ? { rejectionReason } : {}),
...incompleteKycLinks,
},
})

if (result instanceof NotificationsServiceError) return result

if (result.status === SendFilteredPushNotificationStatus.Filtered) {
return true
}

return true
}

export const sendBridgeKycNotificationBestEffort = async (
args: Parameters<typeof sendBridgeKycNotification>[0],
): Promise<void> => {
const result = await sendBridgeKycNotification(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 KYC push notification",
)
}
}
8 changes: 7 additions & 1 deletion src/app/bridge/send-withdrawal-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ const i18n = getI18nInstance()
const formatWithdrawalAmount = (amount: string, currency: string): string =>
`${amount} ${currency.toUpperCase()}`

export type BridgeWithdrawalNotificationOutcome = "completed" | "failed" | "cancelled"
export type BridgeWithdrawalNotificationOutcome =
| "submitted"
| "usdt_sent"
| "processing"
| "completed"
| "failed"
| "cancelled"

export const sendBridgeWithdrawalNotification = async ({
accountId: accountIdRaw,
Expand Down
49 changes: 47 additions & 2 deletions src/config/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,18 @@
"title": "Cashout"
},
"bridgeDeposit": {
"body": "Your deposit of {{amount}} has been added to your account.",
"title": "Deposit received"
"completed": {
"body": "Your deposit of {{amount}} has been added to your account.",
"title": "Deposit received"
},
"processing": {
"body": "Your deposit of {{amount}} is being sent to your wallet.",
"title": "Deposit processing"
},
"received": {
"body": "We received your deposit of {{amount}}.",
"title": "Deposit received"
}
},
"bridgeWithdrawal": {
"flashFeeNotice": "Shown fees and amounts are estimates. Final fees may differ.",
Expand All @@ -59,6 +69,41 @@
"body": "Your withdrawal of {{amount}} could not be completed.",
"bodyWithReason": "Your withdrawal of {{amount}} could not be completed: {{reason}}.",
"title": "Withdrawal failed"
},
"processing": {
"body": "Your withdrawal of {{amount}} is being processed.",
"title": "Withdrawal in progress"
},
"submitted": {
"body": "Your withdrawal of {{amount}} has been submitted.",
"title": "Withdrawal submitted"
},
"usdt_sent": {
"body": "Your withdrawal of {{amount}} is on its way to your bank.",
"title": "Withdrawal processing"
}
},
"bridgeKyc": {
"approved": {
"body": "Your identity verification has been approved.",
"title": "Identity verified"
},
"in_review": {
"body": "Your identity verification is under review. We'll notify you when it's complete.",
"title": "Verification in progress"
},
"incomplete": {
"body": "Your identity verification is not finished yet. Please complete it to continue.",
"title": "Complete your verification"
},
"offboarded": {
"body": "Your identity verification has been closed.",
"title": "Verification closed"
},
"rejected": {
"body": "Your identity verification was not approved.",
"bodyWithReason": "Your identity verification was not approved: {{reason}}.",
"title": "Verification unsuccessful"
}
}
}
Expand Down
49 changes: 47 additions & 2 deletions src/config/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,18 @@
}
},
"bridgeDeposit": {
"body": "Su depósito de {{amount}} se agregó a su cuenta.",
"title": "Depósito recibido"
"completed": {
"body": "Su depósito de {{amount}} se agregó a su cuenta.",
"title": "Depósito recibido"
},
"processing": {
"body": "Su depósito de {{amount}} se está enviando a su billetera.",
"title": "Depósito en proceso"
},
"received": {
"body": "Recibimos su depósito de {{amount}}.",
"title": "Depósito recibido"
}
},
"bridgeWithdrawal": {
"flashFeeNotice": "Las comisiones y montos mostrados son estimados. Las comisiones finales pueden variar.",
Expand All @@ -55,6 +65,41 @@
"body": "No se pudo completar su retiro de {{amount}}.",
"bodyWithReason": "No se pudo completar su retiro de {{amount}}: {{reason}}.",
"title": "Retiro fallido"
},
"processing": {
"body": "Su retiro de {{amount}} está en proceso.",
"title": "Retiro en progreso"
},
"submitted": {
"body": "Su retiro de {{amount}} ha sido enviado.",
"title": "Retiro enviado"
},
"usdt_sent": {
"body": "Su retiro de {{amount}} está en camino a su banco.",
"title": "Retiro en proceso"
}
},
"bridgeKyc": {
"approved": {
"body": "Su verificación de identidad ha sido aprobada.",
"title": "Identidad verificada"
},
"in_review": {
"body": "Su verificación de identidad está en revisión. Le avisaremos cuando esté completa.",
"title": "Verificación en progreso"
},
"incomplete": {
"body": "Su verificación de identidad aún no está completa. Complétela para continuar.",
"title": "Complete su verificación"
},
"offboarded": {
"body": "Su verificación de identidad ha sido cerrada.",
"title": "Verificación cerrada"
},
"rejected": {
"body": "Su verificación de identidad no fue aprobada.",
"bodyWithReason": "Su verificación de identidad no fue aprobada: {{reason}}.",
"title": "Verificación no exitosa"
}
}
}
Expand Down
Loading