diff --git a/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts new file mode 100644 index 000000000..db42bc5f0 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts @@ -0,0 +1,50 @@ +import { + destinationShortfallUsdtMicros, + feeUsdCentsToUsdtMicros, + usdCentsToUsdtMicros, + usdtMicrosToUsdCentsCeil, +} from "@app/cash-wallet-cutover/amount-conversion" + +describe("cash wallet cutover amount conversion", () => { + it("converts USD cents to USDT micros exactly", () => { + expect(usdCentsToUsdtMicros("0")).toBe("0") + expect(usdCentsToUsdtMicros("1")).toBe("10000") + expect(usdCentsToUsdtMicros("100")).toBe("1000000") + expect(usdCentsToUsdtMicros("123456789")).toBe("1234567890000") + }) + + it("converts fee USD cents to USDT micros exactly", () => { + expect(feeUsdCentsToUsdtMicros("7")).toBe("70000") + }) + + it("rounds USDT micros up to USD cents for fee audit fields", () => { + expect(usdtMicrosToUsdCentsCeil("0")).toBe("0") + expect(usdtMicrosToUsdCentsCeil("1")).toBe("1") + expect(usdtMicrosToUsdCentsCeil("10000")).toBe("1") + expect(usdtMicrosToUsdCentsCeil("10001")).toBe("2") + }) + + it("computes destination USDT shortfall from the observed balance delta", () => { + expect( + destinationShortfallUsdtMicros({ + targetUsdtMicros: "10000000", + startingUsdtMicros: "5000000", + currentUsdtMicros: "14930000", + }), + ).toBe("70000") + expect( + destinationShortfallUsdtMicros({ + targetUsdtMicros: "10000000", + startingUsdtMicros: "5000000", + currentUsdtMicros: "15000000", + }), + ).toBe("0") + }) + + it("rejects invalid or fractional cent inputs", () => { + expect(usdCentsToUsdtMicros("1.5")).toBeInstanceOf(Error) + expect(usdCentsToUsdtMicros("abc")).toBeInstanceOf(Error) + expect(usdCentsToUsdtMicros("-1")).toBeInstanceOf(Error) + expect(usdtMicrosToUsdCentsCeil("1.5")).toBeInstanceOf(Error) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts b/test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts new file mode 100644 index 000000000..cfe6211e8 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts @@ -0,0 +1,46 @@ +import { + CASH_WALLET_USDT_CLIENT_CAPABILITY, + parseCashWalletClientCapabilities, +} from "@app/cash-wallet-cutover/client-capability" + +describe("cash wallet client capability parser", () => { + it("defaults missing headers to legacy compatibility", () => { + expect(parseCashWalletClientCapabilities({})).toEqual({ + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, + }) + }) + + it("treats unknown capabilities as legacy compatibility", () => { + expect( + parseCashWalletClientCapabilities({ + "x-flash-client-capabilities": "contacts-v2", + }), + ).toEqual({ + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, + }) + }) + + it("detects the USDT Cash Wallet capability", () => { + expect( + parseCashWalletClientCapabilities({ + "x-flash-client-capabilities": `contacts-v2, ${CASH_WALLET_USDT_CLIENT_CAPABILITY}`, + }), + ).toEqual({ + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, + }) + }) + + it("accepts native client connection-param casing", () => { + expect( + parseCashWalletClientCapabilities({ + "X-Flash-Client-Capabilities": CASH_WALLET_USDT_CLIENT_CAPABILITY, + }), + ).toEqual({ + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, + }) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts b/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts new file mode 100644 index 000000000..356ce2a5f --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts @@ -0,0 +1,154 @@ +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, + evaluateCashWalletCutoverGuard, + evaluateCashWalletCutoverPresentation, +} from "@app/cash-wallet-cutover/guard" + +const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ + state, + cutoverVersion: 2, + runId: "run-2", + updatedAt: new Date("2026-05-19T00:00:00Z"), +}) + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 2, + runId: "run-2", + status, + idempotencyKey: "run-2:account-id", + attempts: 0, + updatedAt: new Date("2026-05-19T00:00:00Z"), +}) + +const legacyClient = { + cashWalletPresentation: "legacy_compat" as const, + hasUsdtCashWalletSupport: false, +} + +const usdtClient = { + cashWalletPresentation: "usdt" as const, + hasUsdtCashWalletSupport: true, +} + +describe("cash wallet cutover guard", () => { + it("allows legacy route before cutover starts", () => { + expect(evaluateCashWalletCutoverGuard({ cutover: config("pre") })).toEqual({ + route: "legacy_usd", + }) + }) + + it("allows legacy route during cutover before this account starts", () => { + expect(evaluateCashWalletCutoverGuard({ cutover: config("in_progress") })).toEqual({ + route: "legacy_usd", + }) + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration("not_started"), + }), + ).toEqual({ route: "legacy_usd" }) + }) + + it("rejects writes while this account is actively migrating", () => { + for (const status of [ + "balance_read", + "balance_move_sending", + "fee_reimbursement_sending", + ] as const) { + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration(status), + }), + ).toBeInstanceOf(CashWalletCutoverInProgressError) + } + }) + + it("routes completed accounts to USDT during cutover", () => { + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration("complete"), + }), + ).toEqual({ route: "usdt" }) + }) + + it("rejects failed and manual-review migrations", () => { + for (const status of ["failed", "requires_operator_review"] as const) { + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration(status), + }), + ).toBeInstanceOf(CashWalletMigrationFailedError) + } + }) + + it("routes all accounts to USDT after global completion", () => { + expect(evaluateCashWalletCutoverGuard({ cutover: config("complete") })).toEqual({ + route: "usdt", + }) + }) +}) + +describe("cash wallet cutover presentation", () => { + it("presents legacy USD before cutover starts", () => { + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("pre"), + client: usdtClient, + }), + ).toEqual({ + presentation: "legacy_usd", + }) + }) + + it("presents completed accounts as legacy-compatible for old clients", () => { + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("in_progress"), + migration: migration("complete"), + client: legacyClient, + }), + ).toEqual({ + presentation: "legacy_usd_compat", + }) + }) + + it("presents completed accounts as USDT for capable clients", () => { + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("in_progress"), + migration: migration("complete"), + client: usdtClient, + }), + ).toEqual({ + presentation: "usdt", + }) + }) + + it("uses client capability after global completion", () => { + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("complete"), + client: legacyClient, + }), + ).toEqual({ + presentation: "legacy_usd_compat", + }) + + expect( + evaluateCashWalletCutoverPresentation({ + cutover: config("complete"), + client: usdtClient, + }), + ).toEqual({ + presentation: "usdt", + }) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts b/test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts new file mode 100644 index 000000000..201b382ba --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts @@ -0,0 +1,86 @@ +import { RepositoryError } from "@domain/errors" + +import { discoverCashWalletCutoverAccounts } from "@app/cash-wallet-cutover/discovery" + +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = (id: AccountId, defaultWalletId: WalletId): Account => + ({ + id, + uuid: `${id}-uuid` as AccountUuid, + defaultWalletId, + }) as Account + +const wallet = ({ + id, + accountId, + currency, +}: { + id: WalletId + accountId: AccountId + currency: WalletCurrency +}): Wallet => + ({ + id, + accountId, + type: WalletType.Checking, + currency, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "lnurl" as Lnurl, + }) as Wallet + +async function* unlockedAccounts(accounts: Account[]): AsyncGenerator { + for (const account of accounts) yield account +} + +describe("cash wallet cutover account discovery collector", () => { + it("classifies every unlocked account with its wallets", async () => { + const accountOne = account("account-1" as AccountId, "account-1-usd" as WalletId) + const accountTwo = account("account-2" as AccountId, "account-2-usdt" as WalletId) + const walletsRepo = { + listByAccountId: jest.fn(async (accountId: AccountId) => [ + wallet({ + id: `${accountId}-usd` as WalletId, + accountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: `${accountId}-usdt` as WalletId, + accountId, + currency: WalletCurrency.Usdt, + }), + ]), + } + + const result = await discoverCashWalletCutoverAccounts({ + accountsRepo: { + listUnlockedAccounts: () => unlockedAccounts([accountOne, accountTwo]), + }, + walletsRepo, + }) + + expect(result).toEqual([ + expect.objectContaining({ accountId: "account-1", status: "legacy_default" }), + expect.objectContaining({ accountId: "account-2", status: "already_usdt" }), + ]) + expect(walletsRepo.listByAccountId).toHaveBeenCalledWith("account-1") + expect(walletsRepo.listByAccountId).toHaveBeenCalledWith("account-2") + }) + + it("returns repository errors without continuing discovery", async () => { + const accountOne = account("account-1" as AccountId, "account-1-usd" as WalletId) + const error = new RepositoryError("wallet lookup failed") + const walletsRepo = { + listByAccountId: jest.fn(async () => error), + } + + const result = await discoverCashWalletCutoverAccounts({ + accountsRepo: { listUnlockedAccounts: () => unlockedAccounts([accountOne]) }, + walletsRepo, + }) + + expect(result).toBe(error) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts b/test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts new file mode 100644 index 000000000..1606d9cad --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts @@ -0,0 +1,111 @@ +import { classifyCashWalletsForCutover } from "@app/cash-wallet-cutover/discovery" + +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = (defaultWalletId: WalletId): Account => ({ + id: "account-id" as AccountId, + uuid: "account-uuid" as AccountUuid, + createdAt: new Date("2026-05-20T00:00:00Z"), + defaultWalletId, + username: "username" as Username, + npub: "npub" as Npub, + level: 1 as AccountLevel, + status: "active" as AccountStatus, + statusHistory: [{ status: "active" as AccountStatus, timestamp: new Date() }], + title: "" as BusinessMapTitle, + coordinates: undefined as Coordinates, + contactEnabled: false, + contacts: [], + withdrawFee: 0 as Satoshis, + isEditor: false, + notificationSettings: { push: { enabled: true, disabledCategories: [] } }, + quizQuestions: [], + quiz: [], + kratosUserId: "user-id" as UserId, + displayCurrency: "USD" as DisplayCurrency, +}) + +const wallet = ({ + id, + currency, + type = WalletType.Checking, +}: { + id: WalletId + currency: WalletCurrency + type?: WalletType +}): Wallet => ({ + id, + accountId: "account-id" as AccountId, + type, + currency, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "lnurl" as Lnurl, +}) + +describe("cash wallet cutover discovery", () => { + const legacyUsdWallet = wallet({ + id: "legacy-usd-wallet-id" as WalletId, + currency: WalletCurrency.Usd, + }) + const destinationUsdtWallet = wallet({ + id: "usdt-wallet-id" as WalletId, + currency: WalletCurrency.Usdt, + }) + + it("classifies accounts whose default still points to legacy USD", () => { + const result = classifyCashWalletsForCutover({ + account: account("legacy-usd-wallet-id" as WalletId), + wallets: [legacyUsdWallet, destinationUsdtWallet], + }) + + expect(result).toMatchObject({ + status: "legacy_default", + accountId: "account-id", + accountUuid: "account-uuid", + legacyUsdWalletId: "legacy-usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + previousDefaultWalletId: "legacy-usd-wallet-id", + }) + }) + + it("classifies accounts already defaulting to ETH-USDT", () => { + const result = classifyCashWalletsForCutover({ + account: account("usdt-wallet-id" as WalletId), + wallets: [legacyUsdWallet, destinationUsdtWallet], + }) + + expect(result).toMatchObject({ + status: "already_usdt", + legacyUsdWalletId: "legacy-usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + previousDefaultWalletId: "usdt-wallet-id", + }) + }) + + it("classifies legacy USD wallets that are no longer the default as residual", () => { + const result = classifyCashWalletsForCutover({ + account: account("btc-wallet-id" as WalletId), + wallets: [legacyUsdWallet, destinationUsdtWallet], + }) + + expect(result).toMatchObject({ status: "residual_legacy_usd" }) + }) + + it("surfaces accounts that cannot be planned because a required cash wallet is missing", () => { + expect( + classifyCashWalletsForCutover({ + account: account("legacy-usd-wallet-id" as WalletId), + wallets: [legacyUsdWallet], + }), + ).toMatchObject({ status: "missing_destination_usdt" }) + + expect( + classifyCashWalletsForCutover({ + account: account("usdt-wallet-id" as WalletId), + wallets: [destinationUsdtWallet], + }), + ).toMatchObject({ status: "missing_legacy_usd" }) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/executor.spec.ts b/test/flash/unit/app/cash-wallet-cutover/executor.spec.ts new file mode 100644 index 000000000..60d5fae41 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/executor.spec.ts @@ -0,0 +1,82 @@ +import { CouldNotUpdateError } from "@domain/errors" + +import { executeCashWalletMigrationStep } from "@app/cash-wallet-cutover/executor" + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +const handlers = () => ({ + not_started: jest.fn(async () => migration("started")), + started: jest.fn(async () => migration("provisioned")), + provisioned: jest.fn(async () => migration("balance_read")), + balance_read: jest.fn(async () => migration("invoice_created")), + invoice_created: jest.fn(async () => migration("balance_move_sending")), + balance_move_sending: jest.fn(async () => migration("balance_move_sent")), + balance_move_sent: jest.fn(async () => migration("balance_move_verified")), + balance_move_verified: jest.fn(async () => + migration("fee_reimbursement_invoice_created"), + ), + fee_reimbursement_invoice_created: jest.fn(async () => + migration("fee_reimbursement_sending"), + ), + fee_reimbursement_sending: jest.fn(async () => migration("fee_reimbursed")), + fee_reimbursed: jest.fn(async () => migration("pointer_flipped")), + pointer_flipped: jest.fn(async () => migration("legacy_zero_verified")), + legacy_zero_verified: jest.fn(async () => migration("complete")), +}) + +describe("cash wallet migration executor", () => { + it("dispatches a runnable migration to the handler for its current status", async () => { + const stepHandlers = handlers() + + const result = await executeCashWalletMigrationStep({ + migration: migration("invoice_created"), + handlers: stepHandlers, + }) + + expect(result).toMatchObject({ status: "balance_move_sending" }) + expect(stepHandlers.invoice_created).toHaveBeenCalledWith( + migration("invoice_created"), + ) + }) + + it("returns terminal migrations without invoking handlers", async () => { + const stepHandlers = handlers() + + const result = await executeCashWalletMigrationStep({ + migration: migration("requires_operator_review"), + handlers: stepHandlers, + }) + + expect(result).toMatchObject({ status: "requires_operator_review" }) + expect(Object.values(stepHandlers).some((handler) => handler.mock.calls.length)).toBe( + false, + ) + }) + + it("returns handler failures without trying a second checkpoint", async () => { + const error = new CouldNotUpdateError("checkpoint failed") + const stepHandlers = { + ...handlers(), + balance_move_sent: jest.fn(async () => error), + } + + const result = await executeCashWalletMigrationStep({ + migration: migration("balance_move_sent"), + handlers: stepHandlers, + }) + + expect(result).toBe(error) + expect(stepHandlers.balance_move_verified).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts new file mode 100644 index 000000000..b807cd96f --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts @@ -0,0 +1,231 @@ +import { createCashWalletMigrationStepHandlers } from "@app/cash-wallet-cutover/handlers" + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +describe("cash wallet migration step handlers", () => { + it("builds handlers for every runnable status", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async ({ to }) => migration(to)), + } + const services = { + now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), + provisioningService: { + ensureDestinationWallet: jest.fn(async () => true), + }, + balanceReader: { + readSourceBalanceUsdCents: jest.fn(async () => "1234"), + readDestinationBalanceUsdtMicros: jest.fn(async () => "5000000"), + }, + invoiceService: { + createInvoice: jest.fn( + async () => + ({ + paymentRequest: "lnbc1" as EncodedPaymentRequest, + paymentHash: "hash" as PaymentHash, + }) as LnInvoice, + ), + createNoAmountInvoice: jest.fn( + async () => + ({ + paymentRequest: "lnbc1-no-amount" as EncodedPaymentRequest, + paymentHash: "noAmountHash" as PaymentHash, + }) as LnInvoice, + ), + }, + paymentService: { + payInvoice: jest.fn(async () => ({ + transactionId: "ibex-tx-id" as IbexTransactionId, + })), + }, + balanceVerifier: { + verifyBalanceMove: jest.fn(async () => true), + }, + feeService: { + readFeeAmountUsdtMicros: jest.fn(async () => "70000"), + }, + treasuryService: { + getTreasuryWalletId: jest.fn(async () => "treasury-wallet-id" as WalletId), + }, + pointerService: { + flipDefaultWallet: jest.fn(async () => ({ + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + }, + legacyWalletVerifier: { + verifyLegacyWalletZero: jest.fn(async () => true), + }, + } + + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services, + }) + + expect(Object.keys(handlers).sort()).toEqual([ + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "balance_read", + "fee_reimbursed", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "invoice_created", + "legacy_zero_verified", + "not_started", + "pointer_flipped", + "provisioned", + "started", + ]) + + await handlers.not_started(migration("not_started")) + await handlers.started(migration("started")) + await handlers.provisioned(migration("provisioned")) + await handlers.balance_move_verified(migration("balance_move_verified")) + + expect(services.now).toHaveBeenCalled() + expect(services.provisioningService.ensureDestinationWallet).toHaveBeenCalled() + expect(services.balanceReader.readSourceBalanceUsdCents).toHaveBeenCalledWith( + migration("provisioned"), + ) + expect(services.balanceReader.readDestinationBalanceUsdtMicros).toHaveBeenCalledWith( + migration("provisioned"), + ) + expect(services.feeService.readFeeAmountUsdtMicros).toHaveBeenCalledWith( + migration("balance_move_verified"), + ) + }) + + it("skips balance move and fee reimbursement for zero-balance migrations", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async ({ to }) => migration(to)), + } + const services = { + now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), + provisioningService: { + ensureDestinationWallet: jest.fn(async () => true), + }, + balanceReader: { + readSourceBalanceUsdCents: jest.fn(async () => "0"), + readDestinationBalanceUsdtMicros: jest.fn(async () => "0"), + }, + invoiceService: { + createInvoice: jest.fn(), + createNoAmountInvoice: jest.fn(), + }, + paymentService: { + payInvoice: jest.fn(), + }, + balanceVerifier: { + verifyBalanceMove: jest.fn(), + }, + feeService: { + readFeeAmountUsdtMicros: jest.fn(), + }, + treasuryService: { + getTreasuryWalletId: jest.fn(), + }, + pointerService: { + flipDefaultWallet: jest.fn(async () => ({ + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + }, + legacyWalletVerifier: { + verifyLegacyWalletZero: jest.fn(async () => true), + }, + } + + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services, + }) + + const result = await handlers.balance_read({ + ...migration("balance_read"), + sourceBalanceUsdCents: "0", + destinationAmountUsdtMicros: "0", + }) + + expect(result).toMatchObject({ status: "pointer_flipped" }) + expect(services.pointerService.flipDefaultWallet).toHaveBeenCalledWith({ + accountId: "account-id", + destinationWalletId: "usdt-wallet-id", + }) + expect(services.invoiceService.createInvoice).not.toHaveBeenCalled() + expect(services.invoiceService.createNoAmountInvoice).not.toHaveBeenCalled() + expect(services.paymentService.payInvoice).not.toHaveBeenCalled() + expect(services.balanceVerifier.verifyBalanceMove).not.toHaveBeenCalled() + expect(services.feeService.readFeeAmountUsdtMicros).not.toHaveBeenCalled() + expect(services.treasuryService.getTreasuryWalletId).not.toHaveBeenCalled() + }) + + it("skips fee reimbursement invoice creation when the destination shortfall is zero", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async ({ to, patch }) => ({ + ...migration(to), + ...patch, + })), + } + const services = { + now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), + provisioningService: { + ensureDestinationWallet: jest.fn(async () => true), + }, + balanceReader: { + readSourceBalanceUsdCents: jest.fn(async () => "1000"), + readDestinationBalanceUsdtMicros: jest.fn(async () => "0"), + }, + invoiceService: { + createInvoice: jest.fn(), + createNoAmountInvoice: jest.fn(), + }, + paymentService: { + payInvoice: jest.fn(), + }, + balanceVerifier: { + verifyBalanceMove: jest.fn(async () => true), + }, + feeService: { + readFeeAmountUsdtMicros: jest.fn(async () => "0"), + }, + treasuryService: { + getTreasuryWalletId: jest.fn(), + }, + pointerService: { + flipDefaultWallet: jest.fn(async () => ({ + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + }, + legacyWalletVerifier: { + verifyLegacyWalletZero: jest.fn(async () => true), + }, + } + + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services, + }) + + const result = await handlers.balance_move_verified( + migration("balance_move_verified"), + ) + + expect(result).toMatchObject({ + status: "fee_reimbursed", + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + }) + expect(services.invoiceService.createInvoice).not.toHaveBeenCalled() + expect(services.treasuryService.getTreasuryWalletId).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts b/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts new file mode 100644 index 000000000..ff578a24d --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts @@ -0,0 +1,190 @@ +jest.mock("@services/mongoose", () => ({ + CashWalletCutoverRepository: jest.fn(), +})) + +import { + completePrimaryCashWalletCutover, + getPrimaryCashWalletCutoverStatus, + startPrimaryCashWalletCutover, +} from "@app/cash-wallet-cutover/lifecycle" +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, + InvalidCashWalletCutoverStateTransitionError, +} from "@app/cash-wallet-cutover/errors" + +const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ + state, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +const repo = ({ + currentConfig = config("pre"), + runnable = [], + counts = {}, +}: { + currentConfig?: CashWalletCutoverConfig + runnable?: CashWalletMigration[] + counts?: Partial> +} = {}) => ({ + getConfig: jest.fn(async () => currentConfig), + updateConfig: jest.fn(async (patch: Partial) => ({ + ...currentConfig, + ...patch, + })), + listRunnableMigrations: jest.fn(async () => runnable), + countByStatus: jest.fn( + async ({ status }: { status: CashWalletMigrationStatus }) => counts[status] ?? 0, + ), +}) + +describe("cash wallet cutover lifecycle", () => { + const now = new Date("2026-05-20T12:00:00Z") + + it("starts a prepared cutover run", async () => { + const migrationsRepo = repo() + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(migrationsRepo.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + state: "in_progress", + cutoverVersion: 7, + runId: "run-7", + startedAt: now, + }), + "operator", + ) + expect(result).toMatchObject({ state: "in_progress", runId: "run-7" }) + }) + + it("is idempotent for the active cutover run", async () => { + const migrationsRepo = repo({ currentConfig: config("in_progress") }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(migrationsRepo.updateConfig).not.toHaveBeenCalled() + expect(result).toEqual(config("in_progress")) + }) + + it("rejects starting a different run while one is active", async () => { + const migrationsRepo = repo({ currentConfig: config("in_progress") }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion: 8, + runId: "run-8", + actor: "operator", + now, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(CashWalletCutoverInProgressError) + }) + + it("rejects restarting a completed cutover", async () => { + const migrationsRepo = repo({ currentConfig: config("complete") }) + + const result = await startPrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(InvalidCashWalletCutoverStateTransitionError) + }) + + it("refuses completion while runnable migrations remain", async () => { + const migrationsRepo = repo({ + currentConfig: config("in_progress"), + runnable: [{ id: "migration-id", status: "started" } as CashWalletMigration], + }) + + const result = await completePrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(CashWalletCutoverInProgressError) + expect(migrationsRepo.updateConfig).not.toHaveBeenCalled() + }) + + it("refuses completion when failed migrations exist", async () => { + const migrationsRepo = repo({ + currentConfig: config("in_progress"), + counts: { failed: 1 }, + }) + + const result = await completePrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(CashWalletMigrationFailedError) + }) + + it("marks cutover complete after all migrations are terminal-success", async () => { + const migrationsRepo = repo({ currentConfig: config("in_progress") }) + + const result = await completePrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + actor: "operator", + now, + migrationsRepo, + }) + + expect(migrationsRepo.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + state: "complete", + cutoverVersion: 7, + runId: "run-7", + completedAt: now, + }), + "operator", + ) + expect(result).toMatchObject({ state: "complete" }) + }) + + it("returns non-zero migration counts for status checks", async () => { + const migrationsRepo = repo({ + currentConfig: config("in_progress"), + counts: { complete: 10, failed: 1 }, + }) + + const result = await getPrimaryCashWalletCutoverStatus({ + cutoverVersion: 7, + runId: "run-7", + migrationsRepo, + }) + + expect(result).toEqual({ + config: config("in_progress"), + countsByStatus: { + complete: 10, + failed: 1, + }, + }) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts b/test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts new file mode 100644 index 000000000..331f0dbb4 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts @@ -0,0 +1,70 @@ +import { RepositoryError } from "@domain/errors" + +import { upsertPrimaryCashWalletMigrationRecords } from "@app/cash-wallet-cutover/migration-records" + +const plan = (accountId: AccountId): PrimaryCashWalletMigrationPlan => ({ + accountId, + accountUuid: `${accountId}-uuid` as AccountUuid, + legacyUsdWalletId: `${accountId}-usd` as WalletId, + destinationUsdtWalletId: `${accountId}-usdt` as WalletId, + previousDefaultWalletId: `${accountId}-default` as WalletId, + cutoverVersion: 5, + runId: "run-5", + idempotencyKey: `cash-wallet-cutover:run-5:${accountId}`, +}) + +describe("cash wallet migration record upsert", () => { + it("upserts one not-started migration record for each primary plan", async () => { + const migrationsRepo = { + upsertMigration: jest.fn(async (args) => ({ + id: `${args.accountId}-migration`, + ...args, + status: "not_started" as CashWalletMigrationStatus, + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), + })), + } + + const result = await upsertPrimaryCashWalletMigrationRecords({ + migrationsRepo, + plans: [plan("account-1" as AccountId), plan("account-2" as AccountId)], + }) + + expect(result).toEqual([ + expect.objectContaining({ id: "account-1-migration", accountId: "account-1" }), + expect.objectContaining({ id: "account-2-migration", accountId: "account-2" }), + ]) + expect(migrationsRepo.upsertMigration).toHaveBeenCalledTimes(2) + expect(migrationsRepo.upsertMigration).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + accountId: "account-1", + accountUuid: "account-1-uuid", + legacyUsdWalletId: "account-1-usd", + destinationUsdtWalletId: "account-1-usdt", + previousDefaultWalletId: "account-1-default", + cutoverVersion: 5, + runId: "run-5", + idempotencyKey: "cash-wallet-cutover:run-5:account-1", + }), + ) + }) + + it("returns repository errors and stops creating more records", async () => { + const error = new RepositoryError("could not upsert migration") + const migrationsRepo = { + upsertMigration: jest + .fn() + .mockResolvedValueOnce(error) + .mockResolvedValueOnce({} as CashWalletMigration), + } + + const result = await upsertPrimaryCashWalletMigrationRecords({ + migrationsRepo, + plans: [plan("account-1" as AccountId), plan("account-2" as AccountId)], + }) + + expect(result).toBe(error) + expect(migrationsRepo.upsertMigration).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts new file mode 100644 index 000000000..c6e4c69a3 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts @@ -0,0 +1,65 @@ +import { + assertCanTransition, + nextResumeStatus, +} from "@app/cash-wallet-cutover/state-machine" + +describe("cash wallet cutover migration state machine", () => { + it("allows the happy-path checkpoint order", () => { + const statuses = [ + "not_started", + "started", + "provisioned", + "balance_read", + "invoice_created", + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "fee_reimbursed", + "pointer_flipped", + "legacy_zero_verified", + "complete", + ] as const + + for (let i = 0; i < statuses.length - 1; i++) { + expect(assertCanTransition(statuses[i], statuses[i + 1])).toBe(true) + } + }) + + it("rejects pointer flip before fee reimbursement", () => { + expect( + assertCanTransition("balance_move_verified", "pointer_flipped"), + ).toBeInstanceOf(Error) + }) + + it("allows skipping fee reimbursement when there is no shortfall", () => { + expect(assertCanTransition("balance_move_verified", "fee_reimbursed")).toBe(true) + }) + + it("allows invoice refreshes before paying resumable invoices", () => { + expect(assertCanTransition("invoice_created", "invoice_created")).toBe(true) + expect( + assertCanTransition( + "fee_reimbursement_invoice_created", + "fee_reimbursement_invoice_created", + ), + ).toBe(true) + }) + + it("resumes from stored checkpoint without repeating completed side effects", () => { + expect(nextResumeStatus("invoice_created")).toBe("invoice_created") + expect(nextResumeStatus("balance_move_sent")).toBe("balance_move_sent") + expect(nextResumeStatus("fee_reimbursement_invoice_created")).toBe( + "fee_reimbursement_invoice_created", + ) + }) + + it("does not progress terminal/manual-review states without override", () => { + expect(assertCanTransition("complete", "started")).toBeInstanceOf(Error) + expect(assertCanTransition("failed", "started")).toBeInstanceOf(Error) + expect(assertCanTransition("requires_operator_review", "started")).toBeInstanceOf( + Error, + ) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts b/test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts new file mode 100644 index 000000000..56121ccfd --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts @@ -0,0 +1,859 @@ +import { + buildCashWalletCutoverOperatorSnapshot, + formatCashWalletCutoverOperatorSnapshotCsv, + parseCashWalletCutoverOperatorManifest, +} from "@app/cash-wallet-cutover/operator-dashboard" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = ({ + id, + defaultWalletId, + uuid, + role, +}: { + id: AccountId + defaultWalletId: WalletId + uuid?: AccountUuid + role?: string +}): Account => + ({ + id, + uuid, + defaultWalletId, + role, + }) as Account + +const wallet = ({ + id, + accountId, + currency, +}: { + id: WalletId + accountId: AccountId + currency: WalletCurrency +}): Wallet => ({ + id, + accountId, + currency, + type: WalletType.Checking, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "" as Lnurl, +}) + +describe("cash wallet cutover operator dashboard", () => { + it("parses both generated manifest shapes", () => { + expect( + parseCashWalletCutoverOperatorManifest({ + runId: "eng345usd", + accounts: [ + { + index: 1, + phone: "+16509940000", + username: "eng345usd01", + accountId: "account-1", + usdWalletId: "usd-1", + usdtWalletId: "usdt-1", + }, + ], + }), + ).toEqual([ + { + batchRunId: "eng345usd", + index: 1, + phone: "+16509940000", + username: "eng345usd01", + accountId: "account-1", + expectedUsdWalletId: "usd-1", + expectedUsdtWalletId: "usdt-1", + }, + ]) + + expect( + parseCashWalletCutoverOperatorManifest({ + runId: "eng345usdonly", + created: [ + { + index: 1, + phone: "+16509941000", + accountId: "account-2", + usdWalletId: "usd-2", + }, + ], + }), + ).toEqual([ + { + batchRunId: "eng345usdonly", + index: 1, + phone: "+16509941000", + accountId: "account-2", + expectedUsdWalletId: "usd-2", + }, + ]) + }) + + it("formats the full operator snapshot as escaped account-level CSV", () => { + const csv = formatCashWalletCutoverOperatorSnapshotCsv({ + generatedAt: "2026-05-28T20:00:00.000Z", + cutover: { + state: "in_progress" as CashWalletCutoverState, + cutoverVersion: 7, + runId: "run-7", + updatedAt: "2026-05-28T19:59:00.000Z", + }, + summary: { + accounts: 1, + wallets: { + current: 2, + target: 2, + usd: 1, + usdt: 1, + missingUsdt: 0, + }, + fundedUsdOnlyAccounts: 0, + usdTotalCents: 123, + usdtTotalMicros: 456_000, + anomalies: 1, + watchlistAnomalies: 1, + canStart: false, + blockers: 0, + watchlistAccounts: 1, + migrationStatuses: { complete: 1 }, + }, + accounts: [ + { + batchRunId: "batch,one", + index: 1, + phone: "+16509940000", + username: 'quoted"user', + accountId: "account-1" as AccountId, + accountUuid: "uuid-1" as AccountUuid, + expectedUsdWalletId: "usd-1" as WalletId, + expectedUsdtWalletId: "usdt-1" as WalletId, + watchlisted: true, + defaultWalletId: "usd-1" as WalletId, + defaultWalletCurrency: WalletCurrency.Usd, + walletCount: 2, + usdWallets: [ + { + id: "usd-1" as WalletId, + currency: WalletCurrency.Usd, + expected: true, + balance: { + currency: WalletCurrency.Usd, + display: "$1.23", + minorUnits: "123", + minorUnitsNumber: 123, + status: "fresh", + }, + }, + ], + usdtWallets: [ + { + id: "usdt-1" as WalletId, + currency: WalletCurrency.Usdt, + expected: true, + balance: { + currency: WalletCurrency.Usdt, + display: "0.46 USDT", + minorUnits: "456000", + minorUnitsNumber: 456000, + status: "fresh", + }, + }, + ], + migrationStatus: "complete", + migrationUpdatedAt: "2026-05-28T20:00:00.000Z", + cutoverBalanceAudit: { + status: "verified", + sourceUsdCents: 123, + expectedMinimumUsdtMicros: 1_230_000, + destinationStartingBalanceUsdtMicros: 0, + currentDestinationBalanceUsdtMicros: 1_240_000, + finalDeltaUsdtMicros: 1_240_000, + roundingSubsidyUsdtMicros: 10_000, + shortfallUsdtMicros: 0, + }, + anomalies: ["manual,review"], + }, + ], + }) + + expect(csv.split("\n")[0]).toBe( + [ + "generatedAt", + "cutoverState", + "cutoverVersion", + "cutoverRunId", + "cutoverUpdatedAt", + "watchlisted", + "batchRunId", + "index", + "phone", + "username", + "accountId", + "accountUuid", + "defaultWalletId", + "defaultWalletCurrency", + "expectedUsdWalletId", + "expectedUsdtWalletId", + "walletCount", + "usdWalletIds", + "usdBalanceDisplays", + "usdBalanceMinorUnits", + "usdBalanceStatuses", + "usdtWalletIds", + "usdtBalanceDisplays", + "usdtBalanceMinorUnits", + "usdtBalanceStatuses", + "migrationStatus", + "migrationUpdatedAt", + "cutoverBalanceAudit", + "anomalies", + ].join(","), + ) + expect(csv).toContain('"batch,one"') + expect(csv).toContain('"quoted""user"') + expect(csv).toContain('"manual,review"') + expect(csv).toContain("usd-1") + expect(csv).toContain("usdt-1") + expect(csv).toContain("roundingSubsidyUsdtMicros=10000") + }) + + it("summarizes raw wallets, balances, migrations, and anomalies", async () => { + const usdTenCents = USDAmount.cents(10n) + const usdtTwentyFiveCents = USDTAmount.smallestUnits(250_000n) + if (usdTenCents instanceof Error) throw usdTenCents + if (usdtTwentyFiveCents instanceof Error) throw usdtTwentyFiveCents + + const accounts = new Map([ + [ + "account-1", + account({ + id: "account-1" as AccountId, + uuid: "uuid-1" as AccountUuid, + defaultWalletId: "usd-1" as WalletId, + }), + ], + [ + "account-2", + account({ + id: "account-2" as AccountId, + uuid: "uuid-2" as AccountUuid, + defaultWalletId: "usd-2" as WalletId, + }), + ], + ]) + + const wallets = new Map([ + [ + "account-1", + [ + wallet({ + id: "usd-1" as WalletId, + accountId: "account-1" as AccountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: "usdt-1" as WalletId, + accountId: "account-1" as AccountId, + currency: WalletCurrency.Usdt, + }), + ], + ], + [ + "account-2", + [ + wallet({ + id: "usd-2" as WalletId, + accountId: "account-2" as AccountId, + currency: WalletCurrency.Usd, + }), + ], + ], + ]) + + const snapshot = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts: [ + { + batchRunId: "batch", + index: 1, + phone: "+16509940000", + accountId: "account-1" as AccountId, + }, + { + batchRunId: "batch", + index: 2, + phone: "+16509941000", + accountId: "account-2" as AccountId, + }, + ], + accountsRepo: { + findById: jest.fn(async (id: AccountId) => accounts.get(id) as Account), + }, + walletsRepo: { + listByAccountId: jest.fn(async (id: AccountId) => wallets.get(id) ?? []), + }, + migrationsRepo: { + getConfig: jest.fn(async () => ({ + state: "in_progress" as CashWalletCutoverState, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + findMigrationByAccountId: jest.fn( + async ({ accountId }: { accountId: AccountId }) => + accountId === "account-1" + ? { + id: "migration-1", + accountId, + legacyUsdWalletId: "usd-1" as WalletId, + destinationUsdtWalletId: "usdt-1" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status: "complete" as CashWalletMigrationStatus, + idempotencyKey: "key", + attempts: 1, + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + } + : null, + ), + }, + getBalanceForWallet: jest.fn(async ({ walletId }: { walletId: WalletId }) => + walletId === "usdt-1" ? usdtTwentyFiveCents : usdTenCents, + ), + preflightReport: { + cutoverVersion: 7, + runId: "run-7", + totalAccounts: 101, + migrationCandidates: 81, + alreadyUsdt: 10, + residualLegacyUsd: 0, + blockers: 10, + blockerAccounts: [], + canStart: false, + }, + now: new Date("2026-05-26T20:01:00.000Z"), + }) + + expect(snapshot.preflight).toMatchObject({ + totalAccounts: 101, + migrationCandidates: 81, + blockers: 10, + canStart: false, + }) + expect(snapshot.summary.accounts).toBe(2) + expect(snapshot.summary.wallets.current).toBe(3) + expect(snapshot.summary.wallets.target).toBe(4) + expect(snapshot.summary.wallets.missingUsdt).toBe(1) + expect(snapshot.summary.canStart).toBe(false) + expect(snapshot.summary.blockers).toBe(1) + expect(snapshot.summary.fundedUsdOnlyAccounts).toBe(1) + expect(snapshot.summary.usdTotalCents).toBe(20) + expect(snapshot.summary.usdtTotalMicros).toBe(250000) + expect(snapshot.summary.migrationStatuses).toEqual({ complete: 1, none: 1 }) + expect(snapshot.accounts[1].anomalies).toContain("missing_usdt") + }) + + it("reports completed migration final balance audit fields", async () => { + const zeroUsd = USDAmount.cents(0n) + const finalUsdt = USDTAmount.smallestUnits(108_000n) + if (zeroUsd instanceof Error) throw zeroUsd + if (finalUsdt instanceof Error) throw finalUsdt + + const snapshot = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts: [ + { + index: 1, + accountId: "account-1" as AccountId, + expectedUsdWalletId: "usd-1" as WalletId, + expectedUsdtWalletId: "usdt-1" as WalletId, + }, + ], + accountsRepo: { + findById: jest.fn(async () => + account({ + id: "account-1" as AccountId, + defaultWalletId: "usdt-1" as WalletId, + }), + ), + }, + walletsRepo: { + listByAccountId: jest.fn(async () => [ + wallet({ + id: "usd-1" as WalletId, + accountId: "account-1" as AccountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: "usdt-1" as WalletId, + accountId: "account-1" as AccountId, + currency: WalletCurrency.Usdt, + }), + ]), + }, + migrationsRepo: { + getConfig: jest.fn(async () => ({ + state: "complete" as CashWalletCutoverState, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + findMigrationByAccountId: jest.fn(async () => ({ + id: "migration-1", + accountId: "account-1" as AccountId, + legacyUsdWalletId: "usd-1" as WalletId, + destinationUsdtWalletId: "usdt-1" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status: "complete" as CashWalletMigrationStatus, + sourceBalanceUsdCents: "10", + destinationAmountUsdtMicros: "100000", + destinationStartingBalanceUsdtMicros: "0", + feeAmountUsdtMicros: "2000", + feeAmountUsdCents: "1", + idempotencyKey: "key", + attempts: 1, + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + }, + getBalanceForWallet: jest.fn(async ({ currency }: { currency?: WalletCurrency }) => + currency === WalletCurrency.Usdt ? finalUsdt : zeroUsd, + ), + now: new Date("2026-05-26T20:01:00.000Z"), + }) + + expect(snapshot.accounts[0].cutoverBalanceAudit).toEqual({ + status: "verified", + sourceUsdCents: 10, + expectedMinimumUsdtMicros: 100_000, + destinationStartingBalanceUsdtMicros: 0, + currentDestinationBalanceUsdtMicros: 108_000, + finalDeltaUsdtMicros: 108_000, + roundingSubsidyUsdtMicros: 8_000, + shortfallUsdtMicros: 0, + }) + }) + + it("does not report an audit shortfall while destination balances are still loading", async () => { + const snapshot = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts: [ + { + index: 1, + accountId: "account-1" as AccountId, + expectedUsdWalletId: "usd-1" as WalletId, + expectedUsdtWalletId: "usdt-1" as WalletId, + }, + ], + accountsRepo: { + findById: jest.fn(async () => + account({ + id: "account-1" as AccountId, + defaultWalletId: "usdt-1" as WalletId, + }), + ), + }, + walletsRepo: { + listByAccountId: jest.fn(async () => [ + wallet({ + id: "usd-1" as WalletId, + accountId: "account-1" as AccountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: "usdt-1" as WalletId, + accountId: "account-1" as AccountId, + currency: WalletCurrency.Usdt, + }), + ]), + }, + migrationsRepo: { + getConfig: jest.fn(async () => ({ + state: "complete" as CashWalletCutoverState, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + findMigrationByAccountId: jest.fn(async () => ({ + id: "migration-1", + accountId: "account-1" as AccountId, + legacyUsdWalletId: "usd-1" as WalletId, + destinationUsdtWalletId: "usdt-1" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status: "complete" as CashWalletMigrationStatus, + sourceBalanceUsdCents: "10", + destinationAmountUsdtMicros: "100000", + destinationStartingBalanceUsdtMicros: "0", + idempotencyKey: "key", + attempts: 1, + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + }, + getBalanceForWallet: jest.fn(), + balanceMode: "structural", + now: new Date("2026-05-26T20:01:00.000Z"), + }) + + expect(snapshot.accounts[0].cutoverBalanceAudit).toMatchObject({ + status: "loading", + finalDeltaUsdtMicros: 0, + roundingSubsidyUsdtMicros: 0, + shortfallUsdtMicros: 0, + }) + }) + + it("includes funder balances in reconciliation without adding migration rows", async () => { + const customerUsd = USDAmount.cents(452n) + const customerUsdt = USDTAmount.smallestUnits(45_200_00n) + const funderUsd = USDAmount.cents(418n) + const funderUsdt = USDTAmount.smallestUnits(9_900_000n) + if (customerUsd instanceof Error) throw customerUsd + if (customerUsdt instanceof Error) throw customerUsdt + if (funderUsd instanceof Error) throw funderUsd + if (funderUsdt instanceof Error) throw funderUsdt + + const accounts = new Map([ + [ + "customer-account", + account({ + id: "customer-account" as AccountId, + defaultWalletId: "customer-usdt" as WalletId, + }), + ], + [ + "funder-account", + account({ + id: "funder-account" as AccountId, + defaultWalletId: "funder-usd" as WalletId, + role: "funder", + }), + ], + ]) + + const wallets = new Map([ + [ + "customer-account", + [ + wallet({ + id: "customer-usd" as WalletId, + accountId: "customer-account" as AccountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: "customer-usdt" as WalletId, + accountId: "customer-account" as AccountId, + currency: WalletCurrency.Usdt, + }), + ], + ], + [ + "funder-account", + [ + wallet({ + id: "funder-usd" as WalletId, + accountId: "funder-account" as AccountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: "funder-usdt" as WalletId, + accountId: "funder-account" as AccountId, + currency: WalletCurrency.Usdt, + }), + ], + ], + ]) + + const snapshot = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts: [], + discoveredAccounts: [ + { + status: "usdt_default", + accountId: "customer-account" as AccountId, + legacyUsdWalletId: "customer-usd" as WalletId, + destinationUsdtWalletId: "customer-usdt" as WalletId, + previousDefaultWalletId: "customer-usd" as WalletId, + }, + ], + treasuryAccountIds: ["funder-account" as AccountId], + accountsRepo: { + findById: jest.fn(async (id: AccountId) => accounts.get(id) as Account), + }, + walletsRepo: { + listByAccountId: jest.fn(async (id: AccountId) => wallets.get(id) ?? []), + }, + migrationsRepo: { + getConfig: jest.fn(async () => ({ + state: "complete" as CashWalletCutoverState, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + findMigrationByAccountId: jest.fn(async () => null), + }, + getBalanceForWallet: jest.fn(async ({ walletId }: { walletId: WalletId }) => { + if (walletId === "customer-usd") return customerUsd + if (walletId === "customer-usdt") return customerUsdt + if (walletId === "funder-usd") return funderUsd + return funderUsdt + }), + now: new Date("2026-05-26T20:01:00.000Z"), + }) + + expect(snapshot.accounts.map((row) => row.accountId)).toEqual([ + "customer-account", + ]) + expect(snapshot.treasury.accounts.map((row) => row.accountId)).toEqual([ + "funder-account", + ]) + expect(snapshot.summary.usdTotalCents).toBe(452) + expect(snapshot.summary.usdtTotalMicros).toBe(4_520_000) + expect(snapshot.treasury.summary.usdTotalCents).toBe(418) + expect(snapshot.treasury.summary.usdtTotalMicros).toBe(9_900_000) + expect(snapshot.reconciliation.customerTotalCents).toBe(904) + expect(snapshot.reconciliation.treasuryTotalCents).toBe(1_408) + expect(snapshot.reconciliation.systemTotalCents).toBe(2_312) + }) + + it("uses global discoveries as dashboard rows while highlighting manifest accounts", async () => { + const zeroUsd = USDAmount.cents(0n) + const zeroUsdt = USDTAmount.smallestUnits(0n) + if (zeroUsd instanceof Error) throw zeroUsd + if (zeroUsdt instanceof Error) throw zeroUsdt + + const accounts = new Map([ + [ + "watchlist-account", + account({ + id: "watchlist-account" as AccountId, + uuid: "watchlist-uuid" as AccountUuid, + defaultWalletId: "watchlist-usd" as WalletId, + }), + ], + [ + "global-account", + account({ + id: "global-account" as AccountId, + uuid: "global-uuid" as AccountUuid, + defaultWalletId: "global-usd" as WalletId, + }), + ], + ]) + + const wallets = new Map([ + [ + "watchlist-account", + [ + wallet({ + id: "watchlist-usd" as WalletId, + accountId: "watchlist-account" as AccountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: "watchlist-usdt" as WalletId, + accountId: "watchlist-account" as AccountId, + currency: WalletCurrency.Usdt, + }), + ], + ], + [ + "global-account", + [ + wallet({ + id: "global-usd" as WalletId, + accountId: "global-account" as AccountId, + currency: WalletCurrency.Usd, + }), + wallet({ + id: "global-usdt" as WalletId, + accountId: "global-account" as AccountId, + currency: WalletCurrency.Usdt, + }), + wallet({ + id: "global-extra-usd" as WalletId, + accountId: "global-account" as AccountId, + currency: WalletCurrency.Usd, + }), + ], + ], + ]) + + const snapshot = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts: [ + { + batchRunId: "batch", + index: 1, + phone: "+16509940000", + accountId: "watchlist-account" as AccountId, + expectedUsdWalletId: "watchlist-usd" as WalletId, + expectedUsdtWalletId: "watchlist-usdt" as WalletId, + }, + ], + discoveredAccounts: [ + { + status: "legacy_default", + accountId: "watchlist-account" as AccountId, + accountUuid: "watchlist-uuid" as AccountUuid, + legacyUsdWalletId: "watchlist-usd" as WalletId, + destinationUsdtWalletId: "watchlist-usdt" as WalletId, + previousDefaultWalletId: "watchlist-usd" as WalletId, + }, + { + status: "legacy_default", + accountId: "global-account" as AccountId, + accountUuid: "global-uuid" as AccountUuid, + legacyUsdWalletId: "global-usd" as WalletId, + destinationUsdtWalletId: "global-usdt" as WalletId, + previousDefaultWalletId: "global-usd" as WalletId, + }, + ], + accountsRepo: { + findById: jest.fn(async (id: AccountId) => accounts.get(id) as Account), + }, + walletsRepo: { + listByAccountId: jest.fn(async (id: AccountId) => wallets.get(id) ?? []), + }, + migrationsRepo: { + getConfig: jest.fn(async () => ({ + state: "pre" as CashWalletCutoverState, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + findMigrationByAccountId: jest.fn(async () => null), + }, + getBalanceForWallet: jest.fn(async ({ currency }: { currency?: WalletCurrency }) => + currency === WalletCurrency.Usdt ? zeroUsdt : zeroUsd, + ), + now: new Date("2026-05-26T20:01:00.000Z"), + }) + + expect(snapshot.summary.accounts).toBe(2) + expect(snapshot.summary.watchlistAccounts).toBe(1) + expect(snapshot.summary.anomalies).toBe(1) + expect(snapshot.summary.watchlistAnomalies).toBe(0) + expect(snapshot.accounts.map((row) => row.accountId)).toEqual([ + "watchlist-account", + "global-account", + ]) + expect(snapshot.accounts[0].watchlisted).toBe(true) + expect(snapshot.accounts[0].phone).toBe("+16509940000") + expect(snapshot.accounts[1].watchlisted).toBe(false) + expect(snapshot.accounts[1].expectedUsdWalletId).toBe("global-usd") + expect(snapshot.accounts[1].expectedUsdtWalletId).toBe("global-usdt") + expect(snapshot.accounts[1].anomalies).toContain("duplicate_usd") + expect(snapshot.accounts[1].anomalies).toContain("unexpected_wallet_id") + }) + + it("retries transient balance read errors before marking a wallet anomalous", async () => { + const balance = USDAmount.cents(10n) + if (balance instanceof Error) throw balance + + const getBalanceForWallet = jest + .fn() + .mockResolvedValueOnce(new Error("temporary ibex failure")) + .mockResolvedValueOnce(balance) + + const snapshot = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts: [ + { + index: 1, + phone: "+16509941000", + accountId: "account-1" as AccountId, + }, + ], + accountsRepo: { + findById: jest.fn(async () => + account({ + id: "account-1" as AccountId, + defaultWalletId: "usd-1" as WalletId, + }), + ), + }, + walletsRepo: { + listByAccountId: jest.fn(async () => [ + wallet({ + id: "usd-1" as WalletId, + accountId: "account-1" as AccountId, + currency: WalletCurrency.Usd, + }), + ]), + }, + migrationsRepo: { + getConfig: jest.fn(async () => ({ + state: "complete" as CashWalletCutoverState, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + findMigrationByAccountId: jest.fn(async () => null), + }, + getBalanceForWallet, + balanceReadAttempts: 2, + now: new Date("2026-05-26T20:01:00.000Z"), + }) + + expect(getBalanceForWallet).toHaveBeenCalledTimes(2) + expect(snapshot.accounts[0].usdWallets[0].balance.display).toBe("$0.10") + expect(snapshot.accounts[0].anomalies).toEqual(["missing_usdt"]) + }) + + it("builds a structural snapshot without reading wallet balances", async () => { + const getBalanceForWallet = jest.fn() + + const snapshot = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts: [ + { + index: 1, + phone: "+16509941000", + accountId: "account-1" as AccountId, + expectedUsdWalletId: "usd-1" as WalletId, + }, + ], + accountsRepo: { + findById: jest.fn(async () => + account({ + id: "account-1" as AccountId, + defaultWalletId: "usd-1" as WalletId, + }), + ), + }, + walletsRepo: { + listByAccountId: jest.fn(async () => [ + wallet({ + id: "usd-1" as WalletId, + accountId: "account-1" as AccountId, + currency: WalletCurrency.Usd, + }), + ]), + }, + migrationsRepo: { + getConfig: jest.fn(async () => ({ + state: "in_progress" as CashWalletCutoverState, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-26T20:00:00.000Z"), + })), + findMigrationByAccountId: jest.fn(async () => null), + }, + getBalanceForWallet, + balanceMode: "structural", + now: new Date("2026-05-26T20:01:00.000Z"), + }) + + expect(getBalanceForWallet).not.toHaveBeenCalled() + expect(snapshot.summary.wallets.current).toBe(1) + expect(snapshot.summary.usdTotalCents).toBe(0) + expect(snapshot.summary.fundedUsdOnlyAccounts).toBe(0) + expect(snapshot.accounts[0].usdWallets[0]).toMatchObject({ + id: "usd-1", + balance: { + status: "loading", + display: "loading", + minorUnitsNumber: 0, + }, + }) + expect(snapshot.accounts[0].anomalies).toEqual(["missing_usdt"]) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts new file mode 100644 index 000000000..32811fb58 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts @@ -0,0 +1,84 @@ +jest.mock("@app/accounts", () => ({ + addWalletIfNonexistent: jest.fn(), + updateDefaultWalletId: jest.fn(), +})) +jest.mock("@app/wallets", () => ({ + addInvoiceForRecipientForUsdWallet: jest.fn(), + addInvoiceNoAmountForRecipient: jest.fn(), + getBalanceForWallet: jest.fn(), +})) +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ findById: jest.fn() })), + CashWalletCutoverRepository: jest.fn(), +})) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + payInvoice: jest.fn(), + getTransactionDetails: jest.fn(), + }, +})) + +import { runPrimaryCashWalletCutoverBatch } from "@app/cash-wallet-cutover/orchestrator" + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +describe("primary cash wallet cutover orchestrator", () => { + it("runs a locked batch with default step handlers", async () => { + const started = migration("not_started") + const locked = migration("not_started") + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("started")), + listRunnableMigrations: jest.fn(async () => [started]), + acquireMigrationLock: jest.fn(async () => locked), + markMigrationFailed: jest.fn(), + releaseMigrationLock: jest.fn(async () => locked), + } + const runtimeServices = { + now: jest.fn(() => new Date("2026-05-20T16:00:00Z")), + provisioningService: { ensureDestinationWallet: jest.fn() }, + balanceReader: { + readSourceBalanceUsdCents: jest.fn(), + readDestinationBalanceUsdtMicros: jest.fn(), + }, + invoiceService: { createInvoice: jest.fn(), createNoAmountInvoice: jest.fn() }, + paymentService: { payInvoice: jest.fn() }, + balanceVerifier: { verifyBalanceMove: jest.fn() }, + feeService: { readFeeAmountUsdtMicros: jest.fn() }, + treasuryService: { getTreasuryWalletId: jest.fn() }, + pointerService: { flipDefaultWallet: jest.fn() }, + legacyWalletVerifier: { verifyLegacyWalletZero: jest.fn() }, + } + + const result = await runPrimaryCashWalletCutoverBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 5, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + runtimeServices, + }) + + expect(result).toEqual({ attempted: 1, advanced: 1, failed: 0, skipped: 0 }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "not_started", + to: "started", + cutoverVersion: 7, + runId: "run-7", + patch: { startedAt: new Date("2026-05-20T16:00:00Z") }, + }) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/planner.spec.ts b/test/flash/unit/app/cash-wallet-cutover/planner.spec.ts new file mode 100644 index 000000000..61f492a1b --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/planner.spec.ts @@ -0,0 +1,51 @@ +import { buildPrimaryCashWalletMigrationPlan } from "@app/cash-wallet-cutover/planner" + +const discovery = ( + status: CashWalletCutoverDiscoveryStatus, + accountId: AccountId, +): CashWalletCutoverDiscovery => ({ + status, + accountId, + accountUuid: `${accountId}-uuid` as AccountUuid, + legacyUsdWalletId: `${accountId}-usd` as WalletId, + destinationUsdtWalletId: `${accountId}-usdt` as WalletId, + previousDefaultWalletId: `${accountId}-default` as WalletId, +}) + +describe("primary cash wallet migration planner", () => { + it("creates deterministic migration plans for legacy-default accounts only", () => { + const result = buildPrimaryCashWalletMigrationPlan({ + cutoverVersion: 4, + runId: "run-4", + discoveries: [ + discovery("legacy_default", "account-1" as AccountId), + discovery("already_usdt", "account-2" as AccountId), + discovery("residual_legacy_usd", "account-3" as AccountId), + discovery("legacy_default", "account-4" as AccountId), + ], + }) + + expect(result).toEqual([ + { + accountId: "account-1", + accountUuid: "account-1-uuid", + legacyUsdWalletId: "account-1-usd", + destinationUsdtWalletId: "account-1-usdt", + previousDefaultWalletId: "account-1-default", + cutoverVersion: 4, + runId: "run-4", + idempotencyKey: "cash-wallet-cutover:run-4:account-1", + }, + { + accountId: "account-4", + accountUuid: "account-4-uuid", + legacyUsdWalletId: "account-4-usd", + destinationUsdtWalletId: "account-4-usdt", + previousDefaultWalletId: "account-4-default", + cutoverVersion: 4, + runId: "run-4", + idempotencyKey: "cash-wallet-cutover:run-4:account-4", + }, + ]) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts b/test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts new file mode 100644 index 000000000..66a1a5aad --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts @@ -0,0 +1,65 @@ +import { buildCashWalletCutoverPreflightReport } from "@app/cash-wallet-cutover/preflight" + +const discovery = ( + status: CashWalletCutoverDiscoveryStatus, + accountId = `${status}-account` as AccountId, +): CashWalletCutoverDiscovery => ({ + status, + accountId, + accountUuid: `${accountId}-uuid` as AccountUuid, + legacyUsdWalletId: + status === "missing_legacy_usd" ? undefined : (`${accountId}-usd` as WalletId), + destinationUsdtWalletId: + status === "missing_destination_usdt" ? undefined : (`${accountId}-usdt` as WalletId), + previousDefaultWalletId: `${accountId}-default` as WalletId, +}) + +describe("cash wallet cutover preflight report", () => { + it("counts migration candidates and non-migrating classifications", () => { + const report = buildCashWalletCutoverPreflightReport({ + cutoverVersion: 3, + runId: "run-3", + discoveries: [ + discovery("legacy_default", "legacy-1" as AccountId), + discovery("legacy_default", "legacy-2" as AccountId), + discovery("already_usdt"), + discovery("residual_legacy_usd"), + discovery("missing_legacy_usd"), + discovery("missing_destination_usdt"), + ], + }) + + expect(report).toMatchObject({ + cutoverVersion: 3, + runId: "run-3", + totalAccounts: 6, + migrationCandidates: 2, + alreadyUsdt: 1, + residualLegacyUsd: 1, + blockers: 2, + canStart: false, + }) + expect(report.blockerAccounts).toEqual([ + { accountId: "missing_legacy_usd-account", reason: "missing_legacy_usd" }, + { + accountId: "missing_destination_usdt-account", + reason: "missing_destination_usdt", + }, + ]) + }) + + it("allows start when every account is either migratable, already migrated, or residual", () => { + const report = buildCashWalletCutoverPreflightReport({ + cutoverVersion: 3, + runId: "run-3", + discoveries: [ + discovery("legacy_default"), + discovery("already_usdt"), + discovery("residual_legacy_usd"), + ], + }) + + expect(report.canStart).toBe(true) + expect(report.blockerAccounts).toEqual([]) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts b/test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts new file mode 100644 index 000000000..137c373dc --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts @@ -0,0 +1,123 @@ +import { RepositoryError } from "@domain/errors" + +import { preparePrimaryCashWalletCutover } from "@app/cash-wallet-cutover/prepare" + +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = (id: AccountId, defaultWalletId: WalletId): Account => + ({ + id, + uuid: `${id}-uuid` as AccountUuid, + defaultWalletId, + }) as Account + +const wallet = (accountId: AccountId, id: WalletId, currency: WalletCurrency): Wallet => + ({ + id, + accountId, + type: WalletType.Checking, + currency, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "lnurl" as Lnurl, + }) as Wallet + +async function* unlockedAccounts(accounts: Account[]): AsyncGenerator { + for (const account of accounts) yield account +} + +describe("prepare primary cash wallet cutover", () => { + it("discovers accounts, builds preflight, and upserts primary migration records", async () => { + const accountOne = account("account-1" as AccountId, "account-1-usd" as WalletId) + const accountTwo = account("account-2" as AccountId, "account-2-usdt" as WalletId) + const walletsRepo = { + listByAccountId: jest.fn(async (accountId: AccountId) => [ + wallet(accountId, `${accountId}-usd` as WalletId, WalletCurrency.Usd), + wallet(accountId, `${accountId}-usdt` as WalletId, WalletCurrency.Usdt), + ]), + } + const migrationsRepo = { + upsertMigration: jest.fn(async (plan: PrimaryCashWalletMigrationPlan) => ({ + id: `${plan.accountId}-migration`, + ...plan, + status: "not_started" as CashWalletMigrationStatus, + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), + })), + } + + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 6, + runId: "run-6", + accountsRepo: { + listUnlockedAccounts: () => unlockedAccounts([accountOne, accountTwo]), + }, + walletsRepo, + migrationsRepo, + }) + + expect(result).toMatchObject({ + report: { + totalAccounts: 2, + migrationCandidates: 1, + alreadyUsdt: 1, + blockers: 0, + canStart: true, + }, + plannedMigrations: [ + expect.objectContaining({ + accountId: "account-1", + idempotencyKey: "cash-wallet-cutover:run-6:account-1", + }), + ], + migrations: [expect.objectContaining({ id: "account-1-migration" })], + }) + expect(migrationsRepo.upsertMigration).toHaveBeenCalledTimes(1) + }) + + it("does not create migration records when preflight has blockers", async () => { + const blockedAccount = account("account-1" as AccountId, "account-1-usd" as WalletId) + const migrationsRepo = { + upsertMigration: jest.fn(), + } + + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 6, + runId: "run-6", + accountsRepo: { listUnlockedAccounts: () => unlockedAccounts([blockedAccount]) }, + walletsRepo: { + listByAccountId: jest.fn(async (accountId: AccountId) => [ + wallet(accountId, `${accountId}-usd` as WalletId, WalletCurrency.Usd), + ]), + }, + migrationsRepo, + }) + + expect(result).toMatchObject({ + report: { + blockers: 1, + canStart: false, + }, + plannedMigrations: [], + migrations: [], + }) + expect(migrationsRepo.upsertMigration).not.toHaveBeenCalled() + }) + + it("returns repository errors from discovery", async () => { + const error = new RepositoryError("wallet lookup failed") + const result = await preparePrimaryCashWalletCutover({ + cutoverVersion: 6, + runId: "run-6", + accountsRepo: { + listUnlockedAccounts: () => + unlockedAccounts([account("account-1" as AccountId, "wallet-id" as WalletId)]), + }, + walletsRepo: { listByAccountId: jest.fn(async () => error) }, + migrationsRepo: { upsertMigration: jest.fn() }, + }) + + expect(result).toBe(error) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts b/test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts new file mode 100644 index 000000000..7e9557733 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts @@ -0,0 +1,135 @@ +jest.mock("@services/mongoose", () => ({ + CashWalletCutoverRepository: jest.fn(), + WalletsRepository: jest.fn(), +})) + +import { + resolveCashWalletMutationWalletIdForAccount, + resolveCashWalletPresentationForAccount, +} from "@app/cash-wallet-cutover/presentation-for-account" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = { id: "account-id", defaultWalletId: "legacy-usd-wallet-id" } as Account + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id, + accountId: account.id, + type: WalletType.Checking, + currency, + }) as Wallet + +const legacyUsdWallet = wallet({ + id: "legacy-usd-wallet-id", + currency: WalletCurrency.Usd, +}) +const usdtWallet = wallet({ + id: "usdt-wallet-id", + currency: WalletCurrency.Usdt, +}) + +const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ + state, + cutoverVersion: 2, + runId: "run-2", + updatedAt: new Date("2026-05-19T00:00:00Z"), +}) + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: account.id, + legacyUsdWalletId: legacyUsdWallet.id, + destinationUsdtWalletId: usdtWallet.id, + cutoverVersion: 2, + runId: "run-2", + status, + idempotencyKey: "run-2:account-id", + attempts: 0, + updatedAt: new Date("2026-05-19T00:00:00Z"), +}) + +describe("cash wallet presentation for account", () => { + it("uses existing migration lookup and presents old clients as legacy-compatible after migration", async () => { + const migrationsRepo = { + getConfig: jest.fn(async () => config("in_progress")), + findMigrationByAccountId: jest.fn(async () => migration("complete")), + } + const walletsRepo = { + listByAccountId: jest.fn(async () => [legacyUsdWallet, usdtWallet]), + } + + const result = await resolveCashWalletPresentationForAccount({ + account, + client: { + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, + }, + migrationsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + wallets: [legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }) + expect(migrationsRepo.findMigrationByAccountId).toHaveBeenCalledWith({ + accountId: account.id, + cutoverVersion: 2, + runId: "run-2", + }) + }) + + it("does not require migration lookup after global completion", async () => { + const migrationsRepo = { + getConfig: jest.fn(async () => config("complete")), + findMigrationByAccountId: jest.fn(), + } + const walletsRepo = { + listByAccountId: jest.fn(async () => [legacyUsdWallet, usdtWallet]), + } + + const result = await resolveCashWalletPresentationForAccount({ + account, + client: { + cashWalletPresentation: "usdt", + hasUsdtCashWalletSupport: true, + }, + migrationsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + wallets: [usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }) + expect(migrationsRepo.findMigrationByAccountId).not.toHaveBeenCalled() + }) + + it("routes old-client legacy USD mutation wallet ids to the active settlement wallet", async () => { + const migrationsRepo = { + getConfig: jest.fn(async () => config("in_progress")), + findMigrationByAccountId: jest.fn(async () => migration("complete")), + } + const walletsRepo = { + listByAccountId: jest.fn(async () => [legacyUsdWallet, usdtWallet]), + } + + const result = await resolveCashWalletMutationWalletIdForAccount({ + account, + walletId: legacyUsdWallet.id, + client: { + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, + }, + migrationsRepo, + walletsRepo, + }) + + expect(result).toBe(usdtWallet.id) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts b/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts new file mode 100644 index 000000000..8eb42fdc1 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts @@ -0,0 +1,120 @@ +import { + CashWalletMissingLegacyUsdWalletError, + CashWalletMissingUsdtWalletError, +} from "@app/cash-wallet-cutover/errors" +import { + cashWalletTransactionWalletIdsForPresentation, + resolveCashWalletPresentation, +} from "@app/cash-wallet-cutover/presentation" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id, + accountId: "account-id", + type: WalletType.Checking, + currency, + }) as Wallet + +const legacyUsdWallet = wallet({ + id: "legacy-usd-wallet-id", + currency: WalletCurrency.Usd, +}) +const usdtWallet = wallet({ + id: "usdt-wallet-id", + currency: WalletCurrency.Usdt, +}) +const btcWallet = wallet({ + id: "btc-wallet-id", + currency: WalletCurrency.Btc, +}) + +describe("cash wallet presentation resolver", () => { + it("returns the legacy USD wallet as the active settlement wallet before cutover", () => { + expect( + resolveCashWalletPresentation({ + decision: { presentation: "legacy_usd" }, + wallets: [btcWallet, legacyUsdWallet, usdtWallet], + }), + ).toEqual({ + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: legacyUsdWallet, + }) + }) + + it("presents legacy USD while routing settlement to USDT for old clients after migration", () => { + expect( + resolveCashWalletPresentation({ + decision: { presentation: "legacy_usd_compat" }, + wallets: [btcWallet, legacyUsdWallet, usdtWallet], + }), + ).toEqual({ + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }) + }) + + it("presents the USDT wallet directly for capable clients", () => { + expect( + resolveCashWalletPresentation({ + decision: { presentation: "usdt" }, + wallets: [btcWallet, legacyUsdWallet, usdtWallet], + }), + ).toEqual({ + wallets: [btcWallet, usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }) + }) + + it("returns cutover-state errors for missing presentation wallets", () => { + expect( + resolveCashWalletPresentation({ + decision: { presentation: "legacy_usd" }, + wallets: [usdtWallet], + }), + ).toBeInstanceOf(CashWalletMissingLegacyUsdWalletError) + + expect( + resolveCashWalletPresentation({ + decision: { presentation: "legacy_usd_compat" }, + wallets: [legacyUsdWallet], + }), + ).toBeInstanceOf(CashWalletMissingUsdtWalletError) + }) +}) + +describe("cash wallet transaction wallet ids for presentation", () => { + it("uses the active settlement wallet when defaulting legacy-compatible history", () => { + expect( + cashWalletTransactionWalletIdsForPresentation({ + presentation: { + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }, + }), + ).toEqual([btcWallet.id, usdtWallet.id]) + }) + + it("remaps explicit legacy USD wallet ids to active settlement wallet ids", () => { + expect( + cashWalletTransactionWalletIdsForPresentation({ + walletIds: [legacyUsdWallet.id], + presentation: { + wallets: [btcWallet, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + }, + }), + ).toEqual([usdtWallet.id]) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/preview.spec.ts b/test/flash/unit/app/cash-wallet-cutover/preview.spec.ts new file mode 100644 index 000000000..90bcc5045 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/preview.spec.ts @@ -0,0 +1,81 @@ +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(), + WalletsRepository: jest.fn(), +})) + +import { previewPrimaryCashWalletCutover } from "@app/cash-wallet-cutover/preview" +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +const account = ({ + id, + defaultWalletId, +}: { + id: string + defaultWalletId: string +}): Account => + ({ + id, + uuid: `${id}-uuid`, + defaultWalletId, + }) as Account + +const wallet = ({ id, currency }: { id: string; currency: WalletCurrency }): Wallet => + ({ + id, + type: WalletType.Checking, + currency, + }) as Wallet + +describe("preview primary cash wallet cutover", () => { + it("builds the preflight report and plan without repository writes", async () => { + const accounts = [ + account({ id: "account-1", defaultWalletId: "usd-1" }), + account({ id: "account-2", defaultWalletId: "usdt-2" }), + ] + + const accountsRepo = { + listUnlockedAccounts: function* () { + yield* accounts + }, + } + const walletsRepo = { + listByAccountId: jest.fn(async (accountId: AccountId) => { + if (accountId === "account-1") { + return [ + wallet({ id: "usd-1", currency: WalletCurrency.Usd }), + wallet({ id: "usdt-1", currency: WalletCurrency.Usdt }), + ] + } + + return [ + wallet({ id: "usd-2", currency: WalletCurrency.Usd }), + wallet({ id: "usdt-2", currency: WalletCurrency.Usdt }), + ] + }), + } + + const result = await previewPrimaryCashWalletCutover({ + cutoverVersion: 7, + runId: "run-7", + accountsRepo, + walletsRepo, + }) + + expect(result).toEqual({ + report: expect.objectContaining({ + totalAccounts: 2, + migrationCandidates: 1, + alreadyUsdt: 1, + canStart: true, + }), + plannedMigrations: [ + expect.objectContaining({ + accountId: "account-1", + legacyUsdWalletId: "usd-1", + destinationUsdtWalletId: "usdt-1", + }), + ], + }) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/provision-usdt-wallets.spec.ts b/test/flash/unit/app/cash-wallet-cutover/provision-usdt-wallets.spec.ts new file mode 100644 index 000000000..9c7610f85 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/provision-usdt-wallets.spec.ts @@ -0,0 +1,235 @@ +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import { InvalidCashWalletCutoverStateTransitionError } from "@app/cash-wallet-cutover/errors" +import { provisionPrimaryCashWalletUsdtWallets } from "@app/cash-wallet-cutover/provision-usdt-wallets" + +const account = (id: AccountId, defaultWalletId: WalletId): Account => + ({ + id, + uuid: `${id}-uuid` as AccountUuid, + defaultWalletId, + }) as Account + +const wallet = (accountId: AccountId, id: WalletId, currency: WalletCurrency): Wallet => + ({ + id, + accountId, + type: WalletType.Checking, + currency, + onChainAddressIdentifiers: [], + onChainAddresses: () => [], + lnurlp: "lnurl" as Lnurl, + }) as Wallet + +async function* unlockedAccounts(accounts: Account[]): AsyncGenerator { + for (const account of accounts) yield account +} + +const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ + state, + cutoverVersion: 7, + runId: "run-7", + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +describe("provision primary cash wallet USDT wallets", () => { + it("provisions missing USDT wallets without changing existing defaults", async () => { + const missingUsdtAccount = account( + "missing-account" as AccountId, + "missing-account-usd" as WalletId, + ) + const migrationCandidate = account( + "candidate-account" as AccountId, + "candidate-account-usd" as WalletId, + ) + const alreadyUsdtAccount = account( + "already-usdt-account" as AccountId, + "already-usdt-account-usdt" as WalletId, + ) + const accounts = [missingUsdtAccount, migrationCandidate, alreadyUsdtAccount] + const walletsByAccountId = new Map([ + [ + missingUsdtAccount.id, + [ + wallet( + missingUsdtAccount.id, + "missing-account-usd" as WalletId, + WalletCurrency.Usd, + ), + ], + ], + [ + migrationCandidate.id, + [ + wallet( + migrationCandidate.id, + "candidate-account-usd" as WalletId, + WalletCurrency.Usd, + ), + wallet( + migrationCandidate.id, + "candidate-account-usdt" as WalletId, + WalletCurrency.Usdt, + ), + ], + ], + [ + alreadyUsdtAccount.id, + [ + wallet( + alreadyUsdtAccount.id, + "already-usdt-account-usd" as WalletId, + WalletCurrency.Usd, + ), + wallet( + alreadyUsdtAccount.id, + "already-usdt-account-usdt" as WalletId, + WalletCurrency.Usdt, + ), + ], + ], + ]) + const provisionedWallet = wallet( + missingUsdtAccount.id, + "missing-account-usdt" as WalletId, + WalletCurrency.Usdt, + ) + const addWalletIfNonexistent = jest.fn(async () => { + walletsByAccountId.set(missingUsdtAccount.id, [ + ...(walletsByAccountId.get(missingUsdtAccount.id) ?? []), + provisionedWallet, + ]) + return provisionedWallet + }) + + const result = await provisionPrimaryCashWalletUsdtWallets({ + cutoverVersion: 7, + runId: "run-7", + accountsRepo: { listUnlockedAccounts: () => unlockedAccounts(accounts) }, + walletsRepo: { + listByAccountId: jest.fn( + async (accountId: AccountId) => walletsByAccountId.get(accountId) ?? [], + ), + }, + migrationsRepo: { getConfig: jest.fn(async () => config("pre")) }, + addWalletIfNonexistent, + sleep: jest.fn(), + }) + + expect(result).toMatchObject({ + before: { + totalAccounts: 3, + migrationCandidates: 1, + alreadyUsdt: 1, + blockers: 1, + canStart: false, + }, + eligible: 1, + provisioned: [ + { + accountId: "missing-account", + walletId: "missing-account-usdt", + }, + ], + failed: [], + after: { + totalAccounts: 3, + migrationCandidates: 2, + alreadyUsdt: 1, + blockers: 0, + canStart: true, + }, + }) + expect(addWalletIfNonexistent).toHaveBeenCalledTimes(1) + expect(addWalletIfNonexistent).toHaveBeenCalledWith({ + accountId: missingUsdtAccount.id, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + expect(missingUsdtAccount.defaultWalletId).toBe("missing-account-usd") + expect(alreadyUsdtAccount.defaultWalletId).toBe("already-usdt-account-usdt") + }) + + it("backs off and retries rate-limited wallet provisioning before marking it failed", async () => { + const firstAccount = account("first-account" as AccountId, "first-usd" as WalletId) + const secondAccount = account("second-account" as AccountId, "second-usd" as WalletId) + const accounts = [firstAccount, secondAccount] + const walletsByAccountId = new Map([ + [firstAccount.id, [wallet(firstAccount.id, "first-usd" as WalletId, WalletCurrency.Usd)]], + [ + secondAccount.id, + [wallet(secondAccount.id, "second-usd" as WalletId, WalletCurrency.Usd)], + ], + ]) + const firstUsdt = wallet(firstAccount.id, "first-usdt" as WalletId, WalletCurrency.Usdt) + const secondUsdt = wallet( + secondAccount.id, + "second-usdt" as WalletId, + WalletCurrency.Usdt, + ) + const addWalletIfNonexistent = jest + .fn() + .mockResolvedValueOnce(new Error("FetchError: Too Many Requests")) + .mockImplementationOnce(async () => { + walletsByAccountId.set(firstAccount.id, [ + ...(walletsByAccountId.get(firstAccount.id) ?? []), + firstUsdt, + ]) + return firstUsdt + }) + .mockImplementationOnce(async () => { + walletsByAccountId.set(secondAccount.id, [ + ...(walletsByAccountId.get(secondAccount.id) ?? []), + secondUsdt, + ]) + return secondUsdt + }) + const sleep = jest.fn(async () => undefined) + + const result = await provisionPrimaryCashWalletUsdtWallets({ + cutoverVersion: 7, + runId: "run-7", + accountsRepo: { listUnlockedAccounts: () => unlockedAccounts(accounts) }, + walletsRepo: { + listByAccountId: jest.fn( + async (accountId: AccountId) => walletsByAccountId.get(accountId) ?? [], + ), + }, + migrationsRepo: { getConfig: jest.fn(async () => config("pre")) }, + addWalletIfNonexistent, + provisionDelayMs: 1_000, + provisionRetryDelayMs: 30_000, + maxProvisionAttempts: 3, + sleep, + }) + + expect(result).toMatchObject({ + eligible: 2, + provisioned: [ + { accountId: "first-account", walletId: "first-usdt" }, + { accountId: "second-account", walletId: "second-usdt" }, + ], + failed: [], + }) + expect(addWalletIfNonexistent).toHaveBeenCalledTimes(3) + expect(sleep).toHaveBeenCalledWith(30_000) + expect(sleep).toHaveBeenCalledWith(1_000) + }) + + it("does not provision after the cutover has started", async () => { + const addWalletIfNonexistent = jest.fn() + + const result = await provisionPrimaryCashWalletUsdtWallets({ + cutoverVersion: 7, + runId: "run-7", + accountsRepo: { listUnlockedAccounts: () => unlockedAccounts([]) }, + walletsRepo: { listByAccountId: jest.fn() }, + migrationsRepo: { getConfig: jest.fn(async () => config("in_progress")) }, + addWalletIfNonexistent, + }) + + expect(result).toBeInstanceOf(InvalidCashWalletCutoverStateTransitionError) + expect(addWalletIfNonexistent).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts new file mode 100644 index 000000000..eb01920f3 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts @@ -0,0 +1,163 @@ +import { CouldNotUpdateError } from "@domain/errors" + +import { runCashWalletMigrationBatch } from "@app/cash-wallet-cutover/runner" + +const migration = ( + status: CashWalletMigrationStatus, + id = `${status}-migration`, +): CashWalletMigration => ({ + id, + accountId: `${id}-account` as AccountId, + legacyUsdWalletId: `${id}-legacy-usd-wallet` as WalletId, + destinationUsdtWalletId: `${id}-usdt-wallet` as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: `cash-wallet-cutover:run-7:${id}-account`, + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +describe("cash wallet migration batch runner", () => { + it("locks each runnable migration, executes one step, and releases the lock", async () => { + const runnable = [migration("not_started", "migration-1")] + const locked = { ...runnable[0], lockedBy: "worker-1" } + const completedStep = migration("started", "migration-1") + const migrationsRepo = { + listRunnableMigrations: jest.fn(async () => runnable), + acquireMigrationLock: jest.fn(async () => locked), + markMigrationFailed: jest.fn(), + releaseMigrationLock: jest.fn(async () => locked), + } + const executor = jest.fn(async () => completedStep) + + const result = await runCashWalletMigrationBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 10, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + executor, + }) + + expect(result).toEqual({ attempted: 1, advanced: 1, failed: 0, skipped: 0 }) + expect(migrationsRepo.listRunnableMigrations).toHaveBeenCalledWith({ + cutoverVersion: 7, + runId: "run-7", + limit: 10, + }) + expect(migrationsRepo.acquireMigrationLock).toHaveBeenCalledWith({ + id: "migration-1", + workerId: "worker-1", + staleBefore: new Date("2026-05-20T15:00:00Z"), + cutoverVersion: 7, + runId: "run-7", + }) + expect(executor).toHaveBeenCalledWith(locked) + expect(migrationsRepo.releaseMigrationLock).toHaveBeenCalledWith({ + id: "migration-1", + workerId: "worker-1", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("skips migrations that cannot be locked", async () => { + const lockError = new CouldNotUpdateError("lock unavailable") + const migrationsRepo = { + listRunnableMigrations: jest.fn(async () => [migration("started", "migration-1")]), + acquireMigrationLock: jest.fn(async () => lockError), + markMigrationFailed: jest.fn(), + releaseMigrationLock: jest.fn(), + } + const executor = jest.fn() + + const result = await runCashWalletMigrationBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 10, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + executor, + }) + + expect(result).toEqual({ attempted: 1, advanced: 0, failed: 0, skipped: 1 }) + expect(executor).not.toHaveBeenCalled() + expect(migrationsRepo.releaseMigrationLock).not.toHaveBeenCalled() + }) + + it("releases the lock when execution fails", async () => { + const locked = migration("balance_move_sent", "migration-1") + const executionError = new CouldNotUpdateError("execution failed") + const migrationsRepo = { + listRunnableMigrations: jest.fn(async () => [locked]), + acquireMigrationLock: jest.fn(async () => locked), + markMigrationFailed: jest.fn(async () => ({ + ...locked, + status: "requires_operator_review" as const, + })), + releaseMigrationLock: jest.fn(async () => locked), + } + const executor = jest.fn(async () => executionError) + + const result = await runCashWalletMigrationBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 10, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + executor, + }) + + expect(result).toEqual({ attempted: 1, advanced: 0, failed: 1, skipped: 0 }) + expect(migrationsRepo.markMigrationFailed).toHaveBeenCalledWith({ + id: "migration-1", + workerId: "worker-1", + cutoverVersion: 7, + runId: "run-7", + error: executionError, + status: "requires_operator_review", + }) + expect(migrationsRepo.releaseMigrationLock).not.toHaveBeenCalled() + }) + + it("waits between attempted migrations when step delay is configured", async () => { + const runnable = [ + migration("provisioned", "migration-1"), + migration("provisioned", "migration-2"), + migration("provisioned", "migration-3"), + ] + const migrationsRepo = { + listRunnableMigrations: jest.fn(async () => runnable), + acquireMigrationLock: jest.fn(async (args: { id: string }) => + migration("provisioned", args.id), + ), + markMigrationFailed: jest.fn(), + releaseMigrationLock: jest.fn(async () => migration("balance_read")), + } + const executor = jest.fn(async (locked: CashWalletMigration) => + migration("balance_read", locked.id), + ) + const sleep = jest.fn(async () => undefined) + + const result = await runCashWalletMigrationBatch({ + cutoverVersion: 7, + runId: "run-7", + workerId: "worker-1", + limit: 3, + lockStaleBefore: new Date("2026-05-20T15:00:00Z"), + migrationsRepo, + executor, + stepDelayMs: 1_000, + sleep, + }) + + expect(result).toEqual({ attempted: 3, advanced: 3, failed: 0, skipped: 0 }) + expect(sleep).toHaveBeenCalledTimes(2) + expect(sleep).toHaveBeenNthCalledWith(1, 1_000) + expect(sleep).toHaveBeenNthCalledWith(2, 1_000) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts new file mode 100644 index 000000000..eabbb66ad --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -0,0 +1,279 @@ +import { CouldNotUpdateError } from "@domain/errors" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +jest.mock("@app/accounts", () => ({ + addWalletIfNonexistent: jest.fn(), + updateDefaultWalletId: jest.fn(), +})) +jest.mock("@app/wallets", () => ({ + addInvoiceForRecipientForUsdWallet: jest.fn(), + addInvoiceNoAmountForRecipient: jest.fn(), + getBalanceForWallet: jest.fn(), +})) +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ findById: jest.fn() })), +})) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + addInvoice: jest.fn(), + payInvoice: jest.fn(), + getTransactionDetails: jest.fn(), + }, +})) + +import { createCashWalletMigrationRuntimeServices } from "@app/cash-wallet-cutover/runtime-services" +import Ibex from "@services/ibex/client" + +const ibexAddInvoiceResponse = { + invoice: { + bolt11: + "lnbc140n1p3k6yzupp53p305l6de6s9xw2j0qaa59pl7lahys4f2uavwncll9z2vq0syvvsdqqcqzpgxqzuysp5mdgsaa734eg7srwx92rsn3hyc4xzt5tphfpadl5c6fanhppwaz4s9qyyssqm6yhnnhl8jltwjtclzk4g7nxr99ycsp4sqd6vksevqh06h8l3gm5fdhtl59t6g3fsalv26sj5zvwhxwlghc9wcfgkrjrtuh4873ejnspc5xksy", + }, +} + +const migration = (patch: Partial = {}): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status: "balance_move_verified", + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), + ...patch, +}) + +describe("cash wallet migration runtime services", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("reads source USD balances as cents", async () => { + const deps = { + getBalanceForWallet: jest.fn(async () => USDAmount.cents("1234")), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.balanceReader.readSourceBalanceUsdCents(migration()) + + expect(result).toBe("1234") + expect(deps.getBalanceForWallet).toHaveBeenCalledWith({ + walletId: "legacy-usd-wallet-id", + currency: WalletCurrency.Usd, + }) + }) + + it("reads destination USDT balances as micros", async () => { + const deps = { + getBalanceForWallet: jest.fn(async () => USDTAmount.smallestUnits("5000000")), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = + await services.balanceReader.readDestinationBalanceUsdtMicros(migration()) + + expect(result).toBe("5000000") + expect(deps.getBalanceForWallet).toHaveBeenCalledWith({ + walletId: "usdt-wallet-id", + currency: WalletCurrency.Usdt, + }) + }) + + it("ensures the expected destination USDT wallet exists", async () => { + const deps = { + addWalletIfNonexistent: jest.fn(async () => ({ + id: "usdt-wallet-id" as WalletId, + })), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.provisioningService.ensureDestinationWallet({ + accountId: "account-id" as AccountId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + }) + + expect(result).toBe(true) + expect(deps.addWalletIfNonexistent).toHaveBeenCalledWith({ + accountId: "account-id", + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + }) + + it("creates no-amount destination invoices through IBEX", async () => { + jest.mocked(Ibex.addInvoice).mockResolvedValue(ibexAddInvoiceResponse as never) + + const services = createCashWalletMigrationRuntimeServices() + + const result = await services.invoiceService.createNoAmountInvoice({ + recipientWalletId: "usdt-wallet-id" as WalletId, + memo: "cash-wallet-cutover:run-7:migration-id:balance-move", + }) + + expect(result).toMatchObject({ + paymentRequest: ibexAddInvoiceResponse.invoice.bolt11, + }) + const args = jest.mocked(Ibex.addInvoice).mock.calls[0][0]! + expect(args.accountId).toBe("usdt-wallet-id") + expect(args.memo).toBe("cash-wallet-cutover:run-7:migration-id:balance-move") + expect(args.expiration).toBe(900) + expect(args.amount).toBeInstanceOf(USDTAmount) + expect((args.amount as USDTAmount).asSmallestUnits()).toBe("0") + expect((args.amount as USDTAmount).toIbex()).toBe(0) + }) + + it("creates amount destination invoices in exact USDT micros through IBEX", async () => { + jest.mocked(Ibex.addInvoice).mockResolvedValue(ibexAddInvoiceResponse as never) + + const services = createCashWalletMigrationRuntimeServices() + + const result = await services.invoiceService.createInvoice({ + recipientWalletId: "usdt-wallet-id" as WalletId, + amount: "4711", + memo: "cash-wallet-cutover:run-7:migration-id:fee-reimbursement", + }) + + expect(result).toMatchObject({ + paymentRequest: ibexAddInvoiceResponse.invoice.bolt11, + }) + const args = jest.mocked(Ibex.addInvoice).mock.calls[0][0]! + expect(args.accountId).toBe("usdt-wallet-id") + expect(args.memo).toBe("cash-wallet-cutover:run-7:migration-id:fee-reimbursement") + expect(args.expiration).toBe(900) + expect(args.amount).toBeInstanceOf(USDTAmount) + expect((args.amount as USDTAmount).asSmallestUnits()).toBe("4711") + expect((args.amount as USDTAmount).toIbex()).toBe(0.004711) + }) + + it("extracts the IBEX transaction id after paying an invoice with a sender-side USD cap", async () => { + const deps = { + payInvoice: jest.fn(async () => ({ + transaction: { id: "ibex-tx-id" }, + })), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.paymentService.payInvoice({ + senderWalletId: "legacy-usd-wallet-id" as WalletId, + paymentRequest: "lnbc1payment", + senderAmountUsdCents: "1000", + }) + + expect(result).toEqual({ transactionId: "ibex-tx-id" }) + const paymentArgs = deps.payInvoice.mock.calls[0][0]! + expect(paymentArgs.accountId).toBe("legacy-usd-wallet-id") + expect(paymentArgs.invoice).toBe("lnbc1payment") + expect(paymentArgs.send).toBeInstanceOf(USDAmount) + expect(paymentArgs.send.asCents()).toBe("1000") + }) + + it("backs off and retries IBEX rate limits while paying cutover invoices", async () => { + const rateLimit = new Error("FetchError: Too Many Requests") + const sleep = jest.fn(async () => undefined) + const payInvoice = jest + .fn() + .mockResolvedValueOnce(rateLimit) + .mockResolvedValueOnce(rateLimit) + .mockResolvedValueOnce({ transaction: { id: "ibex-tx-id" } }) + const deps: Parameters[0] = { + payInvoice, + maxRateLimitAttempts: 3, + rateLimitRetryDelayMs: 1234, + sleep, + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.paymentService.payInvoice({ + senderWalletId: "treasury-wallet-id" as WalletId, + paymentRequest: "lnbc1payment", + }) + + expect(result).toEqual({ transactionId: "ibex-tx-id" }) + expect(payInvoice).toHaveBeenCalledTimes(3) + expect(sleep).toHaveBeenCalledTimes(2) + expect(sleep).toHaveBeenCalledWith(1234) + }) + + it("returns an error when IBEX payment response has no transaction id", async () => { + const services = createCashWalletMigrationRuntimeServices({ + payInvoice: jest.fn(async () => ({})), + }) + + const result = await services.paymentService.payInvoice({ + senderWalletId: "legacy-usd-wallet-id" as WalletId, + paymentRequest: "lnbc1payment", + }) + + expect(result).toBeInstanceOf(Error) + }) + + it("computes the fee reimbursement as the exact destination USDT shortfall", async () => { + const deps = { + getBalanceForWallet: jest.fn(async () => USDTAmount.smallestUnits("14930000")), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.feeService.readFeeAmountUsdtMicros( + migration({ + balanceMovePaymentTransactionId: "ibex-tx-id", + destinationAmountUsdtMicros: "10000000", + destinationStartingBalanceUsdtMicros: "5000000", + }), + ) + + expect(result).toBe("70000") + expect(deps.getBalanceForWallet).toHaveBeenCalledWith({ + walletId: "usdt-wallet-id", + currency: WalletCurrency.Usdt, + }) + }) + + it("flips the default wallet and returns the previous default wallet id", async () => { + const deps = { + accountsRepo: { + findById: jest.fn(async () => ({ + defaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + }, + updateDefaultWalletId: jest.fn(async () => ({ defaultWalletId: "usdt-wallet-id" })), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.pointerService.flipDefaultWallet({ + accountId: "account-id" as AccountId, + destinationWalletId: "usdt-wallet-id" as WalletId, + }) + + expect(result).toEqual({ + previousDefaultWalletId: "legacy-usd-wallet-id", + }) + expect(deps.updateDefaultWalletId).toHaveBeenCalledWith({ + accountId: "account-id", + walletId: "usdt-wallet-id", + }) + }) + + it("propagates legacy zero verification errors", async () => { + const error = new CouldNotUpdateError("balance lookup failed") + const services = createCashWalletMigrationRuntimeServices({ + getBalanceForWallet: jest.fn(async () => error), + }) + + const result = await services.legacyWalletVerifier.verifyLegacyWalletZero({ + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + }) + + expect(result).toBe(error) + }) +}) diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts new file mode 100644 index 000000000..8646ee4aa --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -0,0 +1,1033 @@ +import { CouldNotUpdateError } from "@domain/errors" + +import { + createCashWalletMigrationBalanceMoveInvoice, + createCashWalletMigrationFeeReimbursementInvoice, + flipCashWalletMigrationDefaultPointer, + completeCashWalletMigration, + markCashWalletMigrationFeeReimbursed, + markCashWalletMigrationBalanceMoveSent, + provisionCashWalletMigrationDestination, + recordCashWalletMigrationBalance, + sendCashWalletMigrationBalanceMovePayment, + sendCashWalletMigrationFeeReimbursementPayment, + skipCashWalletMigrationFeeReimbursement, + startCashWalletMigration, + verifyCashWalletMigrationBalanceMove, + verifyCashWalletMigrationLegacyZero, +} from "@app/cash-wallet-cutover/worker" + +const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ + id: "migration-id", + accountId: "account-id" as AccountId, + legacyUsdWalletId: "legacy-usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 7, + runId: "run-7", + status, + idempotencyKey: "cash-wallet-cutover:run-7:account-id", + attempts: 0, + updatedAt: new Date("2026-05-20T00:00:00Z"), +}) + +const expiredCutoverPaymentRequest = + "lnbc1p4pcau7pp5gaweqhcssvpgcnmqemwhr2vy024yyc9a9lggu2mq9dmxfla939nsdyqvdshx6pdwaskcmr9wskkxat5damx2u36d4skuatpdsknxdfn8geryvesv5cnwepdxuerswfdxsmnxd3dvgenvc3dv9nr2enzxsek2etpxvcr5cnpd3skucm994kk7an9cqzzsxqzpusp5dxuvs8zkzt0tdjkz5ezuea6j49p7yhu43kurz8wcf2xryryp0anq9qxpqysgqnypt73d64vpk74kgdk26s0r7c3yufn2yxpyae3h6zagved5dy2hjek3hxsa3nxqqe5pppqygcrxt6t99tgqc66zet4m99yldkq6muysqvvdhu9" as EncodedPaymentRequest + +describe("cash wallet migration worker checkpoints", () => { + it("starts a not-started migration with an atomic repository transition", async () => { + const startedAt = new Date("2026-05-20T13:00:00Z") + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("started")), + } + + const result = await startCashWalletMigration({ + migration: migration("not_started"), + migrationsRepo, + startedAt, + }) + + expect(result).toMatchObject({ status: "started" }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "not_started", + to: "started", + cutoverVersion: 7, + runId: "run-7", + patch: { startedAt }, + }) + }) + + it("rejects invalid start transitions before touching the repository", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + + const result = await startCashWalletMigration({ + migration: migration("balance_read"), + migrationsRepo, + startedAt: new Date("2026-05-20T13:00:00Z"), + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("returns repository transition failures", async () => { + const error = new CouldNotUpdateError("transition failed") + const migrationsRepo = { + transitionMigration: jest.fn(async () => error), + } + + const result = await startCashWalletMigration({ + migration: migration("not_started"), + migrationsRepo, + startedAt: new Date("2026-05-20T13:00:00Z"), + }) + + expect(result).toBe(error) + }) + + it("provisions the destination wallet before reading balances", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("provisioned")), + } + const provisioningService = { + ensureDestinationWallet: jest.fn(async () => true as const), + } + + const result = await provisionCashWalletMigrationDestination({ + migration: migration("started"), + provisioningService, + migrationsRepo, + }) + + expect(result).toMatchObject({ status: "provisioned" }) + expect(provisioningService.ensureDestinationWallet).toHaveBeenCalledWith({ + accountId: "account-id", + destinationUsdtWalletId: "usdt-wallet-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "started", + to: "provisioned", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("returns destination wallet provisioning failures without advancing", async () => { + const error = new CouldNotUpdateError("destination wallet missing") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const provisioningService = { + ensureDestinationWallet: jest.fn(async () => error), + } + + const result = await provisionCashWalletMigrationDestination({ + migration: migration("started"), + provisioningService, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("records source balance and destination amount before creating invoices", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("balance_read"), + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + destinationStartingBalanceUsdtMicros: "5000000", + })), + } + + const result = await recordCashWalletMigrationBalance({ + migration: migration("provisioned"), + migrationsRepo, + sourceBalanceUsdCents: "1234", + destinationStartingBalanceUsdtMicros: "5000000", + }) + + expect(result).toMatchObject({ + status: "balance_read", + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + destinationStartingBalanceUsdtMicros: "5000000", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "provisioned", + to: "balance_read", + cutoverVersion: 7, + runId: "run-7", + patch: { + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + destinationStartingBalanceUsdtMicros: "5000000", + }, + }) + }) + + it("rejects invalid balance amounts before touching the repository", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + + const result = await recordCashWalletMigrationBalance({ + migration: migration("provisioned"), + migrationsRepo, + sourceBalanceUsdCents: "12.34", + destinationStartingBalanceUsdtMicros: "0", + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("creates a no-amount balance move invoice on the destination wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("invoice_created"), + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + balanceMoveInvoicePaymentHash: "paymentHash", + })), + } + const invoice = { + paymentRequest: "lnbc1balance-move" as EncodedPaymentRequest, + paymentHash: "paymentHash" as PaymentHash, + } as LnInvoice + const invoiceService = { + createNoAmountInvoice: jest.fn(async () => invoice), + } + + const result = await createCashWalletMigrationBalanceMoveInvoice({ + migration: { + ...migration("balance_read"), + destinationAmountUsdtMicros: "12340000", + }, + invoiceService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "invoice_created", + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + balanceMoveInvoicePaymentHash: "paymentHash", + }) + expect(invoiceService.createNoAmountInvoice).toHaveBeenCalledWith({ + recipientWalletId: "usdt-wallet-id", + memo: "cash-wallet-cutover:run-7:migration-id:balance-move", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_read", + to: "invoice_created", + cutoverVersion: 7, + runId: "run-7", + patch: { + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + balanceMoveInvoicePaymentHash: "paymentHash", + }, + }) + }) + + it("rejects balance move invoice creation when the destination amount is missing", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const invoiceService = { + createNoAmountInvoice: jest.fn(), + } + + const result = await createCashWalletMigrationBalanceMoveInvoice({ + migration: migration("balance_read"), + invoiceService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(invoiceService.createNoAmountInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("returns balance move invoice creation failures without advancing the checkpoint", async () => { + const error = new CouldNotUpdateError("invoice creation failed") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const invoiceService = { + createNoAmountInvoice: jest.fn(async () => error), + } + + const result = await createCashWalletMigrationBalanceMoveInvoice({ + migration: { + ...migration("balance_read"), + destinationAmountUsdtMicros: "12340000", + }, + invoiceService, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("sends the balance move payment from the legacy wallet capped to the recorded source balance", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("balance_move_sending"), + balanceMovePaymentTransactionId: "ibex-tx-id", + })), + } + const paymentService = { + payInvoice: jest.fn(async () => ({ + transactionId: "ibex-tx-id" as IbexTransactionId, + })), + } + + const result = await sendCashWalletMigrationBalanceMovePayment({ + migration: { + ...migration("invoice_created"), + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + sourceBalanceUsdCents: "1000", + }, + paymentService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "balance_move_sending", + balanceMovePaymentTransactionId: "ibex-tx-id", + }) + expect(paymentService.payInvoice).toHaveBeenCalledWith({ + senderWalletId: "legacy-usd-wallet-id", + paymentRequest: "lnbc1balance-move", + senderAmountUsdCents: "1000", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "invoice_created", + to: "balance_move_sending", + cutoverVersion: 7, + runId: "run-7", + patch: { + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + }) + }) + + it("regenerates an expired balance move invoice before paying it", async () => { + const refreshedInvoice = { + paymentRequest: "lnbc1fresh-balance-move" as EncodedPaymentRequest, + paymentHash: "freshBalanceMoveHash" as PaymentHash, + } as LnInvoice + const refreshedMigration = { + ...migration("invoice_created"), + balanceMoveInvoicePaymentRequest: refreshedInvoice.paymentRequest, + balanceMoveInvoicePaymentHash: refreshedInvoice.paymentHash, + sourceBalanceUsdCents: "1000", + destinationAmountUsdtMicros: "10000000", + } + const migrationsRepo = { + transitionMigration: jest + .fn() + .mockResolvedValueOnce(refreshedMigration) + .mockResolvedValueOnce({ + ...refreshedMigration, + status: "balance_move_sending", + balanceMovePaymentTransactionId: "ibex-tx-id", + }), + } + const invoiceService = { + createNoAmountInvoice: jest.fn(async () => refreshedInvoice), + } + const paymentService = { + payInvoice: jest.fn(async () => ({ + transactionId: "ibex-tx-id" as IbexTransactionId, + })), + } + + const args = { + migration: { + ...migration("invoice_created"), + balanceMoveInvoicePaymentRequest: expiredCutoverPaymentRequest, + balanceMoveInvoicePaymentHash: "expiredBalanceMoveHash" as PaymentHash, + sourceBalanceUsdCents: "1000", + destinationAmountUsdtMicros: "10000000", + }, + paymentService, + invoiceService, + migrationsRepo, + now: () => new Date("2026-05-31T18:04:00Z"), + } + const result = await sendCashWalletMigrationBalanceMovePayment(args) + + expect(result).toMatchObject({ + status: "balance_move_sending", + balanceMovePaymentTransactionId: "ibex-tx-id", + }) + expect(invoiceService.createNoAmountInvoice).toHaveBeenCalledWith({ + recipientWalletId: "usdt-wallet-id", + memo: "cash-wallet-cutover:run-7:migration-id:balance-move", + }) + expect(paymentService.payInvoice).toHaveBeenCalledWith({ + senderWalletId: "legacy-usd-wallet-id", + paymentRequest: "lnbc1fresh-balance-move", + senderAmountUsdCents: "1000", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenNthCalledWith(1, { + id: "migration-id", + from: "invoice_created", + to: "invoice_created", + cutoverVersion: 7, + runId: "run-7", + patch: { + balanceMoveInvoicePaymentRequest: "lnbc1fresh-balance-move", + balanceMoveInvoicePaymentHash: "freshBalanceMoveHash", + }, + }) + expect(migrationsRepo.transitionMigration).toHaveBeenNthCalledWith(2, { + id: "migration-id", + from: "invoice_created", + to: "balance_move_sending", + cutoverVersion: 7, + runId: "run-7", + patch: { + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + }) + }) + + it("rejects balance move payment sending when the invoice payment request is missing", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const paymentService = { + payInvoice: jest.fn(), + } + + const result = await sendCashWalletMigrationBalanceMovePayment({ + migration: migration("invoice_created"), + paymentService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(paymentService.payInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("rejects balance move payment sending when the source balance is missing", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const paymentService = { + payInvoice: jest.fn(), + } + + const result = await sendCashWalletMigrationBalanceMovePayment({ + migration: { + ...migration("invoice_created"), + balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + }, + paymentService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(paymentService.payInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("marks the balance move payment as sent after a transaction id is recorded", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("balance_move_sent"), + balanceMovePaymentTransactionId: "ibex-tx-id", + })), + } + + const result = await markCashWalletMigrationBalanceMoveSent({ + migration: { + ...migration("balance_move_sending"), + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "balance_move_sent", + balanceMovePaymentTransactionId: "ibex-tx-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_move_sending", + to: "balance_move_sent", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("rejects marking the balance move payment as sent before a transaction id exists", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + + const result = await markCashWalletMigrationBalanceMoveSent({ + migration: migration("balance_move_sending"), + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("verifies the balance move before fee reimbursement", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("balance_move_verified")), + } + const balanceVerifier = { + verifyBalanceMove: jest.fn(async () => true as const), + } + + const result = await verifyCashWalletMigrationBalanceMove({ + migration: { + ...migration("balance_move_sent"), + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + balanceVerifier, + migrationsRepo, + }) + + expect(result).toMatchObject({ status: "balance_move_verified" }) + expect(balanceVerifier.verifyBalanceMove).toHaveBeenCalledWith({ + legacyUsdWalletId: "legacy-usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + sourceBalanceUsdCents: undefined, + destinationAmountUsdtMicros: undefined, + transactionId: "ibex-tx-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_move_sent", + to: "balance_move_verified", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("returns balance move verification failures without advancing the checkpoint", async () => { + const error = new CouldNotUpdateError("balance move not settled") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const balanceVerifier = { + verifyBalanceMove: jest.fn(async () => error), + } + + const result = await verifyCashWalletMigrationBalanceMove({ + migration: { + ...migration("balance_move_sent"), + balanceMovePaymentTransactionId: "ibex-tx-id", + }, + balanceVerifier, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("rejects balance move verification before a transaction id exists", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const balanceVerifier = { + verifyBalanceMove: jest.fn(), + } + + const result = await verifyCashWalletMigrationBalanceMove({ + migration: migration("balance_move_sent"), + balanceVerifier, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(balanceVerifier.verifyBalanceMove).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("creates a fee reimbursement invoice rounded up to USD-cent USDT micros", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursement_invoice_created"), + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + feeReimbursementInvoicePaymentHash: "feePaymentHash", + })), + } + const invoice = { + paymentRequest: "lnbc1fee-reimbursement" as EncodedPaymentRequest, + paymentHash: "feePaymentHash" as PaymentHash, + } as LnInvoice + const invoiceService = { + createInvoice: jest.fn(async () => invoice), + } + + const result = await createCashWalletMigrationFeeReimbursementInvoice({ + migration: migration("balance_move_verified"), + invoiceService, + migrationsRepo, + feeAmountUsdtMicros: "70001", + }) + + expect(result).toMatchObject({ + status: "fee_reimbursement_invoice_created", + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + feeReimbursementInvoicePaymentHash: "feePaymentHash", + }) + expect(invoiceService.createInvoice).toHaveBeenCalledWith({ + recipientWalletId: "usdt-wallet-id", + amount: "80000", + memo: "cash-wallet-cutover:run-7:migration-id:fee-reimbursement", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_move_verified", + to: "fee_reimbursement_invoice_created", + cutoverVersion: 7, + runId: "run-7", + patch: { + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + feeReimbursementInvoicePaymentHash: "feePaymentHash", + }, + }) + }) + + it("rejects invalid fee reimbursement amounts before creating an invoice", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const invoiceService = { + createInvoice: jest.fn(), + } + + const result = await createCashWalletMigrationFeeReimbursementInvoice({ + migration: migration("balance_move_verified"), + invoiceService, + migrationsRepo, + feeAmountUsdtMicros: "0.07", + }) + + expect(result).toBeInstanceOf(Error) + expect(invoiceService.createInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("returns fee reimbursement invoice creation failures without advancing", async () => { + const error = new CouldNotUpdateError("fee invoice failed") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const invoiceService = { + createInvoice: jest.fn(async () => error), + } + + const result = await createCashWalletMigrationFeeReimbursementInvoice({ + migration: migration("balance_move_verified"), + invoiceService, + migrationsRepo, + feeAmountUsdtMicros: "70000", + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("skips fee reimbursement when there is no destination shortfall", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursed"), + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + })), + } + + const result = await skipCashWalletMigrationFeeReimbursement({ + migration: migration("balance_move_verified"), + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "fee_reimbursed", + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "balance_move_verified", + to: "fee_reimbursed", + cutoverVersion: 7, + runId: "run-7", + patch: { + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + }, + }) + }) + + it("sends the fee reimbursement payment from the treasury wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursement_sending"), + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + })), + } + const paymentService = { + payInvoice: jest.fn(async () => ({ + transactionId: "fee-ibex-tx-id" as IbexTransactionId, + })), + } + + const result = await sendCashWalletMigrationFeeReimbursementPayment({ + migration: { + ...migration("fee_reimbursement_invoice_created"), + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + }, + treasuryWalletId: "treasury-wallet-id" as WalletId, + paymentService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "fee_reimbursement_sending", + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }) + expect(paymentService.payInvoice).toHaveBeenCalledWith({ + senderWalletId: "treasury-wallet-id", + paymentRequest: "lnbc1fee-reimbursement", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "fee_reimbursement_invoice_created", + to: "fee_reimbursement_sending", + cutoverVersion: 7, + runId: "run-7", + patch: { + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }, + }) + }) + + it("regenerates an expired fee reimbursement invoice before paying it", async () => { + const refreshedInvoice = { + paymentRequest: "lnbc1fresh-fee-reimbursement" as EncodedPaymentRequest, + paymentHash: "freshFeeReimbursementHash" as PaymentHash, + } as LnInvoice + const refreshedMigration = { + ...migration("fee_reimbursement_invoice_created"), + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", + feeReimbursementInvoicePaymentRequest: refreshedInvoice.paymentRequest, + feeReimbursementInvoicePaymentHash: refreshedInvoice.paymentHash, + } + const migrationsRepo = { + transitionMigration: jest + .fn() + .mockResolvedValueOnce(refreshedMigration) + .mockResolvedValueOnce({ + ...refreshedMigration, + status: "fee_reimbursement_sending", + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }), + } + const invoiceService = { + createInvoice: jest.fn(async () => refreshedInvoice), + } + const paymentService = { + payInvoice: jest.fn(async () => ({ + transactionId: "fee-ibex-tx-id" as IbexTransactionId, + })), + } + + const args = { + migration: { + ...migration("fee_reimbursement_invoice_created"), + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", + feeReimbursementInvoicePaymentRequest: expiredCutoverPaymentRequest, + feeReimbursementInvoicePaymentHash: "expiredFeeReimbursementHash" as PaymentHash, + }, + treasuryWalletId: "treasury-wallet-id" as WalletId, + paymentService, + invoiceService, + migrationsRepo, + now: () => new Date("2026-05-31T18:04:00Z"), + } + const result = await sendCashWalletMigrationFeeReimbursementPayment(args) + + expect(result).toMatchObject({ + status: "fee_reimbursement_sending", + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }) + expect(invoiceService.createInvoice).toHaveBeenCalledWith({ + recipientWalletId: "usdt-wallet-id", + amount: "80000", + memo: "cash-wallet-cutover:run-7:migration-id:fee-reimbursement", + }) + expect(paymentService.payInvoice).toHaveBeenCalledWith({ + senderWalletId: "treasury-wallet-id", + paymentRequest: "lnbc1fresh-fee-reimbursement", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenNthCalledWith(1, { + id: "migration-id", + from: "fee_reimbursement_invoice_created", + to: "fee_reimbursement_invoice_created", + cutoverVersion: 7, + runId: "run-7", + patch: { + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", + feeReimbursementInvoicePaymentRequest: "lnbc1fresh-fee-reimbursement", + feeReimbursementInvoicePaymentHash: "freshFeeReimbursementHash", + }, + }) + expect(migrationsRepo.transitionMigration).toHaveBeenNthCalledWith(2, { + id: "migration-id", + from: "fee_reimbursement_invoice_created", + to: "fee_reimbursement_sending", + cutoverVersion: 7, + runId: "run-7", + patch: { + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }, + }) + }) + + it("rejects fee reimbursement sending when the invoice payment request is missing", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const paymentService = { + payInvoice: jest.fn(), + } + + const result = await sendCashWalletMigrationFeeReimbursementPayment({ + migration: migration("fee_reimbursement_invoice_created"), + treasuryWalletId: "treasury-wallet-id" as WalletId, + paymentService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(paymentService.payInvoice).not.toHaveBeenCalled() + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("returns fee reimbursement payment failures without advancing", async () => { + const error = new CouldNotUpdateError("fee payment failed") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const paymentService = { + payInvoice: jest.fn(async () => error), + } + + const result = await sendCashWalletMigrationFeeReimbursementPayment({ + migration: { + ...migration("fee_reimbursement_invoice_created"), + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + }, + treasuryWalletId: "treasury-wallet-id" as WalletId, + paymentService, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("marks the fee reimbursement as complete after a transaction id is recorded", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursed"), + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + })), + } + + const result = await markCashWalletMigrationFeeReimbursed({ + migration: { + ...migration("fee_reimbursement_sending"), + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "fee_reimbursed", + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "fee_reimbursement_sending", + to: "fee_reimbursed", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("rejects marking fee reimbursement complete before a transaction id exists", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(), + } + + const result = await markCashWalletMigrationFeeReimbursed({ + migration: migration("fee_reimbursement_sending"), + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("flips the account default pointer to the destination USDT wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("pointer_flipped"), + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + } + const pointerService = { + flipDefaultWallet: jest.fn(async () => ({ + previousDefaultWalletId: "legacy-usd-wallet-id" as WalletId, + })), + } + + const result = await flipCashWalletMigrationDefaultPointer({ + migration: migration("fee_reimbursed"), + pointerService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "pointer_flipped", + previousDefaultWalletId: "legacy-usd-wallet-id", + }) + expect(pointerService.flipDefaultWallet).toHaveBeenCalledWith({ + accountId: "account-id", + destinationWalletId: "usdt-wallet-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "fee_reimbursed", + to: "pointer_flipped", + cutoverVersion: 7, + runId: "run-7", + patch: { + previousDefaultWalletId: "legacy-usd-wallet-id", + }, + }) + }) + + it("returns pointer flip failures without advancing", async () => { + const error = new CouldNotUpdateError("default wallet update failed") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const pointerService = { + flipDefaultWallet: jest.fn(async () => error), + } + + const result = await flipCashWalletMigrationDefaultPointer({ + migration: migration("fee_reimbursed"), + pointerService, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("verifies the legacy USD wallet is zero after the pointer flip", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => migration("legacy_zero_verified")), + } + const legacyWalletVerifier = { + verifyLegacyWalletZero: jest.fn(async () => true as const), + } + + const result = await verifyCashWalletMigrationLegacyZero({ + migration: migration("pointer_flipped"), + legacyWalletVerifier, + migrationsRepo, + }) + + expect(result).toMatchObject({ status: "legacy_zero_verified" }) + expect(legacyWalletVerifier.verifyLegacyWalletZero).toHaveBeenCalledWith({ + legacyUsdWalletId: "legacy-usd-wallet-id", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "pointer_flipped", + to: "legacy_zero_verified", + cutoverVersion: 7, + runId: "run-7", + }) + }) + + it("returns legacy zero verification failures without advancing", async () => { + const error = new CouldNotUpdateError("legacy wallet still has a balance") + const migrationsRepo = { + transitionMigration: jest.fn(), + } + const legacyWalletVerifier = { + verifyLegacyWalletZero: jest.fn(async () => error), + } + + const result = await verifyCashWalletMigrationLegacyZero({ + migration: migration("pointer_flipped"), + legacyWalletVerifier, + migrationsRepo, + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) + + it("completes the migration after legacy zero verification", async () => { + const completedAt = new Date("2026-05-20T15:30:00Z") + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("complete"), + completedAt, + })), + } + + const result = await completeCashWalletMigration({ + migration: migration("legacy_zero_verified"), + migrationsRepo, + completedAt, + }) + + expect(result).toMatchObject({ + status: "complete", + completedAt, + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "legacy_zero_verified", + to: "complete", + cutoverVersion: 7, + runId: "run-7", + patch: { completedAt }, + }) + }) +}) diff --git a/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts b/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts index 905c76477..3fbeb8b17 100644 --- a/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts +++ b/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts @@ -19,6 +19,15 @@ describe("usdWalletAmountFromInput", () => { expect((amount as USDTAmount).toIbex()).toBe(194.46) }) + it("converts small USDT cent inputs to micro-USDT", () => { + const amount = usdWalletAmountFromInput("30", WalletCurrency.Usdt) + + expect(amount).toBeInstanceOf(USDTAmount) + expect((amount as USDTAmount).asSmallestUnits()).toBe("300000") + expect((amount as USDTAmount).asNumber()).toBe("0.300000") + expect((amount as USDTAmount).toIbex()).toBe(0.3) + }) + it("rejects BTC", () => { const amount = usdWalletAmountFromInput("19446", WalletCurrency.Btc) diff --git a/test/flash/unit/graphql/cash-wallet-cutover.spec.ts b/test/flash/unit/graphql/cash-wallet-cutover.spec.ts new file mode 100644 index 000000000..f8e102a1e --- /dev/null +++ b/test/flash/unit/graphql/cash-wallet-cutover.spec.ts @@ -0,0 +1,81 @@ +jest.mock("@services/mongoose/cash-wallet-cutover", () => ({ + CashWalletCutoverRepository: jest.fn(), +})) + +import CashWalletCutoverQuery from "@graphql/shared/root/query/cash-wallet-cutover" +import CashWalletCutoverUpdateMutation from "@graphql/admin/root/mutation/cash-wallet-cutover-update" +import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" + +describe("cash wallet cutover GraphQL surface", () => { + const updatedAt = new Date("2026-05-22T12:00:00Z") + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns the public cutover flag state", async () => { + const getConfig = jest.fn(async () => ({ + state: "in_progress" as const, + cutoverVersion: 7, + runId: "run-7", + scheduledAt: new Date("2026-05-22T13:00:00Z"), + updatedAt, + })) + jest.mocked(CashWalletCutoverRepository).mockReturnValue({ getConfig } as never) + + const result = await CashWalletCutoverQuery.resolve?.( + null, + {}, + {} as GraphQLPublicContext, + {} as never, + ) + + expect(result).toMatchObject({ + state: "in_progress", + cutoverVersion: 7, + runId: "run-7", + scheduledAt: new Date("2026-05-22T13:00:00Z"), + }) + }) + + it("lets admins mutate the cutover flag", async () => { + const updateConfig = jest.fn(async () => ({ + state: "in_progress" as const, + cutoverVersion: 8, + runId: "run-8", + updatedBy: "admin-user-id", + updatedAt, + })) + jest.mocked(CashWalletCutoverRepository).mockReturnValue({ updateConfig } as never) + + const result = await CashWalletCutoverUpdateMutation.resolve?.( + null, + { + input: { + state: "in_progress", + cutoverVersion: 8, + runId: "run-8", + }, + }, + { user: { id: "admin-user-id" } } as GraphQLAdminContext, + {} as never, + ) + + expect(updateConfig).toHaveBeenCalledWith( + { + state: "in_progress", + cutoverVersion: 8, + runId: "run-8", + }, + "admin-user-id", + ) + expect(result).toMatchObject({ + errors: [], + cashWalletCutover: { + state: "in_progress", + cutoverVersion: 8, + runId: "run-8", + }, + }) + }) +}) diff --git a/test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts b/test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts new file mode 100644 index 000000000..272bd40d6 --- /dev/null +++ b/test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts @@ -0,0 +1,17 @@ +import { usdtMicrosToUsdCents } from "@graphql/shared/types/object/usd-wallet" + +describe("UsdWallet legacy compatibility balance", () => { + it("converts USDT micros to USD cents from integer smallest units", () => { + expect(usdtMicrosToUsdCents("10000000")).toBe(1000) + }) + + it("accepts formatted USDT smallest units from precision calls", () => { + expect(usdtMicrosToUsdCents("10000000.00000000")).toBe(1000) + }) + + it("rejects non-zero fractional micros", () => { + expect(() => usdtMicrosToUsdCents("10000000.5")).toThrow( + "Cannot convert fractional USDT micros", + ) + }) +}) diff --git a/test/flash/unit/graphql/wallet-balance-validation.spec.ts b/test/flash/unit/graphql/wallet-balance-validation.spec.ts new file mode 100644 index 000000000..8a2c828c9 --- /dev/null +++ b/test/flash/unit/graphql/wallet-balance-validation.spec.ts @@ -0,0 +1,30 @@ +import { parse, validate } from "graphql" + +import { gqlMainSchema } from "@graphql/public" + +describe("wallet balance query validation", () => { + it("allows querying USD and USDT wallet balances with the same response name", () => { + const query = parse(` + query Me { + me { + defaultAccount { + wallets { + ... on UsdtWallet { + id + balance + } + ... on UsdWallet { + id + balance + } + } + } + } + } + `) + + const errors = validate(gqlMainSchema, query) + + expect(errors).toEqual([]) + }) +}) diff --git a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts new file mode 100644 index 000000000..fcb76e7de --- /dev/null +++ b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts @@ -0,0 +1,242 @@ +import { CouldNotUpdateError } from "@domain/errors" +import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" +import { CashWalletCutoverConfig, CashWalletMigration } from "@services/mongoose/schema" + +jest.mock("@services/mongoose/schema", () => ({ + CashWalletCutoverConfig: { + findById: jest.fn(), + findOneAndUpdate: jest.fn(), + }, + CashWalletMigration: { + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), + }, +})) + +describe("CashWalletCutoverRepository", () => { + const repo = CashWalletCutoverRepository() + const updatedAt = new Date("2026-05-19T00:00:00Z") + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("returns default pre state when no config exists", async () => { + jest.mocked(CashWalletCutoverConfig.findById).mockResolvedValue(null as never) + + const result = await repo.getConfig() + + expect(result).toEqual({ + state: "pre", + cutoverVersion: 1, + updatedAt: new Date(0), + }) + }) + + it("upserts singleton config", async () => { + jest.mocked(CashWalletCutoverConfig.findOneAndUpdate).mockResolvedValue({ + _id: "cash_wallet_cutover", + state: "in_progress", + cutoverVersion: 2, + runId: "run-2", + updatedBy: "operator", + updatedAt, + } as never) + + const result = await repo.updateConfig( + { state: "in_progress", cutoverVersion: 2, runId: "run-2" }, + "operator", + ) + + expect(CashWalletCutoverConfig.findOneAndUpdate).toHaveBeenCalledWith( + { _id: "cash_wallet_cutover" }, + expect.objectContaining({ + $set: expect.objectContaining({ + state: "in_progress", + cutoverVersion: 2, + runId: "run-2", + updatedBy: "operator", + }), + }), + { upsert: true, new: true }, + ) + expect(result).toMatchObject({ + state: "in_progress", + cutoverVersion: 2, + runId: "run-2", + }) + }) + + it("creates one migration record per account id and run", async () => { + jest.mocked(CashWalletMigration.findOneAndUpdate).mockResolvedValue({ + _id: "migration-id", + accountId: "account-id", + legacyUsdWalletId: "usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + cutoverVersion: 2, + runId: "run-2", + status: "not_started", + idempotencyKey: "run-2:account-id", + attempts: 0, + updatedAt, + } as never) + + const result = await repo.upsertMigration({ + accountId: "account-id" as AccountId, + legacyUsdWalletId: "usd-wallet-id" as WalletId, + destinationUsdtWalletId: "usdt-wallet-id" as WalletId, + cutoverVersion: 2, + runId: "run-2", + idempotencyKey: "run-2:account-id", + }) + + expect(CashWalletMigration.findOneAndUpdate).toHaveBeenCalledWith( + { accountId: "account-id", runId: "run-2" }, + expect.objectContaining({ + $setOnInsert: expect.objectContaining({ + accountId: "account-id", + runId: "run-2", + status: "not_started", + }), + }), + { upsert: true, new: true }, + ) + expect(result).toMatchObject({ id: "migration-id", accountId: "account-id" }) + }) + + it("transitions migration status atomically", async () => { + jest.mocked(CashWalletMigration.findOneAndUpdate).mockResolvedValue({ + _id: "migration-id", + accountId: "account-id", + legacyUsdWalletId: "usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + cutoverVersion: 2, + runId: "run-2", + status: "started", + idempotencyKey: "run-2:account-id", + attempts: 0, + updatedAt, + } as never) + + const result = await repo.transitionMigration({ + id: "migration-id", + from: "not_started", + to: "started", + cutoverVersion: 2, + runId: "run-2", + patch: { startedAt: updatedAt }, + }) + + expect(CashWalletMigration.findOneAndUpdate).toHaveBeenCalledWith( + { _id: "migration-id", status: "not_started", cutoverVersion: 2, runId: "run-2" }, + expect.objectContaining({ $set: expect.objectContaining({ status: "started" }) }), + { new: true }, + ) + expect(result).toMatchObject({ status: "started" }) + }) + + it("acquires and rejects active locks atomically", async () => { + const staleBefore = new Date("2026-05-19T00:00:00Z") + jest.mocked(CashWalletMigration.findOneAndUpdate).mockResolvedValue(null as never) + + const result = await repo.acquireMigrationLock({ + id: "migration-id", + workerId: "worker-1", + staleBefore, + cutoverVersion: 2, + runId: "run-2", + }) + + expect(CashWalletMigration.findOneAndUpdate).toHaveBeenCalledWith( + { + _id: "migration-id", + cutoverVersion: 2, + runId: "run-2", + $or: [{ lockedAt: null }, { lockedAt: { $lt: staleBefore } }], + }, + expect.objectContaining({ + $set: expect.objectContaining({ lockedBy: "worker-1" }), + }), + { new: true }, + ) + expect(result).toBeInstanceOf(Error) + }) + + it("finds resumable non-terminal migrations for the current run", async () => { + const limit = jest.fn().mockResolvedValue([]) + const sort = jest.fn(() => ({ limit })) + jest.mocked(CashWalletMigration.find).mockReturnValue({ sort } as never) + + const result = await repo.listRunnableMigrations({ + cutoverVersion: 2, + runId: "run-2", + limit: 10, + }) + + expect(CashWalletMigration.find).toHaveBeenCalledWith( + expect.objectContaining({ + cutoverVersion: 2, + runId: "run-2", + status: { + $nin: expect.arrayContaining([ + "complete", + "failed", + "requires_operator_review", + ]), + }, + }), + ) + expect(sort).toHaveBeenCalledWith({ updatedAt: 1 }) + expect(limit).toHaveBeenCalledWith(10) + expect(result).toEqual([]) + }) + + it("marks migration failures durably and clears the worker lock", async () => { + jest.mocked(CashWalletMigration.findOneAndUpdate).mockResolvedValue({ + _id: "migration-id", + accountId: "account-id", + legacyUsdWalletId: "usd-wallet-id", + destinationUsdtWalletId: "usdt-wallet-id", + cutoverVersion: 2, + runId: "run-2", + status: "requires_operator_review", + idempotencyKey: "run-2:account-id", + attempts: 2, + lastError: "execution failed", + lockedAt: null, + lockedBy: null, + updatedAt, + } as never) + + const error = new CouldNotUpdateError("execution failed") + const result = await repo.markMigrationFailed({ + id: "migration-id", + workerId: "worker-1", + cutoverVersion: 2, + runId: "run-2", + status: "requires_operator_review", + error, + }) + + expect(CashWalletMigration.findOneAndUpdate).toHaveBeenCalledWith( + { _id: "migration-id", lockedBy: "worker-1", cutoverVersion: 2, runId: "run-2" }, + expect.objectContaining({ + $set: expect.objectContaining({ + status: "requires_operator_review", + lastError: "execution failed", + lockedAt: null, + lockedBy: null, + }), + $inc: { attempts: 1 }, + }), + { new: true }, + ) + expect(result).toMatchObject({ + status: "requires_operator_review", + attempts: 2, + lastError: "execution failed", + }) + }) +})