diff --git a/src/app/accounts/create-account.ts b/src/app/accounts/create-account.ts index 17ea2c1f2..1f876bc4c 100644 --- a/src/app/accounts/create-account.ts +++ b/src/app/accounts/create-account.ts @@ -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 => { - 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> = {} - 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 => { - 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 => { - 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 => { + // 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 => { + 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 => { + 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 +} diff --git a/src/app/accounts/ensure-account-wallets.ts b/src/app/accounts/ensure-account-wallets.ts new file mode 100644 index 000000000..31661baee --- /dev/null +++ b/src/app/accounts/ensure-account-wallets.ts @@ -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> | ApplicationError> => { + const wallets = WalletsRepository() + + const enabledWallets: Partial> = {} + + 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 => { + 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 +} diff --git a/src/app/accounts/index.ts b/src/app/accounts/index.ts index 56e9e8a77..ef3f66d5e 100644 --- a/src/app/accounts/index.ts +++ b/src/app/accounts/index.ts @@ -1,90 +1,102 @@ -import { AccountsRepository, WalletsRepository } from "@services/mongoose" - -export * from "./account-limit" -export * from "./add-new-contact" -export * from "./add-wallet" -export * from "./create-account" -export * from "./get-account-transactions-for-contact" -export * from "./get-contact-by-username" -export * from "./get-csv-for-account" -export * from "./get-transactions-for-account" -export * from "./mark-account-for-deletion" -export * from "./send-default-wallet-balance-to-users" -export * from "./set-username" -export * from "./update-account-ip" -export * from "./update-account-level" -export * from "./business-account-upgrade-request" -export * from "./bank-account-update-request" -export * from "./bank-accounts" -export * from "./get-account-upgrade-request" -export * from "./get-upgrade-verification" -export * from "./upgrade-verification-status" -export * from "./get-account-capabilities" -export * from "./request-capability-upgrade" -export * from "./run-evidence-retention" -export * from "./get-supported-banks" -export * from "./update-account-status" -export * from "./update-business-map-info" -export * from "./update-contact-alias" -export * from "./update-default-walletid" -export * from "./update-display-currency" -export * from "./username-available" -export * from "./npub-by-username" -export * from "./delete-business-map-info" -export * from "./upgrade-device-account" -export * from "./disable-notification-category" -export * from "./enable-notification-category" -export * from "./enable-notification-channel" -export * from "./disable-notification-channel" -export * from "./set-npub" -export * from "./release-npub" -export * from "./assign-npub" -export * from "./find-by-npub" -export * from "./update-external-wallet" - -const accounts = AccountsRepository() - -export const getAccount = async ( - accountId: AccountId, -): Promise => { - return accounts.findById(accountId) -} - -export const getAccountFromUserId = async ( - kratosUserId: UserId, -): Promise => { - return accounts.findByUserId(kratosUserId) -} - -export const hasPermissions = async ( - accountId: AccountId, - walletId: WalletId, -): Promise => { - const wallet = await WalletsRepository().findById(walletId) - if (wallet instanceof Error) return wallet - - return accountId === wallet.accountId -} - -// export const getBusinessMapMarkers = async () => { -// return accounts.listBusinessesForMap() -// } - -export const getUsernameFromWalletId = async ( - walletId: WalletId, -): Promise => { - const wallet = await WalletsRepository().findById(walletId) - if (wallet instanceof Error) return wallet - - const account = await accounts.findById(wallet.accountId) - if (account instanceof Error) return account - return account.username -} - -export const getLnurlpFromWalletId = async ( - walletId: WalletId, -): Promise => { - const wallet = await WalletsRepository().findById(walletId) - if (wallet instanceof Error) return wallet - return wallet.lnurlp -} +import { AccountsRepository, WalletsRepository } from "@services/mongoose" + +import { ensureCashWallets } from "./ensure-account-wallets" + +export * from "./account-limit" +export * from "./add-new-contact" +export * from "./add-wallet" +export * from "./create-account" +export * from "./ensure-account-wallets" +export * from "./get-account-transactions-for-contact" +export * from "./get-contact-by-username" +export * from "./get-csv-for-account" +export * from "./get-transactions-for-account" +export * from "./mark-account-for-deletion" +export * from "./send-default-wallet-balance-to-users" +export * from "./set-username" +export * from "./update-account-ip" +export * from "./update-account-level" +export * from "./business-account-upgrade-request" +export * from "./bank-account-update-request" +export * from "./bank-accounts" +export * from "./get-account-upgrade-request" +export * from "./get-upgrade-verification" +export * from "./upgrade-verification-status" +export * from "./get-account-capabilities" +export * from "./request-capability-upgrade" +export * from "./run-evidence-retention" +export * from "./get-supported-banks" +export * from "./update-account-status" +export * from "./update-business-map-info" +export * from "./update-contact-alias" +export * from "./update-default-walletid" +export * from "./update-display-currency" +export * from "./username-available" +export * from "./npub-by-username" +export * from "./delete-business-map-info" +export * from "./upgrade-device-account" +export * from "./disable-notification-category" +export * from "./enable-notification-category" +export * from "./enable-notification-channel" +export * from "./disable-notification-channel" +export * from "./set-npub" +export * from "./release-npub" +export * from "./assign-npub" +export * from "./find-by-npub" +export * from "./update-external-wallet" + +const accounts = AccountsRepository() + +export const getAccount = async ( + accountId: AccountId, +): Promise => { + return accounts.findById(accountId) +} + +export const getAccountFromUserId = async ( + kratosUserId: UserId, +): Promise => { + const account = await accounts.findByUserId(kratosUserId) + if (account instanceof Error) return account + + // Registration commits the account row before the IBEX cash wallets exist, + // so a rail that was briefly down leaves the account with no + // `defaultWalletId` forever: every later login finds the row and stops + // there. The missing default is the whole signal needed to finish the job. + if (account.defaultWalletId) return account + + return ensureCashWallets(account) +} + +export const hasPermissions = async ( + accountId: AccountId, + walletId: WalletId, +): Promise => { + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + + return accountId === wallet.accountId +} + +// export const getBusinessMapMarkers = async () => { +// return accounts.listBusinessesForMap() +// } + +export const getUsernameFromWalletId = async ( + walletId: WalletId, +): Promise => { + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + + const account = await accounts.findById(wallet.accountId) + if (account instanceof Error) return account + return account.username +} + +export const getLnurlpFromWalletId = async ( + walletId: WalletId, +): Promise => { + const wallet = await WalletsRepository().findById(walletId) + if (wallet instanceof Error) return wallet + return wallet.lnurlp +} diff --git a/test/flash/unit/app/accounts/ensure-account-wallets.spec.ts b/test/flash/unit/app/accounts/ensure-account-wallets.spec.ts new file mode 100644 index 000000000..3ba14ab6b --- /dev/null +++ b/test/flash/unit/app/accounts/ensure-account-wallets.spec.ts @@ -0,0 +1,179 @@ +import { CouldNotFindError, PersistError } from "@domain/errors" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import { AccountsRepository, WalletsRepository } from "@services/mongoose" + +import { + createMissingCashWallets, + ensureCashWallets, +} from "@app/accounts/ensure-account-wallets" + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(), + WalletsRepository: jest.fn(), +})) + +jest.mock("@services/tracing", () => ({ + recordExceptionInCurrentSpan: jest.fn(), +})) + +const mockedPersistNew = jest.fn() +const mockedListByAccountId = jest.fn() +const mockedUpdate = jest.fn() + +const cashWallet = (currency: WalletCurrency, id: string): Wallet => + ({ + id, + accountId: "account-id", + type: WalletType.Checking, + currency, + }) as unknown as Wallet + +const stubRepositories = () => { + mockedPersistNew.mockImplementation( + async ({ currency }: { currency: WalletCurrency }) => + cashWallet(currency, `${currency}-wallet-id`), + ) + mockedListByAccountId.mockResolvedValue([]) + mockedUpdate.mockImplementation(async (account: Account) => account) + + jest.mocked(WalletsRepository).mockReturnValue({ + persistNew: mockedPersistNew, + listByAccountId: mockedListByAccountId, + } as never) + jest.mocked(AccountsRepository).mockReturnValue({ update: mockedUpdate } as never) +} + +beforeEach(() => { + jest.clearAllMocks() + stubRepositories() +}) + +describe("createMissingCashWallets", () => { + it("creates every required cash wallet for an account that has none", async () => { + const result = await createMissingCashWallets({ + accountId: "account-id" as AccountId, + currencies: [WalletCurrency.Usd], + existingWallets: [], + }) + + expect(result).not.toBeInstanceOf(Error) + expect(mockedPersistNew).toHaveBeenCalledTimes(2) + expect((result as Record)[WalletCurrency.Usdt].id).toBe( + `${WalletCurrency.Usdt}-wallet-id`, + ) + }) + + // The point of the helper: a retry after a partial failure must not write a + // second wallet for a currency that already has one. + it("reuses the wallets an earlier attempt already created", async () => { + const existing = cashWallet(WalletCurrency.Usd, "usd-from-first-attempt") + + const result = await createMissingCashWallets({ + accountId: "account-id" as AccountId, + currencies: [WalletCurrency.Usd], + existingWallets: [existing], + }) + + expect(mockedPersistNew).toHaveBeenCalledTimes(1) + expect(mockedPersistNew).toHaveBeenCalledWith({ + accountId: "account-id", + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + expect((result as Record)[WalletCurrency.Usd]).toBe(existing) + }) + + it("fails when a required cash wallet cannot be created", async () => { + mockedPersistNew.mockImplementation(async ({ currency }: { currency: WalletCurrency }) => + currency === WalletCurrency.Usdt + ? new PersistError("IBEX unavailable") + : cashWallet(currency, `${currency}-wallet-id`), + ) + + const result = await createMissingCashWallets({ + accountId: "account-id" as AccountId, + currencies: [], + existingWallets: [], + }) + + expect(result).toBeInstanceOf(PersistError) + }) + + it("keeps going when only an optional wallet fails", async () => { + mockedPersistNew.mockImplementation(async ({ currency }: { currency: WalletCurrency }) => + currency === WalletCurrency.Btc + ? new PersistError("no btc wallet") + : cashWallet(currency, `${currency}-wallet-id`), + ) + + const result = await createMissingCashWallets({ + accountId: "account-id" as AccountId, + currencies: [WalletCurrency.Btc], + existingWallets: [], + }) + + expect(result).not.toBeInstanceOf(Error) + expect(Object.keys(result as Record)).toHaveLength(2) + }) +}) + +describe("ensureCashWallets", () => { + it("fills in the default wallet id of an account left without wallets", async () => { + const result = await ensureCashWallets( + { id: "account-id" } as unknown as Account, + [], + ) + + expect(result).not.toBeInstanceOf(Error) + expect((result as Account).defaultWalletId).toBe(`${WalletCurrency.Usdt}-wallet-id`) + expect(mockedUpdate).toHaveBeenCalledWith( + expect.objectContaining({ defaultWalletId: `${WalletCurrency.Usdt}-wallet-id` }), + ) + }) + + // Healing must be idempotent: the read path runs it on the next request, and + // an account that is already complete should not be rewritten. + it("does not rewrite an account that already points at the default wallet", async () => { + mockedListByAccountId.mockResolvedValue([ + cashWallet(WalletCurrency.Usdt, "usdt-wallet-id"), + cashWallet(WalletCurrency.Usd, "usd-wallet-id"), + ]) + + const account = { + id: "account-id", + defaultWalletId: "usdt-wallet-id" as WalletId, + } as unknown as Account + + const result = await ensureCashWallets(account, []) + + expect(result).toBe(account) + expect(mockedUpdate).not.toHaveBeenCalled() + expect(mockedPersistNew).not.toHaveBeenCalled() + }) + + it("surfaces a repository failure while reading existing wallets", async () => { + mockedListByAccountId.mockResolvedValue(new PersistError("mongo down")) + + const result = await ensureCashWallets( + { id: "account-id" } as unknown as Account, + [], + ) + + expect(result).toBeInstanceOf(PersistError) + expect(mockedPersistNew).not.toHaveBeenCalled() + }) + + it("reports when no cash wallet could be enabled at all", async () => { + mockedPersistNew.mockResolvedValue(undefined) + + const result = await ensureCashWallets( + { id: "account-id" } as unknown as Account, + [], + ) + + expect(result).toBeInstanceOf(CouldNotFindError) + expect(mockedUpdate).not.toHaveBeenCalled() + }) +})