Skip to content
Closed
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
231 changes: 102 additions & 129 deletions src/app/accounts/create-account.ts
Original file line number Diff line number Diff line change
@@ -1,129 +1,102 @@
import { ConfigError, getAdminAccounts, getDefaultAccountsConfig } from "@config"

import { WalletType } from "@domain/wallets"
import { AccountLevel } from "@domain/accounts"

import {
AccountsRepository,
UsersRepository,
WalletsRepository,
} from "@services/mongoose"

import { recordExceptionInCurrentSpan } from "@services/tracing"
import { ErrorLevel, WalletCurrency } from "@domain/shared"

const requiredCashWalletCurrencies: WalletCurrency[] = [
WalletCurrency.Usd,
WalletCurrency.Usdt,
]
const defaultCashWalletCurrency = WalletCurrency.Usdt

const initializeCreatedAccount = async ({
account,
config,
phone,
}: {
account: Account
config: AccountsConfig
phone?: PhoneNumber
}): Promise<Account | ApplicationError> => {
const newWallet = (currency: WalletCurrency) =>
WalletsRepository().persistNew({
accountId: account.id,
type: WalletType.Checking,
currency,
})

const walletsEnabledConfig = Array.from(
new Set([...config.initialWallets, ...requiredCashWalletCurrencies]),
)

// Create all wallets
const enabledWallets: Partial<Record<WalletCurrency, Wallet>> = {}
for (const currency of walletsEnabledConfig) {
const wallet = await newWallet(currency)
if (wallet instanceof Error) {
recordExceptionInCurrentSpan({
error: wallet,
level: ErrorLevel.Critical,
attributes: { accountId: account.id, currency },
})
if (requiredCashWalletCurrencies.includes(currency)) return wallet
continue
}

enabledWallets[currency] = wallet
}

// Set ETH-USDT as the active Cash Wallet while preserving USD for migration.
const defaultWalletId = enabledWallets[defaultCashWalletCurrency]?.id

if (defaultWalletId === undefined) {
return new ConfigError("NoWalletsEnabledInConfigError")
}
account.defaultWalletId = defaultWalletId

// TODO: improve bootstrap process
// the script below is to dynamically attribute the editor account at runtime
// this is only if editor is set in the config - typically only in test env
const role = getAdminAccounts().find(({ phone: phone2 }) => phone2 === phone)?.role
account.role = role || "user"
// end TODO

account.contactEnabled = account.role === "user" || account.role === "editor"

account.statusHistory = [{ status: config.initialStatus, comment: "Initial Status" }]
account.level = config.initialLevel

const updatedAccount = await AccountsRepository().update(account)
if (updatedAccount instanceof Error) return updatedAccount

return updatedAccount
}

export const createAccountForDeviceAccount = async ({
userId,
deviceId,
}: {
userId: UserId
deviceId: DeviceId
}): Promise<Account | RepositoryError> => {
const user = await UsersRepository().update({ id: userId, deviceId })
if (user instanceof Error) return user

const accountNew = await AccountsRepository().persistNew(userId)
if (accountNew instanceof Error) return accountNew

const levelZeroAccountsConfig = getDefaultAccountsConfig()
levelZeroAccountsConfig.initialLevel = AccountLevel.Zero

return initializeCreatedAccount({
account: accountNew,
config: levelZeroAccountsConfig,
})
}

export const createAccountWithPhoneIdentifier = async ({
newAccountInfo: { kratosUserId, phone },
config,
phoneMetadata,
}: {
newAccountInfo: NewAccountWithPhoneIdentifier
config: AccountsConfig
phoneMetadata?: PhoneMetadata
}): Promise<Account | RepositoryError> => {
const user = await UsersRepository().update({ id: kratosUserId, phone, phoneMetadata })
if (user instanceof Error) return user

const accountNew = await AccountsRepository().persistNew(kratosUserId)
if (accountNew instanceof Error) return accountNew

const account = await initializeCreatedAccount({
account: accountNew,
config,
phone,
})
if (account instanceof Error) return account

return account
}
import { ConfigError, getAdminAccounts, getDefaultAccountsConfig } from "@config"

import { AccountLevel } from "@domain/accounts"

import { AccountsRepository, UsersRepository } from "@services/mongoose"

import {
createMissingCashWallets,
defaultCashWalletCurrency,
} from "./ensure-account-wallets"

const initializeCreatedAccount = async ({
account,
config,
phone,
}: {
account: Account
config: AccountsConfig
phone?: PhoneNumber
}): Promise<Account | ApplicationError> => {
// A fresh account has no wallets yet, so every configured currency is
// created. The same helper runs on the read path for accounts left half-made
// by an earlier IBEX failure, where it only fills the gaps (#90).
const enabledWallets = await createMissingCashWallets({
accountId: account.id,
currencies: config.initialWallets,
existingWallets: [],
})
if (enabledWallets instanceof Error) return enabledWallets

// Set ETH-USDT as the active Cash Wallet while preserving USD for migration.
const defaultWalletId = enabledWallets[defaultCashWalletCurrency]?.id

if (defaultWalletId === undefined) {
return new ConfigError("NoWalletsEnabledInConfigError")
}
account.defaultWalletId = defaultWalletId

// TODO: improve bootstrap process
// the script below is to dynamically attribute the editor account at runtime
// this is only if editor is set in the config - typically only in test env
const role = getAdminAccounts().find(({ phone: phone2 }) => phone2 === phone)?.role
account.role = role || "user"
// end TODO

account.contactEnabled = account.role === "user" || account.role === "editor"

account.statusHistory = [{ status: config.initialStatus, comment: "Initial Status" }]
account.level = config.initialLevel

const updatedAccount = await AccountsRepository().update(account)
if (updatedAccount instanceof Error) return updatedAccount

return updatedAccount
}

export const createAccountForDeviceAccount = async ({
userId,
deviceId,
}: {
userId: UserId
deviceId: DeviceId
}): Promise<Account | RepositoryError> => {
const user = await UsersRepository().update({ id: userId, deviceId })
if (user instanceof Error) return user

const accountNew = await AccountsRepository().persistNew(userId)
if (accountNew instanceof Error) return accountNew

const levelZeroAccountsConfig = getDefaultAccountsConfig()
levelZeroAccountsConfig.initialLevel = AccountLevel.Zero

return initializeCreatedAccount({
account: accountNew,
config: levelZeroAccountsConfig,
})
}

export const createAccountWithPhoneIdentifier = async ({
newAccountInfo: { kratosUserId, phone },
config,
phoneMetadata,
}: {
newAccountInfo: NewAccountWithPhoneIdentifier
config: AccountsConfig
phoneMetadata?: PhoneMetadata
}): Promise<Account | RepositoryError> => {
const user = await UsersRepository().update({ id: kratosUserId, phone, phoneMetadata })
if (user instanceof Error) return user

const accountNew = await AccountsRepository().persistNew(kratosUserId)
if (accountNew instanceof Error) return accountNew

const account = await initializeCreatedAccount({
account: accountNew,
config,
phone,
})
if (account instanceof Error) return account

return account
}
98 changes: 98 additions & 0 deletions src/app/accounts/ensure-account-wallets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { CouldNotFindError } from "@domain/errors"
import { ErrorLevel, WalletCurrency } from "@domain/shared"
import { WalletType } from "@domain/wallets"

import { AccountsRepository, WalletsRepository } from "@services/mongoose"
import { recordExceptionInCurrentSpan } from "@services/tracing"

export const requiredCashWalletCurrencies: WalletCurrency[] = [
WalletCurrency.Usd,
WalletCurrency.Usdt,
]

export const defaultCashWalletCurrency = WalletCurrency.Usdt

// Every cash wallet is an IBEX account, and `createAccount` fails
// transiently — a rolling deploy, a timed-out first call. The account row is
// already committed by then, so bailing out leaves an account with no wallets
// and no `defaultWalletId`, and the next login finds that account and never
// retries it. Reading what exists and creating only what is missing makes the
// second attempt finish the job instead of repeating the failure.
export const createMissingCashWallets = async ({
accountId,
currencies,
existingWallets,
}: {
accountId: AccountId
currencies: WalletCurrency[]
existingWallets: Wallet[]
}): Promise<Partial<Record<WalletCurrency, Wallet>> | ApplicationError> => {
const wallets = WalletsRepository()

const enabledWallets: Partial<Record<WalletCurrency, Wallet>> = {}

for (const currency of Array.from(
new Set([...currencies, ...requiredCashWalletCurrencies]),
)) {
const existing = existingWallets.find(
(wallet) => wallet.currency === currency && wallet.type === WalletType.Checking,
)
if (existing) {
enabledWallets[currency] = existing
continue
}

const wallet = await wallets.persistNew({
accountId,
type: WalletType.Checking,
currency,
})
if (wallet instanceof Error) {
recordExceptionInCurrentSpan({
error: wallet,
level: ErrorLevel.Critical,
attributes: { accountId, currency },
})
if (requiredCashWalletCurrencies.includes(currency)) return wallet
continue
}

enabledWallets[currency] = wallet
}

return enabledWallets
}

// An account row is committed before its wallets exist, so a half-initialised
// account is identifiable by the missing `defaultWalletId`. That is the signal
// the read path uses to come back and finish the job once IBEX answers again —
// without it the first failure is permanent for that user.
export const ensureCashWallets = async (
account: Account,
currencies: WalletCurrency[] = requiredCashWalletCurrencies,
): Promise<Account | ApplicationError> => {
const existingWallets = await WalletsRepository().listByAccountId(account.id)
if (existingWallets instanceof Error) return existingWallets

const enabledWallets = await createMissingCashWallets({
accountId: account.id,
currencies: [...currencies, ...existingWallets.map((wallet) => wallet.currency)],
existingWallets,
})
if (enabledWallets instanceof Error) return enabledWallets

const defaultWalletId = enabledWallets[defaultCashWalletCurrency]?.id
if (defaultWalletId === undefined) {
return new CouldNotFindError("No cash wallet could be enabled for the account")
}

if (account.defaultWalletId === defaultWalletId) return account

const updatedAccount = await AccountsRepository().update({
...account,
defaultWalletId,
})
if (updatedAccount instanceof Error) return updatedAccount

return updatedAccount
}
Loading