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
3 changes: 2 additions & 1 deletion dev/config/base-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
112 changes: 112 additions & 0 deletions src/app/bridge/send-withdrawal-notification.ts
Original file line number Diff line number Diff line change
@@ -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<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 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<typeof sendBridgeWithdrawalNotification>[0],
): Promise<void> => {
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",
)
}
}
11 changes: 11 additions & 0 deletions src/config/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
11 changes: 11 additions & 0 deletions src/config/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
}
2 changes: 2 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type BridgeConfig = {
apiKey: string
baseUrl: string
minWithdrawalAmount: number
timeoutMs?: number
webhook: BridgeWebhook
}

Expand Down
20 changes: 10 additions & 10 deletions src/domain/accounts/index.types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -135,10 +135,10 @@ type LimitsCheckerFn = (args: LimiterCheckInputs) => Promise<true | LimitsExceed

type LimitsVolumesFn = (walletVolumes: TxBaseVolumeAmount<WalletCurrency>[]) => Promise<
| {
volumeTotalLimit: UsdPaymentAmount
volumeUsed: UsdPaymentAmount
volumeRemaining: UsdPaymentAmount
}
volumeTotalLimit: UsdPaymentAmount
volumeUsed: UsdPaymentAmount
volumeRemaining: UsdPaymentAmount
}
| ValidationError
>

Expand All @@ -150,10 +150,10 @@ type AccountLimitsChecker = {

type AccountLimitsVolumes =
| {
volumesIntraledger: LimitsVolumesFn
volumesWithdrawal: LimitsVolumesFn
volumesTradeIntraAccount: LimitsVolumesFn
}
volumesIntraledger: LimitsVolumesFn
volumesWithdrawal: LimitsVolumesFn
volumesTradeIntraAccount: LimitsVolumesFn
}
| ValidationError

type AccountValidator = {
Expand All @@ -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<Account | RepositoryError>
Expand Down
8 changes: 8 additions & 0 deletions src/graphql/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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 })
Expand Down
1 change: 1 addition & 0 deletions src/graphql/public/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ type BridgeWithdrawal {
amount: String!
createdAt: String!
currency: String!
failureReason: String
id: ID!
status: String!
}
Expand Down
1 change: 1 addition & 0 deletions src/graphql/public/types/object/bridge-withdrawal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
}),
})
Expand Down
87 changes: 62 additions & 25 deletions src/services/bridge/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ============

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ============
Expand Down Expand Up @@ -425,6 +440,28 @@ export class BridgeClient {
)
}

async getVirtualAccount(
customerId: BridgeCustomerId, virtualAccountId: BridgeVirtualAccountId, idempotencyKey?: string,
): Promise<VirtualAccount> {
return this.request<VirtualAccount>(
"GET",
`/customers/${customerId}/virtual_accounts/${virtualAccountId}`,
undefined,
idempotencyKey,
)
}


async getVirtualAccountByCustomerId(customerId: BridgeCustomerId): Promise<VirtualAccount[]> {
const response = await this.request<{ data: VirtualAccount[] }>(
"GET",
`/customers/${customerId}/virtual_accounts`,
)

return response.data as VirtualAccount[]


}
// ============ External Accounts ============

async createExternalAccount(
Expand Down
Loading