From 293629e32845ce54a60a3bbac377ccffeefc0111 Mon Sep 17 00:00:00 2001 From: Forge0x Date: Mon, 25 May 2026 11:10:25 -0400 Subject: [PATCH 01/40] fix(graphql): make usdt wallet balance nullable (#369) --- dev/apollo-federation/supergraph.graphql | 2 +- src/graphql/admin/schema.graphql | 2 +- src/graphql/public/schema.graphql | 2 +- .../shared/types/object/usdt-wallet.ts | 2 +- .../graphql/wallet-balance-validation.spec.ts | 30 +++++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 test/flash/unit/graphql/wallet-balance-validation.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index bb5202f04..865c5fd87 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -2039,7 +2039,7 @@ type UsdtWallet implements Wallet @join__type(graph: PUBLIC) { accountId: ID! - balance: FractionalCentAmount! + balance: FractionalCentAmount id: ID! isExternal: Boolean! lnurlp: Lnurl diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index 1eb75c263..87eed84b2 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -572,7 +572,7 @@ A wallet belonging to an account which contains a USDT balance and a list of tra """ type UsdtWallet implements Wallet { accountId: ID! - balance: FractionalCentAmount! + balance: FractionalCentAmount id: ID! isExternal: Boolean! lnurlp: Lnurl diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index e2f607717..e0bac3237 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -1654,7 +1654,7 @@ A wallet belonging to an account which contains a USDT balance and a list of tra """ type UsdtWallet implements Wallet { accountId: ID! - balance: FractionalCentAmount! + balance: FractionalCentAmount id: ID! isExternal: Boolean! lnurlp: Lnurl diff --git a/src/graphql/shared/types/object/usdt-wallet.ts b/src/graphql/shared/types/object/usdt-wallet.ts index 3dd36759a..40f064cc0 100644 --- a/src/graphql/shared/types/object/usdt-wallet.ts +++ b/src/graphql/shared/types/object/usdt-wallet.ts @@ -56,7 +56,7 @@ const UsdtWallet = GT.Object({ }, balance: { - type: GT.NonNull(FractionalCentAmount), + type: FractionalCentAmount, resolve: async (source) => { const balance = await Wallets.getBalanceForWallet({ walletId: source.id, 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([]) + }) +}) From f8e1e71c2b078f3b75cb9c49da6cf41d9ae33901 Mon Sep 17 00:00:00 2001 From: forge0x Date: Tue, 19 May 2026 14:14:50 -0400 Subject: [PATCH 02/40] feat(cutover): add cash wallet migration state primitives --- .../cash-wallet-cutover/amount-conversion.ts | 24 +++++++ src/app/cash-wallet-cutover/errors.ts | 8 +++ src/app/cash-wallet-cutover/index.ts | 3 + src/app/cash-wallet-cutover/index.types.d.ts | 66 +++++++++++++++++++ src/app/cash-wallet-cutover/state-machine.ts | 40 +++++++++++ src/app/errors.ts | 2 + src/app/index.ts | 3 + src/graphql/error-map.ts | 24 +++++++ .../amount-conversion.spec.ts | 23 +++++++ .../migration-state-machine.spec.ts | 45 +++++++++++++ 10 files changed, 238 insertions(+) create mode 100644 src/app/cash-wallet-cutover/amount-conversion.ts create mode 100644 src/app/cash-wallet-cutover/errors.ts create mode 100644 src/app/cash-wallet-cutover/index.ts create mode 100644 src/app/cash-wallet-cutover/index.types.d.ts create mode 100644 src/app/cash-wallet-cutover/state-machine.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts diff --git a/src/app/cash-wallet-cutover/amount-conversion.ts b/src/app/cash-wallet-cutover/amount-conversion.ts new file mode 100644 index 000000000..f3610da41 --- /dev/null +++ b/src/app/cash-wallet-cutover/amount-conversion.ts @@ -0,0 +1,24 @@ +import { InvalidCashWalletCutoverAmountError } from "./errors" + +const USDT_MICROS_PER_USD_CENT = 10_000n + +const parseNonNegativeInteger = ( + value: string, +): bigint | InvalidCashWalletCutoverAmountError => { + if (!/^\d+$/.test(value)) { + return new InvalidCashWalletCutoverAmountError( + `Invalid non-negative integer amount: ${value}`, + ) + } + return BigInt(value) +} + +export const usdCentsToUsdtMicros = ( + usdCents: string, +): string | InvalidCashWalletCutoverAmountError => { + const parsed = parseNonNegativeInteger(usdCents) + if (parsed instanceof Error) return parsed + return (parsed * USDT_MICROS_PER_USD_CENT).toString() +} + +export const feeUsdCentsToUsdtMicros = usdCentsToUsdtMicros diff --git a/src/app/cash-wallet-cutover/errors.ts b/src/app/cash-wallet-cutover/errors.ts new file mode 100644 index 000000000..1bbb68678 --- /dev/null +++ b/src/app/cash-wallet-cutover/errors.ts @@ -0,0 +1,8 @@ +import { DomainError, ValidationError } from "@domain/shared" + +export class InvalidCashWalletCutoverAmountError extends ValidationError {} +export class InvalidCashWalletMigrationTransitionError extends ValidationError {} +export class CashWalletCutoverInProgressError extends ValidationError {} +export class CashWalletMigrationFailedError extends DomainError {} +export class CashWalletCutoverPreflightError extends DomainError {} +export class CashWalletCutoverTreasuryInsufficientBalanceError extends DomainError {} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts new file mode 100644 index 000000000..e7f75e8c5 --- /dev/null +++ b/src/app/cash-wallet-cutover/index.ts @@ -0,0 +1,3 @@ +export * from "./amount-conversion" +export * from "./errors" +export * from "./state-machine" diff --git a/src/app/cash-wallet-cutover/index.types.d.ts b/src/app/cash-wallet-cutover/index.types.d.ts new file mode 100644 index 000000000..fdce0cf9a --- /dev/null +++ b/src/app/cash-wallet-cutover/index.types.d.ts @@ -0,0 +1,66 @@ +type CashWalletCutoverState = "pre" | "in_progress" | "complete" + +type CashWalletMigrationStatus = + | "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" + | "failed" + | "requires_operator_review" + | "skipped_already_migrated" + | "rollback_started" + | "rolled_back" + +type CashWalletCutoverConfig = { + state: CashWalletCutoverState + scheduledAt?: Date + startedAt?: Date + completedAt?: Date + pausedAt?: Date + pauseReason?: string + updatedBy?: string + cutoverVersion: number + runId?: string + updatedAt: Date +} + +type CashWalletMigration = { + id: string + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + previousDefaultWalletId?: WalletId + cutoverVersion: number + runId: string + status: CashWalletMigrationStatus + sourceBalanceUsdCents?: string + destinationAmountUsdtMicros?: string + feeAmountUsdCents?: string + feeAmountUsdtMicros?: string + balanceMoveInvoicePaymentRequest?: string + balanceMoveInvoicePaymentHash?: string + balanceMovePaymentTransactionId?: string + feeReimbursementInvoicePaymentRequest?: string + feeReimbursementInvoicePaymentHash?: string + feeReimbursementPaymentTransactionId?: string + estimatedFee?: boolean + idempotencyKey: string + attempts: number + lastError?: string + lockedAt?: Date + lockedBy?: string + startedAt?: Date + completedAt?: Date + updatedAt: Date +} diff --git a/src/app/cash-wallet-cutover/state-machine.ts b/src/app/cash-wallet-cutover/state-machine.ts new file mode 100644 index 000000000..66898b3f0 --- /dev/null +++ b/src/app/cash-wallet-cutover/state-machine.ts @@ -0,0 +1,40 @@ +import { InvalidCashWalletMigrationTransitionError } from "./errors" + +const transitions: Partial> = { + not_started: ["started"], + started: ["provisioned", "failed"], + provisioned: ["balance_read", "failed", "skipped_already_migrated"], + balance_read: ["invoice_created", "pointer_flipped", "failed"], + invoice_created: ["balance_move_sending", "failed", "requires_operator_review"], + balance_move_sending: ["balance_move_sent", "failed", "requires_operator_review"], + balance_move_sent: ["balance_move_verified", "failed", "requires_operator_review"], + balance_move_verified: [ + "fee_reimbursement_invoice_created", + "failed", + "requires_operator_review", + ], + fee_reimbursement_invoice_created: [ + "fee_reimbursement_sending", + "failed", + "requires_operator_review", + ], + fee_reimbursement_sending: ["fee_reimbursed", "failed", "requires_operator_review"], + fee_reimbursed: ["pointer_flipped", "failed"], + pointer_flipped: ["legacy_zero_verified", "failed"], + legacy_zero_verified: ["complete", "failed"], + rollback_started: ["rolled_back", "failed"], +} + +export const assertCanTransition = ( + from: CashWalletMigrationStatus, + to: CashWalletMigrationStatus, +): true | InvalidCashWalletMigrationTransitionError => { + if (transitions[from]?.includes(to)) return true + return new InvalidCashWalletMigrationTransitionError( + `Invalid migration transition: ${from} -> ${to}`, + ) +} + +export const nextResumeStatus = ( + status: CashWalletMigrationStatus, +): CashWalletMigrationStatus => status diff --git a/src/app/errors.ts b/src/app/errors.ts index 6e747daf6..4740230f4 100644 --- a/src/app/errors.ts +++ b/src/app/errors.ts @@ -19,6 +19,7 @@ import * as PubSubErrors from "@domain/pubsub/errors" import * as CaptchaErrors from "@domain/captcha/errors" import * as AuthenticationErrors from "@domain/authentication/errors" import * as UserErrors from "@domain/users/errors" +import * as CashWalletCutoverErrors from "@app/cash-wallet-cutover/errors" import * as LedgerFacadeErrors from "@services/ledger/domain/errors" import * as KratosErrors from "@services/kratos/errors" @@ -51,6 +52,7 @@ export const ApplicationErrors = { ...CaptchaErrors, ...AuthenticationErrors, ...UserErrors, + ...CashWalletCutoverErrors, ...KratosErrors, ...LedgerFacadeErrors, diff --git a/src/app/index.ts b/src/app/index.ts index b006e8446..46afb0463 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -14,6 +14,7 @@ import * as WalletsMod from "./wallets" import * as PaymentsMod from "./payments" import * as MerchantsMod from "./merchants" import * as SwapMod from "./swap" +import * as CashWalletCutoverMod from "./cash-wallet-cutover" const allFunctions = { Accounts: { ...AccountsMod }, @@ -30,6 +31,7 @@ const allFunctions = { Payments: { ...PaymentsMod }, Merchants: { ...MerchantsMod }, Swap: { ...SwapMod }, + CashWalletCutover: { ...CashWalletCutoverMod }, } as const let subModule: keyof typeof allFunctions @@ -60,4 +62,5 @@ export const { Payments, Merchants, Swap, + CashWalletCutover, } = allFunctions diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 9f93e8875..9c0d8100c 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -792,6 +792,30 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "InvalidLnurlError": return new InvalidLnurlError({ message: error.message, logger: baseLogger }) + case "CashWalletCutoverInProgressError": + message = "Cash Wallet cutover is in progress. Please try again shortly." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletMigrationFailedError": + message = "Cash Wallet migration needs support review." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletCutoverPreflightError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletCutoverTreasuryInsufficientBalanceError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletCutoverAmountError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + + case "InvalidCashWalletMigrationTransitionError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + case "UnknownCaptchaError": message = `Unknown error occurred (code: ${error.name}${ error.message ? ": " + error.message : "" 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..3d77174a3 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts @@ -0,0 +1,23 @@ +import { + feeUsdCentsToUsdtMicros, + usdCentsToUsdtMicros, +} 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("rejects invalid or fractional cent inputs", () => { + expect(usdCentsToUsdtMicros("1.5")).toBeInstanceOf(Error) + expect(usdCentsToUsdtMicros("abc")).toBeInstanceOf(Error) + expect(usdCentsToUsdtMicros("-1")).toBeInstanceOf(Error) + }) +}) 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..bc199dadd --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts @@ -0,0 +1,45 @@ +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("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) + }) +}) From 0abbddd87e5b6b7af3ff367fdc5b2251b739c529 Mon Sep 17 00:00:00 2001 From: forge0x Date: Tue, 19 May 2026 14:17:33 -0400 Subject: [PATCH 03/40] feat(cutover): persist cash wallet cutover state --- src/services/mongoose/cash-wallet-cutover.ts | 278 ++++++++++++++++++ src/services/mongoose/index.ts | 1 + src/services/mongoose/schema.ts | 62 ++++ src/services/mongoose/schema.types.d.ts | 45 +++ .../mongoose/cash-wallet-cutover.spec.ts | 174 +++++++++++ 5 files changed, 560 insertions(+) create mode 100644 src/services/mongoose/cash-wallet-cutover.ts create mode 100644 test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts new file mode 100644 index 000000000..768e06e6a --- /dev/null +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -0,0 +1,278 @@ +import { randomUUID } from "crypto" + +import { CouldNotUpdateError } from "@domain/errors" + +import { parseRepositoryError } from "./utils" +import { CashWalletCutoverConfig, CashWalletMigration } from "./schema" + +const CONFIG_ID = "cash_wallet_cutover" + +const TERMINAL_STATUSES: CashWalletMigrationStatus[] = [ + "complete", + "failed", + "requires_operator_review", + "skipped_already_migrated", + "rolled_back", +] + +type UpsertMigrationArgs = { + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + previousDefaultWalletId?: WalletId + cutoverVersion: number + runId: string + idempotencyKey: string +} + +type TransitionMigrationArgs = { + id: string + from: CashWalletMigrationStatus + to: CashWalletMigrationStatus + cutoverVersion: number + runId: string + patch?: Partial +} + +type LockMigrationArgs = { + id: string + workerId: string + staleBefore: Date + cutoverVersion: number + runId: string +} + +const defaultConfig = (): CashWalletCutoverConfig => ({ + state: "pre", + cutoverVersion: 1, + updatedAt: new Date(0), +}) + +const resultToConfig = (record: CashWalletCutoverConfigRecord): CashWalletCutoverConfig => ({ + state: record.state, + scheduledAt: record.scheduledAt, + startedAt: record.startedAt, + completedAt: record.completedAt, + pausedAt: record.pausedAt, + pauseReason: record.pauseReason, + updatedBy: record.updatedBy, + cutoverVersion: record.cutoverVersion, + runId: record.runId, + updatedAt: record.updatedAt, +}) + +const resultToMigration = (record: CashWalletMigrationRecord): CashWalletMigration => ({ + id: record._id, + accountId: record.accountId as AccountId, + accountUuid: record.accountUuid as AccountUuid | undefined, + legacyUsdWalletId: record.legacyUsdWalletId as WalletId, + destinationUsdtWalletId: record.destinationUsdtWalletId as WalletId, + previousDefaultWalletId: record.previousDefaultWalletId as WalletId | undefined, + cutoverVersion: record.cutoverVersion, + runId: record.runId, + status: record.status, + sourceBalanceUsdCents: record.sourceBalanceUsdCents, + destinationAmountUsdtMicros: record.destinationAmountUsdtMicros, + feeAmountUsdCents: record.feeAmountUsdCents, + feeAmountUsdtMicros: record.feeAmountUsdtMicros, + balanceMoveInvoicePaymentRequest: record.balanceMoveInvoicePaymentRequest, + balanceMoveInvoicePaymentHash: record.balanceMoveInvoicePaymentHash, + balanceMovePaymentTransactionId: record.balanceMovePaymentTransactionId, + feeReimbursementInvoicePaymentRequest: record.feeReimbursementInvoicePaymentRequest, + feeReimbursementInvoicePaymentHash: record.feeReimbursementInvoicePaymentHash, + feeReimbursementPaymentTransactionId: record.feeReimbursementPaymentTransactionId, + estimatedFee: record.estimatedFee, + idempotencyKey: record.idempotencyKey, + attempts: record.attempts, + lastError: record.lastError, + lockedAt: record.lockedAt, + lockedBy: record.lockedBy, + startedAt: record.startedAt, + completedAt: record.completedAt, + updatedAt: record.updatedAt, +}) + +export const CashWalletCutoverRepository = () => { + const getConfig = async (): Promise => { + try { + const result = await CashWalletCutoverConfig.findById(CONFIG_ID) + if (!result) return defaultConfig() + return resultToConfig(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const updateConfig = async ( + patch: Partial, + actor?: string, + ): Promise => { + try { + const result = await CashWalletCutoverConfig.findOneAndUpdate( + { _id: CONFIG_ID }, + { $set: { ...patch, updatedBy: actor, updatedAt: new Date() } }, + { upsert: true, new: true }, + ) + return resultToConfig(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const upsertMigration = async ( + args: UpsertMigrationArgs, + ): Promise => { + try { + const now = new Date() + const result = await CashWalletMigration.findOneAndUpdate( + { accountId: args.accountId, runId: args.runId }, + { + $setOnInsert: { + _id: randomUUID(), + ...args, + status: "not_started", + attempts: 0, + updatedAt: now, + }, + }, + { upsert: true, new: true }, + ) + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const findMigrationByAccountId = async ({ + accountId, + cutoverVersion, + runId, + }: { + accountId: AccountId + cutoverVersion: number + runId: string + }): Promise => { + try { + const result = await CashWalletMigration.findOne({ accountId, cutoverVersion, runId }) + if (!result) return null + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const transitionMigration = async ({ + id, + from, + to, + cutoverVersion, + runId, + patch = {}, + }: TransitionMigrationArgs): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { _id: id, status: from, cutoverVersion, runId }, + { $set: { ...patch, status: to, updatedAt: new Date() } }, + { new: true }, + ) + if (!result) return new CouldNotUpdateError("Could not transition cash wallet migration") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const acquireMigrationLock = async ({ + id, + workerId, + staleBefore, + cutoverVersion, + runId, + }: LockMigrationArgs): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { + _id: id, + cutoverVersion, + runId, + $or: [{ lockedAt: null }, { lockedAt: { $lt: staleBefore } }], + }, + { $set: { lockedAt: new Date(), lockedBy: workerId, updatedAt: new Date() } }, + { new: true }, + ) + if (!result) return new CouldNotUpdateError("Could not acquire cash wallet migration lock") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const releaseMigrationLock = async ({ + id, + workerId, + cutoverVersion, + runId, + }: Omit): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { _id: id, lockedBy: workerId, cutoverVersion, runId }, + { $set: { lockedAt: null, lockedBy: null, updatedAt: new Date() } }, + { new: true }, + ) + if (!result) return new CouldNotUpdateError("Could not release cash wallet migration lock") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + + const listRunnableMigrations = async ({ + cutoverVersion, + runId, + limit, + }: { + cutoverVersion: number + runId: string + limit?: number + }): Promise => { + try { + const results = await CashWalletMigration.find({ + cutoverVersion, + runId, + status: { $nin: TERMINAL_STATUSES }, + }) + return results.slice(0, limit).map(resultToMigration) + } catch (err) { + return parseRepositoryError(err) + } + } + + const countByStatus = async ({ + cutoverVersion, + runId, + status, + }: { + cutoverVersion: number + runId: string + status: CashWalletMigrationStatus + }): Promise => { + try { + return CashWalletMigration.countDocuments({ cutoverVersion, runId, status }) + } catch (err) { + return parseRepositoryError(err) + } + } + + return { + getConfig, + updateConfig, + upsertMigration, + findMigrationByAccountId, + transitionMigration, + acquireMigrationLock, + releaseMigrationLock, + listRunnableMigrations, + countByStatus, + } +} diff --git a/src/services/mongoose/index.ts b/src/services/mongoose/index.ts index 8e5be4f68..e0cb83509 100644 --- a/src/services/mongoose/index.ts +++ b/src/services/mongoose/index.ts @@ -8,3 +8,4 @@ export * from "./wallet-invoices" export * from "./wallet-on-chain-addresses" export * from "./wallet-onchain-pending-receive" export * from "./merchants" +export * from "./cash-wallet-cutover" diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index ef7b0ebf9..ab476041b 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -620,6 +620,68 @@ export const WalletOnChainPendingReceive = WalletOnChainPendingReceiveSchema, ) +const CashWalletCutoverConfigSchema = new Schema({ + _id: { type: String, default: "cash_wallet_cutover" }, + state: { type: String, enum: ["pre", "in_progress", "complete"], required: true }, + scheduledAt: Date, + startedAt: Date, + completedAt: Date, + pausedAt: Date, + pauseReason: String, + updatedBy: String, + cutoverVersion: { type: Number, required: true, default: 1 }, + runId: String, + updatedAt: { type: Date, default: Date.now }, +}) + +export const CashWalletCutoverConfig = mongoose.model( + "CashWalletCutoverConfig", + CashWalletCutoverConfigSchema, +) + +const CashWalletMigrationSchema = new Schema({ + _id: { type: String, required: true }, + accountId: { type: String, required: true, index: true }, + accountUuid: String, + legacyUsdWalletId: { type: String, required: true }, + destinationUsdtWalletId: { type: String, required: true }, + previousDefaultWalletId: String, + cutoverVersion: { type: Number, required: true, index: true }, + runId: { type: String, required: true, index: true }, + status: { type: String, required: true, index: true }, + sourceBalanceUsdCents: String, + destinationAmountUsdtMicros: String, + feeAmountUsdCents: String, + feeAmountUsdtMicros: String, + balanceMoveInvoicePaymentRequest: String, + balanceMoveInvoicePaymentHash: String, + balanceMovePaymentTransactionId: String, + feeReimbursementInvoicePaymentRequest: String, + feeReimbursementInvoicePaymentHash: String, + feeReimbursementPaymentTransactionId: String, + estimatedFee: Boolean, + idempotencyKey: { type: String, required: true }, + attempts: { type: Number, default: 0 }, + lastError: String, + lockedAt: Date, + lockedBy: String, + startedAt: Date, + completedAt: Date, + updatedAt: { type: Date, default: Date.now, index: true }, +}) + +CashWalletMigrationSchema.index({ accountId: 1, cutoverVersion: 1 }, { unique: true }) +CashWalletMigrationSchema.index({ accountId: 1, runId: 1 }, { unique: true }) +CashWalletMigrationSchema.index({ idempotencyKey: 1 }, { unique: true }) +CashWalletMigrationSchema.index({ cutoverVersion: 1, status: 1, updatedAt: 1 }) +CashWalletMigrationSchema.index({ runId: 1, status: 1 }) +CashWalletMigrationSchema.index({ lockedAt: 1 }) + +export const CashWalletMigration = mongoose.model( + "CashWalletMigration", + CashWalletMigrationSchema, +) + const BridgeVirtualAccountSchema = new Schema({ // unique: true enforces one VA per account at the DB layer — idempotency guard accountId: { type: String, required: true, unique: true }, diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index 39d5a3da9..e3e25c34b 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -103,6 +103,51 @@ interface AccountRecord { save: () => Promise } +interface CashWalletCutoverConfigRecord { + _id: string + state: CashWalletCutoverState + scheduledAt?: Date + startedAt?: Date + completedAt?: Date + pausedAt?: Date + pauseReason?: string + updatedBy?: string + cutoverVersion: number + runId?: string + updatedAt: Date +} + +interface CashWalletMigrationRecord { + _id: string + accountId: string + accountUuid?: string + legacyUsdWalletId: string + destinationUsdtWalletId: string + previousDefaultWalletId?: string + cutoverVersion: number + runId: string + status: CashWalletMigrationStatus + sourceBalanceUsdCents?: string + destinationAmountUsdtMicros?: string + feeAmountUsdCents?: string + feeAmountUsdtMicros?: string + balanceMoveInvoicePaymentRequest?: string + balanceMoveInvoicePaymentHash?: string + balanceMovePaymentTransactionId?: string + feeReimbursementInvoicePaymentRequest?: string + feeReimbursementInvoicePaymentHash?: string + feeReimbursementPaymentTransactionId?: string + estimatedFee?: boolean + idempotencyKey: string + attempts: number + lastError?: string + lockedAt?: Date + lockedBy?: string + startedAt?: Date + completedAt?: Date + updatedAt: Date +} + interface LocationRecord { type: "Point" coordinates: CoordinateRecord 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..ad6e00d19 --- /dev/null +++ b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts @@ -0,0 +1,174 @@ +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 () => { + jest.mocked(CashWalletMigration.find).mockResolvedValue([] 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(result).toEqual([]) + }) +}) From fb519b5b8a09a42a05086fc7e043fd8973001948 Mon Sep 17 00:00:00 2001 From: forge0x Date: Tue, 19 May 2026 14:18:35 -0400 Subject: [PATCH 04/40] feat(cutover): add cash wallet write guard --- src/app/cash-wallet-cutover/guard.ts | 45 ++++++++++ src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/cutover-gate.spec.ts | 86 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 src/app/cash-wallet-cutover/guard.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts diff --git a/src/app/cash-wallet-cutover/guard.ts b/src/app/cash-wallet-cutover/guard.ts new file mode 100644 index 000000000..d3fb154b6 --- /dev/null +++ b/src/app/cash-wallet-cutover/guard.ts @@ -0,0 +1,45 @@ +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, +} from "./errors" + +export { CashWalletCutoverInProgressError, CashWalletMigrationFailedError } + +const ACTIVE_STATUSES: CashWalletMigrationStatus[] = [ + "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", + "rollback_started", +] + +export const evaluateCashWalletCutoverGuard = ({ + cutover, + migration, +}: { + cutover: CashWalletCutoverConfig + migration?: CashWalletMigration | null +}): { route: "legacy_usd" | "eth_usdt" } | ApplicationError => { + if (cutover.state === "pre") return { route: "legacy_usd" } + if (cutover.state === "complete") return { route: "eth_usdt" } + + if (!migration || migration.status === "not_started") return { route: "legacy_usd" } + if (migration.status === "complete" || migration.status === "skipped_already_migrated") { + return { route: "eth_usdt" } + } + if (migration.status === "failed" || migration.status === "requires_operator_review") { + return new CashWalletMigrationFailedError() + } + if (ACTIVE_STATUSES.includes(migration.status)) { + return new CashWalletCutoverInProgressError() + } + + return { route: "legacy_usd" } +} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index e7f75e8c5..8384d0e68 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -1,3 +1,4 @@ export * from "./amount-conversion" export * from "./errors" export * from "./state-machine" +export * from "./guard" 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..0d79195a4 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts @@ -0,0 +1,86 @@ +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, + evaluateCashWalletCutoverGuard, +} 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"), +}) + +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 ETH-USDT during cutover", () => { + expect( + evaluateCashWalletCutoverGuard({ + cutover: config("in_progress"), + migration: migration("complete"), + }), + ).toEqual({ route: "eth_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 ETH-USDT after global completion", () => { + expect(evaluateCashWalletCutoverGuard({ cutover: config("complete") })).toEqual({ + route: "eth_usdt", + }) + }) +}) From 86a1204b96f2771eebc3d33d1a51c863e559fa28 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 08:58:06 -0400 Subject: [PATCH 05/40] feat(cutover): classify cash wallet migration candidates --- src/app/cash-wallet-cutover/discovery.ts | 56 +++++++++ src/app/cash-wallet-cutover/index.ts | 1 + .../app/cash-wallet-cutover/discovery.spec.ts | 111 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 src/app/cash-wallet-cutover/discovery.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts diff --git a/src/app/cash-wallet-cutover/discovery.ts b/src/app/cash-wallet-cutover/discovery.ts new file mode 100644 index 000000000..c6e73ce63 --- /dev/null +++ b/src/app/cash-wallet-cutover/discovery.ts @@ -0,0 +1,56 @@ +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +type CashWalletCutoverDiscoveryStatus = + | "legacy_default" + | "already_usdt" + | "residual_legacy_usd" + | "missing_legacy_usd" + | "missing_destination_usdt" + +type CashWalletCutoverDiscovery = { + status: CashWalletCutoverDiscoveryStatus + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId?: WalletId + destinationUsdtWalletId?: WalletId + previousDefaultWalletId: WalletId +} + +export const classifyCashWalletsForCutover = ({ + account, + wallets, +}: { + account: Account + wallets: Wallet[] +}): CashWalletCutoverDiscovery => { + const legacyUsdWallet = wallets.find( + (wallet) => + wallet.type === WalletType.Checking && wallet.currency === WalletCurrency.Usd, + ) + const destinationUsdtWallet = wallets.find( + (wallet) => + wallet.type === WalletType.Checking && wallet.currency === WalletCurrency.Usdt, + ) + + const base = { + accountId: account.id, + accountUuid: account.uuid, + legacyUsdWalletId: legacyUsdWallet?.id, + destinationUsdtWalletId: destinationUsdtWallet?.id, + previousDefaultWalletId: account.defaultWalletId, + } + + if (!legacyUsdWallet) return { ...base, status: "missing_legacy_usd" } + if (!destinationUsdtWallet) return { ...base, status: "missing_destination_usdt" } + + if (account.defaultWalletId === legacyUsdWallet.id) { + return { ...base, status: "legacy_default" } + } + + if (account.defaultWalletId === destinationUsdtWallet.id) { + return { ...base, status: "already_usdt" } + } + + return { ...base, status: "residual_legacy_usd" } +} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 8384d0e68..128f1d5f8 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -2,3 +2,4 @@ export * from "./amount-conversion" export * from "./errors" export * from "./state-machine" export * from "./guard" +export * from "./discovery" 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" }) + }) +}) From 1d77a82acc732e39b6291ea8256984184bb1b4a8 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:00:39 -0400 Subject: [PATCH 06/40] feat(cutover): add cash wallet preflight summary --- src/app/cash-wallet-cutover/discovery.ts | 4 +- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/preflight.ts | 49 ++++++++++++++ .../app/cash-wallet-cutover/preflight.spec.ts | 65 +++++++++++++++++++ 4 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/app/cash-wallet-cutover/preflight.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts diff --git a/src/app/cash-wallet-cutover/discovery.ts b/src/app/cash-wallet-cutover/discovery.ts index c6e73ce63..2141219e0 100644 --- a/src/app/cash-wallet-cutover/discovery.ts +++ b/src/app/cash-wallet-cutover/discovery.ts @@ -1,14 +1,14 @@ import { WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" -type CashWalletCutoverDiscoveryStatus = +export type CashWalletCutoverDiscoveryStatus = | "legacy_default" | "already_usdt" | "residual_legacy_usd" | "missing_legacy_usd" | "missing_destination_usdt" -type CashWalletCutoverDiscovery = { +export type CashWalletCutoverDiscovery = { status: CashWalletCutoverDiscoveryStatus accountId: AccountId accountUuid?: AccountUuid diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 128f1d5f8..47fde4e65 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -3,3 +3,4 @@ export * from "./errors" export * from "./state-machine" export * from "./guard" export * from "./discovery" +export * from "./preflight" diff --git a/src/app/cash-wallet-cutover/preflight.ts b/src/app/cash-wallet-cutover/preflight.ts new file mode 100644 index 000000000..4c40fd71a --- /dev/null +++ b/src/app/cash-wallet-cutover/preflight.ts @@ -0,0 +1,49 @@ +type CashWalletCutoverPreflightBlocker = { + accountId: AccountId + reason: "missing_legacy_usd" | "missing_destination_usdt" +} + +type CashWalletCutoverPreflightReport = { + cutoverVersion: number + runId: string + totalAccounts: number + migrationCandidates: number + alreadyUsdt: number + residualLegacyUsd: number + blockers: number + blockerAccounts: CashWalletCutoverPreflightBlocker[] + canStart: boolean +} + +export const buildCashWalletCutoverPreflightReport = ({ + cutoverVersion, + runId, + discoveries, +}: { + cutoverVersion: number + runId: string + discoveries: CashWalletCutoverDiscovery[] +}): CashWalletCutoverPreflightReport => { + const blockerAccounts = discoveries.flatMap(({ accountId, status }) => { + if (status !== "missing_legacy_usd" && status !== "missing_destination_usdt") { + return [] + } + + return [{ accountId, reason: status }] + }) + + return { + cutoverVersion, + runId, + totalAccounts: discoveries.length, + migrationCandidates: discoveries.filter(({ status }) => status === "legacy_default") + .length, + alreadyUsdt: discoveries.filter(({ status }) => status === "already_usdt").length, + residualLegacyUsd: discoveries.filter( + ({ status }) => status === "residual_legacy_usd", + ).length, + blockers: blockerAccounts.length, + blockerAccounts, + canStart: blockerAccounts.length === 0, + } +} 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([]) + }) +}) From 11ec908b428a4b0b745cd34181618b1a90b88c79 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:04:11 -0400 Subject: [PATCH 07/40] feat(cutover): collect cash wallet discovery results --- src/app/cash-wallet-cutover/discovery.ts | 22 +++++ .../discovery-collector.spec.ts | 86 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts diff --git a/src/app/cash-wallet-cutover/discovery.ts b/src/app/cash-wallet-cutover/discovery.ts index 2141219e0..14304c10b 100644 --- a/src/app/cash-wallet-cutover/discovery.ts +++ b/src/app/cash-wallet-cutover/discovery.ts @@ -54,3 +54,25 @@ export const classifyCashWalletsForCutover = ({ return { ...base, status: "residual_legacy_usd" } } + +export const discoverCashWalletCutoverAccounts = async ({ + accountsRepo, + walletsRepo, +}: { + accountsRepo: Pick + walletsRepo: Pick +}): Promise => { + const accounts = accountsRepo.listUnlockedAccounts() + if (accounts instanceof Error) return accounts + + const discoveries: CashWalletCutoverDiscovery[] = [] + + for await (const account of accounts) { + const wallets = await walletsRepo.listByAccountId(account.id) + if (wallets instanceof Error) return wallets + + discoveries.push(classifyCashWalletsForCutover({ account, wallets })) + } + + return discoveries +} 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) + }) +}) From 6eaab8c072485a9c942f159d1ee8fbf688ba0b83 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:06:22 -0400 Subject: [PATCH 08/40] feat(cutover): plan primary cash wallet migrations --- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/planner.ts | 37 ++++++++++++++ .../app/cash-wallet-cutover/planner.spec.ts | 51 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 src/app/cash-wallet-cutover/planner.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/planner.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 47fde4e65..06c73fed4 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -4,3 +4,4 @@ export * from "./state-machine" export * from "./guard" export * from "./discovery" export * from "./preflight" +export * from "./planner" diff --git a/src/app/cash-wallet-cutover/planner.ts b/src/app/cash-wallet-cutover/planner.ts new file mode 100644 index 000000000..e263cbf5f --- /dev/null +++ b/src/app/cash-wallet-cutover/planner.ts @@ -0,0 +1,37 @@ +type PrimaryCashWalletMigrationPlan = { + accountId: AccountId + accountUuid?: AccountUuid + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + previousDefaultWalletId: WalletId + cutoverVersion: number + runId: string + idempotencyKey: string +} + +export const buildPrimaryCashWalletMigrationPlan = ({ + cutoverVersion, + runId, + discoveries, +}: { + cutoverVersion: number + runId: string + discoveries: CashWalletCutoverDiscovery[] +}): PrimaryCashWalletMigrationPlan[] => + discoveries.flatMap((discovery) => { + if (discovery.status !== "legacy_default") return [] + if (!discovery.legacyUsdWalletId || !discovery.destinationUsdtWalletId) return [] + + return [ + { + accountId: discovery.accountId, + accountUuid: discovery.accountUuid, + legacyUsdWalletId: discovery.legacyUsdWalletId, + destinationUsdtWalletId: discovery.destinationUsdtWalletId, + previousDefaultWalletId: discovery.previousDefaultWalletId, + cutoverVersion, + runId, + idempotencyKey: `cash-wallet-cutover:${runId}:${discovery.accountId}`, + }, + ] + }) 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", + }, + ]) + }) +}) From f32179822541cb78d889deca90bb429c01a7cced Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:54:44 -0400 Subject: [PATCH 09/40] feat(cutover): upsert planned migration records --- src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/migration-records.ts | 24 +++++++ src/app/cash-wallet-cutover/planner.ts | 2 + .../migration-records.spec.ts | 70 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 src/app/cash-wallet-cutover/migration-records.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 06c73fed4..6dc804e00 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -5,3 +5,4 @@ export * from "./guard" export * from "./discovery" export * from "./preflight" export * from "./planner" +export * from "./migration-records" diff --git a/src/app/cash-wallet-cutover/migration-records.ts b/src/app/cash-wallet-cutover/migration-records.ts new file mode 100644 index 000000000..fa56d81c5 --- /dev/null +++ b/src/app/cash-wallet-cutover/migration-records.ts @@ -0,0 +1,24 @@ +type CashWalletMigrationRecordsRepository = { + upsertMigration( + args: PrimaryCashWalletMigrationPlan, + ): Promise +} + +export const upsertPrimaryCashWalletMigrationRecords = async ({ + migrationsRepo, + plans, +}: { + migrationsRepo: CashWalletMigrationRecordsRepository + plans: PrimaryCashWalletMigrationPlan[] +}): Promise => { + const migrations: CashWalletMigration[] = [] + + for (const plan of plans) { + const migration = await migrationsRepo.upsertMigration(plan) + if (migration instanceof Error) return migration + + migrations.push(migration) + } + + return migrations +} diff --git a/src/app/cash-wallet-cutover/planner.ts b/src/app/cash-wallet-cutover/planner.ts index e263cbf5f..772928061 100644 --- a/src/app/cash-wallet-cutover/planner.ts +++ b/src/app/cash-wallet-cutover/planner.ts @@ -9,6 +9,8 @@ type PrimaryCashWalletMigrationPlan = { idempotencyKey: string } +export type { PrimaryCashWalletMigrationPlan } + export const buildPrimaryCashWalletMigrationPlan = ({ cutoverVersion, runId, 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) + }) +}) From 9654a517673d767bd6a489b0d61fc87d6db23540 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 09:58:23 -0400 Subject: [PATCH 10/40] feat(cutover): prepare primary migration batch --- src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/migration-records.ts | 2 + src/app/cash-wallet-cutover/preflight.ts | 2 + src/app/cash-wallet-cutover/prepare.ts | 57 ++++++++ .../app/cash-wallet-cutover/prepare.spec.ts | 123 ++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 src/app/cash-wallet-cutover/prepare.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 6dc804e00..5e49b7ca7 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -6,3 +6,4 @@ export * from "./discovery" export * from "./preflight" export * from "./planner" export * from "./migration-records" +export * from "./prepare" diff --git a/src/app/cash-wallet-cutover/migration-records.ts b/src/app/cash-wallet-cutover/migration-records.ts index fa56d81c5..7e0dd8a56 100644 --- a/src/app/cash-wallet-cutover/migration-records.ts +++ b/src/app/cash-wallet-cutover/migration-records.ts @@ -4,6 +4,8 @@ type CashWalletMigrationRecordsRepository = { ): Promise } +export type { CashWalletMigrationRecordsRepository } + export const upsertPrimaryCashWalletMigrationRecords = async ({ migrationsRepo, plans, diff --git a/src/app/cash-wallet-cutover/preflight.ts b/src/app/cash-wallet-cutover/preflight.ts index 4c40fd71a..058679f6b 100644 --- a/src/app/cash-wallet-cutover/preflight.ts +++ b/src/app/cash-wallet-cutover/preflight.ts @@ -15,6 +15,8 @@ type CashWalletCutoverPreflightReport = { canStart: boolean } +export type { CashWalletCutoverPreflightReport } + export const buildCashWalletCutoverPreflightReport = ({ cutoverVersion, runId, diff --git a/src/app/cash-wallet-cutover/prepare.ts b/src/app/cash-wallet-cutover/prepare.ts new file mode 100644 index 000000000..d32a62976 --- /dev/null +++ b/src/app/cash-wallet-cutover/prepare.ts @@ -0,0 +1,57 @@ +import { buildCashWalletCutoverPreflightReport } from "./preflight" +import { discoverCashWalletCutoverAccounts } from "./discovery" +import { buildPrimaryCashWalletMigrationPlan } from "./planner" +import { + upsertPrimaryCashWalletMigrationRecords, + CashWalletMigrationRecordsRepository, +} from "./migration-records" + +type PreparePrimaryCashWalletCutoverResult = { + report: CashWalletCutoverPreflightReport + plannedMigrations: PrimaryCashWalletMigrationPlan[] + migrations: CashWalletMigration[] +} + +export const preparePrimaryCashWalletCutover = async ({ + cutoverVersion, + runId, + accountsRepo, + walletsRepo, + migrationsRepo, +}: { + cutoverVersion: number + runId: string + accountsRepo: Pick + walletsRepo: Pick + migrationsRepo: CashWalletMigrationRecordsRepository +}): Promise => { + const discoveries = await discoverCashWalletCutoverAccounts({ + accountsRepo, + walletsRepo, + }) + if (discoveries instanceof Error) return discoveries + + const report = buildCashWalletCutoverPreflightReport({ + cutoverVersion, + runId, + discoveries, + }) + + if (!report.canStart) { + return { report, plannedMigrations: [], migrations: [] } + } + + const plannedMigrations = buildPrimaryCashWalletMigrationPlan({ + cutoverVersion, + runId, + discoveries, + }) + + const migrations = await upsertPrimaryCashWalletMigrationRecords({ + migrationsRepo, + plans: plannedMigrations, + }) + if (migrations instanceof Error) return migrations + + return { report, plannedMigrations, migrations } +} 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) + }) +}) From 0a85eff07fd859ec1f08ddab582a2a5f3527a1d5 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 10:00:12 -0400 Subject: [PATCH 11/40] feat(cutover): start migration worker checkpoint --- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/worker.ts | 34 +++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 71 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/app/cash-wallet-cutover/worker.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/worker.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 5e49b7ca7..2d707e786 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -7,3 +7,4 @@ export * from "./preflight" export * from "./planner" export * from "./migration-records" export * from "./prepare" +export * from "./worker" diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts new file mode 100644 index 000000000..fa5fe53e4 --- /dev/null +++ b/src/app/cash-wallet-cutover/worker.ts @@ -0,0 +1,34 @@ +import { assertCanTransition } from "./state-machine" + +type CashWalletMigrationTransitionRepository = { + transitionMigration(args: { + id: string + from: CashWalletMigrationStatus + to: CashWalletMigrationStatus + cutoverVersion: number + runId: string + patch?: Partial + }): Promise +} + +export const startCashWalletMigration = async ({ + migration, + migrationsRepo, + startedAt, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository + startedAt: Date +}): Promise => { + const transition = assertCanTransition(migration.status, "started") + if (transition instanceof Error) return transition + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "started", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { startedAt }, + }) +} 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..6ffd4ba33 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -0,0 +1,71 @@ +import { CouldNotUpdateError } from "@domain/errors" + +import { startCashWalletMigration } 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"), +}) + +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) + }) +}) From c6de5d7fe909bdfb3e457b5060daeba1f8e53fd1 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 10:03:05 -0400 Subject: [PATCH 12/40] feat(cutover): record migration source balance --- src/app/cash-wallet-cutover/worker.ts | 29 ++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 53 ++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index fa5fe53e4..ab2baed7b 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,4 +1,5 @@ import { assertCanTransition } from "./state-machine" +import { usdCentsToUsdtMicros } from "./amount-conversion" type CashWalletMigrationTransitionRepository = { transitionMigration(args: { @@ -32,3 +33,31 @@ export const startCashWalletMigration = async ({ patch: { startedAt }, }) } + +export const recordCashWalletMigrationBalance = async ({ + migration, + migrationsRepo, + sourceBalanceUsdCents, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository + sourceBalanceUsdCents: string +}): Promise => { + const destinationAmountUsdtMicros = usdCentsToUsdtMicros(sourceBalanceUsdCents) + if (destinationAmountUsdtMicros instanceof Error) return destinationAmountUsdtMicros + + const transition = assertCanTransition(migration.status, "balance_read") + if (transition instanceof Error) return transition + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "balance_read", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + sourceBalanceUsdCents, + destinationAmountUsdtMicros, + }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 6ffd4ba33..cb0cfae57 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -1,6 +1,9 @@ import { CouldNotUpdateError } from "@domain/errors" -import { startCashWalletMigration } from "@app/cash-wallet-cutover/worker" +import { + recordCashWalletMigrationBalance, + startCashWalletMigration, +} from "@app/cash-wallet-cutover/worker" const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ id: "migration-id", @@ -68,4 +71,52 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBe(error) }) + + it("records source balance and destination amount before creating invoices", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("balance_read"), + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + })), + } + + const result = await recordCashWalletMigrationBalance({ + migration: migration("provisioned"), + migrationsRepo, + sourceBalanceUsdCents: "1234", + }) + + expect(result).toMatchObject({ + status: "balance_read", + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + }) + expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ + id: "migration-id", + from: "provisioned", + to: "balance_read", + cutoverVersion: 7, + runId: "run-7", + patch: { + sourceBalanceUsdCents: "1234", + destinationAmountUsdtMicros: "12340000", + }, + }) + }) + + 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", + }) + + expect(result).toBeInstanceOf(Error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From ff14a2c898ca22303b4f9b77d43c49a3f5be8a7b Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:02:35 -0400 Subject: [PATCH 13/40] feat(cutover): create balance move invoice checkpoint --- src/app/cash-wallet-cutover/worker.ts | 47 ++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 90 +++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index ab2baed7b..398d8d806 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,5 +1,6 @@ import { assertCanTransition } from "./state-machine" import { usdCentsToUsdtMicros } from "./amount-conversion" +import { InvalidCashWalletCutoverAmountError } from "./errors" type CashWalletMigrationTransitionRepository = { transitionMigration(args: { @@ -12,6 +13,14 @@ type CashWalletMigrationTransitionRepository = { }): Promise } +type CashWalletMigrationInvoiceService = { + createInvoice(args: { + recipientWalletId: WalletId + amount: string + memo: string + }): Promise +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -61,3 +70,41 @@ export const recordCashWalletMigrationBalance = async ({ }, }) } + +export const createCashWalletMigrationBalanceMoveInvoice = async ({ + migration, + invoiceService, + migrationsRepo, +}: { + migration: CashWalletMigration + invoiceService: CashWalletMigrationInvoiceService + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "invoice_created") + if (transition instanceof Error) return transition + + if (migration.destinationAmountUsdtMicros === undefined) { + return new InvalidCashWalletCutoverAmountError( + "destinationAmountUsdtMicros is required before balance move invoice creation", + ) + } + + const invoice = await invoiceService.createInvoice({ + recipientWalletId: migration.destinationUsdtWalletId, + amount: migration.destinationAmountUsdtMicros, + memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:balance-move`, + }) + if (invoice instanceof Error) return invoice + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "invoice_created", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + balanceMoveInvoicePaymentRequest: invoice.paymentRequest, + balanceMoveInvoicePaymentHash: invoice.paymentHash, + }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index cb0cfae57..9c3f3d7b4 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -1,6 +1,7 @@ import { CouldNotUpdateError } from "@domain/errors" import { + createCashWalletMigrationBalanceMoveInvoice, recordCashWalletMigrationBalance, startCashWalletMigration, } from "@app/cash-wallet-cutover/worker" @@ -119,4 +120,93 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBeInstanceOf(Error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("creates a 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 = { + createInvoice: 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.createInvoice).toHaveBeenCalledWith({ + recipientWalletId: "usdt-wallet-id", + amount: "12340000", + 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 = { + createInvoice: jest.fn(), + } + + const result = await createCashWalletMigrationBalanceMoveInvoice({ + migration: migration("balance_read"), + invoiceService, + migrationsRepo, + }) + + expect(result).toBeInstanceOf(Error) + expect(invoiceService.createInvoice).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 = { + createInvoice: 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() + }) }) From cbc7bde4af4b38068aeb849cacd1c079300eaae9 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:05:28 -0400 Subject: [PATCH 14/40] feat(cutover): send balance move payment checkpoint --- src/app/cash-wallet-cutover/worker.ts | 48 +++++++++++++- .../app/cash-wallet-cutover/worker.spec.ts | 62 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 398d8d806..7b59c7bba 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,6 +1,9 @@ import { assertCanTransition } from "./state-machine" import { usdCentsToUsdtMicros } from "./amount-conversion" -import { InvalidCashWalletCutoverAmountError } from "./errors" +import { + InvalidCashWalletCutoverAmountError, + InvalidCashWalletMigrationTransitionError, +} from "./errors" type CashWalletMigrationTransitionRepository = { transitionMigration(args: { @@ -21,6 +24,13 @@ type CashWalletMigrationInvoiceService = { }): Promise } +type CashWalletMigrationPaymentService = { + payInvoice(args: { + senderWalletId: WalletId + paymentRequest: string + }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -71,6 +81,42 @@ export const recordCashWalletMigrationBalance = async ({ }) } +export const sendCashWalletMigrationBalanceMovePayment = async ({ + migration, + paymentService, + migrationsRepo, +}: { + migration: CashWalletMigration + paymentService: CashWalletMigrationPaymentService + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "balance_move_sending") + if (transition instanceof Error) return transition + + if (migration.balanceMoveInvoicePaymentRequest === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMoveInvoicePaymentRequest is required before balance move payment sending", + ) + } + + const payment = await paymentService.payInvoice({ + senderWalletId: migration.legacyUsdWalletId, + paymentRequest: migration.balanceMoveInvoicePaymentRequest, + }) + if (payment instanceof Error) return payment + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "balance_move_sending", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + balanceMovePaymentTransactionId: payment.transactionId, + }, + }) +} + export const createCashWalletMigrationBalanceMoveInvoice = async ({ migration, invoiceService, diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 9c3f3d7b4..46a693c5b 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -3,6 +3,7 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, recordCashWalletMigrationBalance, + sendCashWalletMigrationBalanceMovePayment, startCashWalletMigration, } from "@app/cash-wallet-cutover/worker" @@ -209,4 +210,65 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBe(error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("sends the balance move payment from the legacy wallet", 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", + }, + 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", + }) + 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("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() + }) }) From 49c13035793bea6802c5e56bc3af89221e87849a Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:11:56 -0400 Subject: [PATCH 15/40] feat(cutover): verify balance move checkpoint --- src/app/cash-wallet-cutover/worker.ts | 71 +++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 120 ++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 7b59c7bba..19c2439dd 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -31,6 +31,16 @@ type CashWalletMigrationPaymentService = { }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> } +type CashWalletMigrationBalanceVerifier = { + verifyBalanceMove(args: { + legacyUsdWalletId: WalletId + destinationUsdtWalletId: WalletId + sourceBalanceUsdCents?: string + destinationAmountUsdtMicros?: string + transactionId: IbexTransactionId + }): Promise +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -117,6 +127,67 @@ export const sendCashWalletMigrationBalanceMovePayment = async ({ }) } +export const markCashWalletMigrationBalanceMoveSent = async ({ + migration, + migrationsRepo, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "balance_move_sent") + if (transition instanceof Error) return transition + + if (migration.balanceMovePaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMovePaymentTransactionId is required before marking balance move sent", + ) + } + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "balance_move_sent", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + +export const verifyCashWalletMigrationBalanceMove = async ({ + migration, + balanceVerifier, + migrationsRepo, +}: { + migration: CashWalletMigration + balanceVerifier: CashWalletMigrationBalanceVerifier + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "balance_move_verified") + if (transition instanceof Error) return transition + + if (migration.balanceMovePaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMovePaymentTransactionId is required before verifying balance move", + ) + } + + const verified = await balanceVerifier.verifyBalanceMove({ + legacyUsdWalletId: migration.legacyUsdWalletId, + destinationUsdtWalletId: migration.destinationUsdtWalletId, + sourceBalanceUsdCents: migration.sourceBalanceUsdCents, + destinationAmountUsdtMicros: migration.destinationAmountUsdtMicros, + transactionId: migration.balanceMovePaymentTransactionId, + }) + if (verified instanceof Error) return verified + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "balance_move_verified", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + export const createCashWalletMigrationBalanceMoveInvoice = async ({ migration, invoiceService, diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 46a693c5b..85b5c0504 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -2,9 +2,11 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, + markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, startCashWalletMigration, + verifyCashWalletMigrationBalanceMove, } from "@app/cash-wallet-cutover/worker" const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ @@ -271,4 +273,122 @@ describe("cash wallet migration worker checkpoints", () => { 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), + } + + 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() + }) }) From cc57275359e34099adfa670f8cf0b786486e5224 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:14:16 -0400 Subject: [PATCH 16/40] feat(cutover): create fee reimbursement invoice checkpoint --- src/app/cash-wallet-cutover/worker.ts | 44 ++++++++- .../app/cash-wallet-cutover/worker.spec.ts | 93 +++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 19c2439dd..6fd996582 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,5 +1,5 @@ import { assertCanTransition } from "./state-machine" -import { usdCentsToUsdtMicros } from "./amount-conversion" +import { feeUsdCentsToUsdtMicros, usdCentsToUsdtMicros } from "./amount-conversion" import { InvalidCashWalletCutoverAmountError, InvalidCashWalletMigrationTransitionError, @@ -225,3 +225,45 @@ export const createCashWalletMigrationBalanceMoveInvoice = async ({ }, }) } + +export const createCashWalletMigrationFeeReimbursementInvoice = async ({ + migration, + invoiceService, + migrationsRepo, + feeAmountUsdCents, +}: { + migration: CashWalletMigration + invoiceService: CashWalletMigrationInvoiceService + migrationsRepo: CashWalletMigrationTransitionRepository + feeAmountUsdCents: string +}): Promise => { + const feeAmountUsdtMicros = feeUsdCentsToUsdtMicros(feeAmountUsdCents) + if (feeAmountUsdtMicros instanceof Error) return feeAmountUsdtMicros + + const transition = assertCanTransition( + migration.status, + "fee_reimbursement_invoice_created", + ) + if (transition instanceof Error) return transition + + const invoice = await invoiceService.createInvoice({ + recipientWalletId: migration.legacyUsdWalletId, + amount: feeAmountUsdCents, + memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:fee-reimbursement`, + }) + if (invoice instanceof Error) return invoice + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "fee_reimbursement_invoice_created", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + feeAmountUsdCents, + feeAmountUsdtMicros, + feeReimbursementInvoicePaymentRequest: invoice.paymentRequest, + feeReimbursementInvoicePaymentHash: invoice.paymentHash, + }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 85b5c0504..75601d5c2 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -2,6 +2,7 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, + createCashWalletMigrationFeeReimbursementInvoice, markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, @@ -391,4 +392,96 @@ describe("cash wallet migration worker checkpoints", () => { expect(balanceVerifier.verifyBalanceMove).not.toHaveBeenCalled() expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("creates a fee reimbursement invoice on the legacy wallet", async () => { + const migrationsRepo = { + transitionMigration: jest.fn(async () => ({ + ...migration("fee_reimbursement_invoice_created"), + feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70000", + 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, + feeAmountUsdCents: "7", + }) + + expect(result).toMatchObject({ + status: "fee_reimbursement_invoice_created", + feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70000", + feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", + feeReimbursementInvoicePaymentHash: "feePaymentHash", + }) + expect(invoiceService.createInvoice).toHaveBeenCalledWith({ + recipientWalletId: "legacy-usd-wallet-id", + amount: "7", + 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: "7", + feeAmountUsdtMicros: "70000", + 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, + feeAmountUsdCents: "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, + feeAmountUsdCents: "7", + }) + + expect(result).toBe(error) + expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() + }) }) From acb481a96101145265f7765ecd336a8beb7a5d4a Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:18:06 -0400 Subject: [PATCH 17/40] feat(cutover): complete fee reimbursement checkpoint --- src/app/cash-wallet-cutover/worker.ts | 61 +++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 128 ++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 6fd996582..b6e30ff29 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -267,3 +267,64 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ }, }) } + +export const sendCashWalletMigrationFeeReimbursementPayment = async ({ + migration, + paymentService, + migrationsRepo, +}: { + migration: CashWalletMigration + paymentService: CashWalletMigrationPaymentService + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "fee_reimbursement_sending") + if (transition instanceof Error) return transition + + if (migration.feeReimbursementInvoicePaymentRequest === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeReimbursementInvoicePaymentRequest is required before fee reimbursement sending", + ) + } + + const payment = await paymentService.payInvoice({ + senderWalletId: migration.destinationUsdtWalletId, + paymentRequest: migration.feeReimbursementInvoicePaymentRequest, + }) + if (payment instanceof Error) return payment + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "fee_reimbursement_sending", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + feeReimbursementPaymentTransactionId: payment.transactionId, + }, + }) +} + +export const markCashWalletMigrationFeeReimbursed = async ({ + migration, + migrationsRepo, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "fee_reimbursed") + if (transition instanceof Error) return transition + + if (migration.feeReimbursementPaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeReimbursementPaymentTransactionId is required before marking fee reimbursed", + ) + } + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "fee_reimbursed", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 75601d5c2..e773d334d 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -3,9 +3,11 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, createCashWalletMigrationFeeReimbursementInvoice, + markCashWalletMigrationFeeReimbursed, markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, + sendCashWalletMigrationFeeReimbursementPayment, startCashWalletMigration, verifyCashWalletMigrationBalanceMove, } from "@app/cash-wallet-cutover/worker" @@ -484,4 +486,130 @@ describe("cash wallet migration worker checkpoints", () => { expect(result).toBe(error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) + + it("sends the fee reimbursement payment from the destination 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", + }, + paymentService, + migrationsRepo, + }) + + expect(result).toMatchObject({ + status: "fee_reimbursement_sending", + feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", + }) + expect(paymentService.payInvoice).toHaveBeenCalledWith({ + senderWalletId: "usdt-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("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"), + 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", + }, + 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() + }) }) From 54547f95ce646451fee7572b470d7826c109c12c Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:20:02 -0400 Subject: [PATCH 18/40] feat(cutover): flip default wallet checkpoint --- src/app/cash-wallet-cutover/worker.ts | 37 ++++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 59 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index b6e30ff29..79c72f221 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -41,6 +41,13 @@ type CashWalletMigrationBalanceVerifier = { }): Promise } +type CashWalletMigrationPointerService = { + flipDefaultWallet(args: { + accountId: AccountId + destinationWalletId: WalletId + }): Promise<{ previousDefaultWalletId: WalletId } | ApplicationError> +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -328,3 +335,33 @@ export const markCashWalletMigrationFeeReimbursed = async ({ runId: migration.runId, }) } + +export const flipCashWalletMigrationDefaultPointer = async ({ + migration, + pointerService, + migrationsRepo, +}: { + migration: CashWalletMigration + pointerService: CashWalletMigrationPointerService + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "pointer_flipped") + if (transition instanceof Error) return transition + + const pointer = await pointerService.flipDefaultWallet({ + accountId: migration.accountId, + destinationWalletId: migration.destinationUsdtWalletId, + }) + if (pointer instanceof Error) return pointer + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "pointer_flipped", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + previousDefaultWalletId: pointer.previousDefaultWalletId, + }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index e773d334d..4492b6d51 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -3,6 +3,7 @@ import { CouldNotUpdateError } from "@domain/errors" import { createCashWalletMigrationBalanceMoveInvoice, createCashWalletMigrationFeeReimbursementInvoice, + flipCashWalletMigrationDefaultPointer, markCashWalletMigrationFeeReimbursed, markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, @@ -612,4 +613,62 @@ describe("cash wallet migration worker checkpoints", () => { 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() + }) }) From 825dc583a30d40ac53231cddff06a2e928a84240 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:31:54 -0400 Subject: [PATCH 19/40] feat(cutover): complete migration worker checkpoints --- src/app/cash-wallet-cutover/worker.ts | 54 +++++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 77 +++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 79c72f221..2f50c54f6 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -48,6 +48,12 @@ type CashWalletMigrationPointerService = { }): Promise<{ previousDefaultWalletId: WalletId } | ApplicationError> } +type CashWalletMigrationLegacyWalletVerifier = { + verifyLegacyWalletZero(args: { + legacyUsdWalletId: WalletId + }): Promise +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -365,3 +371,51 @@ export const flipCashWalletMigrationDefaultPointer = async ({ }, }) } + +export const verifyCashWalletMigrationLegacyZero = async ({ + migration, + legacyWalletVerifier, + migrationsRepo, +}: { + migration: CashWalletMigration + legacyWalletVerifier: CashWalletMigrationLegacyWalletVerifier + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "legacy_zero_verified") + if (transition instanceof Error) return transition + + const verified = await legacyWalletVerifier.verifyLegacyWalletZero({ + legacyUsdWalletId: migration.legacyUsdWalletId, + }) + if (verified instanceof Error) return verified + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "legacy_zero_verified", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + +export const completeCashWalletMigration = async ({ + migration, + migrationsRepo, + completedAt, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository + completedAt: Date +}): Promise => { + const transition = assertCanTransition(migration.status, "complete") + if (transition instanceof Error) return transition + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "complete", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { completedAt }, + }) +} diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 4492b6d51..b6d5fa9f6 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -4,6 +4,7 @@ import { createCashWalletMigrationBalanceMoveInvoice, createCashWalletMigrationFeeReimbursementInvoice, flipCashWalletMigrationDefaultPointer, + completeCashWalletMigration, markCashWalletMigrationFeeReimbursed, markCashWalletMigrationBalanceMoveSent, recordCashWalletMigrationBalance, @@ -11,6 +12,7 @@ import { sendCashWalletMigrationFeeReimbursementPayment, startCashWalletMigration, verifyCashWalletMigrationBalanceMove, + verifyCashWalletMigrationLegacyZero, } from "@app/cash-wallet-cutover/worker" const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ @@ -671,4 +673,79 @@ describe("cash wallet migration worker checkpoints", () => { 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), + } + + 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 }, + }) + }) }) From 85a846a6734efbd3de0a6642ffd1e9e5d6ba6ecd Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:34:33 -0400 Subject: [PATCH 20/40] feat(cutover): provision destination checkpoint --- src/app/cash-wallet-cutover/worker.ts | 34 +++++++++++++ .../app/cash-wallet-cutover/worker.spec.ts | 48 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 2f50c54f6..efccc719a 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -54,6 +54,13 @@ type CashWalletMigrationLegacyWalletVerifier = { }): Promise } +type CashWalletMigrationProvisioningService = { + ensureDestinationWallet(args: { + accountId: AccountId + destinationUsdtWalletId: WalletId + }): Promise +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -76,6 +83,33 @@ export const startCashWalletMigration = async ({ }) } +export const provisionCashWalletMigrationDestination = async ({ + migration, + provisioningService, + migrationsRepo, +}: { + migration: CashWalletMigration + provisioningService: CashWalletMigrationProvisioningService + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "provisioned") + if (transition instanceof Error) return transition + + const provisioned = await provisioningService.ensureDestinationWallet({ + accountId: migration.accountId, + destinationUsdtWalletId: migration.destinationUsdtWalletId, + }) + if (provisioned instanceof Error) return provisioned + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "provisioned", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + }) +} + export const recordCashWalletMigrationBalance = async ({ migration, migrationsRepo, diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index b6d5fa9f6..6681a3e72 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -7,6 +7,7 @@ import { completeCashWalletMigration, markCashWalletMigrationFeeReimbursed, markCashWalletMigrationBalanceMoveSent, + provisionCashWalletMigrationDestination, recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, sendCashWalletMigrationFeeReimbursementPayment, @@ -82,6 +83,53 @@ describe("cash wallet migration worker checkpoints", () => { 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), + } + + 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 () => ({ From 09b44bed030cd4c591d9a0e28e5686d7f5fef4e8 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:37:18 -0400 Subject: [PATCH 21/40] feat(cutover): dispatch migration worker steps --- src/app/cash-wallet-cutover/executor.ts | 39 +++++++++ src/app/cash-wallet-cutover/index.ts | 1 + .../app/cash-wallet-cutover/executor.spec.ts | 82 +++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 src/app/cash-wallet-cutover/executor.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/executor.spec.ts diff --git a/src/app/cash-wallet-cutover/executor.ts b/src/app/cash-wallet-cutover/executor.ts new file mode 100644 index 000000000..5df68aa00 --- /dev/null +++ b/src/app/cash-wallet-cutover/executor.ts @@ -0,0 +1,39 @@ +type RunnableCashWalletMigrationStatus = Exclude< + CashWalletMigrationStatus, + | "complete" + | "failed" + | "requires_operator_review" + | "skipped_already_migrated" + | "rollback_started" + | "rolled_back" +> + +type CashWalletMigrationStepHandler = ( + migration: CashWalletMigration, +) => Promise + +export type CashWalletMigrationStepHandlers = Record< + RunnableCashWalletMigrationStatus, + CashWalletMigrationStepHandler +> + +const terminalStatuses: CashWalletMigrationStatus[] = [ + "complete", + "failed", + "requires_operator_review", + "skipped_already_migrated", + "rollback_started", + "rolled_back", +] + +export const executeCashWalletMigrationStep = async ({ + migration, + handlers, +}: { + migration: CashWalletMigration + handlers: CashWalletMigrationStepHandlers +}): Promise => { + if (terminalStatuses.includes(migration.status)) return migration + + return handlers[migration.status as RunnableCashWalletMigrationStatus](migration) +} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 2d707e786..055c076f6 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -8,3 +8,4 @@ export * from "./planner" export * from "./migration-records" export * from "./prepare" export * from "./worker" +export * from "./executor" 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() + }) +}) From 544788cf68f1d1a9793d2d8ed4e3e74afbbcad97 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:51:25 -0400 Subject: [PATCH 22/40] feat(cutover): run locked migration batches --- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/runner.ts | 95 ++++++++++++++ .../app/cash-wallet-cutover/runner.spec.ts | 117 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 src/app/cash-wallet-cutover/runner.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/runner.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 055c076f6..84ac1018e 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -9,3 +9,4 @@ export * from "./migration-records" export * from "./prepare" export * from "./worker" export * from "./executor" +export * from "./runner" diff --git a/src/app/cash-wallet-cutover/runner.ts b/src/app/cash-wallet-cutover/runner.ts new file mode 100644 index 000000000..a416e09a7 --- /dev/null +++ b/src/app/cash-wallet-cutover/runner.ts @@ -0,0 +1,95 @@ +type CashWalletMigrationBatchRepository = { + listRunnableMigrations(args: { + cutoverVersion: number + runId: string + limit?: number + }): Promise + acquireMigrationLock(args: { + id: string + workerId: string + staleBefore: Date + cutoverVersion: number + runId: string + }): Promise + releaseMigrationLock(args: { + id: string + workerId: string + cutoverVersion: number + runId: string + }): Promise +} + +type CashWalletMigrationBatchExecutor = ( + migration: CashWalletMigration, +) => Promise + +type CashWalletMigrationBatchResult = { + attempted: number + advanced: number + failed: number + skipped: number +} + +export const runCashWalletMigrationBatch = async ({ + cutoverVersion, + runId, + workerId, + limit, + lockStaleBefore, + migrationsRepo, + executor, +}: { + cutoverVersion: number + runId: string + workerId: string + limit?: number + lockStaleBefore: Date + migrationsRepo: CashWalletMigrationBatchRepository + executor: CashWalletMigrationBatchExecutor +}): Promise => { + const migrations = await migrationsRepo.listRunnableMigrations({ + cutoverVersion, + runId, + limit, + }) + if (migrations instanceof Error) return migrations + + const result: CashWalletMigrationBatchResult = { + attempted: 0, + advanced: 0, + failed: 0, + skipped: 0, + } + + for (const migration of migrations) { + result.attempted += 1 + + const locked = await migrationsRepo.acquireMigrationLock({ + id: migration.id, + workerId, + staleBefore: lockStaleBefore, + cutoverVersion, + runId, + }) + if (locked instanceof Error) { + result.skipped += 1 + continue + } + + const step = await executor(locked) + if (step instanceof Error) { + result.failed += 1 + } else { + result.advanced += 1 + } + + await migrationsRepo.releaseMigrationLock({ + id: locked.id, + workerId, + cutoverVersion, + runId, + }) + } + + return result +} 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..ab33cc4a2 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts @@ -0,0 +1,117 @@ +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), + 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), + 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), + 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.releaseMigrationLock).toHaveBeenCalledWith({ + id: "migration-1", + workerId: "worker-1", + cutoverVersion: 7, + runId: "run-7", + }) + }) +}) From 0c03cf5b87afa6de54422ae6a6d4f2c3f2ab772c Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 11:57:04 -0400 Subject: [PATCH 23/40] feat(cutover): build migration step handlers --- src/app/cash-wallet-cutover/handlers.ts | 139 ++++++++++++++++++ src/app/cash-wallet-cutover/index.ts | 1 + .../app/cash-wallet-cutover/handlers.spec.ts | 94 ++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 src/app/cash-wallet-cutover/handlers.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts diff --git a/src/app/cash-wallet-cutover/handlers.ts b/src/app/cash-wallet-cutover/handlers.ts new file mode 100644 index 000000000..af563fdd1 --- /dev/null +++ b/src/app/cash-wallet-cutover/handlers.ts @@ -0,0 +1,139 @@ +import { + completeCashWalletMigration, + createCashWalletMigrationBalanceMoveInvoice, + createCashWalletMigrationFeeReimbursementInvoice, + flipCashWalletMigrationDefaultPointer, + markCashWalletMigrationBalanceMoveSent, + markCashWalletMigrationFeeReimbursed, + provisionCashWalletMigrationDestination, + recordCashWalletMigrationBalance, + sendCashWalletMigrationBalanceMovePayment, + sendCashWalletMigrationFeeReimbursementPayment, + startCashWalletMigration, + verifyCashWalletMigrationBalanceMove, + verifyCashWalletMigrationLegacyZero, +} from "./worker" +import { CashWalletMigrationStepHandlers } from "./executor" + +type CashWalletMigrationTransitionRepository = Parameters< + typeof startCashWalletMigration +>[0]["migrationsRepo"] + +type CashWalletMigrationHandlerServices = { + now(): Date + provisioningService: Parameters< + typeof provisionCashWalletMigrationDestination + >[0]["provisioningService"] + balanceReader: { + readSourceBalanceUsdCents( + migration: CashWalletMigration, + ): Promise + } + invoiceService: Parameters< + typeof createCashWalletMigrationBalanceMoveInvoice + >[0]["invoiceService"] + paymentService: Parameters< + typeof sendCashWalletMigrationBalanceMovePayment + >[0]["paymentService"] + balanceVerifier: Parameters< + typeof verifyCashWalletMigrationBalanceMove + >[0]["balanceVerifier"] + feeService: { + readFeeAmountUsdCents( + migration: CashWalletMigration, + ): Promise + } + pointerService: Parameters< + typeof flipCashWalletMigrationDefaultPointer + >[0]["pointerService"] + legacyWalletVerifier: Parameters< + typeof verifyCashWalletMigrationLegacyZero + >[0]["legacyWalletVerifier"] +} + +export const createCashWalletMigrationStepHandlers = ({ + migrationsRepo, + services, +}: { + migrationsRepo: CashWalletMigrationTransitionRepository + services: CashWalletMigrationHandlerServices +}): CashWalletMigrationStepHandlers => ({ + not_started: (migration) => + startCashWalletMigration({ + migration, + migrationsRepo, + startedAt: services.now(), + }), + started: (migration) => + provisionCashWalletMigrationDestination({ + migration, + migrationsRepo, + provisioningService: services.provisioningService, + }), + provisioned: async (migration) => { + const sourceBalanceUsdCents = + await services.balanceReader.readSourceBalanceUsdCents(migration) + if (sourceBalanceUsdCents instanceof Error) return sourceBalanceUsdCents + return recordCashWalletMigrationBalance({ + migration, + migrationsRepo, + sourceBalanceUsdCents, + }) + }, + balance_read: (migration) => + createCashWalletMigrationBalanceMoveInvoice({ + migration, + migrationsRepo, + invoiceService: services.invoiceService, + }), + invoice_created: (migration) => + sendCashWalletMigrationBalanceMovePayment({ + migration, + migrationsRepo, + paymentService: services.paymentService, + }), + balance_move_sending: (migration) => + markCashWalletMigrationBalanceMoveSent({ migration, migrationsRepo }), + balance_move_sent: (migration) => + verifyCashWalletMigrationBalanceMove({ + migration, + migrationsRepo, + balanceVerifier: services.balanceVerifier, + }), + balance_move_verified: async (migration) => { + const feeAmountUsdCents = await services.feeService.readFeeAmountUsdCents(migration) + if (feeAmountUsdCents instanceof Error) return feeAmountUsdCents + return createCashWalletMigrationFeeReimbursementInvoice({ + migration, + migrationsRepo, + invoiceService: services.invoiceService, + feeAmountUsdCents, + }) + }, + fee_reimbursement_invoice_created: (migration) => + sendCashWalletMigrationFeeReimbursementPayment({ + migration, + migrationsRepo, + paymentService: services.paymentService, + }), + fee_reimbursement_sending: (migration) => + markCashWalletMigrationFeeReimbursed({ migration, migrationsRepo }), + fee_reimbursed: (migration) => + flipCashWalletMigrationDefaultPointer({ + migration, + migrationsRepo, + pointerService: services.pointerService, + }), + pointer_flipped: (migration) => + verifyCashWalletMigrationLegacyZero({ + migration, + migrationsRepo, + legacyWalletVerifier: services.legacyWalletVerifier, + }), + legacy_zero_verified: (migration) => + completeCashWalletMigration({ + migration, + migrationsRepo, + completedAt: services.now(), + }), +}) diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 84ac1018e..afd60836c 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -10,3 +10,4 @@ export * from "./prepare" export * from "./worker" export * from "./executor" export * from "./runner" +export * from "./handlers" 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..f36dd5102 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts @@ -0,0 +1,94 @@ +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"), + }, + invoiceService: { + createInvoice: jest.fn( + async () => + ({ + paymentRequest: "lnbc1" as EncodedPaymentRequest, + paymentHash: "hash" as PaymentHash, + }) as LnInvoice, + ), + }, + paymentService: { + payInvoice: jest.fn(async () => ({ + transactionId: "ibex-tx-id" as IbexTransactionId, + })), + }, + balanceVerifier: { + verifyBalanceMove: jest.fn(async () => true), + }, + feeService: { + readFeeAmountUsdCents: jest.fn(async () => "7"), + }, + 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.feeService.readFeeAmountUsdCents).toHaveBeenCalledWith( + migration("balance_move_verified"), + ) + }) +}) From 8169fcc24f220b294cbfd95cbd413427836d5e01 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:04:02 -0400 Subject: [PATCH 24/40] feat(cutover): wire migration runtime services --- src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/runtime-services.ts | 195 ++++++++++++++++++ .../runtime-services.spec.ts | 168 +++++++++++++++ 3 files changed, 364 insertions(+) create mode 100644 src/app/cash-wallet-cutover/runtime-services.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index afd60836c..d33b9fd57 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -11,3 +11,4 @@ export * from "./worker" export * from "./executor" export * from "./runner" export * from "./handlers" +export * from "./runtime-services" diff --git a/src/app/cash-wallet-cutover/runtime-services.ts b/src/app/cash-wallet-cutover/runtime-services.ts new file mode 100644 index 000000000..920a29476 --- /dev/null +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -0,0 +1,195 @@ +import { addWalletIfNonexistent, updateDefaultWalletId } from "@app/accounts" +import { addInvoiceForRecipientForUsdWallet, getBalanceForWallet } from "@app/wallets" +import { InvalidWalletId } from "@domain/errors" +import { USDAmount, WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" +import { AccountsRepository } from "@services/mongoose" +import Ibex from "@services/ibex/client" +import { UnexpectedIbexResponse } from "@services/ibex/errors" + +import { + CashWalletMigrationFailedError, + InvalidCashWalletCutoverAmountError, + InvalidCashWalletMigrationTransitionError, +} from "./errors" + +type RuntimeServiceDependencies = { + now?: () => Date + addWalletIfNonexistent?: typeof addWalletIfNonexistent + updateDefaultWalletId?: typeof updateDefaultWalletId + getBalanceForWallet?: typeof getBalanceForWallet + createInvoice?: typeof addInvoiceForRecipientForUsdWallet + payInvoice?: typeof Ibex.payInvoice + getTransactionDetails?: typeof Ibex.getTransactionDetails + accountsRepo?: Pick, "findById"> +} + +const isUsdAmount = (amount: unknown): amount is USDAmount => amount instanceof USDAmount + +const feeAmountUsdCentsFromNumber = ( + feeAmount: number | undefined, +): string | InvalidCashWalletCutoverAmountError => { + if (feeAmount === undefined || Number.isNaN(feeAmount) || feeAmount < 0) { + return new InvalidCashWalletCutoverAmountError("Invalid fee amount") + } + return Math.ceil(feeAmount * 100).toString() +} + +export const createCashWalletMigrationRuntimeServices = ( + deps: RuntimeServiceDependencies = {}, +) => { + const addWallet = deps.addWalletIfNonexistent ?? addWalletIfNonexistent + const updateDefaultWallet = deps.updateDefaultWalletId ?? updateDefaultWalletId + const balanceForWallet = deps.getBalanceForWallet ?? getBalanceForWallet + const invoiceForRecipient = deps.createInvoice ?? addInvoiceForRecipientForUsdWallet + const payInvoice = deps.payInvoice ?? Ibex.payInvoice + const getTransactionDetails = deps.getTransactionDetails ?? Ibex.getTransactionDetails + const accountsRepo = deps.accountsRepo ?? AccountsRepository() + + return { + now: deps.now ?? (() => new Date()), + provisioningService: { + ensureDestinationWallet: async ({ + accountId, + destinationUsdtWalletId, + }: { + accountId: AccountId + destinationUsdtWalletId: WalletId + }): Promise => { + const wallet = await addWallet({ + accountId, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + if (wallet instanceof Error) return wallet + if (wallet.id !== destinationUsdtWalletId) return new InvalidWalletId() + return true + }, + }, + balanceReader: { + readSourceBalanceUsdCents: async ( + migration: CashWalletMigration, + ): Promise => { + const balance = await balanceForWallet({ + walletId: migration.legacyUsdWalletId, + currency: WalletCurrency.Usd, + }) + if (balance instanceof Error) return balance + if (!isUsdAmount(balance)) { + return new InvalidCashWalletCutoverAmountError("Expected USD balance") + } + return balance.asCents() + }, + }, + invoiceService: { + createInvoice: ({ + recipientWalletId, + amount, + memo, + }: { + recipientWalletId: WalletId + amount: string + memo: string + }) => + invoiceForRecipient({ + recipientWalletId, + amount: amount as FractionalCentAmount, + memo, + }), + }, + paymentService: { + payInvoice: async ({ + senderWalletId, + paymentRequest, + }: { + senderWalletId: WalletId + paymentRequest: string + }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> => { + const payment = await payInvoice({ + accountId: senderWalletId as IbexAccountId, + invoice: paymentRequest as Bolt11, + }) + if (payment instanceof Error) return payment + + const transactionId = payment.transaction?.id + if (!transactionId) { + return new UnexpectedIbexResponse("Payment transaction id not found") + } + return { transactionId: transactionId as IbexTransactionId } + }, + }, + balanceVerifier: { + verifyBalanceMove: async ({ + legacyUsdWalletId, + }: { + legacyUsdWalletId: WalletId + }): Promise => { + const balance = await balanceForWallet({ + walletId: legacyUsdWalletId, + currency: WalletCurrency.Usd, + }) + if (balance instanceof Error) return balance + if (!isUsdAmount(balance) || !balance.isZero()) { + return new CashWalletMigrationFailedError("Legacy USD wallet is not zero") + } + return true + }, + }, + feeService: { + readFeeAmountUsdCents: async ( + migration: CashWalletMigration, + ): Promise => { + if (migration.balanceMovePaymentTransactionId === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMovePaymentTransactionId is required before reading fee amount", + ) + } + + const transaction = await getTransactionDetails( + migration.balanceMovePaymentTransactionId, + ) + if (transaction instanceof Error) return transaction + + return feeAmountUsdCentsFromNumber(transaction.networkFee ?? transaction.fee) + }, + }, + pointerService: { + flipDefaultWallet: async ({ + accountId, + destinationWalletId, + }: { + accountId: AccountId + destinationWalletId: WalletId + }): Promise<{ previousDefaultWalletId: WalletId } | ApplicationError> => { + const account = await accountsRepo.findById(accountId) + if (account instanceof Error) return account + + const previousDefaultWalletId = account.defaultWalletId + const updated = await updateDefaultWallet({ + accountId, + walletId: destinationWalletId, + }) + if (updated instanceof Error) return updated + + return { previousDefaultWalletId } + }, + }, + legacyWalletVerifier: { + verifyLegacyWalletZero: async ({ + legacyUsdWalletId, + }: { + legacyUsdWalletId: WalletId + }): Promise => { + const balance = await balanceForWallet({ + walletId: legacyUsdWalletId, + currency: WalletCurrency.Usd, + }) + if (balance instanceof Error) return balance + if (!isUsdAmount(balance) || !balance.isZero()) { + return new CashWalletMigrationFailedError("Legacy USD wallet is not zero") + } + return true + }, + }, + } +} 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..b89a49e38 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -0,0 +1,168 @@ +import { CouldNotUpdateError } from "@domain/errors" +import { USDAmount, 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(), + getBalanceForWallet: jest.fn(), +})) +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ findById: jest.fn() })), +})) +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + payInvoice: jest.fn(), + getTransactionDetails: jest.fn(), + }, +})) + +import { createCashWalletMigrationRuntimeServices } from "@app/cash-wallet-cutover/runtime-services" + +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", () => { + 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("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("extracts the IBEX transaction id after paying an invoice", 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", + }) + + expect(result).toEqual({ transactionId: "ibex-tx-id" }) + expect(deps.payInvoice).toHaveBeenCalledWith({ + accountId: "legacy-usd-wallet-id", + invoice: "lnbc1payment", + }) + }) + + 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("reads the balance move fee as rounded-up USD cents", async () => { + const deps = { + getTransactionDetails: jest.fn(async () => ({ + networkFee: 0.077, + })), + } + + const services = createCashWalletMigrationRuntimeServices(deps) + + const result = await services.feeService.readFeeAmountUsdCents( + migration({ balanceMovePaymentTransactionId: "ibex-tx-id" }), + ) + + expect(result).toBe("8") + expect(deps.getTransactionDetails).toHaveBeenCalledWith("ibex-tx-id") + }) + + 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 () => ({})), + } + + 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) + }) +}) From 1540210ed67c4c68f246f1a51608048d660cc3e3 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:08:56 -0400 Subject: [PATCH 25/40] feat(cutover): orchestrate primary migration batches --- src/app/cash-wallet-cutover/index.ts | 3 +- src/app/cash-wallet-cutover/orchestrator.ts | 52 +++++++++++++ .../cash-wallet-cutover/orchestrator.spec.ts | 78 +++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/app/cash-wallet-cutover/orchestrator.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index d33b9fd57..8ccc61780 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -1,7 +1,7 @@ export * from "./amount-conversion" export * from "./errors" export * from "./state-machine" -export * from "./guard" +export { evaluateCashWalletCutoverGuard } from "./guard" export * from "./discovery" export * from "./preflight" export * from "./planner" @@ -12,3 +12,4 @@ export * from "./executor" export * from "./runner" export * from "./handlers" export * from "./runtime-services" +export * from "./orchestrator" diff --git a/src/app/cash-wallet-cutover/orchestrator.ts b/src/app/cash-wallet-cutover/orchestrator.ts new file mode 100644 index 000000000..89d8203c6 --- /dev/null +++ b/src/app/cash-wallet-cutover/orchestrator.ts @@ -0,0 +1,52 @@ +import { CashWalletCutoverRepository } from "@services/mongoose" + +import { createCashWalletMigrationStepHandlers } from "./handlers" +import { createCashWalletMigrationRuntimeServices } from "./runtime-services" +import { executeCashWalletMigrationStep } from "./executor" +import { runCashWalletMigrationBatch } from "./runner" + +type PrimaryCashWalletCutoverBatchRepository = Parameters< + typeof runCashWalletMigrationBatch +>[0]["migrationsRepo"] & + Parameters[0]["migrationsRepo"] + +type PrimaryCashWalletCutoverRuntimeServices = Parameters< + typeof createCashWalletMigrationStepHandlers +>[0]["services"] + +export const runPrimaryCashWalletCutoverBatch = ({ + cutoverVersion, + runId, + workerId, + limit, + lockStaleBefore, + migrationsRepo = CashWalletCutoverRepository(), + runtimeServices = createCashWalletMigrationRuntimeServices(), +}: { + cutoverVersion: number + runId: string + workerId: string + limit?: number + lockStaleBefore: Date + migrationsRepo?: PrimaryCashWalletCutoverBatchRepository + runtimeServices?: PrimaryCashWalletCutoverRuntimeServices +}) => { + const handlers = createCashWalletMigrationStepHandlers({ + migrationsRepo, + services: runtimeServices, + }) + + return runCashWalletMigrationBatch({ + cutoverVersion, + runId, + workerId, + limit, + lockStaleBefore, + migrationsRepo, + executor: (migration) => + executeCashWalletMigrationStep({ + migration, + handlers, + }), + }) +} 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..4cfd5e1f6 --- /dev/null +++ b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts @@ -0,0 +1,78 @@ +jest.mock("@app/accounts", () => ({ + addWalletIfNonexistent: jest.fn(), + updateDefaultWalletId: jest.fn(), +})) +jest.mock("@app/wallets", () => ({ + addInvoiceForRecipientForUsdWallet: 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), + 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() }, + invoiceService: { createInvoice: jest.fn() }, + paymentService: { payInvoice: jest.fn() }, + balanceVerifier: { verifyBalanceMove: jest.fn() }, + feeService: { readFeeAmountUsdCents: 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") }, + }) + }) +}) From 1d4a49d3bb222a0c474ade72ed871f42f4ae158c Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:15:04 -0400 Subject: [PATCH 26/40] feat(cutover): add migration lifecycle controls --- src/app/cash-wallet-cutover/errors.ts | 1 + src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/lifecycle.ts | 174 ++++++++++++++++ src/graphql/error-map.ts | 4 + .../app/cash-wallet-cutover/lifecycle.spec.ts | 190 ++++++++++++++++++ 5 files changed, 370 insertions(+) create mode 100644 src/app/cash-wallet-cutover/lifecycle.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts diff --git a/src/app/cash-wallet-cutover/errors.ts b/src/app/cash-wallet-cutover/errors.ts index 1bbb68678..1fa62da12 100644 --- a/src/app/cash-wallet-cutover/errors.ts +++ b/src/app/cash-wallet-cutover/errors.ts @@ -2,6 +2,7 @@ import { DomainError, ValidationError } from "@domain/shared" export class InvalidCashWalletCutoverAmountError extends ValidationError {} export class InvalidCashWalletMigrationTransitionError extends ValidationError {} +export class InvalidCashWalletCutoverStateTransitionError extends ValidationError {} export class CashWalletCutoverInProgressError extends ValidationError {} export class CashWalletMigrationFailedError extends DomainError {} export class CashWalletCutoverPreflightError extends DomainError {} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 8ccc61780..e652966cf 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -13,3 +13,4 @@ export * from "./runner" export * from "./handlers" export * from "./runtime-services" export * from "./orchestrator" +export * from "./lifecycle" diff --git a/src/app/cash-wallet-cutover/lifecycle.ts b/src/app/cash-wallet-cutover/lifecycle.ts new file mode 100644 index 000000000..80e4fd848 --- /dev/null +++ b/src/app/cash-wallet-cutover/lifecycle.ts @@ -0,0 +1,174 @@ +import { CashWalletCutoverRepository } from "@services/mongoose" + +import { + CashWalletCutoverInProgressError, + CashWalletMigrationFailedError, + InvalidCashWalletCutoverStateTransitionError, +} from "./errors" + +const migrationStatuses: CashWalletMigrationStatus[] = [ + "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", + "failed", + "requires_operator_review", + "skipped_already_migrated", + "rollback_started", + "rolled_back", +] + +type CashWalletCutoverLifecycleRepository = { + getConfig: () => Promise + updateConfig: ( + patch: Partial, + actor?: string, + ) => Promise + listRunnableMigrations: ({ + cutoverVersion, + runId, + limit, + }: { + cutoverVersion: number + runId: string + limit?: number + }) => Promise + countByStatus: ({ + cutoverVersion, + runId, + status, + }: { + cutoverVersion: number + runId: string + status: CashWalletMigrationStatus + }) => Promise +} + +export type CashWalletCutoverStatusReport = { + config: CashWalletCutoverConfig + countsByStatus: Partial> +} + +export const startPrimaryCashWalletCutover = async ({ + cutoverVersion, + runId, + actor, + now = new Date(), + migrationsRepo = CashWalletCutoverRepository(), +}: { + cutoverVersion: number + runId: string + actor: string + now?: Date + migrationsRepo?: CashWalletCutoverLifecycleRepository +}): Promise => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) return config + + if (config.state === "complete") { + return new InvalidCashWalletCutoverStateTransitionError( + "Cash wallet cutover is already complete", + ) + } + + if (config.state === "in_progress") { + if (config.runId === runId && config.cutoverVersion === cutoverVersion) return config + return new CashWalletCutoverInProgressError( + "Cash wallet cutover is already in progress", + ) + } + + return migrationsRepo.updateConfig( + { + state: "in_progress", + cutoverVersion, + runId, + startedAt: now, + scheduledAt: undefined, + pausedAt: undefined, + pauseReason: undefined, + }, + actor, + ) +} + +export const completePrimaryCashWalletCutover = async ({ + cutoverVersion, + runId, + actor, + now = new Date(), + migrationsRepo = CashWalletCutoverRepository(), +}: { + cutoverVersion: number + runId: string + actor: string + now?: Date + migrationsRepo?: CashWalletCutoverLifecycleRepository +}): Promise => { + const failedCount = await migrationsRepo.countByStatus({ + cutoverVersion, + runId, + status: "failed", + }) + if (failedCount instanceof Error) return failedCount + if (failedCount > 0) return new CashWalletMigrationFailedError() + + const reviewCount = await migrationsRepo.countByStatus({ + cutoverVersion, + runId, + status: "requires_operator_review", + }) + if (reviewCount instanceof Error) return reviewCount + if (reviewCount > 0) return new CashWalletMigrationFailedError() + + const runnable = await migrationsRepo.listRunnableMigrations({ + cutoverVersion, + runId, + limit: 1, + }) + if (runnable instanceof Error) return runnable + if (runnable.length > 0) return new CashWalletCutoverInProgressError() + + return migrationsRepo.updateConfig( + { + state: "complete", + cutoverVersion, + runId, + completedAt: now, + }, + actor, + ) +} + +export const getPrimaryCashWalletCutoverStatus = async ({ + cutoverVersion, + runId, + migrationsRepo = CashWalletCutoverRepository(), +}: { + cutoverVersion: number + runId: string + migrationsRepo?: CashWalletCutoverLifecycleRepository +}): Promise => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) return config + + const countsByStatus: Partial> = {} + + for (const status of migrationStatuses) { + const count = await migrationsRepo.countByStatus({ cutoverVersion, runId, status }) + if (count instanceof Error) return count + if (count > 0) countsByStatus[status] = count + } + + return { config, countsByStatus } +} diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 9c0d8100c..4cf3864ff 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -816,6 +816,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = error.message return new ValidationInternalError({ message, logger: baseLogger }) + case "InvalidCashWalletCutoverStateTransitionError": + message = error.message + return new ValidationInternalError({ message, logger: baseLogger }) + case "UnknownCaptchaError": message = `Unknown error occurred (code: ${error.name}${ error.message ? ": " + error.message : "" 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, + }, + }) + }) +}) From 4ab0b6e5c9339dc9f436b90d80edaea81d4a9f9f Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:17:28 -0400 Subject: [PATCH 27/40] fix(cutover): align migration indexes with run ids --- src/services/mongoose/cash-wallet-cutover.ts | 27 ++++++++++++----- src/services/mongoose/schema.ts | 1 - .../mongoose/cash-wallet-cutover.spec.ts | 30 +++++++++++++++---- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts index 768e06e6a..a2a6b8d1d 100644 --- a/src/services/mongoose/cash-wallet-cutover.ts +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -49,7 +49,9 @@ const defaultConfig = (): CashWalletCutoverConfig => ({ updatedAt: new Date(0), }) -const resultToConfig = (record: CashWalletCutoverConfigRecord): CashWalletCutoverConfig => ({ +const resultToConfig = ( + record: CashWalletCutoverConfigRecord, +): CashWalletCutoverConfig => ({ state: record.state, scheduledAt: record.scheduledAt, startedAt: record.startedAt, @@ -154,7 +156,11 @@ export const CashWalletCutoverRepository = () => { runId: string }): Promise => { try { - const result = await CashWalletMigration.findOne({ accountId, cutoverVersion, runId }) + const result = await CashWalletMigration.findOne({ + accountId, + cutoverVersion, + runId, + }) if (!result) return null return resultToMigration(result) } catch (err) { @@ -176,7 +182,8 @@ export const CashWalletCutoverRepository = () => { { $set: { ...patch, status: to, updatedAt: new Date() } }, { new: true }, ) - if (!result) return new CouldNotUpdateError("Could not transition cash wallet migration") + if (!result) + return new CouldNotUpdateError("Could not transition cash wallet migration") return resultToMigration(result) } catch (err) { return parseRepositoryError(err) @@ -201,7 +208,8 @@ export const CashWalletCutoverRepository = () => { { $set: { lockedAt: new Date(), lockedBy: workerId, updatedAt: new Date() } }, { new: true }, ) - if (!result) return new CouldNotUpdateError("Could not acquire cash wallet migration lock") + if (!result) + return new CouldNotUpdateError("Could not acquire cash wallet migration lock") return resultToMigration(result) } catch (err) { return parseRepositoryError(err) @@ -213,14 +221,17 @@ export const CashWalletCutoverRepository = () => { workerId, cutoverVersion, runId, - }: Omit): Promise => { + }: Omit): Promise< + CashWalletMigration | RepositoryError + > => { try { const result = await CashWalletMigration.findOneAndUpdate( { _id: id, lockedBy: workerId, cutoverVersion, runId }, { $set: { lockedAt: null, lockedBy: null, updatedAt: new Date() } }, { new: true }, ) - if (!result) return new CouldNotUpdateError("Could not release cash wallet migration lock") + if (!result) + return new CouldNotUpdateError("Could not release cash wallet migration lock") return resultToMigration(result) } catch (err) { return parseRepositoryError(err) @@ -242,7 +253,9 @@ export const CashWalletCutoverRepository = () => { runId, status: { $nin: TERMINAL_STATUSES }, }) - return results.slice(0, limit).map(resultToMigration) + .sort({ updatedAt: 1 }) + .limit(limit ?? 0) + return results.map(resultToMigration) } catch (err) { return parseRepositoryError(err) } diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index ab476041b..18391f8e5 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -670,7 +670,6 @@ const CashWalletMigrationSchema = new Schema({ updatedAt: { type: Date, default: Date.now, index: true }, }) -CashWalletMigrationSchema.index({ accountId: 1, cutoverVersion: 1 }, { unique: true }) CashWalletMigrationSchema.index({ accountId: 1, runId: 1 }, { unique: true }) CashWalletMigrationSchema.index({ idempotencyKey: 1 }, { unique: true }) CashWalletMigrationSchema.index({ cutoverVersion: 1, status: 1, updatedAt: 1 }) diff --git a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts index ad6e00d19..e036d25bb 100644 --- a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts +++ b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts @@ -61,7 +61,11 @@ describe("CashWalletCutoverRepository", () => { }), { upsert: true, new: true }, ) - expect(result).toMatchObject({ state: "in_progress", cutoverVersion: 2, runId: "run-2" }) + expect(result).toMatchObject({ + state: "in_progress", + cutoverVersion: 2, + runId: "run-2", + }) }) it("creates one migration record per account id and run", async () => { @@ -151,24 +155,40 @@ describe("CashWalletCutoverRepository", () => { runId: "run-2", $or: [{ lockedAt: null }, { lockedAt: { $lt: staleBefore } }], }, - expect.objectContaining({ $set: expect.objectContaining({ lockedBy: "worker-1" }) }), + 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 () => { - jest.mocked(CashWalletMigration.find).mockResolvedValue([] as never) + 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 }) + 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"]) }, + status: { + $nin: expect.arrayContaining([ + "complete", + "failed", + "requires_operator_review", + ]), + }, }), ) + expect(sort).toHaveBeenCalledWith({ updatedAt: 1 }) + expect(limit).toHaveBeenCalledWith(10) expect(result).toEqual([]) }) }) From dcb39d9adf9762f39ac66c052273476057ec2671 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:20:27 -0400 Subject: [PATCH 28/40] feat(cutover): add operator command script --- src/scripts/cash-wallet-cutover.ts | 119 +++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/scripts/cash-wallet-cutover.ts diff --git a/src/scripts/cash-wallet-cutover.ts b/src/scripts/cash-wallet-cutover.ts new file mode 100644 index 000000000..ee2c70681 --- /dev/null +++ b/src/scripts/cash-wallet-cutover.ts @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import yargs from "yargs" +import { hideBin } from "yargs/helpers" + +import { CashWalletCutover } from "@app" +import { setupMongoConnection } from "@services/mongodb" +import { + AccountsRepository, + CashWalletCutoverRepository, + WalletsRepository, +} from "@services/mongoose" +import { baseLogger } from "@services/logger" + +const args = yargs(hideBin(process.argv)) + .command("prepare", "discover accounts and upsert migration records") + .command("start", "mark a prepared cutover run in progress") + .command("run-batch", "run one locked migration worker batch") + .command("status", "print cutover config and migration counts") + .command("complete", "mark cutover complete after all migrations finish") + .demandCommand(1) + .option("cutover-version", { type: "number", demandOption: true }) + .option("run-id", { type: "string", demandOption: true }) + .option("operator", { type: "string", default: "unknown" }) + .option("worker-id", { type: "string", default: `worker-${process.pid}` }) + .option("limit", { type: "number", default: 25 }) + .option("lock-stale-seconds", { type: "number", default: 300 }) + .option("configPath", { type: "string", demandOption: true }) + .parseSync() + +const repository = CashWalletCutoverRepository() + +const toJson = (result: unknown) => { + console.log(JSON.stringify(result, null, 2)) +} + +const run = async () => { + const command = args._[0] + const cutoverVersion = args["cutover-version"] + const runId = args["run-id"] + + switch (command) { + case "prepare": { + const result = await CashWalletCutover.preparePrimaryCashWalletCutover({ + cutoverVersion, + runId, + accountsRepo: AccountsRepository(), + walletsRepo: WalletsRepository(), + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "start": { + const result = await CashWalletCutover.startPrimaryCashWalletCutover({ + cutoverVersion, + runId, + actor: args.operator, + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "run-batch": { + const result = await CashWalletCutover.runPrimaryCashWalletCutoverBatch({ + cutoverVersion, + runId, + workerId: args["worker-id"], + limit: args.limit, + lockStaleBefore: new Date(Date.now() - args["lock-stale-seconds"] * 1000), + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "status": { + const result = await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ + cutoverVersion, + runId, + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + case "complete": { + const result = await CashWalletCutover.completePrimaryCashWalletCutover({ + cutoverVersion, + runId, + actor: args.operator, + migrationsRepo: repository, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + + default: + throw new Error(`Unsupported cash wallet cutover command: ${command}`) + } +} + +setupMongoConnection() + .then(async (mongoose) => { + await run() + await mongoose?.connection.close() + process.exit(0) + }) + .catch((error) => { + baseLogger.error({ error }, "Cash wallet cutover operator command failed") + process.exit(1) + }) From 0d234cbfc7c983eae9c88bbae751799352267ffe Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:22:09 -0400 Subject: [PATCH 29/40] chore(cutover): format migration state helpers --- src/app/cash-wallet-cutover/guard.ts | 5 ++++- src/app/cash-wallet-cutover/state-machine.ts | 4 +++- .../migration-state-machine.spec.ts | 12 +++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/cash-wallet-cutover/guard.ts b/src/app/cash-wallet-cutover/guard.ts index d3fb154b6..e37f61369 100644 --- a/src/app/cash-wallet-cutover/guard.ts +++ b/src/app/cash-wallet-cutover/guard.ts @@ -31,7 +31,10 @@ export const evaluateCashWalletCutoverGuard = ({ if (cutover.state === "complete") return { route: "eth_usdt" } if (!migration || migration.status === "not_started") return { route: "legacy_usd" } - if (migration.status === "complete" || migration.status === "skipped_already_migrated") { + if ( + migration.status === "complete" || + migration.status === "skipped_already_migrated" + ) { return { route: "eth_usdt" } } if (migration.status === "failed" || migration.status === "requires_operator_review") { diff --git a/src/app/cash-wallet-cutover/state-machine.ts b/src/app/cash-wallet-cutover/state-machine.ts index 66898b3f0..3206d4bb5 100644 --- a/src/app/cash-wallet-cutover/state-machine.ts +++ b/src/app/cash-wallet-cutover/state-machine.ts @@ -1,6 +1,8 @@ import { InvalidCashWalletMigrationTransitionError } from "./errors" -const transitions: Partial> = { +const transitions: Partial< + Record +> = { not_started: ["started"], started: ["provisioned", "failed"], provisioned: ["balance_read", "failed", "skipped_already_migrated"], 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 index bc199dadd..2a6e9d17c 100644 --- 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 @@ -28,18 +28,24 @@ describe("cash wallet cutover migration state machine", () => { }) it("rejects pointer flip before fee reimbursement", () => { - expect(assertCanTransition("balance_move_verified", "pointer_flipped")).toBeInstanceOf(Error) + expect( + assertCanTransition("balance_move_verified", "pointer_flipped"), + ).toBeInstanceOf(Error) }) 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") + 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) + expect(assertCanTransition("requires_operator_review", "started")).toBeInstanceOf( + Error, + ) }) }) From 93e72d1c6b137f678155021ac952993e4ecab71d Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 12:50:32 -0400 Subject: [PATCH 30/40] feat(cutover): preview dry-run migration plan --- src/app/cash-wallet-cutover/index.ts | 1 + src/app/cash-wallet-cutover/preview.ts | 48 +++++++++++ src/scripts/cash-wallet-cutover.ts | 11 +++ .../app/cash-wallet-cutover/preview.spec.ts | 81 +++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/app/cash-wallet-cutover/preview.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/preview.spec.ts diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index e652966cf..357dabbd0 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -14,3 +14,4 @@ export * from "./handlers" export * from "./runtime-services" export * from "./orchestrator" export * from "./lifecycle" +export * from "./preview" diff --git a/src/app/cash-wallet-cutover/preview.ts b/src/app/cash-wallet-cutover/preview.ts new file mode 100644 index 000000000..42ac910e6 --- /dev/null +++ b/src/app/cash-wallet-cutover/preview.ts @@ -0,0 +1,48 @@ +import { AccountsRepository, WalletsRepository } from "@services/mongoose" + +import { discoverCashWalletCutoverAccounts } from "./discovery" +import { buildCashWalletCutoverPreflightReport } from "./preflight" +import { buildPrimaryCashWalletMigrationPlan } from "./planner" + +export const previewPrimaryCashWalletCutover = async ({ + cutoverVersion, + runId, + accountsRepo = AccountsRepository(), + walletsRepo = WalletsRepository(), +}: { + cutoverVersion: number + runId: string + accountsRepo?: Pick + walletsRepo?: Pick +}): Promise< + | { + report: CashWalletCutoverPreflightReport + plannedMigrations: PrimaryCashWalletMigrationPlan[] + } + | RepositoryError +> => { + const discoveries = await discoverCashWalletCutoverAccounts({ + accountsRepo, + walletsRepo, + }) + if (discoveries instanceof Error) return discoveries + + const report = buildCashWalletCutoverPreflightReport({ + cutoverVersion, + runId, + discoveries, + }) + + if (!report.canStart) { + return { report, plannedMigrations: [] } + } + + return { + report, + plannedMigrations: buildPrimaryCashWalletMigrationPlan({ + cutoverVersion, + runId, + discoveries, + }), + } +} diff --git a/src/scripts/cash-wallet-cutover.ts b/src/scripts/cash-wallet-cutover.ts index ee2c70681..3bd9a173a 100644 --- a/src/scripts/cash-wallet-cutover.ts +++ b/src/scripts/cash-wallet-cutover.ts @@ -13,6 +13,7 @@ import { import { baseLogger } from "@services/logger" const args = yargs(hideBin(process.argv)) + .command("preview", "discover accounts and print the migration plan without writes") .command("prepare", "discover accounts and upsert migration records") .command("start", "mark a prepared cutover run in progress") .command("run-batch", "run one locked migration worker batch") @@ -40,6 +41,16 @@ const run = async () => { const runId = args["run-id"] switch (command) { + case "preview": { + const result = await CashWalletCutover.previewPrimaryCashWalletCutover({ + cutoverVersion, + runId, + }) + if (result instanceof Error) throw result + toJson(result) + return + } + case "prepare": { const result = await CashWalletCutover.preparePrimaryCashWalletCutover({ cutoverVersion, 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", + }), + ], + }) + }) +}) From d30b61d013cae36cec075b2f8d1bf8e68d43aba8 Mon Sep 17 00:00:00 2001 From: forge0x Date: Wed, 20 May 2026 14:47:59 -0400 Subject: [PATCH 31/40] fix(cutover): satisfy production build types --- src/app/cash-wallet-cutover/migration-records.ts | 2 ++ src/app/cash-wallet-cutover/planner.ts | 2 ++ src/app/cash-wallet-cutover/preflight.ts | 2 ++ src/app/cash-wallet-cutover/prepare.ts | 10 ++++++++-- src/app/cash-wallet-cutover/preview.ts | 10 ++++++++-- src/app/cash-wallet-cutover/runtime-services.ts | 9 +++++++-- src/app/cash-wallet-cutover/worker.ts | 2 +- 7 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/app/cash-wallet-cutover/migration-records.ts b/src/app/cash-wallet-cutover/migration-records.ts index 7e0dd8a56..9ac33b8d0 100644 --- a/src/app/cash-wallet-cutover/migration-records.ts +++ b/src/app/cash-wallet-cutover/migration-records.ts @@ -1,3 +1,5 @@ +import { PrimaryCashWalletMigrationPlan } from "./planner" + type CashWalletMigrationRecordsRepository = { upsertMigration( args: PrimaryCashWalletMigrationPlan, diff --git a/src/app/cash-wallet-cutover/planner.ts b/src/app/cash-wallet-cutover/planner.ts index 772928061..0a1e2031c 100644 --- a/src/app/cash-wallet-cutover/planner.ts +++ b/src/app/cash-wallet-cutover/planner.ts @@ -1,3 +1,5 @@ +import { CashWalletCutoverDiscovery } from "./discovery" + type PrimaryCashWalletMigrationPlan = { accountId: AccountId accountUuid?: AccountUuid diff --git a/src/app/cash-wallet-cutover/preflight.ts b/src/app/cash-wallet-cutover/preflight.ts index 058679f6b..df05bfddf 100644 --- a/src/app/cash-wallet-cutover/preflight.ts +++ b/src/app/cash-wallet-cutover/preflight.ts @@ -1,3 +1,5 @@ +import { CashWalletCutoverDiscovery } from "./discovery" + type CashWalletCutoverPreflightBlocker = { accountId: AccountId reason: "missing_legacy_usd" | "missing_destination_usdt" diff --git a/src/app/cash-wallet-cutover/prepare.ts b/src/app/cash-wallet-cutover/prepare.ts index d32a62976..35793b208 100644 --- a/src/app/cash-wallet-cutover/prepare.ts +++ b/src/app/cash-wallet-cutover/prepare.ts @@ -1,6 +1,12 @@ -import { buildCashWalletCutoverPreflightReport } from "./preflight" +import { + buildCashWalletCutoverPreflightReport, + CashWalletCutoverPreflightReport, +} from "./preflight" import { discoverCashWalletCutoverAccounts } from "./discovery" -import { buildPrimaryCashWalletMigrationPlan } from "./planner" +import { + buildPrimaryCashWalletMigrationPlan, + PrimaryCashWalletMigrationPlan, +} from "./planner" import { upsertPrimaryCashWalletMigrationRecords, CashWalletMigrationRecordsRepository, diff --git a/src/app/cash-wallet-cutover/preview.ts b/src/app/cash-wallet-cutover/preview.ts index 42ac910e6..2cca59bc9 100644 --- a/src/app/cash-wallet-cutover/preview.ts +++ b/src/app/cash-wallet-cutover/preview.ts @@ -1,8 +1,14 @@ import { AccountsRepository, WalletsRepository } from "@services/mongoose" import { discoverCashWalletCutoverAccounts } from "./discovery" -import { buildCashWalletCutoverPreflightReport } from "./preflight" -import { buildPrimaryCashWalletMigrationPlan } from "./planner" +import { + buildCashWalletCutoverPreflightReport, + CashWalletCutoverPreflightReport, +} from "./preflight" +import { + buildPrimaryCashWalletMigrationPlan, + PrimaryCashWalletMigrationPlan, +} from "./planner" export const previewPrimaryCashWalletCutover = async ({ cutoverVersion, diff --git a/src/app/cash-wallet-cutover/runtime-services.ts b/src/app/cash-wallet-cutover/runtime-services.ts index 920a29476..b9e1be5d2 100644 --- a/src/app/cash-wallet-cutover/runtime-services.ts +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -35,6 +35,9 @@ const feeAmountUsdCentsFromNumber = ( return Math.ceil(feeAmount * 100).toString() } +const numericFee = (value: unknown): number | undefined => + typeof value === "number" ? value : undefined + export const createCashWalletMigrationRuntimeServices = ( deps: RuntimeServiceDependencies = {}, ) => { @@ -146,11 +149,13 @@ export const createCashWalletMigrationRuntimeServices = ( } const transaction = await getTransactionDetails( - migration.balanceMovePaymentTransactionId, + migration.balanceMovePaymentTransactionId as IbexTransactionId, ) if (transaction instanceof Error) return transaction - return feeAmountUsdCentsFromNumber(transaction.networkFee ?? transaction.fee) + return feeAmountUsdCentsFromNumber( + numericFee(transaction.networkFee) ?? numericFee(transaction.fee), + ) }, }, pointerService: { diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index efccc719a..8e3111ec5 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -222,7 +222,7 @@ export const verifyCashWalletMigrationBalanceMove = async ({ destinationUsdtWalletId: migration.destinationUsdtWalletId, sourceBalanceUsdCents: migration.sourceBalanceUsdCents, destinationAmountUsdtMicros: migration.destinationAmountUsdtMicros, - transactionId: migration.balanceMovePaymentTransactionId, + transactionId: migration.balanceMovePaymentTransactionId as IbexTransactionId, }) if (verified instanceof Error) return verified From 768c55ef6a382f2cfaa413b81d95b34642ba70a1 Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 25 May 2026 11:13:32 -0400 Subject: [PATCH 32/40] feat(cutover): add operator controls and verification --- dev/apollo-federation/supergraph.graphql | 24 + .../01-query-admin-state.bru | 49 ++ .../02-set-scheduled-pre.bru | 62 +++ .../03-set-in-progress.bru | 61 +++ .../cash-wallet-cutover/04-set-complete.bru | 61 +++ .../admin/cash-wallet-cutover/folder.bru | 5 + .../notoken/queries/cash-wallet-cutover.bru | 45 ++ operator-runs/eng-345-manual-347/findings.md | 6 + .../funding-retry-results.json | 97 ++++ .../eng-345-manual-347/manifest.json | 78 +++ .../eng-345-manual-347/prep-348-results.json | 344 +++++++++++++ operator-runs/eng-345-manual-347/progress.md | 45 ++ operator-runs/eng-345-manual-347/results.json | 95 ++++ operator-runs/eng-345-manual-347/run.ts | 477 ++++++++++++++++++ operator-runs/eng-345-manual-347/task_plan.md | 70 +++ .../verify-348-prep-results.json | 74 +++ .../cash-wallet-cutover/amount-conversion.ts | 30 ++ src/app/cash-wallet-cutover/handlers.ts | 56 +- src/app/cash-wallet-cutover/index.types.d.ts | 1 + src/app/cash-wallet-cutover/runner.ts | 34 ++ .../cash-wallet-cutover/runtime-services.ts | 110 +++- src/app/cash-wallet-cutover/state-machine.ts | 1 + src/app/cash-wallet-cutover/worker.ts | 64 ++- src/graphql/admin/mutations.ts | 5 +- src/graphql/admin/queries.ts | 2 + .../mutation/cash-wallet-cutover-update.ts | 61 +++ src/graphql/admin/schema.graphql | 34 ++ .../types/payload/cash-wallet-cutover.ts | 13 + src/graphql/public/queries.ts | 2 + src/graphql/public/schema.graphql | 20 + .../shared/root/query/cash-wallet-cutover.ts | 14 + .../types/object/cash-wallet-cutover.ts | 21 + .../types/scalar/cash-wallet-cutover-state.ts | 12 + src/services/mongoose/cash-wallet-cutover.ts | 38 ++ src/services/mongoose/schema.ts | 1 + src/services/mongoose/schema.types.d.ts | 1 + .../amount-conversion.spec.ts | 27 + .../app/cash-wallet-cutover/handlers.spec.ts | 141 +++++- .../migration-state-machine.spec.ts | 4 + .../cash-wallet-cutover/orchestrator.spec.ts | 12 +- .../app/cash-wallet-cutover/runner.spec.ts | 11 +- .../runtime-services.spec.ts | 82 ++- .../app/cash-wallet-cutover/worker.spec.ts | 105 +++- .../unit/graphql/cash-wallet-cutover.spec.ts | 81 +++ .../mongoose/cash-wallet-cutover.spec.ts | 48 ++ 45 files changed, 2536 insertions(+), 88 deletions(-) create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru create mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru create mode 100644 dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru create mode 100644 operator-runs/eng-345-manual-347/findings.md create mode 100644 operator-runs/eng-345-manual-347/funding-retry-results.json create mode 100644 operator-runs/eng-345-manual-347/manifest.json create mode 100644 operator-runs/eng-345-manual-347/prep-348-results.json create mode 100644 operator-runs/eng-345-manual-347/progress.md create mode 100644 operator-runs/eng-345-manual-347/results.json create mode 100644 operator-runs/eng-345-manual-347/run.ts create mode 100644 operator-runs/eng-345-manual-347/task_plan.md create mode 100644 operator-runs/eng-345-manual-347/verify-348-prep-results.json create mode 100644 src/graphql/admin/root/mutation/cash-wallet-cutover-update.ts create mode 100644 src/graphql/admin/types/payload/cash-wallet-cutover.ts create mode 100644 src/graphql/shared/root/query/cash-wallet-cutover.ts create mode 100644 src/graphql/shared/types/object/cash-wallet-cutover.ts create mode 100644 src/graphql/shared/types/scalar/cash-wallet-cutover-state.ts create mode 100644 test/flash/unit/graphql/cash-wallet-cutover.spec.ts diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index 865c5fd87..baeee5a52 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -501,6 +501,29 @@ type CashoutOffer walletId: WalletId! } +type CashWalletCutover + @join__type(graph: PUBLIC) +{ + completedAt: Timestamp + cutoverVersion: Int! + pauseReason: String + pausedAt: Timestamp + runId: String + scheduledAt: Timestamp + startedAt: Timestamp + state: CashWalletCutoverState! + updatedAt: Timestamp! + updatedBy: String +} + +enum CashWalletCutoverState + @join__type(graph: PUBLIC) +{ + COMPLETE @join__enumValue(graph: PUBLIC) + IN_PROGRESS @join__enumValue(graph: PUBLIC) + PRE @join__enumValue(graph: PUBLIC) +} + """(Positive) Cent amount (1/100 of a dollar)""" scalar CentAmount @join__type(graph: PUBLIC) @@ -1651,6 +1674,7 @@ type Query btcPrice(currency: DisplayCurrency! = "USD"): Price @deprecated(reason: "Deprecated in favor of realtimePrice") btcPriceList(range: PriceGraphRange!): [PricePoint] businessMapMarkers: [MapMarker!]! + cashWalletCutover: CashWalletCutover! currencyList: [Currency!]! globals: Globals isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru new file mode 100644 index 000000000..dfdf9e130 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru @@ -0,0 +1,49 @@ +meta { + name: 01-query-admin-state + type: graphql + seq: 1 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + query CashWalletCutoverAdminState { + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pausedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } +} + +body:graphql:vars { + {} +} + +script:post-response { + test("admin cutover state returns without GraphQL errors", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutover.state).to.be.oneOf([ + "PRE", + "IN_PROGRESS", + "COMPLETE", + ]) + }) +} + diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru new file mode 100644 index 000000000..2240d8493 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru @@ -0,0 +1,62 @@ +meta { + name: 02-set-scheduled-pre + type: graphql + seq: 2 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation CashWalletCutoverSetScheduledPre($input: CashWalletCutoverUpdateInput!) { + cashWalletCutoverUpdate(input: $input) { + errors { + message + } + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "state": "PRE", + "scheduledAt": "2026-05-22T15:00:00.000Z", + "cutoverVersion": 345, + "runId": "manual-eng-345", + "pauseReason": "manual ENG-345 preflight" + } + } +} + +script:post-response { + test("sets cutover config to PRE with schedule metadata", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) + + const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover + expect(config.state).to.eql("PRE") + expect(config.cutoverVersion).to.eql(345) + expect(config.runId).to.eql("manual-eng-345") + }) +} + diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru new file mode 100644 index 000000000..222d93e80 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru @@ -0,0 +1,61 @@ +meta { + name: 03-set-in-progress + type: graphql + seq: 3 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation CashWalletCutoverSetInProgress($input: CashWalletCutoverUpdateInput!) { + cashWalletCutoverUpdate(input: $input) { + errors { + message + } + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "state": "IN_PROGRESS", + "cutoverVersion": 345, + "runId": "manual-eng-345", + "pauseReason": "manual ENG-345 in-progress test" + } + } +} + +script:post-response { + test("sets cutover config to IN_PROGRESS", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) + + const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover + expect(config.state).to.eql("IN_PROGRESS") + expect(config.cutoverVersion).to.eql(345) + expect(config.runId).to.eql("manual-eng-345") + }) +} + diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru new file mode 100644 index 000000000..cb9d8ee55 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru @@ -0,0 +1,61 @@ +meta { + name: 04-set-complete + type: graphql + seq: 4 +} + +post { + url: {{admin_url}} + body: graphql + auth: bearer +} + +auth:bearer { + token: {{admin_token}} +} + +body:graphql { + mutation CashWalletCutoverSetComplete($input: CashWalletCutoverUpdateInput!) { + cashWalletCutoverUpdate(input: $input) { + errors { + message + } + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "state": "COMPLETE", + "cutoverVersion": 345, + "runId": "manual-eng-345", + "pauseReason": "manual ENG-345 complete test" + } + } +} + +script:post-response { + test("sets cutover config to COMPLETE", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) + + const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover + expect(config.state).to.eql("COMPLETE") + expect(config.cutoverVersion).to.eql(345) + expect(config.runId).to.eql("manual-eng-345") + }) +} + diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru new file mode 100644 index 000000000..88076d4b9 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru @@ -0,0 +1,5 @@ +meta { + name: cash-wallet-cutover + seq: 10 +} + diff --git a/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru new file mode 100644 index 000000000..79a2c7427 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru @@ -0,0 +1,45 @@ +meta { + name: cash-wallet-cutover + type: graphql + seq: 11 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +body:graphql { + query CashWalletCutoverPublicState { + cashWalletCutover { + state + scheduledAt + startedAt + completedAt + pausedAt + pauseReason + cutoverVersion + runId + updatedBy + updatedAt + } + } +} + +body:graphql:vars { + {} +} + +script:post-response { + test("public cutover state returns without GraphQL errors", function () { + const jsonData = res.getBody() + expect(jsonData.errors).to.be.undefined + expect(jsonData.data.cashWalletCutover.state).to.be.oneOf([ + "PRE", + "IN_PROGRESS", + "COMPLETE", + ]) + }) +} + diff --git a/operator-runs/eng-345-manual-347/findings.md b/operator-runs/eng-345-manual-347/findings.md new file mode 100644 index 000000000..79191ab38 --- /dev/null +++ b/operator-runs/eng-345-manual-347/findings.md @@ -0,0 +1,6 @@ +# Findings + +- `preparePrimaryCashWalletCutover` accepts injected repositories, so the test can scope discovery to the 10 created accounts by passing an `accountsRepo.listUnlockedAccounts()` generator for only those IDs. +- Account creation creates both USD and USDT checking wallets and defaults new accounts to USDT. For this test, each new account must be explicitly updated back to the USD wallet with `Accounts.updateDefaultWalletId`. +- Funding can use `Payments.intraledgerPaymentSendWalletIdForUsdWallet` from the funder account's USD wallet to each target legacy USD wallet. The amount argument is cents, so `$0.25` is `25` and `$0.01` is `1`. +- Cutover batch execution can use `CashWalletCutover.runPrimaryCashWalletCutoverBatch`; status and lifecycle can use the existing app lifecycle functions. diff --git a/operator-runs/eng-345-manual-347/funding-retry-results.json b/operator-runs/eng-345-manual-347/funding-retry-results.json new file mode 100644 index 000000000..0f9591d76 --- /dev/null +++ b/operator-runs/eng-345-manual-347/funding-retry-results.json @@ -0,0 +1,97 @@ +{ + "targetCents": 25, + "funderUsdWalletId": "37a3bce4-1930-484d-bbfc-279c1f8bfb66", + "results": [ + { + "index": 1, + "accountId": "6a11ada7e55310755eeb0257", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 2, + "accountId": "6a11ada8e55310755eeb0271", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 3, + "accountId": "6a11ada9e55310755eeb028b", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 4, + "accountId": "6a11ada9e55310755eeb02a5", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 5, + "accountId": "6a11adabe55310755eeb02bf", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 6, + "accountId": "6a11adace55310755eeb02d9", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + }, + { + "index": 7, + "accountId": "6a11adade55310755eeb02f3", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "beforeCents": 0, + "targetCents": 25, + "deltaCents": 25, + "status": { + "value": "success" + }, + "error": null, + "afterCents": 25 + } + ] +} diff --git a/operator-runs/eng-345-manual-347/manifest.json b/operator-runs/eng-345-manual-347/manifest.json new file mode 100644 index 000000000..e1af02bce --- /dev/null +++ b/operator-runs/eng-345-manual-347/manifest.json @@ -0,0 +1,78 @@ +{ + "cutoverVersion": 347, + "runId": "manual-eng-347", + "createdAt": "2026-05-23T13:37:44.318Z", + "funderUsdWalletId": "37a3bce4-1930-484d-bbfc-279c1f8bfb66", + "accounts": [ + { + "index": 1, + "phone": "+16509-recovered-01", + "kratosUserId": "03cc58c6-a325-4966-9cf0-a96a58a8b366", + "accountId": "6a11ada7e55310755eeb0257", + "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "startingFundingCents": 25 + }, + { + "index": 2, + "phone": "+16509-recovered-02", + "kratosUserId": "4b7a6cea-7024-47d2-a4ba-f9df7d97aa7b", + "accountId": "6a11ada8e55310755eeb0271", + "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "startingFundingCents": 25 + }, + { + "index": 3, + "phone": "+16509-recovered-03", + "kratosUserId": "cb7eee02-7cb5-41a3-a4a5-1210624cb309", + "accountId": "6a11ada9e55310755eeb028b", + "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "startingFundingCents": 25 + }, + { + "index": 4, + "phone": "+16509-recovered-04", + "kratosUserId": "76fe0bc7-1a9c-440b-9586-317781c153f4", + "accountId": "6a11ada9e55310755eeb02a5", + "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "startingFundingCents": 25 + }, + { + "index": 5, + "phone": "+16509-recovered-05", + "kratosUserId": "4736f580-cd98-4750-ab16-1da95e986dc9", + "accountId": "6a11adabe55310755eeb02bf", + "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "startingFundingCents": 25 + }, + { + "index": 6, + "phone": "+16509-recovered-06", + "kratosUserId": "c5cab6ac-a56e-431b-a460-118d3627f7d3", + "accountId": "6a11adace55310755eeb02d9", + "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "startingFundingCents": 25 + }, + { + "index": 7, + "phone": "+16509-recovered-07", + "kratosUserId": "c1df1aa1-18c6-4ec9-97a6-9dfc5736be7f", + "accountId": "6a11adade55310755eeb02f3", + "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "startingFundingCents": 25 + } + ] +} diff --git a/operator-runs/eng-345-manual-347/prep-348-results.json b/operator-runs/eng-345-manual-347/prep-348-results.json new file mode 100644 index 000000000..951b9cb85 --- /dev/null +++ b/operator-runs/eng-345-manual-347/prep-348-results.json @@ -0,0 +1,344 @@ +{ + "cutoverVersion": 348, + "runId": "manual-eng-348", + "flips": [ + { + "index": 1, + "accountId": "6a11ada7e55310755eeb0257", + "defaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 2, + "accountId": "6a11ada8e55310755eeb0271", + "defaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 3, + "accountId": "6a11ada9e55310755eeb028b", + "defaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 4, + "accountId": "6a11ada9e55310755eeb02a5", + "defaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 5, + "accountId": "6a11adabe55310755eeb02bf", + "defaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 6, + "accountId": "6a11adace55310755eeb02d9", + "defaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + }, + { + "index": 7, + "accountId": "6a11adade55310755eeb02f3", + "defaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25" + } + ], + "configReset": { + "acknowledged": true, + "modifiedCount": 1, + "upsertedId": null, + "upsertedCount": 0, + "matchedCount": 1 + }, + "preview": { + "report": { + "cutoverVersion": 348, + "runId": "manual-eng-348", + "totalAccounts": 7, + "migrationCandidates": 7, + "alreadyUsdt": 0, + "residualLegacyUsd": 0, + "blockers": 0, + "blockerAccounts": [], + "canStart": true + }, + "plannedMigrations": [ + { + "accountId": "6a11ada7e55310755eeb0257", + "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257" + }, + { + "accountId": "6a11ada8e55310755eeb0271", + "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271" + }, + { + "accountId": "6a11ada9e55310755eeb028b", + "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b" + }, + { + "accountId": "6a11ada9e55310755eeb02a5", + "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5" + }, + { + "accountId": "6a11adabe55310755eeb02bf", + "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf" + }, + { + "accountId": "6a11adace55310755eeb02d9", + "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9" + }, + { + "accountId": "6a11adade55310755eeb02f3", + "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3" + } + ] + }, + "prepared": { + "report": { + "cutoverVersion": 348, + "runId": "manual-eng-348", + "totalAccounts": 7, + "migrationCandidates": 7, + "alreadyUsdt": 0, + "residualLegacyUsd": 0, + "blockers": 0, + "blockerAccounts": [], + "canStart": true + }, + "plannedMigrations": [ + { + "accountId": "6a11ada7e55310755eeb0257", + "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257" + }, + { + "accountId": "6a11ada8e55310755eeb0271", + "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271" + }, + { + "accountId": "6a11ada9e55310755eeb028b", + "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b" + }, + { + "accountId": "6a11ada9e55310755eeb02a5", + "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5" + }, + { + "accountId": "6a11adabe55310755eeb02bf", + "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf" + }, + { + "accountId": "6a11adace55310755eeb02d9", + "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9" + }, + { + "accountId": "6a11adade55310755eeb02f3", + "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3" + } + ], + "migrations": [ + { + "id": "24353ef3-66ba-4acc-9365-1ad6e83301f0", + "accountId": "6a11ada7e55310755eeb0257", + "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.370Z" + }, + { + "id": "d47e3b77-39d0-49ea-a5eb-e0dbbf5a306c", + "accountId": "6a11ada8e55310755eeb0271", + "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.382Z" + }, + { + "id": "b5b42c93-519a-4103-b7fb-a1ff5a3dd17d", + "accountId": "6a11ada9e55310755eeb028b", + "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.393Z" + }, + { + "id": "1eafc20f-3e53-4d72-ba25-48cd8d5c5c24", + "accountId": "6a11ada9e55310755eeb02a5", + "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.401Z" + }, + { + "id": "78fdd4b6-9c81-4883-a0e0-0bab20491c17", + "accountId": "6a11adabe55310755eeb02bf", + "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.406Z" + }, + { + "id": "9cf2b55b-d118-4b2a-a322-9c857e2dee8d", + "accountId": "6a11adace55310755eeb02d9", + "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.414Z" + }, + { + "id": "20e0769a-6c20-4475-bb0a-9eda26698668", + "accountId": "6a11adade55310755eeb02f3", + "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": "not_started", + "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3", + "attempts": 0, + "updatedAt": "2026-05-23T14:49:36.424Z" + } + ] + }, + "status": { + "config": { + "state": "pre", + "updatedBy": "manual-local", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "updatedAt": "2026-05-23T14:49:36.246Z" + }, + "countsByStatus": { + "not_started": 7 + } + } +} diff --git a/operator-runs/eng-345-manual-347/progress.md b/operator-runs/eng-345-manual-347/progress.md new file mode 100644 index 000000000..7e433b28a --- /dev/null +++ b/operator-runs/eng-345-manual-347/progress.md @@ -0,0 +1,45 @@ +- 2026-05-23T13:37:13.813Z ERROR Cannot read properties of undefined (reading 'findOne') +- 2026-05-23T13:37:44.318Z created account 1: 6a11ada7e55310755eeb0257 USD=0a4d1c55-d8ec-4685-8457-216d41569d61 USDT=8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6 funding=25 +- 2026-05-23T13:37:45.186Z created account 2: 6a11ada8e55310755eeb0271 USD=08ee5f13-b4c3-4ff5-835a-55dd786d3887 USDT=741a97b0-9612-4f34-af32-ed28c0a3b5fd funding=25 +- 2026-05-23T13:37:45.866Z created account 3: 6a11ada9e55310755eeb028b USD=697b8583-6e1d-47d5-aba0-8b2c8aa6bc32 USDT=144db453-fdb8-4180-b405-097104637644 funding=25 +- 2026-05-23T13:37:47.835Z created account 4: 6a11ada9e55310755eeb02a5 USD=dbdfba0c-ead0-4c94-96f8-e15130b7f796 USDT=ae3bddb2-1bb7-4019-8390-39694881c91b funding=25 +- 2026-05-23T13:37:48.713Z created account 5: 6a11adabe55310755eeb02bf USD=9756a443-d30d-4b1c-a797-933ec2ced1d0 USDT=fa7c7780-9246-4088-beb6-56fcd98e3209 funding=25 +- 2026-05-23T13:37:49.430Z created account 6: 6a11adace55310755eeb02d9 USD=ecaf103c-d024-419a-8bad-b0c61e8068be USDT=53884f7a-0a55-4406-8b60-3c6d68a6c180 funding=25 +- 2026-05-23T13:37:50.263Z created account 7: 6a11adade55310755eeb02f3 USD=8a3e1bd4-4d2d-4f97-b201-d58d3723a53c USDT=80f0de1f-b4e6-4904-8c3f-d90bad763ce2 funding=25 +- 2026-05-23T13:37:50.789Z ERROR +- 2026-05-23T13:39:59.481Z ERROR +- 2026-05-23T13:41:36.710Z ERROR +- 2026-05-23T13:42:04.155Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json +- 2026-05-23T13:42:26.832Z ERROR +- 2026-05-23T13:44:28.884Z ERROR +- 2026-05-23T13:50:13.799Z preview planned=7 +- 2026-05-23T13:50:27.921Z prepared migrations=7 +- 2026-05-23T13:50:44.208Z reset singleton cutover config to pre for manual-eng-347 +- 2026-05-23T13:50:48.388Z started state=in_progress +- 2026-05-23T13:51:03.235Z batch 1: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:03.298Z batch 2: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:06.709Z batch 3: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:06.840Z batch 4: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:08.144Z batch 5: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:08.238Z batch 6: {"attempted":7,"advanced":7,"failed":0,"skipped":0} +- 2026-05-23T13:51:08.268Z all migrations complete after batch 6 +- 2026-05-23T13:51:25.286Z completed lifecycle state=complete +- 2026-05-23T13:51:31.895Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json +- 2026-05-23T14:05:39.454Z funding retry account 1: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:42.219Z funding retry account 2: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:44.498Z funding retry account 3: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:46.786Z funding retry account 4: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:49.479Z funding retry account 5: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:50.986Z funding retry account 6: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:05:52.277Z funding retry account 7: before=0 delta=25 status=[object Object] after=25 +- 2026-05-23T14:06:31.393Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json +- 2026-05-23T14:49:35.240Z reset account 1 defaultWalletId=0a4d1c55-d8ec-4685-8457-216d41569d61 fundedUsdCents=25 +- 2026-05-23T14:49:35.433Z reset account 2 defaultWalletId=08ee5f13-b4c3-4ff5-835a-55dd786d3887 fundedUsdCents=25 +- 2026-05-23T14:49:35.582Z reset account 3 defaultWalletId=697b8583-6e1d-47d5-aba0-8b2c8aa6bc32 fundedUsdCents=25 +- 2026-05-23T14:49:35.729Z reset account 4 defaultWalletId=dbdfba0c-ead0-4c94-96f8-e15130b7f796 fundedUsdCents=25 +- 2026-05-23T14:49:35.950Z reset account 5 defaultWalletId=9756a443-d30d-4b1c-a797-933ec2ced1d0 fundedUsdCents=25 +- 2026-05-23T14:49:36.092Z reset account 6 defaultWalletId=ecaf103c-d024-419a-8bad-b0c61e8068be fundedUsdCents=25 +- 2026-05-23T14:49:36.245Z reset account 7 defaultWalletId=8a3e1bd4-4d2d-4f97-b201-d58d3723a53c fundedUsdCents=25 +- 2026-05-23T14:49:36.256Z reset singleton cutover config to pre for manual-eng-348 +- 2026-05-23T14:49:36.323Z preview manual-eng-348 planned=7 +- 2026-05-23T14:49:36.431Z prepared manual-eng-348 migrations=7 diff --git a/operator-runs/eng-345-manual-347/results.json b/operator-runs/eng-345-manual-347/results.json new file mode 100644 index 000000000..57d76e89c --- /dev/null +++ b/operator-runs/eng-345-manual-347/results.json @@ -0,0 +1,95 @@ +{ + "status": { + "config": { + "state": "complete", + "startedAt": "2026-05-23T13:50:48.355Z", + "completedAt": "2026-05-23T13:51:25.254Z", + "updatedBy": "manual-local", + "cutoverVersion": 347, + "runId": "manual-eng-347", + "updatedAt": "2026-05-23T13:51:25.281Z" + }, + "countsByStatus": { + "complete": 7 + } + }, + "accounts": [ + { + "index": 1, + "accountId": "6a11ada7e55310755eeb0257", + "expectedFundingCents": 25, + "defaultWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 2, + "accountId": "6a11ada8e55310755eeb0271", + "expectedFundingCents": 25, + "defaultWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 3, + "accountId": "6a11ada9e55310755eeb028b", + "expectedFundingCents": 25, + "defaultWalletId": "144db453-fdb8-4180-b405-097104637644", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 4, + "accountId": "6a11ada9e55310755eeb02a5", + "expectedFundingCents": 25, + "defaultWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 5, + "accountId": "6a11adabe55310755eeb02bf", + "expectedFundingCents": 25, + "defaultWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 6, + "accountId": "6a11adace55310755eeb02d9", + "expectedFundingCents": 25, + "defaultWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 7, + "accountId": "6a11adade55310755eeb02f3", + "expectedFundingCents": 25, + "defaultWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "defaultIsDestinationUsdt": true, + "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + } + ] +} diff --git a/operator-runs/eng-345-manual-347/run.ts b/operator-runs/eng-345-manual-347/run.ts new file mode 100644 index 000000000..3438416d3 --- /dev/null +++ b/operator-runs/eng-345-manual-347/run.ts @@ -0,0 +1,477 @@ +import fs from "fs/promises" +import path from "path" +import { randomUUID } from "crypto" + +import { getDefaultAccountsConfig } from "@config" + +import { Accounts, CashWalletCutover, Payments, Wallets } from "@app" +import { getBalanceForWallet } from "@app/wallets" +import { WalletCurrency } from "@domain/shared" +import { PaymentSendStatus } from "@domain/bitcoin/lightning" +import { WalletType } from "@domain/wallets" + +import { setupMongoConnection } from "@services/mongodb" +import { + AccountsRepository, + CashWalletCutoverRepository, + WalletsRepository, +} from "@services/mongoose" +import { Account, CashWalletCutoverConfig } from "@services/mongoose/schema" + +type TargetAccount = { + index: number + phone: string + kratosUserId: string + accountId: string + accountUuid?: string + legacyUsdWalletId: string + destinationUsdtWalletId: string + startingFundingCents: number +} + +type Manifest = { + cutoverVersion: number + runId: string + createdAt: string + funderUsdWalletId?: string + accounts: TargetAccount[] +} + +const CUTOVER_VERSION = 347 +const RUN_ID = "manual-eng-347" +const ACCOUNT_COUNT = 10 +const OUTPUT_DIR = path.resolve("operator-runs/eng-345-manual-347") +const MANIFEST_PATH = path.join(OUTPUT_DIR, "manifest.json") +const RESULTS_PATH = path.join(OUTPUT_DIR, "results.json") +const PROGRESS_PATH = path.join(OUTPUT_DIR, "progress.md") + +const logProgress = async (line: string) => { + await fs.appendFile(PROGRESS_PATH, `- ${new Date().toISOString()} ${line}\n`) +} + +const throwIfError = (result: T | Error): T => { + if (result instanceof Error) throw result + return result +} + +const loadManifest = async (): Promise => { + const raw = await fs.readFile(MANIFEST_PATH, "utf8") + return JSON.parse(raw) as Manifest +} + +const saveManifest = async (manifest: Manifest) => { + await fs.writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`) +} + +const listWalletsForAccount = async (accountId: string) => { + const wallets = throwIfError( + await WalletsRepository().listByAccountId(accountId as AccountId), + ) + const usd = wallets.find((wallet) => wallet.currency === WalletCurrency.Usd) + const usdt = wallets.find((wallet) => wallet.currency === WalletCurrency.Usdt) + + if (!usd) throw new Error(`Missing USD wallet for account ${accountId}`) + if (!usdt) throw new Error(`Missing USDT wallet for account ${accountId}`) + + return { usd, usdt } +} + +const funderUsdWalletId = async (): Promise => { + const funder = await Account.findOne({ role: "funder" }) + if (!funder) throw new Error("Missing funder account") + + const wallets = throwIfError( + await WalletsRepository().listByAccountId(funder._id.toString() as AccountId), + ) + const usdWallet = wallets.find((wallet) => wallet.currency === WalletCurrency.Usd) + if (!usdWallet) throw new Error("Missing funder USD wallet") + + return usdWallet.id +} + +const createAccounts = async () => { + await fs.mkdir(OUTPUT_DIR, { recursive: true }) + + const config = getDefaultAccountsConfig() + const existing = await fs + .readFile(MANIFEST_PATH, "utf8") + .then((raw) => JSON.parse(raw) as Manifest) + .catch(() => undefined) + const funderWalletId = existing?.funderUsdWalletId ?? (await funderUsdWalletId()) + const accounts: TargetAccount[] = existing?.accounts ?? [] + const stamp = Date.now().toString().slice(-7) + + for (let i = accounts.length + 1; i <= ACCOUNT_COUNT; i += 1) { + const suffix = `${stamp}${String(i).padStart(2, "0")}` + const phone = `+16509${suffix}` as PhoneNumber + const kratosUserId = randomUUID() as UserId + + const account = throwIfError( + await Accounts.createAccountWithPhoneIdentifier({ + newAccountInfo: { kratosUserId, phone }, + config, + }), + ) + + const { usd, usdt } = await listWalletsForAccount(account.id) + + throwIfError( + await Accounts.updateDefaultWalletId({ + accountId: account.id, + walletId: usd.id, + }), + ) + + const fundingCents = i <= 8 ? 25 : i === 9 ? 1 : 0 + + accounts.push({ + index: i, + phone, + kratosUserId, + accountId: account.id, + accountUuid: account.uuid, + legacyUsdWalletId: usd.id, + destinationUsdtWalletId: usdt.id, + startingFundingCents: fundingCents, + }) + + await logProgress( + `created account ${i}: ${account.id} USD=${usd.id} USDT=${usdt.id} funding=${fundingCents}`, + ) + + await saveManifest({ + cutoverVersion: CUTOVER_VERSION, + runId: RUN_ID, + createdAt: existing?.createdAt ?? new Date().toISOString(), + funderUsdWalletId: funderWalletId, + accounts, + }) + } + + const manifest: Manifest = { + cutoverVersion: CUTOVER_VERSION, + runId: RUN_ID, + createdAt: existing?.createdAt ?? new Date().toISOString(), + funderUsdWalletId: funderWalletId, + accounts, + } + await saveManifest(manifest) + console.log(JSON.stringify(manifest, null, 2)) +} + +const completePartialAccounts = async () => { + const manifest = await loadManifest() + const existingIds = new Set(manifest.accounts.map((account) => account.accountId)) + const partials = await Account.find({ + role: "user", + defaultWalletId: { $exists: false }, + created_at: { $gte: new Date(Date.now() - 60 * 60 * 1000) }, + }).sort({ created_at: 1 }) + + for (const partial of partials) { + if (manifest.accounts.length >= ACCOUNT_COUNT) break + const accountId = partial._id.toString() + if (existingIds.has(accountId)) continue + + const usd = throwIfError( + await WalletsRepository().persistNew({ + accountId: accountId as AccountId, + type: WalletType.Checking, + currency: WalletCurrency.Usd, + }), + ) + const usdt = throwIfError( + await WalletsRepository().persistNew({ + accountId: accountId as AccountId, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }), + ) + throwIfError( + await Accounts.updateDefaultWalletId({ + accountId: accountId as AccountId, + walletId: usd.id, + }), + ) + + const index = manifest.accounts.length + 1 + const fundingCents = index <= 8 ? 25 : index === 9 ? 1 : 0 + manifest.accounts.push({ + index, + phone: `partial-${index}`, + kratosUserId: partial.kratosUserId, + accountId, + accountUuid: partial.id, + legacyUsdWalletId: usd.id, + destinationUsdtWalletId: usdt.id, + startingFundingCents: fundingCents, + }) + existingIds.add(accountId) + await saveManifest(manifest) + await logProgress( + `completed partial account ${index}: ${accountId} USD=${usd.id} USDT=${usdt.id} funding=${fundingCents}`, + ) + } + + console.log(JSON.stringify(manifest, null, 2)) +} + +const fundAccounts = async () => { + const manifest = await loadManifest() + if (!manifest.funderUsdWalletId) { + manifest.funderUsdWalletId = await funderUsdWalletId() + await saveManifest(manifest) + } + + for (const account of manifest.accounts) { + if (account.startingFundingCents === 0) { + await logProgress(`left account ${account.index} unfunded`) + continue + } + + const status = throwIfError( + await Payments.intraledgerPaymentSendWalletIdForUsdWallet({ + senderWalletId: manifest.funderUsdWalletId, + recipientWalletId: account.legacyUsdWalletId, + amount: account.startingFundingCents, + memo: `ENG-345 ${RUN_ID} seed ${account.index}`, + }), + ) + + if (status !== PaymentSendStatus.Success && status !== PaymentSendStatus.Pending) { + throw new Error(`Funding account ${account.index} returned ${status}`) + } + + await logProgress( + `funded account ${account.index} ${account.legacyUsdWalletId} with ${account.startingFundingCents} cents status=${status}`, + ) + } +} + +const scopedAccountsRepo = (targetIds: Set) => ({ + async *listUnlockedAccounts() { + for (const accountId of targetIds) { + yield throwIfError(await AccountsRepository().findById(accountId as AccountId)) + } + }, +}) + +const preview = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.previewPrimaryCashWalletCutover({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + accountsRepo: scopedAccountsRepo(new Set(manifest.accounts.map((a) => a.accountId))), + walletsRepo: WalletsRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`preview planned=${result.plannedMigrations.length}`) +} + +const prepare = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.preparePrimaryCashWalletCutover({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + accountsRepo: scopedAccountsRepo(new Set(manifest.accounts.map((a) => a.accountId))), + walletsRepo: WalletsRepository(), + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`prepared migrations=${result.migrations.length}`) +} + +const start = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.startPrimaryCashWalletCutover({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + actor: "manual-local", + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`started state=${result.state}`) +} + +const runBatches = async () => { + const manifest = await loadManifest() + const batches = [] + + for (let i = 1; i <= 40; i += 1) { + const result = throwIfError( + await CashWalletCutover.runPrimaryCashWalletCutoverBatch({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + workerId: "manual-local", + limit: ACCOUNT_COUNT, + lockStaleBefore: new Date(Date.now() - 300_000), + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + batches.push(result) + await logProgress(`batch ${i}: ${JSON.stringify(result)}`) + + const status = throwIfError( + await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + + if (result.failed > 0) { + console.log(JSON.stringify({ batches, status }, null, 2)) + throw new Error(`Batch ${i} failed`) + } + + if (Object.keys(status.countsByStatus).length === 1 && status.countsByStatus.complete) { + console.log(JSON.stringify({ batches, status }, null, 2)) + await logProgress(`all migrations complete after batch ${i}`) + return + } + + if (result.attempted === 0) { + console.log(JSON.stringify({ batches, status }, null, 2)) + throw new Error("No runnable migrations, but run is not complete") + } + } + + throw new Error("Exceeded maximum batch count") +} + +const complete = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.completePrimaryCashWalletCutover({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + actor: "manual-local", + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`completed lifecycle state=${result.state}`) +} + +const status = async () => { + const manifest = await loadManifest() + const result = throwIfError( + await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + console.log(JSON.stringify(result, null, 2)) +} + +const resetConfig = async () => { + const manifest = await loadManifest() + const result = await CashWalletCutoverConfig.updateOne( + { _id: "cash_wallet_cutover" }, + { + $set: { + state: "pre", + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + updatedBy: "manual-local", + updatedAt: new Date(), + }, + $unset: { + scheduledAt: "", + startedAt: "", + completedAt: "", + pausedAt: "", + pauseReason: "", + }, + }, + { upsert: true }, + ) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`reset singleton cutover config to pre for ${manifest.runId}`) +} + +const verify = async () => { + const manifest = await loadManifest() + const rows = [] + + for (const target of manifest.accounts) { + const account = throwIfError( + await AccountsRepository().findById(target.accountId as AccountId), + ) + const usdBalance = throwIfError( + await getBalanceForWallet({ + walletId: target.legacyUsdWalletId as WalletId, + currency: WalletCurrency.Usd, + }), + ) + const usdtBalance = throwIfError( + await getBalanceForWallet({ + walletId: target.destinationUsdtWalletId as WalletId, + currency: WalletCurrency.Usdt, + }), + ) + + rows.push({ + index: target.index, + accountId: target.accountId, + expectedFundingCents: target.startingFundingCents, + defaultWalletId: account.defaultWalletId, + defaultIsDestinationUsdt: account.defaultWalletId === target.destinationUsdtWalletId, + legacyUsdWalletId: target.legacyUsdWalletId, + destinationUsdtWalletId: target.destinationUsdtWalletId, + legacyUsdBalanceCents: usdBalance.asCents(), + destinationUsdtBalanceMicros: usdtBalance.asSmallestUnits(), + }) + } + + const statusResult = throwIfError( + await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ + cutoverVersion: manifest.cutoverVersion, + runId: manifest.runId, + migrationsRepo: CashWalletCutoverRepository(), + }), + ) + + const result = { status: statusResult, accounts: rows } + await fs.writeFile(RESULTS_PATH, `${JSON.stringify(result, null, 2)}\n`) + console.log(JSON.stringify(result, null, 2)) + await logProgress(`wrote verification results to ${RESULTS_PATH}`) +} + +const commands: Record Promise> = { + "create-accounts": createAccounts, + "complete-partials": completePartialAccounts, + fund: fundAccounts, + preview, + prepare, + start, + "run-batches": runBatches, + complete, + status, + "reset-config": resetConfig, + verify, +} + +setupMongoConnection() + .then(async (mongoose) => { + const command = process.argv.find((arg) => commands[arg]) + if (!command || !commands[command]) { + throw new Error(`Expected command: ${Object.keys(commands).join(", ")}`) + } + + await commands[command]() + await mongoose?.connection.close() + process.exit(0) + }) + .catch(async (error) => { + await logProgress(`ERROR ${error instanceof Error ? error.message : String(error)}`) + console.error(error) + process.exit(1) + }) diff --git a/operator-runs/eng-345-manual-347/task_plan.md b/operator-runs/eng-345-manual-347/task_plan.md new file mode 100644 index 000000000..a7240b9cf --- /dev/null +++ b/operator-runs/eng-345-manual-347/task_plan.md @@ -0,0 +1,70 @@ +# Task Plan: ENG-345 Fresh 7-Account Manual Cutover + +## Goal +Run the full cash-wallet cutover pipeline on the 7 fresh local accounts that were successfully created with legacy USD default wallets. + +## Current Phase +Phase 6 + +## Phases + +### Phase 1: Discovery +- [x] Find existing account creation/funding helpers +- [x] Confirm database and service config for the local run +- [x] Document reusable commands +- **Status:** complete + +### Phase 2: Test Data Setup +- [x] Create 7 new accounts +- [x] Capture account IDs and USD/USDT wallet IDs for 7 complete accounts +- [x] Set 7 complete accounts' `defaultWalletId` to legacy USD wallet ID +- [x] Leave all 7 legacy USD wallets at zero balance +- **Status:** complete + +### Phase 3: Cutover Pipeline +- [x] Preview run +- [x] Prepare run +- [x] Start run +- [x] Run batches until all migrations reach terminal state or failure +- [x] Complete lifecycle if all migrations complete +- **Status:** complete + +### Phase 4: Verification +- [x] Verify migration counts +- [x] Verify account default wallet pointers changed to USDT +- [x] Verify source/destination amounts for zero-balance accounts +- [x] Document any failures and recovery actions +- **Status:** complete + +### Phase 5: Report +- [x] Summarize setup, commands, and final status +- [x] Update session memory +- **Status:** complete + +### Phase 6: Reset and Prep Funded Rerun +- [x] Verify seven funded legacy USD balances +- [x] Reset seven account `defaultWalletId` values to legacy USD wallets +- [x] Create fresh cutover prep run for the same seven accounts +- [x] Verify migration prep count and account pointers +- **Status:** complete + +## Key Parameters +- Worktree: `/Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review` +- Planned cutoverVersion: `347` +- Planned runId: `manual-eng-347` +- Account count: `7` +- Funding: `7 x $0.00` + +## Decisions Made +| Decision | Rationale | +|----------|-----------| +| Use a fresh run/version instead of rewinding manual-eng-346 | manual-eng-346 already moved funds and completed; fresh run gives clean manual-test evidence | +| Convert test to 7 zero-balance accounts | Dread requested changing the plan to a 7-account cutover after IBEX write failures blocked creating/funding 10 accounts | +| Use `cutoverVersion=348`, `runId=manual-eng-348` for the funded rerun prep | Version 347 already completed as the zero-balance cutover, so a new run keeps evidence separated | + +## Errors Encountered +| Error | Attempt | Resolution | +|-------|---------|------------| +| Config loader tried to read `create-accounts` as a YAML file; `Account` model import was undefined | 1 | Patched operator script to locate command anywhere in argv and import `Account` from `@services/mongoose/schema`; rerun with command before `--configPath` | +| IBEX fetch error while creating the 8th account left 7 complete accounts and one partial account without wallets/default | 2 | Reconstructed a manifest for the 7 known-good accounts, excluded the partial account, and patched script to save/resume manifest incrementally | +| IBEX write path continued returning blank `FetchError` after cooldown for partial wallet creation and funding invoice creation | 3 | Stopped rather than running an incomplete/fabricated 10-account cutover; verified reads still work and documented resume state | diff --git a/operator-runs/eng-345-manual-347/verify-348-prep-results.json b/operator-runs/eng-345-manual-347/verify-348-prep-results.json new file mode 100644 index 000000000..cb35272d1 --- /dev/null +++ b/operator-runs/eng-345-manual-347/verify-348-prep-results.json @@ -0,0 +1,74 @@ +{ + "cutoverVersion": 348, + "runId": "manual-eng-348", + "status": { + "config": { + "state": "pre", + "updatedBy": "manual-local", + "cutoverVersion": 348, + "runId": "manual-eng-348", + "updatedAt": "2026-05-23T14:49:36.246Z" + }, + "countsByStatus": { + "not_started": 7 + } + }, + "accounts": [ + { + "index": 1, + "accountId": "6a11ada7e55310755eeb0257", + "defaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 2, + "accountId": "6a11ada8e55310755eeb0271", + "defaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 3, + "accountId": "6a11ada9e55310755eeb028b", + "defaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 4, + "accountId": "6a11ada9e55310755eeb02a5", + "defaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 5, + "accountId": "6a11adabe55310755eeb02bf", + "defaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 6, + "accountId": "6a11adace55310755eeb02d9", + "defaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + }, + { + "index": 7, + "accountId": "6a11adade55310755eeb02f3", + "defaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", + "defaultIsLegacyUsd": true, + "legacyUsdBalanceCents": "25", + "destinationUsdtBalanceMicros": "0" + } + ] +} diff --git a/src/app/cash-wallet-cutover/amount-conversion.ts b/src/app/cash-wallet-cutover/amount-conversion.ts index f3610da41..fd6bd4e09 100644 --- a/src/app/cash-wallet-cutover/amount-conversion.ts +++ b/src/app/cash-wallet-cutover/amount-conversion.ts @@ -22,3 +22,33 @@ export const usdCentsToUsdtMicros = ( } export const feeUsdCentsToUsdtMicros = usdCentsToUsdtMicros + +export const usdtMicrosToUsdCentsCeil = ( + usdtMicros: string, +): string | InvalidCashWalletCutoverAmountError => { + const parsed = parseNonNegativeInteger(usdtMicros) + if (parsed instanceof Error) return parsed + if (parsed === 0n) return "0" + return ((parsed + USDT_MICROS_PER_USD_CENT - 1n) / USDT_MICROS_PER_USD_CENT).toString() +} + +export const destinationShortfallUsdtMicros = ({ + targetUsdtMicros, + startingUsdtMicros, + currentUsdtMicros, +}: { + targetUsdtMicros: string + startingUsdtMicros: string + currentUsdtMicros: string +}): string | InvalidCashWalletCutoverAmountError => { + const target = parseNonNegativeInteger(targetUsdtMicros) + if (target instanceof Error) return target + const starting = parseNonNegativeInteger(startingUsdtMicros) + if (starting instanceof Error) return starting + const current = parseNonNegativeInteger(currentUsdtMicros) + if (current instanceof Error) return current + + const received = current > starting ? current - starting : 0n + if (received >= target) return "0" + return (target - received).toString() +} diff --git a/src/app/cash-wallet-cutover/handlers.ts b/src/app/cash-wallet-cutover/handlers.ts index af563fdd1..20d32ff3f 100644 --- a/src/app/cash-wallet-cutover/handlers.ts +++ b/src/app/cash-wallet-cutover/handlers.ts @@ -9,6 +9,7 @@ import { recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, sendCashWalletMigrationFeeReimbursementPayment, + skipCashWalletMigrationFeeReimbursement, startCashWalletMigration, verifyCashWalletMigrationBalanceMove, verifyCashWalletMigrationLegacyZero, @@ -28,10 +29,14 @@ type CashWalletMigrationHandlerServices = { readSourceBalanceUsdCents( migration: CashWalletMigration, ): Promise + readDestinationBalanceUsdtMicros( + migration: CashWalletMigration, + ): Promise } invoiceService: Parameters< typeof createCashWalletMigrationBalanceMoveInvoice - >[0]["invoiceService"] + >[0]["invoiceService"] & + Parameters[0]["invoiceService"] paymentService: Parameters< typeof sendCashWalletMigrationBalanceMovePayment >[0]["paymentService"] @@ -39,10 +44,13 @@ type CashWalletMigrationHandlerServices = { typeof verifyCashWalletMigrationBalanceMove >[0]["balanceVerifier"] feeService: { - readFeeAmountUsdCents( + readFeeAmountUsdtMicros( migration: CashWalletMigration, ): Promise } + treasuryService: { + getTreasuryWalletId(): Promise + } pointerService: Parameters< typeof flipCashWalletMigrationDefaultPointer >[0]["pointerService"] @@ -74,18 +82,33 @@ export const createCashWalletMigrationStepHandlers = ({ const sourceBalanceUsdCents = await services.balanceReader.readSourceBalanceUsdCents(migration) if (sourceBalanceUsdCents instanceof Error) return sourceBalanceUsdCents + const destinationStartingBalanceUsdtMicros = + await services.balanceReader.readDestinationBalanceUsdtMicros(migration) + if (destinationStartingBalanceUsdtMicros instanceof Error) { + return destinationStartingBalanceUsdtMicros + } return recordCashWalletMigrationBalance({ migration, migrationsRepo, sourceBalanceUsdCents, + destinationStartingBalanceUsdtMicros, }) }, - balance_read: (migration) => - createCashWalletMigrationBalanceMoveInvoice({ + balance_read: (migration) => { + if (migration.destinationAmountUsdtMicros === "0") { + return flipCashWalletMigrationDefaultPointer({ + migration, + migrationsRepo, + pointerService: services.pointerService, + }) + } + + return createCashWalletMigrationBalanceMoveInvoice({ migration, migrationsRepo, invoiceService: services.invoiceService, - }), + }) + }, invoice_created: (migration) => sendCashWalletMigrationBalanceMovePayment({ migration, @@ -101,21 +124,32 @@ export const createCashWalletMigrationStepHandlers = ({ balanceVerifier: services.balanceVerifier, }), balance_move_verified: async (migration) => { - const feeAmountUsdCents = await services.feeService.readFeeAmountUsdCents(migration) - if (feeAmountUsdCents instanceof Error) return feeAmountUsdCents + const feeAmountUsdtMicros = + await services.feeService.readFeeAmountUsdtMicros(migration) + if (feeAmountUsdtMicros instanceof Error) return feeAmountUsdtMicros + if (feeAmountUsdtMicros === "0") { + return skipCashWalletMigrationFeeReimbursement({ + migration, + migrationsRepo, + }) + } return createCashWalletMigrationFeeReimbursementInvoice({ migration, migrationsRepo, invoiceService: services.invoiceService, - feeAmountUsdCents, + feeAmountUsdtMicros, }) }, - fee_reimbursement_invoice_created: (migration) => - sendCashWalletMigrationFeeReimbursementPayment({ + fee_reimbursement_invoice_created: async (migration) => { + const treasuryWalletId = await services.treasuryService.getTreasuryWalletId() + if (treasuryWalletId instanceof Error) return treasuryWalletId + return sendCashWalletMigrationFeeReimbursementPayment({ migration, migrationsRepo, paymentService: services.paymentService, - }), + treasuryWalletId, + }) + }, fee_reimbursement_sending: (migration) => markCashWalletMigrationFeeReimbursed({ migration, migrationsRepo }), fee_reimbursed: (migration) => diff --git a/src/app/cash-wallet-cutover/index.types.d.ts b/src/app/cash-wallet-cutover/index.types.d.ts index fdce0cf9a..3f16650b7 100644 --- a/src/app/cash-wallet-cutover/index.types.d.ts +++ b/src/app/cash-wallet-cutover/index.types.d.ts @@ -46,6 +46,7 @@ type CashWalletMigration = { status: CashWalletMigrationStatus sourceBalanceUsdCents?: string destinationAmountUsdtMicros?: string + destinationStartingBalanceUsdtMicros?: string feeAmountUsdCents?: string feeAmountUsdtMicros?: string balanceMoveInvoicePaymentRequest?: string diff --git a/src/app/cash-wallet-cutover/runner.ts b/src/app/cash-wallet-cutover/runner.ts index a416e09a7..c937496f0 100644 --- a/src/app/cash-wallet-cutover/runner.ts +++ b/src/app/cash-wallet-cutover/runner.ts @@ -17,6 +17,14 @@ type CashWalletMigrationBatchRepository = { cutoverVersion: number runId: string }): Promise + markMigrationFailed(args: { + id: string + workerId: string + cutoverVersion: number + runId: string + error: Error + status: "failed" | "requires_operator_review" + }): Promise } type CashWalletMigrationBatchExecutor = ( @@ -30,6 +38,22 @@ type CashWalletMigrationBatchResult = { skipped: number } +const AMBIGUOUS_SIDE_EFFECT_STATUSES: CashWalletMigrationStatus[] = [ + "invoice_created", + "balance_move_sending", + "balance_move_sent", + "balance_move_verified", + "fee_reimbursement_invoice_created", + "fee_reimbursement_sending", + "fee_reimbursed", + "pointer_flipped", +] + +const failureStatusForMigration = ( + status: CashWalletMigrationStatus, +): "failed" | "requires_operator_review" => + AMBIGUOUS_SIDE_EFFECT_STATUSES.includes(status) ? "requires_operator_review" : "failed" + export const runCashWalletMigrationBatch = async ({ cutoverVersion, runId, @@ -79,6 +103,16 @@ export const runCashWalletMigrationBatch = async ({ const step = await executor(locked) if (step instanceof Error) { result.failed += 1 + const marked = await migrationsRepo.markMigrationFailed({ + id: locked.id, + workerId, + cutoverVersion, + runId, + error: step, + status: failureStatusForMigration(locked.status), + }) + if (marked instanceof Error) return marked + continue } else { result.advanced += 1 } diff --git a/src/app/cash-wallet-cutover/runtime-services.ts b/src/app/cash-wallet-cutover/runtime-services.ts index b9e1be5d2..776149ba5 100644 --- a/src/app/cash-wallet-cutover/runtime-services.ts +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -1,17 +1,25 @@ import { addWalletIfNonexistent, updateDefaultWalletId } from "@app/accounts" -import { addInvoiceForRecipientForUsdWallet, getBalanceForWallet } from "@app/wallets" +import { + addInvoiceForRecipientForUsdWallet, + getBalanceForWallet, +} from "@app/wallets" +import { decodeInvoice } from "@domain/bitcoin/lightning" import { InvalidWalletId } from "@domain/errors" -import { USDAmount, WalletCurrency } from "@domain/shared" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" import { AccountsRepository } from "@services/mongoose" import Ibex from "@services/ibex/client" import { UnexpectedIbexResponse } from "@services/ibex/errors" +import { getFunderWalletId } from "@services/ledger/caching" import { CashWalletMigrationFailedError, InvalidCashWalletCutoverAmountError, InvalidCashWalletMigrationTransitionError, } from "./errors" +import { destinationShortfallUsdtMicros } from "./amount-conversion" + +const CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS = 15 * 60 type RuntimeServiceDependencies = { now?: () => Date @@ -19,24 +27,27 @@ type RuntimeServiceDependencies = { updateDefaultWalletId?: typeof updateDefaultWalletId getBalanceForWallet?: typeof getBalanceForWallet createInvoice?: typeof addInvoiceForRecipientForUsdWallet + createNoAmountInvoice?: typeof Ibex.addInvoice payInvoice?: typeof Ibex.payInvoice - getTransactionDetails?: typeof Ibex.getTransactionDetails accountsRepo?: Pick, "findById"> + getTreasuryWalletId?: () => Promise } const isUsdAmount = (amount: unknown): amount is USDAmount => amount instanceof USDAmount +const isUsdtAmount = (amount: unknown): amount is USDTAmount => + amount instanceof USDTAmount -const feeAmountUsdCentsFromNumber = ( - feeAmount: number | undefined, -): string | InvalidCashWalletCutoverAmountError => { - if (feeAmount === undefined || Number.isNaN(feeAmount) || feeAmount < 0) { - return new InvalidCashWalletCutoverAmountError("Invalid fee amount") - } - return Math.ceil(feeAmount * 100).toString() -} +const ibexInvoiceToDomainInvoice = (response: Awaited>) => { + if (response instanceof Error) return response + + const invoiceString = response.invoice?.bolt11 + if (!invoiceString) return new UnexpectedIbexResponse("Could not find invoice.") -const numericFee = (value: unknown): number | undefined => - typeof value === "number" ? value : undefined + const decodedInvoice = decodeInvoice(invoiceString) + if (decodedInvoice instanceof Error) return decodedInvoice + + return decodedInvoice +} export const createCashWalletMigrationRuntimeServices = ( deps: RuntimeServiceDependencies = {}, @@ -45,8 +56,8 @@ export const createCashWalletMigrationRuntimeServices = ( const updateDefaultWallet = deps.updateDefaultWalletId ?? updateDefaultWalletId const balanceForWallet = deps.getBalanceForWallet ?? getBalanceForWallet const invoiceForRecipient = deps.createInvoice ?? addInvoiceForRecipientForUsdWallet + const noAmountInvoiceForRecipient = deps.createNoAmountInvoice ?? Ibex.addInvoice const payInvoice = deps.payInvoice ?? Ibex.payInvoice - const getTransactionDetails = deps.getTransactionDetails ?? Ibex.getTransactionDetails const accountsRepo = deps.accountsRepo ?? AccountsRepository() return { @@ -83,6 +94,19 @@ export const createCashWalletMigrationRuntimeServices = ( } return balance.asCents() }, + readDestinationBalanceUsdtMicros: async ( + migration: CashWalletMigration, + ): Promise => { + const balance = await balanceForWallet({ + walletId: migration.destinationUsdtWalletId, + currency: WalletCurrency.Usdt, + }) + if (balance instanceof Error) return balance + if (!isUsdtAmount(balance)) { + return new InvalidCashWalletCutoverAmountError("Expected USDT balance") + } + return balance.asSmallestUnits() + }, }, invoiceService: { createInvoice: ({ @@ -99,18 +123,39 @@ export const createCashWalletMigrationRuntimeServices = ( amount: amount as FractionalCentAmount, memo, }), + createNoAmountInvoice: ({ + recipientWalletId, + memo, + }: { + recipientWalletId: WalletId + memo: string + }) => + noAmountInvoiceForRecipient({ + accountId: recipientWalletId, + memo, + expiration: CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS as Seconds, + }).then(ibexInvoiceToDomainInvoice), }, paymentService: { payInvoice: async ({ senderWalletId, paymentRequest, + senderAmountUsdCents, }: { senderWalletId: WalletId paymentRequest: string + senderAmountUsdCents?: string }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> => { + const send = + senderAmountUsdCents === undefined + ? undefined + : USDAmount.cents(senderAmountUsdCents) + if (send instanceof Error) return send + const payment = await payInvoice({ accountId: senderWalletId as IbexAccountId, invoice: paymentRequest as Bolt11, + send, }) if (payment instanceof Error) return payment @@ -139,7 +184,7 @@ export const createCashWalletMigrationRuntimeServices = ( }, }, feeService: { - readFeeAmountUsdCents: async ( + readFeeAmountUsdtMicros: async ( migration: CashWalletMigration, ): Promise => { if (migration.balanceMovePaymentTransactionId === undefined) { @@ -148,16 +193,37 @@ export const createCashWalletMigrationRuntimeServices = ( ) } - const transaction = await getTransactionDetails( - migration.balanceMovePaymentTransactionId as IbexTransactionId, - ) - if (transaction instanceof Error) return transaction + if (migration.destinationAmountUsdtMicros === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "destinationAmountUsdtMicros is required before reading fee amount", + ) + } + + if (migration.destinationStartingBalanceUsdtMicros === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "destinationStartingBalanceUsdtMicros is required before reading fee amount", + ) + } - return feeAmountUsdCentsFromNumber( - numericFee(transaction.networkFee) ?? numericFee(transaction.fee), - ) + const currentBalance = await balanceForWallet({ + walletId: migration.destinationUsdtWalletId, + currency: WalletCurrency.Usdt, + }) + if (currentBalance instanceof Error) return currentBalance + if (!isUsdtAmount(currentBalance)) { + return new InvalidCashWalletCutoverAmountError("Expected USDT balance") + } + + return destinationShortfallUsdtMicros({ + targetUsdtMicros: migration.destinationAmountUsdtMicros, + startingUsdtMicros: migration.destinationStartingBalanceUsdtMicros, + currentUsdtMicros: currentBalance.asSmallestUnits(), + }) }, }, + treasuryService: { + getTreasuryWalletId: deps.getTreasuryWalletId ?? getFunderWalletId, + }, pointerService: { flipDefaultWallet: async ({ accountId, diff --git a/src/app/cash-wallet-cutover/state-machine.ts b/src/app/cash-wallet-cutover/state-machine.ts index 3206d4bb5..6bd152b8d 100644 --- a/src/app/cash-wallet-cutover/state-machine.ts +++ b/src/app/cash-wallet-cutover/state-machine.ts @@ -12,6 +12,7 @@ const transitions: Partial< balance_move_sent: ["balance_move_verified", "failed", "requires_operator_review"], balance_move_verified: [ "fee_reimbursement_invoice_created", + "fee_reimbursed", "failed", "requires_operator_review", ], diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 8e3111ec5..67b26250f 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,5 +1,5 @@ import { assertCanTransition } from "./state-machine" -import { feeUsdCentsToUsdtMicros, usdCentsToUsdtMicros } from "./amount-conversion" +import { usdCentsToUsdtMicros, usdtMicrosToUsdCentsCeil } from "./amount-conversion" import { InvalidCashWalletCutoverAmountError, InvalidCashWalletMigrationTransitionError, @@ -24,10 +24,18 @@ type CashWalletMigrationInvoiceService = { }): Promise } +type CashWalletMigrationNoAmountInvoiceService = { + createNoAmountInvoice(args: { + recipientWalletId: WalletId + memo: string + }): Promise +} + type CashWalletMigrationPaymentService = { payInvoice(args: { senderWalletId: WalletId paymentRequest: string + senderAmountUsdCents?: string }): Promise<{ transactionId: IbexTransactionId } | ApplicationError> } @@ -114,10 +122,12 @@ export const recordCashWalletMigrationBalance = async ({ migration, migrationsRepo, sourceBalanceUsdCents, + destinationStartingBalanceUsdtMicros, }: { migration: CashWalletMigration migrationsRepo: CashWalletMigrationTransitionRepository sourceBalanceUsdCents: string + destinationStartingBalanceUsdtMicros: string }): Promise => { const destinationAmountUsdtMicros = usdCentsToUsdtMicros(sourceBalanceUsdCents) if (destinationAmountUsdtMicros instanceof Error) return destinationAmountUsdtMicros @@ -134,6 +144,7 @@ export const recordCashWalletMigrationBalance = async ({ patch: { sourceBalanceUsdCents, destinationAmountUsdtMicros, + destinationStartingBalanceUsdtMicros, }, }) } @@ -156,9 +167,16 @@ export const sendCashWalletMigrationBalanceMovePayment = async ({ ) } + if (migration.sourceBalanceUsdCents === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "sourceBalanceUsdCents is required before balance move payment sending", + ) + } + const payment = await paymentService.payInvoice({ senderWalletId: migration.legacyUsdWalletId, paymentRequest: migration.balanceMoveInvoicePaymentRequest, + senderAmountUsdCents: migration.sourceBalanceUsdCents, }) if (payment instanceof Error) return payment @@ -241,7 +259,7 @@ export const createCashWalletMigrationBalanceMoveInvoice = async ({ migrationsRepo, }: { migration: CashWalletMigration - invoiceService: CashWalletMigrationInvoiceService + invoiceService: CashWalletMigrationNoAmountInvoiceService migrationsRepo: CashWalletMigrationTransitionRepository }): Promise => { const transition = assertCanTransition(migration.status, "invoice_created") @@ -253,9 +271,8 @@ export const createCashWalletMigrationBalanceMoveInvoice = async ({ ) } - const invoice = await invoiceService.createInvoice({ + const invoice = await invoiceService.createNoAmountInvoice({ recipientWalletId: migration.destinationUsdtWalletId, - amount: migration.destinationAmountUsdtMicros, memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:balance-move`, }) if (invoice instanceof Error) return invoice @@ -277,15 +294,15 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ migration, invoiceService, migrationsRepo, - feeAmountUsdCents, + feeAmountUsdtMicros, }: { migration: CashWalletMigration invoiceService: CashWalletMigrationInvoiceService migrationsRepo: CashWalletMigrationTransitionRepository - feeAmountUsdCents: string + feeAmountUsdtMicros: string }): Promise => { - const feeAmountUsdtMicros = feeUsdCentsToUsdtMicros(feeAmountUsdCents) - if (feeAmountUsdtMicros instanceof Error) return feeAmountUsdtMicros + const feeAmountUsdCents = usdtMicrosToUsdCentsCeil(feeAmountUsdtMicros) + if (feeAmountUsdCents instanceof Error) return feeAmountUsdCents const transition = assertCanTransition( migration.status, @@ -294,8 +311,8 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ if (transition instanceof Error) return transition const invoice = await invoiceService.createInvoice({ - recipientWalletId: migration.legacyUsdWalletId, - amount: feeAmountUsdCents, + recipientWalletId: migration.destinationUsdtWalletId, + amount: feeAmountUsdtMicros, memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:fee-reimbursement`, }) if (invoice instanceof Error) return invoice @@ -315,12 +332,37 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ }) } +export const skipCashWalletMigrationFeeReimbursement = async ({ + migration, + migrationsRepo, +}: { + migration: CashWalletMigration + migrationsRepo: CashWalletMigrationTransitionRepository +}): Promise => { + const transition = assertCanTransition(migration.status, "fee_reimbursed") + if (transition instanceof Error) return transition + + return migrationsRepo.transitionMigration({ + id: migration.id, + from: migration.status, + to: "fee_reimbursed", + cutoverVersion: migration.cutoverVersion, + runId: migration.runId, + patch: { + feeAmountUsdCents: "0", + feeAmountUsdtMicros: "0", + }, + }) +} + export const sendCashWalletMigrationFeeReimbursementPayment = async ({ migration, + treasuryWalletId, paymentService, migrationsRepo, }: { migration: CashWalletMigration + treasuryWalletId: WalletId paymentService: CashWalletMigrationPaymentService migrationsRepo: CashWalletMigrationTransitionRepository }): Promise => { @@ -334,7 +376,7 @@ export const sendCashWalletMigrationFeeReimbursementPayment = async ({ } const payment = await paymentService.payInvoice({ - senderWalletId: migration.destinationUsdtWalletId, + senderWalletId: treasuryWalletId, paymentRequest: migration.feeReimbursementInvoicePaymentRequest, }) if (payment instanceof Error) return payment diff --git a/src/graphql/admin/mutations.ts b/src/graphql/admin/mutations.ts index 070a0d7c7..fcf3f6ce1 100644 --- a/src/graphql/admin/mutations.ts +++ b/src/graphql/admin/mutations.ts @@ -3,6 +3,7 @@ import { GT } from "@graphql/index" import AccountUpdateLevelMutation from "@graphql/admin/root/mutation/account-update-level" import AccountUpdateStatusMutation from "@graphql/admin/root/mutation/account-update-status" import BusinessUpdateMapInfoMutation from "@graphql/admin/root/mutation/business-update-map-info" +import CashWalletCutoverUpdateMutation from "@graphql/admin/root/mutation/cash-wallet-cutover-update" import UserUpdatePhoneMutation from "./root/mutation/user-update-phone" import BusinessDeleteMapInfoMutation from "./root/mutation/delete-business-map" @@ -13,8 +14,7 @@ import MerchantMapDeleteMutation from "./root/mutation/merchant-map-delete" import MerchantMapValidateMutation from "./root/mutation/merchant-map-validate" export const mutationFields = { - unauthed: { - }, + unauthed: {}, authed: { userUpdatePhone: UserUpdatePhoneMutation, accountUpdateLevel: AccountUpdateLevelMutation, @@ -25,6 +25,7 @@ export const mutationFields = { businessDeleteMapInfo: BusinessDeleteMapInfoMutation, sendNotification: SendNotificationMutation, cashoutNotificationSend: sendCashoutSettledNotification, + cashWalletCutoverUpdate: CashWalletCutoverUpdateMutation, }, } diff --git a/src/graphql/admin/queries.ts b/src/graphql/admin/queries.ts index a069657df..deafdfd9f 100644 --- a/src/graphql/admin/queries.ts +++ b/src/graphql/admin/queries.ts @@ -1,4 +1,5 @@ import { GT } from "@graphql/index" +import CashWalletCutoverQuery from "@graphql/shared/root/query/cash-wallet-cutover" import AllLevelsQuery from "./root/query/all-levels" import LightningInvoiceQuery from "./root/query/lightning-invoice" @@ -36,6 +37,7 @@ export const queryFields = { idDocumentReadUrl: IdDocumentReadUrlQuery, notificationTopics: NotificationTopicsQuery, bridgeReconciliationOrphans: BridgeReconciliationOrphansQuery, + cashWalletCutover: CashWalletCutoverQuery, }, } diff --git a/src/graphql/admin/root/mutation/cash-wallet-cutover-update.ts b/src/graphql/admin/root/mutation/cash-wallet-cutover-update.ts new file mode 100644 index 000000000..ff2df55e8 --- /dev/null +++ b/src/graphql/admin/root/mutation/cash-wallet-cutover-update.ts @@ -0,0 +1,61 @@ +import { GT } from "@graphql/index" +import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import CashWalletCutoverPayload from "@graphql/admin/types/payload/cash-wallet-cutover" +import CashWalletCutoverState from "@graphql/shared/types/scalar/cash-wallet-cutover-state" +import Timestamp from "@graphql/shared/types/scalar/timestamp" +import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" + +const CashWalletCutoverUpdateInput = GT.Input({ + name: "CashWalletCutoverUpdateInput", + fields: () => ({ + state: { type: GT.NonNull(CashWalletCutoverState) }, + scheduledAt: { type: Timestamp }, + cutoverVersion: { type: GT.Int }, + runId: { type: GT.String }, + pauseReason: { type: GT.String }, + }), +}) + +const CashWalletCutoverUpdateMutation = GT.Field< + null, + GraphQLAdminContext, + { + input: { + state: CashWalletCutoverState | Error + scheduledAt?: Date | Error + cutoverVersion?: number + runId?: string + pauseReason?: string + } + } +>({ + type: GT.NonNull(CashWalletCutoverPayload), + args: { + input: { type: GT.NonNull(CashWalletCutoverUpdateInput) }, + }, + resolve: async (_, { input }, ctx) => { + if (input.state instanceof Error) { + return { errors: [{ message: input.state.message }] } + } + if (input.scheduledAt instanceof Error) { + return { errors: [{ message: input.scheduledAt.message }] } + } + + const patch: Partial = { + state: input.state, + scheduledAt: input.scheduledAt, + cutoverVersion: input.cutoverVersion, + runId: input.runId, + pauseReason: input.pauseReason, + } + + const result = await CashWalletCutoverRepository().updateConfig(patch, ctx.user.id) + if (result instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(result)] } + } + + return { errors: [], cashWalletCutover: result } + }, +}) + +export default CashWalletCutoverUpdateMutation diff --git a/src/graphql/admin/schema.graphql b/src/graphql/admin/schema.graphql index 87eed84b2..46da132ac 100644 --- a/src/graphql/admin/schema.graphql +++ b/src/graphql/admin/schema.graphql @@ -136,6 +136,38 @@ input BusinessUpdateMapInfoInput { username: Username! } +type CashWalletCutover { + completedAt: Timestamp + cutoverVersion: Int! + pauseReason: String + pausedAt: Timestamp + runId: String + scheduledAt: Timestamp + startedAt: Timestamp + state: CashWalletCutoverState! + updatedAt: Timestamp! + updatedBy: String +} + +type CashWalletCutoverPayload { + cashWalletCutover: CashWalletCutover + errors: [Error!]! +} + +enum CashWalletCutoverState { + COMPLETE + IN_PROGRESS + PRE +} + +input CashWalletCutoverUpdateInput { + cutoverVersion: Int + pauseReason: String + runId: String + scheduledAt: Timestamp + state: CashWalletCutoverState! +} + input CashoutNotificationSendInput { accountId: String! amount: Int! @@ -278,6 +310,7 @@ type Mutation { accountUpdateStatus(input: AccountUpdateStatusInput!): AccountDetailPayload! businessDeleteMapInfo(input: BusinessDeleteMapInfoInput!): AccountDetailPayload! businessUpdateMapInfo(input: BusinessUpdateMapInfoInput!): AccountDetailPayload! + cashWalletCutoverUpdate(input: CashWalletCutoverUpdateInput!): CashWalletCutoverPayload! cashoutNotificationSend(input: CashoutNotificationSendInput!): SuccessPayload! merchantMapDelete(input: MerchantMapDeleteInput!): MerchantPayload! merchantMapValidate(input: MerchantMapValidateInput!): MerchantPayload! @@ -335,6 +368,7 @@ type Query { accountDetailsByUsername(username: Username!): AuditedAccount! allLevels: [AccountLevel!]! bridgeReconciliationOrphans(limit: Int = 50, orphanType: String = null, status: String = null): [BridgeReconciliationOrphan!]! + cashWalletCutover: CashWalletCutover! idDocumentReadUrl( """Storage key of the ID document file""" fileKey: String! diff --git a/src/graphql/admin/types/payload/cash-wallet-cutover.ts b/src/graphql/admin/types/payload/cash-wallet-cutover.ts new file mode 100644 index 000000000..5f209898c --- /dev/null +++ b/src/graphql/admin/types/payload/cash-wallet-cutover.ts @@ -0,0 +1,13 @@ +import { GT } from "@graphql/index" +import IError from "@graphql/shared/types/abstract/error" +import CashWalletCutoverObject from "@graphql/shared/types/object/cash-wallet-cutover" + +const CashWalletCutoverPayload = GT.Object({ + name: "CashWalletCutoverPayload", + fields: () => ({ + errors: { type: GT.NonNullList(IError) }, + cashWalletCutover: { type: CashWalletCutoverObject }, + }), +}) + +export default CashWalletCutoverPayload diff --git a/src/graphql/public/queries.ts b/src/graphql/public/queries.ts index 8f8e7f46f..7a401d97e 100644 --- a/src/graphql/public/queries.ts +++ b/src/graphql/public/queries.ts @@ -1,4 +1,5 @@ import { GT } from "@graphql/index" +import CashWalletCutoverQuery from "@graphql/shared/root/query/cash-wallet-cutover" import MeQuery from "@graphql/public/root/query/me" import GlobalsQuery from "@graphql/public/root/query/globals" @@ -44,6 +45,7 @@ export const queryFields = { npubByUsername: NpubByUserNameQuery, isFlashNpub: IsFlashNpubQuery, supportedBanks: SupportedBanksQuery, + cashWalletCutover: CashWalletCutoverQuery, }, authed: { atAccountLevel: { diff --git a/src/graphql/public/schema.graphql b/src/graphql/public/schema.graphql index e0bac3237..30c15eada 100644 --- a/src/graphql/public/schema.graphql +++ b/src/graphql/public/schema.graphql @@ -358,6 +358,25 @@ input CaptchaRequestAuthCodeInput { validationCode: String! } +type CashWalletCutover { + completedAt: Timestamp + cutoverVersion: Int! + pauseReason: String + pausedAt: Timestamp + runId: String + scheduledAt: Timestamp + startedAt: Timestamp + state: CashWalletCutoverState! + updatedAt: Timestamp! + updatedBy: String +} + +enum CashWalletCutoverState { + COMPLETE + IN_PROGRESS + PRE +} + type CashoutOffer { """The rate used when withdrawing to a JMD bank account""" exchangeRate: JMDCents @@ -1296,6 +1315,7 @@ type Query { btcPrice(currency: DisplayCurrency! = "USD"): Price @deprecated(reason: "Deprecated in favor of realtimePrice") btcPriceList(range: PriceGraphRange!): [PricePoint] businessMapMarkers: [MapMarker!]! + cashWalletCutover: CashWalletCutover! currencyList: [Currency!]! globals: Globals isFlashNpub(input: IsFlashNpubInput!): IsFlashNpubPayload diff --git a/src/graphql/shared/root/query/cash-wallet-cutover.ts b/src/graphql/shared/root/query/cash-wallet-cutover.ts new file mode 100644 index 000000000..71ca79e82 --- /dev/null +++ b/src/graphql/shared/root/query/cash-wallet-cutover.ts @@ -0,0 +1,14 @@ +import { GT } from "@graphql/index" +import CashWalletCutoverObject from "@graphql/shared/types/object/cash-wallet-cutover" +import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" + +const CashWalletCutoverQuery = GT.Field({ + type: GT.NonNull(CashWalletCutoverObject), + resolve: async () => { + const config = await CashWalletCutoverRepository().getConfig() + if (config instanceof Error) throw config + return config + }, +}) + +export default CashWalletCutoverQuery diff --git a/src/graphql/shared/types/object/cash-wallet-cutover.ts b/src/graphql/shared/types/object/cash-wallet-cutover.ts new file mode 100644 index 000000000..6570e1b69 --- /dev/null +++ b/src/graphql/shared/types/object/cash-wallet-cutover.ts @@ -0,0 +1,21 @@ +import { GT } from "@graphql/index" +import Timestamp from "@graphql/shared/types/scalar/timestamp" +import CashWalletCutoverState from "@graphql/shared/types/scalar/cash-wallet-cutover-state" + +const CashWalletCutoverObject = GT.Object({ + name: "CashWalletCutover", + fields: () => ({ + state: { type: GT.NonNull(CashWalletCutoverState) }, + scheduledAt: { type: Timestamp }, + startedAt: { type: Timestamp }, + completedAt: { type: Timestamp }, + pausedAt: { type: Timestamp }, + pauseReason: { type: GT.String }, + cutoverVersion: { type: GT.NonNull(GT.Int) }, + runId: { type: GT.String }, + updatedBy: { type: GT.String }, + updatedAt: { type: GT.NonNull(Timestamp) }, + }), +}) + +export default CashWalletCutoverObject diff --git a/src/graphql/shared/types/scalar/cash-wallet-cutover-state.ts b/src/graphql/shared/types/scalar/cash-wallet-cutover-state.ts new file mode 100644 index 000000000..831a377f8 --- /dev/null +++ b/src/graphql/shared/types/scalar/cash-wallet-cutover-state.ts @@ -0,0 +1,12 @@ +import { GT } from "@graphql/index" + +const CashWalletCutoverState = GT.Enum({ + name: "CashWalletCutoverState", + values: { + PRE: { value: "pre" }, + IN_PROGRESS: { value: "in_progress" }, + COMPLETE: { value: "complete" }, + }, +}) + +export default CashWalletCutoverState diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts index a2a6b8d1d..7db76ae4b 100644 --- a/src/services/mongoose/cash-wallet-cutover.ts +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -43,6 +43,11 @@ type LockMigrationArgs = { runId: string } +type MarkMigrationFailedArgs = Omit & { + error: Error + status: "failed" | "requires_operator_review" +} + const defaultConfig = (): CashWalletCutoverConfig => ({ state: "pre", cutoverVersion: 1, @@ -76,6 +81,7 @@ const resultToMigration = (record: CashWalletMigrationRecord): CashWalletMigrati status: record.status, sourceBalanceUsdCents: record.sourceBalanceUsdCents, destinationAmountUsdtMicros: record.destinationAmountUsdtMicros, + destinationStartingBalanceUsdtMicros: record.destinationStartingBalanceUsdtMicros, feeAmountUsdCents: record.feeAmountUsdCents, feeAmountUsdtMicros: record.feeAmountUsdtMicros, balanceMoveInvoicePaymentRequest: record.balanceMoveInvoicePaymentRequest, @@ -238,6 +244,37 @@ export const CashWalletCutoverRepository = () => { } } + const markMigrationFailed = async ({ + id, + workerId, + cutoverVersion, + runId, + error, + status, + }: MarkMigrationFailedArgs): Promise => { + try { + const result = await CashWalletMigration.findOneAndUpdate( + { _id: id, lockedBy: workerId, cutoverVersion, runId }, + { + $set: { + status, + lastError: error.message, + lockedAt: null, + lockedBy: null, + updatedAt: new Date(), + }, + $inc: { attempts: 1 }, + }, + { new: true }, + ) + if (!result) + return new CouldNotUpdateError("Could not mark cash wallet migration failed") + return resultToMigration(result) + } catch (err) { + return parseRepositoryError(err) + } + } + const listRunnableMigrations = async ({ cutoverVersion, runId, @@ -285,6 +322,7 @@ export const CashWalletCutoverRepository = () => { transitionMigration, acquireMigrationLock, releaseMigrationLock, + markMigrationFailed, listRunnableMigrations, countByStatus, } diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index 18391f8e5..a15023362 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -651,6 +651,7 @@ const CashWalletMigrationSchema = new Schema({ status: { type: String, required: true, index: true }, sourceBalanceUsdCents: String, destinationAmountUsdtMicros: String, + destinationStartingBalanceUsdtMicros: String, feeAmountUsdCents: String, feeAmountUsdtMicros: String, balanceMoveInvoicePaymentRequest: String, diff --git a/src/services/mongoose/schema.types.d.ts b/src/services/mongoose/schema.types.d.ts index e3e25c34b..332c918f5 100644 --- a/src/services/mongoose/schema.types.d.ts +++ b/src/services/mongoose/schema.types.d.ts @@ -129,6 +129,7 @@ interface CashWalletMigrationRecord { status: CashWalletMigrationStatus sourceBalanceUsdCents?: string destinationAmountUsdtMicros?: string + destinationStartingBalanceUsdtMicros?: string feeAmountUsdCents?: string feeAmountUsdtMicros?: string balanceMoveInvoicePaymentRequest?: string 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 index 3d77174a3..db42bc5f0 100644 --- a/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts @@ -1,6 +1,8 @@ import { + destinationShortfallUsdtMicros, feeUsdCentsToUsdtMicros, usdCentsToUsdtMicros, + usdtMicrosToUsdCentsCeil, } from "@app/cash-wallet-cutover/amount-conversion" describe("cash wallet cutover amount conversion", () => { @@ -15,9 +17,34 @@ describe("cash wallet cutover amount conversion", () => { 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/handlers.spec.ts b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts index f36dd5102..b807cd96f 100644 --- a/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts @@ -25,6 +25,7 @@ describe("cash wallet migration step handlers", () => { }, balanceReader: { readSourceBalanceUsdCents: jest.fn(async () => "1234"), + readDestinationBalanceUsdtMicros: jest.fn(async () => "5000000"), }, invoiceService: { createInvoice: jest.fn( @@ -34,6 +35,13 @@ describe("cash wallet migration step handlers", () => { 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 () => ({ @@ -44,7 +52,10 @@ describe("cash wallet migration step handlers", () => { verifyBalanceMove: jest.fn(async () => true), }, feeService: { - readFeeAmountUsdCents: jest.fn(async () => "7"), + readFeeAmountUsdtMicros: jest.fn(async () => "70000"), + }, + treasuryService: { + getTreasuryWalletId: jest.fn(async () => "treasury-wallet-id" as WalletId), }, pointerService: { flipDefaultWallet: jest.fn(async () => ({ @@ -87,8 +98,134 @@ describe("cash wallet migration step handlers", () => { expect(services.balanceReader.readSourceBalanceUsdCents).toHaveBeenCalledWith( migration("provisioned"), ) - expect(services.feeService.readFeeAmountUsdCents).toHaveBeenCalledWith( + 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/migration-state-machine.spec.ts b/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts index 2a6e9d17c..316fa2443 100644 --- 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 @@ -33,6 +33,10 @@ describe("cash wallet cutover migration state machine", () => { ).toBeInstanceOf(Error) }) + it("allows skipping fee reimbursement when there is no shortfall", () => { + expect(assertCanTransition("balance_move_verified", "fee_reimbursed")).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") diff --git a/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts index 4cfd5e1f6..32811fb58 100644 --- a/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts @@ -4,6 +4,7 @@ jest.mock("@app/accounts", () => ({ })) jest.mock("@app/wallets", () => ({ addInvoiceForRecipientForUsdWallet: jest.fn(), + addInvoiceNoAmountForRecipient: jest.fn(), getBalanceForWallet: jest.fn(), })) jest.mock("@services/mongoose", () => ({ @@ -41,16 +42,21 @@ describe("primary cash wallet cutover orchestrator", () => { 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() }, - invoiceService: { createInvoice: 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: { readFeeAmountUsdCents: jest.fn() }, + feeService: { readFeeAmountUsdtMicros: jest.fn() }, + treasuryService: { getTreasuryWalletId: jest.fn() }, pointerService: { flipDefaultWallet: jest.fn() }, legacyWalletVerifier: { verifyLegacyWalletZero: jest.fn() }, } diff --git a/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts index ab33cc4a2..6987c3873 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts @@ -26,6 +26,7 @@ describe("cash wallet migration batch runner", () => { 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) @@ -67,6 +68,7 @@ describe("cash wallet migration batch runner", () => { 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() @@ -92,6 +94,10 @@ describe("cash wallet migration batch runner", () => { 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) @@ -107,11 +113,14 @@ describe("cash wallet migration batch runner", () => { }) expect(result).toEqual({ attempted: 1, advanced: 0, failed: 1, skipped: 0 }) - expect(migrationsRepo.releaseMigrationLock).toHaveBeenCalledWith({ + 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() }) }) 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 index b89a49e38..1a83083b1 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -1,5 +1,5 @@ import { CouldNotUpdateError } from "@domain/errors" -import { USDAmount, WalletCurrency } from "@domain/shared" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" import { WalletType } from "@domain/wallets" jest.mock("@app/accounts", () => ({ @@ -8,6 +8,7 @@ jest.mock("@app/accounts", () => ({ })) jest.mock("@app/wallets", () => ({ addInvoiceForRecipientForUsdWallet: jest.fn(), + addInvoiceNoAmountForRecipient: jest.fn(), getBalanceForWallet: jest.fn(), })) jest.mock("@services/mongoose", () => ({ @@ -16,12 +17,21 @@ jest.mock("@services/mongoose", () => ({ 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", @@ -54,6 +64,23 @@ describe("cash wallet migration runtime services", () => { }) }) + 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 () => ({ @@ -76,7 +103,27 @@ describe("cash wallet migration runtime services", () => { }) }) - it("extracts the IBEX transaction id after paying an invoice", async () => { + 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, + }) + expect(Ibex.addInvoice).toHaveBeenCalledWith({ + accountId: "usdt-wallet-id", + memo: "cash-wallet-cutover:run-7:migration-id:balance-move", + expiration: 900, + }) + }) + + 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" }, @@ -88,13 +135,15 @@ describe("cash wallet migration runtime services", () => { const result = await services.paymentService.payInvoice({ senderWalletId: "legacy-usd-wallet-id" as WalletId, paymentRequest: "lnbc1payment", + senderAmountUsdCents: "1000", }) expect(result).toEqual({ transactionId: "ibex-tx-id" }) - expect(deps.payInvoice).toHaveBeenCalledWith({ - accountId: "legacy-usd-wallet-id", - invoice: "lnbc1payment", - }) + 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("returns an error when IBEX payment response has no transaction id", async () => { @@ -110,21 +159,26 @@ describe("cash wallet migration runtime services", () => { expect(result).toBeInstanceOf(Error) }) - it("reads the balance move fee as rounded-up USD cents", async () => { + it("computes the fee reimbursement as the exact destination USDT shortfall", async () => { const deps = { - getTransactionDetails: jest.fn(async () => ({ - networkFee: 0.077, - })), + getBalanceForWallet: jest.fn(async () => USDTAmount.smallestUnits("14930000")), } const services = createCashWalletMigrationRuntimeServices(deps) - const result = await services.feeService.readFeeAmountUsdCents( - migration({ balanceMovePaymentTransactionId: "ibex-tx-id" }), + const result = await services.feeService.readFeeAmountUsdtMicros( + migration({ + balanceMovePaymentTransactionId: "ibex-tx-id", + destinationAmountUsdtMicros: "10000000", + destinationStartingBalanceUsdtMicros: "5000000", + }), ) - expect(result).toBe("8") - expect(deps.getTransactionDetails).toHaveBeenCalledWith("ibex-tx-id") + 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 () => { diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 6681a3e72..1feb58287 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -11,6 +11,7 @@ import { recordCashWalletMigrationBalance, sendCashWalletMigrationBalanceMovePayment, sendCashWalletMigrationFeeReimbursementPayment, + skipCashWalletMigrationFeeReimbursement, startCashWalletMigration, verifyCashWalletMigrationBalanceMove, verifyCashWalletMigrationLegacyZero, @@ -136,6 +137,7 @@ describe("cash wallet migration worker checkpoints", () => { ...migration("balance_read"), sourceBalanceUsdCents: "1234", destinationAmountUsdtMicros: "12340000", + destinationStartingBalanceUsdtMicros: "5000000", })), } @@ -143,12 +145,14 @@ describe("cash wallet migration worker checkpoints", () => { 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", @@ -159,6 +163,7 @@ describe("cash wallet migration worker checkpoints", () => { patch: { sourceBalanceUsdCents: "1234", destinationAmountUsdtMicros: "12340000", + destinationStartingBalanceUsdtMicros: "5000000", }, }) }) @@ -178,7 +183,7 @@ describe("cash wallet migration worker checkpoints", () => { expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("creates a balance move invoice on the destination wallet", async () => { + it("creates a no-amount balance move invoice on the destination wallet", async () => { const migrationsRepo = { transitionMigration: jest.fn(async () => ({ ...migration("invoice_created"), @@ -191,7 +196,7 @@ describe("cash wallet migration worker checkpoints", () => { paymentHash: "paymentHash" as PaymentHash, } as LnInvoice const invoiceService = { - createInvoice: jest.fn(async () => invoice), + createNoAmountInvoice: jest.fn(async () => invoice), } const result = await createCashWalletMigrationBalanceMoveInvoice({ @@ -208,9 +213,8 @@ describe("cash wallet migration worker checkpoints", () => { balanceMoveInvoicePaymentRequest: "lnbc1balance-move", balanceMoveInvoicePaymentHash: "paymentHash", }) - expect(invoiceService.createInvoice).toHaveBeenCalledWith({ + expect(invoiceService.createNoAmountInvoice).toHaveBeenCalledWith({ recipientWalletId: "usdt-wallet-id", - amount: "12340000", memo: "cash-wallet-cutover:run-7:migration-id:balance-move", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ @@ -231,7 +235,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(), } const invoiceService = { - createInvoice: jest.fn(), + createNoAmountInvoice: jest.fn(), } const result = await createCashWalletMigrationBalanceMoveInvoice({ @@ -241,7 +245,7 @@ describe("cash wallet migration worker checkpoints", () => { }) expect(result).toBeInstanceOf(Error) - expect(invoiceService.createInvoice).not.toHaveBeenCalled() + expect(invoiceService.createNoAmountInvoice).not.toHaveBeenCalled() expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) @@ -251,7 +255,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(), } const invoiceService = { - createInvoice: jest.fn(async () => error), + createNoAmountInvoice: jest.fn(async () => error), } const result = await createCashWalletMigrationBalanceMoveInvoice({ @@ -267,7 +271,7 @@ describe("cash wallet migration worker checkpoints", () => { expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("sends the balance move payment from the legacy wallet", async () => { + 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"), @@ -284,6 +288,7 @@ describe("cash wallet migration worker checkpoints", () => { migration: { ...migration("invoice_created"), balanceMoveInvoicePaymentRequest: "lnbc1balance-move", + sourceBalanceUsdCents: "1000", }, paymentService, migrationsRepo, @@ -296,6 +301,7 @@ describe("cash wallet migration worker checkpoints", () => { expect(paymentService.payInvoice).toHaveBeenCalledWith({ senderWalletId: "legacy-usd-wallet-id", paymentRequest: "lnbc1balance-move", + senderAmountUsdCents: "1000", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ id: "migration-id", @@ -328,6 +334,28 @@ describe("cash wallet migration worker checkpoints", () => { 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 () => ({ @@ -446,12 +474,12 @@ describe("cash wallet migration worker checkpoints", () => { expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("creates a fee reimbursement invoice on the legacy wallet", async () => { + it("creates a fee reimbursement invoice on the destination wallet for the exact USDT shortfall", async () => { const migrationsRepo = { transitionMigration: jest.fn(async () => ({ ...migration("fee_reimbursement_invoice_created"), - feeAmountUsdCents: "7", - feeAmountUsdtMicros: "70000", + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", feeReimbursementInvoicePaymentHash: "feePaymentHash", })), @@ -468,19 +496,19 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("balance_move_verified"), invoiceService, migrationsRepo, - feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70001", }) expect(result).toMatchObject({ status: "fee_reimbursement_invoice_created", - feeAmountUsdCents: "7", - feeAmountUsdtMicros: "70000", + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", feeReimbursementInvoicePaymentHash: "feePaymentHash", }) expect(invoiceService.createInvoice).toHaveBeenCalledWith({ - recipientWalletId: "legacy-usd-wallet-id", - amount: "7", + recipientWalletId: "usdt-wallet-id", + amount: "70001", memo: "cash-wallet-cutover:run-7:migration-id:fee-reimbursement", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ @@ -490,8 +518,8 @@ describe("cash wallet migration worker checkpoints", () => { cutoverVersion: 7, runId: "run-7", patch: { - feeAmountUsdCents: "7", - feeAmountUsdtMicros: "70000", + feeAmountUsdCents: "8", + feeAmountUsdtMicros: "70001", feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", feeReimbursementInvoicePaymentHash: "feePaymentHash", }, @@ -510,7 +538,7 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("balance_move_verified"), invoiceService, migrationsRepo, - feeAmountUsdCents: "0.07", + feeAmountUsdtMicros: "0.07", }) expect(result).toBeInstanceOf(Error) @@ -531,14 +559,46 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("balance_move_verified"), invoiceService, migrationsRepo, - feeAmountUsdCents: "7", + feeAmountUsdtMicros: "70000", }) expect(result).toBe(error) expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("sends the fee reimbursement payment from the destination wallet", async () => { + 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"), @@ -556,6 +616,7 @@ describe("cash wallet migration worker checkpoints", () => { ...migration("fee_reimbursement_invoice_created"), feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", }, + treasuryWalletId: "treasury-wallet-id" as WalletId, paymentService, migrationsRepo, }) @@ -565,7 +626,7 @@ describe("cash wallet migration worker checkpoints", () => { feeReimbursementPaymentTransactionId: "fee-ibex-tx-id", }) expect(paymentService.payInvoice).toHaveBeenCalledWith({ - senderWalletId: "usdt-wallet-id", + senderWalletId: "treasury-wallet-id", paymentRequest: "lnbc1fee-reimbursement", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ 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..66a2fceea --- /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?.( + undefined, + {}, + {} 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?.( + undefined, + { + 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/services/mongoose/cash-wallet-cutover.spec.ts b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts index e036d25bb..fcb76e7de 100644 --- a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts +++ b/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts @@ -1,3 +1,4 @@ +import { CouldNotUpdateError } from "@domain/errors" import { CashWalletCutoverRepository } from "@services/mongoose/cash-wallet-cutover" import { CashWalletCutoverConfig, CashWalletMigration } from "@services/mongoose/schema" @@ -191,4 +192,51 @@ describe("CashWalletCutoverRepository", () => { 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", + }) + }) }) From 8cd9bea00d9763ba32b99811685d4e17cf2b2b7e Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 25 May 2026 15:41:48 -0400 Subject: [PATCH 33/40] feat(cutover): add client-aware cash wallet presentation --- .../cash-wallet-cutover/client-capability.ts | 37 +++++ src/app/cash-wallet-cutover/errors.ts | 2 + src/app/cash-wallet-cutover/guard.ts | 35 ++++- src/app/cash-wallet-cutover/index.ts | 8 +- .../presentation-for-account.ts | 94 ++++++++++++ src/app/cash-wallet-cutover/presentation.ts | 82 +++++++++++ src/graphql/error-map.ts | 8 ++ .../mutation/intraledger-usd-payment-send.ts | 22 ++- .../ln-noamount-usd-invoice-fee-probe.ts | 28 ++-- .../ln-noamount-usd-invoice-payment-send.ts | 19 ++- .../root/mutation/ln-usd-invoice-create.ts | 21 ++- .../root/mutation/ln-usd-invoice-fee-probe.ts | 19 ++- .../root/mutation/onchain-usd-payment-send.ts | 31 ++-- .../root/query/account-default-wallet-id.ts | 14 +- .../root/query/account-default-wallet.ts | 23 +-- .../public/types/object/business-account.ts | 65 ++++++--- .../public/types/object/consumer-account.ts | 47 ++++-- src/graphql/shared/types/object/usd-wallet.ts | 38 ++++- src/servers/graphql-main-server.ts | 17 ++- src/servers/index.files.d.ts | 1 + src/servers/middlewares/session.ts | 5 +- src/servers/ws-server.ts | 19 ++- .../client-capability.spec.ts | 46 ++++++ .../cash-wallet-cutover/cutover-gate.spec.ts | 76 +++++++++- .../presentation-for-account.spec.ts | 135 ++++++++++++++++++ .../cash-wallet-cutover/presentation.spec.ts | 120 ++++++++++++++++ .../runtime-services.spec.ts | 4 +- .../app/cash-wallet-cutover/worker.spec.ts | 9 +- .../unit/graphql/cash-wallet-cutover.spec.ts | 4 +- .../shared/types/object/usd-wallet.spec.ts | 17 +++ 30 files changed, 942 insertions(+), 104 deletions(-) create mode 100644 src/app/cash-wallet-cutover/client-capability.ts create mode 100644 src/app/cash-wallet-cutover/presentation-for-account.ts create mode 100644 src/app/cash-wallet-cutover/presentation.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts create mode 100644 test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts diff --git a/src/app/cash-wallet-cutover/client-capability.ts b/src/app/cash-wallet-cutover/client-capability.ts new file mode 100644 index 000000000..e30b95fb5 --- /dev/null +++ b/src/app/cash-wallet-cutover/client-capability.ts @@ -0,0 +1,37 @@ +export const CASH_WALLET_USDT_CLIENT_CAPABILITY = "cash-wallet-usdt-v1" + +export type CashWalletPresentation = "legacy_compat" | "usdt" + +export type CashWalletClientCapabilities = { + cashWalletPresentation: CashWalletPresentation + hasUsdtCashWalletSupport: boolean +} + +export const DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES: CashWalletClientCapabilities = { + cashWalletPresentation: "legacy_compat", + hasUsdtCashWalletSupport: false, +} + +export const parseCashWalletClientCapabilities = ( + headers: Record, +): CashWalletClientCapabilities => { + const values = Object.entries(headers).flatMap(([key, raw]) => { + if (key.toLowerCase() !== "x-flash-client-capabilities") return [] + if (typeof raw === "string") return [raw] + if (Array.isArray(raw)) + return raw.filter((value): value is string => typeof value === "string") + return [] + }) + + const capabilities = values + .flatMap((value) => value.split(",")) + .map((value) => value.trim().toLowerCase()) + + const hasUsdtCashWalletSupport = capabilities.includes( + CASH_WALLET_USDT_CLIENT_CAPABILITY, + ) + + if (!hasUsdtCashWalletSupport) return DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES + + return { cashWalletPresentation: "usdt", hasUsdtCashWalletSupport } +} diff --git a/src/app/cash-wallet-cutover/errors.ts b/src/app/cash-wallet-cutover/errors.ts index 1fa62da12..373dcd742 100644 --- a/src/app/cash-wallet-cutover/errors.ts +++ b/src/app/cash-wallet-cutover/errors.ts @@ -5,5 +5,7 @@ export class InvalidCashWalletMigrationTransitionError extends ValidationError { export class InvalidCashWalletCutoverStateTransitionError extends ValidationError {} export class CashWalletCutoverInProgressError extends ValidationError {} export class CashWalletMigrationFailedError extends DomainError {} +export class CashWalletMissingLegacyUsdWalletError extends DomainError {} +export class CashWalletMissingUsdtWalletError extends DomainError {} export class CashWalletCutoverPreflightError extends DomainError {} export class CashWalletCutoverTreasuryInsufficientBalanceError extends DomainError {} diff --git a/src/app/cash-wallet-cutover/guard.ts b/src/app/cash-wallet-cutover/guard.ts index e37f61369..214968d4c 100644 --- a/src/app/cash-wallet-cutover/guard.ts +++ b/src/app/cash-wallet-cutover/guard.ts @@ -2,9 +2,17 @@ import { CashWalletCutoverInProgressError, CashWalletMigrationFailedError, } from "./errors" +import { CashWalletClientCapabilities } from "./client-capability" export { CashWalletCutoverInProgressError, CashWalletMigrationFailedError } +export type CashWalletCutoverRoute = "legacy_usd" | "usdt" +export type CashWalletCutoverPresentation = "legacy_usd" | "legacy_usd_compat" | "usdt" + +export type CashWalletCutoverDecision = { + presentation: CashWalletCutoverPresentation +} + const ACTIVE_STATUSES: CashWalletMigrationStatus[] = [ "started", "provisioned", @@ -26,16 +34,16 @@ export const evaluateCashWalletCutoverGuard = ({ }: { cutover: CashWalletCutoverConfig migration?: CashWalletMigration | null -}): { route: "legacy_usd" | "eth_usdt" } | ApplicationError => { +}): { route: CashWalletCutoverRoute } | ApplicationError => { if (cutover.state === "pre") return { route: "legacy_usd" } - if (cutover.state === "complete") return { route: "eth_usdt" } + if (cutover.state === "complete") return { route: "usdt" } if (!migration || migration.status === "not_started") return { route: "legacy_usd" } if ( migration.status === "complete" || migration.status === "skipped_already_migrated" ) { - return { route: "eth_usdt" } + return { route: "usdt" } } if (migration.status === "failed" || migration.status === "requires_operator_review") { return new CashWalletMigrationFailedError() @@ -46,3 +54,24 @@ export const evaluateCashWalletCutoverGuard = ({ return { route: "legacy_usd" } } + +export const evaluateCashWalletCutoverPresentation = ({ + cutover, + migration, + client, +}: { + cutover: CashWalletCutoverConfig + migration?: CashWalletMigration | null + client: CashWalletClientCapabilities +}): CashWalletCutoverDecision | ApplicationError => { + const guard = evaluateCashWalletCutoverGuard({ cutover, migration }) + if (guard instanceof Error) return guard + + if (guard.route === "legacy_usd") { + return { presentation: "legacy_usd" } + } + + return { + presentation: client.hasUsdtCashWalletSupport ? "usdt" : "legacy_usd_compat", + } +} diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index 357dabbd0..eae3f76bd 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -1,7 +1,13 @@ export * from "./amount-conversion" +export * from "./client-capability" export * from "./errors" +export * from "./presentation" +export * from "./presentation-for-account" export * from "./state-machine" -export { evaluateCashWalletCutoverGuard } from "./guard" +export { + evaluateCashWalletCutoverGuard, + evaluateCashWalletCutoverPresentation, +} from "./guard" export * from "./discovery" export * from "./preflight" export * from "./planner" diff --git a/src/app/cash-wallet-cutover/presentation-for-account.ts b/src/app/cash-wallet-cutover/presentation-for-account.ts new file mode 100644 index 000000000..53483ea19 --- /dev/null +++ b/src/app/cash-wallet-cutover/presentation-for-account.ts @@ -0,0 +1,94 @@ +import { WalletsRepository, CashWalletCutoverRepository } from "@services/mongoose" + +import { CashWalletClientCapabilities } from "./client-capability" +import { CashWalletCutoverPreflightError } from "./errors" +import { evaluateCashWalletCutoverPresentation } from "./guard" +import { + CashWalletPresentationResult, + resolveCashWalletPresentation, +} from "./presentation" + +type CashWalletPresentationMigrationsRepository = { + getConfig: () => Promise + findMigrationByAccountId: ({ + accountId, + cutoverVersion, + runId, + }: { + accountId: AccountId + cutoverVersion: number + runId: string + }) => Promise +} + +type CashWalletPresentationWalletsRepository = { + listByAccountId: (accountId: AccountId) => Promise +} + +export const resolveCashWalletPresentationForAccount = async ({ + account, + client, + migrationsRepo = CashWalletCutoverRepository(), + walletsRepo = WalletsRepository(), +}: { + account: Account + client: CashWalletClientCapabilities + migrationsRepo?: CashWalletPresentationMigrationsRepository + walletsRepo?: CashWalletPresentationWalletsRepository +}): Promise => { + const cutover = await migrationsRepo.getConfig() + if (cutover instanceof Error) return cutover + + let migration: CashWalletMigration | null | undefined + if (cutover.state === "in_progress") { + if (!cutover.runId) return new CashWalletCutoverPreflightError() + + const foundMigration = await migrationsRepo.findMigrationByAccountId({ + accountId: account.id, + cutoverVersion: cutover.cutoverVersion, + runId: cutover.runId, + }) + if (foundMigration instanceof Error) return foundMigration + migration = foundMigration + } + + const decision = evaluateCashWalletCutoverPresentation({ + cutover, + migration, + client, + }) + if (decision instanceof Error) return decision + + const wallets = await walletsRepo.listByAccountId(account.id) + if (wallets instanceof Error) return wallets + + return resolveCashWalletPresentation({ decision, wallets }) +} + +export const resolveCashWalletMutationWalletIdForAccount = async ({ + account, + walletId, + client, + migrationsRepo, + walletsRepo, +}: { + account: Account + walletId: WalletId + client: CashWalletClientCapabilities + migrationsRepo?: CashWalletPresentationMigrationsRepository + walletsRepo?: CashWalletPresentationWalletsRepository +}): Promise => { + const presentation = await resolveCashWalletPresentationForAccount({ + account, + client, + migrationsRepo, + walletsRepo, + }) + if (presentation instanceof Error) return presentation + + if (walletId === presentation.legacyUsdWallet?.id) { + return presentation.activeSettlementWallet.id + } + + return walletId +} diff --git a/src/app/cash-wallet-cutover/presentation.ts b/src/app/cash-wallet-cutover/presentation.ts new file mode 100644 index 000000000..7c41478a8 --- /dev/null +++ b/src/app/cash-wallet-cutover/presentation.ts @@ -0,0 +1,82 @@ +import { WalletCurrency } from "@domain/shared" + +import { CashWalletCutoverDecision } from "./guard" +import { + CashWalletMissingLegacyUsdWalletError, + CashWalletMissingUsdtWalletError, +} from "./errors" + +export type CashWalletPresentationResult = { + wallets: Wallet[] + defaultWalletId: WalletId + legacyUsdWallet?: Wallet + activeSettlementWallet: Wallet +} + +export const resolveCashWalletPresentation = ({ + decision, + wallets, +}: { + decision: CashWalletCutoverDecision + wallets: Wallet[] +}): CashWalletPresentationResult | ApplicationError => { + const legacyUsdWallet = wallets.find((wallet) => wallet.currency === WalletCurrency.Usd) + const usdtWallet = wallets.find((wallet) => wallet.currency === WalletCurrency.Usdt) + const nonCashWallets = wallets.filter( + (wallet) => + wallet.currency !== WalletCurrency.Usd && wallet.currency !== WalletCurrency.Usdt, + ) + + if (decision.presentation === "usdt") { + if (!usdtWallet) return new CashWalletMissingUsdtWalletError() + + return { + wallets: [...nonCashWallets, usdtWallet], + defaultWalletId: usdtWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + } + } + + if (!legacyUsdWallet) return new CashWalletMissingLegacyUsdWalletError() + + if (decision.presentation === "legacy_usd_compat") { + if (!usdtWallet) return new CashWalletMissingUsdtWalletError() + + return { + wallets: [...nonCashWallets, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: usdtWallet, + } + } + + return { + wallets: [...nonCashWallets, legacyUsdWallet], + defaultWalletId: legacyUsdWallet.id, + legacyUsdWallet, + activeSettlementWallet: legacyUsdWallet, + } +} + +export const cashWalletTransactionWalletIdsForPresentation = ({ + walletIds, + presentation, +}: { + walletIds?: WalletId[] + presentation: CashWalletPresentationResult +}): WalletId[] => { + const selectedWalletIds = walletIds ?? presentation.wallets.map((wallet) => wallet.id) + + if (!presentation.legacyUsdWallet) return selectedWalletIds + + return Array.from( + new Set( + selectedWalletIds.map((walletId) => + walletId === presentation.legacyUsdWallet?.id + ? presentation.activeSettlementWallet.id + : walletId, + ), + ), + ) +} diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 4cf3864ff..480711d87 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -808,6 +808,14 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = error.message return new ValidationInternalError({ message, logger: baseLogger }) + case "CashWalletMissingLegacyUsdWalletError": + message = "Legacy USD Cash Wallet is missing for this account." + return new ValidationInternalError({ message, logger: baseLogger }) + + case "CashWalletMissingUsdtWalletError": + message = "USDT Cash Wallet is missing for this account." + return new ValidationInternalError({ message, logger: baseLogger }) + case "InvalidCashWalletCutoverAmountError": message = error.message return new ValidationInternalError({ message, logger: baseLogger }) diff --git a/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts b/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts index 7c5b4dd2a..cf663acc1 100644 --- a/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts +++ b/src/graphql/public/root/mutation/intraledger-usd-payment-send.ts @@ -1,4 +1,5 @@ import { Payments } from "@app" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import { checkedToWalletId } from "@domain/wallets" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import { GT } from "@graphql/index" @@ -9,7 +10,6 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import dedent from "dedent" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" // import { RequestInit, Response } from 'node-fetch' -import { EmailService } from "@services/email" const IntraLedgerUsdPaymentSendInput = GT.Input({ name: "IntraLedgerUsdPaymentSendInput", @@ -34,7 +34,11 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field { + resolve: async ( + _, + args, + { domainAccount, cashWalletClientCapabilities }: GraphQLPublicContextAuth, + ) => { const { walletId, recipientWalletId, amount, memo } = args.input for (const input of [walletId, recipientWalletId, amount, memo]) { if (input instanceof Error) { @@ -52,11 +56,23 @@ const IntraLedgerUsdPaymentSendMutation = GT.Field({ extensions: { complexity: 120, }, @@ -30,7 +31,7 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ args: { input: { type: GT.NonNull(LnNoAmountUsdInvoiceFeeProbeInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, paymentRequest, amount } = args.input for (const input of [walletId, paymentRequest, amount]) { @@ -39,6 +40,15 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ } } + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, + walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(routedWalletId)] } + } + // FLASH FORK: create IBEX fee estimation instead of Galoy fee estimation // const { result: feeSatAmount, error } = // await Payments.getNoAmountLightningFeeEstimationForUsdWallet({ @@ -49,17 +59,19 @@ const LnNoAmountUsdInvoiceFeeProbeMutation = GT.Field({ // TODO: Move Ibex call to Payments interface const checkedAmount = await usdWalletAmountFromWalletId({ - walletId, + walletId: routedWalletId, amount: amount.toString(), }) if (checkedAmount instanceof Error) { return { errors: [mapAndParseErrorForGqlResponse(checkedAmount)] } } - const resp: IbexFeeEstimation | IbexError = await Ibex.getLnFeeEstimation({ - invoice: paymentRequest as Bolt11, - send: checkedAmount, - }) - if (resp instanceof IbexError) return { errors: [mapAndParseErrorForGqlResponse(resp)] } + const resp: IbexFeeEstimation | IbexError = + await Ibex.getLnFeeEstimation({ + invoice: paymentRequest as Bolt11, + send: checkedAmount, + }) + if (resp instanceof IbexError) + return { errors: [mapAndParseErrorForGqlResponse(resp)] } // if (resp.amount === undefined) return new UnexpectedIbexResponse("Unable to parse fee.") // const feeSatAmount: PaymentAmount = { diff --git a/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts b/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts index e8d8d6227..12298ec34 100644 --- a/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts +++ b/src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts @@ -14,6 +14,7 @@ import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fract import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { usdWalletAmountFromWalletId } from "@app/wallets" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import Ibex from "@services/ibex/client" import { IbexError } from "@services/ibex/errors" @@ -63,7 +64,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(LnNoAmountUsdInvoicePaymentInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, paymentRequest, amount, memo } = args.input if (walletId instanceof InputValidationError) { @@ -90,8 +91,20 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< if (!domainAccount) throw new Error("Authentication required") // eslint-disable-next-line @typescript-eslint/no-explicit-any - const usCents = await usdWalletAmountFromWalletId({ + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { + status: "failed", + errors: [mapAndParseErrorForGqlResponse(routedWalletId)], + } + } + + const usCents = await usdWalletAmountFromWalletId({ + walletId: routedWalletId, amount: amount.toString(), }) if (usCents instanceof Error) { @@ -102,7 +115,7 @@ const LnNoAmountUsdInvoicePaymentSendMutation = GT.Field< } const PayLightningInvoice = await Ibex.payInvoice({ invoice: paymentRequest as Bolt11, - accountId: walletId, + accountId: routedWalletId, send: usCents, }) diff --git a/src/graphql/public/root/mutation/ln-usd-invoice-create.ts b/src/graphql/public/root/mutation/ln-usd-invoice-create.ts index 2cbedab73..064a5d4d3 100644 --- a/src/graphql/public/root/mutation/ln-usd-invoice-create.ts +++ b/src/graphql/public/root/mutation/ln-usd-invoice-create.ts @@ -9,6 +9,7 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import LnInvoicePayload from "@graphql/public/types/payload/ln-invoice" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import { Wallets } from "@app/index" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" const LnUsdInvoiceCreateInput = GT.Input({ @@ -18,7 +19,10 @@ const LnUsdInvoiceCreateInput = GT.Input({ type: GT.NonNull(WalletId), description: "Wallet ID for a USD wallet belonging to the current user.", }, - amount: { type: GT.NonNull(FractionalCentAmount), description: "Amount in USD cents." }, + amount: { + type: GT.NonNull(FractionalCentAmount), + description: "Amount in USD cents.", + }, memo: { type: Memo, description: "Optional memo for the lightning invoice." }, expiresIn: { type: Minutes, @@ -27,7 +31,7 @@ const LnUsdInvoiceCreateInput = GT.Input({ }), }) -const LnUsdInvoiceCreateMutation = GT.Field({ +const LnUsdInvoiceCreateMutation = GT.Field({ extensions: { complexity: 120, }, @@ -39,7 +43,7 @@ const LnUsdInvoiceCreateMutation = GT.Field({ args: { input: { type: GT.NonNull(LnUsdInvoiceCreateInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, amount, memo, expiresIn } = args.input for (const input of [walletId, amount, memo, expiresIn]) { @@ -48,8 +52,17 @@ const LnUsdInvoiceCreateMutation = GT.Field({ } } - const invoice = await Wallets.addInvoiceForSelfForUsdWallet({ + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(routedWalletId)] } + } + + const invoice = await Wallets.addInvoiceForSelfForUsdWallet({ + walletId: routedWalletId, amount, memo, expiresIn, diff --git a/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts b/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts index ccfb2c7bb..c1a06e346 100644 --- a/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts +++ b/src/graphql/public/root/mutation/ln-usd-invoice-fee-probe.ts @@ -3,6 +3,7 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import CentAmountPayload from "@graphql/public/types/payload/cent-amount" import LnPaymentRequest from "@graphql/shared/types/scalar/ln-payment-request" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" import { checkedToWalletId } from "@domain/wallets" @@ -53,7 +54,7 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< args: { input: { type: GT.NonNull(LnUsdInvoiceFeeProbeInput) }, }, - resolve: async (_, args) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, paymentRequest } = args.input if (walletId instanceof Error) { @@ -68,6 +69,15 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< if (walletIdChecked instanceof Error) return { errors: [mapAndParseErrorForGqlResponse(walletIdChecked)] } + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, + walletId: walletIdChecked, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { errors: [mapAndParseErrorForGqlResponse(routedWalletId)] } + } + // FLASH FORK: create IBEX fee estimation instead of Galoy fee estimation // const { result: feeSatAmount, error } = // await Payments.getLightningFeeEstimationForUsdWallet({ @@ -75,7 +85,7 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< // uncheckedPaymentRequest: paymentRequest, // }) - const wallet = await WalletsRepository().findById(walletIdChecked) + const wallet = await WalletsRepository().findById(routedWalletId) if (wallet instanceof Error) { return { errors: [mapAndParseErrorForGqlResponse(wallet)] } } @@ -84,8 +94,9 @@ const LnUsdInvoiceFeeProbeMutation = GT.Field< invoice: paymentRequest as Bolt11, currency: wallet.currency, }) - if (resp instanceof IbexError) return { errors: [mapAndParseErrorForGqlResponse(resp)] } - + if (resp instanceof IbexError) + return { errors: [mapAndParseErrorForGqlResponse(resp)] } + return { errors: [], invoiceAmount: resp.invoice, diff --git a/src/graphql/public/root/mutation/onchain-usd-payment-send.ts b/src/graphql/public/root/mutation/onchain-usd-payment-send.ts index b45224a6c..f85b02c55 100644 --- a/src/graphql/public/root/mutation/onchain-usd-payment-send.ts +++ b/src/graphql/public/root/mutation/onchain-usd-payment-send.ts @@ -12,6 +12,7 @@ import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fract import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { Wallets } from "@app/index" import { usdWalletAmountFromWalletId } from "@app/wallets" +import { resolveCashWalletMutationWalletIdForAccount } from "@app/cash-wallet-cutover" const OnChainUsdPaymentSendInput = GT.Input({ name: "OnChainUsdPaymentSendInput", @@ -47,7 +48,7 @@ const OnChainUsdPaymentSendMutation = GT.Field< args: { input: { type: GT.NonNull(OnChainUsdPaymentSendInput) }, }, - resolve: async (_, args, { domainAccount }) => { + resolve: async (_, args, { domainAccount, cashWalletClientCapabilities }) => { const { walletId, address, amount, memo, speed } = args.input if (walletId instanceof Error) { @@ -67,8 +68,20 @@ const OnChainUsdPaymentSendMutation = GT.Field< } if (!domainAccount) throw new Error("Authentication required") - const usdAmount = await usdWalletAmountFromWalletId({ + const routedWalletId = await resolveCashWalletMutationWalletIdForAccount({ + account: domainAccount, walletId, + client: cashWalletClientCapabilities, + }) + if (routedWalletId instanceof Error) { + return { + status: PaymentSendStatus.Failure.value, + errors: [mapAndParseErrorForGqlResponse(routedWalletId)], + } + } + + const usdAmount = await usdWalletAmountFromWalletId({ + walletId: routedWalletId, amount: amount.toString(), }) if (usdAmount instanceof Error) { @@ -77,24 +90,24 @@ const OnChainUsdPaymentSendMutation = GT.Field< errors: [mapAndParseErrorForGqlResponse(usdAmount)], } } - + const result = await Wallets.payOnChainByWalletId({ senderAccount: domainAccount, - senderWalletId: walletId, + senderWalletId: routedWalletId, amount: usdAmount, address, speed, memo, }) if (result instanceof Error) { - return { - status: PaymentSendStatus.Failure.value, - errors: [mapAndParseErrorForGqlResponse(result)] + return { + status: PaymentSendStatus.Failure.value, + errors: [mapAndParseErrorForGqlResponse(result)], } } return { - status: result.status.value, - errors: [] + status: result.status.value, + errors: [], } }, }) diff --git a/src/graphql/public/root/query/account-default-wallet-id.ts b/src/graphql/public/root/query/account-default-wallet-id.ts index 23763edda..f91ebc4a1 100644 --- a/src/graphql/public/root/query/account-default-wallet-id.ts +++ b/src/graphql/public/root/query/account-default-wallet-id.ts @@ -1,10 +1,11 @@ +import { resolveCashWalletPresentationForAccount } from "@app/cash-wallet-cutover" import { mapError } from "@graphql/error-map" import { GT } from "@graphql/index" import Username from "@graphql/shared/types/scalar/username" import WalletId from "@graphql/shared/types/scalar/wallet-id" import { AccountsRepository } from "@services/mongoose" -const AccountDefaultWalletIdQuery = GT.Field({ +const AccountDefaultWalletIdQuery = GT.Field({ deprecationReason: "will be migrated to AccountDefaultWalletId", type: GT.NonNull(WalletId), args: { @@ -12,7 +13,7 @@ const AccountDefaultWalletIdQuery = GT.Field({ type: GT.NonNull(Username), }, }, - resolve: async (_, args) => { + resolve: async (_, args, { cashWalletClientCapabilities }) => { const { username } = args if (username instanceof Error) { @@ -24,8 +25,13 @@ const AccountDefaultWalletIdQuery = GT.Field({ throw mapError(account) } - const walletId = account.defaultWalletId - return walletId + const presentation = await resolveCashWalletPresentationForAccount({ + account, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.defaultWalletId }, }) diff --git a/src/graphql/public/root/query/account-default-wallet.ts b/src/graphql/public/root/query/account-default-wallet.ts index 1387c0169..8d5b12f47 100644 --- a/src/graphql/public/root/query/account-default-wallet.ts +++ b/src/graphql/public/root/query/account-default-wallet.ts @@ -1,4 +1,4 @@ -import { Wallets } from "@app" +import { resolveCashWalletPresentationForAccount } from "@app/cash-wallet-cutover" import { CouldNotFindWalletFromUsernameAndCurrencyError } from "@domain/errors" import { mapError } from "@graphql/error-map" import { GT } from "@graphql/index" @@ -7,7 +7,7 @@ import WalletCurrency from "@graphql/shared/types/scalar/wallet-currency" import PublicWallet from "@graphql/public/types/abstract/public-wallet" import { AccountsRepository } from "@services/mongoose" -const AccountDefaultWalletQuery = GT.Field({ +const AccountDefaultWalletQuery = GT.Field({ type: GT.NonNull(PublicWallet), args: { username: { @@ -15,7 +15,7 @@ const AccountDefaultWalletQuery = GT.Field({ }, walletCurrency: { type: WalletCurrency }, }, - resolve: async (_, args) => { + resolve: async (_, args, { cashWalletClientCapabilities }) => { const { username, walletCurrency } = args if (username instanceof Error) { @@ -27,16 +27,21 @@ const AccountDefaultWalletQuery = GT.Field({ throw mapError(account) } - const wallets = await Wallets.listWalletsByAccountId(account.id) - if (wallets instanceof Error) { - throw mapError(wallets) - } + const presentation = await resolveCashWalletPresentationForAccount({ + account, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) if (!walletCurrency) { - return wallets.find((wallet) => wallet.id === account.defaultWalletId) + return presentation.wallets.find( + (wallet) => wallet.id === presentation.defaultWalletId, + ) } - const wallet = wallets.find((wallet) => wallet.currency === walletCurrency) + const wallet = presentation.wallets.find( + (wallet) => wallet.currency === walletCurrency, + ) if (!wallet) { throw mapError(new CouldNotFindWalletFromUsernameAndCurrencyError(username)) } diff --git a/src/graphql/public/types/object/business-account.ts b/src/graphql/public/types/object/business-account.ts index 97daf3670..a9980a89e 100644 --- a/src/graphql/public/types/object/business-account.ts +++ b/src/graphql/public/types/object/business-account.ts @@ -1,4 +1,8 @@ -import { Accounts, Prices, Wallets } from "@app" +import { Accounts, Prices } from "@app" +import { + cashWalletTransactionWalletIdsForPresentation, + resolveCashWalletPresentationForAccount, +} from "@app/cash-wallet-cutover" import { majorToMinorUnit, @@ -15,8 +19,6 @@ import { checkedConnectionArgs, } from "@graphql/connections" -import { WalletsRepository } from "@services/mongoose" - import IAccount from "../abstract/account" import Wallet from "../../../shared/types/abstract/wallet" @@ -30,7 +32,7 @@ import { TransactionConnection } from "../../../shared/types/object/transaction" import RealtimePrice from "./realtime-price" import { NotificationSettings } from "./notification-settings" -const BusinessAccount = GT.Object({ +const BusinessAccount = GT.Object({ name: "BusinessAccount", interfaces: () => [IAccount], isTypeOf: () => false, @@ -42,15 +44,36 @@ const BusinessAccount = GT.Object({ wallets: { type: GT.NonNullList(Wallet), - resolve: async (source: Account) => { - return Wallets.listWalletsByAccountId(source.id) + resolve: async ( + source: Account, + args, + { cashWalletClientCapabilities }: GraphQLPublicContextAuth, + ) => { + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.wallets }, }, defaultWalletId: { type: GT.NonNull(WalletId), - resolve: (source, args, { domainAccount }: { domainAccount: Account }) => - domainAccount.defaultWalletId, + resolve: async ( + source: Account, + args, + { cashWalletClientCapabilities }: GraphQLPublicContextAuth, + ) => { + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.defaultWalletId + }, }, level: { @@ -60,8 +83,7 @@ const BusinessAccount = GT.Object({ displayCurrency: { type: GT.NonNull(DisplayCurrency), - resolve: (source, args, { domainAccount }: { domainAccount: Account }) => - domainAccount.displayCurrency, + resolve: (source, args, { domainAccount }) => domainAccount.displayCurrency, }, realtimePrice: { @@ -123,21 +145,28 @@ const BusinessAccount = GT.Object({ type: GT.List(WalletId), }, }, - resolve: async (source, args) => { + resolve: async ( + source: Account, + args, + { cashWalletClientCapabilities }: GraphQLPublicContextAuth, + ) => { const paginationArgs = checkedConnectionArgs(args) if (paginationArgs instanceof Error) { throw paginationArgs } + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + let { walletIds } = args - if (!walletIds) { - const wallets = await WalletsRepository().listByAccountId(source.id) - if (wallets instanceof Error) { - throw mapError(wallets) - } - walletIds = wallets.map((wallet) => wallet.id) - } + walletIds = cashWalletTransactionWalletIdsForPresentation({ + walletIds, + presentation, + }) const { result, error } = await Accounts.getTransactionsForAccountByWalletIds({ account: source, diff --git a/src/graphql/public/types/object/consumer-account.ts b/src/graphql/public/types/object/consumer-account.ts index 53e3e5a17..660b3a999 100644 --- a/src/graphql/public/types/object/consumer-account.ts +++ b/src/graphql/public/types/object/consumer-account.ts @@ -1,4 +1,8 @@ -import { Accounts, Prices, Wallets } from "@app" +import { Accounts, Prices } from "@app" +import { + cashWalletTransactionWalletIdsForPresentation, + resolveCashWalletPresentationForAccount, +} from "@app/cash-wallet-cutover" import { majorToMinorUnit, @@ -21,8 +25,6 @@ import WalletId from "@graphql/shared/types/scalar/wallet-id" import RealtimePrice from "@graphql/public/types/object/realtime-price" import DisplayCurrency from "@graphql/shared/types/scalar/display-currency" -import { WalletsRepository } from "@services/mongoose" - import { listEndpoints } from "@app/callback" import AccountLevel from "../../../shared/types/scalar/account-level" @@ -54,14 +56,28 @@ const ConsumerAccount = GT.Object({ wallets: { type: GT.NonNullList(Wallet), - resolve: async (source) => { - return Wallets.listWalletsByAccountId(source.id) + resolve: async (source, args, { cashWalletClientCapabilities }) => { + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.wallets }, }, defaultWalletId: { type: GT.NonNull(WalletId), - resolve: (source) => source.defaultWalletId, + resolve: async (source, args, { cashWalletClientCapabilities }) => { + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + return presentation.defaultWalletId + }, }, displayCurrency: { @@ -145,21 +161,24 @@ const ConsumerAccount = GT.Object({ type: GT.List(WalletId), }, }, - resolve: async (source, args) => { + resolve: async (source, args, { cashWalletClientCapabilities }) => { const paginationArgs = checkedConnectionArgs(args) if (paginationArgs instanceof Error) { throw paginationArgs } + const presentation = await resolveCashWalletPresentationForAccount({ + account: source, + client: cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + let { walletIds } = args - if (!walletIds) { - const wallets = await WalletsRepository().listByAccountId(source.id) - if (wallets instanceof Error) { - throw mapError(wallets) - } - walletIds = wallets.map((wallet) => wallet.id) - } + walletIds = cashWalletTransactionWalletIdsForPresentation({ + walletIds, + presentation, + }) const { result, error } = await Accounts.getTransactionsForAccountByWalletIds({ account: source, diff --git a/src/graphql/shared/types/object/usd-wallet.ts b/src/graphql/shared/types/object/usd-wallet.ts index 305a855bb..510351520 100644 --- a/src/graphql/shared/types/object/usd-wallet.ts +++ b/src/graphql/shared/types/object/usd-wallet.ts @@ -9,6 +9,7 @@ import { mapError } from "@graphql/error-map" import FractionalCentAmount from "@graphql/public/types/scalar/cent-amount-fraction" import { Wallets } from "@app" +import { resolveCashWalletPresentationForAccount } from "@app/cash-wallet-cutover" import { WalletCurrency as WalletCurrencyDomain, USDTAmount } from "@domain/shared" import { WalletType } from "@domain/wallets" @@ -22,6 +23,18 @@ import Lnurl from "../scalar/lnurl" import { TransactionConnection } from "./transaction" +export const usdtMicrosToUsdCents = (usdtMicros: bigint | number | string): number => { + const [wholeMicros, fractionalMicros] = usdtMicros.toString().split(".") + if (fractionalMicros && !/^0+$/.test(fractionalMicros)) { + throw new Error(`Cannot convert fractional USDT micros ${usdtMicros} to USD cents`) + } + + const amount = USDTAmount.smallestUnits(wholeMicros) + if (amount instanceof Error) throw amount + + return Number(amount.asUsdCents()) +} + const UsdWallet = GT.Object({ name: "UsdWallet", description: @@ -49,17 +62,34 @@ const UsdWallet = GT.Object({ }, balance: { type: FractionalCentAmount, - resolve: async (source) => { + resolve: async (source, args, ctx) => { if (source.type === WalletType.External) return null + let balanceWallet = source + + if ( + "cashWalletClientCapabilities" in ctx && + ctx.domainAccount?.id === source.accountId + ) { + const presentation = await resolveCashWalletPresentationForAccount({ + account: ctx.domainAccount, + client: ctx.cashWalletClientCapabilities, + }) + if (presentation instanceof Error) throw mapError(presentation) + + if (source.id === presentation.legacyUsdWallet?.id) { + balanceWallet = presentation.activeSettlementWallet + } + } + const balance = await Wallets.getBalanceForWallet({ - walletId: source.id, - currency: source.currency, + walletId: balanceWallet.id, + currency: balanceWallet.currency, }) if (balance instanceof Error) { throw mapError(balance) } if (balance instanceof USDTAmount) { - return Number(balance.asUsdCents()) + return usdtMicrosToUsdCents(balance.asSmallestUnits()) } return Number(balance.asCents(8)) }, diff --git a/src/servers/graphql-main-server.ts b/src/servers/graphql-main-server.ts index a0c3b1598..dfbeb53e6 100644 --- a/src/servers/graphql-main-server.ts +++ b/src/servers/graphql-main-server.ts @@ -6,7 +6,6 @@ import { AuthorizationError } from "@graphql/error" import { gqlMainSchema, mutationFields, queryFields } from "@graphql/public" import { bootstrap } from "@app/bootstrap" -import { activateLndHealthCheck } from "@services/lnd/health" import { baseLogger } from "@services/logger" import { setupMongoConnection } from "@services/mongodb" import { shield } from "graphql-shield" @@ -20,6 +19,7 @@ import { import { NextFunction, Request, Response } from "express" import { parseIps } from "@domain/accounts-ips" +import { parseCashWalletClientCapabilities } from "@app/cash-wallet-cutover/client-capability" import { startApolloServerForAdminSchema } from "./graphql-admin-server" import { isAuthenticated, startApolloServer } from "./graphql-server" @@ -44,8 +44,12 @@ const setGqlContext = async ( tokenPayload, ip, }) + const cashWalletClientCapabilities = parseCashWalletClientCapabilities(req.headers) - req.gqlContext = gqlContext + req.gqlContext = { + ...gqlContext, + cashWalletClientCapabilities, + } return addAttributesToCurrentSpanAndPropagate( { @@ -56,6 +60,11 @@ const setGqlContext = async ( [SemanticAttributes.HTTP_USER_AGENT]: req.headers["user-agent"], [ACCOUNT_USERNAME]: gqlContext?.domainAccount?.username, [SemanticAttributes.ENDUSER_ID]: tokenPayload?.sub, + "cash_wallet.client_presentation": + cashWalletClientCapabilities.cashWalletPresentation, + "cash_wallet.client_usdt_supported": String( + cashWalletClientCapabilities.hasUsdtCashWalletSupport, + ), }, next, ) @@ -101,9 +110,9 @@ export async function startApolloServerForCoreSchema() { if (require.main === module) { setupMongoConnection(true) .then(async () => { - // activateLndHealthCheck() // + // activateLndHealthCheck() - const res = await bootstrap() + await bootstrap() // if (res instanceof Error) throw res await Promise.race([ diff --git a/src/servers/index.files.d.ts b/src/servers/index.files.d.ts index 29513578f..c918274f8 100644 --- a/src/servers/index.files.d.ts +++ b/src/servers/index.files.d.ts @@ -13,6 +13,7 @@ type GraphQLPublicContext = { domainAccount: Account | undefined ip: IpAddress | undefined sessionId: SessionId | undefined + cashWalletClientCapabilities: import("@app/cash-wallet-cutover/client-capability").CashWalletClientCapabilities } type GraphQLPublicContextAuth = Omit & { diff --git a/src/servers/middlewares/session.ts b/src/servers/middlewares/session.ts index 03201a40a..2631ce286 100644 --- a/src/servers/middlewares/session.ts +++ b/src/servers/middlewares/session.ts @@ -1,6 +1,7 @@ import DataLoader from "dataloader" import { Accounts, Transactions } from "@app" +import { DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES } from "@app/cash-wallet-cutover" import { recordExceptionInCurrentSpan } from "@services/tracing" import jsonwebtoken from "jsonwebtoken" @@ -67,8 +68,7 @@ export const sessionPublicContext = async ({ error: txnMetadata, }) return keys.map(() => undefined) - } - else if (txnMetadata instanceof Error) { + } else if (txnMetadata instanceof Error) { recordExceptionInCurrentSpan({ error: txnMetadata, level: txnMetadata.level, @@ -88,5 +88,6 @@ export const sessionPublicContext = async ({ domainAccount, ip, sessionId, + cashWalletClientCapabilities: DEFAULT_CASH_WALLET_CLIENT_CAPABILITIES, } } diff --git a/src/servers/ws-server.ts b/src/servers/ws-server.ts index 9255a7f46..c98cbb28a 100644 --- a/src/servers/ws-server.ts +++ b/src/servers/ws-server.ts @@ -10,6 +10,7 @@ import jsonwebtoken from "jsonwebtoken" import { parseIps } from "@domain/accounts-ips" import { ErrorLevel } from "@domain/shared" +import { parseCashWalletClientCapabilities } from "@app/cash-wallet-cutover" import jwksRsa from "jwks-rsa" @@ -60,12 +61,16 @@ const getContext = async ( fnName: "getContext", fn: async () => { const connectionParams = ctx.connectionParams + const cashWalletClientCapabilities = parseCashWalletClientCapabilities({ + ...ctx.extra?.request?.headers, + ...connectionParams, + }) // TODO: check if nginx pass the ip to the header // TODO: ip not been used currently for subscription. // implement some rate limiting. const ipString = UNSECURE_IP_FROM_REQUEST_OBJECT - ? connectionParams?.ip ?? ctx.extra?.request?.socket?.remoteAddress + ? (connectionParams?.ip ?? ctx.extra?.request?.socket?.remoteAddress) : connectionParams?.["x-real-ip"] || connectionParams?.["x-forwarded-for"] const ip = parseIps(ipString) @@ -82,10 +87,14 @@ const getContext = async ( sub: kratosCookieRes.kratosUserId, } - return sessionPublicContext({ + const context = await sessionPublicContext({ tokenPayload, ip, }) + return { + ...context, + cashWalletClientCapabilities, + } } const kratosToken = authz?.slice(7) as AuthToken @@ -106,10 +115,14 @@ const getContext = async ( return false } - return sessionPublicContext({ + const context = await sessionPublicContext({ tokenPayload, ip, }) + return { + ...context, + cashWalletClientCapabilities, + } }, })() } 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 index 0d79195a4..356ce2a5f 100644 --- a/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts @@ -2,6 +2,7 @@ import { CashWalletCutoverInProgressError, CashWalletMigrationFailedError, evaluateCashWalletCutoverGuard, + evaluateCashWalletCutoverPresentation, } from "@app/cash-wallet-cutover/guard" const config = (state: CashWalletCutoverState): CashWalletCutoverConfig => ({ @@ -24,6 +25,16 @@ const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ 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({ @@ -58,13 +69,13 @@ describe("cash wallet cutover guard", () => { } }) - it("routes completed accounts to ETH-USDT during cutover", () => { + it("routes completed accounts to USDT during cutover", () => { expect( evaluateCashWalletCutoverGuard({ cutover: config("in_progress"), migration: migration("complete"), }), - ).toEqual({ route: "eth_usdt" }) + ).toEqual({ route: "usdt" }) }) it("rejects failed and manual-review migrations", () => { @@ -78,9 +89,66 @@ describe("cash wallet cutover guard", () => { } }) - it("routes all accounts to ETH-USDT after global completion", () => { + it("routes all accounts to USDT after global completion", () => { expect(evaluateCashWalletCutoverGuard({ cutover: config("complete") })).toEqual({ - route: "eth_usdt", + 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/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/runtime-services.spec.ts b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts index 1a83083b1..45e793e60 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -139,7 +139,7 @@ describe("cash wallet migration runtime services", () => { }) expect(result).toEqual({ transactionId: "ibex-tx-id" }) - const paymentArgs = deps.payInvoice.mock.calls[0][0] + 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) @@ -188,7 +188,7 @@ describe("cash wallet migration runtime services", () => { defaultWalletId: "legacy-usd-wallet-id" as WalletId, })), }, - updateDefaultWalletId: jest.fn(async () => ({})), + updateDefaultWalletId: jest.fn(async () => ({ defaultWalletId: "usdt-wallet-id" })), } const services = createCashWalletMigrationRuntimeServices(deps) diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 1feb58287..0d27c14ae 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -89,7 +89,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(async () => migration("provisioned")), } const provisioningService = { - ensureDestinationWallet: jest.fn(async () => true), + ensureDestinationWallet: jest.fn(async () => true as const), } const result = await provisionCashWalletMigrationDestination({ @@ -177,6 +177,7 @@ describe("cash wallet migration worker checkpoints", () => { migration: migration("provisioned"), migrationsRepo, sourceBalanceUsdCents: "12.34", + destinationStartingBalanceUsdtMicros: "0", }) expect(result).toBeInstanceOf(Error) @@ -404,7 +405,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(async () => migration("balance_move_verified")), } const balanceVerifier = { - verifyBalanceMove: jest.fn(async () => true), + verifyBalanceMove: jest.fn(async () => true as const), } const result = await verifyCashWalletMigrationBalanceMove({ @@ -651,6 +652,7 @@ describe("cash wallet migration worker checkpoints", () => { const result = await sendCashWalletMigrationFeeReimbursementPayment({ migration: migration("fee_reimbursement_invoice_created"), + treasuryWalletId: "treasury-wallet-id" as WalletId, paymentService, migrationsRepo, }) @@ -674,6 +676,7 @@ describe("cash wallet migration worker checkpoints", () => { ...migration("fee_reimbursement_invoice_created"), feeReimbursementInvoicePaymentRequest: "lnbc1fee-reimbursement", }, + treasuryWalletId: "treasury-wallet-id" as WalletId, paymentService, migrationsRepo, }) @@ -788,7 +791,7 @@ describe("cash wallet migration worker checkpoints", () => { transitionMigration: jest.fn(async () => migration("legacy_zero_verified")), } const legacyWalletVerifier = { - verifyLegacyWalletZero: jest.fn(async () => true), + verifyLegacyWalletZero: jest.fn(async () => true as const), } const result = await verifyCashWalletMigrationLegacyZero({ diff --git a/test/flash/unit/graphql/cash-wallet-cutover.spec.ts b/test/flash/unit/graphql/cash-wallet-cutover.spec.ts index 66a2fceea..f8e102a1e 100644 --- a/test/flash/unit/graphql/cash-wallet-cutover.spec.ts +++ b/test/flash/unit/graphql/cash-wallet-cutover.spec.ts @@ -24,7 +24,7 @@ describe("cash wallet cutover GraphQL surface", () => { jest.mocked(CashWalletCutoverRepository).mockReturnValue({ getConfig } as never) const result = await CashWalletCutoverQuery.resolve?.( - undefined, + null, {}, {} as GraphQLPublicContext, {} as never, @@ -49,7 +49,7 @@ describe("cash wallet cutover GraphQL surface", () => { jest.mocked(CashWalletCutoverRepository).mockReturnValue({ updateConfig } as never) const result = await CashWalletCutoverUpdateMutation.resolve?.( - undefined, + null, { input: { state: "in_progress", 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", + ) + }) +}) From 77d3b5ceb9f61bb7f6129e1544f882e68e1f9b5d Mon Sep 17 00:00:00 2001 From: Forge0x Date: Mon, 1 Jun 2026 11:34:27 -0400 Subject: [PATCH 34/40] fix(cutover): harden operator cutover run (#376) * feat(cutover): add operator dashboard and provisioning tools * fix(cutover): use ibex oauth credentials in local setup * fix(cutover): retry wallet provisioning and refresh stale invoices * fix(cutover): retry ibex rate limits during migration payments * fix(cutover): clarify dashboard run anomalies --- .env | 8 +- DEV.md | 28 +- dev/apollo-federation/supergraph.graphql | 1 + .../Flash GraphQL API/environments/local.bru | 8 +- dev/config/set-overrides.sh | 7 +- dev/setup.sh | 26 +- docker-compose.yml | 5 +- ...-05-26-local-cutover-operator-dashboard.md | 88 ++ ...6-05-28-cutover-dashboard-lazy-balances.md | 77 ++ src/app/cash-wallet-cutover/handlers.ts | 4 + src/app/cash-wallet-cutover/index.ts | 1 + .../cash-wallet-cutover/operator-dashboard.ts | 951 +++++++++++++++++ src/app/cash-wallet-cutover/orchestrator.ts | 3 + .../provision-usdt-wallets.ts | 160 +++ src/app/cash-wallet-cutover/runner.ts | 15 +- .../cash-wallet-cutover/runtime-services.ts | 105 +- src/app/cash-wallet-cutover/state-machine.ts | 8 +- src/app/cash-wallet-cutover/worker.ts | 128 ++- src/scripts/cash-wallet-cutover-dashboard.ts | 988 ++++++++++++++++++ src/scripts/cash-wallet-cutover.ts | 36 + src/services/ibex/client.ts | 53 +- .../migration-state-machine.spec.ts | 10 + .../operator-dashboard.spec.ts | 859 +++++++++++++++ .../provision-usdt-wallets.spec.ts | 235 +++++ .../app/cash-wallet-cutover/runner.spec.ts | 37 + .../runtime-services.spec.ts | 64 +- .../app/cash-wallet-cutover/worker.spec.ts | 174 ++- .../app/wallets/usd-wallet-amount.spec.ts | 9 + 28 files changed, 3974 insertions(+), 114 deletions(-) create mode 100644 docs/plans/2026-05-26-local-cutover-operator-dashboard.md create mode 100644 docs/plans/2026-05-28-cutover-dashboard-lazy-balances.md create mode 100644 src/app/cash-wallet-cutover/operator-dashboard.ts create mode 100644 src/app/cash-wallet-cutover/provision-usdt-wallets.ts create mode 100644 src/scripts/cash-wallet-cutover-dashboard.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts create mode 100644 test/flash/unit/app/cash-wallet-cutover/provision-usdt-wallets.spec.ts diff --git a/.env b/.env index 44699ef99..59076eba4 100644 --- a/.env +++ b/.env @@ -123,8 +123,8 @@ export ERPNEXT_JWT_SECRET="not-so-secret" COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml:docker-compose.local.yml -# Ibex API (used by docker compose; app reads from yaml config) -export IBEX_URL="https://api-sandbox.poweredbyibex.io" -export IBEX_EMAIL="" -export IBEX_PASSWORD="" +# Ibex API — app reads from yaml config, not env vars +# Get sandbox credentials from your team lead +# export IBEX_CLIENT_ID="" +# export IBEX_CLIENT_SECRET="" diff --git a/DEV.md b/DEV.md index 0b17a2e58..05f11208c 100644 --- a/DEV.md +++ b/DEV.md @@ -38,30 +38,11 @@ If you prefer to set things up yourself, or if the setup script fails: ### 1. Environment Variables -The project loads environment variables from `.env` (committed) and `.env.local` (git-ignored, for secrets). - -Create `.env.local` with your Ibex sandbox credentials: - -```bash -echo "export IBEX_EMAIL='your-ibex-email'" >> .env.local -echo "export IBEX_PASSWORD='your-ibex-password'" >> .env.local -``` - -If you use direnv, allow it: - -```bash -direnv allow -``` - -If not using direnv, source the env files manually before running commands: - -```bash -source .env && source .env.local -``` +Flash uses YAML config files. Ibex OAuth2 credentials go in local config overrides, not env vars. ### 2. App Config Overrides -Flash uses YAML config files. The base config is at `dev/config/base-config.yaml`. Secrets and local overrides go in `$CONFIG_PATH/dev-overrides.yaml` (default: `~/.config/flash/dev-overrides.yaml`). +The base config is at `dev/config/base-config.yaml`. Secrets and local overrides go in `$CONFIG_PATH/dev-overrides.yaml` (default: `~/.config/flash/dev-overrides.yaml`). **Option A — Run the interactive script:** @@ -74,8 +55,9 @@ Flash uses YAML config files. The base config is at `dev/config/base-config.yaml ```yaml # ~/.config/flash/dev-overrides.yaml ibex: - email: your-ibex-email - password: your-ibex-password + clientId: your-sandbox-client-id + clientSecret: your-sandbox-client-secret + environment: sandbox ``` Additional overrides you might need: diff --git a/dev/apollo-federation/supergraph.graphql b/dev/apollo-federation/supergraph.graphql index baeee5a52..09c7b3662 100644 --- a/dev/apollo-federation/supergraph.graphql +++ b/dev/apollo-federation/supergraph.graphql @@ -343,6 +343,7 @@ type BridgeWithdrawal amount: String! createdAt: String! currency: String! + failureReason: String id: ID! status: String! } diff --git a/dev/bruno/Flash GraphQL API/environments/local.bru b/dev/bruno/Flash GraphQL API/environments/local.bru index 1cda70a56..1d803e8a3 100644 --- a/dev/bruno/Flash GraphQL API/environments/local.bru +++ b/dev/bruno/Flash GraphQL API/environments/local.bru @@ -1,13 +1,15 @@ vars { flashGraphqlUrl: http://localhost:4002/graphql admin_url: http://localhost:4001/graphql - admin_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJhZG1pbiIsInJvbGVzIjpbIkFjY291bnRzIE1hbmFnZXIiXX0.UOmQR2K6RdS1FVvQbjvSQfoQ-VsTC6Y7x2YAXZImdsA currency: BTC - phone: +16505554322 + phone: +16505554320 code: 000000 - token: walletId: walletIdUsd: c593736e-5a58-42e4-93fa-dc895856c1f1 userEmail: mauriente@gmail.com userFullName: maurientes } +vars:secret [ + admin_token, + token +] diff --git a/dev/config/set-overrides.sh b/dev/config/set-overrides.sh index 18d86797d..d8b0c03aa 100755 --- a/dev/config/set-overrides.sh +++ b/dev/config/set-overrides.sh @@ -7,8 +7,9 @@ mkdir -p "$(dirname "$OUTPUT_FILE")" # Define YAML paths and their descriptions directly in the script declare -a yaml_paths=( - "ibex.email, Email address to Ibex Account" - "ibex.password, Password to Ibex Account" + "ibex.clientId, OAuth2 client ID for the Ibex account" + "ibex.clientSecret, OAuth2 client secret for the Ibex account" + "ibex.environment, Ibex environment: sandbox or production" "ibex.webhook.uri, The URI where Ibex will send payment events" "sendgrid.apiKey, API key to SendGrid email service (from Twilio)" "cashout.email.to, Recipient email address for cashout notifications" @@ -57,4 +58,4 @@ for entry in "${yaml_paths[@]}"; do write_yaml "$path" $value done -echo "YAML file has been written to $OUTPUT_FILE." \ No newline at end of file +echo "YAML file has been written to $OUTPUT_FILE." diff --git a/dev/setup.sh b/dev/setup.sh index 3b001bef7..718b372fa 100755 --- a/dev/setup.sh +++ b/dev/setup.sh @@ -73,24 +73,27 @@ echo "" # ── 5. Configure Ibex credentials ──────────────────── echo "Checking Ibex credentials..." -if [ -f .env.local ] && grep -q "IBEX_PASSWORD" .env.local 2>/dev/null; then +if [ -f .env.local ] && grep -q "IBEX_CLIENT_SECRET" .env.local 2>/dev/null; then info "Ibex credentials found in .env.local" else echo "" - echo "Flash requires Ibex sandbox credentials to connect to the payment backend." + echo "Flash requires Ibex OAuth2 sandbox credentials to connect to the payment backend." echo "If you don't have credentials, ask your team lead." echo "" - read -rp "Ibex email (or press Enter to skip): " IBEX_EMAIL - if [ -n "$IBEX_EMAIL" ]; then - read -rsp "Ibex password: " IBEX_PASSWORD + read -rp "Ibex client ID (or press Enter to skip): " IBEX_CLIENT_ID + if [ -n "$IBEX_CLIENT_ID" ]; then + read -rsp "Ibex client secret: " IBEX_CLIENT_SECRET echo "" + read -rp "Ibex environment [sandbox]: " IBEX_ENVIRONMENT + IBEX_ENVIRONMENT="${IBEX_ENVIRONMENT:-sandbox}" cat > .env.local << EOF -export IBEX_EMAIL='${IBEX_EMAIL}' -export IBEX_PASSWORD='${IBEX_PASSWORD}' +export IBEX_CLIENT_ID='${IBEX_CLIENT_ID}' +export IBEX_CLIENT_SECRET='${IBEX_CLIENT_SECRET}' +export IBEX_ENVIRONMENT='${IBEX_ENVIRONMENT}' EOF info "Credentials saved to .env.local (git-ignored)" else - warn "Skipped — you'll need to create .env.local with IBEX_EMAIL and IBEX_PASSWORD before starting" + warn "Skipped — you'll need to create .env.local with IBEX_CLIENT_ID and IBEX_CLIENT_SECRET before starting" fi fi @@ -109,11 +112,12 @@ else if [ -f .env.local ]; then source .env.local 2>/dev/null || true fi - if [ -n "${IBEX_EMAIL:-}" ] && [ -n "${IBEX_PASSWORD:-}" ]; then + if [ -n "${IBEX_CLIENT_ID:-}" ] && [ -n "${IBEX_CLIENT_SECRET:-}" ]; then cat > "$OVERRIDES" << EOF ibex: - email: ${IBEX_EMAIL} - password: ${IBEX_PASSWORD} + clientId: ${IBEX_CLIENT_ID} + clientSecret: ${IBEX_CLIENT_SECRET} + environment: ${IBEX_ENVIRONMENT:-sandbox} EOF info "Generated $OVERRIDES with Ibex credentials" else diff --git a/docker-compose.yml b/docker-compose.yml index 83fd574a6..690a13c30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,9 +79,8 @@ services: - REDIS_TYPE=standalone - REDIS_0_DNS=redis - REDIS_0_PORT=6378 - - IBEX_URL=${IBEX_URL} - - IBEX_EMAIL=${IBEX_EMAIL} - - IBEX_PASSWORD=${IBEX_PASSWORD} + - IBEX_CLIENT_ID=${IBEX_CLIENT_ID:-} + - IBEX_CLIENT_SECRET=${IBEX_CLIENT_SECRET:-} price-history: image: docker.io/lnflash/price-history:edge # image: us.gcr.io/galoy-org/price-history:edge diff --git a/docs/plans/2026-05-26-local-cutover-operator-dashboard.md b/docs/plans/2026-05-26-local-cutover-operator-dashboard.md new file mode 100644 index 000000000..f3a40d326 --- /dev/null +++ b/docs/plans/2026-05-26-local-cutover-operator-dashboard.md @@ -0,0 +1,88 @@ +# Local Cash Wallet Cutover Operator Dashboard Plan + +## Goal + +Build a local-only dashboard at `http://localhost:3450` that lets an operator monitor the 60 cutover test accounts and their cash wallets through each cutover state. The dashboard must use raw backend repositories and wallet balances, not the GraphQL presentation layer, because public wallet queries intentionally hide either USD or USDT depending on client capability and cutover state. + +## Constraints + +- Read-only: the dashboard must not mutate accounts, wallets, balances, migrations, or cutover config. +- Local-only: bind to localhost and serve a static browser UI plus a JSON snapshot endpoint. +- Source of truth: + - account manifests from `/tmp/eng345usd-20260526115410-local-backend-accounts.json` and `/tmp/eng345usdonly-20260526195758-accounts.json` + - raw Mongo repositories for accounts, wallets, and cash-wallet-cutover state + - `Wallets.getBalanceForWallet` for live balances +- Expected population: + - 60 accounts + - 110 current wallets before `provision-usdt-wallets` + - 120 target wallets after every USD-only account receives USDT +- No production API behavior should change. + +## Design + +1. Add a pure dashboard snapshot builder under `src/app/cash-wallet-cutover/operator-dashboard.ts`. + - Load account IDs from manifest records. + - Fetch each account by id and all raw wallets by account id. + - Identify checking USD and checking USDT wallets directly from raw wallet records. + - Fetch live balances for each cash wallet. + - Fetch cutover config and per-account migration records when config has `runId`. + - Derive summary totals and account-level anomaly badges. + +2. Add focused unit tests under `test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts`. + - Verify wallet grouping and current/target wallet counts. + - Verify missing-USDT detection for USD-only accounts. + - Verify funded USD-only count from USD cent balances. + - Verify migration status counts and anomaly flags. + +3. Add a thin local HTTP script under `src/scripts/cash-wallet-cutover-dashboard.ts`. + - Accept `--port`, `--configPath`, optional `--run-id`, optional `--cutover-version`, optional `--expected-accounts`, and repeated `--manifest` arguments. + - Default port: `3450`. + - Bind explicitly to `127.0.0.1`. + - Default manifests: + - `/tmp/eng345usd-20260526115410-local-backend-accounts.json` + - `/tmp/eng345usdonly-20260526195758-accounts.json` + - Routes: + - `GET /` static dashboard HTML/CSS/JS + - `GET /api/snapshot` live JSON snapshot + - Poll snapshot every 10 seconds from the browser, with a manual refresh button. + - Cache server-side snapshots for a short TTL so browser refreshes do not hammer IBEX. + +4. UI content. + - Compact operational layout. + - Summary strip for cutover state, run id/version, accounts, wallets current/target, missing USDT, funded USD-only, USD total, USDT total, and anomalies. + - Filters for anomalies, funded only, missing USDT, nonzero USD, nonzero USDT, and migration status. + - Per-account table with phone, account id, default wallet, USD wallet/balance, USDT wallet/balance, migration status, and anomaly badges. + - Color coding: + - green expected + - yellow pending/missing-but-expected + - red broken or dangerous anomalies + +5. Verification. + - Run the new unit test first and confirm it fails before implementation. + - Implement the snapshot builder and dashboard script. + - Run the focused unit test. + - Run TypeScript check for touched files through the repo build/test path where practical. + - Start the dashboard on `localhost:3450` and verify: + - `GET /` returns HTML + - `GET /api/snapshot` returns JSON + - dashboard process is listening on port `3450` + +## Risks + +- `Wallets.getBalanceForWallet` returns currency-specific amount shapes; the snapshot builder must normalize cautiously and preserve raw balance display for unknown shapes. +- Account manifest shape may differ between the 50-account and 10-account batches. The loader should accept common `accountId`, `account.id`, `id`, `phone`, and `username` fields and fail with clear errors if no account id can be found. +- Prepared migration records may exist before cutover config has `runId`. The dashboard must accept explicit `--run-id` and `--cutover-version` and use them for migration lookup when provided. +- The manifest loader must support the actual top-level `accounts` and `created` arrays, reject duplicate account IDs, and by default validate that 60 accounts were loaded. +- Balance reads can be expensive across 110-120 wallets. The local server must cache snapshots with a short TTL, capture per-wallet balance errors, and avoid making every browser poll trigger a full IBEX balance sweep. +- Running through `ts-node` may need `transpile-only` and `tsconfig-paths/register`, matching the earlier local script behavior. +- Large account lists should remain cheap: 60 accounts and 120 wallets is small, so simple sequential fetches are acceptable for operator clarity. + +## Dual-Model Review Notes + +- Reviewer 1 required explicit migration lookup arguments so PRE/prepared migration records remain visible. Plan updated. +- Reviewer 1 required explicit currency on balance reads. Implementation will always pass `wallet.currency`. +- Reviewer 1 required support for both manifest shapes and default count validation. Plan updated. +- Reviewer 1 required loopback-only binding. Plan updated. +- Reviewer 1 recommended keeping the dashboard out of GraphQL/production HTTP routes. The module will only be consumed by the local script and unit tests. +- Reviewer 2 required server-side snapshot caching and a slower poll interval to avoid roughly 55-60 IBEX calls/sec. Plan updated. +- Reviewer 2 required dependency injection for the snapshot builder. The builder will take manifests, repos, cutover repo, and `getBalanceForWallet`. diff --git a/docs/plans/2026-05-28-cutover-dashboard-lazy-balances.md b/docs/plans/2026-05-28-cutover-dashboard-lazy-balances.md new file mode 100644 index 000000000..4a99b5c86 --- /dev/null +++ b/docs/plans/2026-05-28-cutover-dashboard-lazy-balances.md @@ -0,0 +1,77 @@ +# Cash Wallet Cutover Dashboard Lazy Balances Implementation Plan + +> Implementation note: keep this local dashboard read-only and preserve the existing IBEX balance throttle. + +**Goal:** Make the local Cash Wallet Cutover Dashboard render readiness and account structure immediately while IBEX wallet balances hydrate lazily in the background. + +**Architecture:** Split dashboard data into a fast structural snapshot and a throttled balance refresh path. The structural snapshot reads Mongo, migrations, and preflight state, but does not call IBEX. A local in-memory balance cache and single-worker queue refresh wallet balances with the existing throttle and expose cached values through a lazy `/api/balances` endpoint. + +**Tech Stack:** TypeScript, Express, existing Flash repositories, Jest unit tests, vanilla browser JavaScript. + +--- + +## Task 1: Structural Snapshot Mode + +**Files:** +- Modify: `src/app/cash-wallet-cutover/operator-dashboard.ts` +- Test: `test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts` + +**Steps:** +1. Add a failing unit test proving `buildCashWalletCutoverOperatorSnapshot` can produce rows without calling `getBalanceForWallet` when balance mode is disabled. +2. Add a placeholder balance formatter that returns `display: "loading"`, zero minor units, and a `status` field for balance hydration. +3. Thread a `balanceMode` option through the snapshot builder with default live behavior preserved for existing callers. +4. Verify existing live-balance tests still pass. + +## Task 2: Balance Cache And Queue + +**Files:** +- Modify: `src/scripts/cash-wallet-cutover-dashboard.ts` + +**Steps:** +1. Add a focused unit-testable helper only if it can stay small; otherwise keep the cache local to the script. +2. Add an in-memory `Map` and FIFO queue with de-duping. +3. Keep the existing one-wallet-at-a-time throttle and retry behavior inside the queue worker. +4. Add cache statuses for the first pass: `loading`, `fresh`, and `error`. + +## Task 3: Lazy Balance Endpoints + +**Files:** +- Modify: `src/scripts/cash-wallet-cutover-dashboard.ts` + +**Steps:** +1. Change `/api/snapshot` to build structural snapshots only. +2. Add `GET /api/balances?walletIds=...&refresh=0|1`, returning cached balance payloads immediately and enqueueing requested wallet IDs. +3. Add `GET /api/balance-status` for queue length, refreshed count, loading count, and last sweep timestamp. +4. Keep `?refresh=1` on `/api/snapshot` as structural refresh only, not a full IBEX balance sweep. + +## Task 4: Browser Lazy Hydration + +**Files:** +- Modify: `src/scripts/cash-wallet-cutover-dashboard.ts` + +**Steps:** +1. Render the structural snapshot immediately. +2. Collect wallet IDs from the structural snapshot and hand them to `/api/balances` without blocking first render. +3. Poll `/api/balances` and update the row objects in memory as balances arrive. +4. Show status text such as `Balances 24/192 refreshed` instead of blocking `Loading...`. +5. Ensure filters continue to work while balances are loading. + +## Task 5: Verification + +**Commands:** +1. Run focused unit tests: + `PATH=/Users/dread/.nvm/versions/node/v20.20.0/bin:$PATH TEST=test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts yarn test:unit` +2. Restart local dashboard: + `tmux kill-session -t cutover-dashboard` then start the existing dashboard command. +3. Verify: + - `GET /` returns HTML. + - `GET /api/snapshot?refresh=1` returns quickly and reports `watchlistAccounts: 60`. + - `GET /api/balances` returns immediately with cached/loading payloads. + - `GET /api/balance-status` shows queue progress. + +## Constraints + +- Do not increase IBEX request rate. +- Do not mutate accounts, wallets, migrations, or cutover config. +- Keep dashboard local-only on `127.0.0.1`. +- Avoid broad refactors and generated-file churn. diff --git a/src/app/cash-wallet-cutover/handlers.ts b/src/app/cash-wallet-cutover/handlers.ts index 20d32ff3f..b09756c1d 100644 --- a/src/app/cash-wallet-cutover/handlers.ts +++ b/src/app/cash-wallet-cutover/handlers.ts @@ -114,6 +114,8 @@ export const createCashWalletMigrationStepHandlers = ({ migration, migrationsRepo, paymentService: services.paymentService, + invoiceService: services.invoiceService, + now: services.now, }), balance_move_sending: (migration) => markCashWalletMigrationBalanceMoveSent({ migration, migrationsRepo }), @@ -147,6 +149,8 @@ export const createCashWalletMigrationStepHandlers = ({ migration, migrationsRepo, paymentService: services.paymentService, + invoiceService: services.invoiceService, + now: services.now, treasuryWalletId, }) }, diff --git a/src/app/cash-wallet-cutover/index.ts b/src/app/cash-wallet-cutover/index.ts index eae3f76bd..67d632d84 100644 --- a/src/app/cash-wallet-cutover/index.ts +++ b/src/app/cash-wallet-cutover/index.ts @@ -21,3 +21,4 @@ export * from "./runtime-services" export * from "./orchestrator" export * from "./lifecycle" export * from "./preview" +export * from "./provision-usdt-wallets" diff --git a/src/app/cash-wallet-cutover/operator-dashboard.ts b/src/app/cash-wallet-cutover/operator-dashboard.ts new file mode 100644 index 000000000..63d4f8faa --- /dev/null +++ b/src/app/cash-wallet-cutover/operator-dashboard.ts @@ -0,0 +1,951 @@ +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import { CashWalletCutoverDiscovery } from "./discovery" +import { CashWalletCutoverPreflightReport } from "./preflight" + +export type CashWalletCutoverOperatorManifestAccount = { + batchRunId?: string + index?: number + phone?: string + username?: string + accountId: AccountId + accountUuid?: AccountUuid + expectedUsdWalletId?: WalletId + expectedUsdtWalletId?: WalletId +} + +export type OperatorBalanceStatus = "loading" | "fresh" | "error" + +export type OperatorBalance = { + currency: WalletCurrency + display: string + minorUnits: string + minorUnitsNumber: number + status?: OperatorBalanceStatus + error?: string +} + +export type OperatorWallet = { + id: WalletId + currency: WalletCurrency + expected: boolean + balance: OperatorBalance +} + +export type OperatorAccount = { + batchRunId?: string + index?: number + phone?: string + username?: string + accountId: AccountId + accountUuid?: AccountUuid + expectedUsdWalletId?: WalletId + expectedUsdtWalletId?: WalletId + watchlisted: boolean + defaultWalletId?: WalletId + defaultWalletCurrency?: WalletCurrency + walletCount: number + usdWallets: OperatorWallet[] + usdtWallets: OperatorWallet[] + migrationStatus: CashWalletMigrationStatus | "none" + migrationUpdatedAt?: string + cutoverBalanceAudit?: OperatorCutoverBalanceAudit + anomalies: string[] +} + +export type OperatorCutoverBalanceAudit = { + status: "loading" | "shortfall" | "verified" + sourceUsdCents: number + expectedMinimumUsdtMicros: number + destinationStartingBalanceUsdtMicros: number + currentDestinationBalanceUsdtMicros: number + finalDeltaUsdtMicros: number + roundingSubsidyUsdtMicros: number + shortfallUsdtMicros: number +} + +export type OperatorTreasuryAccount = { + accountId: AccountId + accountUuid?: AccountUuid + role?: string + defaultWalletId?: WalletId + defaultWalletCurrency?: WalletCurrency + walletCount: number + usdWallets: OperatorWallet[] + usdtWallets: OperatorWallet[] + anomalies: string[] +} + +export type OperatorTreasurySummary = { + accounts: number + wallets: number + usdTotalCents: number + usdtTotalMicros: number +} + +export type OperatorReconciliationSummary = { + customerTotalCents: number + treasuryTotalCents: number + systemTotalCents: number +} + +export type CashWalletCutoverOperatorSnapshot = { + generatedAt: string + cutover: { + state: CashWalletCutoverState + cutoverVersion: number + runId?: string + updatedAt?: string + } + preflight?: CashWalletCutoverPreflightReport + summary: { + accounts: number + wallets: { + current: number + target: number + usd: number + usdt: number + missingUsdt: number + } + fundedUsdOnlyAccounts: number + usdTotalCents: number + usdtTotalMicros: number + anomalies: number + watchlistAnomalies: number + canStart: boolean + blockers: number + watchlistAccounts: number + migrationStatuses: Record + } + accounts: OperatorAccount[] + treasury: { + accounts: OperatorTreasuryAccount[] + summary: OperatorTreasurySummary + } + reconciliation: OperatorReconciliationSummary +} + +type AccountManifestRecord = { + index?: number + phone?: string + username?: string + accountId?: string + account?: { id?: string } + id?: string + accountUuid?: string + usdWalletId?: string + usdtWalletId?: string +} + +type ManifestShape = { + runId?: string + accounts?: AccountManifestRecord[] + created?: AccountManifestRecord[] +} + +type BuildSnapshotArgs = { + manifestAccounts: CashWalletCutoverOperatorManifestAccount[] + discoveredAccounts?: CashWalletCutoverDiscovery[] + accountsRepo: Pick + walletsRepo: Pick + migrationsRepo: { + getConfig: () => Promise + findMigrationByAccountId: (args: { + accountId: AccountId + cutoverVersion: number + runId: string + }) => Promise + } + getBalanceForWallet: (args: { + walletId: WalletId + currency?: WalletCurrency + }) => Promise + migrationLookup?: { + cutoverVersion: number + runId: string + } + preflightReport?: CashWalletCutoverPreflightReport + balanceReadAttempts?: number + balanceMode?: "live" | "structural" + treasuryAccountIds?: AccountId[] + now?: Date +} + +export const parseCashWalletCutoverOperatorManifest = ( + input: ManifestShape | AccountManifestRecord[], +): CashWalletCutoverOperatorManifestAccount[] => { + const batchRunId = Array.isArray(input) ? undefined : input.runId + const records = Array.isArray(input) ? input : (input.accounts ?? input.created ?? []) + + const accounts = records.map((record) => { + const accountId = record.accountId ?? record.account?.id ?? record.id + if (!accountId) { + throw new Error("Operator manifest record is missing accountId") + } + + return { + batchRunId, + index: record.index, + phone: record.phone, + username: record.username, + accountId: accountId as AccountId, + accountUuid: record.accountUuid as AccountUuid | undefined, + expectedUsdWalletId: record.usdWalletId as WalletId | undefined, + expectedUsdtWalletId: record.usdtWalletId as WalletId | undefined, + } + }) + + const seen = new Set() + for (const account of accounts) { + if (seen.has(account.accountId)) { + throw new Error(`Duplicate operator manifest accountId: ${account.accountId}`) + } + seen.add(account.accountId) + } + + return accounts +} + +const csvHeaders = [ + "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", +] + +const csvValue = (value: unknown): string => { + if (value === undefined || value === null) return "" + const text = String(value) + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text +} + +const walletIdsCsv = (wallets: OperatorWallet[]) => + wallets.map((wallet) => wallet.id).join(";") + +const walletBalanceDisplaysCsv = (wallets: OperatorWallet[]) => + wallets.map((wallet) => wallet.balance.display).join(";") + +const walletBalanceMinorUnitsCsv = (wallets: OperatorWallet[]) => + wallets.map((wallet) => wallet.balance.minorUnits).join(";") + +const walletBalanceStatusesCsv = (wallets: OperatorWallet[]) => + wallets.map((wallet) => wallet.balance.status ?? "").join(";") + +const cutoverBalanceAuditCsv = (audit?: OperatorCutoverBalanceAudit) => { + if (!audit) return "" + return [ + `status=${audit.status}`, + `expectedMinimumUsdtMicros=${audit.expectedMinimumUsdtMicros}`, + `finalDeltaUsdtMicros=${audit.finalDeltaUsdtMicros}`, + `roundingSubsidyUsdtMicros=${audit.roundingSubsidyUsdtMicros}`, + `shortfallUsdtMicros=${audit.shortfallUsdtMicros}`, + ].join(";") +} + +export const formatCashWalletCutoverOperatorSnapshotCsv = ( + snapshot: CashWalletCutoverOperatorSnapshot, +): string => { + const rows = snapshot.accounts.map((account) => + [ + snapshot.generatedAt, + snapshot.cutover.state, + snapshot.cutover.cutoverVersion, + snapshot.cutover.runId, + snapshot.cutover.updatedAt, + account.watchlisted, + account.batchRunId, + account.index, + account.phone, + account.username, + account.accountId, + account.accountUuid, + account.defaultWalletId, + account.defaultWalletCurrency, + account.expectedUsdWalletId, + account.expectedUsdtWalletId, + account.walletCount, + walletIdsCsv(account.usdWallets), + walletBalanceDisplaysCsv(account.usdWallets), + walletBalanceMinorUnitsCsv(account.usdWallets), + walletBalanceStatusesCsv(account.usdWallets), + walletIdsCsv(account.usdtWallets), + walletBalanceDisplaysCsv(account.usdtWallets), + walletBalanceMinorUnitsCsv(account.usdtWallets), + walletBalanceStatusesCsv(account.usdtWallets), + account.migrationStatus, + account.migrationUpdatedAt, + cutoverBalanceAuditCsv(account.cutoverBalanceAudit), + account.anomalies.join(";"), + ].map(csvValue), + ) + + return [csvHeaders, ...rows].map((row) => row.join(",")).join("\n") +} + +const describeError = (error: Error): string => { + const message = error.message?.split("\n")[0]?.trim() + if (message) return message + + if (error.name && error.name !== "Error") return error.name + + const rendered = String(error) + return rendered && rendered !== "[object Object]" ? rendered : "Unknown error" +} + +const balanceError = (wallet: Wallet, error: Error): OperatorBalance => ({ + currency: wallet.currency, + display: "error", + minorUnits: "0", + minorUnitsNumber: 0, + status: "error", + error: describeError(error), +}) + +export const formatOperatorBalance = ( + wallet: Wallet, + balance: USDAmount | USDTAmount | ApplicationError, +): OperatorBalance => { + if (balance instanceof Error) return balanceError(wallet, balance) + + if (wallet.currency === WalletCurrency.Usdt && balance instanceof USDTAmount) { + const micros = balance.asSmallestUnits() + return { + currency: WalletCurrency.Usdt, + display: `${(Number(micros) / 1_000_000).toFixed(2)} USDT`, + minorUnits: micros, + minorUnitsNumber: Number(micros), + status: "fresh", + } + } + + if (wallet.currency === WalletCurrency.Usd && balance instanceof USDAmount) { + const cents = balance.asCents() + return { + currency: WalletCurrency.Usd, + display: `$${balance.asDollars(2)}`, + minorUnits: cents, + minorUnitsNumber: Number(cents), + status: "fresh", + } + } + + return { + currency: wallet.currency, + display: "unexpected currency", + minorUnits: "0", + minorUnitsNumber: 0, + status: "error", + error: `Expected ${wallet.currency} balance`, + } +} + +const loadingBalance = (wallet: Wallet): OperatorBalance => ({ + currency: wallet.currency, + display: "loading", + minorUnits: "0", + minorUnitsNumber: 0, + status: "loading", +}) + +const summarizeWallet = async ({ + wallet, + expectedWalletId, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, +}: { + wallet: Wallet + expectedWalletId?: WalletId + getBalanceForWallet: BuildSnapshotArgs["getBalanceForWallet"] + balanceReadAttempts: number + balanceMode: BuildSnapshotArgs["balanceMode"] +}): Promise => { + if (balanceMode === "structural") { + return { + id: wallet.id, + currency: wallet.currency, + expected: expectedWalletId === undefined || expectedWalletId === wallet.id, + balance: loadingBalance(wallet), + } + } + + let balance: USDAmount | USDTAmount | ApplicationError = new Error( + "Balance read was not attempted", + ) as ApplicationError + + for (let attempt = 0; attempt < balanceReadAttempts; attempt++) { + balance = await getBalanceForWallet({ + walletId: wallet.id, + currency: wallet.currency, + }) + if (!(balance instanceof Error)) break + } + + return { + id: wallet.id, + currency: wallet.currency, + expected: expectedWalletId === undefined || expectedWalletId === wallet.id, + balance: formatOperatorBalance(wallet, balance), + } +} + +const increment = (record: Record, key: string) => { + record[key] = (record[key] ?? 0) + 1 +} + +const accountsForDashboard = ({ + manifestAccounts, + discoveredAccounts, +}: { + manifestAccounts: CashWalletCutoverOperatorManifestAccount[] + discoveredAccounts?: CashWalletCutoverDiscovery[] +}): OperatorAccountInput[] => { + const manifestByAccountId = new Map( + manifestAccounts.map((account) => [account.accountId, account]), + ) + + if (!discoveredAccounts) { + return manifestAccounts.map((account) => ({ ...account, watchlisted: true })) + } + + const merged = discoveredAccounts.map((discovery) => { + const manifestAccount = manifestByAccountId.get(discovery.accountId) + return { + ...manifestAccount, + accountId: discovery.accountId, + accountUuid: manifestAccount?.accountUuid ?? discovery.accountUuid, + expectedUsdWalletId: + manifestAccount?.expectedUsdWalletId ?? discovery.legacyUsdWalletId, + expectedUsdtWalletId: + manifestAccount?.expectedUsdtWalletId ?? discovery.destinationUsdtWalletId, + watchlisted: manifestAccount !== undefined, + } + }) + + const discoveredAccountIds = new Set(merged.map((account) => account.accountId)) + const missingManifestAccounts = manifestAccounts + .filter((account) => !discoveredAccountIds.has(account.accountId)) + .map((account) => ({ ...account, watchlisted: true })) + + return [...merged, ...missingManifestAccounts] +} + +type OperatorAccountInput = CashWalletCutoverOperatorManifestAccount & { + watchlisted: boolean +} + +const usdTotalCentsForAccounts = ( + accounts: Array<{ usdWallets: OperatorWallet[] }>, +): number => + accounts.reduce( + (sum, account) => + sum + + account.usdWallets.reduce( + (walletSum, wallet) => walletSum + wallet.balance.minorUnitsNumber, + 0, + ), + 0, + ) + +const usdtTotalMicrosForAccounts = ( + accounts: Array<{ usdtWallets: OperatorWallet[] }>, +): number => + accounts.reduce( + (sum, account) => + sum + + account.usdtWallets.reduce( + (walletSum, wallet) => walletSum + wallet.balance.minorUnitsNumber, + 0, + ), + 0, + ) + +const parseIntegerAmount = (value?: string): number | undefined => { + if (value === undefined || !/^\d+$/.test(value)) return undefined + return Number(value) +} + +const computeCutoverBalanceAudit = ({ + migration, + usdtWallets, +}: { + migration?: CashWalletMigration | null + usdtWallets: OperatorWallet[] +}): OperatorCutoverBalanceAudit | undefined => { + if (!migration || migration.status !== "complete") return undefined + + const sourceUsdCents = parseIntegerAmount(migration.sourceBalanceUsdCents) + const expectedMinimumUsdtMicros = parseIntegerAmount( + migration.destinationAmountUsdtMicros, + ) + const destinationStartingBalanceUsdtMicros = parseIntegerAmount( + migration.destinationStartingBalanceUsdtMicros, + ) + + if ( + sourceUsdCents === undefined || + expectedMinimumUsdtMicros === undefined || + destinationStartingBalanceUsdtMicros === undefined + ) { + return undefined + } + + const destinationWallet = usdtWallets.find( + (wallet) => wallet.id === migration.destinationUsdtWalletId, + ) + if (!destinationWallet) return undefined + + if (destinationWallet.balance.status === "loading") { + return { + status: "loading", + sourceUsdCents, + expectedMinimumUsdtMicros, + destinationStartingBalanceUsdtMicros, + currentDestinationBalanceUsdtMicros: 0, + finalDeltaUsdtMicros: 0, + roundingSubsidyUsdtMicros: 0, + shortfallUsdtMicros: 0, + } + } + + const currentDestinationBalanceUsdtMicros = + destinationWallet.balance.minorUnitsNumber + const finalDeltaUsdtMicros = Math.max( + 0, + currentDestinationBalanceUsdtMicros - destinationStartingBalanceUsdtMicros, + ) + const shortfallUsdtMicros = Math.max( + 0, + expectedMinimumUsdtMicros - finalDeltaUsdtMicros, + ) + const roundingSubsidyUsdtMicros = Math.max( + 0, + finalDeltaUsdtMicros - expectedMinimumUsdtMicros, + ) + + return { + status: shortfallUsdtMicros > 0 ? "shortfall" : "verified", + sourceUsdCents, + expectedMinimumUsdtMicros, + destinationStartingBalanceUsdtMicros, + currentDestinationBalanceUsdtMicros, + finalDeltaUsdtMicros, + roundingSubsidyUsdtMicros, + shortfallUsdtMicros, + } +} + +export const refreshOperatorAccountCutoverBalanceAudit = < + T extends { + expectedUsdtWalletId?: WalletId + usdtWallets: OperatorWallet[] + cutoverBalanceAudit?: OperatorCutoverBalanceAudit + }, +>( + account: T, +): T => { + const audit = account.cutoverBalanceAudit + if (!audit) return account + + const destinationWallet = + account.usdtWallets.find((wallet) => wallet.id === account.expectedUsdtWalletId) ?? + account.usdtWallets.find((wallet) => wallet.expected) ?? + account.usdtWallets[0] + if (!destinationWallet) return account + + if (destinationWallet.balance.status === "loading") { + return { + ...account, + cutoverBalanceAudit: { + ...audit, + status: "loading", + currentDestinationBalanceUsdtMicros: 0, + finalDeltaUsdtMicros: 0, + roundingSubsidyUsdtMicros: 0, + shortfallUsdtMicros: 0, + }, + } + } + + const currentDestinationBalanceUsdtMicros = + destinationWallet.balance.minorUnitsNumber + const finalDeltaUsdtMicros = Math.max( + 0, + currentDestinationBalanceUsdtMicros - + audit.destinationStartingBalanceUsdtMicros, + ) + const shortfallUsdtMicros = Math.max( + 0, + audit.expectedMinimumUsdtMicros - finalDeltaUsdtMicros, + ) + const roundingSubsidyUsdtMicros = Math.max( + 0, + finalDeltaUsdtMicros - audit.expectedMinimumUsdtMicros, + ) + + return { + ...account, + cutoverBalanceAudit: { + ...audit, + status: shortfallUsdtMicros > 0 ? "shortfall" : "verified", + currentDestinationBalanceUsdtMicros, + finalDeltaUsdtMicros, + roundingSubsidyUsdtMicros, + shortfallUsdtMicros, + }, + } +} + +const combinedTotalCents = ({ + usdTotalCents, + usdtTotalMicros, +}: { + usdTotalCents: number + usdtTotalMicros: number +}) => usdTotalCents + usdtTotalMicros / 10_000 + +const treasurySummary = ( + accounts: OperatorTreasuryAccount[], +): OperatorTreasurySummary => ({ + accounts: accounts.length, + wallets: accounts.reduce((sum, account) => sum + account.walletCount, 0), + usdTotalCents: usdTotalCentsForAccounts(accounts), + usdtTotalMicros: usdtTotalMicrosForAccounts(accounts), +}) + +const reconciliationSummary = ({ + customerUsdTotalCents, + customerUsdtTotalMicros, + treasury, +}: { + customerUsdTotalCents: number + customerUsdtTotalMicros: number + treasury: OperatorTreasurySummary +}): OperatorReconciliationSummary => { + const customerTotalCents = combinedTotalCents({ + usdTotalCents: customerUsdTotalCents, + usdtTotalMicros: customerUsdtTotalMicros, + }) + const treasuryTotalCents = combinedTotalCents({ + usdTotalCents: treasury.usdTotalCents, + usdtTotalMicros: treasury.usdtTotalMicros, + }) + return { + customerTotalCents, + treasuryTotalCents, + systemTotalCents: customerTotalCents + treasuryTotalCents, + } +} + +const summarizeTreasuryAccount = async ({ + accountId, + accountsRepo, + walletsRepo, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, +}: { + accountId: AccountId + accountsRepo: BuildSnapshotArgs["accountsRepo"] + walletsRepo: BuildSnapshotArgs["walletsRepo"] + getBalanceForWallet: BuildSnapshotArgs["getBalanceForWallet"] + balanceReadAttempts: number + balanceMode: BuildSnapshotArgs["balanceMode"] +}): Promise => { + const account = await accountsRepo.findById(accountId) + if (account instanceof Error) { + return { + accountId, + walletCount: 0, + usdWallets: [], + usdtWallets: [], + anomalies: ["missing_account"], + } + } + + const rawWallets = await walletsRepo.listByAccountId(account.id) + if (rawWallets instanceof Error) throw rawWallets + + const cashWallets = rawWallets.filter((wallet) => wallet.type === WalletType.Checking) + const usdWalletsRaw = cashWallets.filter( + (wallet) => wallet.currency === WalletCurrency.Usd, + ) + const usdtWalletsRaw = cashWallets.filter( + (wallet) => wallet.currency === WalletCurrency.Usdt, + ) + const defaultWallet = cashWallets.find((wallet) => wallet.id === account.defaultWalletId) + + const [usdWallets, usdtWallets] = await Promise.all([ + Promise.all( + usdWalletsRaw.map((wallet) => + summarizeWallet({ + wallet, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ), + Promise.all( + usdtWalletsRaw.map((wallet) => + summarizeWallet({ + wallet, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ), + ]) + + return { + accountId: account.id, + accountUuid: account.uuid, + role: account.role ?? "funder", + defaultWalletId: account.defaultWalletId, + defaultWalletCurrency: defaultWallet?.currency, + walletCount: rawWallets.length, + usdWallets, + usdtWallets, + anomalies: [...usdWallets, ...usdtWallets].some( + (wallet) => wallet.balance.error !== undefined, + ) + ? ["balance_error"] + : [], + } +} + +export const buildCashWalletCutoverOperatorSnapshot = async ({ + manifestAccounts, + discoveredAccounts, + accountsRepo, + walletsRepo, + migrationsRepo, + getBalanceForWallet, + migrationLookup, + preflightReport, + balanceReadAttempts = 1, + balanceMode = "live", + treasuryAccountIds = [], + now = new Date(), +}: BuildSnapshotArgs): Promise => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) throw config + const lookup = + migrationLookup ?? + (config.runId + ? { + cutoverVersion: config.cutoverVersion, + runId: config.runId, + } + : undefined) + + const accounts: OperatorAccount[] = [] + const migrationStatuses: Record = {} + const operatorAccounts = accountsForDashboard({ manifestAccounts, discoveredAccounts }) + + for (const dashboardAccount of operatorAccounts) { + const anomalies: string[] = [] + const account = await accountsRepo.findById(dashboardAccount.accountId) + if (account instanceof Error) { + increment(migrationStatuses, "none") + accounts.push({ + ...dashboardAccount, + walletCount: 0, + usdWallets: [], + usdtWallets: [], + migrationStatus: "none", + anomalies: ["missing_account"], + }) + continue + } + + const rawWallets = await walletsRepo.listByAccountId(account.id) + if (rawWallets instanceof Error) throw rawWallets + + const cashWallets = rawWallets.filter((wallet) => wallet.type === WalletType.Checking) + const usdWalletsRaw = cashWallets.filter( + (wallet) => wallet.currency === WalletCurrency.Usd, + ) + const usdtWalletsRaw = cashWallets.filter( + (wallet) => wallet.currency === WalletCurrency.Usdt, + ) + const defaultWallet = cashWallets.find( + (wallet) => wallet.id === account.defaultWalletId, + ) + + if (usdWalletsRaw.length === 0) anomalies.push("missing_usd") + if (usdtWalletsRaw.length === 0) anomalies.push("missing_usdt") + if (usdWalletsRaw.length > 1) anomalies.push("duplicate_usd") + if (usdtWalletsRaw.length > 1) anomalies.push("duplicate_usdt") + if (!defaultWallet) anomalies.push("default_not_cash") + + const [usdWallets, usdtWallets] = await Promise.all([ + Promise.all( + usdWalletsRaw.map((wallet) => + summarizeWallet({ + wallet, + expectedWalletId: dashboardAccount.expectedUsdWalletId, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ), + Promise.all( + usdtWalletsRaw.map((wallet) => + summarizeWallet({ + wallet, + expectedWalletId: dashboardAccount.expectedUsdtWalletId, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ), + ]) + + if ( + [...usdWallets, ...usdtWallets].some((wallet) => wallet.balance.error !== undefined) + ) { + anomalies.push("balance_error") + } + if ([...usdWallets, ...usdtWallets].some((wallet) => !wallet.expected)) { + anomalies.push("unexpected_wallet_id") + } + + let migrationStatus: CashWalletMigrationStatus | "none" = "none" + let migrationUpdatedAt: string | undefined + let migration: CashWalletMigration | null = null + if (lookup) { + const migrationResult = await migrationsRepo.findMigrationByAccountId({ + accountId: account.id, + cutoverVersion: lookup.cutoverVersion, + runId: lookup.runId, + }) + if (migrationResult instanceof Error) throw migrationResult + migration = migrationResult + if (migration) { + migrationStatus = migration.status + migrationUpdatedAt = migration.updatedAt?.toISOString() + if (migration.status === "failed") anomalies.push("migration_failed") + if (migration.status === "requires_operator_review") { + anomalies.push("migration_requires_review") + } + } + } + increment(migrationStatuses, migrationStatus) + + accounts.push({ + ...dashboardAccount, + accountUuid: account.uuid ?? dashboardAccount.accountUuid, + defaultWalletId: account.defaultWalletId, + defaultWalletCurrency: defaultWallet?.currency, + walletCount: rawWallets.length, + usdWallets, + usdtWallets, + migrationStatus, + migrationUpdatedAt, + cutoverBalanceAudit: computeCutoverBalanceAudit({ migration, usdtWallets }), + anomalies, + }) + } + + const treasuryAccounts = await Promise.all( + treasuryAccountIds.map((accountId) => + summarizeTreasuryAccount({ + accountId, + accountsRepo, + walletsRepo, + getBalanceForWallet, + balanceReadAttempts, + balanceMode, + }), + ), + ) + const treasury = treasurySummary(treasuryAccounts) + const usdTotalCents = usdTotalCentsForAccounts(accounts) + const usdtTotalMicros = usdtTotalMicrosForAccounts(accounts) + const missingUsdt = accounts.filter( + (account) => account.usdtWallets.length === 0, + ).length + const blockers = accounts.filter( + (account) => + account.anomalies.includes("missing_usd") || + account.anomalies.includes("missing_usdt"), + ).length + + const reconciliation = reconciliationSummary({ + customerUsdTotalCents: usdTotalCents, + customerUsdtTotalMicros: usdtTotalMicros, + treasury, + }) + + return { + generatedAt: now.toISOString(), + cutover: { + state: config.state, + cutoverVersion: config.cutoverVersion, + runId: config.runId, + updatedAt: config.updatedAt?.toISOString(), + }, + preflight: preflightReport, + summary: { + accounts: accounts.length, + wallets: { + current: accounts.reduce((sum, account) => sum + account.walletCount, 0), + target: accounts.length * 2, + usd: accounts.reduce((sum, account) => sum + account.usdWallets.length, 0), + usdt: accounts.reduce((sum, account) => sum + account.usdtWallets.length, 0), + missingUsdt, + }, + fundedUsdOnlyAccounts: accounts.filter( + (account) => + account.usdtWallets.length === 0 && + account.usdWallets.some((wallet) => wallet.balance.minorUnitsNumber > 0), + ).length, + usdTotalCents, + usdtTotalMicros, + anomalies: accounts.filter((account) => account.anomalies.length > 0).length, + watchlistAnomalies: accounts.filter( + (account) => account.watchlisted && account.anomalies.length > 0, + ).length, + canStart: blockers === 0, + blockers, + watchlistAccounts: accounts.filter((account) => account.watchlisted).length, + migrationStatuses, + }, + accounts, + treasury: { + accounts: treasuryAccounts, + summary: treasury, + }, + reconciliation, + } +} diff --git a/src/app/cash-wallet-cutover/orchestrator.ts b/src/app/cash-wallet-cutover/orchestrator.ts index 89d8203c6..3e58b17c6 100644 --- a/src/app/cash-wallet-cutover/orchestrator.ts +++ b/src/app/cash-wallet-cutover/orchestrator.ts @@ -19,6 +19,7 @@ export const runPrimaryCashWalletCutoverBatch = ({ runId, workerId, limit, + stepDelayMs, lockStaleBefore, migrationsRepo = CashWalletCutoverRepository(), runtimeServices = createCashWalletMigrationRuntimeServices(), @@ -27,6 +28,7 @@ export const runPrimaryCashWalletCutoverBatch = ({ runId: string workerId: string limit?: number + stepDelayMs?: number lockStaleBefore: Date migrationsRepo?: PrimaryCashWalletCutoverBatchRepository runtimeServices?: PrimaryCashWalletCutoverRuntimeServices @@ -41,6 +43,7 @@ export const runPrimaryCashWalletCutoverBatch = ({ runId, workerId, limit, + stepDelayMs, lockStaleBefore, migrationsRepo, executor: (migration) => diff --git a/src/app/cash-wallet-cutover/provision-usdt-wallets.ts b/src/app/cash-wallet-cutover/provision-usdt-wallets.ts new file mode 100644 index 000000000..b66a1be53 --- /dev/null +++ b/src/app/cash-wallet-cutover/provision-usdt-wallets.ts @@ -0,0 +1,160 @@ +import { WalletCurrency } from "@domain/shared" +import { WalletType } from "@domain/wallets" + +import { discoverCashWalletCutoverAccounts } from "./discovery" +import { InvalidCashWalletCutoverStateTransitionError } from "./errors" +import { + buildCashWalletCutoverPreflightReport, + CashWalletCutoverPreflightReport, +} from "./preflight" + +type ProvisionedCashWalletUsdtWallet = { + accountId: AccountId + walletId?: WalletId +} + +type FailedCashWalletUsdtWalletProvision = { + accountId: AccountId + error: string +} + +type ProvisionPrimaryCashWalletUsdtWalletsResult = { + before: CashWalletCutoverPreflightReport + after: CashWalletCutoverPreflightReport + eligible: number + provisioned: ProvisionedCashWalletUsdtWallet[] + failed: FailedCashWalletUsdtWalletProvision[] + dryRun: boolean +} + +type CashWalletCutoverProvisioningRepository = { + getConfig: () => Promise +} + +type AddWalletIfNonexistent = ({ + accountId, + type, + currency, +}: { + accountId: AccountId + type: WalletType + currency: WalletCurrency +}) => Promise + +const defaultSleep = (delayMs: number) => + new Promise((resolve) => setTimeout(resolve, delayMs)) + +const errorMessage = (error: unknown): string => { + if (error instanceof Error && error.message) return error.message + return String(error) +} + +const isRateLimitError = (error: unknown): boolean => + errorMessage(error).toLowerCase().includes("too many requests") + +export type { ProvisionPrimaryCashWalletUsdtWalletsResult } + +export const provisionPrimaryCashWalletUsdtWallets = async ({ + cutoverVersion, + runId, + accountsRepo, + walletsRepo, + migrationsRepo, + addWalletIfNonexistent, + provisionLimit, + provisionDelayMs = 0, + provisionRetryDelayMs = 60_000, + maxProvisionAttempts = 5, + dryRun = false, + sleep = defaultSleep, +}: { + cutoverVersion: number + runId: string + accountsRepo: Pick + walletsRepo: Pick + migrationsRepo: CashWalletCutoverProvisioningRepository + addWalletIfNonexistent: AddWalletIfNonexistent + provisionLimit?: number + provisionDelayMs?: number + provisionRetryDelayMs?: number + maxProvisionAttempts?: number + dryRun?: boolean + sleep?: (delayMs: number) => Promise +}): Promise => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) return config + + if (config.state !== "pre") { + return new InvalidCashWalletCutoverStateTransitionError( + "Cash wallet USDT provisioning can only run before cutover start", + ) + } + + const discoveries = await discoverCashWalletCutoverAccounts({ + accountsRepo, + walletsRepo, + }) + if (discoveries instanceof Error) return discoveries + + const before = buildCashWalletCutoverPreflightReport({ + cutoverVersion, + runId, + discoveries, + }) + + const eligibleDiscoveries = discoveries + .filter(({ status }) => status === "missing_destination_usdt") + .slice(0, provisionLimit) + const provisioned: ProvisionedCashWalletUsdtWallet[] = [] + const failed: FailedCashWalletUsdtWalletProvision[] = [] + + if (!dryRun) { + for (const [index, discovery] of eligibleDiscoveries.entries()) { + let wallet: Wallet | ApplicationError = new Error("Provisioning was not attempted") + const attempts = Math.max(1, maxProvisionAttempts) + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + wallet = await addWalletIfNonexistent({ + accountId: discovery.accountId, + type: WalletType.Checking, + currency: WalletCurrency.Usdt, + }) + + if (!(wallet instanceof Error)) break + if (!isRateLimitError(wallet) || attempt === attempts) break + + await sleep(provisionRetryDelayMs) + } + + if (wallet instanceof Error) { + failed.push({ accountId: discovery.accountId, error: errorMessage(wallet) }) + } else { + provisioned.push({ accountId: discovery.accountId, walletId: wallet.id }) + } + + if (provisionDelayMs > 0 && index < eligibleDiscoveries.length - 1) { + await sleep(provisionDelayMs) + } + } + } + + const afterDiscoveries = dryRun + ? discoveries + : await discoverCashWalletCutoverAccounts({ accountsRepo, walletsRepo }) + if (afterDiscoveries instanceof Error) return afterDiscoveries + + const after = buildCashWalletCutoverPreflightReport({ + cutoverVersion, + runId, + discoveries: afterDiscoveries, + }) + + return { + before, + after, + eligible: eligibleDiscoveries.length, + provisioned, + failed, + dryRun, + } +} diff --git a/src/app/cash-wallet-cutover/runner.ts b/src/app/cash-wallet-cutover/runner.ts index c937496f0..70789c8c9 100644 --- a/src/app/cash-wallet-cutover/runner.ts +++ b/src/app/cash-wallet-cutover/runner.ts @@ -38,6 +38,11 @@ type CashWalletMigrationBatchResult = { skipped: number } +type SleepFn = (delayMs: number) => Promise + +const sleep = (delayMs: number) => + new Promise((resolve) => setTimeout(resolve, delayMs)) + const AMBIGUOUS_SIDE_EFFECT_STATUSES: CashWalletMigrationStatus[] = [ "invoice_created", "balance_move_sending", @@ -62,6 +67,8 @@ export const runCashWalletMigrationBatch = async ({ lockStaleBefore, migrationsRepo, executor, + stepDelayMs = 0, + sleep: sleepFn = sleep, }: { cutoverVersion: number runId: string @@ -70,6 +77,8 @@ export const runCashWalletMigrationBatch = async ({ lockStaleBefore: Date migrationsRepo: CashWalletMigrationBatchRepository executor: CashWalletMigrationBatchExecutor + stepDelayMs?: number + sleep?: SleepFn }): Promise => { const migrations = await migrationsRepo.listRunnableMigrations({ cutoverVersion, @@ -85,7 +94,7 @@ export const runCashWalletMigrationBatch = async ({ skipped: 0, } - for (const migration of migrations) { + for (const [index, migration] of migrations.entries()) { result.attempted += 1 const locked = await migrationsRepo.acquireMigrationLock({ @@ -123,6 +132,10 @@ export const runCashWalletMigrationBatch = async ({ cutoverVersion, runId, }) + + if (stepDelayMs > 0 && index < migrations.length - 1) { + await sleepFn(stepDelayMs) + } } return result diff --git a/src/app/cash-wallet-cutover/runtime-services.ts b/src/app/cash-wallet-cutover/runtime-services.ts index 776149ba5..4f2766689 100644 --- a/src/app/cash-wallet-cutover/runtime-services.ts +++ b/src/app/cash-wallet-cutover/runtime-services.ts @@ -1,8 +1,5 @@ import { addWalletIfNonexistent, updateDefaultWalletId } from "@app/accounts" -import { - addInvoiceForRecipientForUsdWallet, - getBalanceForWallet, -} from "@app/wallets" +import { getBalanceForWallet } from "@app/wallets" import { decodeInvoice } from "@domain/bitcoin/lightning" import { InvalidWalletId } from "@domain/errors" import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" @@ -20,17 +17,24 @@ import { import { destinationShortfallUsdtMicros } from "./amount-conversion" const CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS = 15 * 60 +const CUTOVER_IBEX_RATE_LIMIT_RETRY_DELAY_MS = 60_000 +const CUTOVER_IBEX_RATE_LIMIT_MAX_ATTEMPTS = 5 + +type SleepFn = (delayMs: number) => Promise type RuntimeServiceDependencies = { now?: () => Date addWalletIfNonexistent?: typeof addWalletIfNonexistent updateDefaultWalletId?: typeof updateDefaultWalletId getBalanceForWallet?: typeof getBalanceForWallet - createInvoice?: typeof addInvoiceForRecipientForUsdWallet + createInvoice?: typeof Ibex.addInvoice createNoAmountInvoice?: typeof Ibex.addInvoice payInvoice?: typeof Ibex.payInvoice accountsRepo?: Pick, "findById"> getTreasuryWalletId?: () => Promise + maxRateLimitAttempts?: number + rateLimitRetryDelayMs?: number + sleep?: SleepFn } const isUsdAmount = (amount: unknown): amount is USDAmount => amount instanceof USDAmount @@ -49,16 +53,59 @@ const ibexInvoiceToDomainInvoice = (response: Awaited + new Promise((resolve) => setTimeout(resolve, delayMs)) + +const errorMessage = (error: Error): string => error.message || String(error) + +const isIbexRateLimitError = (error: Error): boolean => + errorMessage(error).toLowerCase().includes("too many requests") + +const withIbexRateLimitRetry = async ({ + operation, + maxAttempts, + retryDelayMs, + sleepFn, +}: { + operation: () => Promise + maxAttempts: number + retryDelayMs: number + sleepFn: SleepFn +}): Promise => { + for (let attempt = 1; ; attempt += 1) { + const result = await operation() + + if ( + !(result instanceof Error) || + !isIbexRateLimitError(result) || + attempt >= maxAttempts + ) { + return result + } + + await sleepFn(retryDelayMs) + } +} + export const createCashWalletMigrationRuntimeServices = ( deps: RuntimeServiceDependencies = {}, ) => { const addWallet = deps.addWalletIfNonexistent ?? addWalletIfNonexistent const updateDefaultWallet = deps.updateDefaultWalletId ?? updateDefaultWalletId const balanceForWallet = deps.getBalanceForWallet ?? getBalanceForWallet - const invoiceForRecipient = deps.createInvoice ?? addInvoiceForRecipientForUsdWallet + const invoiceForRecipient = deps.createInvoice ?? Ibex.addInvoice const noAmountInvoiceForRecipient = deps.createNoAmountInvoice ?? Ibex.addInvoice const payInvoice = deps.payInvoice ?? Ibex.payInvoice const accountsRepo = deps.accountsRepo ?? AccountsRepository() + const rateLimitRetry = { + maxAttempts: Math.max( + 1, + deps.maxRateLimitAttempts ?? CUTOVER_IBEX_RATE_LIMIT_MAX_ATTEMPTS, + ), + retryDelayMs: + deps.rateLimitRetryDelayMs ?? CUTOVER_IBEX_RATE_LIMIT_RETRY_DELAY_MS, + sleepFn: deps.sleep ?? sleep, + } return { now: deps.now ?? (() => new Date()), @@ -117,12 +164,21 @@ export const createCashWalletMigrationRuntimeServices = ( recipientWalletId: WalletId amount: string memo: string - }) => - invoiceForRecipient({ - recipientWalletId, - amount: amount as FractionalCentAmount, - memo, - }), + }) => { + const usdtAmount = USDTAmount.smallestUnits(amount) + if (usdtAmount instanceof Error) return Promise.resolve(usdtAmount) + + return withIbexRateLimitRetry({ + ...rateLimitRetry, + operation: () => + invoiceForRecipient({ + accountId: recipientWalletId as IbexAccountId, + amount: usdtAmount, + memo, + expiration: CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS as Seconds, + }), + }).then(ibexInvoiceToDomainInvoice) + }, createNoAmountInvoice: ({ recipientWalletId, memo, @@ -130,10 +186,15 @@ export const createCashWalletMigrationRuntimeServices = ( recipientWalletId: WalletId memo: string }) => - noAmountInvoiceForRecipient({ - accountId: recipientWalletId, - memo, - expiration: CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS as Seconds, + withIbexRateLimitRetry({ + ...rateLimitRetry, + operation: () => + noAmountInvoiceForRecipient({ + accountId: recipientWalletId, + amount: USDTAmount.ZERO, + memo, + expiration: CUTOVER_IBEX_INVOICE_EXPIRATION_SECONDS as Seconds, + }), }).then(ibexInvoiceToDomainInvoice), }, paymentService: { @@ -152,10 +213,14 @@ export const createCashWalletMigrationRuntimeServices = ( : USDAmount.cents(senderAmountUsdCents) if (send instanceof Error) return send - const payment = await payInvoice({ - accountId: senderWalletId as IbexAccountId, - invoice: paymentRequest as Bolt11, - send, + const payment = await withIbexRateLimitRetry({ + ...rateLimitRetry, + operation: () => + payInvoice({ + accountId: senderWalletId as IbexAccountId, + invoice: paymentRequest as Bolt11, + send, + }), }) if (payment instanceof Error) return payment diff --git a/src/app/cash-wallet-cutover/state-machine.ts b/src/app/cash-wallet-cutover/state-machine.ts index 6bd152b8d..9a950e065 100644 --- a/src/app/cash-wallet-cutover/state-machine.ts +++ b/src/app/cash-wallet-cutover/state-machine.ts @@ -7,7 +7,12 @@ const transitions: Partial< started: ["provisioned", "failed"], provisioned: ["balance_read", "failed", "skipped_already_migrated"], balance_read: ["invoice_created", "pointer_flipped", "failed"], - invoice_created: ["balance_move_sending", "failed", "requires_operator_review"], + invoice_created: [ + "invoice_created", + "balance_move_sending", + "failed", + "requires_operator_review", + ], balance_move_sending: ["balance_move_sent", "failed", "requires_operator_review"], balance_move_sent: ["balance_move_verified", "failed", "requires_operator_review"], balance_move_verified: [ @@ -17,6 +22,7 @@ const transitions: Partial< "requires_operator_review", ], fee_reimbursement_invoice_created: [ + "fee_reimbursement_invoice_created", "fee_reimbursement_sending", "failed", "requires_operator_review", diff --git a/src/app/cash-wallet-cutover/worker.ts b/src/app/cash-wallet-cutover/worker.ts index 67b26250f..f0b860031 100644 --- a/src/app/cash-wallet-cutover/worker.ts +++ b/src/app/cash-wallet-cutover/worker.ts @@ -1,5 +1,11 @@ +import { decodeInvoice } from "@domain/bitcoin/lightning" + import { assertCanTransition } from "./state-machine" -import { usdCentsToUsdtMicros, usdtMicrosToUsdCentsCeil } from "./amount-conversion" +import { + feeUsdCentsToUsdtMicros, + usdCentsToUsdtMicros, + usdtMicrosToUsdCentsCeil, +} from "./amount-conversion" import { InvalidCashWalletCutoverAmountError, InvalidCashWalletMigrationTransitionError, @@ -69,6 +75,23 @@ type CashWalletMigrationProvisioningService = { }): Promise } +const CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS = 30 * 1000 + +const isInvoicePaymentRequestStale = ({ + paymentRequest, + now, + safetyWindowMs = CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS, +}: { + paymentRequest: string + now: Date + safetyWindowMs?: number +}): boolean => { + const invoice = decodeInvoice(paymentRequest) + if (invoice instanceof Error) return true + + return invoice.expiresAt.getTime() <= now.getTime() + safetyWindowMs +} + export const startCashWalletMigration = async ({ migration, migrationsRepo, @@ -152,11 +175,17 @@ export const recordCashWalletMigrationBalance = async ({ export const sendCashWalletMigrationBalanceMovePayment = async ({ migration, paymentService, + invoiceService, migrationsRepo, + now = () => new Date(), + invoicePaymentSafetyWindowMs = CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS, }: { migration: CashWalletMigration paymentService: CashWalletMigrationPaymentService + invoiceService?: CashWalletMigrationNoAmountInvoiceService migrationsRepo: CashWalletMigrationTransitionRepository + now?: () => Date + invoicePaymentSafetyWindowMs?: number }): Promise => { const transition = assertCanTransition(migration.status, "balance_move_sending") if (transition instanceof Error) return transition @@ -173,19 +202,49 @@ export const sendCashWalletMigrationBalanceMovePayment = async ({ ) } + let payableMigration = migration + if ( + invoiceService && + isInvoicePaymentRequestStale({ + paymentRequest: migration.balanceMoveInvoicePaymentRequest, + now: now(), + safetyWindowMs: invoicePaymentSafetyWindowMs, + }) + ) { + const refreshedMigration = await createCashWalletMigrationBalanceMoveInvoice({ + migration, + invoiceService, + migrationsRepo, + }) + if (refreshedMigration instanceof Error) return refreshedMigration + payableMigration = refreshedMigration + } + + if (payableMigration.balanceMoveInvoicePaymentRequest === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "balanceMoveInvoicePaymentRequest is required before balance move payment sending", + ) + } + + if (payableMigration.sourceBalanceUsdCents === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "sourceBalanceUsdCents is required before balance move payment sending", + ) + } + const payment = await paymentService.payInvoice({ - senderWalletId: migration.legacyUsdWalletId, - paymentRequest: migration.balanceMoveInvoicePaymentRequest, - senderAmountUsdCents: migration.sourceBalanceUsdCents, + senderWalletId: payableMigration.legacyUsdWalletId, + paymentRequest: payableMigration.balanceMoveInvoicePaymentRequest, + senderAmountUsdCents: payableMigration.sourceBalanceUsdCents, }) if (payment instanceof Error) return payment return migrationsRepo.transitionMigration({ - id: migration.id, - from: migration.status, + id: payableMigration.id, + from: payableMigration.status, to: "balance_move_sending", - cutoverVersion: migration.cutoverVersion, - runId: migration.runId, + cutoverVersion: payableMigration.cutoverVersion, + runId: payableMigration.runId, patch: { balanceMovePaymentTransactionId: payment.transactionId, }, @@ -304,6 +363,10 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ const feeAmountUsdCents = usdtMicrosToUsdCentsCeil(feeAmountUsdtMicros) if (feeAmountUsdCents instanceof Error) return feeAmountUsdCents + const reimbursableFeeAmountUsdtMicros = feeUsdCentsToUsdtMicros(feeAmountUsdCents) + if (reimbursableFeeAmountUsdtMicros instanceof Error) + return reimbursableFeeAmountUsdtMicros + const transition = assertCanTransition( migration.status, "fee_reimbursement_invoice_created", @@ -312,7 +375,7 @@ export const createCashWalletMigrationFeeReimbursementInvoice = async ({ const invoice = await invoiceService.createInvoice({ recipientWalletId: migration.destinationUsdtWalletId, - amount: feeAmountUsdtMicros, + amount: reimbursableFeeAmountUsdtMicros, memo: `cash-wallet-cutover:${migration.runId}:${migration.id}:fee-reimbursement`, }) if (invoice instanceof Error) return invoice @@ -359,12 +422,18 @@ export const sendCashWalletMigrationFeeReimbursementPayment = async ({ migration, treasuryWalletId, paymentService, + invoiceService, migrationsRepo, + now = () => new Date(), + invoicePaymentSafetyWindowMs = CUTOVER_INVOICE_PAYMENT_SAFETY_WINDOW_MS, }: { migration: CashWalletMigration treasuryWalletId: WalletId paymentService: CashWalletMigrationPaymentService + invoiceService?: CashWalletMigrationInvoiceService migrationsRepo: CashWalletMigrationTransitionRepository + now?: () => Date + invoicePaymentSafetyWindowMs?: number }): Promise => { const transition = assertCanTransition(migration.status, "fee_reimbursement_sending") if (transition instanceof Error) return transition @@ -375,18 +444,49 @@ export const sendCashWalletMigrationFeeReimbursementPayment = async ({ ) } + let payableMigration = migration + if ( + invoiceService && + isInvoicePaymentRequestStale({ + paymentRequest: migration.feeReimbursementInvoicePaymentRequest, + now: now(), + safetyWindowMs: invoicePaymentSafetyWindowMs, + }) + ) { + if (migration.feeAmountUsdtMicros === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeAmountUsdtMicros is required before fee reimbursement invoice refresh", + ) + } + + const refreshedMigration = await createCashWalletMigrationFeeReimbursementInvoice({ + migration, + invoiceService, + migrationsRepo, + feeAmountUsdtMicros: migration.feeAmountUsdtMicros, + }) + if (refreshedMigration instanceof Error) return refreshedMigration + payableMigration = refreshedMigration + } + + if (payableMigration.feeReimbursementInvoicePaymentRequest === undefined) { + return new InvalidCashWalletMigrationTransitionError( + "feeReimbursementInvoicePaymentRequest is required before fee reimbursement sending", + ) + } + const payment = await paymentService.payInvoice({ senderWalletId: treasuryWalletId, - paymentRequest: migration.feeReimbursementInvoicePaymentRequest, + paymentRequest: payableMigration.feeReimbursementInvoicePaymentRequest, }) if (payment instanceof Error) return payment return migrationsRepo.transitionMigration({ - id: migration.id, - from: migration.status, + id: payableMigration.id, + from: payableMigration.status, to: "fee_reimbursement_sending", - cutoverVersion: migration.cutoverVersion, - runId: migration.runId, + cutoverVersion: payableMigration.cutoverVersion, + runId: payableMigration.runId, patch: { feeReimbursementPaymentTransactionId: payment.transactionId, }, diff --git a/src/scripts/cash-wallet-cutover-dashboard.ts b/src/scripts/cash-wallet-cutover-dashboard.ts new file mode 100644 index 000000000..a8d3b217f --- /dev/null +++ b/src/scripts/cash-wallet-cutover-dashboard.ts @@ -0,0 +1,988 @@ +#!/usr/bin/env node + +import fs from "fs" +import http from "http" + +import express from "express" +import yargs from "yargs" +import { hideBin } from "yargs/helpers" + +import { + buildCashWalletCutoverOperatorSnapshot, + CashWalletCutoverOperatorManifestAccount, + CashWalletCutoverOperatorSnapshot, + formatCashWalletCutoverOperatorSnapshotCsv, + formatOperatorBalance, + OperatorBalance, + parseCashWalletCutoverOperatorManifest, + refreshOperatorAccountCutoverBalanceAudit, +} from "@app/cash-wallet-cutover/operator-dashboard" +import { discoverCashWalletCutoverAccounts } from "@app/cash-wallet-cutover/discovery" +import { buildCashWalletCutoverPreflightReport } from "@app/cash-wallet-cutover/preflight" +import { getBalanceForWallet } from "@app/wallets" +import { WalletCurrency } from "@domain/shared" +import { setupMongoConnection } from "@services/mongodb" +import { + AccountsRepository, + CashWalletCutoverRepository, + WalletsRepository, +} from "@services/mongoose" +import { baseLogger } from "@services/logger" +import { getFunderWalletId } from "@services/ledger/caching" + +const DEFAULT_MANIFESTS = [ + "/tmp/eng345usd-20260526115410-local-backend-accounts.json", + "/tmp/eng345usdonly-20260526195758-accounts.json", +] + +const BALANCE_TIMEOUT_MS = 7_500 +const BALANCE_READ_ATTEMPTS = 3 +const BALANCE_READ_SPACING_MS = 1_000 + +const args = yargs(hideBin(process.argv)) + .option("port", { type: "number", default: 3450 }) + .option("manifest", { type: "array", string: true, default: DEFAULT_MANIFESTS }) + .option("expected-accounts", { type: "number", default: 60 }) + .option("snapshot-ttl-ms", { type: "number", default: 5_000 }) + .option("run-id", { type: "string" }) + .option("cutover-version", { type: "number" }) + .option("configPath", { type: "string", demandOption: true }) + .parseSync() + +const readManifestAccounts = (): CashWalletCutoverOperatorManifestAccount[] => { + const accounts = args.manifest.flatMap((manifestPath) => + parseCashWalletCutoverOperatorManifest( + JSON.parse(fs.readFileSync(manifestPath, "utf8")), + ), + ) + + const seen = new Set() + for (const account of accounts) { + if (seen.has(account.accountId)) { + throw new Error(`Duplicate operator dashboard accountId: ${account.accountId}`) + } + seen.add(account.accountId) + } + + if (args["expected-accounts"] && accounts.length !== args["expected-accounts"]) { + throw new Error( + `Expected ${args["expected-accounts"]} operator accounts, loaded ${accounts.length}`, + ) + } + + return accounts +} + +const withBalanceTimeout = (balance: ReturnType) => + Promise.race([ + balance, + new Promise((resolve) => { + setTimeout( + () => resolve(new Error("Balance read timed out") as ApplicationError), + BALANCE_TIMEOUT_MS, + ) + }), + ]) + +let nextBalanceReadAt = 0 + +const readBalanceThrottled = async ( + request: Parameters[0], +) => { + const now = Date.now() + const scheduledAt = Math.max(now, nextBalanceReadAt) + nextBalanceReadAt = scheduledAt + BALANCE_READ_SPACING_MS + + const waitMs = scheduledAt - now + if (waitMs > 0) { + await new Promise((resolve) => setTimeout(resolve, waitMs)) + } + + return withBalanceTimeout(getBalanceForWallet(request)) +} + +const shortId = (value?: string) => (value ? value.slice(0, 8) : "-") + +type CachedBalance = OperatorBalance & { + walletId: WalletId + updatedAt?: string +} + +const html = ` + + + + + Cash Wallet Cutover Dashboard + + + +
+
+

Cash Wallet Cutover Dashboard

+
Raw Mongo wallets plus lazy IBEX balances. Presentation filtering is bypassed.
+
+
+ canStart: - + Loading... + + +
+
+
+
+
+ + + + + + +
+
+ + + + + + + + + + + + + + + + + +
#PhoneAccountDefaultUSDUSD BalanceUSDTUSDT BalanceAuditMigrationAnomalies
+
+
+ + +` + +const start = async () => { + const manifestAccounts = readManifestAccounts() + const accountsRepo = AccountsRepository() + const walletsRepo = WalletsRepository() + const migrationsRepo = CashWalletCutoverRepository() + const migrationLookup = + args["run-id"] && args["cutover-version"] + ? { runId: args["run-id"], cutoverVersion: args["cutover-version"] } + : undefined + + await setupMongoConnection() + + const loadTreasuryAccountIds = async (): Promise => { + const funderWalletId = await getFunderWalletId() + + const funderWallet = await walletsRepo.findById(funderWalletId) + if (funderWallet instanceof Error) throw funderWallet + + return [funderWallet.accountId] + } + + const treasuryAccountIds = await loadTreasuryAccountIds() + + let cache: + | { + snapshot: CashWalletCutoverOperatorSnapshot + cachedAt: number + } + | undefined + let pending: Promise | undefined + const walletCurrencies = new Map() + const balanceCache = new Map() + const balanceQueue: Array<{ walletId: WalletId; currency: WalletCurrency }> = [] + const queuedBalanceIds = new Set() + let activeBalanceId: WalletId | undefined + let balanceWorker: Promise | undefined + + const registerSnapshotWallets = (snapshot: CashWalletCutoverOperatorSnapshot) => { + for (const account of [...snapshot.accounts, ...snapshot.treasury.accounts]) { + for (const wallet of [...account.usdWallets, ...account.usdtWallets]) { + walletCurrencies.set(wallet.id, wallet.currency) + if (!balanceCache.has(wallet.id)) { + balanceCache.set(wallet.id, { + walletId: wallet.id, + currency: wallet.currency, + display: "loading", + minorUnits: "0", + minorUnitsNumber: 0, + status: "loading", + }) + } + } + } + } + + const runBalanceWorker = () => { + if (balanceWorker) return balanceWorker + + balanceWorker = (async () => { + while (balanceQueue.length > 0) { + const request = balanceQueue.shift() + if (!request) continue + + queuedBalanceIds.delete(request.walletId) + activeBalanceId = request.walletId + balanceCache.set(request.walletId, { + walletId: request.walletId, + currency: request.currency, + display: "loading", + minorUnits: "0", + minorUnitsNumber: 0, + status: "loading", + }) + + const balance = await readBalanceThrottled({ + walletId: request.walletId, + currency: request.currency, + }) + balanceCache.set(request.walletId, { + walletId: request.walletId, + ...formatOperatorBalance( + { id: request.walletId, currency: request.currency } as Wallet, + balance, + ), + updatedAt: new Date().toISOString(), + }) + } + })().finally(() => { + activeBalanceId = undefined + balanceWorker = undefined + if (balanceQueue.length > 0) runBalanceWorker() + }) + + return balanceWorker + } + + const enqueueBalance = ({ + walletId, + currency, + force, + }: { + walletId: WalletId + currency: WalletCurrency + force: boolean + }) => { + const cached = balanceCache.get(walletId) + if (!force && cached && cached.status !== "loading") return + if (queuedBalanceIds.has(walletId) || activeBalanceId === walletId) return + + queuedBalanceIds.add(walletId) + balanceQueue.push({ walletId, currency }) + runBalanceWorker() + } + + const snapshotWithCachedBalances = ( + currentSnapshot: CashWalletCutoverOperatorSnapshot, + ): CashWalletCutoverOperatorSnapshot => ({ + ...currentSnapshot, + accounts: currentSnapshot.accounts.map((account) => + refreshOperatorAccountCutoverBalanceAudit({ + ...account, + usdWallets: account.usdWallets.map((wallet) => ({ + ...wallet, + balance: balanceCache.get(wallet.id) ?? wallet.balance, + })), + usdtWallets: account.usdtWallets.map((wallet) => ({ + ...wallet, + balance: balanceCache.get(wallet.id) ?? wallet.balance, + })), + }), + ), + treasury: { + ...currentSnapshot.treasury, + accounts: currentSnapshot.treasury.accounts.map((account) => ({ + ...account, + usdWallets: account.usdWallets.map((wallet) => ({ + ...wallet, + balance: balanceCache.get(wallet.id) ?? wallet.balance, + })), + usdtWallets: account.usdtWallets.map((wallet) => ({ + ...wallet, + balance: balanceCache.get(wallet.id) ?? wallet.balance, + })), + })), + }, + }) + + const buildSnapshot = async () => { + const config = await migrationsRepo.getConfig() + if (config instanceof Error) throw config + const lookup = + migrationLookup ?? + (config.runId + ? { + cutoverVersion: config.cutoverVersion, + runId: config.runId, + } + : undefined) + const discoveries = lookup + ? await discoverCashWalletCutoverAccounts({ + accountsRepo, + walletsRepo, + }) + : undefined + if (discoveries instanceof Error) throw discoveries + const preflightReport = + lookup && discoveries + ? buildCashWalletCutoverPreflightReport({ + cutoverVersion: lookup.cutoverVersion, + runId: lookup.runId, + discoveries, + }) + : undefined + + const result = await buildCashWalletCutoverOperatorSnapshot({ + manifestAccounts, + accountsRepo, + walletsRepo, + migrationsRepo, + migrationLookup: lookup, + preflightReport, + discoveredAccounts: discoveries, + treasuryAccountIds, + balanceReadAttempts: BALANCE_READ_ATTEMPTS, + balanceMode: "structural", + getBalanceForWallet: (request) => + readBalanceThrottled({ + walletId: request.walletId, + currency: request.currency ?? WalletCurrency.Usd, + }), + }) + registerSnapshotWallets(result) + return result + } + + const snapshot = async (force: boolean) => { + const now = Date.now() + if (!force && cache && now - cache.cachedAt < args["snapshot-ttl-ms"]) { + return cache.snapshot + } + if (pending) return pending + + pending = buildSnapshot() + .then((result) => { + cache = { snapshot: result, cachedAt: Date.now() } + return result + }) + .finally(() => { + pending = undefined + }) + return pending + } + + const app = express() + app.get("/", (_req, res) => res.type("html").send(html)) + app.get("/api/snapshot", async (req, res) => { + try { + res.json(await snapshot(req.query.refresh === "1")) + } catch (error) { + baseLogger.error({ error }, "Cash wallet cutover dashboard snapshot failed") + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }) + } + }) + app.get("/api/balances", async (req, res) => { + try { + await snapshot(false) + const rawWalletIds = + typeof req.query.walletIds === "string" ? req.query.walletIds : "" + const requestedWalletIds = rawWalletIds + ? rawWalletIds + .split(",") + .map((walletId) => walletId.trim()) + .filter(Boolean) + : Array.from(walletCurrencies.keys()) + const force = req.query.refresh === "1" + + for (const rawWalletId of requestedWalletIds) { + const walletId = rawWalletId as WalletId + const currency = walletCurrencies.get(walletId) + if (!currency) continue + enqueueBalance({ walletId, currency, force }) + } + + const balances: Record = {} + for (const rawWalletId of requestedWalletIds) { + const walletId = rawWalletId as WalletId + const cached = balanceCache.get(walletId) + if (cached) balances[walletId] = cached + } + + res.json({ + balances, + queue: { + pending: balanceQueue.length, + active: activeBalanceId, + }, + }) + } catch (error) { + baseLogger.error({ error }, "Cash wallet cutover dashboard balance refresh failed") + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }) + } + }) + app.get("/api/balance-status", async (_req, res) => { + const balances = Array.from(balanceCache.values()) + res.json({ + known: walletCurrencies.size, + cached: balances.length, + fresh: balances.filter((balance) => balance.status === "fresh").length, + errors: balances.filter((balance) => balance.status === "error").length, + loading: balances.filter((balance) => balance.status === "loading").length, + queue: { + pending: balanceQueue.length, + active: activeBalanceId, + }, + }) + }) + app.get("/api/export.csv", async (_req, res) => { + try { + const currentSnapshot = await snapshot(false) + const csv = formatCashWalletCutoverOperatorSnapshotCsv( + snapshotWithCachedBalances(currentSnapshot), + ) + const runId = currentSnapshot.cutover.runId ?? "unknown-run" + res + .type("text/csv") + .attachment(`cash-wallet-cutover-${runId}-${Date.now()}.csv`) + .send(csv) + } catch (error) { + baseLogger.error({ error }, "Cash wallet cutover dashboard CSV export failed") + res.status(500).json({ + error: error instanceof Error ? error.message : String(error), + }) + } + }) + + const server = http.createServer(app) + server.listen(args.port, "127.0.0.1", () => { + baseLogger.info( + { port: args.port, accounts: manifestAccounts.length }, + "Cash wallet cutover dashboard listening", + ) + console.log(`Cash wallet cutover dashboard: http://localhost:${args.port}`) + }) +} + +start().catch((error) => { + baseLogger.error({ error }, "Cash wallet cutover dashboard failed") + process.exit(1) +}) diff --git a/src/scripts/cash-wallet-cutover.ts b/src/scripts/cash-wallet-cutover.ts index 3bd9a173a..5e481a074 100644 --- a/src/scripts/cash-wallet-cutover.ts +++ b/src/scripts/cash-wallet-cutover.ts @@ -4,6 +4,7 @@ import yargs from "yargs" import { hideBin } from "yargs/helpers" import { CashWalletCutover } from "@app" +import { addWalletIfNonexistent } from "@app/accounts" import { setupMongoConnection } from "@services/mongodb" import { AccountsRepository, @@ -14,6 +15,10 @@ import { baseLogger } from "@services/logger" const args = yargs(hideBin(process.argv)) .command("preview", "discover accounts and print the migration plan without writes") + .command( + "provision-usdt-wallets", + "create missing destination USDT wallets before preparing migrations", + ) .command("prepare", "discover accounts and upsert migration records") .command("start", "mark a prepared cutover run in progress") .command("run-batch", "run one locked migration worker batch") @@ -25,6 +30,12 @@ const args = yargs(hideBin(process.argv)) .option("operator", { type: "string", default: "unknown" }) .option("worker-id", { type: "string", default: `worker-${process.pid}` }) .option("limit", { type: "number", default: 25 }) + .option("step-delay-ms", { type: "number", default: 0 }) + .option("provision-limit", { type: "number" }) + .option("provision-delay-ms", { type: "number", default: 12_500 }) + .option("provision-retry-delay-ms", { type: "number", default: 60_000 }) + .option("max-provision-attempts", { type: "number", default: 5 }) + .option("dry-run", { type: "boolean", default: false }) .option("lock-stale-seconds", { type: "number", default: 300 }) .option("configPath", { type: "string", demandOption: true }) .parseSync() @@ -51,6 +62,30 @@ const run = async () => { return } + case "provision-usdt-wallets": { + const result = await CashWalletCutover.provisionPrimaryCashWalletUsdtWallets({ + cutoverVersion, + runId, + accountsRepo: AccountsRepository(), + walletsRepo: WalletsRepository(), + migrationsRepo: repository, + addWalletIfNonexistent, + provisionLimit: args["provision-limit"], + provisionDelayMs: args["provision-delay-ms"], + provisionRetryDelayMs: args["provision-retry-delay-ms"], + maxProvisionAttempts: args["max-provision-attempts"], + dryRun: args["dry-run"], + }) + if (result instanceof Error) throw result + toJson(result) + if (result.failed.length > 0) { + throw new Error( + `Failed to provision ${result.failed.length} destination USDT wallet(s)`, + ) + } + return + } + case "prepare": { const result = await CashWalletCutover.preparePrimaryCashWalletCutover({ cutoverVersion, @@ -82,6 +117,7 @@ const run = async () => { runId, workerId: args["worker-id"], limit: args.limit, + stepDelayMs: args["step-delay-ms"], lockStaleBefore: new Date(Date.now() - args["lock-stale-seconds"] * 1000), migrationsRepo: repository, }) diff --git a/src/services/ibex/client.ts b/src/services/ibex/client.ts index 8464520e1..cf448778e 100644 --- a/src/services/ibex/client.ts +++ b/src/services/ibex/client.ts @@ -17,6 +17,7 @@ import IbexClient, { PayToALnurlPayResponse201, SendToAddressCopyBodyParam, SendToAddressCopyResponse200, + IbexUrls, } from "ibex-client" import { IbexConfig } from "@config" @@ -55,6 +56,8 @@ const Ibex = new IbexClient( Redis, ) +const IbexUrlConfig = IbexUrls[IbexConfig.environment] + const createAccount = async ( name: string, currencyId: IbexCurrencyId, @@ -262,42 +265,39 @@ const getIbexToken = async (): Promise => { const cached = await Ibex.authentication.storage.getAccessToken() if (typeof cached === "string") return `${cached}` - // The SDK uses a single base URL for all calls, but the sandbox auth domain is separate - const resp = await fetch(`${IbexConfig.url}/auth/signin`, { + const body = new URLSearchParams({ + grant_type: "client_credentials", + client_id: IbexConfig.clientId, + client_secret: IbexConfig.clientSecret, + audience: IbexUrlConfig.audience, + }) + + const resp = await fetch(`${IbexUrlConfig.authDomain}/oauth/token`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: IbexConfig.email, password: IbexConfig.password }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), }).catch( (err: unknown) => new IbexError(err instanceof Error ? err : new Error(String(err))), ) if (resp instanceof IbexError) return resp if (!resp.ok) { - const body = await resp.text().catch(() => "") - return new IbexError(new Error(`IBEX sign-in failed: ${resp.status} — ${body}`)) + const responseBody = await resp.text().catch(() => "") + return new IbexError( + new Error(`IBEX token request failed: ${resp.status} — ${responseBody}`), + ) } const data = (await resp.json()) as { - accessToken?: string - accessTokenExpiresAt?: number - refreshToken?: string - refreshTokenExpiresAt?: number + access_token?: string + expires_in?: number } - if (!data.accessToken) - return new IbexError(new Error("IBEX sign-in: no access token in response")) + if (!data.access_token) + return new IbexError(new Error("IBEX token request: no access_token in response")) - await Ibex.authentication.storage.setAccessToken( - data.accessToken, - data.accessTokenExpiresAt, - ) - if (data.refreshToken) { - await Ibex.authentication.storage.setRefreshToken( - data.refreshToken, - data.refreshTokenExpiresAt, - ) - } + await Ibex.authentication.storage.setAccessToken(data.access_token, data.expires_in) - return data.accessToken as string + return data.access_token } const ibexFetch = async ( @@ -305,7 +305,7 @@ const ibexFetch = async ( path: string, init: RequestInit = {}, ): Promise => { - const url = `${IbexConfig.url}${path}` + const url = `${IbexUrlConfig.hubUrl}${path}` const resp = await fetch(url, { ...init, headers: { @@ -439,7 +439,10 @@ const getEthereumUsdtOption = async (): Promise const getIbexCurrencyId = async ( currency: WalletCurrency, ): Promise => { - const data = await ibexGet<{ currencies: IbexCurrency[] }>("", "/currency/all") + const token = await getIbexToken() + if (token instanceof IbexError) return token + + const data = await ibexGet<{ currencies: IbexCurrency[] }>(token, "/currency/all") if (data instanceof IbexError) return data const currencyId = data.currencies.find((c) => c.name === currency)?.id if (!currencyId) return new IbexError(new Error(`Currency ${currency} not found`)) 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 index 316fa2443..c6e4c69a3 100644 --- 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 @@ -37,6 +37,16 @@ describe("cash wallet cutover migration state machine", () => { 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") 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/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 index 6987c3873..eb01920f3 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts @@ -123,4 +123,41 @@ describe("cash wallet migration batch runner", () => { }) 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 index 45e793e60..56c40ca9e 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -48,6 +48,10 @@ const migration = (patch: Partial = {}): CashWalletMigratio }) 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")), @@ -116,11 +120,36 @@ describe("cash wallet migration runtime services", () => { expect(result).toMatchObject({ paymentRequest: ibexAddInvoiceResponse.invoice.bolt11, }) - expect(Ibex.addInvoice).toHaveBeenCalledWith({ - accountId: "usdt-wallet-id", - memo: "cash-wallet-cutover:run-7:migration-id:balance-move", - expiration: 900, + 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 () => { @@ -146,6 +175,33 @@ describe("cash wallet migration runtime services", () => { 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 deps = { + payInvoice: jest + .fn() + .mockResolvedValueOnce(rateLimit) + .mockResolvedValueOnce(rateLimit) + .mockResolvedValueOnce({ transaction: { id: "ibex-tx-id" } }), + maxRateLimitAttempts: 3, + rateLimitRetryDelayMs: 1234, + sleep, + } as any + + 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(deps.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 () => ({})), diff --git a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts index 0d27c14ae..8646ee4aa 100644 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts @@ -30,6 +30,9 @@ const migration = (status: CashWalletMigrationStatus): CashWalletMigration => ({ 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") @@ -316,6 +319,88 @@ describe("cash wallet migration worker checkpoints", () => { }) }) + 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(), @@ -475,7 +560,7 @@ describe("cash wallet migration worker checkpoints", () => { expect(migrationsRepo.transitionMigration).not.toHaveBeenCalled() }) - it("creates a fee reimbursement invoice on the destination wallet for the exact USDT shortfall", async () => { + 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"), @@ -509,7 +594,7 @@ describe("cash wallet migration worker checkpoints", () => { }) expect(invoiceService.createInvoice).toHaveBeenCalledWith({ recipientWalletId: "usdt-wallet-id", - amount: "70001", + amount: "80000", memo: "cash-wallet-cutover:run-7:migration-id:fee-reimbursement", }) expect(migrationsRepo.transitionMigration).toHaveBeenCalledWith({ @@ -642,6 +727,91 @@ describe("cash wallet migration worker checkpoints", () => { }) }) + 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(), 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) From 0d7757b6f2cbb07c6fae2ddc81c0cdcd7aacb679 Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 1 Jun 2026 08:38:34 -0700 Subject: [PATCH 35/40] chore(cutover): remove local run artifacts --- .../01-query-admin-state.bru | 1 - .../02-set-scheduled-pre.bru | 1 - .../03-set-in-progress.bru | 1 - .../cash-wallet-cutover/04-set-complete.bru | 1 - .../admin/cash-wallet-cutover/folder.bru | 1 - .../notoken/queries/cash-wallet-cutover.bru | 1 - operator-runs/eng-345-manual-347/findings.md | 6 - .../funding-retry-results.json | 97 ---- .../eng-345-manual-347/manifest.json | 78 --- .../eng-345-manual-347/prep-348-results.json | 344 ------------- operator-runs/eng-345-manual-347/progress.md | 45 -- operator-runs/eng-345-manual-347/results.json | 95 ---- operator-runs/eng-345-manual-347/run.ts | 477 ------------------ operator-runs/eng-345-manual-347/task_plan.md | 70 --- .../verify-348-prep-results.json | 74 --- 15 files changed, 1292 deletions(-) delete mode 100644 operator-runs/eng-345-manual-347/findings.md delete mode 100644 operator-runs/eng-345-manual-347/funding-retry-results.json delete mode 100644 operator-runs/eng-345-manual-347/manifest.json delete mode 100644 operator-runs/eng-345-manual-347/prep-348-results.json delete mode 100644 operator-runs/eng-345-manual-347/progress.md delete mode 100644 operator-runs/eng-345-manual-347/results.json delete mode 100644 operator-runs/eng-345-manual-347/run.ts delete mode 100644 operator-runs/eng-345-manual-347/task_plan.md delete mode 100644 operator-runs/eng-345-manual-347/verify-348-prep-results.json diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru index dfdf9e130..1cc27b2f2 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru @@ -46,4 +46,3 @@ script:post-response { ]) }) } - diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru index 2240d8493..963c26430 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru @@ -59,4 +59,3 @@ script:post-response { expect(config.runId).to.eql("manual-eng-345") }) } - diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru index 222d93e80..e44b497d4 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru @@ -58,4 +58,3 @@ script:post-response { expect(config.runId).to.eql("manual-eng-345") }) } - diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru index cb9d8ee55..c8ab4e0f8 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru @@ -58,4 +58,3 @@ script:post-response { expect(config.runId).to.eql("manual-eng-345") }) } - diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru index 88076d4b9..c180d00b2 100644 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru +++ b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru @@ -2,4 +2,3 @@ meta { name: cash-wallet-cutover seq: 10 } - diff --git a/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru index 79a2c7427..1c6f7cd06 100644 --- a/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru +++ b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru @@ -42,4 +42,3 @@ script:post-response { ]) }) } - diff --git a/operator-runs/eng-345-manual-347/findings.md b/operator-runs/eng-345-manual-347/findings.md deleted file mode 100644 index 79191ab38..000000000 --- a/operator-runs/eng-345-manual-347/findings.md +++ /dev/null @@ -1,6 +0,0 @@ -# Findings - -- `preparePrimaryCashWalletCutover` accepts injected repositories, so the test can scope discovery to the 10 created accounts by passing an `accountsRepo.listUnlockedAccounts()` generator for only those IDs. -- Account creation creates both USD and USDT checking wallets and defaults new accounts to USDT. For this test, each new account must be explicitly updated back to the USD wallet with `Accounts.updateDefaultWalletId`. -- Funding can use `Payments.intraledgerPaymentSendWalletIdForUsdWallet` from the funder account's USD wallet to each target legacy USD wallet. The amount argument is cents, so `$0.25` is `25` and `$0.01` is `1`. -- Cutover batch execution can use `CashWalletCutover.runPrimaryCashWalletCutoverBatch`; status and lifecycle can use the existing app lifecycle functions. diff --git a/operator-runs/eng-345-manual-347/funding-retry-results.json b/operator-runs/eng-345-manual-347/funding-retry-results.json deleted file mode 100644 index 0f9591d76..000000000 --- a/operator-runs/eng-345-manual-347/funding-retry-results.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "targetCents": 25, - "funderUsdWalletId": "37a3bce4-1930-484d-bbfc-279c1f8bfb66", - "results": [ - { - "index": 1, - "accountId": "6a11ada7e55310755eeb0257", - "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "beforeCents": 0, - "targetCents": 25, - "deltaCents": 25, - "status": { - "value": "success" - }, - "error": null, - "afterCents": 25 - }, - { - "index": 2, - "accountId": "6a11ada8e55310755eeb0271", - "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "beforeCents": 0, - "targetCents": 25, - "deltaCents": 25, - "status": { - "value": "success" - }, - "error": null, - "afterCents": 25 - }, - { - "index": 3, - "accountId": "6a11ada9e55310755eeb028b", - "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "beforeCents": 0, - "targetCents": 25, - "deltaCents": 25, - "status": { - "value": "success" - }, - "error": null, - "afterCents": 25 - }, - { - "index": 4, - "accountId": "6a11ada9e55310755eeb02a5", - "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "beforeCents": 0, - "targetCents": 25, - "deltaCents": 25, - "status": { - "value": "success" - }, - "error": null, - "afterCents": 25 - }, - { - "index": 5, - "accountId": "6a11adabe55310755eeb02bf", - "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "beforeCents": 0, - "targetCents": 25, - "deltaCents": 25, - "status": { - "value": "success" - }, - "error": null, - "afterCents": 25 - }, - { - "index": 6, - "accountId": "6a11adace55310755eeb02d9", - "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "beforeCents": 0, - "targetCents": 25, - "deltaCents": 25, - "status": { - "value": "success" - }, - "error": null, - "afterCents": 25 - }, - { - "index": 7, - "accountId": "6a11adade55310755eeb02f3", - "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "beforeCents": 0, - "targetCents": 25, - "deltaCents": 25, - "status": { - "value": "success" - }, - "error": null, - "afterCents": 25 - } - ] -} diff --git a/operator-runs/eng-345-manual-347/manifest.json b/operator-runs/eng-345-manual-347/manifest.json deleted file mode 100644 index e1af02bce..000000000 --- a/operator-runs/eng-345-manual-347/manifest.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "cutoverVersion": 347, - "runId": "manual-eng-347", - "createdAt": "2026-05-23T13:37:44.318Z", - "funderUsdWalletId": "37a3bce4-1930-484d-bbfc-279c1f8bfb66", - "accounts": [ - { - "index": 1, - "phone": "+16509-recovered-01", - "kratosUserId": "03cc58c6-a325-4966-9cf0-a96a58a8b366", - "accountId": "6a11ada7e55310755eeb0257", - "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", - "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", - "startingFundingCents": 25 - }, - { - "index": 2, - "phone": "+16509-recovered-02", - "kratosUserId": "4b7a6cea-7024-47d2-a4ba-f9df7d97aa7b", - "accountId": "6a11ada8e55310755eeb0271", - "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", - "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", - "startingFundingCents": 25 - }, - { - "index": 3, - "phone": "+16509-recovered-03", - "kratosUserId": "cb7eee02-7cb5-41a3-a4a5-1210624cb309", - "accountId": "6a11ada9e55310755eeb028b", - "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", - "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", - "startingFundingCents": 25 - }, - { - "index": 4, - "phone": "+16509-recovered-04", - "kratosUserId": "76fe0bc7-1a9c-440b-9586-317781c153f4", - "accountId": "6a11ada9e55310755eeb02a5", - "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", - "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", - "startingFundingCents": 25 - }, - { - "index": 5, - "phone": "+16509-recovered-05", - "kratosUserId": "4736f580-cd98-4750-ab16-1da95e986dc9", - "accountId": "6a11adabe55310755eeb02bf", - "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", - "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", - "startingFundingCents": 25 - }, - { - "index": 6, - "phone": "+16509-recovered-06", - "kratosUserId": "c5cab6ac-a56e-431b-a460-118d3627f7d3", - "accountId": "6a11adace55310755eeb02d9", - "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", - "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", - "startingFundingCents": 25 - }, - { - "index": 7, - "phone": "+16509-recovered-07", - "kratosUserId": "c1df1aa1-18c6-4ec9-97a6-9dfc5736be7f", - "accountId": "6a11adade55310755eeb02f3", - "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", - "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", - "startingFundingCents": 25 - } - ] -} diff --git a/operator-runs/eng-345-manual-347/prep-348-results.json b/operator-runs/eng-345-manual-347/prep-348-results.json deleted file mode 100644 index 951b9cb85..000000000 --- a/operator-runs/eng-345-manual-347/prep-348-results.json +++ /dev/null @@ -1,344 +0,0 @@ -{ - "cutoverVersion": 348, - "runId": "manual-eng-348", - "flips": [ - { - "index": 1, - "accountId": "6a11ada7e55310755eeb0257", - "defaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25" - }, - { - "index": 2, - "accountId": "6a11ada8e55310755eeb0271", - "defaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25" - }, - { - "index": 3, - "accountId": "6a11ada9e55310755eeb028b", - "defaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25" - }, - { - "index": 4, - "accountId": "6a11ada9e55310755eeb02a5", - "defaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25" - }, - { - "index": 5, - "accountId": "6a11adabe55310755eeb02bf", - "defaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25" - }, - { - "index": 6, - "accountId": "6a11adace55310755eeb02d9", - "defaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25" - }, - { - "index": 7, - "accountId": "6a11adade55310755eeb02f3", - "defaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25" - } - ], - "configReset": { - "acknowledged": true, - "modifiedCount": 1, - "upsertedId": null, - "upsertedCount": 0, - "matchedCount": 1 - }, - "preview": { - "report": { - "cutoverVersion": 348, - "runId": "manual-eng-348", - "totalAccounts": 7, - "migrationCandidates": 7, - "alreadyUsdt": 0, - "residualLegacyUsd": 0, - "blockers": 0, - "blockerAccounts": [], - "canStart": true - }, - "plannedMigrations": [ - { - "accountId": "6a11ada7e55310755eeb0257", - "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", - "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", - "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257" - }, - { - "accountId": "6a11ada8e55310755eeb0271", - "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", - "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", - "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271" - }, - { - "accountId": "6a11ada9e55310755eeb028b", - "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", - "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", - "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b" - }, - { - "accountId": "6a11ada9e55310755eeb02a5", - "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", - "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", - "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5" - }, - { - "accountId": "6a11adabe55310755eeb02bf", - "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", - "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", - "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf" - }, - { - "accountId": "6a11adace55310755eeb02d9", - "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", - "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", - "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9" - }, - { - "accountId": "6a11adade55310755eeb02f3", - "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", - "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", - "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3" - } - ] - }, - "prepared": { - "report": { - "cutoverVersion": 348, - "runId": "manual-eng-348", - "totalAccounts": 7, - "migrationCandidates": 7, - "alreadyUsdt": 0, - "residualLegacyUsd": 0, - "blockers": 0, - "blockerAccounts": [], - "canStart": true - }, - "plannedMigrations": [ - { - "accountId": "6a11ada7e55310755eeb0257", - "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", - "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", - "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257" - }, - { - "accountId": "6a11ada8e55310755eeb0271", - "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", - "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", - "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271" - }, - { - "accountId": "6a11ada9e55310755eeb028b", - "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", - "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", - "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b" - }, - { - "accountId": "6a11ada9e55310755eeb02a5", - "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", - "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", - "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5" - }, - { - "accountId": "6a11adabe55310755eeb02bf", - "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", - "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", - "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf" - }, - { - "accountId": "6a11adace55310755eeb02d9", - "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", - "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", - "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9" - }, - { - "accountId": "6a11adade55310755eeb02f3", - "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", - "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", - "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3" - } - ], - "migrations": [ - { - "id": "24353ef3-66ba-4acc-9365-1ad6e83301f0", - "accountId": "6a11ada7e55310755eeb0257", - "accountUuid": "2da55813-e2a6-4bfc-946d-dd52af5b6a99", - "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", - "previousDefaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "status": "not_started", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada7e55310755eeb0257", - "attempts": 0, - "updatedAt": "2026-05-23T14:49:36.370Z" - }, - { - "id": "d47e3b77-39d0-49ea-a5eb-e0dbbf5a306c", - "accountId": "6a11ada8e55310755eeb0271", - "accountUuid": "71b5a288-4a46-4e9d-858c-87012d55c12e", - "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", - "previousDefaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "status": "not_started", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada8e55310755eeb0271", - "attempts": 0, - "updatedAt": "2026-05-23T14:49:36.382Z" - }, - { - "id": "b5b42c93-519a-4103-b7fb-a1ff5a3dd17d", - "accountId": "6a11ada9e55310755eeb028b", - "accountUuid": "a822f6c2-c8ed-4c6e-a5ce-20c93abaad66", - "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", - "previousDefaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "status": "not_started", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb028b", - "attempts": 0, - "updatedAt": "2026-05-23T14:49:36.393Z" - }, - { - "id": "1eafc20f-3e53-4d72-ba25-48cd8d5c5c24", - "accountId": "6a11ada9e55310755eeb02a5", - "accountUuid": "87c4db3b-0aa8-47e3-942d-8fe845a6c6b7", - "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", - "previousDefaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "status": "not_started", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11ada9e55310755eeb02a5", - "attempts": 0, - "updatedAt": "2026-05-23T14:49:36.401Z" - }, - { - "id": "78fdd4b6-9c81-4883-a0e0-0bab20491c17", - "accountId": "6a11adabe55310755eeb02bf", - "accountUuid": "bbf1af43-70ef-4e22-a40c-eba31289735a", - "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", - "previousDefaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "status": "not_started", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adabe55310755eeb02bf", - "attempts": 0, - "updatedAt": "2026-05-23T14:49:36.406Z" - }, - { - "id": "9cf2b55b-d118-4b2a-a322-9c857e2dee8d", - "accountId": "6a11adace55310755eeb02d9", - "accountUuid": "06ee59a8-8d59-4139-99d1-92378e5b8011", - "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", - "previousDefaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "status": "not_started", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adace55310755eeb02d9", - "attempts": 0, - "updatedAt": "2026-05-23T14:49:36.414Z" - }, - { - "id": "20e0769a-6c20-4475-bb0a-9eda26698668", - "accountId": "6a11adade55310755eeb02f3", - "accountUuid": "019555c0-b26a-468f-b11e-5bbe69048e02", - "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", - "previousDefaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "status": "not_started", - "idempotencyKey": "cash-wallet-cutover:manual-eng-348:6a11adade55310755eeb02f3", - "attempts": 0, - "updatedAt": "2026-05-23T14:49:36.424Z" - } - ] - }, - "status": { - "config": { - "state": "pre", - "updatedBy": "manual-local", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "updatedAt": "2026-05-23T14:49:36.246Z" - }, - "countsByStatus": { - "not_started": 7 - } - } -} diff --git a/operator-runs/eng-345-manual-347/progress.md b/operator-runs/eng-345-manual-347/progress.md deleted file mode 100644 index 7e433b28a..000000000 --- a/operator-runs/eng-345-manual-347/progress.md +++ /dev/null @@ -1,45 +0,0 @@ -- 2026-05-23T13:37:13.813Z ERROR Cannot read properties of undefined (reading 'findOne') -- 2026-05-23T13:37:44.318Z created account 1: 6a11ada7e55310755eeb0257 USD=0a4d1c55-d8ec-4685-8457-216d41569d61 USDT=8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6 funding=25 -- 2026-05-23T13:37:45.186Z created account 2: 6a11ada8e55310755eeb0271 USD=08ee5f13-b4c3-4ff5-835a-55dd786d3887 USDT=741a97b0-9612-4f34-af32-ed28c0a3b5fd funding=25 -- 2026-05-23T13:37:45.866Z created account 3: 6a11ada9e55310755eeb028b USD=697b8583-6e1d-47d5-aba0-8b2c8aa6bc32 USDT=144db453-fdb8-4180-b405-097104637644 funding=25 -- 2026-05-23T13:37:47.835Z created account 4: 6a11ada9e55310755eeb02a5 USD=dbdfba0c-ead0-4c94-96f8-e15130b7f796 USDT=ae3bddb2-1bb7-4019-8390-39694881c91b funding=25 -- 2026-05-23T13:37:48.713Z created account 5: 6a11adabe55310755eeb02bf USD=9756a443-d30d-4b1c-a797-933ec2ced1d0 USDT=fa7c7780-9246-4088-beb6-56fcd98e3209 funding=25 -- 2026-05-23T13:37:49.430Z created account 6: 6a11adace55310755eeb02d9 USD=ecaf103c-d024-419a-8bad-b0c61e8068be USDT=53884f7a-0a55-4406-8b60-3c6d68a6c180 funding=25 -- 2026-05-23T13:37:50.263Z created account 7: 6a11adade55310755eeb02f3 USD=8a3e1bd4-4d2d-4f97-b201-d58d3723a53c USDT=80f0de1f-b4e6-4904-8c3f-d90bad763ce2 funding=25 -- 2026-05-23T13:37:50.789Z ERROR -- 2026-05-23T13:39:59.481Z ERROR -- 2026-05-23T13:41:36.710Z ERROR -- 2026-05-23T13:42:04.155Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json -- 2026-05-23T13:42:26.832Z ERROR -- 2026-05-23T13:44:28.884Z ERROR -- 2026-05-23T13:50:13.799Z preview planned=7 -- 2026-05-23T13:50:27.921Z prepared migrations=7 -- 2026-05-23T13:50:44.208Z reset singleton cutover config to pre for manual-eng-347 -- 2026-05-23T13:50:48.388Z started state=in_progress -- 2026-05-23T13:51:03.235Z batch 1: {"attempted":7,"advanced":7,"failed":0,"skipped":0} -- 2026-05-23T13:51:03.298Z batch 2: {"attempted":7,"advanced":7,"failed":0,"skipped":0} -- 2026-05-23T13:51:06.709Z batch 3: {"attempted":7,"advanced":7,"failed":0,"skipped":0} -- 2026-05-23T13:51:06.840Z batch 4: {"attempted":7,"advanced":7,"failed":0,"skipped":0} -- 2026-05-23T13:51:08.144Z batch 5: {"attempted":7,"advanced":7,"failed":0,"skipped":0} -- 2026-05-23T13:51:08.238Z batch 6: {"attempted":7,"advanced":7,"failed":0,"skipped":0} -- 2026-05-23T13:51:08.268Z all migrations complete after batch 6 -- 2026-05-23T13:51:25.286Z completed lifecycle state=complete -- 2026-05-23T13:51:31.895Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json -- 2026-05-23T14:05:39.454Z funding retry account 1: before=0 delta=25 status=[object Object] after=25 -- 2026-05-23T14:05:42.219Z funding retry account 2: before=0 delta=25 status=[object Object] after=25 -- 2026-05-23T14:05:44.498Z funding retry account 3: before=0 delta=25 status=[object Object] after=25 -- 2026-05-23T14:05:46.786Z funding retry account 4: before=0 delta=25 status=[object Object] after=25 -- 2026-05-23T14:05:49.479Z funding retry account 5: before=0 delta=25 status=[object Object] after=25 -- 2026-05-23T14:05:50.986Z funding retry account 6: before=0 delta=25 status=[object Object] after=25 -- 2026-05-23T14:05:52.277Z funding retry account 7: before=0 delta=25 status=[object Object] after=25 -- 2026-05-23T14:06:31.393Z wrote verification results to /Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review/operator-runs/eng-345-manual-347/results.json -- 2026-05-23T14:49:35.240Z reset account 1 defaultWalletId=0a4d1c55-d8ec-4685-8457-216d41569d61 fundedUsdCents=25 -- 2026-05-23T14:49:35.433Z reset account 2 defaultWalletId=08ee5f13-b4c3-4ff5-835a-55dd786d3887 fundedUsdCents=25 -- 2026-05-23T14:49:35.582Z reset account 3 defaultWalletId=697b8583-6e1d-47d5-aba0-8b2c8aa6bc32 fundedUsdCents=25 -- 2026-05-23T14:49:35.729Z reset account 4 defaultWalletId=dbdfba0c-ead0-4c94-96f8-e15130b7f796 fundedUsdCents=25 -- 2026-05-23T14:49:35.950Z reset account 5 defaultWalletId=9756a443-d30d-4b1c-a797-933ec2ced1d0 fundedUsdCents=25 -- 2026-05-23T14:49:36.092Z reset account 6 defaultWalletId=ecaf103c-d024-419a-8bad-b0c61e8068be fundedUsdCents=25 -- 2026-05-23T14:49:36.245Z reset account 7 defaultWalletId=8a3e1bd4-4d2d-4f97-b201-d58d3723a53c fundedUsdCents=25 -- 2026-05-23T14:49:36.256Z reset singleton cutover config to pre for manual-eng-348 -- 2026-05-23T14:49:36.323Z preview manual-eng-348 planned=7 -- 2026-05-23T14:49:36.431Z prepared manual-eng-348 migrations=7 diff --git a/operator-runs/eng-345-manual-347/results.json b/operator-runs/eng-345-manual-347/results.json deleted file mode 100644 index 57d76e89c..000000000 --- a/operator-runs/eng-345-manual-347/results.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "status": { - "config": { - "state": "complete", - "startedAt": "2026-05-23T13:50:48.355Z", - "completedAt": "2026-05-23T13:51:25.254Z", - "updatedBy": "manual-local", - "cutoverVersion": 347, - "runId": "manual-eng-347", - "updatedAt": "2026-05-23T13:51:25.281Z" - }, - "countsByStatus": { - "complete": 7 - } - }, - "accounts": [ - { - "index": 1, - "accountId": "6a11ada7e55310755eeb0257", - "expectedFundingCents": 25, - "defaultWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", - "defaultIsDestinationUsdt": true, - "legacyUsdWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "destinationUsdtWalletId": "8a97ec4c-bf67-4a74-a5c2-bc8cf28e99c6", - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 2, - "accountId": "6a11ada8e55310755eeb0271", - "expectedFundingCents": 25, - "defaultWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", - "defaultIsDestinationUsdt": true, - "legacyUsdWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "destinationUsdtWalletId": "741a97b0-9612-4f34-af32-ed28c0a3b5fd", - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 3, - "accountId": "6a11ada9e55310755eeb028b", - "expectedFundingCents": 25, - "defaultWalletId": "144db453-fdb8-4180-b405-097104637644", - "defaultIsDestinationUsdt": true, - "legacyUsdWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "destinationUsdtWalletId": "144db453-fdb8-4180-b405-097104637644", - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 4, - "accountId": "6a11ada9e55310755eeb02a5", - "expectedFundingCents": 25, - "defaultWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", - "defaultIsDestinationUsdt": true, - "legacyUsdWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "destinationUsdtWalletId": "ae3bddb2-1bb7-4019-8390-39694881c91b", - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 5, - "accountId": "6a11adabe55310755eeb02bf", - "expectedFundingCents": 25, - "defaultWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", - "defaultIsDestinationUsdt": true, - "legacyUsdWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "destinationUsdtWalletId": "fa7c7780-9246-4088-beb6-56fcd98e3209", - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 6, - "accountId": "6a11adace55310755eeb02d9", - "expectedFundingCents": 25, - "defaultWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", - "defaultIsDestinationUsdt": true, - "legacyUsdWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "destinationUsdtWalletId": "53884f7a-0a55-4406-8b60-3c6d68a6c180", - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 7, - "accountId": "6a11adade55310755eeb02f3", - "expectedFundingCents": 25, - "defaultWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", - "defaultIsDestinationUsdt": true, - "legacyUsdWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "destinationUsdtWalletId": "80f0de1f-b4e6-4904-8c3f-d90bad763ce2", - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - } - ] -} diff --git a/operator-runs/eng-345-manual-347/run.ts b/operator-runs/eng-345-manual-347/run.ts deleted file mode 100644 index 3438416d3..000000000 --- a/operator-runs/eng-345-manual-347/run.ts +++ /dev/null @@ -1,477 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import { randomUUID } from "crypto" - -import { getDefaultAccountsConfig } from "@config" - -import { Accounts, CashWalletCutover, Payments, Wallets } from "@app" -import { getBalanceForWallet } from "@app/wallets" -import { WalletCurrency } from "@domain/shared" -import { PaymentSendStatus } from "@domain/bitcoin/lightning" -import { WalletType } from "@domain/wallets" - -import { setupMongoConnection } from "@services/mongodb" -import { - AccountsRepository, - CashWalletCutoverRepository, - WalletsRepository, -} from "@services/mongoose" -import { Account, CashWalletCutoverConfig } from "@services/mongoose/schema" - -type TargetAccount = { - index: number - phone: string - kratosUserId: string - accountId: string - accountUuid?: string - legacyUsdWalletId: string - destinationUsdtWalletId: string - startingFundingCents: number -} - -type Manifest = { - cutoverVersion: number - runId: string - createdAt: string - funderUsdWalletId?: string - accounts: TargetAccount[] -} - -const CUTOVER_VERSION = 347 -const RUN_ID = "manual-eng-347" -const ACCOUNT_COUNT = 10 -const OUTPUT_DIR = path.resolve("operator-runs/eng-345-manual-347") -const MANIFEST_PATH = path.join(OUTPUT_DIR, "manifest.json") -const RESULTS_PATH = path.join(OUTPUT_DIR, "results.json") -const PROGRESS_PATH = path.join(OUTPUT_DIR, "progress.md") - -const logProgress = async (line: string) => { - await fs.appendFile(PROGRESS_PATH, `- ${new Date().toISOString()} ${line}\n`) -} - -const throwIfError = (result: T | Error): T => { - if (result instanceof Error) throw result - return result -} - -const loadManifest = async (): Promise => { - const raw = await fs.readFile(MANIFEST_PATH, "utf8") - return JSON.parse(raw) as Manifest -} - -const saveManifest = async (manifest: Manifest) => { - await fs.writeFile(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`) -} - -const listWalletsForAccount = async (accountId: string) => { - const wallets = throwIfError( - await WalletsRepository().listByAccountId(accountId as AccountId), - ) - const usd = wallets.find((wallet) => wallet.currency === WalletCurrency.Usd) - const usdt = wallets.find((wallet) => wallet.currency === WalletCurrency.Usdt) - - if (!usd) throw new Error(`Missing USD wallet for account ${accountId}`) - if (!usdt) throw new Error(`Missing USDT wallet for account ${accountId}`) - - return { usd, usdt } -} - -const funderUsdWalletId = async (): Promise => { - const funder = await Account.findOne({ role: "funder" }) - if (!funder) throw new Error("Missing funder account") - - const wallets = throwIfError( - await WalletsRepository().listByAccountId(funder._id.toString() as AccountId), - ) - const usdWallet = wallets.find((wallet) => wallet.currency === WalletCurrency.Usd) - if (!usdWallet) throw new Error("Missing funder USD wallet") - - return usdWallet.id -} - -const createAccounts = async () => { - await fs.mkdir(OUTPUT_DIR, { recursive: true }) - - const config = getDefaultAccountsConfig() - const existing = await fs - .readFile(MANIFEST_PATH, "utf8") - .then((raw) => JSON.parse(raw) as Manifest) - .catch(() => undefined) - const funderWalletId = existing?.funderUsdWalletId ?? (await funderUsdWalletId()) - const accounts: TargetAccount[] = existing?.accounts ?? [] - const stamp = Date.now().toString().slice(-7) - - for (let i = accounts.length + 1; i <= ACCOUNT_COUNT; i += 1) { - const suffix = `${stamp}${String(i).padStart(2, "0")}` - const phone = `+16509${suffix}` as PhoneNumber - const kratosUserId = randomUUID() as UserId - - const account = throwIfError( - await Accounts.createAccountWithPhoneIdentifier({ - newAccountInfo: { kratosUserId, phone }, - config, - }), - ) - - const { usd, usdt } = await listWalletsForAccount(account.id) - - throwIfError( - await Accounts.updateDefaultWalletId({ - accountId: account.id, - walletId: usd.id, - }), - ) - - const fundingCents = i <= 8 ? 25 : i === 9 ? 1 : 0 - - accounts.push({ - index: i, - phone, - kratosUserId, - accountId: account.id, - accountUuid: account.uuid, - legacyUsdWalletId: usd.id, - destinationUsdtWalletId: usdt.id, - startingFundingCents: fundingCents, - }) - - await logProgress( - `created account ${i}: ${account.id} USD=${usd.id} USDT=${usdt.id} funding=${fundingCents}`, - ) - - await saveManifest({ - cutoverVersion: CUTOVER_VERSION, - runId: RUN_ID, - createdAt: existing?.createdAt ?? new Date().toISOString(), - funderUsdWalletId: funderWalletId, - accounts, - }) - } - - const manifest: Manifest = { - cutoverVersion: CUTOVER_VERSION, - runId: RUN_ID, - createdAt: existing?.createdAt ?? new Date().toISOString(), - funderUsdWalletId: funderWalletId, - accounts, - } - await saveManifest(manifest) - console.log(JSON.stringify(manifest, null, 2)) -} - -const completePartialAccounts = async () => { - const manifest = await loadManifest() - const existingIds = new Set(manifest.accounts.map((account) => account.accountId)) - const partials = await Account.find({ - role: "user", - defaultWalletId: { $exists: false }, - created_at: { $gte: new Date(Date.now() - 60 * 60 * 1000) }, - }).sort({ created_at: 1 }) - - for (const partial of partials) { - if (manifest.accounts.length >= ACCOUNT_COUNT) break - const accountId = partial._id.toString() - if (existingIds.has(accountId)) continue - - const usd = throwIfError( - await WalletsRepository().persistNew({ - accountId: accountId as AccountId, - type: WalletType.Checking, - currency: WalletCurrency.Usd, - }), - ) - const usdt = throwIfError( - await WalletsRepository().persistNew({ - accountId: accountId as AccountId, - type: WalletType.Checking, - currency: WalletCurrency.Usdt, - }), - ) - throwIfError( - await Accounts.updateDefaultWalletId({ - accountId: accountId as AccountId, - walletId: usd.id, - }), - ) - - const index = manifest.accounts.length + 1 - const fundingCents = index <= 8 ? 25 : index === 9 ? 1 : 0 - manifest.accounts.push({ - index, - phone: `partial-${index}`, - kratosUserId: partial.kratosUserId, - accountId, - accountUuid: partial.id, - legacyUsdWalletId: usd.id, - destinationUsdtWalletId: usdt.id, - startingFundingCents: fundingCents, - }) - existingIds.add(accountId) - await saveManifest(manifest) - await logProgress( - `completed partial account ${index}: ${accountId} USD=${usd.id} USDT=${usdt.id} funding=${fundingCents}`, - ) - } - - console.log(JSON.stringify(manifest, null, 2)) -} - -const fundAccounts = async () => { - const manifest = await loadManifest() - if (!manifest.funderUsdWalletId) { - manifest.funderUsdWalletId = await funderUsdWalletId() - await saveManifest(manifest) - } - - for (const account of manifest.accounts) { - if (account.startingFundingCents === 0) { - await logProgress(`left account ${account.index} unfunded`) - continue - } - - const status = throwIfError( - await Payments.intraledgerPaymentSendWalletIdForUsdWallet({ - senderWalletId: manifest.funderUsdWalletId, - recipientWalletId: account.legacyUsdWalletId, - amount: account.startingFundingCents, - memo: `ENG-345 ${RUN_ID} seed ${account.index}`, - }), - ) - - if (status !== PaymentSendStatus.Success && status !== PaymentSendStatus.Pending) { - throw new Error(`Funding account ${account.index} returned ${status}`) - } - - await logProgress( - `funded account ${account.index} ${account.legacyUsdWalletId} with ${account.startingFundingCents} cents status=${status}`, - ) - } -} - -const scopedAccountsRepo = (targetIds: Set) => ({ - async *listUnlockedAccounts() { - for (const accountId of targetIds) { - yield throwIfError(await AccountsRepository().findById(accountId as AccountId)) - } - }, -}) - -const preview = async () => { - const manifest = await loadManifest() - const result = throwIfError( - await CashWalletCutover.previewPrimaryCashWalletCutover({ - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - accountsRepo: scopedAccountsRepo(new Set(manifest.accounts.map((a) => a.accountId))), - walletsRepo: WalletsRepository(), - }), - ) - console.log(JSON.stringify(result, null, 2)) - await logProgress(`preview planned=${result.plannedMigrations.length}`) -} - -const prepare = async () => { - const manifest = await loadManifest() - const result = throwIfError( - await CashWalletCutover.preparePrimaryCashWalletCutover({ - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - accountsRepo: scopedAccountsRepo(new Set(manifest.accounts.map((a) => a.accountId))), - walletsRepo: WalletsRepository(), - migrationsRepo: CashWalletCutoverRepository(), - }), - ) - console.log(JSON.stringify(result, null, 2)) - await logProgress(`prepared migrations=${result.migrations.length}`) -} - -const start = async () => { - const manifest = await loadManifest() - const result = throwIfError( - await CashWalletCutover.startPrimaryCashWalletCutover({ - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - actor: "manual-local", - migrationsRepo: CashWalletCutoverRepository(), - }), - ) - console.log(JSON.stringify(result, null, 2)) - await logProgress(`started state=${result.state}`) -} - -const runBatches = async () => { - const manifest = await loadManifest() - const batches = [] - - for (let i = 1; i <= 40; i += 1) { - const result = throwIfError( - await CashWalletCutover.runPrimaryCashWalletCutoverBatch({ - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - workerId: "manual-local", - limit: ACCOUNT_COUNT, - lockStaleBefore: new Date(Date.now() - 300_000), - migrationsRepo: CashWalletCutoverRepository(), - }), - ) - batches.push(result) - await logProgress(`batch ${i}: ${JSON.stringify(result)}`) - - const status = throwIfError( - await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - migrationsRepo: CashWalletCutoverRepository(), - }), - ) - - if (result.failed > 0) { - console.log(JSON.stringify({ batches, status }, null, 2)) - throw new Error(`Batch ${i} failed`) - } - - if (Object.keys(status.countsByStatus).length === 1 && status.countsByStatus.complete) { - console.log(JSON.stringify({ batches, status }, null, 2)) - await logProgress(`all migrations complete after batch ${i}`) - return - } - - if (result.attempted === 0) { - console.log(JSON.stringify({ batches, status }, null, 2)) - throw new Error("No runnable migrations, but run is not complete") - } - } - - throw new Error("Exceeded maximum batch count") -} - -const complete = async () => { - const manifest = await loadManifest() - const result = throwIfError( - await CashWalletCutover.completePrimaryCashWalletCutover({ - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - actor: "manual-local", - migrationsRepo: CashWalletCutoverRepository(), - }), - ) - console.log(JSON.stringify(result, null, 2)) - await logProgress(`completed lifecycle state=${result.state}`) -} - -const status = async () => { - const manifest = await loadManifest() - const result = throwIfError( - await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - migrationsRepo: CashWalletCutoverRepository(), - }), - ) - console.log(JSON.stringify(result, null, 2)) -} - -const resetConfig = async () => { - const manifest = await loadManifest() - const result = await CashWalletCutoverConfig.updateOne( - { _id: "cash_wallet_cutover" }, - { - $set: { - state: "pre", - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - updatedBy: "manual-local", - updatedAt: new Date(), - }, - $unset: { - scheduledAt: "", - startedAt: "", - completedAt: "", - pausedAt: "", - pauseReason: "", - }, - }, - { upsert: true }, - ) - console.log(JSON.stringify(result, null, 2)) - await logProgress(`reset singleton cutover config to pre for ${manifest.runId}`) -} - -const verify = async () => { - const manifest = await loadManifest() - const rows = [] - - for (const target of manifest.accounts) { - const account = throwIfError( - await AccountsRepository().findById(target.accountId as AccountId), - ) - const usdBalance = throwIfError( - await getBalanceForWallet({ - walletId: target.legacyUsdWalletId as WalletId, - currency: WalletCurrency.Usd, - }), - ) - const usdtBalance = throwIfError( - await getBalanceForWallet({ - walletId: target.destinationUsdtWalletId as WalletId, - currency: WalletCurrency.Usdt, - }), - ) - - rows.push({ - index: target.index, - accountId: target.accountId, - expectedFundingCents: target.startingFundingCents, - defaultWalletId: account.defaultWalletId, - defaultIsDestinationUsdt: account.defaultWalletId === target.destinationUsdtWalletId, - legacyUsdWalletId: target.legacyUsdWalletId, - destinationUsdtWalletId: target.destinationUsdtWalletId, - legacyUsdBalanceCents: usdBalance.asCents(), - destinationUsdtBalanceMicros: usdtBalance.asSmallestUnits(), - }) - } - - const statusResult = throwIfError( - await CashWalletCutover.getPrimaryCashWalletCutoverStatus({ - cutoverVersion: manifest.cutoverVersion, - runId: manifest.runId, - migrationsRepo: CashWalletCutoverRepository(), - }), - ) - - const result = { status: statusResult, accounts: rows } - await fs.writeFile(RESULTS_PATH, `${JSON.stringify(result, null, 2)}\n`) - console.log(JSON.stringify(result, null, 2)) - await logProgress(`wrote verification results to ${RESULTS_PATH}`) -} - -const commands: Record Promise> = { - "create-accounts": createAccounts, - "complete-partials": completePartialAccounts, - fund: fundAccounts, - preview, - prepare, - start, - "run-batches": runBatches, - complete, - status, - "reset-config": resetConfig, - verify, -} - -setupMongoConnection() - .then(async (mongoose) => { - const command = process.argv.find((arg) => commands[arg]) - if (!command || !commands[command]) { - throw new Error(`Expected command: ${Object.keys(commands).join(", ")}`) - } - - await commands[command]() - await mongoose?.connection.close() - process.exit(0) - }) - .catch(async (error) => { - await logProgress(`ERROR ${error instanceof Error ? error.message : String(error)}`) - console.error(error) - process.exit(1) - }) diff --git a/operator-runs/eng-345-manual-347/task_plan.md b/operator-runs/eng-345-manual-347/task_plan.md deleted file mode 100644 index a7240b9cf..000000000 --- a/operator-runs/eng-345-manual-347/task_plan.md +++ /dev/null @@ -1,70 +0,0 @@ -# Task Plan: ENG-345 Fresh 7-Account Manual Cutover - -## Goal -Run the full cash-wallet cutover pipeline on the 7 fresh local accounts that were successfully created with legacy USD default wallets. - -## Current Phase -Phase 6 - -## Phases - -### Phase 1: Discovery -- [x] Find existing account creation/funding helpers -- [x] Confirm database and service config for the local run -- [x] Document reusable commands -- **Status:** complete - -### Phase 2: Test Data Setup -- [x] Create 7 new accounts -- [x] Capture account IDs and USD/USDT wallet IDs for 7 complete accounts -- [x] Set 7 complete accounts' `defaultWalletId` to legacy USD wallet ID -- [x] Leave all 7 legacy USD wallets at zero balance -- **Status:** complete - -### Phase 3: Cutover Pipeline -- [x] Preview run -- [x] Prepare run -- [x] Start run -- [x] Run batches until all migrations reach terminal state or failure -- [x] Complete lifecycle if all migrations complete -- **Status:** complete - -### Phase 4: Verification -- [x] Verify migration counts -- [x] Verify account default wallet pointers changed to USDT -- [x] Verify source/destination amounts for zero-balance accounts -- [x] Document any failures and recovery actions -- **Status:** complete - -### Phase 5: Report -- [x] Summarize setup, commands, and final status -- [x] Update session memory -- **Status:** complete - -### Phase 6: Reset and Prep Funded Rerun -- [x] Verify seven funded legacy USD balances -- [x] Reset seven account `defaultWalletId` values to legacy USD wallets -- [x] Create fresh cutover prep run for the same seven accounts -- [x] Verify migration prep count and account pointers -- **Status:** complete - -## Key Parameters -- Worktree: `/Users/dread/Documents/Island-Bitcoin/Flash/flash/.worktrees/eng-345-review` -- Planned cutoverVersion: `347` -- Planned runId: `manual-eng-347` -- Account count: `7` -- Funding: `7 x $0.00` - -## Decisions Made -| Decision | Rationale | -|----------|-----------| -| Use a fresh run/version instead of rewinding manual-eng-346 | manual-eng-346 already moved funds and completed; fresh run gives clean manual-test evidence | -| Convert test to 7 zero-balance accounts | Dread requested changing the plan to a 7-account cutover after IBEX write failures blocked creating/funding 10 accounts | -| Use `cutoverVersion=348`, `runId=manual-eng-348` for the funded rerun prep | Version 347 already completed as the zero-balance cutover, so a new run keeps evidence separated | - -## Errors Encountered -| Error | Attempt | Resolution | -|-------|---------|------------| -| Config loader tried to read `create-accounts` as a YAML file; `Account` model import was undefined | 1 | Patched operator script to locate command anywhere in argv and import `Account` from `@services/mongoose/schema`; rerun with command before `--configPath` | -| IBEX fetch error while creating the 8th account left 7 complete accounts and one partial account without wallets/default | 2 | Reconstructed a manifest for the 7 known-good accounts, excluded the partial account, and patched script to save/resume manifest incrementally | -| IBEX write path continued returning blank `FetchError` after cooldown for partial wallet creation and funding invoice creation | 3 | Stopped rather than running an incomplete/fabricated 10-account cutover; verified reads still work and documented resume state | diff --git a/operator-runs/eng-345-manual-347/verify-348-prep-results.json b/operator-runs/eng-345-manual-347/verify-348-prep-results.json deleted file mode 100644 index cb35272d1..000000000 --- a/operator-runs/eng-345-manual-347/verify-348-prep-results.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "cutoverVersion": 348, - "runId": "manual-eng-348", - "status": { - "config": { - "state": "pre", - "updatedBy": "manual-local", - "cutoverVersion": 348, - "runId": "manual-eng-348", - "updatedAt": "2026-05-23T14:49:36.246Z" - }, - "countsByStatus": { - "not_started": 7 - } - }, - "accounts": [ - { - "index": 1, - "accountId": "6a11ada7e55310755eeb0257", - "defaultWalletId": "0a4d1c55-d8ec-4685-8457-216d41569d61", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 2, - "accountId": "6a11ada8e55310755eeb0271", - "defaultWalletId": "08ee5f13-b4c3-4ff5-835a-55dd786d3887", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 3, - "accountId": "6a11ada9e55310755eeb028b", - "defaultWalletId": "697b8583-6e1d-47d5-aba0-8b2c8aa6bc32", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 4, - "accountId": "6a11ada9e55310755eeb02a5", - "defaultWalletId": "dbdfba0c-ead0-4c94-96f8-e15130b7f796", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 5, - "accountId": "6a11adabe55310755eeb02bf", - "defaultWalletId": "9756a443-d30d-4b1c-a797-933ec2ced1d0", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 6, - "accountId": "6a11adace55310755eeb02d9", - "defaultWalletId": "ecaf103c-d024-419a-8bad-b0c61e8068be", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - }, - { - "index": 7, - "accountId": "6a11adade55310755eeb02f3", - "defaultWalletId": "8a3e1bd4-4d2d-4f97-b201-d58d3723a53c", - "defaultIsLegacyUsd": true, - "legacyUsdBalanceCents": "25", - "destinationUsdtBalanceMicros": "0" - } - ] -} From 64d5377afc539a61be0ab842572f2363bafc85d2 Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 1 Jun 2026 08:53:14 -0700 Subject: [PATCH 36/40] chore(cutover): trim bridge PR noise --- .env | 8 +- DEV.md | 28 ++++-- .../01-query-admin-state.bru | 48 ---------- .../02-set-scheduled-pre.bru | 61 ------------- .../03-set-in-progress.bru | 60 ------------- .../cash-wallet-cutover/04-set-complete.bru | 60 ------------- .../admin/cash-wallet-cutover/folder.bru | 4 - .../Flash GraphQL API/environments/local.bru | 8 +- .../notoken/queries/cash-wallet-cutover.bru | 44 ---------- dev/config/set-overrides.sh | 7 +- dev/setup.sh | 26 +++--- docker-compose.yml | 5 +- ...-05-26-local-cutover-operator-dashboard.md | 88 ------------------- ...6-05-28-cutover-dashboard-lazy-balances.md | 77 ---------------- src/scripts/cash-wallet-cutover-dashboard.ts | 7 +- .../runtime-services.spec.ts | 17 ++-- 16 files changed, 57 insertions(+), 491 deletions(-) delete mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru delete mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru delete mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru delete mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru delete mode 100644 dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru delete mode 100644 dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru delete mode 100644 docs/plans/2026-05-26-local-cutover-operator-dashboard.md delete mode 100644 docs/plans/2026-05-28-cutover-dashboard-lazy-balances.md diff --git a/.env b/.env index 59076eba4..44699ef99 100644 --- a/.env +++ b/.env @@ -123,8 +123,8 @@ export ERPNEXT_JWT_SECRET="not-so-secret" COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml:docker-compose.local.yml -# Ibex API — app reads from yaml config, not env vars -# Get sandbox credentials from your team lead -# export IBEX_CLIENT_ID="" -# export IBEX_CLIENT_SECRET="" +# Ibex API (used by docker compose; app reads from yaml config) +export IBEX_URL="https://api-sandbox.poweredbyibex.io" +export IBEX_EMAIL="" +export IBEX_PASSWORD="" diff --git a/DEV.md b/DEV.md index 05f11208c..0b17a2e58 100644 --- a/DEV.md +++ b/DEV.md @@ -38,11 +38,30 @@ If you prefer to set things up yourself, or if the setup script fails: ### 1. Environment Variables -Flash uses YAML config files. Ibex OAuth2 credentials go in local config overrides, not env vars. +The project loads environment variables from `.env` (committed) and `.env.local` (git-ignored, for secrets). + +Create `.env.local` with your Ibex sandbox credentials: + +```bash +echo "export IBEX_EMAIL='your-ibex-email'" >> .env.local +echo "export IBEX_PASSWORD='your-ibex-password'" >> .env.local +``` + +If you use direnv, allow it: + +```bash +direnv allow +``` + +If not using direnv, source the env files manually before running commands: + +```bash +source .env && source .env.local +``` ### 2. App Config Overrides -The base config is at `dev/config/base-config.yaml`. Secrets and local overrides go in `$CONFIG_PATH/dev-overrides.yaml` (default: `~/.config/flash/dev-overrides.yaml`). +Flash uses YAML config files. The base config is at `dev/config/base-config.yaml`. Secrets and local overrides go in `$CONFIG_PATH/dev-overrides.yaml` (default: `~/.config/flash/dev-overrides.yaml`). **Option A — Run the interactive script:** @@ -55,9 +74,8 @@ The base config is at `dev/config/base-config.yaml`. Secrets and local overrides ```yaml # ~/.config/flash/dev-overrides.yaml ibex: - clientId: your-sandbox-client-id - clientSecret: your-sandbox-client-secret - environment: sandbox + email: your-ibex-email + password: your-ibex-password ``` Additional overrides you might need: diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru deleted file mode 100644 index 1cc27b2f2..000000000 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/01-query-admin-state.bru +++ /dev/null @@ -1,48 +0,0 @@ -meta { - name: 01-query-admin-state - type: graphql - seq: 1 -} - -post { - url: {{admin_url}} - body: graphql - auth: bearer -} - -auth:bearer { - token: {{admin_token}} -} - -body:graphql { - query CashWalletCutoverAdminState { - cashWalletCutover { - state - scheduledAt - startedAt - completedAt - pausedAt - pauseReason - cutoverVersion - runId - updatedBy - updatedAt - } - } -} - -body:graphql:vars { - {} -} - -script:post-response { - test("admin cutover state returns without GraphQL errors", function () { - const jsonData = res.getBody() - expect(jsonData.errors).to.be.undefined - expect(jsonData.data.cashWalletCutover.state).to.be.oneOf([ - "PRE", - "IN_PROGRESS", - "COMPLETE", - ]) - }) -} diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru deleted file mode 100644 index 963c26430..000000000 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/02-set-scheduled-pre.bru +++ /dev/null @@ -1,61 +0,0 @@ -meta { - name: 02-set-scheduled-pre - type: graphql - seq: 2 -} - -post { - url: {{admin_url}} - body: graphql - auth: bearer -} - -auth:bearer { - token: {{admin_token}} -} - -body:graphql { - mutation CashWalletCutoverSetScheduledPre($input: CashWalletCutoverUpdateInput!) { - cashWalletCutoverUpdate(input: $input) { - errors { - message - } - cashWalletCutover { - state - scheduledAt - startedAt - completedAt - pauseReason - cutoverVersion - runId - updatedBy - updatedAt - } - } - } -} - -body:graphql:vars { - { - "input": { - "state": "PRE", - "scheduledAt": "2026-05-22T15:00:00.000Z", - "cutoverVersion": 345, - "runId": "manual-eng-345", - "pauseReason": "manual ENG-345 preflight" - } - } -} - -script:post-response { - test("sets cutover config to PRE with schedule metadata", function () { - const jsonData = res.getBody() - expect(jsonData.errors).to.be.undefined - expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) - - const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover - expect(config.state).to.eql("PRE") - expect(config.cutoverVersion).to.eql(345) - expect(config.runId).to.eql("manual-eng-345") - }) -} diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru deleted file mode 100644 index e44b497d4..000000000 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/03-set-in-progress.bru +++ /dev/null @@ -1,60 +0,0 @@ -meta { - name: 03-set-in-progress - type: graphql - seq: 3 -} - -post { - url: {{admin_url}} - body: graphql - auth: bearer -} - -auth:bearer { - token: {{admin_token}} -} - -body:graphql { - mutation CashWalletCutoverSetInProgress($input: CashWalletCutoverUpdateInput!) { - cashWalletCutoverUpdate(input: $input) { - errors { - message - } - cashWalletCutover { - state - scheduledAt - startedAt - completedAt - pauseReason - cutoverVersion - runId - updatedBy - updatedAt - } - } - } -} - -body:graphql:vars { - { - "input": { - "state": "IN_PROGRESS", - "cutoverVersion": 345, - "runId": "manual-eng-345", - "pauseReason": "manual ENG-345 in-progress test" - } - } -} - -script:post-response { - test("sets cutover config to IN_PROGRESS", function () { - const jsonData = res.getBody() - expect(jsonData.errors).to.be.undefined - expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) - - const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover - expect(config.state).to.eql("IN_PROGRESS") - expect(config.cutoverVersion).to.eql(345) - expect(config.runId).to.eql("manual-eng-345") - }) -} diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru deleted file mode 100644 index c8ab4e0f8..000000000 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/04-set-complete.bru +++ /dev/null @@ -1,60 +0,0 @@ -meta { - name: 04-set-complete - type: graphql - seq: 4 -} - -post { - url: {{admin_url}} - body: graphql - auth: bearer -} - -auth:bearer { - token: {{admin_token}} -} - -body:graphql { - mutation CashWalletCutoverSetComplete($input: CashWalletCutoverUpdateInput!) { - cashWalletCutoverUpdate(input: $input) { - errors { - message - } - cashWalletCutover { - state - scheduledAt - startedAt - completedAt - pauseReason - cutoverVersion - runId - updatedBy - updatedAt - } - } - } -} - -body:graphql:vars { - { - "input": { - "state": "COMPLETE", - "cutoverVersion": 345, - "runId": "manual-eng-345", - "pauseReason": "manual ENG-345 complete test" - } - } -} - -script:post-response { - test("sets cutover config to COMPLETE", function () { - const jsonData = res.getBody() - expect(jsonData.errors).to.be.undefined - expect(jsonData.data.cashWalletCutoverUpdate.errors).to.eql([]) - - const config = jsonData.data.cashWalletCutoverUpdate.cashWalletCutover - expect(config.state).to.eql("COMPLETE") - expect(config.cutoverVersion).to.eql(345) - expect(config.runId).to.eql("manual-eng-345") - }) -} diff --git a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru b/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru deleted file mode 100644 index c180d00b2..000000000 --- a/dev/bruno/Flash GraphQL API/admin/cash-wallet-cutover/folder.bru +++ /dev/null @@ -1,4 +0,0 @@ -meta { - name: cash-wallet-cutover - seq: 10 -} diff --git a/dev/bruno/Flash GraphQL API/environments/local.bru b/dev/bruno/Flash GraphQL API/environments/local.bru index 1d803e8a3..1cda70a56 100644 --- a/dev/bruno/Flash GraphQL API/environments/local.bru +++ b/dev/bruno/Flash GraphQL API/environments/local.bru @@ -1,15 +1,13 @@ vars { flashGraphqlUrl: http://localhost:4002/graphql admin_url: http://localhost:4001/graphql + admin_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJhZG1pbiIsInJvbGVzIjpbIkFjY291bnRzIE1hbmFnZXIiXX0.UOmQR2K6RdS1FVvQbjvSQfoQ-VsTC6Y7x2YAXZImdsA currency: BTC - phone: +16505554320 + phone: +16505554322 code: 000000 + token: walletId: walletIdUsd: c593736e-5a58-42e4-93fa-dc895856c1f1 userEmail: mauriente@gmail.com userFullName: maurientes } -vars:secret [ - admin_token, - token -] diff --git a/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru b/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru deleted file mode 100644 index 1c6f7cd06..000000000 --- a/dev/bruno/Flash GraphQL API/notoken/queries/cash-wallet-cutover.bru +++ /dev/null @@ -1,44 +0,0 @@ -meta { - name: cash-wallet-cutover - type: graphql - seq: 11 -} - -post { - url: {{flashGraphqlUrl}} - body: graphql - auth: inherit -} - -body:graphql { - query CashWalletCutoverPublicState { - cashWalletCutover { - state - scheduledAt - startedAt - completedAt - pausedAt - pauseReason - cutoverVersion - runId - updatedBy - updatedAt - } - } -} - -body:graphql:vars { - {} -} - -script:post-response { - test("public cutover state returns without GraphQL errors", function () { - const jsonData = res.getBody() - expect(jsonData.errors).to.be.undefined - expect(jsonData.data.cashWalletCutover.state).to.be.oneOf([ - "PRE", - "IN_PROGRESS", - "COMPLETE", - ]) - }) -} diff --git a/dev/config/set-overrides.sh b/dev/config/set-overrides.sh index d8b0c03aa..18d86797d 100755 --- a/dev/config/set-overrides.sh +++ b/dev/config/set-overrides.sh @@ -7,9 +7,8 @@ mkdir -p "$(dirname "$OUTPUT_FILE")" # Define YAML paths and their descriptions directly in the script declare -a yaml_paths=( - "ibex.clientId, OAuth2 client ID for the Ibex account" - "ibex.clientSecret, OAuth2 client secret for the Ibex account" - "ibex.environment, Ibex environment: sandbox or production" + "ibex.email, Email address to Ibex Account" + "ibex.password, Password to Ibex Account" "ibex.webhook.uri, The URI where Ibex will send payment events" "sendgrid.apiKey, API key to SendGrid email service (from Twilio)" "cashout.email.to, Recipient email address for cashout notifications" @@ -58,4 +57,4 @@ for entry in "${yaml_paths[@]}"; do write_yaml "$path" $value done -echo "YAML file has been written to $OUTPUT_FILE." +echo "YAML file has been written to $OUTPUT_FILE." \ No newline at end of file diff --git a/dev/setup.sh b/dev/setup.sh index 718b372fa..3b001bef7 100755 --- a/dev/setup.sh +++ b/dev/setup.sh @@ -73,27 +73,24 @@ echo "" # ── 5. Configure Ibex credentials ──────────────────── echo "Checking Ibex credentials..." -if [ -f .env.local ] && grep -q "IBEX_CLIENT_SECRET" .env.local 2>/dev/null; then +if [ -f .env.local ] && grep -q "IBEX_PASSWORD" .env.local 2>/dev/null; then info "Ibex credentials found in .env.local" else echo "" - echo "Flash requires Ibex OAuth2 sandbox credentials to connect to the payment backend." + echo "Flash requires Ibex sandbox credentials to connect to the payment backend." echo "If you don't have credentials, ask your team lead." echo "" - read -rp "Ibex client ID (or press Enter to skip): " IBEX_CLIENT_ID - if [ -n "$IBEX_CLIENT_ID" ]; then - read -rsp "Ibex client secret: " IBEX_CLIENT_SECRET + read -rp "Ibex email (or press Enter to skip): " IBEX_EMAIL + if [ -n "$IBEX_EMAIL" ]; then + read -rsp "Ibex password: " IBEX_PASSWORD echo "" - read -rp "Ibex environment [sandbox]: " IBEX_ENVIRONMENT - IBEX_ENVIRONMENT="${IBEX_ENVIRONMENT:-sandbox}" cat > .env.local << EOF -export IBEX_CLIENT_ID='${IBEX_CLIENT_ID}' -export IBEX_CLIENT_SECRET='${IBEX_CLIENT_SECRET}' -export IBEX_ENVIRONMENT='${IBEX_ENVIRONMENT}' +export IBEX_EMAIL='${IBEX_EMAIL}' +export IBEX_PASSWORD='${IBEX_PASSWORD}' EOF info "Credentials saved to .env.local (git-ignored)" else - warn "Skipped — you'll need to create .env.local with IBEX_CLIENT_ID and IBEX_CLIENT_SECRET before starting" + warn "Skipped — you'll need to create .env.local with IBEX_EMAIL and IBEX_PASSWORD before starting" fi fi @@ -112,12 +109,11 @@ else if [ -f .env.local ]; then source .env.local 2>/dev/null || true fi - if [ -n "${IBEX_CLIENT_ID:-}" ] && [ -n "${IBEX_CLIENT_SECRET:-}" ]; then + if [ -n "${IBEX_EMAIL:-}" ] && [ -n "${IBEX_PASSWORD:-}" ]; then cat > "$OVERRIDES" << EOF ibex: - clientId: ${IBEX_CLIENT_ID} - clientSecret: ${IBEX_CLIENT_SECRET} - environment: ${IBEX_ENVIRONMENT:-sandbox} + email: ${IBEX_EMAIL} + password: ${IBEX_PASSWORD} EOF info "Generated $OVERRIDES with Ibex credentials" else diff --git a/docker-compose.yml b/docker-compose.yml index 690a13c30..83fd574a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,8 +79,9 @@ services: - REDIS_TYPE=standalone - REDIS_0_DNS=redis - REDIS_0_PORT=6378 - - IBEX_CLIENT_ID=${IBEX_CLIENT_ID:-} - - IBEX_CLIENT_SECRET=${IBEX_CLIENT_SECRET:-} + - IBEX_URL=${IBEX_URL} + - IBEX_EMAIL=${IBEX_EMAIL} + - IBEX_PASSWORD=${IBEX_PASSWORD} price-history: image: docker.io/lnflash/price-history:edge # image: us.gcr.io/galoy-org/price-history:edge diff --git a/docs/plans/2026-05-26-local-cutover-operator-dashboard.md b/docs/plans/2026-05-26-local-cutover-operator-dashboard.md deleted file mode 100644 index f3a40d326..000000000 --- a/docs/plans/2026-05-26-local-cutover-operator-dashboard.md +++ /dev/null @@ -1,88 +0,0 @@ -# Local Cash Wallet Cutover Operator Dashboard Plan - -## Goal - -Build a local-only dashboard at `http://localhost:3450` that lets an operator monitor the 60 cutover test accounts and their cash wallets through each cutover state. The dashboard must use raw backend repositories and wallet balances, not the GraphQL presentation layer, because public wallet queries intentionally hide either USD or USDT depending on client capability and cutover state. - -## Constraints - -- Read-only: the dashboard must not mutate accounts, wallets, balances, migrations, or cutover config. -- Local-only: bind to localhost and serve a static browser UI plus a JSON snapshot endpoint. -- Source of truth: - - account manifests from `/tmp/eng345usd-20260526115410-local-backend-accounts.json` and `/tmp/eng345usdonly-20260526195758-accounts.json` - - raw Mongo repositories for accounts, wallets, and cash-wallet-cutover state - - `Wallets.getBalanceForWallet` for live balances -- Expected population: - - 60 accounts - - 110 current wallets before `provision-usdt-wallets` - - 120 target wallets after every USD-only account receives USDT -- No production API behavior should change. - -## Design - -1. Add a pure dashboard snapshot builder under `src/app/cash-wallet-cutover/operator-dashboard.ts`. - - Load account IDs from manifest records. - - Fetch each account by id and all raw wallets by account id. - - Identify checking USD and checking USDT wallets directly from raw wallet records. - - Fetch live balances for each cash wallet. - - Fetch cutover config and per-account migration records when config has `runId`. - - Derive summary totals and account-level anomaly badges. - -2. Add focused unit tests under `test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts`. - - Verify wallet grouping and current/target wallet counts. - - Verify missing-USDT detection for USD-only accounts. - - Verify funded USD-only count from USD cent balances. - - Verify migration status counts and anomaly flags. - -3. Add a thin local HTTP script under `src/scripts/cash-wallet-cutover-dashboard.ts`. - - Accept `--port`, `--configPath`, optional `--run-id`, optional `--cutover-version`, optional `--expected-accounts`, and repeated `--manifest` arguments. - - Default port: `3450`. - - Bind explicitly to `127.0.0.1`. - - Default manifests: - - `/tmp/eng345usd-20260526115410-local-backend-accounts.json` - - `/tmp/eng345usdonly-20260526195758-accounts.json` - - Routes: - - `GET /` static dashboard HTML/CSS/JS - - `GET /api/snapshot` live JSON snapshot - - Poll snapshot every 10 seconds from the browser, with a manual refresh button. - - Cache server-side snapshots for a short TTL so browser refreshes do not hammer IBEX. - -4. UI content. - - Compact operational layout. - - Summary strip for cutover state, run id/version, accounts, wallets current/target, missing USDT, funded USD-only, USD total, USDT total, and anomalies. - - Filters for anomalies, funded only, missing USDT, nonzero USD, nonzero USDT, and migration status. - - Per-account table with phone, account id, default wallet, USD wallet/balance, USDT wallet/balance, migration status, and anomaly badges. - - Color coding: - - green expected - - yellow pending/missing-but-expected - - red broken or dangerous anomalies - -5. Verification. - - Run the new unit test first and confirm it fails before implementation. - - Implement the snapshot builder and dashboard script. - - Run the focused unit test. - - Run TypeScript check for touched files through the repo build/test path where practical. - - Start the dashboard on `localhost:3450` and verify: - - `GET /` returns HTML - - `GET /api/snapshot` returns JSON - - dashboard process is listening on port `3450` - -## Risks - -- `Wallets.getBalanceForWallet` returns currency-specific amount shapes; the snapshot builder must normalize cautiously and preserve raw balance display for unknown shapes. -- Account manifest shape may differ between the 50-account and 10-account batches. The loader should accept common `accountId`, `account.id`, `id`, `phone`, and `username` fields and fail with clear errors if no account id can be found. -- Prepared migration records may exist before cutover config has `runId`. The dashboard must accept explicit `--run-id` and `--cutover-version` and use them for migration lookup when provided. -- The manifest loader must support the actual top-level `accounts` and `created` arrays, reject duplicate account IDs, and by default validate that 60 accounts were loaded. -- Balance reads can be expensive across 110-120 wallets. The local server must cache snapshots with a short TTL, capture per-wallet balance errors, and avoid making every browser poll trigger a full IBEX balance sweep. -- Running through `ts-node` may need `transpile-only` and `tsconfig-paths/register`, matching the earlier local script behavior. -- Large account lists should remain cheap: 60 accounts and 120 wallets is small, so simple sequential fetches are acceptable for operator clarity. - -## Dual-Model Review Notes - -- Reviewer 1 required explicit migration lookup arguments so PRE/prepared migration records remain visible. Plan updated. -- Reviewer 1 required explicit currency on balance reads. Implementation will always pass `wallet.currency`. -- Reviewer 1 required support for both manifest shapes and default count validation. Plan updated. -- Reviewer 1 required loopback-only binding. Plan updated. -- Reviewer 1 recommended keeping the dashboard out of GraphQL/production HTTP routes. The module will only be consumed by the local script and unit tests. -- Reviewer 2 required server-side snapshot caching and a slower poll interval to avoid roughly 55-60 IBEX calls/sec. Plan updated. -- Reviewer 2 required dependency injection for the snapshot builder. The builder will take manifests, repos, cutover repo, and `getBalanceForWallet`. diff --git a/docs/plans/2026-05-28-cutover-dashboard-lazy-balances.md b/docs/plans/2026-05-28-cutover-dashboard-lazy-balances.md deleted file mode 100644 index 4a99b5c86..000000000 --- a/docs/plans/2026-05-28-cutover-dashboard-lazy-balances.md +++ /dev/null @@ -1,77 +0,0 @@ -# Cash Wallet Cutover Dashboard Lazy Balances Implementation Plan - -> Implementation note: keep this local dashboard read-only and preserve the existing IBEX balance throttle. - -**Goal:** Make the local Cash Wallet Cutover Dashboard render readiness and account structure immediately while IBEX wallet balances hydrate lazily in the background. - -**Architecture:** Split dashboard data into a fast structural snapshot and a throttled balance refresh path. The structural snapshot reads Mongo, migrations, and preflight state, but does not call IBEX. A local in-memory balance cache and single-worker queue refresh wallet balances with the existing throttle and expose cached values through a lazy `/api/balances` endpoint. - -**Tech Stack:** TypeScript, Express, existing Flash repositories, Jest unit tests, vanilla browser JavaScript. - ---- - -## Task 1: Structural Snapshot Mode - -**Files:** -- Modify: `src/app/cash-wallet-cutover/operator-dashboard.ts` -- Test: `test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts` - -**Steps:** -1. Add a failing unit test proving `buildCashWalletCutoverOperatorSnapshot` can produce rows without calling `getBalanceForWallet` when balance mode is disabled. -2. Add a placeholder balance formatter that returns `display: "loading"`, zero minor units, and a `status` field for balance hydration. -3. Thread a `balanceMode` option through the snapshot builder with default live behavior preserved for existing callers. -4. Verify existing live-balance tests still pass. - -## Task 2: Balance Cache And Queue - -**Files:** -- Modify: `src/scripts/cash-wallet-cutover-dashboard.ts` - -**Steps:** -1. Add a focused unit-testable helper only if it can stay small; otherwise keep the cache local to the script. -2. Add an in-memory `Map` and FIFO queue with de-duping. -3. Keep the existing one-wallet-at-a-time throttle and retry behavior inside the queue worker. -4. Add cache statuses for the first pass: `loading`, `fresh`, and `error`. - -## Task 3: Lazy Balance Endpoints - -**Files:** -- Modify: `src/scripts/cash-wallet-cutover-dashboard.ts` - -**Steps:** -1. Change `/api/snapshot` to build structural snapshots only. -2. Add `GET /api/balances?walletIds=...&refresh=0|1`, returning cached balance payloads immediately and enqueueing requested wallet IDs. -3. Add `GET /api/balance-status` for queue length, refreshed count, loading count, and last sweep timestamp. -4. Keep `?refresh=1` on `/api/snapshot` as structural refresh only, not a full IBEX balance sweep. - -## Task 4: Browser Lazy Hydration - -**Files:** -- Modify: `src/scripts/cash-wallet-cutover-dashboard.ts` - -**Steps:** -1. Render the structural snapshot immediately. -2. Collect wallet IDs from the structural snapshot and hand them to `/api/balances` without blocking first render. -3. Poll `/api/balances` and update the row objects in memory as balances arrive. -4. Show status text such as `Balances 24/192 refreshed` instead of blocking `Loading...`. -5. Ensure filters continue to work while balances are loading. - -## Task 5: Verification - -**Commands:** -1. Run focused unit tests: - `PATH=/Users/dread/.nvm/versions/node/v20.20.0/bin:$PATH TEST=test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts yarn test:unit` -2. Restart local dashboard: - `tmux kill-session -t cutover-dashboard` then start the existing dashboard command. -3. Verify: - - `GET /` returns HTML. - - `GET /api/snapshot?refresh=1` returns quickly and reports `watchlistAccounts: 60`. - - `GET /api/balances` returns immediately with cached/loading payloads. - - `GET /api/balance-status` shows queue progress. - -## Constraints - -- Do not increase IBEX request rate. -- Do not mutate accounts, wallets, migrations, or cutover config. -- Keep dashboard local-only on `127.0.0.1`. -- Avoid broad refactors and generated-file churn. diff --git a/src/scripts/cash-wallet-cutover-dashboard.ts b/src/scripts/cash-wallet-cutover-dashboard.ts index a8d3b217f..cbd034b9f 100644 --- a/src/scripts/cash-wallet-cutover-dashboard.ts +++ b/src/scripts/cash-wallet-cutover-dashboard.ts @@ -30,18 +30,13 @@ import { import { baseLogger } from "@services/logger" import { getFunderWalletId } from "@services/ledger/caching" -const DEFAULT_MANIFESTS = [ - "/tmp/eng345usd-20260526115410-local-backend-accounts.json", - "/tmp/eng345usdonly-20260526195758-accounts.json", -] - const BALANCE_TIMEOUT_MS = 7_500 const BALANCE_READ_ATTEMPTS = 3 const BALANCE_READ_SPACING_MS = 1_000 const args = yargs(hideBin(process.argv)) .option("port", { type: "number", default: 3450 }) - .option("manifest", { type: "array", string: true, default: DEFAULT_MANIFESTS }) + .option("manifest", { type: "array", string: true, demandOption: true }) .option("expected-accounts", { type: "number", default: 60 }) .option("snapshot-ttl-ms", { type: "number", default: 5_000 }) .option("run-id", { type: "string" }) 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 index 56c40ca9e..eabbb66ad 100644 --- a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts +++ b/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts @@ -178,16 +178,17 @@ describe("cash wallet migration runtime services", () => { 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 deps = { - payInvoice: jest - .fn() - .mockResolvedValueOnce(rateLimit) - .mockResolvedValueOnce(rateLimit) - .mockResolvedValueOnce({ transaction: { id: "ibex-tx-id" } }), + const payInvoice = jest + .fn() + .mockResolvedValueOnce(rateLimit) + .mockResolvedValueOnce(rateLimit) + .mockResolvedValueOnce({ transaction: { id: "ibex-tx-id" } }) + const deps: Parameters[0] = { + payInvoice, maxRateLimitAttempts: 3, rateLimitRetryDelayMs: 1234, sleep, - } as any + } const services = createCashWalletMigrationRuntimeServices(deps) @@ -197,7 +198,7 @@ describe("cash wallet migration runtime services", () => { }) expect(result).toEqual({ transactionId: "ibex-tx-id" }) - expect(deps.payInvoice).toHaveBeenCalledTimes(3) + expect(payInvoice).toHaveBeenCalledTimes(3) expect(sleep).toHaveBeenCalledTimes(2) expect(sleep).toHaveBeenCalledWith(1234) }) From 1ed63048b6b6c33e8a22abf7ae1b09780d82d192 Mon Sep 17 00:00:00 2001 From: Vandana Date: Mon, 1 Jun 2026 09:22:16 -0700 Subject: [PATCH 37/40] chore(cutover): move unit tests to separate PR --- .../amount-conversion.spec.ts | 50 - .../client-capability.spec.ts | 46 - .../cash-wallet-cutover/cutover-gate.spec.ts | 154 --- .../discovery-collector.spec.ts | 86 -- .../app/cash-wallet-cutover/discovery.spec.ts | 111 -- .../app/cash-wallet-cutover/executor.spec.ts | 82 -- .../app/cash-wallet-cutover/handlers.spec.ts | 231 ---- .../app/cash-wallet-cutover/lifecycle.spec.ts | 190 --- .../migration-records.spec.ts | 70 -- .../migration-state-machine.spec.ts | 65 -- .../operator-dashboard.spec.ts | 859 -------------- .../cash-wallet-cutover/orchestrator.spec.ts | 84 -- .../app/cash-wallet-cutover/planner.spec.ts | 51 - .../app/cash-wallet-cutover/preflight.spec.ts | 65 -- .../app/cash-wallet-cutover/prepare.spec.ts | 123 -- .../presentation-for-account.spec.ts | 135 --- .../cash-wallet-cutover/presentation.spec.ts | 120 -- .../app/cash-wallet-cutover/preview.spec.ts | 81 -- .../provision-usdt-wallets.spec.ts | 235 ---- .../app/cash-wallet-cutover/runner.spec.ts | 163 --- .../runtime-services.spec.ts | 279 ----- .../app/cash-wallet-cutover/worker.spec.ts | 1033 ----------------- .../app/wallets/usd-wallet-amount.spec.ts | 9 - .../unit/graphql/cash-wallet-cutover.spec.ts | 81 -- .../shared/types/object/usd-wallet.spec.ts | 17 - .../graphql/wallet-balance-validation.spec.ts | 30 - .../mongoose/cash-wallet-cutover.spec.ts | 242 ---- 27 files changed, 4692 deletions(-) delete mode 100644 test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/executor.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/planner.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/preview.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/provision-usdt-wallets.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/runner.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts delete mode 100644 test/flash/unit/app/cash-wallet-cutover/worker.spec.ts delete mode 100644 test/flash/unit/graphql/cash-wallet-cutover.spec.ts delete mode 100644 test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts delete mode 100644 test/flash/unit/graphql/wallet-balance-validation.spec.ts delete mode 100644 test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts 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 deleted file mode 100644 index db42bc5f0..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/amount-conversion.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index cfe6211e8..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/client-capability.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 356ce2a5f..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/cutover-gate.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index 201b382ba..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/discovery-collector.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -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 deleted file mode 100644 index 1606d9cad..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/discovery.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 60d5fae41..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/executor.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index b807cd96f..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/handlers.spec.ts +++ /dev/null @@ -1,231 +0,0 @@ -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 deleted file mode 100644 index ff578a24d..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/lifecycle.spec.ts +++ /dev/null @@ -1,190 +0,0 @@ -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 deleted file mode 100644 index 331f0dbb4..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/migration-records.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index c6e4c69a3..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/migration-state-machine.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 56121ccfd..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/operator-dashboard.spec.ts +++ /dev/null @@ -1,859 +0,0 @@ -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 deleted file mode 100644 index 32811fb58..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/orchestrator.spec.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 61f492a1b..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/planner.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 66a1a5aad..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/preflight.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 137c373dc..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/prepare.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index 7e9557733..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/presentation-for-account.spec.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 8eb42fdc1..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/presentation.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 90bcc5045..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/preview.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 9c7610f85..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/provision-usdt-wallets.spec.ts +++ /dev/null @@ -1,235 +0,0 @@ -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 deleted file mode 100644 index eb01920f3..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/runner.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -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 deleted file mode 100644 index eabbb66ad..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/runtime-services.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -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 deleted file mode 100644 index 8646ee4aa..000000000 --- a/test/flash/unit/app/cash-wallet-cutover/worker.spec.ts +++ /dev/null @@ -1,1033 +0,0 @@ -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 3fbeb8b17..905c76477 100644 --- a/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts +++ b/test/flash/unit/app/wallets/usd-wallet-amount.spec.ts @@ -19,15 +19,6 @@ 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 deleted file mode 100644 index f8e102a1e..000000000 --- a/test/flash/unit/graphql/cash-wallet-cutover.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 deleted file mode 100644 index 272bd40d6..000000000 --- a/test/flash/unit/graphql/shared/types/object/usd-wallet.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 8a2c828c9..000000000 --- a/test/flash/unit/graphql/wallet-balance-validation.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index fcb76e7de..000000000 --- a/test/flash/unit/services/mongoose/cash-wallet-cutover.spec.ts +++ /dev/null @@ -1,242 +0,0 @@ -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", - }) - }) -}) From 7b4137d28192bb52f061c5556938eac379af6f43 Mon Sep 17 00:00:00 2001 From: Island Bitcoin <34528298+islandbitcoin@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:50:08 -0700 Subject: [PATCH 38/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/services/mongoose/cash-wallet-cutover.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts index 7db76ae4b..124f393a9 100644 --- a/src/services/mongoose/cash-wallet-cutover.ts +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -12,6 +12,7 @@ const TERMINAL_STATUSES: CashWalletMigrationStatus[] = [ "failed", "requires_operator_review", "skipped_already_migrated", + "rollback_started", "rolled_back", ] From 390b83263f6586113cc447bf30b3dcb655aae8f0 Mon Sep 17 00:00:00 2001 From: Island Bitcoin <34528298+islandbitcoin@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:51:24 -0700 Subject: [PATCH 39/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/services/mongoose/cash-wallet-cutover.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts index 124f393a9..bc3067eaa 100644 --- a/src/services/mongoose/cash-wallet-cutover.ts +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -118,9 +118,22 @@ export const CashWalletCutoverRepository = () => { actor?: string, ): Promise => { try { + const $set: Record = { updatedBy: actor, updatedAt: new Date() } + const $unset: Record = {} + + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + $unset[key] = 1 + } else { + $set[key] = value + } + } + + const update = Object.keys($unset).length > 0 ? { $set, $unset } : { $set } + const result = await CashWalletCutoverConfig.findOneAndUpdate( { _id: CONFIG_ID }, - { $set: { ...patch, updatedBy: actor, updatedAt: new Date() } }, + update, { upsert: true, new: true }, ) return resultToConfig(result) @@ -128,6 +141,11 @@ export const CashWalletCutoverRepository = () => { return parseRepositoryError(err) } } + return resultToConfig(result) + } catch (err) { + return parseRepositoryError(err) + } + } const upsertMigration = async ( args: UpsertMigrationArgs, From 4b7c7a6f055279f80ac9f848fbc3c3e45dfd8db6 Mon Sep 17 00:00:00 2001 From: Island Bitcoin <34528298+islandbitcoin@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:54:00 -0700 Subject: [PATCH 40/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/services/mongoose/cash-wallet-cutover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/mongoose/cash-wallet-cutover.ts b/src/services/mongoose/cash-wallet-cutover.ts index bc3067eaa..4ce2f19d8 100644 --- a/src/services/mongoose/cash-wallet-cutover.ts +++ b/src/services/mongoose/cash-wallet-cutover.ts @@ -33,7 +33,7 @@ type TransitionMigrationArgs = { to: CashWalletMigrationStatus cutoverVersion: number runId: string - patch?: Partial + patch?: Partial> } type LockMigrationArgs = {