diff --git a/dev/config/base-config.yaml b/dev/config/base-config.yaml index 9106be4bc..7aa2ce734 100644 --- a/dev/config/base-config.yaml +++ b/dev/config/base-config.yaml @@ -56,16 +56,18 @@ fygaro: lookbackDays: 7 maxPerSweep: 20 -# ENG-653: Flash-native payment splits for incoming payments. +# ENG-653, v2 (ENG-717): Flash-native payment splits, settled through a shadow +# receive account. paymentSplits: enabled: false # flags-off baseline; enable per environment via config overrides maxAttempts: 5 - minShareCents: 1 + minShareCents: 1 # a share below this folds into the merchant's net leg loopIntervalMs: 60000 # executor + stale-sweep cadence inside the ibex-webhook pod - executorTimeBudgetMs: 45000 # one executor pass drains due rows for at most this long - claimGraceMs: 120000 # a new pending split is not claimable for this long (lets a post-payment LNURL internal marker land) + executorTimeBudgetMs: 45000 # one executor pass drains due legs for at most this long + claimGraceMs: 120000 # a new pending leg is not claimable for this long (lets a post-payment LNURL internal marker land) reconcileIntervalMs: 900000 # webhook-safety-net reconcile cadence inside the same loop cronIntervalMs: 86400000 # galoy cronjob schedule (chart: 0 2 * * *); sizes the cron reconcile lookback + shadowAlertAfterMs: 600000 # residual balance on a shadow with no open leg: ops alert (customer funds in custody) bridge: enabled: false # flags-off baseline; enable per environment via config overrides diff --git a/src/app/accounts/index.ts b/src/app/accounts/index.ts index 56e9e8a77..281cde927 100644 --- a/src/app/accounts/index.ts +++ b/src/app/accounts/index.ts @@ -1,4 +1,8 @@ import { AccountsRepository, WalletsRepository } from "@services/mongoose" +import { + ensureReceiveAccount, + hasEnabledSplits, +} from "@app/payment-splits/receive-account" export * from "./account-limit" export * from "./add-new-contact" @@ -86,5 +90,22 @@ export const getLnurlpFromWalletId = async ( ): Promise => { const wallet = await WalletsRepository().findById(walletId) if (wallet instanceof Error) return wallet + + // ENG-717: while the wallet has enabled split rules its Lightning address + // serves the SHADOW's LNURL-pay, so external pays settle on the shadow and + // fan out. (flash-pay must not cache this answer — see its no-cache PR.) + const shadow = await hasEnabledSplits(walletId) + if (shadow === true) { + const receiveAccount = await ensureReceiveAccount({ + walletId, + currency: wallet.currency, + }) + if (!(receiveAccount instanceof Error) && receiveAccount.lnurlp) { + return receiveAccount.lnurlp as Lnurl + } + // No shadow (or no LNURL on it): fall through to the wallet's own + // LNURL — an unsplit receive, the fail-safe everywhere else too. + } + return wallet.lnurlp } diff --git a/src/app/payment-splits/build-legs.ts b/src/app/payment-splits/build-legs.ts new file mode 100644 index 000000000..ae335d13d --- /dev/null +++ b/src/app/payment-splits/build-legs.ts @@ -0,0 +1,177 @@ +import { PaymentSplitsConfig } from "@config" + +import { + computeShare, + isSplitCurrency, + minorUnitsPerMajor, + type SplitLegKind, +} from "@domain/payment-splits" + +import { isInternalTransferInvoice } from "@services/mongoose/internal-transfer-invoices" +import { PaymentSplitExecutionsRepository } from "@services/mongoose/payment-split-executions" +import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules" + +export type BuildLegsResult = { + recorded: number + duplicates: number +} + +const NOTHING: BuildLegsResult = { recorded: 0, duplicates: 0 } + +/** + * Builds the payout leg set for one receive settled on a shadow receive + * account, under `triggerKey = paymentHash`. One `share` leg per enabled rule + * (v1 computeShare, unchanged), plus one `net` leg to the merchant: gross + * minus the sum of the shares, so rounding dust — and any share folded for + * landing below minShareCents — stays with the merchant and the shadow + * returns to exactly 0. + * + * All legs are inserted `pending` in one loop; the unique + * (triggerKey, legKind, destinationWalletId) index makes a replay or a + * reconcile re-run a duplicate-key no-op. Rules are snapshotted here: edits + * after settle never touch legs already built. + * + * Never sends: payouts happen in the executor (webhook-adjacent in-process, + * then the loop as the retry/safety net). + * + * `receivedAt`, when known (the reconcile reads it from IBEX, the webhook + * from its body), keeps a rule from firing on a receive that settled before + * the rule was created. Note this guards SHARE legs only — a receive on the + * shadow only exists because a rule was enabled at invoice time, so the net + * leg is always built. + */ +export const buildLegs = async ({ + walletId, + sourceIbexAccountId, + triggerKey, + paymentHash, + grossAmount, + currency, + receivedAt, + now = new Date(), +}: { + // The merchant's Flash wallet (the shadow's owner). + walletId: string + // The shadow IBEX account the receive settled on; every leg pays FROM it. + sourceIbexAccountId: string + triggerKey: string + paymentHash?: string + // The gross receive in major units as IBEX reports it (dollars / USDT). + grossAmount: number + currency: string + receivedAt?: Date + now?: Date +}): Promise => { + if (!PaymentSplitsConfig.enabled) return NOTHING + if (!isSplitCurrency(currency)) return NOTHING + + // Invoices Flash minted for its own transfers (P2P, Fygaro top-ups, referral + // payouts, split payouts, cutover moves) never trigger splits. A shadow + // receive is always external by construction, but the marker check costs + // one indexed read and keeps the invariant absolute: the marker is written + // for the destination invoice of every payout leg, and a payout leg to a + // wallet that itself has splits would otherwise settle on THAT wallet's + // shadow if it ever landed there. + if (paymentHash) { + const internal = await isInternalTransferInvoice(paymentHash) + if (internal instanceof Error) return internal + if (internal) return NOTHING + } + + // ENG-653 decision 1, kept: an LNURL send from another Flash user is marked + // internal only after payToLnurl returns, so this receive can be built + // before the marker exists. The executor re-checks the marker at pay time; + // holding the rows back for claimGraceMs lets that marker land first. + const firstClaimableAt = new Date(now.getTime() + PaymentSplitsConfig.claimGraceMs) + + const rules = await PaymentSplitRulesRepository().listByWalletId(walletId, { + enabledOnly: true, + }) + if (rules instanceof Error) return rules + + const executions = PaymentSplitExecutionsRepository() + const result = { ...NOTHING } + + // The shares first, so the net can subtract the shares actually built. + let sharesCents = 0 + type PlannedLeg = { + legKind: SplitLegKind + destinationWalletId: string + ruleId: string + shareCents: number + } + const planned: PlannedLeg[] = [] + + for (const rule of rules) { + if (receivedAt && receivedAt.getTime() < rule.createdAt.getTime()) continue + + const share = computeShare({ + amount: grossAmount, + currency, + percent: rule.percent, + minShareCents: PaymentSplitsConfig.minShareCents, + }) + if (share instanceof Error) return share + + // A share below minShareCents folds into the net leg (decision 2: + // fold, never fail) — it is not recorded as its own row. + if (share.status === "skipped_min") continue + + sharesCents += share.shareCents + planned.push({ + legKind: "share", + destinationWalletId: rule.destinationWalletId, + ruleId: rule.id, + shareCents: share.shareCents, + }) + } + + // Gross in minor units via computeShare's own map, so net = gross − Σ + // shares can never diverge from the share conversion. + const perMajor = minorUnitsPerMajor(currency) + if (perMajor === undefined) return NOTHING // isSplitCurrency gate above + const grossMinor = BigInt(Math.round(grossAmount * Number(perMajor))) + const netCents = Number(grossMinor / (perMajor / 100n)) - sharesCents + if (netCents < 0) { + // Rules summing over 100 are refused at save time; a rule edited between + // snapshot and build cannot push Σ shares past the gross either (each + // share is floored). Unreachable in practice; refuse rather than pay a + // negative leg. + return new Error( + `split legs exceed the gross: net ${netCents} cents for trigger ${triggerKey}`, + ) + } + + // The merchant is the net destination. The wallet record's id IS the + // merchant's IBEX account id, so the leg pays wallet → wallet from the + // shadow. ruleId is required by the schema; the net leg has no rule, so it + // carries the merchant's wallet id as a stable placeholder. + planned.push({ + legKind: "net", + destinationWalletId: walletId, + ruleId: walletId, + shareCents: netCents, + }) + + for (const leg of planned) { + const inserted = await executions.insert({ + triggerKey, + legKind: leg.legKind, + ruleId: leg.ruleId, + walletId, + sourceIbexAccountId, + destinationWalletId: leg.destinationWalletId, + currency, + grossMinor: grossMinor.toString(), + shareCents: leg.shareCents, + status: "pending", + nextAttemptAt: firstClaimableAt, + now, + }) + if (inserted instanceof Error) return inserted + if (!inserted.inserted) result.duplicates += 1 + else result.recorded += 1 + } + + return result +} diff --git a/src/app/payment-splits/execute-pending-splits.ts b/src/app/payment-splits/execute-pending-splits.ts index 4f8d91f16..f857c184e 100644 --- a/src/app/payment-splits/execute-pending-splits.ts +++ b/src/app/payment-splits/execute-pending-splits.ts @@ -1,17 +1,13 @@ import { PaymentSplitsConfig } from "@config" import { sendOutcomeNotificationBestEffort } from "@app/notifications/send-outcome-notification" -import { authorizeSend } from "@app/payments/authorize-send" -import { isDefinitivePaymentRejection } from "@app/payments/mark-outgoing-invoice" -import { intraledgerPaymentSendWalletIdForUsdWallet } from "@app/payments/send-intraledger" +import { isDefinitivePaymentRejection as isDefinitivePaymentRejectionIbex } from "@app/payments/mark-outgoing-invoice" import { AccountValidator } from "@domain/accounts" import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { CouldNotFindError, InactiveAccountError } from "@domain/errors" -import { LockServiceError, ResourceAttemptsLockServiceError } from "@domain/lock" -import { InternalTransferMarkerError, splitRetryBackoffMs } from "@domain/payment-splits" -import { SendLimitsUnavailableError } from "@domain/payments/errors" -import { RateLimitServiceError } from "@domain/rate-limit/errors" +import { splitRetryBackoffMs } from "@domain/payment-splits" +import { WalletCurrency } from "@domain/shared" import { notifyOpsEvent } from "@services/alerts/ops-events" import { IbexError } from "@services/ibex/errors" @@ -24,96 +20,69 @@ import { } from "@services/mongoose/payment-split-executions" import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules" -const logger = baseLogger.child({ module: "payment-splits" }) +import { paySplitLeg } from "./internal-transfer" +const logger = baseLogger.child({ module: "payment-splits" }) export const PAYMENT_SPLIT_MEMO = "Payment split" -export const splitIdempotencyKey = (executionId: string): IdempotencyKey => - `split:${executionId}` as IdempotencyKey +// The NET leg memo carries the gross and the payer's context: the merchant's +// IBEX history shows one +net from the shadow, and this line is what makes it +// auditable (ENG-717 decision 3 — history = net + memo in v2). +const NET_MEMO_PREFIX = "Payment received" type SendProgress = { - // The ENG-573 guard (the `authorize` hook) passed. withPaymentIdempotency - // runs it inside the idempotency lock just before `execute`. - paymentAttempted: boolean - // The send reached IBEX payInvoice (the `beforePay` hook ran). Before this, - // only IBEX addInvoice (minting the recipient invoice) and the - // internal-transfer marker write ran, and neither moves money. + // Set once paySplitLeg reached IBEX payInvoice. Before that only addInvoice + // and the marker write ran, and neither moves money. payInvoiceReached?: boolean } /** * Errors worth another attempt: raised before IBEX paid anything and expected - * to clear on their own (rate limits, a send guard that could not reach Redis - * or a price, the internal-transfer marker write, an IBEX 429, a busy - * idempotency lock that was never acquired, and any IBEX error from minting - * the recipient invoice that is not a definitive 4xx). Everything else - * (limits, KYC level, insufficient balance, an inactive account, an IBEX error - * from payInvoice) is terminal on the first attempt, so an error whose payment - * outcome is unknown is never re-sent. - * - * An addInvoice failure is safe to retry: no payment was attempted and the - * error return is left uncached by withPaymentIdempotency, so the same key - * re-runs the whole send. + * to clear on their own (an IBEX 429, the internal-transfer marker write, or + * any IBEX error from minting the recipient invoice that is not a definitive + * 4xx). Everything else is terminal on the first attempt, so an error whose + * payment outcome is unknown is never re-sent. * - * Lock errors are the subtle case. redlock also returns a LockServiceError when - * the locked routine throws or when releasing the lock fails, and both can - * happen AFTER the IBEX payment succeeded, replacing the Success result. So a - * lock error is retryable only when it is ResourceAttemptsLockServiceError AND - * the send never reached the payment (`paymentAttempted` false: the authorize - * hook, which runs immediately before the payment, never passed). + * Unlike v1 there is no send-guard/authorize stage: split legs are exempt + * from the merchant's send limits and KYC (ENG-717 decision 1), and the + * sender is the shadow (no Flash account, no rate-limit key). That removes + * the lock-error cases v1 had to reason about: withPaymentIdempotency is not + * used on this path (its 24 h cache cannot replay a mid-send crash anyway — + * see the stale sweep), so there is no idempotency lock to fail. */ -export const isRetryableSplitError = ( - error: Error, - { paymentAttempted, payInvoiceReached = false }: SendProgress, -): boolean => { - if (error instanceof LockServiceError) { - return error instanceof ResourceAttemptsLockServiceError && !paymentAttempted - } +export const isRetryableSplitError = (error: Error): boolean => { if (error instanceof IbexError) { if (error.httpCode === 429) return true - return paymentAttempted && !payInvoiceReached && !isDefinitivePaymentRejection(error) + // Non-definitive IBEX errors are retryable wherever they came from: an + // addInvoice error moved nothing, and a payInvoice one is caught earlier + // by isAmbiguousSplitSendError (which takes precedence in executeOne). + return !isDefinitivePaymentRejectionIbex(error) } - return ( - error instanceof RateLimitServiceError || - error instanceof SendLimitsUnavailableError || - error instanceof InternalTransferMarkerError - ) + // Only meaningful pre-pay (the marker is written before payInvoice): a + // marker error after the pay reached is ambiguous first. + return error.name === "InternalTransferMarkerError" } /** - * An error the send may have paid through. Treated like a row the stale sweep - * finds stuck in `sending`: `unknown` plus an ops alert, never resent, and no - * "split failed" push to the owner (who would then pay the partner by hand). - * - * - A lock error that is not a retryable pre-payment one (see - * isRetryableSplitError). - * - Once the send reached IBEX payInvoice (`payInvoiceReached`), any IBEX error - * that is not a definitive rejection: a transport error, a timeout or a 5xx - * from payInvoice, where IBEX may already have debited (the KNOWN GAP in - * idempotency.ts). A definitive rejection (insufficient balance, a 4xx such - * as no route or a 429) moved no money and stays `failed` / retryable. An - * IBEX error from addInvoice (before payInvoice) moved nothing either and is - * retried. + * An error the send may have paid through: once the send reached IBEX + * payInvoice, any IBEX error that is not a definitive rejection (a transport + * error, a timeout, a 5xx — IBEX may already have debited the shadow). + * Treated like a row the stale sweep finds stuck in `sending`: `unknown` plus + * an ops alert, never resent. */ export const isAmbiguousSplitSendError = ( error: Error, - { paymentAttempted, payInvoiceReached = false }: SendProgress, -): boolean => { - if (error instanceof LockServiceError) { - return !isRetryableSplitError(error, { paymentAttempted, payInvoiceReached }) - } - return ( - payInvoiceReached && - error instanceof IbexError && - !isDefinitivePaymentRejection(error) - ) -} + { payInvoiceReached = false }: SendProgress, +): boolean => + payInvoiceReached && + error instanceof IbexError && + !isDefinitivePaymentRejectionIbex(error) const formatCents = (cents: number): string => `US$${(cents / 100).toFixed(2)}` const notifyOwner = async ( accountId: string, - outcome: "sent" | "failed" | "disabled", + outcome: "failed" | "disabled", execution: PaymentSplitExecutionRecord, ): Promise => sendOutcomeNotificationBestEffort({ @@ -131,11 +100,11 @@ const notifyOwner = async ( }) /** - * ENG-660: whether the split's DESTINATION account is inactive right now. - * InactiveAccountError from the send does not say which side was inactive - * (the sender is checked first), so the destination is looked up directly. - * A lookup failure answers false: the row then fails as before and the rule - * stays enabled. + * ENG-660 carry-over: whether the split's DESTINATION account is inactive + * right now. InactiveAccountError from the send does not say which side was + * inactive (the shadow is checked first by IBEX), so the destination is + * looked up directly. A lookup failure answers false: the row then fails as + * before and the rule stays enabled. */ const isDestinationInactive = async (destinationWalletId: string): Promise => { const wallet = await WalletsRepository().findById(destinationWalletId as WalletId) @@ -145,13 +114,7 @@ const isDestinationInactive = async (destinationWalletId: string): Promise => { const executions = PaymentSplitExecutionsRepository() const attempts = execution.attempts + 1 + // The merchant's account id, resolved below and used by the fold/notify + // paths. Undefined only while the lookups are still pending — the fold is + // never reachable before them. + let ownerAccountId: string | undefined = undefined // Records the outcome stamped with wall-clock time (not the pass's `now`), // so updatedAt reflects when the row actually finished. A false result means @@ -189,23 +156,64 @@ const executeOne = async ( ) } + // Decision 2 (fold, never fail): a terminal failure on a SHARE leg moves + // the share back into the net leg. The fold is a second payout from the + // shadow to the merchant for shareCents more — the net row was inserted at + // settle with gross − Σ shares, so the fold row tops the merchant up to the + // full gross. Insert FIRST, notify after: the push promises the money is + // accounted for, and it is only true once the fold row exists. + const foldShareToNet = async ( + reason: string, + pushOwner?: "disabled" | "failed", + ): Promise => { + if (!ownerAccountId) { + // Unreachable per the control flow below (every fold call site follows + // the account lookup); type-narrow, never cast. + return fail(new Error("fold requested before account resolution")) + } + const folded = await executions.insertFold(execution, reason, stamp()) + if (folded instanceof Error) { + logger.error( + { executionId: execution.id, error: folded }, + "could not fold a failed share into the net leg; the share stays failed for ops", + ) + // The share row is already terminal `failed`; the money stays in the + // shadow and the reconcile's residual alert catches it. Page, not just + // log: nobody was folding when v1's settle-and-forward had this case. + notifyOpsEvent({ + flow: "transfer", + phase: "split-fold-failed", + status: "failed", + amount: { value: (execution.shareCents / 100).toFixed(2), currency: "USD" }, + meta: { + executionId: execution.id, + walletId: execution.walletId, + destinationWalletId: execution.destinationWalletId, + reason, + }, + }) + return "failed" + } + if (pushOwner) await notifyOwner(ownerAccountId, pushOwner, execution) + return "folded" + } + const fail = async (error: Error | string, accountId?: string) => { const lastError = typeof error === "string" ? error : error.name || error.message await finish({ status: "failed", attempts, lastError }) - if (accountId) await notifyOwner(accountId, "failed", execution) + if (!accountId) return "failed" as const + if (execution.legKind === "share") { + return foldShareToNet(lastError, "failed") + } + await notifyOwner(accountId, "failed", execution) return "failed" as const } - // ENG-660: the destination account is no longer active, so every later - // payout to it would fail the same way. Turn off the wallet's rules to that - // destination and tell the owner once (only the call that flipped `enabled` - // notifies); further rows for the same destination fail without another - // push. The rules are matched by destination, not by execution.ruleId: a - // save replaces every rule with a new id, and a delete removes it, so the - // recorded ruleId may no longer exist. When nothing was disabled AND no rule - // to the destination exists any more, the owner still gets the ordinary - // "failed" push, so a failed payout is never silent. - const failAndDisableRule = async (error: Error, accountId: string) => { + // ENG-660 carry-over: the destination account is no longer active, so every + // later payout to it would fail the same way. Turn off the wallet's rules to + // that destination and tell the owner once (only the call that flipped + // `enabled` notifies). The share itself folds to the merchant. + const failAndDisableRule = async (error: Error) => { await finish({ status: "failed", attempts, lastError: error.name }) const rules = PaymentSplitRulesRepository() const logContext = { @@ -224,16 +232,14 @@ const executeOne = async ( { ...logContext, error: disabled }, "could not disable payment split rule with an inactive destination", ) - await notifyOwner(accountId, "failed", execution) - return "failed" as const + return foldShareToNet(error.name, "failed") } if (disabled) { logger.warn( logContext, "payment split rule disabled: destination account is not active", ) - await notifyOwner(accountId, "disabled", execution) - return "failed" as const + return foldShareToNet(error.name, "disabled") } // Nothing flipped: either an earlier row already disabled the rule (it // still exists, its owner was told) or the rule is gone (edited away or @@ -248,8 +254,7 @@ const executeOne = async ( "could not check payment split rule for an inactive destination", ) } - if (exists !== true) await notifyOwner(accountId, "failed", execution) - return "failed" as const + return foldShareToNet(error.name, exists !== true ? "failed" : undefined) } const retry = async (error: Error) => { @@ -277,61 +282,59 @@ const executeOne = async ( return fail(error) } + // The merchant's account: needed for owner notifications and (for the net + // leg) the destination-active check. + const wallet = await WalletsRepository().findById(execution.walletId as WalletId) + if (wallet instanceof Error) return failBeforeSend(wallet) + const account = await AccountsRepository().findById(wallet.accountId) + if (account instanceof Error) return failBeforeSend(account) + ownerAccountId = account.id + // Re-check the internal-transfer marker at pay time. An LNURL send from - // another Flash user is marked only after IBEX returns, so its receive can be - // recorded before the marker exists (ENG-653 decision 1). + // another Flash user is marked only after payToLnurl returns, so a receive + // can be built before the marker exists (ENG-653 decision 1, kept). const internal = await isInternalTransferInvoice(execution.triggerKey) if (internal instanceof Error) { // Nothing was sent: the same retry budget as any pre-payment error. return failBeforeSend(internal) } - if (internal) { - await finish({ status: "skipped_internal", attempts: execution.attempts }) - return "skipped_internal" + if (internal && execution.legKind === "share") { + // The trigger receive turned out to be Flash-to-Flash: it should never + // have built legs. The share folds to the merchant like any other + // terminal share failure — never dropped silently (the net leg below + // pays; the money really is in the shadow and belongs to the merchant). + return fail("internal_transfer", account.id) } - const wallet = await WalletsRepository().findById(execution.walletId as WalletId) - if (wallet instanceof Error) return failBeforeSend(wallet) - const account = await AccountsRepository().findById(wallet.accountId) - if (account instanceof Error) return failBeforeSend(account) - - // Set once the ENG-573 guard passes. withPaymentIdempotency runs `authorize` - // inside the idempotency lock immediately before the payment, so this is the - // last point at which no money can have moved yet. - let paymentAttempted = false - // Set right before IBEX payInvoice. Between the two only IBEX addInvoice and - // the marker write run, and neither moves money. + // Set right before IBEX payInvoice. Between the marker write and this only + // addInvoice runs, and it moves no money. let payInvoiceReached = false - const result = await intraledgerPaymentSendWalletIdForUsdWallet({ - senderWalletId: wallet.id, - recipientWalletId: execution.destinationWalletId as WalletId, - amount: execution.shareCents, - memo: PAYMENT_SPLIT_MEMO, - senderAccount: account, - // Stable across retries of the same execution, so a retry after an - // ambiguous failure replays the cached outcome instead of paying again. - idempotencyKey: splitIdempotencyKey(execution.id), - // A split is a send out of the user's own wallet: it goes through the same - // ENG-573 guard (attempt budget, amount sanity, daily limit) as a manual - // intraledger send. - authorize: async () => { - const authorized = await authorizeSend({ - senderAccount: account, - senderWalletId: wallet.id, - amount: { currency: "USD", cents: execution.shareCents }, - kind: "intraledger", - }) - if (!(authorized instanceof Error)) paymentAttempted = true - return authorized - }, - beforePay: () => { - payInvoiceReached = true + // The net leg's memo carries the gross in major units. grossMinor is minor + // units: cents for USD, micros for USDT (10 000 micros per US cent — the + // same conversion computeShare uses). + const grossMajor = + execution.currency === WalletCurrency.Usdt + ? Number(execution.grossMinor) / 1_000_000 + : Number(execution.grossMinor) / 100 + const memo = + execution.legKind === "net" + ? `${NET_MEMO_PREFIX} $${grossMajor.toFixed(2)}` + : PAYMENT_SPLIT_MEMO + + const result = await paySplitLeg({ + sourceIbexAccountId: execution.sourceIbexAccountId, + destinationWalletId: execution.destinationWalletId, + cents: execution.shareCents, + currency: execution.currency, + memo, + onProgress: (progress) => { + payInvoiceReached = progress.payInvoiceReached ?? false }, }) if (result instanceof Error) { - if (isAmbiguousSplitSendError(result, { paymentAttempted, payInvoiceReached })) { + if (isAmbiguousSplitSendError(result, { payInvoiceReached })) { await finish({ status: "unknown", attempts, lastError: result.name }) logger.error( { executionId: execution.id, walletId: execution.walletId, error: result }, @@ -344,17 +347,14 @@ const executeOne = async ( amount: { value: (execution.shareCents / 100).toFixed(2), currency: "USD" }, meta: { executionId: execution.id, - senderWalletId: execution.walletId, + sourceIbexAccountId: execution.sourceIbexAccountId, recipientWalletId: execution.destinationWalletId, reason: result.name, }, }) return "unknown" } - if ( - isRetryableSplitError(result, { paymentAttempted, payInvoiceReached }) && - attempts < PaymentSplitsConfig.maxAttempts - ) { + if (isRetryableSplitError(result) && attempts < PaymentSplitsConfig.maxAttempts) { return retry(result) } logger.warn( @@ -363,22 +363,22 @@ const executeOne = async ( ) if ( result instanceof InactiveAccountError && + execution.legKind === "share" && (await isDestinationInactive(execution.destinationWalletId)) ) { - return failAndDisableRule(result, account.id) + return failAndDisableRule(result) } return fail(result, account.id) } if (result === PaymentSendStatus.Success) { await finish({ status: "sent", attempts }) - await notifyOwner(account.id, "sent", execution) return "sent" } if (result === PaymentSendStatus.Pending) { - // Money has probably left the wallet: never re-pay (same stance as the - // Fygaro credit). Ops resolves the final state from IBEX. + // Money has probably left the shadow: never re-pay (same stance as v1 and + // the Fygaro credit). Ops resolves the final state from IBEX. await finish({ status: "ibex_pending", attempts }) return "ibex_pending" } @@ -391,24 +391,23 @@ export type ExecutePendingSplitsSummary = { sent: number ibexPending: number failed: number + folded: number retried: number unknown: number - skippedInternal: number } /** - * Executor: claims due `pending` executions one at a time (atomically, so - * concurrent runs never claim the same row) and pays each share as an - * intraledger send from the receiving wallet to the split destination. + * Executor: claims due `pending` legs one at a time (atomically, so + * concurrent runs never claim the same row) and pays each from the shadow + * receive account named on the row. * - * Runs every PaymentSplitsConfig.loopIntervalMs from the split loop and keeps - * claiming until nothing is due or executorTimeBudgetMs is spent, so the - * throughput is not capped by a fixed batch per run. `now` is fixed per pass - * and decides what is due: a row retried in this pass is due at now + backoff - * and is picked up by a later pass, which is what makes the 1/2/4/8 minute - * backoff real. Claims and outcomes are stamped with `clock()` (wall-clock - * time), never the pass's `now`, so a row's updatedAt never looks older to the - * stale sweep on another replica than it really is. + * Runs every PaymentSplitsConfig.loopIntervalMs from the split loop AND once + * inline after the webhook builds legs (§2.3: settle → execute immediately; + * the loop is the retry/safety net). Keeps claiming until nothing is due or + * executorTimeBudgetMs is spent. `now` is fixed per pass and decides what is + * due; claims and outcomes are stamped with `clock()` (wall-clock time), so a + * row's updatedAt never looks older to the stale sweep on another replica + * than it really is. * * `shouldStop` lets the loop drain on SIGTERM (see startPaymentSplitsLoop). */ @@ -424,9 +423,9 @@ export const executePendingSplits = async ({ sent: 0, ibexPending: 0, failed: 0, + folded: 0, retried: 0, unknown: 0, - skippedInternal: 0, } if (!PaymentSplitsConfig.enabled) return summary @@ -447,7 +446,7 @@ export const executePendingSplits = async ({ else if (outcome === "ibex_pending") summary.ibexPending += 1 else if (outcome === "failed") summary.failed += 1 else if (outcome === "unknown") summary.unknown += 1 - else if (outcome === "skipped_internal") summary.skippedInternal += 1 + else if (outcome === "folded") summary.folded += 1 else summary.retried += 1 } diff --git a/src/app/payment-splits/index.ts b/src/app/payment-splits/index.ts index b61f16519..9527bf293 100644 --- a/src/app/payment-splits/index.ts +++ b/src/app/payment-splits/index.ts @@ -1,7 +1,9 @@ export * from "./set-splits" export * from "./delete-split" export * from "./get-splits" -export * from "./record-split-executions" +export * from "./build-legs" +export * from "./receive-account" +export * from "./internal-transfer" export * from "./execute-pending-splits" export * from "./sweep-stale-splits" export * from "./reconcile-splits" diff --git a/src/app/payment-splits/internal-transfer.ts b/src/app/payment-splits/internal-transfer.ts new file mode 100644 index 000000000..86a3bb3f1 --- /dev/null +++ b/src/app/payment-splits/internal-transfer.ts @@ -0,0 +1,107 @@ +import { decodeInvoice, PaymentSendStatus } from "@domain/bitcoin/lightning" +import { InternalTransferOrigin } from "@domain/payment-splits" +import { InternalTransferMarkerError } from "@domain/payment-splits/errors" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +import { recordInternalTransferInvoice } from "@services/mongoose/internal-transfer-invoices" + +import Ibex from "@services/ibex/client" +import { UnexpectedIbexResponse } from "@services/ibex/errors" +import { paymentSendStatusOrPending } from "@services/ibex/payment-status" + +const PaymentHashRegex = /^[0-9a-f]{64}$/i + +export type SplitPayoutArgs = { + // The shadow receive account the leg pays FROM. + sourceIbexAccountId: string + // The merchant or destination wallet the leg pays TO. A Flash wallet id IS + // an IBEX account id. + destinationWalletId: string + cents: number + currency: string + memo: string +} + +export type SplitPayoutProgress = { + // The recipient invoice was minted and the marker written: from here IBEX + // may debit the shadow. Errors before this are safe to retry wholesale. + payInvoiceReached?: boolean +} + +/** + * One payout leg of a split settle: the shadow pays a Flash wallet. + * + * Deliberately NOT intraledgerPaymentSendWalletIdForUsdWallet (decision 1): + * that path validates the SENDER as a Flash wallet and runs the ENG-573 send + * guards against the merchant's account. The shadow has no Flash account, and + * a split payout must not consume the merchant's send limits or demand its + * KYC level — the money is the merchant's own inbound payment being handed + * over, not a send. + * + * Sequence mirrors send-intraledger's: mark the recipient invoice internal + * (origin "split") BEFORE paying, fail closed on a marker error (nothing has + * moved), then addInvoice + payInvoice against the shadow. Internal pays are + * fee-free and synchronous on IBEX (Phase 0), so the pay response's payment + * status is settlement truth; it is read with the shared reader, whose + * unconfirmed-outcome mapping this caller treats as `pending` via + * paymentSendStatusOrPending (never re-paid — same stance as v1). + * + * The marker's paymentHash comes from decoding the bolt11 we are about to + * pay, with IBEX's own invoice.hash as fallback — same precedence as + * send-intraledger's marker. + */ +export const paySplitLeg = async ({ + sourceIbexAccountId, + destinationWalletId, + cents, + currency, + memo, + onProgress, +}: SplitPayoutArgs & { + onProgress?: (progress: SplitPayoutProgress) => void +}): Promise => { + const amount = + currency === WalletCurrency.Usdt + ? USDTAmount.usdCents(cents.toString()) + : USDAmount.cents(cents.toString()) + if (amount instanceof Error) return amount + + const invoiceResp = await Ibex.addInvoice({ + accountId: destinationWalletId as IbexAccountId, + amount, + memo, + }) + if (invoiceResp instanceof Error) return invoiceResp + if (invoiceResp.invoice?.bolt11 === undefined) { + return new UnexpectedIbexResponse("Bolt11 field not found.") + } + const bolt11 = invoiceResp.invoice.bolt11 + + // The hash of the bolt11 we are about to pay is authoritative; IBEX's own + // `invoice.hash` is the fallback when the bolt11 cannot be decoded locally. + const decoded = decodeInvoice(bolt11) + const paymentHash = + decoded instanceof Error ? invoiceResp.invoice.hash : decoded.paymentHash + if (typeof paymentHash !== "string" || !PaymentHashRegex.test(paymentHash)) { + return new InternalTransferMarkerError("Could not read recipient invoice hash") + } + + const recorded = await recordInternalTransferInvoice({ + paymentHash, + recipientWalletId: destinationWalletId, + origin: InternalTransferOrigin.Split, + }) + if (recorded instanceof Error) { + return new InternalTransferMarkerError(recorded) + } + + // Everything above moved no money. From here IBEX may debit the shadow. + onProgress?.({ payInvoiceReached: true }) + const payResp = await Ibex.payInvoice({ + accountId: sourceIbexAccountId as IbexAccountId, + invoice: bolt11 as Bolt11, + }) + if (payResp instanceof Error) return payResp + + return paymentSendStatusOrPending(payResp) +} diff --git a/src/app/payment-splits/receive-account.ts b/src/app/payment-splits/receive-account.ts new file mode 100644 index 000000000..b3c326d7c --- /dev/null +++ b/src/app/payment-splits/receive-account.ts @@ -0,0 +1,162 @@ +import { PaymentSplitsConfig } from "@config" + +import { SplitReceiveAccountError } from "@domain/payment-splits/errors" +import { USDAmount, USDTAmount, WalletCurrency } from "@domain/shared" + +import Ibex from "@services/ibex/client" +import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules" +import { + SplitReceiveAccountsRepository, + type SplitReceiveAccountRecord, +} from "@services/mongoose/split-receive-accounts" + +/** + * ENG-717: the shadow receive account. A wallet with at least one enabled + * split rule mints its EXTERNAL receive invoices on a Flash-owned IBEX + * account (the shadow) instead of the merchant's own, so the merchant's + * wallet never holds the gross. On settle, the webhook fans the shadow's + * balance out — each rule's share, then the merchant's net — and the shadow + * returns to zero. + * + * The shadow is permanent per wallet (decision 4). Its IBEX balance is + * customer funds in transit (see the ops runbook line in the PR). + */ + +// The wallet's currency as an IBEX currency id, for createAccount / +// createLnurlPay. Same mapping the wallet-creation path uses +// (src/services/mongoose/wallets.ts getIbexCurrencyId); kept local so this +// module reads as one unit. +const ibexCurrencyIdFor = (currency: WalletCurrency): IbexCurrencyId | Error => { + switch (currency) { + case WalletCurrency.Usd: + return USDAmount.currencyId + case WalletCurrency.Usdt: + return USDTAmount.currencyId + default: + return new Error(`Unsupported split currency: ${currency}`) + } +} + +/** + * Creates the shadow account + LNURL for `walletId` if it does not exist yet. + * Fails the caller on any IBEX or persistence error: walletSplitsSet runs + * this BEFORE saving rules, so a wallet is never left with enabled rules and + * no shadow (routing would then fall back to the real account and split + * nothing, silently). + * + * Idempotent: a second call for a wallet that already has a shadow returns + * the existing record without touching IBEX. + */ +export const ensureReceiveAccount = async ({ + walletId, + currency, +}: { + walletId: WalletId + currency: WalletCurrency +}): Promise => { + const repo = SplitReceiveAccountsRepository() + + const existing = await repo.findByWalletId(walletId) + if (existing instanceof Error) return existing + if (existing) return existing + + const currencyId = ibexCurrencyIdFor(currency) + if (currencyId instanceof Error) return new SplitReceiveAccountError(currencyId.message) + + // Named after the wallet so IBEX's account list stays readable; the real + // key is walletId, unique in the collection here. + const account = await Ibex.createAccount(`flash-splits-${walletId}`, currencyId) + if (account instanceof Error) return new SplitReceiveAccountError(account.message) + if (!account.id) { + return new SplitReceiveAccountError("IBEX created no account id for the shadow") + } + + const lnurl = await Ibex.createLnurlPay({ accountId: account.id, currencyId }) + if (lnurl instanceof Error) { + // The IBEX account exists but is unusable for LNURL receives. walletSplitsSet + // fails the mutation, so no rule is saved and nothing routes here yet — the + // orphaned account is harmless, and the next save attempt re-ensures. + return new SplitReceiveAccountError(lnurl.message) + } + + const created = await repo.insert({ + walletId, + ibexAccountId: account.id, + currency, + lnurlp: lnurl.lnurl, + }) + if (created instanceof Error) return created + if (!created.created) { + // A concurrent save inserted first. Its record wins; ours is an orphaned + // IBEX account no traffic is ever routed to. + const winner = await repo.findByWalletId(walletId) + if (winner instanceof Error) return winner + if (winner) return winner + return new SplitReceiveAccountError( + "shadow receive account insert reported a duplicate but none was found", + ) + } + + return { + walletId, + ibexAccountId: account.id, + currency, + lnurlp: lnurl.lnurl, + createdAt: new Date(), + updatedAt: new Date(), + } +} + +/** + * Whether the wallet currently routes its external receives through a shadow. + * One read, no IBEX calls: the settle path and the LNURL resolver call this + * per request, so it must stay cheap. + */ +export const hasEnabledSplits = async (walletId: WalletId): Promise => { + if (!PaymentSplitsConfig.enabled) return false + const rules = await PaymentSplitRulesRepository().listByWalletId(walletId, { + enabledOnly: true, + }) + if (rules instanceof Error) return rules + return rules.length > 0 +} + +/** + * Which IBEX account an EXTERNAL receive invoice for `walletId` is minted on: + * the wallet's shadow when splits are on and the wallet has at least one + * enabled rule, else the wallet's own account (the pre-v2 behavior). + * + * READ-ONLY on the invoice-creation hot path: it never creates the shadow. + * Creation belongs to walletSplitsSet (which fails the mutation if it can't), + * so a wallet here either has its shadow or falls back to the real account + * for this invoice — the next walletSplitsSet retries the ensure. + * + * The fail-safe is deliberate: any error here returns `walletId`, so a new + * mint site or a transient repo error lands the receive unsplit on the real + * account rather than failing an invoice the user asked for. + * + * NOT for internal credits: send-intraledger, the cutover moves and split + * payouts mint on the real account on purpose (they must not re-split). + */ +export const receiveAccountFor = async (walletId: WalletId): Promise => { + if (!PaymentSplitsConfig.enabled) return walletId + + const enabled = await hasEnabledSplits(walletId) + if (enabled !== true) return walletId + + const shadow = await SplitReceiveAccountsRepository().findByWalletId(walletId) + if (shadow instanceof Error || !shadow) return walletId + return shadow.ibexAccountId as IbexAccountId +} + +/** + * The wallet a shadow IBEX account backs, when `ibexAccountId` is a shadow. + * Used by the on-receive webhook: a receive on a shadow settles through the + * split pipeline (§2.3), a receive on a real account is an ordinary receive. + */ +export const resolveShadow = async ( + ibexAccountId: string, +): Promise => + SplitReceiveAccountsRepository().findByIbexAccountId(ibexAccountId) + +export type { SplitReceiveAccountRecord } diff --git a/src/app/payment-splits/reconcile-splits.ts b/src/app/payment-splits/reconcile-splits.ts index b9af082c9..2d646c1b4 100644 --- a/src/app/payment-splits/reconcile-splits.ts +++ b/src/app/payment-splits/reconcile-splits.ts @@ -3,22 +3,25 @@ import { PaymentSplitsConfig } from "@config" import { splitReconcileWindowMs } from "@domain/payment-splits" import Ibex from "@services/ibex/client" +import { notifyOpsEvent } from "@services/alerts/ops-events" import { baseLogger } from "@services/logger" +import { parseRepositoryError } from "@services/mongoose/utils" +import { PaymentSplitExecution } from "@services/mongoose/schema" +import { SplitReceiveAccountsRepository } from "@services/mongoose/split-receive-accounts" import { WalletsRepository } from "@services/mongoose" -import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules" -import { recordSplitExecutions } from "./record-split-executions" +import { buildLegs } from "./build-legs" const logger = baseLogger.child({ module: "payment-splits-reconcile" }) // IBEX transaction type ids (GET /v2/transaction-types/all). const IBEX_LIGHTNING_RECEIVE = 1 -// Type 9 (crypto receive: USDT on Ethereum) deliberately does NOT trigger in -// v1. Bridge virtual-account deposits arrive as the same type on the same -// address, so a split could not tell them apart (ENG-653 decision 2). +// Type 9 (crypto receive: USDT on Ethereum) deliberately does NOT trigger +// splits (v1 decision kept): Bridge virtual-account deposits arrive as the +// same type on the same address, so a split could not tell them apart. // Transactions read per IBEX page. Pages are read newest-settled first until -// one reaches past the lookback, so a busy wallet is covered whatever its +// one reaches past the lookback, so a busy shadow is covered whatever its // receive rate; MAX_PAGES only stops a runaway loop (50k transactions). const TRANSACTIONS_PAGE_SIZE = 100 const MAX_PAGES = 500 @@ -27,9 +30,10 @@ const PaymentHashRegex = /^[0-9a-f]{64}$/i export type ReconcileSplitsSummary = { windowMs: number - wallets: number + shadows: number receivesSeen: number recorded: number + residueAlerts: number errors: number } @@ -41,12 +45,12 @@ type IbexAccountTransaction = { } /** - * Every transaction on `walletId` from the newest back to `since`, one IBEX + * Every transaction on `accountId` from the newest back to `since`, one IBEX * page at a time (sort settledAt: newest settled first; pages are 0-based). * Stops at the first page that reaches past `since` or comes back short. */ const readTransactionsSince = async ( - walletId: string, + accountId: string, since: number, ): Promise => { const seen = new Set() @@ -54,7 +58,7 @@ const readTransactionsSince = async ( for (let page = 0; page < MAX_PAGES; page++) { const batch = await Ibex.getAccountTransactions({ - account_id: walletId, + account_id: accountId, limit: TRANSACTIONS_PAGE_SIZE, page, sort: "settledAt", @@ -74,22 +78,34 @@ const readTransactionsSince = async ( } logger.warn( - { walletId, pages: MAX_PAGES }, + { accountId, pages: MAX_PAGES }, "split reconcile: page cap reached before the lookback; older receives not read", ) return transactions } /** - * Safety net for missed or failed webhooks: re-reads recent Lightning receives - * on every wallet with enabled split rules and records any execution the - * webhook did not. The trigger key is the payment hash — the same key the - * webhook uses — so a receive the webhook already recorded is a duplicate-key + * Safety net for missed or failed webhooks, scoped to shadows only (ENG-717 + * decision 5: the v1 IBEX-wide scan is gone): re-reads recent Lightning + * receives on every SplitReceiveAccount and builds the leg set any receive + * the webhook did not. The trigger key is the payment hash — the same key the + * webhook uses — so a receive the webhook already built is a duplicate-key * no-op. * + * Second job: residue alert. A shadow holding a balance with no + * pending/sending legs for longer than paymentSplits.shadowAlertAfterMs means + * something did not fan out (a stuck net leg, an unknown leg, a missed + * webhook on a wallet whose rules were deleted). Funds in a Flash-owned + * account are customer money in custody — alert ops; draining residue to the + * merchant is a manual step off this alert, not an automated payout. + * * `cadenceMs` is how often the caller runs this. The lookback is derived from - * it (splitReconcileWindowMs: cadence plus a margin) so consecutive runs always - * overlap; a caller cannot hand in a window shorter than its own schedule. + * it (splitReconcileWindowMs: cadence plus a margin) so consecutive runs + * always overlap. + * + * IBEX's balance read lags a just-settled pay by ≥1 s (Phase 0), and the + * alert window (default 10 min) is orders of magnitude wider — a lagging + * balance read cannot trip it. */ export const reconcileSplits = async ({ cadenceMs, @@ -101,32 +117,39 @@ export const reconcileSplits = async ({ const windowMs = splitReconcileWindowMs(cadenceMs) const summary: ReconcileSplitsSummary = { windowMs, - wallets: 0, + shadows: 0, receivesSeen: 0, recorded: 0, + residueAlerts: 0, errors: 0, } if (!PaymentSplitsConfig.enabled) return summary - const walletIds = await PaymentSplitRulesRepository().listWalletIdsWithEnabledRules() - if (walletIds instanceof Error) return walletIds + const shadows = await SplitReceiveAccountsRepository().listAll() + if (shadows instanceof Error) return shadows const since = now.getTime() - windowMs - for (const walletId of walletIds) { - summary.wallets += 1 + for (const shadow of shadows) { + summary.shadows += 1 - const wallet = await WalletsRepository().findById(walletId as WalletId) + const wallet = await WalletsRepository().findById(shadow.walletId as WalletId) if (wallet instanceof Error) { summary.errors += 1 - logger.warn({ walletId, error: wallet }, "split reconcile: wallet lookup failed") + logger.warn( + { walletId: shadow.walletId, error: wallet }, + "split reconcile: wallet lookup failed", + ) continue } - const transactions = await readTransactionsSince(walletId, since) + const transactions = await readTransactionsSince(shadow.ibexAccountId, since) if (transactions instanceof Error) { summary.errors += 1 - logger.warn({ walletId, error: transactions }, "split reconcile: IBEX read failed") + logger.warn( + { shadowId: shadow.ibexAccountId, error: transactions }, + "split reconcile: IBEX read failed", + ) continue } @@ -142,29 +165,110 @@ export const reconcileSplits = async ({ const details = await Ibex.getTransactionDetails(tx.id as IbexTransactionId) if (details instanceof Error) { summary.errors += 1 - logger.warn({ walletId, txId: tx.id, error: details }, "split reconcile: details") + logger.warn( + { shadowId: shadow.ibexAccountId, txId: tx.id, error: details }, + "split reconcile: details", + ) continue } const paymentHash = details.invoice?.hash if (typeof paymentHash !== "string" || !PaymentHashRegex.test(paymentHash)) continue - const recorded = await recordSplitExecutions({ - walletId, + const built = await buildLegs({ + walletId: shadow.walletId, + sourceIbexAccountId: shadow.ibexAccountId, triggerKey: paymentHash, paymentHash, - amount: Number(details.amount ?? tx.amount ?? 0), - currency: wallet.currency, + grossAmount: Number(details.amount ?? tx.amount ?? 0), + currency: shadow.currency, receivedAt, now, }) - if (recorded instanceof Error) { + if (built instanceof Error) { summary.errors += 1 - logger.warn({ walletId, txId: tx.id, error: recorded }, "split reconcile: record") + logger.warn( + { shadowId: shadow.ibexAccountId, txId: tx.id, error: built }, + "split reconcile: build legs", + ) continue } - summary.recorded += recorded.recorded + summary.recorded += built.recorded } + + // Residue check: legs still open for this trigger, against the shadow's + // current balance. A zero balance needs no alert regardless of legs; a + // balance with an open leg inside the alert window is mid-fanout (or a + // lagging read) and waits; a balance with no open legs at all, or open + // legs older than the window, alerts. + const details = await Ibex.getAccountDetails(shadow.ibexAccountId as IbexAccountId) + if (details instanceof Error) { + summary.errors += 1 + logger.warn( + { shadowId: shadow.ibexAccountId, error: details }, + "split reconcile: shadow balance read failed", + ) + continue + } + // asPaymentAmount is the one reader both USD (cents) and USDT (micros) + // amount classes share — it returns major units (dollars / USDT). + const balanceMajor = details.balance + ? Number(details.balance.asPaymentAmount().amount) + : 0 + const balanceCents = Math.round(balanceMajor * 100) + if (balanceCents <= 0) continue + + const openLegs = await hasOpenLegsForShadow(shadow.ibexAccountId) + if (openLegs instanceof Error) { + summary.errors += 1 + continue + } + if (openLegs.open && openLegs.oldestUpdatedAt) { + const openMs = now.getTime() - openLegs.oldestUpdatedAt.getTime() + if (openMs < PaymentSplitsConfig.shadowAlertAfterMs) continue + } + + summary.residueAlerts += 1 + logger.error( + { + walletId: shadow.walletId, + shadowId: shadow.ibexAccountId, + balanceCents, + openLegs, + }, + "split reconcile: shadow receive account holds a residual balance; customer funds in custody — resolve by hand", + ) + notifyOpsEvent({ + flow: "deposit", + phase: "split-residue", + status: "failed", + amount: { value: (balanceCents / 100).toFixed(2), currency: "USD" }, + meta: { + walletId: shadow.walletId, + shadowId: shadow.ibexAccountId, + reason: "residual-balance-on-shadow", + }, + }) } return summary } + +// Whether the shadow has any leg row not yet terminal, and how old the oldest +// open one is. Repo-level query kept local: it is reconcile-only. +const hasOpenLegsForShadow = async ( + sourceIbexAccountId: string, +): Promise<{ open: boolean; oldestUpdatedAt?: Date } | RepositoryError> => { + try { + const doc = await PaymentSplitExecution.findOne({ + sourceIbexAccountId: { $eq: sourceIbexAccountId }, + status: { $in: ["pending", "sending"] }, + }) + .sort({ updatedAt: 1 }) + .lean<{ updatedAt: Date } | null>() + .exec() + if (!doc) return { open: false } + return { open: true, oldestUpdatedAt: new Date(doc.updatedAt) } + } catch (error) { + return parseRepositoryError(error) + } +} diff --git a/src/app/payment-splits/record-split-executions.ts b/src/app/payment-splits/record-split-executions.ts deleted file mode 100644 index a7e14bcbc..000000000 --- a/src/app/payment-splits/record-split-executions.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { PaymentSplitsConfig } from "@config" - -import { computeShare, isSplitCurrency } from "@domain/payment-splits" - -import { isInternalTransferInvoice } from "@services/mongoose/internal-transfer-invoices" -import { PaymentSplitExecutionsRepository } from "@services/mongoose/payment-split-executions" -import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules" - -export type RecordSplitExecutionsResult = { - recorded: number - duplicates: number - skippedMin: number -} - -const NOTHING: RecordSplitExecutionsResult = { recorded: 0, duplicates: 0, skippedMin: 0 } - -/** - * Records one pending split execution per enabled rule on `walletId` for one - * receive. Shared by the on-receive webhook and the reconcile cron, which both - * key the receive by its payment hash so the unique (triggerKey, walletId, - * destinationWalletId) index dedupes them against each other and against - * webhook replays, including a replay after the rules were rewritten (new - * rule ids for the same destinations). - * - * Never sends: payouts happen only in the cron executor. - * - * `receivedAt`, when known (the cron reads it from IBEX, the webhook from its - * body), keeps a rule from firing on a receive that settled before the rule - * was created. - */ -export const recordSplitExecutions = async ({ - walletId, - triggerKey, - paymentHash, - amount, - currency, - receivedAt, - now = new Date(), -}: { - walletId: string - triggerKey: string - paymentHash?: string - amount: number - currency: string - receivedAt?: Date - now?: Date -}): Promise => { - if (!PaymentSplitsConfig.enabled) return NOTHING - if (!isSplitCurrency(currency)) return NOTHING - - const rules = await PaymentSplitRulesRepository().listByWalletId(walletId, { - enabledOnly: true, - }) - if (rules instanceof Error) return rules - if (rules.length === 0) return NOTHING - - // Invoices Flash minted for its own transfers (P2P, Fygaro top-ups, referral - // payouts, split payouts, cutover moves) never trigger splits. - if (paymentHash) { - const internal = await isInternalTransferInvoice(paymentHash) - if (internal instanceof Error) return internal - if (internal) return NOTHING - } - - // ENG-653 decision 1: an LNURL send from another Flash user is marked - // internal only after payToLnurl returns, so this receive can be recorded - // before the marker exists. The executor re-checks the marker at pay time; - // holding the row back for claimGraceMs lets that marker land first. - const firstClaimableAt = new Date(now.getTime() + PaymentSplitsConfig.claimGraceMs) - - const executions = PaymentSplitExecutionsRepository() - const result = { ...NOTHING } - for (const rule of rules) { - if (receivedAt && receivedAt.getTime() < rule.createdAt.getTime()) continue - - const share = computeShare({ - amount, - currency, - percent: rule.percent, - minShareCents: PaymentSplitsConfig.minShareCents, - }) - if (share instanceof Error) return share - - const inserted = await executions.insert({ - triggerKey, - ruleId: rule.id, - walletId, - destinationWalletId: rule.destinationWalletId, - currency, - triggerAmountMinor: share.triggerAmountMinor, - shareCents: share.shareCents, - status: share.status === "ok" ? "pending" : "skipped_min", - nextAttemptAt: firstClaimableAt, - now, - }) - if (inserted instanceof Error) return inserted - if (!inserted.inserted) result.duplicates += 1 - else if (share.status === "skipped_min") result.skippedMin += 1 - else result.recorded += 1 - } - return result -} diff --git a/src/app/payment-splits/set-splits.ts b/src/app/payment-splits/set-splits.ts index 39d9efa85..229926bee 100644 --- a/src/app/payment-splits/set-splits.ts +++ b/src/app/payment-splits/set-splits.ts @@ -18,6 +18,8 @@ import { type PaymentSplitRuleRecord, } from "@services/mongoose/payment-split-rules" +import { ensureReceiveAccount } from "./receive-account" + export type SplitInput = { destination: string; percent: number } /** @@ -152,13 +154,26 @@ export const setPaymentSplits = async ({ }) if (valid instanceof Error) return valid - return LockService().lockWalletId(sourceWallet.id, async () => - PaymentSplitRulesRepository().replaceForWallet( + return LockService().lockWalletId(sourceWallet.id, async () => { + // ENG-717: the shadow receive account must exist before rules go live, or + // routing (receiveAccountFor) would fall back to the real account and + // split nothing. A non-empty set creates it; clearing the rules keeps the + // existing shadow (decision 4: permanent). Creation failure fails the + // mutation, so a wallet is never left with enabled rules and no shadow. + if (entries.length > 0) { + const shadow = await ensureReceiveAccount({ + walletId: sourceWallet.id, + currency: sourceWallet.currency, + }) + if (shadow instanceof Error) return shadow + } + + return PaymentSplitRulesRepository().replaceForWallet( sourceWallet.id, entries.map(({ destinationWalletId, percent }) => ({ destinationWalletId, percent, })), - ), - ) + ) + }) } diff --git a/src/app/wallets/add-invoice-for-wallet.ts b/src/app/wallets/add-invoice-for-wallet.ts index 8cef9cfc3..ec5ad11d9 100644 --- a/src/app/wallets/add-invoice-for-wallet.ts +++ b/src/app/wallets/add-invoice-for-wallet.ts @@ -26,6 +26,8 @@ import { decodeInvoice } from "@domain/bitcoin/lightning/ln-invoice" import { AddInvoiceResponse201 } from "ibex-client" +import { receiveAccountFor } from "@app/payment-splits/receive-account" + import { usdWalletAmountFromInput } from "./usd-wallet-amount" import { validateIsBtcWallet, validateIsUsdWallet } from "./validate" @@ -55,9 +57,13 @@ const addInvoiceForSelf = async ({ ? usdWalletAmountFromInput(amount.toString(), wallet.currency) : undefined if (checkedAmount instanceof Error) return checkedAmount + // ENG-717: an external payer's invoice mints on the wallet's shadow receive + // account when the wallet has enabled split rules; receives then settle + // there and fan out. receiveAccountFor fails safe to the wallet's own id. + const mintAccountId = await receiveAccountFor(walletId) const resp = await Ibex.addInvoice({ amount: checkedAmount, - accountId: walletId, + accountId: mintAccountId, memo, expiration: (expiresIn * 60) as Seconds, }) @@ -144,9 +150,13 @@ const addInvoiceForRecipient = async ({ const checkedAmount = usdWalletAmountFromInput(amount.toString(), wallet.currency) if (checkedAmount instanceof Error) return checkedAmount + // ENG-717: same shadow routing as the self path — an invoice created on + // behalf of a split-enabled recipient is external from the recipient's + // point of view and must settle on the shadow. + const mintAccountId = await receiveAccountFor(recipientWalletId) const resp = await Ibex.addInvoice({ amount: checkedAmount, - accountId: recipientWalletId, + accountId: mintAccountId, memo, expiration: expiresIn ? ((expiresIn * 60) as Seconds) : undefined, }) diff --git a/src/config/locales/en.json b/src/config/locales/en.json index 95f9ce844..680cde509 100644 --- a/src/config/locales/en.json +++ b/src/config/locales/en.json @@ -136,9 +136,9 @@ } }, "paymentSplit": { - "sent": { - "title": "Payment split sent", - "body": "{{amount}} from a payment you received was sent to your split recipient." + "received": { + "title": "Payment received", + "body": "Received ${{amount}} · split to {{splits}} — your share is on its way." }, "failed": { "title": "Payment split failed", diff --git a/src/config/locales/es.json b/src/config/locales/es.json index 2dc655c60..df5b5fc9b 100644 --- a/src/config/locales/es.json +++ b/src/config/locales/es.json @@ -132,9 +132,9 @@ } }, "paymentSplit": { - "sent": { - "title": "División de pago enviada", - "body": "Se enviaron {{amount}} de un pago que recibiste al destinatario de tu división." + "received": { + "title": "Pago recibido", + "body": "Recibiste ${{amount}} · división a {{splits}} — tu parte está en camino." }, "failed": { "title": "La división de pago falló", diff --git a/src/config/schema.ts b/src/config/schema.ts index ba63b75af..c2554100a 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1121,20 +1121,22 @@ export const configSchema = { // Default OFF baseline (mirrors topup); enable per environment via overrides. default: { enabled: false }, }, - // ENG-653: Flash-native payment splits for incoming payments. With this OFF - // the webhook middleware records nothing, the cron tasks no-op and the + // ENG-653, v2 (ENG-717): Flash-native payment splits for incoming + // payments, settled through a shadow receive account. With this OFF the + // webhook settle middleware builds nothing, the loop no-ops, receive + // routing falls back to the wallet's own account and the // walletSplitsSet / walletSplitDelete mutations reject. paymentSplits: { type: "object", properties: { enabled: { type: "boolean", default: false }, - // Sends attempted per execution before a retryable error becomes a - // terminal `failed`. Backoff between attempts is 1, 2, 4, 8... minutes, + // Attempts per leg before a retryable error becomes a terminal + // `failed`. Backoff between attempts is 1, 2, 4, 8... minutes, // so loopIntervalMs must stay at or below one minute for those steps // to be real. maxAttempts: { type: "integer", minimum: 1, default: 5 }, - // A share below this many US cents is recorded as `skipped_min` and - // never sent. + // A share below this many US cents folds into the merchant's net leg + // instead of being paid on its own. minShareCents: { type: "integer", minimum: 1, default: 1 }, // Cadence of the split loop (executor + stale sweep) that runs inside // the long-running ibex-webhook workload. The galoy cron is a daily @@ -1149,13 +1151,13 @@ export const configSchema = { // rows until none are left or the budget is spent, so throughput is // not capped by a fixed batch size. executorTimeBudgetMs: { type: "integer", minimum: 1000, default: 45000 }, - // A newly recorded pending execution is not claimable until this long - // after it was recorded. An LNURL send from another Flash user is - // marked internal only after payToLnurl returns, so the payee's receive - // can be recorded first; the grace lets that marker land before the - // executor's pay-time re-check (ENG-653 decision 1). + // A newly built pending leg is not claimable until this long after it + // was built. An LNURL send from another Flash user is marked internal + // only after payToLnurl returns, so the payee's receive can be built + // first; the grace lets that marker land before the executor's + // pay-time re-check (ENG-653 decision 1). claimGraceMs: { type: "integer", minimum: 0, default: 120000 }, - // How often the loop re-reads recent IBEX receives to record splits + // How often the loop re-reads recent shadow receives to build splits // a missed or failed webhook did not. The lookback is derived from // this (see splitReconcileWindowMs). reconcileIntervalMs: { type: "integer", minimum: 60000, default: 900000 }, @@ -1163,6 +1165,10 @@ export const configSchema = { // The cron reconcile is the backstop for loop downtime and looks back // this long plus a margin. cronIntervalMs: { type: "integer", minimum: 60000, default: 86400000 }, + // A shadow holding a balance with no open leg for longer than this + // means the fanout did not complete (stuck net leg, missed webhook): + // customer funds in a Flash-owned account. Ops alert. + shadowAlertAfterMs: { type: "integer", minimum: 60000, default: 600000 }, }, additionalProperties: false, default: { @@ -1174,6 +1180,7 @@ export const configSchema = { claimGraceMs: 120000, reconcileIntervalMs: 900000, cronIntervalMs: 86400000, + shadowAlertAfterMs: 600000, }, }, frappe: { diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index d3fad2bf3..fdbb24129 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -137,6 +137,7 @@ type PaymentSplitsConfig = { claimGraceMs: number reconcileIntervalMs: number cronIntervalMs: number + shadowAlertAfterMs: number } type CashoutEmail = { diff --git a/src/config/yaml.ts b/src/config/yaml.ts index dfec29107..f117904fe 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -654,6 +654,7 @@ export const PaymentSplitsConfig = (yamlConfig.paymentSplits ?? { claimGraceMs: 120000, reconcileIntervalMs: 900000, cronIntervalMs: 86400000, + shadowAlertAfterMs: 600000, }) as PaymentSplitsConfig export const FrappeConfig = yamlConfig.frappe as FrappeConfig diff --git a/src/domain/payment-splits/compute-share.ts b/src/domain/payment-splits/compute-share.ts index 7ddd6059c..b6cbac373 100644 --- a/src/domain/payment-splits/compute-share.ts +++ b/src/domain/payment-splits/compute-share.ts @@ -18,6 +18,12 @@ export type SplitShare = export const isSplitCurrency = (currency: string): boolean => currency === WalletCurrency.Usd || currency === WalletCurrency.Usdt +// Minor units per major unit per currency (cents per dollar / micros per +// USDT). Also the conversion buildLegs uses for the net leg — ONE map, so a +// future currency cannot diverge between share and net math. +export const minorUnitsPerMajor = (currency: string): bigint | undefined => + MINOR_UNITS_PER_MAJOR[currency] + /** * The share of an incoming payment one split rule pays out, in whole US cents. * diff --git a/src/domain/payment-splits/errors.ts b/src/domain/payment-splits/errors.ts index 98b90e487..75d946145 100644 --- a/src/domain/payment-splits/errors.ts +++ b/src/domain/payment-splits/errors.ts @@ -25,6 +25,13 @@ export class SplitDuplicateDestinationError extends ValidationError {} export class SplitRuleNotFoundError extends ValidationError {} export class PaymentSplitsDisabledError extends ValidationError {} +// ENG-717: the shadow receive account (IBEX account + LNURL-pay backing a +// split-enabled wallet) could not be created or read. walletSplitsSet fails +// with this, so a wallet is never left with enabled rules and no shadow. +export class SplitReceiveAccountError extends DomainError { + level = ErrorLevel.Critical +} + // The internal-transfer marker could not be persisted before paying an // intraledger invoice. The send is refused (no money moved) so that the // recipient's split rules can never fire on a system credit. diff --git a/src/domain/payment-splits/index.ts b/src/domain/payment-splits/index.ts index eafd69423..222e3c611 100644 --- a/src/domain/payment-splits/index.ts +++ b/src/domain/payment-splits/index.ts @@ -2,21 +2,10 @@ export * from "./errors" export * from "./compute-share" export * from "./validate" -export const PaymentSplitExecutionStatus = { - Pending: "pending", - Sending: "sending", - Sent: "sent", - IbexPending: "ibex_pending", - Failed: "failed", - SkippedMin: "skipped_min", - // The trigger turned out to be a Flash-to-Flash payment (its hash was marked - // after the receive was recorded, e.g. an LNURL send): never paid. - SkippedInternal: "skipped_internal", - Unknown: "unknown", -} as const - -export type PaymentSplitExecutionStatus = - (typeof PaymentSplitExecutionStatus)[keyof typeof PaymentSplitExecutionStatus] +// ENG-717: one payout leg of a settled receive. A share leg pays an enabled +// rule's percentage; the net leg pays the merchant the remainder (rounding +// dust, and any share folded for being below minShareCents, go to the net). +export type SplitLegKind = "share" | "net" export const InternalTransferOrigin = { Intraledger: "intraledger", @@ -27,6 +16,10 @@ export const InternalTransferOrigin = { // A Flash user paid a Lightning address / LNURL. IBEX fetches the invoice // itself, so the hash is only known (and marked) after the payment returns. LnurlSend: "lnurl-send", + // A split payout leg: the shadow receive account pays a destination (or the + // merchant's net). Marked before paying, like any Flash-minted invoice, so + // the destination's own splits never fire on it. + Split: "split", } as const export type InternalTransferOrigin = diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index c9e7bdf67..275712ef5 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -111,6 +111,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "Payment splits are not available" return new ValidationInternalError({ message, logger: baseLogger }) + case "SplitReceiveAccountError": + message = "Payment splits are temporarily unavailable, please try again later." + return new UnexpectedClientError({ message, logger: baseLogger }) + case "InternalTransferMarkerError": message = "Sending is temporarily unavailable, please try again later." return new UnexpectedClientError({ message, logger: baseLogger }) diff --git a/src/graphql/public/root/mutation/ln-noamount-invoice-create.ts b/src/graphql/public/root/mutation/ln-noamount-invoice-create.ts index d2c334d64..7ad66b214 100644 --- a/src/graphql/public/root/mutation/ln-noamount-invoice-create.ts +++ b/src/graphql/public/root/mutation/ln-noamount-invoice-create.ts @@ -9,6 +9,7 @@ import LnNoAmountInvoicePayload from "@graphql/public/types/payload/ln-noamount- // FLASH FORK: import ibex dependencies import { decodeInvoice } from "@domain/bitcoin/lightning" import Ibex from "@services/ibex/client" +import { receiveAccountFor } from "@app/payment-splits/receive-account" import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" @@ -51,8 +52,11 @@ const LnNoAmountInvoiceCreateMutation = GT.Field({ // FLASH FORK: create IBEX invoice instead of Galoy invoice // TODO: move this into Wallets.addInvoiceNoAmountForSelf + // ENG-717: mint on the shadow receive account when the wallet has enabled + // split rules (fails safe to the wallet's own id). + const mintAccountId = await receiveAccountFor(walletId) const resp = await Ibex.addInvoice({ - accountId: walletId, + accountId: mintAccountId, memo, expiration: (expiresIn * 60) as Seconds, }) diff --git a/src/graphql/public/root/query/account-default-wallet.ts b/src/graphql/public/root/query/account-default-wallet.ts index 8d5b12f47..2363e6ec9 100644 --- a/src/graphql/public/root/query/account-default-wallet.ts +++ b/src/graphql/public/root/query/account-default-wallet.ts @@ -1,4 +1,6 @@ import { resolveCashWalletPresentationForAccount } from "@app/cash-wallet-cutover" +import { hasEnabledSplits } from "@app/payment-splits/receive-account" +import { SplitReceiveAccountsRepository } from "@services/mongoose/split-receive-accounts" import { CouldNotFindWalletFromUsernameAndCurrencyError } from "@domain/errors" import { mapError } from "@graphql/error-map" import { GT } from "@graphql/index" @@ -34,8 +36,8 @@ const AccountDefaultWalletQuery = GT.Field({ if (presentation instanceof Error) throw mapError(presentation) if (!walletCurrency) { - return presentation.wallets.find( - (wallet) => wallet.id === presentation.defaultWalletId, + return withShadowLnurlp( + presentation.wallets.find((wallet) => wallet.id === presentation.defaultWalletId), ) } @@ -46,8 +48,30 @@ const AccountDefaultWalletQuery = GT.Field({ throw mapError(new CouldNotFindWalletFromUsernameAndCurrencyError(username)) } - return wallet + return withShadowLnurlp(wallet) }, }) +/** + * ENG-717: while a wallet has enabled split rules, its Lightning address + * serves the SHADOW receive account's LNURL-pay, so external pays settle on + * the shadow and fan out (flash-pay reads THIS field; it must not cache the + * answer — see its no-cache change). READ-ONLY: the shadow is created by + * walletSplitsSet; a wallet whose ensure failed at save time falls back to + * its own lnurlp — an unsplit receive, the fail-safe everywhere else too. + */ +const withShadowLnurlp = async ( + wallet: Wallet | undefined, +): Promise => { + if (!wallet) return wallet + + const enabled = await hasEnabledSplits(wallet.id) + if (enabled !== true) return wallet + + const shadow = await SplitReceiveAccountsRepository().findByWalletId(wallet.id) + if (shadow instanceof Error || !shadow?.lnurlp) return wallet + + return { ...wallet, lnurlp: shadow.lnurlp as Lnurl } +} + export default AccountDefaultWalletQuery diff --git a/src/migrations/20261019000000-payment-split-executions-leg-kind.ts b/src/migrations/20261019000000-payment-split-executions-leg-kind.ts new file mode 100644 index 000000000..6e0ac5ebf --- /dev/null +++ b/src/migrations/20261019000000-payment-split-executions-leg-kind.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +/* eslint @typescript-eslint/no-var-requires: "off" */ + +/** + * Migration: payment splits v2 (ENG-717) execution-schema change + * + * Background + * ---------- + * ENG-717 replaces v1's hold-and-forward splits (ENG-653) with settle-on-a- + * shadow-receive-account and immediate fanout. Two collection changes: + * + * 1. `paymentsplitexecutions` gains `legKind` ("share" | "net") and drops the + * v1 `skipped_min` / `skipped_internal` statuses (a sub-min share now folds + * into the net leg instead of being recorded as skipped). The unique index + * moves from (triggerKey, walletId, destinationWalletId) to + * (triggerKey, legKind, destinationWalletId) — the net leg and a share leg + * may share a destination wallet on the same receive, and the fold row + * (decision 2) keys as a share leg to the merchant, distinct from the + * settle-time net row by legKind. + * 2. New collection `splitreceiveaccounts`, one shadow receive account per + * wallet, unique on walletId and on ibexAccountId. + * + * v1 rows are all terminal in prod (every split since 10/13 has settled or + * failed terminally; `pending` rows would be stale beyond the executor's + * retry window), so no data migration is performed: old rows simply do not + * match the new executor's queries in a way that could pay twice — + * `claimNextDue` matches status "pending", and no v1 row is re-claimable once + * its index entry is superseded. Old rows keep their documents; the new index + * accepts them (their legKind field is absent → null, unique per trigger). + * + * Rollout + * ------- + * THIS MIGRATION MUST COMPLETE BEFORE ANY POD RUNNING THE NEW IMAGE BOOTS + * (the chart's wait-for-mongodb-migrate initContainer enforces the ordering + * the same way it does for every galoy workload). `schema.ts` declares the + * new indexes; a `syncIndexes()` boot against a collection still carrying the + * old unique index would conflict with the new one on existing documents + * whose legKind is absent — actually it would not (different key sets), but + * the OLD index would then reject the new net rows (same triggerKey, same + * walletId, same destination as the share leg) with E11000, so the migrate + * Job must have dropped it first. + * + * Rollback (down) + * --------------- + * Recreates the v1 unique index and drops the v2 one, and drops + * `splitreceiveaccounts` ONLY if it is empty (a non-empty one means v2 + * already minted shadows; dropping them would orphan real IBEX accounts — + * those are permanent by decision 4 and are left in place, the collection + * too). + */ + +const EXECUTIONS = "paymentsplitexecutions" +const OLD_INDEX = "triggerKey_1_walletId_1_destinationWalletId_1" +const NEW_INDEX = "triggerKey_1_legKind_1_destinationWalletId_1" +const SHADOWS = "splitreceiveaccounts" + +module.exports = { + async up(db) { + const col = db.collection(EXECUTIONS) + + const exists = await db.listCollections({ name: EXECUTIONS }).toArray() + if (exists.length > 0) { + const old = await col.indexes() + if (old.some((i) => i.name === OLD_INDEX)) { + await col.dropIndex(OLD_INDEX) + console.log(`[migration] ${EXECUTIONS}: dropped "${OLD_INDEX}"`) + } + } + + // createIndex is idempotent (same name + same keys = no-op). The + // partialFilterExpression keeps absent-legKind documents (v1 rows) out of + // the unique key space, so they cannot collide with each other. + await col.createIndex( + { triggerKey: 1, legKind: 1, destinationWalletId: 1 }, + { + unique: true, + name: NEW_INDEX, + partialFilterExpression: { legKind: { $type: "string" } }, + }, + ) + console.log(`[migration] ${EXECUTIONS}: created "${NEW_INDEX}"`) + + // The shadows collection itself needs no migration — its indexes are + // created by syncIndexes() at boot — but creating them here makes the + // first walletSplitsSet not race two unique-index creations. + await db + .collection(SHADOWS) + .createIndex({ walletId: 1 }, { unique: true, name: "walletId_1" }) + await db + .collection(SHADOWS) + .createIndex({ ibexAccountId: 1 }, { unique: true, name: "ibexAccountId_1" }) + console.log(`[migration] ${SHADOWS}: indexes ensured`) + }, + + async down(db) { + const col = db.collection(EXECUTIONS) + const current = await col.indexes() + if (current.some((i) => i.name === NEW_INDEX)) { + await col.dropIndex(NEW_INDEX) + } + await col.createIndex( + { triggerKey: 1, walletId: 1, destinationWalletId: 1 }, + { unique: true, name: OLD_INDEX }, + ) + + const shadowsEmpty = + !(await db.listCollections({ name: SHADOWS }).toArray()).length || + (await db.collection(SHADOWS).countDocuments()) === 0 + if (shadowsEmpty) { + await db.collection(SHADOWS).drop() + console.log(`[migration] ${SHADOWS}: dropped (was empty)`) + } else { + console.log( + `[migration] ${SHADOWS}: NOT dropped — holds created shadow accounts (permanent by ENG-717 decision 4)`, + ) + } + }, +} diff --git a/src/services/ibex/webhook-server/routes/on-receive.ts b/src/services/ibex/webhook-server/routes/on-receive.ts index 6fc84ea89..b74baaf0a 100644 --- a/src/services/ibex/webhook-server/routes/on-receive.ts +++ b/src/services/ibex/webhook-server/routes/on-receive.ts @@ -16,8 +16,14 @@ import { NotificationsServiceError, } from "@domain/notifications" import { getCurrentPriceAsDisplayPriceRatio } from "@app/prices" +import { getI18nInstance } from "@config" +import { getLanguageOrDefault } from "@domain/locale" import { removeDeviceTokens } from "@app/users/remove-device-tokens" -import { recordSplitExecutions } from "@app/payment-splits/record-split-executions" +import { buildLegs } from "@app/payment-splits/build-legs" +import { executePendingSplits } from "@app/payment-splits/execute-pending-splits" +import { resolveShadow } from "@app/payment-splits/receive-account" +import type { SplitReceiveAccountRecord } from "@services/mongoose/split-receive-accounts" +import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules" import { ZapRequestModel } from "@services/mongoose/zap-request" import { ZapPublisher } from "@services/nostr/zapPublisher" import { ibexWebhookPaths } from "@services/ibex/webhook-config" @@ -30,6 +36,10 @@ interface PaymentContext { receiverWallet: Wallet recipientAccount: Account recipientUser: User + // Set when the receive landed on a shadow receive account (ENG-717): the + // settle middleware branches on it, and the notification replaces the plain + // receive push with the split push. + splitReceive?: { shadowId: string; walletId: string } } interface PaymentRequest extends Request { @@ -44,7 +54,36 @@ const fetchPaymentContext = async ( const { transaction } = req.body const recipientWalletId = transaction.accountId - const receiverWallet = await WalletsRepository().findById(recipientWalletId) + // A receive on a shadow receive account resolves to the OWNING wallet's + // account/user: the merchant must be notified, and the settle middleware + // needs the shadow → wallet mapping. Looked up BEFORE WalletsRepository — + // a shadow id is never a wallet id. + // + // Fail-OPEN on a resolve error (a Mongo blip): treat the receive as a plain + // wallet receive. A 500 here makes IBEX redeliver and re-run notifications; + // the reconcile rebuilds any missed shadow legs instead, so failing open + // loses nothing but time. + let shadow: SplitReceiveAccountRecord | null = null + try { + const resolved = await resolveShadow(recipientWalletId) + if (resolved instanceof Error) { + logger.error( + resolved, + `Failed to resolve split receive account with id ${recipientWalletId}; treating as a plain receive — the split reconcile will cover it`, + ) + } else { + shadow = resolved + } + } catch (err) { + logger.error( + { err, accountId: recipientWalletId }, + "split receive resolution threw; treating as a plain receive", + ) + } + + const receiverWallet = await WalletsRepository().findById( + (shadow?.walletId ?? recipientWalletId) as WalletId, + ) if (receiverWallet instanceof RepositoryError) { logger.error(receiverWallet, `Failed to fetch wallet with id ${recipientWalletId}`) return resp.sendStatus(500) @@ -67,17 +106,17 @@ const fetchPaymentContext = async ( ) return resp.sendStatus(500) } - req.paymentContext = { receiverWallet, recipientAccount, recipientUser } + req.paymentContext = { + receiverWallet, + recipientAccount, + recipientUser, + ...(shadow + ? { splitReceive: { shadowId: recipientWalletId, walletId: receiverWallet.id } } + : {}), + } next() } -// ENG-653: record (never send) the payment splits a Lightning/LNURL receive -// triggers. Failure here must not fail the webhook: IBEX would retry it and -// re-run the receive notification. The split reconcile records any receive -// this step missed: every paymentSplits.reconcileIntervalMs from this pod's -// split loop (lookback = interval plus margin) and once a day from the galoy -// cron (lookback = a day plus margin). Duplicate deliveries are duplicate-key -// no-ops. // The receive time from the webhook body, so a rule created after the receive // (a replay that lands after the merchant set or re-saved a split) never fires // on it. Undefined when IBEX sent no parseable time. @@ -91,7 +130,18 @@ const parseReceivedAt = (transaction: unknown): Date | undefined => { return undefined } -const recordPaymentSplits = async ( +// ENG-717: build (never send from this middleware — execution runs after +// next()) the payout legs for a receive settled on a shadow receive account. +// Failure here must not fail the webhook: IBEX would retry it and re-run the +// receive notification. The split reconcile re-reads shadow receives and +// builds any leg set this step missed: every paymentSplits.reconcileIntervalMs +// from this pod's split loop (lookback = interval plus margin) and once a day +// from the galoy cron (lookback = a day plus margin). Duplicate deliveries +// are duplicate-key no-ops. +// +// Execution runs fire-and-forget AFTER next() (see routes below): the webhook +// must return 200 fast, and a failed execution is the loop's retry problem. +const settleSplitReceive = async ( req: PaymentRequest, _resp: Response, next: NextFunction, @@ -102,33 +152,42 @@ const recordPaymentSplits = async ( const { transaction } = req.body const paymentHash = parsePaymentHash(transaction?.invoice?.hash) const amount = Number(transaction?.amount) - if (paymentHash && Number.isFinite(amount)) { - const { receiverWallet } = req.paymentContext - const result = await recordSplitExecutions({ - walletId: receiverWallet.id, + const { splitReceive, receiverWallet } = req.paymentContext + if (splitReceive && paymentHash && Number.isFinite(amount)) { + const built = await buildLegs({ + walletId: splitReceive.walletId, + sourceIbexAccountId: splitReceive.shadowId, triggerKey: paymentHash, paymentHash, - amount, + grossAmount: amount, currency: receiverWallet.currency, receivedAt: parseReceivedAt(transaction), }) - if (result instanceof Error) { + if (built instanceof Error) { logger.error( - { err: result, walletId: receiverWallet.id, paymentHash }, - "Failed to record payment splits; the split reconcile will pick it up", + { err: built, walletId: splitReceive.walletId, paymentHash }, + "Failed to build split legs; the split reconcile will pick it up", ) } } } catch (err) { logger.error( { err }, - "Failed to record payment splits; the split reconcile will pick it up", + "Failed to build split legs; the split reconcile will pick it up", ) } next() } +// Fire-and-forget executor kick after the response is on its way. Errors are +// logged, never thrown: the split loop retries everything still `pending`. +const executeSplitLegs = () => { + executePendingSplits().catch((err) => + logger.error({ err }, "inline split execution failed; the split loop will retry"), + ) +} + const sendLightningNotification = async ( req: PaymentRequest, resp: Response, @@ -138,7 +197,24 @@ const sendLightningNotification = async ( const { transaction, receivedMsat } = req.body const receivedSat = receivedMsat / 1000 - const { receiverWallet, recipientAccount, recipientUser } = req.paymentContext + const { receiverWallet, recipientAccount, recipientUser, splitReceive } = + req.paymentContext + + // A receive on a shadow replaces the plain receive push with the split push: + // the merchant's wallet is credited the NET moments later, so "received $50" + // would be wrong. "Received $50 · $5 split to @partner" names both legs. + if (splitReceive) { + await sendSplitReceivedNotification(req) + notifyOpsEvent({ + flow: "deposit", + phase: "succeeded", + status: "success", + accountId: recipientAccount.id, + amount: { value: transaction.amount, currency: receiverWallet.currency }, + meta: { type: "lightning", splitReceive: "true" }, + }) + return next() + } const nsResp = await NotificationsService().lightningTxReceived({ recipientAccountId: recipientAccount.id, @@ -171,6 +247,66 @@ const sendLightningNotification = async ( next() } +// The split variant of the receive push (ENG-717): names the gross and where +// the shares went. Best-effort like every notification on a money path — the +// legs were already built and will pay regardless. +const i18n = getI18nInstance() + +const sendSplitReceivedNotification = async (req: PaymentRequest): Promise => { + try { + const { transaction } = req.body + const { recipientUser, receiverWallet, splitReceive } = req.paymentContext! + if (!splitReceive) return + + const rules = await PaymentSplitRulesRepository().listByWalletId(receiverWallet.id, { + enabledOnly: true, + }) + const destinations: string[] = [] + if (rules instanceof Error) { + // The push still goes out without destination names: the merchant must + // hear about the receive even when the split metadata is unreadable. + logger.warn(rules, "could not list split rules for the receive push") + } else { + for (const rule of rules) { + const wallet = await WalletsRepository().findById( + rule.destinationWalletId as WalletId, + ) + let name: string | undefined + if (!(wallet instanceof Error)) { + const account = await AccountsRepository().findById(wallet.accountId) + if (!(account instanceof Error) && account.username) + name = `@${account.username}` + } + destinations.push(name ?? rule.destinationWalletId) + } + } + + const locale = getLanguageOrDefault(recipientUser.language) + const title = i18n.__({ + phrase: "notification.paymentSplit.received.title", + locale, + }) + const body = i18n.__( + { phrase: "notification.paymentSplit.received.body", locale }, + { amount: Number(transaction.amount).toFixed(2), splits: destinations.join(", ") }, + ) + + if (recipientUser.deviceTokens && recipientUser.deviceTokens.length > 0) { + const nsResp = await NotificationsService().adminPushNotificationSend({ + deviceTokens: recipientUser.deviceTokens, + title, + body, + data: { type: "payment_split_received" }, + }) + if (nsResp instanceof NotificationsServiceError) { + logger.error(nsResp, "split receive push failed") + } + } + } catch (err) { + logger.error({ err }, "split receive push failed") + } +} + const sendOnchainNotification = async ( req: PaymentRequest, res: Response, @@ -263,6 +399,14 @@ const webhookRateLimit = rateLimitMiddleware({ legacyHeaders: false, }) +const respond200 = (_req: Request, resp: Response) => { + // Split legs built above are executed in-process, after the response — + // §2.3: settle → 200 → execute. Internal pays are synchronous (~0.5 s) and + // the loop retries anything left pending. + if ((_req as PaymentRequest).paymentContext?.splitReceive) executeSplitLegs() + resp.status(200).end() +} + router.post( paths.invoice, webhookRateLimit, @@ -270,10 +414,10 @@ router.post( authenticate, logRequest, fetchPaymentContext, - recordPaymentSplits, + settleSplitReceive, sendLightningNotification, sendZapReceipt, - (_req: Request, resp: Response) => resp.status(200).end(), + respond200, ) router.post( @@ -283,10 +427,10 @@ router.post( authenticate, logRequest, fetchPaymentContext, - recordPaymentSplits, + settleSplitReceive, sendLightningNotification, sendZapReceipt, - (_req: Request, resp: Response) => resp.status(200).end(), + respond200, ) router.post( diff --git a/src/services/mongoose/payment-split-executions.ts b/src/services/mongoose/payment-split-executions.ts index f62e2a16c..1f7d465d6 100644 --- a/src/services/mongoose/payment-split-executions.ts +++ b/src/services/mongoose/payment-split-executions.ts @@ -7,18 +7,18 @@ export type PaymentSplitExecutionStatusValue = | "sent" | "ibex_pending" | "failed" - | "skipped_min" - | "skipped_internal" | "unknown" export type PaymentSplitExecutionRecord = { id: string triggerKey: string ruleId: string + legKind: "share" | "net" walletId: string + sourceIbexAccountId: string destinationWalletId: string currency: string - triggerAmountMinor: string + grossMinor: string shareCents: number status: PaymentSplitExecutionStatusValue attempts: number @@ -42,10 +42,12 @@ const toExecution = (raw: RawExecution): PaymentSplitExecutionRecord => ({ id: raw._id.toString(), triggerKey: raw.triggerKey, ruleId: raw.ruleId, + legKind: raw.legKind, walletId: raw.walletId, + sourceIbexAccountId: raw.sourceIbexAccountId, destinationWalletId: raw.destinationWalletId, currency: raw.currency, - triggerAmountMinor: raw.triggerAmountMinor, + grossMinor: raw.grossMinor, shareCents: raw.shareCents, status: raw.status, attempts: raw.attempts, @@ -57,20 +59,22 @@ const toExecution = (raw: RawExecution): PaymentSplitExecutionRecord => ({ export const PaymentSplitExecutionsRepository = () => { /** - * Inserts one execution. A duplicate (triggerKey, walletId, - * destinationWalletId) — a webhook replay, or the reconcile cron finding a - * receive the webhook already recorded, even under a rule rewritten since — - * is a typed no-op: `{ inserted: false }`. + * Inserts one leg. A duplicate (triggerKey, legKind, destinationWalletId) — + * a webhook replay, or the reconcile cron finding a receive the webhook + * already built, even under a rule rewritten since — is a typed no-op: + * `{ inserted: false }`. */ const insert = async (data: { triggerKey: string + legKind: "share" | "net" ruleId: string walletId: string + sourceIbexAccountId: string destinationWalletId: string currency: string - triggerAmountMinor: string + grossMinor: string shareCents: number - status: "pending" | "skipped_min" + status: "pending" nextAttemptAt?: Date now?: Date }): Promise<{ inserted: true; id: string } | { inserted: false } | RepositoryError> => { @@ -90,6 +94,55 @@ export const PaymentSplitExecutionsRepository = () => { } } + /** + * ENG-717 decision 2 (fold, never fail): tops the merchant up for a share + * leg that failed terminally. Inserts one `share`-kind row keyed on the + * MERCHANT as destination for the same trigger: unique against the + * settle-time net row (different legKind) and against the original share + * row (different destination). One fold per (trigger, destination): a + * replayed fold is a duplicate-key no-op. The fold row is claimable at + * `now` — no claimGraceMs, the trigger receive already settled. + */ + const insertFold = async ( + execution: { + triggerKey: string + ruleId: string + walletId: string + sourceIbexAccountId: string + currency: string + grossMinor: string + shareCents: number + }, + reason: string, + now: Date = new Date(), + ): Promise => { + try { + await PaymentSplitExecutionModel.create({ + triggerKey: execution.triggerKey, + legKind: "share", + ruleId: execution.ruleId, + walletId: execution.walletId, + sourceIbexAccountId: execution.sourceIbexAccountId, + destinationWalletId: execution.walletId, + currency: execution.currency, + grossMinor: execution.grossMinor, + shareCents: execution.shareCents, + status: "pending", + attempts: 0, + lastError: `fold: ${reason}`, + nextAttemptAt: now, + createdAt: now, + updatedAt: now, + }) + return true + } catch (error) { + // The share was already folded (an earlier attempt folded it, or the + // settle-time net leg covers it): nothing more to pay. + if (isDuplicateKeyError(error)) return false + return toError(error) + } + } + /** * Atomically moves one due (nextAttemptAt <= `now`) `pending` row to * `sending`. Two concurrent claimers can never both get the same row: @@ -122,13 +175,7 @@ export const PaymentSplitExecutionsRepository = () => { const finishClaimed = async ( id: string, update: { - status: - | "pending" - | "sent" - | "ibex_pending" - | "failed" - | "skipped_internal" - | "unknown" + status: "pending" | "sent" | "ibex_pending" | "failed" | "unknown" attempts: number nextAttemptAt?: Date lastError?: string @@ -166,5 +213,5 @@ export const PaymentSplitExecutionsRepository = () => { } } - return { insert, claimNextDue, finishClaimed, markNextStaleUnknown } + return { insert, insertFold, claimNextDue, finishClaimed, markNextStaleUnknown } } diff --git a/src/services/mongoose/schema.ts b/src/services/mongoose/schema.ts index e3976f57a..0bf869a95 100644 --- a/src/services/mongoose/schema.ts +++ b/src/services/mongoose/schema.ts @@ -911,35 +911,69 @@ PaymentSplitRuleSchema.index({ walletId: 1, enabled: 1 }) export const PaymentSplitRule = mongoose.model("PaymentSplitRule", PaymentSplitRuleSchema) -// One row per (receive, payout target). The unique (triggerKey, walletId, +// ENG-717: one row per wallet with (or previously with) split rules — the +// Flash-owned IBEX account external receives are minted on so no user wallet +// ever holds the gross. Permanent per wallet (decision 4): created lazily by +// walletSplitsSet, never deleted. After the last rule is deleted the shadow +// keeps its residue, which the split reconcile drains to the merchant. +const SplitReceiveAccountSchema = new Schema({ + // The Flash wallet the shadow backs (Wallet.id == the merchant's own IBEX + // account id). + walletId: { type: String, required: true }, + // The shadow IBEX account id. + ibexAccountId: { type: String, required: true }, + currency: { type: String, required: true }, + // The IBEX LNURL-pay created on the shadow account, served for the wallet's + // Lightning address while it has enabled rules. + lnurlp: { type: String }, + createdAt: { type: Date, required: true, default: Date.now }, + updatedAt: { type: Date, required: true, default: Date.now }, +}) + +SplitReceiveAccountSchema.index({ walletId: 1 }, { unique: true }) +SplitReceiveAccountSchema.index({ ibexAccountId: 1 }, { unique: true }) + +export const SplitReceiveAccount = mongoose.model( + "SplitReceiveAccount", + SplitReceiveAccountSchema, +) + +// One row per (receive, payout target). The unique (triggerKey, legKind, // destinationWalletId) index is the dedupe between the webhook and the // reconcile cron, and between webhook replays: a second insert for the same // receive is a duplicate-key no-op. It is keyed on the payout target, not the // rule id, because walletSplitsSet replaces every rule with a fresh _id: a // replay after the merchant re-saves (or deletes and re-adds) the same split // must still be a duplicate, not a second payout. A wallet's rules never -// repeat a destination (SplitDuplicateDestinationError). +// repeat a destination (SplitDuplicateDestinationError), and a receive has +// exactly one net leg, so the legKind in the key keeps the net leg from +// colliding with a share leg to the same wallet. +// +// v1 rows carried no legKind and a (triggerKey, walletId, destinationWalletId) +// unique index; every v1 row is terminal in prod, so the 2026-10 migration +// drops the old index and this schema declares the replacement. const PaymentSplitExecutionSchema = new Schema({ triggerKey: { type: String, required: true }, ruleId: { type: String, required: true }, + // Which leg of the settle this row pays. + legKind: { + type: String, + enum: ["share", "net"], + required: true, + }, walletId: { type: String, required: true }, + // The shadow receive account the leg is paid FROM (the IBEX account the + // receive settled on). + sourceIbexAccountId: { type: String, required: true }, destinationWalletId: { type: String, required: true }, currency: { type: String, required: true }, - // The receive amount in the source wallet's minor units (cents / micros). - triggerAmountMinor: { type: String, required: true }, + // The gross receive amount in the source wallet's minor units + // (cents / micros). + grossMinor: { type: String, required: true }, shareCents: { type: Number, required: true }, status: { type: String, - enum: [ - "pending", - "sending", - "sent", - "ibex_pending", - "failed", - "skipped_min", - "skipped_internal", - "unknown", - ], + enum: ["pending", "sending", "sent", "ibex_pending", "failed", "unknown"], required: true, }, attempts: { type: Number, required: true, default: 0 }, @@ -950,7 +984,7 @@ const PaymentSplitExecutionSchema = new Schema({ }) PaymentSplitExecutionSchema.index( - { triggerKey: 1, walletId: 1, destinationWalletId: 1 }, + { triggerKey: 1, legKind: 1, destinationWalletId: 1 }, { unique: true }, ) PaymentSplitExecutionSchema.index({ status: 1, nextAttemptAt: 1 }) @@ -975,7 +1009,7 @@ const InternalTransferInvoiceSchema = new Schema({ recipientWalletId: { type: String }, origin: { type: String, - enum: ["intraledger", "cutover", "ln-send", "lnurl-send"], + enum: ["intraledger", "cutover", "ln-send", "lnurl-send", "split"], required: true, }, createdAt: { type: Date, required: true, default: Date.now }, diff --git a/src/services/mongoose/split-receive-accounts.ts b/src/services/mongoose/split-receive-accounts.ts new file mode 100644 index 000000000..d33e587a2 --- /dev/null +++ b/src/services/mongoose/split-receive-accounts.ts @@ -0,0 +1,103 @@ +import { parseRepositoryError } from "./utils" +import { SplitReceiveAccount as SplitReceiveAccountModel } from "./schema" + +export type SplitReceiveAccountRecord = { + walletId: string + ibexAccountId: string + currency: string + lnurlp?: string + createdAt: Date + updatedAt: Date +} + +type RawRecord = Omit & { + _id: { toString(): string } +} + +const isDuplicateKeyError = (error: unknown): boolean => + (error as { code?: number } | null)?.code === 11000 + +// Typed RepositoryErrors, so every failure maps cleanly through error-map. +const toError = (error: unknown): RepositoryError => parseRepositoryError(error) + +const toRecord = (raw: RawRecord): SplitReceiveAccountRecord => ({ + walletId: raw.walletId, + ibexAccountId: raw.ibexAccountId, + currency: raw.currency, + lnurlp: raw.lnurlp, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, +}) + +export const SplitReceiveAccountsRepository = () => { + // ENG-717 decision 4: the shadow is permanent. A second ensure for the same + // wallet (every walletSplitsSet after the first) is a typed no-op, so the + // IBEX account and LNURL are created exactly once. + const insert = async (data: { + walletId: string + ibexAccountId: string + currency: string + lnurlp?: string + now?: Date + }): Promise<{ created: true } | { created: false } | RepositoryError> => { + const { now = new Date(), ...fields } = data + try { + await SplitReceiveAccountModel.create({ + ...fields, + createdAt: now, + updatedAt: now, + }) + return { created: true } + } catch (error) { + if (isDuplicateKeyError(error)) return { created: false } + return toError(error) + } + } + + const findByWalletId = async ( + walletId: string, + ): Promise => { + try { + const doc = await SplitReceiveAccountModel.findOne({ + walletId: { $eq: walletId }, + }) + .lean() + .exec() + return doc ? toRecord(doc) : null + } catch (error) { + return toError(error) + } + } + + const findByIbexAccountId = async ( + ibexAccountId: string, + ): Promise => { + try { + const doc = await SplitReceiveAccountModel.findOne({ + ibexAccountId: { $eq: ibexAccountId }, + }) + .lean() + .exec() + return doc ? toRecord(doc) : null + } catch (error) { + return toError(error) + } + } + + // Every shadow ever created. The reconcile scans them all: one with a + // balance and no in-flight legs is either a missed webhook (build legs) or + // residue (alert, then drain per the lifecycle rule). + const listAll = async (): Promise => { + try { + const docs = await SplitReceiveAccountModel.find({}) + .sort({ createdAt: 1 }) + .lean() + .exec() + return docs.map(toRecord) + } catch (error) { + return toError(error) + } + } + + return { insert, findByWalletId, findByIbexAccountId, listAll } +} diff --git a/test/flash/integration/payment-splits/payment-splits-repositories.spec.ts b/test/flash/integration/payment-splits/payment-splits-repositories.spec.ts index 49344b6c2..8f015aa30 100644 --- a/test/flash/integration/payment-splits/payment-splits-repositories.spec.ts +++ b/test/flash/integration/payment-splits/payment-splits-repositories.spec.ts @@ -1,12 +1,13 @@ import { PaymentSplitsConfig } from "@config" -import { recordSplitExecutions } from "@app/payment-splits/record-split-executions" +import { buildLegs } from "@app/payment-splits/build-legs" import { INTERNAL_TRANSFER_INVOICE_TTL_SECONDS, InternalTransferInvoice, PaymentSplitExecution, PaymentSplitRule, + SplitReceiveAccount, } from "@services/mongoose/schema" import { deleteInternalTransferInvoice, @@ -16,32 +17,40 @@ import { } from "@services/mongoose/internal-transfer-invoices" import { PaymentSplitExecutionsRepository } from "@services/mongoose/payment-split-executions" import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules" +import { SplitReceiveAccountsRepository } from "@services/mongoose/split-receive-accounts" const executionFields = ( triggerKey: string, ruleId: string, - { destinationWalletId = "dest-wallet" }: { destinationWalletId?: string } = {}, + { + destinationWalletId = "dest-wallet", + legKind = "share", + }: { destinationWalletId?: string; legKind?: "share" | "net" } = {}, ) => ({ triggerKey, + legKind, ruleId, walletId: "source-wallet", + sourceIbexAccountId: "shadow-account", destinationWalletId, currency: "USDT", - triggerAmountMinor: "1000000", + grossMinor: "1000000", shareCents: 10, status: "pending" as const, }) -describe("payment split repositories (ENG-653)", () => { +describe("payment split repositories (ENG-717)", () => { beforeAll(async () => { await PaymentSplitExecution.syncIndexes() await InternalTransferInvoice.syncIndexes() + await SplitReceiveAccount.syncIndexes() }) beforeEach(async () => { await PaymentSplitExecution.deleteMany({}) await InternalTransferInvoice.deleteMany({}) await PaymentSplitRule.deleteMany({}) + await SplitReceiveAccount.deleteMany({}) }) it("replaces a wallet's whole rule set and lists wallets with enabled rules", async () => { @@ -76,7 +85,7 @@ describe("payment split repositories (ENG-653)", () => { expect(await rules.listWalletIdsWithEnabledRules()).toEqual([]) }) - it("rejects a duplicate (triggerKey, walletId, destinationWalletId) as a typed no-op", async () => { + it("rejects a duplicate (triggerKey, legKind, destinationWalletId) as a typed no-op", async () => { const repo = PaymentSplitExecutionsRepository() const triggerKey = "d".repeat(64) @@ -89,22 +98,41 @@ describe("payment split repositories (ENG-653)", () => { const otherDestination = await repo.insert( executionFields(triggerKey, "rule-3", { destinationWalletId: "dest-wallet-2" }), ) + // The NET leg to the merchant on the same receive: different legKind, so + // it coexists with a share leg to the same wallet (v1's index could not). + const netLeg = await repo.insert( + executionFields(triggerKey, "merchant", { + destinationWalletId: "source-wallet", + legKind: "net", + }), + ) expect(first).toMatchObject({ inserted: true }) expect(second).toEqual({ inserted: false }) expect(rewrittenRule).toEqual({ inserted: false }) expect(otherDestination).toMatchObject({ inserted: true }) - expect(await PaymentSplitExecution.countDocuments({ triggerKey })).toBe(2) + expect(netLeg).toMatchObject({ inserted: true }) + expect(await PaymentSplitExecution.countDocuments({ triggerKey })).toBe(3) const indexes = await PaymentSplitExecution.collection.indexes() expect(indexes).toEqual( expect.arrayContaining([ expect.objectContaining({ - key: { triggerKey: 1, walletId: 1, destinationWalletId: 1 }, + key: { triggerKey: 1, legKind: 1, destinationWalletId: 1 }, unique: true, }), ]), ) + // v1's index is gone (dropped by the migration, not re-created by the + // schema): a share leg to the merchant and the net leg must coexist. + expect( + indexes.find( + (index) => + index.unique && + JSON.stringify(index.key) === + JSON.stringify({ triggerKey: 1, walletId: 1, destinationWalletId: 1 }), + ), + ).toBeUndefined() expect( indexes.find( (index) => @@ -114,6 +142,56 @@ describe("payment split repositories (ENG-653)", () => { ).toBeUndefined() }) + it("keeps split receive accounts unique per wallet and per IBEX account", async () => { + const repo = SplitReceiveAccountsRepository() + + const first = await repo.insert({ + walletId: "wallet-s", + ibexAccountId: "shadow-1", + currency: "USDT", + lnurlp: "lnurl1abc", + }) + expect(first).toEqual({ created: true }) + + // Same wallet again: typed no-op (idempotent ensure). + expect( + await repo.insert({ + walletId: "wallet-s", + ibexAccountId: "shadow-2", + currency: "USDT", + }), + ).toEqual({ created: false }) + // Same IBEX account under another wallet: also a duplicate. + expect( + await repo.insert({ + walletId: "wallet-t", + ibexAccountId: "shadow-1", + currency: "USDT", + }), + ).toEqual({ created: false }) + + expect(await repo.findByWalletId("wallet-s")).toMatchObject({ + ibexAccountId: "shadow-1", + lnurlp: "lnurl1abc", + }) + expect(await repo.findByWalletId("wallet-none")).toBeNull() + expect(await repo.findByIbexAccountId("shadow-1")).toMatchObject({ + walletId: "wallet-s", + }) + expect(await repo.findByIbexAccountId("shadow-none")).toBeNull() + const all = await repo.listAll() + if (all instanceof Error) throw all + expect(all.map((s) => s.walletId)).toEqual(["wallet-s"]) + + const indexes = await SplitReceiveAccount.collection.indexes() + expect(indexes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: { walletId: 1 }, unique: true }), + expect.objectContaining({ key: { ibexAccountId: 1 }, unique: true }), + ]), + ) + }) + it("carries createdAt forward for a destination that survives a re-save", async () => { const rules = PaymentSplitRulesRepository() const firstSave = new Date("2026-10-11T12:00:00Z") @@ -149,7 +227,7 @@ describe("payment split repositories (ENG-653)", () => { expect(byDestination.has("dropped")).toBe(false) }) - it("records an unrecorded receive that settled before a re-save of the same destination", async () => { + it("builds legs for an unrecorded receive that settled before a re-save of the same destination", async () => { const enabled = PaymentSplitsConfig.enabled PaymentSplitsConfig.enabled = true try { @@ -167,7 +245,7 @@ describe("payment split repositories (ENG-653)", () => { ) if (saved instanceof Error) throw saved - // A receive settles at 12:00 and the webhook's record fails. At 12:02 the + // A receive settles at 12:00 and the webhook's build fails. At 12:02 the // merchant re-saves, adding a destination and keeping the partner. const reSaved = await rules.replaceForWallet( "wallet-c", @@ -179,29 +257,41 @@ describe("payment split repositories (ENG-653)", () => { ) if (reSaved instanceof Error) throw reSaved - // The reconcile at 12:15 records the partner's share. The destination - // added after the receive is still skipped. + // The reconcile at 12:15 builds the partner's share and the net leg. The + // destination added after the receive is still skipped (receivedAt gate). const triggerKey = "c".repeat(64) - const result = await recordSplitExecutions({ + const result = await buildLegs({ walletId: "wallet-c", + sourceIbexAccountId: "shadow-c", triggerKey, paymentHash: triggerKey, - amount: 100, + grossAmount: 100, currency: "USDT", receivedAt, now: reconcileAt, }) if (result instanceof Error) throw result - expect(result).toMatchObject({ recorded: 1 }) - const rows = await PaymentSplitExecution.find({ triggerKey }).lean() - expect(rows.map((row) => row.destinationWalletId)).toEqual(["partner"]) + // partner share (10 USDT = 1_000_000 micros * 10% / 10_000 = 1000 cents) + // plus the net leg. + expect(result).toMatchObject({ recorded: 2 }) + const rows = await PaymentSplitExecution.find({ triggerKey }) + .sort({ legKind: 1 }) + .lean() + expect(rows.map((row) => row.destinationWalletId).sort()).toEqual([ + "partner", + "wallet-c", + ]) + expect(rows.find((row) => row.legKind === "net")).toMatchObject({ + destinationWalletId: "wallet-c", + shareCents: 9000, + }) } finally { PaymentSplitsConfig.enabled = enabled } }) - it("disables a rule once, and a disabled rule records nothing (ENG-660)", async () => { + it("disables a rule once, and a disabled rule builds no share leg (ENG-660)", async () => { const enabled = PaymentSplitsConfig.enabled PaymentSplitsConfig.enabled = true try { @@ -231,20 +321,28 @@ describe("payment split repositories (ENG-653)", () => { expect(await rules.existsForDestination("wallet-d", "closed-partner")).toBe(true) expect(await rules.existsForDestination("wallet-d", "no-such-partner")).toBe(false) - // Webhook and reconcile both record through recordSplitExecutions. + // Webhook and reconcile both build through buildLegs. const triggerKey = "d".repeat(64) - const result = await recordSplitExecutions({ + const result = await buildLegs({ walletId: "wallet-d", + sourceIbexAccountId: "shadow-d", triggerKey, paymentHash: triggerKey, - amount: 100, + grossAmount: 100, currency: "USDT", }) if (result instanceof Error) throw result - expect(result).toMatchObject({ recorded: 1 }) + // open-partner's share + the net leg (the closed partner's share folded + // into the net by receivedAt/skipped-min folding — here by inactivity + // being a save-time reject, so only enabled rules are listed). + expect(result).toMatchObject({ recorded: 2 }) const rows = await PaymentSplitExecution.find({ triggerKey }).lean() - expect(rows.map((row) => row.destinationWalletId)).toEqual(["open-partner"]) + expect( + rows + .filter((row) => row.legKind === "share") + .map((row) => row.destinationWalletId), + ).toEqual(["open-partner"]) // A wallet whose only rule is disabled is not reconciled at all. const only = await rules.replaceForWallet("wallet-e", [ @@ -258,35 +356,7 @@ describe("payment split repositories (ENG-653)", () => { } }) - it("disables a destination's rule after a re-save gave it a new id (ENG-660)", async () => { - const rules = PaymentSplitRulesRepository() - const first = await rules.replaceForWallet("wallet-f", [ - { destinationWalletId: "closed-partner", percent: 10 }, - ]) - if (first instanceof Error) throw first - // The owner edits the percentage: the rule is re-inserted with a new id, - // so an execution recorded under the first id no longer names a rule. - const second = await rules.replaceForWallet("wallet-f", [ - { destinationWalletId: "closed-partner", percent: 15 }, - ]) - if (second instanceof Error) throw second - expect(second[0].id).not.toEqual(first[0].id) - expect(await rules.findById(first[0].id)).toBeNull() - - // Another owner has an enabled rule to the same destination. - const other = await rules.replaceForWallet("wallet-g", [ - { destinationWalletId: "closed-partner", percent: 5 }, - ]) - if (other instanceof Error) throw other - - expect(await rules.disableForDestination("wallet-f", "closed-partner")).toBe(true) - expect(await rules.findById(second[0].id)).toMatchObject({ enabled: false }) - // Disabling wallet-f's rule must not touch wallet-g's rule. - expect(await rules.findById(other[0].id)).toMatchObject({ enabled: true }) - expect(await rules.disableForDestination("wallet-g", "closed-partner")).toBe(true) - }) - - it("lets exactly one of several concurrent claimers claim a pending row", async () => { + it("lets exactly one of several concurrent claimers claim a pending leg", async () => { const repo = PaymentSplitExecutionsRepository() await repo.insert(executionFields("e".repeat(64), "rule-1")) @@ -300,7 +370,7 @@ describe("payment split repositories (ENG-653)", () => { expect(claimed[0]).toMatchObject({ status: "sending" }) }) - it("does not claim a row before its first nextAttemptAt", async () => { + it("does not claim a leg before its first nextAttemptAt", async () => { const repo = PaymentSplitExecutionsRepository() const now = new Date() const claimableAt = new Date(now.getTime() + 120_000) @@ -314,6 +384,36 @@ describe("payment split repositories (ENG-653)", () => { expect(await repo.claimNextDue(claimableAt)).toMatchObject({ status: "sending" }) }) + it("inserts one fold per (trigger, merchant), replay-safe", async () => { + const repo = PaymentSplitExecutionsRepository() + const triggerKey = "9".repeat(64) + await repo.insert(executionFields(triggerKey, "rule-1")) + + const execution = { + triggerKey, + ruleId: "rule-1", + walletId: "source-wallet", + sourceIbexAccountId: "shadow-account", + currency: "USDT", + grossMinor: "1000000", + shareCents: 10, + } + expect(await repo.insertFold(execution, "InactiveAccountError")).toBe(true) + // A replayed fold (the fold row itself failing terminally again) is a no-op. + expect(await repo.insertFold(execution, "InactiveAccountError")).toBe(false) + + const folds = await PaymentSplitExecution.find({ + triggerKey, + destinationWalletId: "source-wallet", + }).lean() + expect(folds).toHaveLength(1) + expect(folds[0]).toMatchObject({ + legKind: "share", + status: "pending", + lastError: "fold: InactiveAccountError", + }) + }) + it("has a TTL index on internal-transfer markers that outlives the reconcile lookback", async () => { const indexes = await InternalTransferInvoice.collection.indexes() const ttl = indexes.find((index) => index.expireAfterSeconds !== undefined) @@ -341,6 +441,18 @@ describe("payment split repositories (ENG-653)", () => { expect(await InternalTransferInvoice.countDocuments({})).toBe(1) }) + it("accepts the split payout origin on a marker", async () => { + const hash = "ab".repeat(32) + expect( + await insertInternalTransferInvoice({ + paymentHash: hash, + recipientWalletId: "partner", + origin: "split", + }), + ).toEqual({ created: true }) + expect(await isInternalTransferInvoice(hash)).toBe(true) + }) + it("keeps one marker per (hash, sender, origin), not one per hash", async () => { const hash = "ef".repeat(32) const lnSend = (senderWalletId: string) => ({ diff --git a/test/flash/unit/app/accounts/get-lnurlp.spec.ts b/test/flash/unit/app/accounts/get-lnurlp.spec.ts new file mode 100644 index 000000000..389c48606 --- /dev/null +++ b/test/flash/unit/app/accounts/get-lnurlp.spec.ts @@ -0,0 +1,135 @@ +const mockFindAccountByUsername = jest.fn() +const mockHasEnabledSplits = jest.fn() +const mockShadowFindByWalletId = jest.fn() + +jest.mock("@services/mongoose", () => ({ + AccountsRepository: jest.fn(() => ({ + findByUsername: (...args: unknown[]) => mockFindAccountByUsername(...args), + })), +})) + +jest.mock("@app/cash-wallet-cutover", () => ({ + resolveCashWalletPresentationForAccount: jest.fn(async ({ account }) => ({ + wallets: [ + { + id: WALLET, + accountId: account.id, + currency: WalletCurrency.Usdt, + lnurlp: OWN_LNURL, + }, + ], + defaultWalletId: WALLET, + activeSettlementWallet: { id: WALLET, currency: WalletCurrency.Usdt }, + })), +})) + +jest.mock("@app/payment-splits/receive-account", () => ({ + hasEnabledSplits: (...args: unknown[]) => mockHasEnabledSplits(...args), +})) + +jest.mock("@services/mongoose/split-receive-accounts", () => ({ + SplitReceiveAccountsRepository: jest.fn(() => ({ + findByWalletId: (...args: unknown[]) => mockShadowFindByWalletId(...args), + })), +})) + +jest.mock("@services/notifications", () => ({ + NotificationsService: jest.fn(() => ({})), +})) + +import { WalletCurrency } from "@domain/shared" +import AccountDefaultWalletQuery from "@graphql/public/root/query/account-default-wallet" + +const WALLET = "11111111-1111-4111-8111-111111111111" +const OWN_LNURL = "lnurl1own-wallet" +const SHADOW_LNURL = "lnurl1shadow" +const USERNAME = "merchant" + +const resolve = async (walletCurrency?: unknown) => + ( + AccountDefaultWalletQuery as unknown as { + resolve: ( + s: null, + a: { username: unknown; walletCurrency: unknown }, + c: { cashWalletClientCapabilities: unknown }, + ) => Promise + } + ).resolve( + null, + { username: USERNAME, walletCurrency }, + { + cashWalletClientCapabilities: { cashWalletUsdt: true }, + }, + ) + +beforeEach(() => { + jest.clearAllMocks() + mockFindAccountByUsername.mockResolvedValue({ id: "owner-account" }) + mockHasEnabledSplits.mockResolvedValue(false) +}) + +describe("accountDefaultWallet.lnurlp shadow routing (ENG-717)", () => { + it("serves the wallet's own lnurlp when it has no enabled splits", async () => { + const wallet = (await resolve("USDT")) as { lnurlp: string } + + expect(wallet.lnurlp).toBe(OWN_LNURL) + expect(mockShadowFindByWalletId).not.toHaveBeenCalled() + }) + + it("serves the shadow's lnurlp when the wallet has enabled splits", async () => { + mockHasEnabledSplits.mockResolvedValue(true) + mockShadowFindByWalletId.mockResolvedValue({ + walletId: WALLET, + ibexAccountId: "shadow-account", + currency: "USDT", + lnurlp: SHADOW_LNURL, + createdAt: new Date(), + updatedAt: new Date(), + }) + + const wallet = (await resolve("USDT")) as { lnurlp: string } + + expect(wallet.lnurlp).toBe(SHADOW_LNURL) + expect(mockShadowFindByWalletId).toHaveBeenCalledWith(WALLET) + }) + + it("falls back to the wallet's own lnurlp when the shadow has no lnurlp", async () => { + mockHasEnabledSplits.mockResolvedValue(true) + mockShadowFindByWalletId.mockResolvedValue({ + walletId: WALLET, + ibexAccountId: "shadow-account", + currency: "USDT", + createdAt: new Date(), + updatedAt: new Date(), + }) + + const wallet = (await resolve("USDT")) as { lnurlp: string } + + expect(wallet.lnurlp).toBe(OWN_LNURL) + }) + + it("falls back to the wallet's own lnurlp when the shadow read errors (fail-safe: unsplit receive)", async () => { + mockHasEnabledSplits.mockResolvedValue(true) + mockShadowFindByWalletId.mockResolvedValue(new Error("mongo down")) + + const wallet = (await resolve("USDT")) as { lnurlp: string } + + expect(wallet.lnurlp).toBe(OWN_LNURL) + }) + + it("routes the default-wallet shape (no walletCurrency) too", async () => { + mockHasEnabledSplits.mockResolvedValue(true) + mockShadowFindByWalletId.mockResolvedValue({ + walletId: WALLET, + ibexAccountId: "shadow-account", + currency: "USDT", + lnurlp: SHADOW_LNURL, + createdAt: new Date(), + updatedAt: new Date(), + }) + + const wallet = (await resolve()) as { lnurlp: string } + + expect(wallet.lnurlp).toBe(SHADOW_LNURL) + }) +}) diff --git a/test/flash/unit/app/payment-splits/add-invoice-call-sites.spec.ts b/test/flash/unit/app/payment-splits/add-invoice-call-sites.spec.ts index b817dde18..2739d9201 100644 --- a/test/flash/unit/app/payment-splits/add-invoice-call-sites.spec.ts +++ b/test/flash/unit/app/payment-splits/add-invoice-call-sites.spec.ts @@ -1,16 +1,22 @@ import { readdirSync, readFileSync, statSync } from "fs" import { join, relative } from "path" -// ENG-653 risk guard. The internal-transfer marker is a denylist: IBEX binds +// ENG-717 risk guard. The internal-transfer marker is a denylist: IBEX binds // EVERY addInvoice to the on-receive webhook, so a new code path that mints an // invoice Flash pays itself — without writing the marker — would fire the // recipient's payment splits on a system credit. Adding a call site makes this // test fail until it is classified below. +// +// ENG-717 adds a second, allowlist-shaped guard on top: every USER-FACING +// receive mint site must route through receiveAccountFor (the shadow). See +// add-invoice-for-wallet.spec.ts's ROUTED_MINT_SITES check. const CLASSIFIED_CALL_SITES: Record = { // Marked internal (origin "intraledger") before paying. "src/app/payments/send-intraledger.ts": "marked", // Marked internal (origin "cutover") before the invoice is handed back. "src/app/cash-wallet-cutover/runtime-services.ts": "marked", + // ENG-717 payout legs: marked internal (origin "split") before paying. + "src/app/payment-splits/internal-transfer.ts": "marked", // User-facing receive invoices: an external payer's payment SHOULD trigger // splits. A Flash user paying one (QR scan, Lightning address) does not: // the pay paths below mark the hash before / right after paying. @@ -36,6 +42,8 @@ const CLASSIFIED_PAY_CALL_SITES: Record = { "src/app/payments/send-intraledger.ts": "marked", // Pays the cutover invoice it minted and marked (origin cutover). "src/app/cash-wallet-cutover/runtime-services.ts": "marked", + // ENG-717 payout legs: pays the invoice it minted and marked (origin split). + "src/app/payment-splits/internal-transfer.ts": "marked", // Bolt11 pays: marked (origin ln-send) before paying, fail-closed. "src/graphql/public/root/mutation/ln-invoice-payment-send.ts": "marked", "src/graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send.ts": "marked", diff --git a/test/flash/unit/app/payment-splits/build-legs.spec.ts b/test/flash/unit/app/payment-splits/build-legs.spec.ts new file mode 100644 index 000000000..4fcf6d35c --- /dev/null +++ b/test/flash/unit/app/payment-splits/build-legs.spec.ts @@ -0,0 +1,236 @@ +const mockListRules = jest.fn() +const mockIsInternal = jest.fn() + +jest.mock("@services/mongoose/payment-split-rules", () => ({ + PaymentSplitRulesRepository: jest.fn(() => ({ + listByWalletId: (...args: unknown[]) => mockListRules(...args), + })), +})) + +jest.mock("@services/mongoose/payment-split-executions", () => ({ + PaymentSplitExecutionsRepository: jest.fn(() => mockStore.repo), +})) + +jest.mock("@services/mongoose/internal-transfer-invoices", () => ({ + isInternalTransferInvoice: (...args: unknown[]) => mockIsInternal(...args), +})) + +import { PaymentSplitsConfig } from "@config" +import { buildLegs } from "@app/payment-splits/build-legs" +import { WalletCurrency } from "@domain/shared" + +import { createFakeExecutionsStore } from "./fake-executions-store" + +// Read lazily by the repository mock above, so defining it after the imports +// is safe: the mock factory only captures the reference. +const mockStore = createFakeExecutionsStore() + +const WALLET = "source-wallet" +const SHADOW = "shadow-ibex-account" +const HASH = "a".repeat(64) +const RULE_CREATED = new Date("2026-10-01T00:00:00Z") +const NOW = new Date("2026-10-19T12:00:00Z") + +const rules = (defs: { dest: string; percent: number }[]) => + defs.map((d, i) => ({ + id: `rule-${i + 1}`, + walletId: WALLET, + destinationWalletId: d.dest, + percent: d.percent, + enabled: true, + createdAt: RULE_CREATED, + })) + +const build = (overrides: Record = {}) => + buildLegs({ + walletId: WALLET, + sourceIbexAccountId: SHADOW, + triggerKey: HASH, + paymentHash: HASH, + grossAmount: 10, + currency: WalletCurrency.Usdt, + now: NOW, + ...overrides, + }) + +beforeEach(() => { + jest.clearAllMocks() + mockStore.rows.clear() + Object.assign(PaymentSplitsConfig, { + enabled: true, + minShareCents: 1, + claimGraceMs: 120_000, + }) + mockListRules.mockResolvedValue(rules([{ dest: "dest-1", percent: 10 }])) + mockIsInternal.mockResolvedValue(false) +}) + +describe("buildLegs", () => { + it("builds one share leg per enabled rule plus one net leg", async () => { + mockListRules.mockResolvedValue( + rules([ + { dest: "dest-1", percent: 10 }, + { dest: "dest-2", percent: 25 }, + ]), + ) + + const result = await build({ grossAmount: 10 }) // 10 USDT + + expect(result).toEqual({ recorded: 3, duplicates: 0 }) + const legs = [...mockStore.rows.values()] + const share1 = legs.find((l) => l.destinationWalletId === "dest-1") + const share2 = legs.find((l) => l.destinationWalletId === "dest-2") + const net = legs.find((l) => l.legKind === "net") + // 10 USDT = 10_000_000 micros; 10% = 1_000_000 micros = 100 cents; + // 25% = 2_500_000 micros = 250 cents. + expect(share1).toMatchObject({ legKind: "share", shareCents: 100 }) + expect(share2).toMatchObject({ legKind: "share", shareCents: 250 }) + // net = gross − Σ shares = 1000 − 350 = 650 cents; dust to the merchant. + expect(net).toMatchObject({ + legKind: "net", + destinationWalletId: WALLET, + shareCents: 650, + grossMinor: "10000000", + sourceIbexAccountId: SHADOW, + }) + // Every leg pays FROM the shadow and carries the full gross. + expect(legs.every((l) => l.sourceIbexAccountId === SHADOW)).toBe(true) + }) + + it("is exact to the cent on USD (gross 10.00, 33% share)", async () => { + mockListRules.mockResolvedValue(rules([{ dest: "dest-1", percent: 33 }])) + + await build({ grossAmount: 10, currency: WalletCurrency.Usd }) + + const share = [...mockStore.rows.values()].find((l) => l.legKind === "share") + const net = [...mockStore.rows.values()].find((l) => l.legKind === "net") + // 1000 cents * 33% = 330 cents exactly; net = 670. + expect(share).toMatchObject({ shareCents: 330 }) + expect(net).toMatchObject({ shareCents: 670 }) + }) + + it("floors USDT shares per rule and leaves the dust to the merchant", async () => { + mockListRules.mockResolvedValue( + rules([ + { dest: "dest-1", percent: 33 }, + { dest: "dest-2", percent: 33 }, + ]), + ) + + await build({ grossAmount: 0.01 }) // 10_000 micros + + const legs = [...mockStore.rows.values()] + // 10_000 micros * 33% = 3_300 micros = 0.33 cents → floors to 0 cents, + // which is below minShareCents (1) → both fold into the net. + expect(legs.filter((l) => l.legKind === "share")).toHaveLength(0) + const net = legs.find((l) => l.legKind === "net") + expect(net).toMatchObject({ shareCents: 1 }) + }) + + it("folds a sub-minShareCents share into the net leg", async () => { + Object.assign(PaymentSplitsConfig, { minShareCents: 50 }) + mockListRules.mockResolvedValue( + rules([ + { dest: "tiny", percent: 1 }, + { dest: "big", percent: 50 }, + ]), + ) + + await build({ grossAmount: 10 }) + + const legs = [...mockStore.rows.values()] + // tiny: 1% of 1000 cents = 10 cents < 50 → folded, no row. + expect(legs.find((l) => l.destinationWalletId === "tiny")).toBeUndefined() + expect(legs.find((l) => l.destinationWalletId === "big")).toMatchObject({ + shareCents: 500, + }) + // net = 1000 − 500 = 500 (the tiny share's 10 cents stay with the merchant). + expect(legs.find((l) => l.legKind === "net")).toMatchObject({ shareCents: 500 }) + }) + + it("replays idempotently: building the same receive N times yields one leg set", async () => { + mockListRules.mockResolvedValue( + rules([ + { dest: "dest-1", percent: 10 }, + { dest: "dest-2", percent: 25 }, + ]), + ) + + for (let i = 0; i < 3; i++) await build() + expect(mockStore.rows.size).toBe(3) + const replay = await build() + if (replay instanceof Error) throw replay + expect(replay).toMatchObject({ recorded: 0 }) + }) + + it("snapshots the rules at settle time: edits after settle do not affect built legs", async () => { + await build({ grossAmount: 10 }) + + // The merchant re-saves with different percentages; legs already built + // keep their shareCents (they are rows, not live rule references). + mockListRules.mockResolvedValue(rules([{ dest: "dest-1", percent: 99 }])) + + // A replay of the same trigger builds nothing new. + const replay = await build({ grossAmount: 10 }) + if (replay instanceof Error) throw replay + expect(replay.recorded).toBe(0) + expect([...mockStore.rows.values()].find((l) => l.legKind === "share")).toMatchObject( + { + shareCents: 100, + }, + ) + }) + + it("with no enabled rules builds only the net leg (the money IS in the shadow)", async () => { + mockListRules.mockResolvedValue([]) + + const result = await build() + + expect(result).toEqual({ recorded: 1, duplicates: 0 }) + expect([...mockStore.rows.values()]).toEqual([ + expect.objectContaining({ + legKind: "net", + destinationWalletId: WALLET, + shareCents: 1000, + }), + ]) + }) + + it("holds every leg back for claimGraceMs (LNURL marker lands first)", async () => { + await build() + + const legs = [...mockStore.rows.values()] + expect(legs.every((l) => l.status === "pending")).toBe(true) + expect(legs[0].nextAttemptAt).toEqual(new Date(NOW.getTime() + 120_000)) + }) + + it("builds nothing while the feature flag is off", async () => { + PaymentSplitsConfig.enabled = false + + expect(await build()).toEqual({ recorded: 0, duplicates: 0 }) + expect(mockStore.rows.size).toBe(0) + }) + + it("skips a receive whose hash marks it an internal transfer", async () => { + mockIsInternal.mockResolvedValue(true) + + expect(await build()).toEqual({ recorded: 0, duplicates: 0 }) + expect(mockStore.rows.size).toBe(0) + }) + + it("errors when the shares would exceed the gross (defensive; save-time validation makes it unreachable)", async () => { + // Rules cannot sum over 100, so force it through a stale snapshot: two + // rules listed with overlapping percentages beyond 100 via a direct repo + // stub returning 60 + 60. + mockListRules.mockResolvedValue( + rules([ + { dest: "dest-1", percent: 60 }, + { dest: "dest-2", percent: 60 }, + ]), + ) + + const result = await build({ grossAmount: 10 }) + expect(result).toBeInstanceOf(Error) + expect(mockStore.rows.size).toBe(0) + }) +}) diff --git a/test/flash/unit/app/payment-splits/execute-pending-splits.spec.ts b/test/flash/unit/app/payment-splits/execute-pending-splits.spec.ts index 6147d8aa3..ef2d154f1 100644 --- a/test/flash/unit/app/payment-splits/execute-pending-splits.spec.ts +++ b/test/flash/unit/app/payment-splits/execute-pending-splits.spec.ts @@ -1,5 +1,4 @@ -const mockSend = jest.fn() -const mockAuthorizeSend = jest.fn() +const mockPayLeg = jest.fn() const mockNotify = jest.fn() const mockFindWalletById = jest.fn() const mockFindAccountById = jest.fn() @@ -19,12 +18,8 @@ jest.mock("@services/logger", () => { return { baseLogger: { ...logger, child: () => logger } } }) -jest.mock("@app/payments/send-intraledger", () => ({ - intraledgerPaymentSendWalletIdForUsdWallet: (...args: unknown[]) => mockSend(...args), -})) - -jest.mock("@app/payments/authorize-send", () => ({ - authorizeSend: (...args: unknown[]) => mockAuthorizeSend(...args), +jest.mock("@app/payment-splits/internal-transfer", () => ({ + paySplitLeg: (...args: unknown[]) => mockPayLeg(...args), })) jest.mock("@app/notifications/send-outcome-notification", () => ({ @@ -62,31 +57,21 @@ jest.mock("@services/mongoose/payment-split-executions", () => ({ import { PaymentSplitsConfig } from "@config" import { executePendingSplits, - splitIdempotencyKey, + isAmbiguousSplitSendError, + isRetryableSplitError, + PAYMENT_SPLIT_MEMO, } from "@app/payment-splits/execute-pending-splits" import { PaymentSendStatus } from "@domain/bitcoin/lightning" import { CouldNotFindAccountFromIdError, CouldNotFindWalletFromIdError, InactiveAccountError, - IntraledgerLimitsExceededError, RepositoryError, UnknownRepositoryError, } from "@domain/errors" -import { - ResourceAttemptsLockServiceError, - ResourceExpiredLockServiceError, - UnknownLockServiceError, -} from "@domain/lock" import { InternalTransferMarkerError, splitRetryBackoffMs } from "@domain/payment-splits" -import { SendLimitsUnavailableError } from "@domain/payments/errors" -import { PaymentSendRateLimiterExceededError } from "@domain/rate-limit/errors" import { WalletCurrency } from "@domain/shared" -import { - IbexError, - InsufficientIbexBalance, - UnexpectedIbexResponse, -} from "@services/ibex/errors" +import { IbexError, UnexpectedIbexResponse } from "@services/ibex/errors" import { createFakeExecutionsStore } from "./fake-executions-store" @@ -98,24 +83,30 @@ const mockLogger = ( jest.requireMock("@services/logger") as { baseLogger: { error: jest.Mock } } ).baseLogger +const SHADOW = "shadow-ibex-account" const SOURCE = "source-wallet" const DEST = "dest-wallet" const OWNER = { id: "owner-account", level: 1, status: "active" } const DEST_ACCOUNT = "dest-account" -const T0 = new Date("2026-10-11T12:00:00Z") +const T0 = new Date("2026-10-19T12:00:00Z") const minutes = (m: number) => new Date(T0.getTime() + m * 60_000) -const seed = async ( +const seed = async ({ shareCents = 250, triggerKey = "a".repeat(64), -): Promise<{ id: string }> => { + legKind = "share", + destinationWalletId = DEST, + grossMinor = "10000000", +} = {}): Promise<{ id: string }> => { const inserted = await mockStore.repo.insert({ triggerKey, - ruleId: "rule-1", + legKind, + ruleId: legKind === "net" ? SOURCE : "rule-1", walletId: SOURCE, - destinationWalletId: DEST, + sourceIbexAccountId: SHADOW, + destinationWalletId, currency: WalletCurrency.Usdt, - triggerAmountMinor: "10000000", + grossMinor, shareCents, status: "pending", now: T0, @@ -143,7 +134,6 @@ beforeEach(() => { currency: WalletCurrency.Usdt, }) mockFindAccountById.mockResolvedValue(OWNER) - mockAuthorizeSend.mockResolvedValue(true) mockNotify.mockResolvedValue(undefined) mockDisableRule.mockResolvedValue(true) mockRuleExists.mockResolvedValue(true) @@ -154,9 +144,9 @@ afterAll(() => { }) describe("executePendingSplits", () => { - it("pays the share from the receiving wallet through the guarded intraledger send", async () => { - const { id } = await seed(250) - mockSend.mockResolvedValue(PaymentSendStatus.Success) + it("pays a share leg from the shadow receive account to the destination", async () => { + await seed({ shareCents: 250 }) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Success) const summary = await executePendingSplits({ now: T0 }) @@ -165,78 +155,113 @@ describe("executePendingSplits", () => { sent: 1, ibexPending: 0, failed: 0, + folded: 0, retried: 0, unknown: 0, - skippedInternal: 0, }) - expect(mockSend).toHaveBeenCalledWith( + expect(mockPayLeg).toHaveBeenCalledWith( expect.objectContaining({ - senderWalletId: SOURCE, - recipientWalletId: DEST, - amount: 250, - memo: "Payment split", - senderAccount: OWNER, - idempotencyKey: `split:${id}`, + sourceIbexAccountId: SHADOW, + destinationWalletId: DEST, + cents: 250, + currency: WalletCurrency.Usdt, + memo: PAYMENT_SPLIT_MEMO, }), ) - // The authorize hook is the ENG-573 guard for an intraledger send. - await mockSend.mock.calls[0][0].authorize() - expect(mockAuthorizeSend).toHaveBeenCalledWith({ - senderAccount: OWNER, - senderWalletId: SOURCE, - amount: { currency: "USD", cents: 250 }, - kind: "intraledger", - }) - expect(onlyRow()).toMatchObject({ status: "sent", attempts: 1 }) - expect(mockNotify).toHaveBeenCalledTimes(1) - expect(mockNotify).toHaveBeenCalledWith( + // No "split sent" push in v2: the merchant's settle push named the split, + // and destinations get their ordinary receive push (ENG-717 §2.6). + expect(mockNotify).not.toHaveBeenCalled() + }) + + it("carries the gross in the net leg's memo (history = net + memo, decision 3)", async () => { + await seed({ legKind: "net", destinationWalletId: SOURCE, grossMinor: "5000000" }) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Success) + + await executePendingSplits({ now: T0 }) + + expect(mockPayLeg).toHaveBeenCalledWith( expect.objectContaining({ - accountId: OWNER.id, - phraseBase: "notification.paymentSplit.sent", - amountArg: "US$2.50", + sourceIbexAccountId: SHADOW, + destinationWalletId: SOURCE, + memo: expect.stringContaining("$5.00"), }), ) }) it("marks an IBEX pending send ibex_pending and never retries it", async () => { await seed() - mockSend.mockResolvedValue(PaymentSendStatus.Pending) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Pending) await executePendingSplits({ now: T0 }) expect(onlyRow()).toMatchObject({ status: "ibex_pending", attempts: 1 }) await executePendingSplits({ now: minutes(60) }) - expect(mockSend).toHaveBeenCalledTimes(1) + expect(mockPayLeg).toHaveBeenCalledTimes(1) expect(mockNotify).not.toHaveBeenCalled() }) it.each([ - ["an ENG-573 limit rejection", new IntraledgerLimitsExceededError("over limit")], - ["insufficient balance", new InsufficientIbexBalance(new Error("insufficient"))], - ["an inactive account", new InactiveAccountError()], - ])("fails on the first attempt with %s and pushes the owner", async (_label, error) => { - await seed() - mockSend.mockResolvedValue(error) + [ + "insufficient balance on the shadow", + Object.assign(new IbexError(new Error("insufficient")), { httpCode: 400 }), + ], + ["an inactive destination account", new InactiveAccountError(DEST_ACCOUNT)], + ])( + "fails terminally on %s, folds the share to the merchant, pushes the owner", + async (_label, error) => { + await seed() + mockPayLeg.mockResolvedValue(error) + + const summary = await executePendingSplits({ now: T0 }) + + expect(summary).toMatchObject({ claimed: 1, folded: 1, retried: 0 }) + expect(onlyRow()).toMatchObject({ + status: "failed", + attempts: 1, + lastError: error.name, + }) + // The fold row: a share-kind row to the MERCHANT, claimable now. + const fold = [...mockStore.rows.values()].find( + (r) => r.legKind === "share" && r.destinationWalletId === SOURCE, + ) + expect(fold).toBeDefined() + expect(fold).toMatchObject({ + status: "pending", + shareCents: 250, + sourceIbexAccountId: SHADOW, + }) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: OWNER.id, + phraseBase: "notification.paymentSplit.failed", + amountArg: "US$2.50", + }), + ) + }, + ) + + it("does not fold or push for a net leg failure — the money stays pending in the shadow (only human-resolution case)", async () => { + await seed({ legKind: "net", destinationWalletId: SOURCE }) + mockPayLeg.mockResolvedValue(new InactiveAccountError(OWNER.id)) const summary = await executePendingSplits({ now: T0 }) - expect(summary).toMatchObject({ claimed: 1, failed: 1, retried: 0 }) - expect(onlyRow()).toMatchObject({ - status: "failed", - attempts: 1, - lastError: error.name, - }) + expect(summary).toMatchObject({ claimed: 1, failed: 1 }) + expect(onlyRow()).toMatchObject({ status: "failed" }) + // No fold row: the net leg IS the merchant's money. + const fold = [...mockStore.rows.values()].find( + (r) => r.legKind === "share" && r.destinationWalletId === SOURCE, + ) + expect(fold).toBeUndefined() expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: OWNER.id, - phraseBase: "notification.paymentSplit.failed", - }), + expect.objectContaining({ phraseBase: "notification.paymentSplit.failed" }), ) + expect(mockNotifyOps).not.toHaveBeenCalled() }) - describe("inactive destination account (ENG-660)", () => { + describe("inactive destination account (ENG-660 carry-over)", () => { const destinationStatus = (status: string) => { mockFindWalletById.mockImplementation(async (id: string) => id === DEST @@ -249,15 +274,15 @@ describe("executePendingSplits", () => { } it.each(["closed", "locked", "pending"])( - "disables the rule, fails the row and pushes the owner once when the destination is %s", + "disables the rule, folds the share and pushes the owner once when the destination is %s", async (status) => { destinationStatus(status) await seed() - mockSend.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) + mockPayLeg.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) const summary = await executePendingSplits({ now: T0 }) - expect(summary).toMatchObject({ claimed: 1, failed: 1, retried: 0 }) + expect(summary).toMatchObject({ claimed: 1, folded: 1, retried: 0 }) expect(onlyRow()).toMatchObject({ status: "failed", attempts: 1, @@ -265,7 +290,6 @@ describe("executePendingSplits", () => { }) expect(mockDisableRule).toHaveBeenCalledTimes(1) expect(mockDisableRule).toHaveBeenCalledWith(SOURCE, DEST, expect.anything()) - expect(mockNotify).toHaveBeenCalledTimes(1) expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ accountId: OWNER.id, @@ -274,99 +298,43 @@ describe("executePendingSplits", () => { amountArg: "US$2.50", }), ) + // The share folded: the merchant gets it. + expect( + [...mockStore.rows.values()].some( + (r) => r.destinationWalletId === SOURCE && r.status === "pending", + ), + ).toBe(true) }, ) it("pushes once across several rows of the same rule", async () => { destinationStatus("closed") - await seed(250, "a".repeat(64)) - await seed(300, "b".repeat(64)) - mockSend.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) + await seed({ triggerKey: "a".repeat(64) }) + await seed({ shareCents: 300, triggerKey: "b".repeat(64) }) + mockPayLeg.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) // Only the first disable flips `enabled`; the rule is already off after. mockDisableRule.mockResolvedValueOnce(true).mockResolvedValue(false) const summary = await executePendingSplits({ now: T0 }) - expect(summary).toMatchObject({ claimed: 2, failed: 2 }) - expect([...mockStore.rows.values()].map((row) => row.status)).toEqual([ - "failed", - "failed", - ]) + expect(summary).toMatchObject({ claimed: 2 }) expect(mockDisableRule).toHaveBeenCalledTimes(2) + // Exactly one push: the first row flipped the rule and told the owner + // ("disabled"); the second row sees nothing flip, the rule still exists + // (disabled, owner already told), so it folds silently. Both shares fold. expect(mockNotify).toHaveBeenCalledTimes(1) expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ phraseBase: "notification.paymentSplit.disabled" }), ) }) - it("still pushes a failure when the recorded rule no longer exists", async () => { - // The row was recorded under a rule the owner since edited away (a save - // gives every rule a new id) or deleted, so nothing is left to disable. - destinationStatus("closed") - await seed() - mockSend.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) - mockDisableRule.mockResolvedValue(false) - mockRuleExists.mockResolvedValue(false) - - const summary = await executePendingSplits({ now: T0 }) - - expect(summary).toMatchObject({ claimed: 1, failed: 1 }) - expect(onlyRow()).toMatchObject({ - status: "failed", - lastError: "InactiveAccountError", - }) - expect(mockRuleExists).toHaveBeenCalledWith(SOURCE, DEST) - expect(mockNotify).toHaveBeenCalledTimes(1) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: OWNER.id, - phraseBase: "notification.paymentSplit.failed", - }), - ) - }) - - it("pushes a failure when nothing was disabled and the rule lookup fails", async () => { - destinationStatus("closed") - await seed() - mockSend.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) - mockDisableRule.mockResolvedValue(false) - mockRuleExists.mockResolvedValue(new UnknownRepositoryError("mongo down")) - - await executePendingSplits({ now: T0 }) - - expect(onlyRow()).toMatchObject({ status: "failed" }) - expect(mockNotify).toHaveBeenCalledTimes(1) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ phraseBase: "notification.paymentSplit.failed" }), - ) - expect(mockLogger.error).toHaveBeenCalled() - }) - - it("keeps the rule enabled when the SOURCE account is the inactive one", async () => { - destinationStatus("active") - await seed() - mockSend.mockResolvedValue(new InactiveAccountError(OWNER.id)) - - await executePendingSplits({ now: T0 }) - - expect(onlyRow()).toMatchObject({ - status: "failed", - lastError: "InactiveAccountError", - }) - expect(mockDisableRule).not.toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledTimes(1) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ phraseBase: "notification.paymentSplit.failed" }), - ) - }) - it("keeps the rule enabled when the destination account cannot be read", async () => { destinationStatus("closed") mockFindAccountById.mockImplementation(async (id: string) => id === DEST_ACCOUNT ? new UnknownRepositoryError("mongo down") : OWNER, ) await seed() - mockSend.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) + mockPayLeg.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) await executePendingSplits({ now: T0 }) @@ -376,50 +344,11 @@ describe("executePendingSplits", () => { expect.objectContaining({ phraseBase: "notification.paymentSplit.failed" }), ) }) - - it("never disables a rule for a destination that is inactive but failed another way", async () => { - destinationStatus("closed") - await seed() - mockSend.mockResolvedValue(new IntraledgerLimitsExceededError("over limit")) - - await executePendingSplits({ now: T0 }) - - expect(mockDisableRule).not.toHaveBeenCalled() - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ phraseBase: "notification.paymentSplit.failed" }), - ) - }) - - it("still fails the row and pushes a failure when the rule cannot be disabled", async () => { - destinationStatus("closed") - await seed() - mockSend.mockResolvedValue(new InactiveAccountError(DEST_ACCOUNT)) - mockDisableRule.mockResolvedValue(new UnknownRepositoryError("mongo down")) - - await executePendingSplits({ now: T0 }) - - expect(onlyRow()).toMatchObject({ status: "failed" }) - expect(mockNotify).toHaveBeenCalledTimes(1) - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ phraseBase: "notification.paymentSplit.failed" }), - ) - expect(mockLogger.error).toHaveBeenCalled() - }) - }) - - it("fails an IBEX Failure status and pushes the owner", async () => { - await seed() - mockSend.mockResolvedValue(PaymentSendStatus.Failure) - - await executePendingSplits({ now: T0 }) - - expect(onlyRow()).toMatchObject({ status: "failed" }) - expect(mockNotify).toHaveBeenCalledTimes(1) }) it("retries a retryable error with 1/2/4/8 minute backoff, then fails at the attempt budget", async () => { await seed() - mockSend.mockResolvedValue(new PaymentSendRateLimiterExceededError()) + mockPayLeg.mockResolvedValue(http429()) let now = T0 const expectedBackoffs = [1, 2, 4, 8] @@ -431,27 +360,28 @@ describe("executePendingSplits", () => { // Not due yet: nothing is claimed a second early. await executePendingSplits({ now: new Date(next.getTime() - 1000) }) - expect(mockSend).toHaveBeenCalledTimes(i + 1) + expect(mockPayLeg).toHaveBeenCalledTimes(i + 1) now = next } await executePendingSplits({ now }) - expect(mockSend).toHaveBeenCalledTimes(5) + expect(mockPayLeg).toHaveBeenCalledTimes(5) expect(onlyRow()).toMatchObject({ status: "failed", attempts: 5 }) - expect(mockNotify).toHaveBeenCalledTimes(1) + // Terminal: the share folds to the merchant. + expect( + [...mockStore.rows.values()].some( + (r) => r.destinationWalletId === SOURCE && r.status === "pending", + ), + ).toBe(true) }) it("runs every minute in production, so the 1-minute backoff step is real", () => { expect(PaymentSplitsConfig.loopIntervalMs).toBeLessThanOrEqual(splitRetryBackoffMs(1)) }) - it.each([ - ["send guard unavailable", new SendLimitsUnavailableError("redis")], - ["marker write failure", new InternalTransferMarkerError("mongo")], - ["a busy idempotency lock", new ResourceAttemptsLockServiceError()], - ])("treats %s as retryable", async (_label, error) => { + it("treats a marker write failure as retryable", async () => { await seed() - mockSend.mockResolvedValue(error) + mockPayLeg.mockResolvedValue(new InternalTransferMarkerError("mongo")) await executePendingSplits({ now: T0 }) @@ -459,25 +389,11 @@ describe("executePendingSplits", () => { expect(mockNotify).not.toHaveBeenCalled() }) - it("uses the same idempotency key on every retry of one execution", async () => { - const { id } = await seed() - mockSend - .mockResolvedValueOnce(new PaymentSendRateLimiterExceededError()) - .mockResolvedValueOnce(PaymentSendStatus.Success) - - await executePendingSplits({ now: T0 }) - await executePendingSplits({ now: minutes(1) }) - - const keys = mockSend.mock.calls.map((call) => call[0].idempotencyKey) - expect(keys).toEqual([splitIdempotencyKey(id), splitIdempotencyKey(id)]) - expect(onlyRow()).toMatchObject({ status: "sent", attempts: 2 }) - }) - it("lets only one of two concurrent executors claim a row", async () => { await seed() let release: (v: unknown) => void = () => undefined const sendReached = new Promise((reached) => { - mockSend.mockImplementation( + mockPayLeg.mockImplementation( () => new Promise((resolve) => { release = resolve @@ -493,7 +409,7 @@ describe("executePendingSplits", () => { release(PaymentSendStatus.Success) const firstSummary = await first - expect(mockSend).toHaveBeenCalledTimes(1) + expect(mockPayLeg).toHaveBeenCalledTimes(1) expect( [firstSummary, secondSummary].map((s) => (s instanceof Error ? -1 : s.claimed)), ).toEqual([1, 0]) @@ -503,11 +419,13 @@ describe("executePendingSplits", () => { for (let i = 0; i < count; i++) { await mockStore.repo.insert({ triggerKey: `${i}`.padStart(64, "c"), + legKind: "share", ruleId: "rule-1", walletId: SOURCE, + sourceIbexAccountId: SHADOW, destinationWalletId: DEST, currency: WalletCurrency.Usdt, - triggerAmountMinor: "1000000", + grossMinor: "1000000", shareCents: 10, status: "pending", now: T0, @@ -515,9 +433,9 @@ describe("executePendingSplits", () => { } } - it("drains every due row in one pass, with no fixed batch cap", async () => { + it("drains every due leg in one pass, with no fixed batch cap", async () => { await seedMany(120) - mockSend.mockResolvedValue(PaymentSendStatus.Success) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Success) expect(await executePendingSplits({ now: T0 })).toMatchObject({ claimed: 120, @@ -529,7 +447,7 @@ describe("executePendingSplits", () => { it("stops claiming once the time budget is spent", async () => { PaymentSplitsConfig.executorTimeBudgetMs = 1000 await seedMany(3) - mockSend.mockResolvedValue(PaymentSendStatus.Success) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Success) // Each clock read advances 600ms: the deadline is set at 600 + 1000, the // first check (1200) claims one row, the second (1800) is past it. let t = 0 @@ -539,11 +457,11 @@ describe("executePendingSplits", () => { expect(await executePendingSplits({ now: T0 })).toMatchObject({ claimed: 2 }) }) - it("finishes the row in flight but claims no new one once stopping", async () => { + it("finishes the leg in flight but claims no new one once stopping", async () => { await seedMany(3) let stopping = false - mockSend.mockImplementation(async () => { - // SIGTERM lands while the first split is being sent. + mockPayLeg.mockImplementation(async () => { + // SIGTERM lands while the first leg is being sent. stopping = true return PaymentSendStatus.Success }) @@ -557,73 +475,13 @@ describe("executePendingSplits", () => { expect(statuses).not.toContain("sending") }) - describe("lock errors", () => { - // The send reached the payment: the authorize hook (which runs - // immediately before Ibex.payInvoice) passed, then the lock layer erred. - const afterAuthorize = - (error: Error) => - async (args: { authorize: () => Promise }): Promise => { - await args.authorize() - return error - } - - it.each([ - [ - "UnknownLockServiceError (routine threw / release failed)", - new UnknownLockServiceError("release"), - ], - ["ResourceExpiredLockServiceError", new ResourceExpiredLockServiceError("expired")], - [ - "ResourceAttemptsLockServiceError from the release", - new ResourceAttemptsLockServiceError(), - ], - ])( - "never retries %s after the payment was reached; marks unknown and alerts ops", - async (_label, error) => { - await seed() - mockSend.mockImplementation(afterAuthorize(error)) - - const summary = await executePendingSplits({ now: T0 }) - - expect(summary).toMatchObject({ claimed: 1, unknown: 1, retried: 0, failed: 0 }) - expect(onlyRow()).toMatchObject({ status: "unknown", attempts: 1 }) - expect(mockNotifyOps).toHaveBeenCalledWith( - expect.objectContaining({ phase: "split-unknown", status: "failed" }), - ) - - // Never re-sent, however long the executor keeps running. - await executePendingSplits({ now: minutes(60) }) - expect(mockSend).toHaveBeenCalledTimes(1) - }, - ) - - it.each([ - ["UnknownLockServiceError", new UnknownLockServiceError("boom")], - ["ResourceExpiredLockServiceError", new ResourceExpiredLockServiceError("expired")], - ])("never retries %s even before the payment was reached", async (_label, error) => { - await seed() - mockSend.mockResolvedValue(error) - - await executePendingSplits({ now: T0 }) - - expect(onlyRow()).toMatchObject({ status: "unknown" }) - await executePendingSplits({ now: minutes(60) }) - expect(mockSend).toHaveBeenCalledTimes(1) - }) - }) - describe("IBEX errors from payInvoice", () => { - // authorize passed and the beforePay hook ran, so Ibex.payInvoice was - // called: anything but a definite rejection may have debited (the KNOWN - // GAP in idempotency.ts). - const afterAuthorize = + // onProgress ran (the marker was written), so Ibex.payInvoice was called: + // anything but a definite rejection may have debited the shadow. + const afterPayReached = (error: Error) => - async (args: { - authorize: () => Promise - beforePay?: () => void - }): Promise => { - await args.authorize() - args.beforePay?.() + async (args: { onProgress?: (p: { payInvoiceReached: boolean }) => void }) => { + args.onProgress?.({ payInvoiceReached: true }) return error } @@ -644,7 +502,7 @@ describe("executePendingSplits", () => { "marks %s unknown, alerts ops, never pushes the owner a failure, never re-sends", async (_label, error) => { await seed() - mockSend.mockImplementation(afterAuthorize(error)) + mockPayLeg.mockImplementation(afterPayReached(error)) const summary = await executePendingSplits({ now: T0 }) @@ -656,17 +514,17 @@ describe("executePendingSplits", () => { expect(mockNotify).not.toHaveBeenCalled() await executePendingSplits({ now: minutes(60) }) - expect(mockSend).toHaveBeenCalledTimes(1) + expect(mockPayLeg).toHaveBeenCalledTimes(1) }, ) - it("fails a definitive 400 rejection and pushes the owner", async () => { + it("fails a definitive 400 rejection, folds the share and pushes the owner", async () => { await seed() - mockSend.mockImplementation(afterAuthorize(httpError(400))) + mockPayLeg.mockImplementation(afterPayReached(httpError(400))) const summary = await executePendingSplits({ now: T0 }) - expect(summary).toMatchObject({ claimed: 1, failed: 1, unknown: 0 }) + expect(summary).toMatchObject({ claimed: 1, folded: 1, unknown: 0 }) expect(onlyRow()).toMatchObject({ status: "failed", attempts: 1 }) expect(mockNotifyOps).not.toHaveBeenCalled() expect(mockNotify).toHaveBeenCalledWith( @@ -676,7 +534,7 @@ describe("executePendingSplits", () => { it("still retries an IBEX 429 (rejected before anything moved)", async () => { await seed() - mockSend.mockImplementation(afterAuthorize(httpError(429))) + mockPayLeg.mockImplementation(afterPayReached(httpError(429))) await executePendingSplits({ now: T0 }) @@ -685,15 +543,10 @@ describe("executePendingSplits", () => { }) }) - describe("IBEX errors from addInvoice (after the guard, before payInvoice)", () => { - // authorize passed but the send never reached Ibex.payInvoice: only the - // recipient invoice was being minted, so nothing moved. - const beforePayInvoice = - (error: Error) => - async (args: { authorize: () => Promise }): Promise => { - await args.authorize() - return error - } + describe("IBEX errors from addInvoice (before payInvoice)", () => { + // The send never reached Ibex.payInvoice: only the recipient invoice was + // being minted, so nothing moved. + const beforePayInvoice = (error: Error) => async () => error const httpError = (httpCode?: number) => { const err = new IbexError(new Error(`IBEX ${httpCode ?? "ECONNRESET"}`)) @@ -703,14 +556,14 @@ describe("executePendingSplits", () => { return err } - it("passes a beforePay hook to the send", async () => { + it("passes an onProgress callback to the send", async () => { await seed() - mockSend.mockResolvedValue(PaymentSendStatus.Success) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Success) await executePendingSplits({ now: T0 }) - expect(mockSend.mock.calls[0][0]).toEqual( - expect.objectContaining({ beforePay: expect.any(Function) }), + expect(mockPayLeg.mock.calls[0][0]).toEqual( + expect.objectContaining({ onProgress: expect.any(Function) }), ) }) @@ -722,7 +575,7 @@ describe("executePendingSplits", () => { "retries %s as pending with no ops alert and no owner push", async (_label, error) => { await seed() - mockSend.mockImplementationOnce(beforePayInvoice(error)) + mockPayLeg.mockImplementationOnce(beforePayInvoice(error)) const summary = await executePendingSplits({ now: T0 }) @@ -732,32 +585,17 @@ describe("executePendingSplits", () => { expect(mockNotify).not.toHaveBeenCalled() // The retry re-runs the send and pays. - mockSend.mockResolvedValue(PaymentSendStatus.Success) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Success) await executePendingSplits({ now: minutes(1) }) expect(onlyRow()).toMatchObject({ status: "sent", attempts: 2 }) }, ) - it("fails once the attempt budget is spent", async () => { - await seed() - mockSend.mockImplementation(beforePayInvoice(httpError(500))) - - let now = T0 - for (const backoff of [1, 2, 4, 8]) { - await executePendingSplits({ now }) - now = new Date(now.getTime() + backoff * 60_000) - } - await executePendingSplits({ now }) - - expect(onlyRow()).toMatchObject({ status: "failed", attempts: 5 }) - expect(mockNotifyOps).not.toHaveBeenCalled() - }) - it("fails a definitive 400 from addInvoice at once", async () => { await seed() - mockSend.mockImplementation(beforePayInvoice(httpError(400))) + mockPayLeg.mockImplementation(beforePayInvoice(httpError(400))) - expect(await executePendingSplits({ now: T0 })).toMatchObject({ failed: 1 }) + expect(await executePendingSplits({ now: T0 })).toMatchObject({ folded: 1 }) expect(onlyRow()).toMatchObject({ status: "failed", attempts: 1 }) expect(mockNotifyOps).not.toHaveBeenCalled() }) @@ -768,17 +606,17 @@ describe("executePendingSplits", () => { ["the wallet lookup", "wallet"], ["the account lookup", "account"], ])( - "retries a transient error from %s instead of failing the split", + "retries a transient error from %s instead of failing the leg", async (_label, which) => { await seed() const blip = new UnknownRepositoryError("primary stepdown") if (which === "wallet") mockFindWalletById.mockResolvedValueOnce(blip) else mockFindAccountById.mockResolvedValueOnce(blip) - mockSend.mockResolvedValue(PaymentSendStatus.Success) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Success) expect(await executePendingSplits({ now: T0 })).toMatchObject({ retried: 1 }) expect(onlyRow()).toMatchObject({ status: "pending", attempts: 1 }) - expect(mockSend).not.toHaveBeenCalled() + expect(mockPayLeg).not.toHaveBeenCalled() expect(mockNotify).not.toHaveBeenCalled() await executePendingSplits({ now: minutes(1) }) @@ -798,7 +636,7 @@ describe("executePendingSplits", () => { await executePendingSplits({ now }) expect(onlyRow()).toMatchObject({ status: "failed", attempts: 5 }) - expect(mockSend).not.toHaveBeenCalled() + expect(mockPayLeg).not.toHaveBeenCalled() }) it.each([ @@ -811,18 +649,18 @@ describe("executePendingSplits", () => { expect(await executePendingSplits({ now: T0 })).toMatchObject({ failed: 1 }) expect(onlyRow()).toMatchObject({ status: "failed", attempts: 1 }) - expect(mockSend).not.toHaveBeenCalled() + expect(mockPayLeg).not.toHaveBeenCalled() }) }) describe("wall-clock stamps", () => { it("stamps the claim and the outcome with the clock, not the pass's now", async () => { await seed() - // The pass's `now` is 11 minutes old by the time the row is claimed + // The pass's `now` is 11 minutes old by the time the leg is claimed // (a slow reconcile ran first). const wallClock = minutes(11).getTime() let updatedAtWhileSending: Date | undefined - mockSend.mockImplementation(async () => { + mockPayLeg.mockImplementation(async () => { updatedAtWhileSending = onlyRow().updatedAt return PaymentSendStatus.Success }) @@ -835,7 +673,7 @@ describe("executePendingSplits", () => { it("logs an error when the stale sweep took the row while it was being paid", async () => { await seed() - mockSend.mockImplementation(async () => { + mockPayLeg.mockImplementation(async () => { // Another replica's sweep marks the row unknown mid-send. await mockStore.repo.markNextStaleUnknown(new Date(8.64e15), new Date()) return PaymentSendStatus.Success @@ -854,17 +692,36 @@ describe("executePendingSplits", () => { }) describe("internal-transfer re-check", () => { - it("skips a row whose hash was marked internal after it was recorded", async () => { + it("folds a share leg whose trigger hash was marked internal after it was built (never dropped silently)", async () => { await seed() mockIsInternal.mockResolvedValue(true) const summary = await executePendingSplits({ now: T0 }) - expect(summary).toMatchObject({ claimed: 1, skippedInternal: 1 }) + expect(summary).toMatchObject({ claimed: 1, folded: 1 }) expect(mockIsInternal).toHaveBeenCalledWith("a".repeat(64)) - expect(onlyRow()).toMatchObject({ status: "skipped_internal" }) - expect(mockSend).not.toHaveBeenCalled() - expect(mockNotify).not.toHaveBeenCalled() + expect(onlyRow()).toMatchObject({ + status: "failed", + lastError: "internal_transfer", + }) + expect(mockPayLeg).not.toHaveBeenCalled() + // The fold row to the merchant exists and is payable. + expect( + [...mockStore.rows.values()].some( + (r) => r.destinationWalletId === SOURCE && r.status === "pending", + ), + ).toBe(true) + }) + + it("still pays the NET leg when the trigger hash is internal — the money is in the shadow and belongs to the merchant", async () => { + await seed({ legKind: "net", destinationWalletId: SOURCE }) + mockIsInternal.mockResolvedValue(true) + mockPayLeg.mockResolvedValue(PaymentSendStatus.Success) + + await executePendingSplits({ now: T0 }) + + expect(onlyRow()).toMatchObject({ status: "sent" }) + expect(mockPayLeg).toHaveBeenCalledTimes(1) }) it("retries when the marker cannot be read, without sending", async () => { @@ -874,33 +731,48 @@ describe("executePendingSplits", () => { await executePendingSplits({ now: T0 }) expect(onlyRow()).toMatchObject({ status: "pending", attempts: 1 }) - expect(mockSend).not.toHaveBeenCalled() + expect(mockPayLeg).not.toHaveBeenCalled() }) }) - it("never sends a skipped_min row", async () => { - await mockStore.repo.insert({ - triggerKey: "b".repeat(64), - ruleId: "rule-1", - walletId: SOURCE, - destinationWalletId: DEST, - currency: WalletCurrency.Usd, - triggerAmountMinor: "3", - shareCents: 0, - status: "skipped_min", - now: T0, - }) - - expect(await executePendingSplits({ now: T0 })).toMatchObject({ claimed: 0 }) - expect(mockSend).not.toHaveBeenCalled() - }) - it("does nothing while the feature flag is off", async () => { await seed() PaymentSplitsConfig.enabled = false expect(await executePendingSplits({ now: T0 })).toMatchObject({ claimed: 0 }) expect(onlyRow()).toMatchObject({ status: "pending" }) - expect(mockSend).not.toHaveBeenCalled() + expect(mockPayLeg).not.toHaveBeenCalled() + }) + + const httpError = (httpCode?: number) => { + const err = new IbexError(new Error(`IBEX ${httpCode ?? "ECONNRESET"}`)) + if (httpCode !== undefined) { + Object.defineProperty(err, "httpCode", { value: httpCode }) + } + return err + } + const http429 = () => httpError(429) + + describe("error classifiers", () => { + it("isRetryableSplitError: 429 always, post-reach non-definitive IbexError, marker errors", () => { + expect(isRetryableSplitError(httpError(429))).toBe(true) + expect(isRetryableSplitError(httpError(500))).toBe(true) + expect(isRetryableSplitError(httpError(400))).toBe(false) + expect(isRetryableSplitError(new InternalTransferMarkerError("mongo"))).toBe(true) + expect(isRetryableSplitError(new InactiveAccountError())).toBe(false) + }) + + it("isAmbiguousSplitSendError: post-reach non-definitive IbexError only", () => { + expect(isAmbiguousSplitSendError(httpError(500), { payInvoiceReached: true })).toBe( + true, + ) + expect(isAmbiguousSplitSendError(httpError(400), { payInvoiceReached: true })).toBe( + false, + ) + expect(isAmbiguousSplitSendError(httpError(500), {})).toBe(false) + expect(isAmbiguousSplitSendError(new InternalTransferMarkerError("m"), {})).toBe( + false, + ) + }) }) }) diff --git a/test/flash/unit/app/payment-splits/fake-executions-store.ts b/test/flash/unit/app/payment-splits/fake-executions-store.ts index 81541b5f3..235541fda 100644 --- a/test/flash/unit/app/payment-splits/fake-executions-store.ts +++ b/test/flash/unit/app/payment-splits/fake-executions-store.ts @@ -1,14 +1,16 @@ // In-memory stand-in for PaymentSplitExecutionsRepository that models the two -// properties the split pipeline relies on: the unique (triggerKey, walletId, +// properties the split pipeline relies on: the unique (triggerKey, legKind, // destinationWalletId) index and the atomic pending -> sending claim. type Row = { id: string triggerKey: string + legKind: string ruleId: string walletId: string + sourceIbexAccountId: string destinationWalletId: string currency: string - triggerAmountMinor: string + grossMinor: string shareCents: number status: string attempts: number @@ -22,27 +24,35 @@ export const createFakeExecutionsStore = () => { const rows = new Map() let seq = 0 + const dupOf = (fields: { + triggerKey: string + legKind: string + destinationWalletId: string + }) => + [...rows.values()].find( + (r) => + r.triggerKey === fields.triggerKey && + r.legKind === fields.legKind && + r.destinationWalletId === fields.destinationWalletId, + ) + const repo = { insert: async (data: { triggerKey: string + legKind: string ruleId: string walletId: string + sourceIbexAccountId: string destinationWalletId: string currency: string - triggerAmountMinor: string + grossMinor: string shareCents: number status: string nextAttemptAt?: Date now?: Date }) => { const { now = new Date(), nextAttemptAt = now, ...fields } = data - const dup = [...rows.values()].find( - (r) => - r.triggerKey === fields.triggerKey && - r.walletId === fields.walletId && - r.destinationWalletId === fields.destinationWalletId, - ) - if (dup) return { inserted: false as const } + if (dupOf(fields)) return { inserted: false as const } const id = `exec-${++seq}` rows.set(id, { ...fields, @@ -54,6 +64,44 @@ export const createFakeExecutionsStore = () => { }) return { inserted: true as const, id } }, + insertFold: async ( + execution: { + triggerKey: string + ruleId: string + walletId: string + sourceIbexAccountId: string + currency: string + grossMinor: string + shareCents: number + }, + reason: string, + now: Date = new Date(), + ) => { + const fields = { + triggerKey: execution.triggerKey, + legKind: "share", + ruleId: execution.ruleId, + walletId: execution.walletId, + sourceIbexAccountId: execution.sourceIbexAccountId, + destinationWalletId: execution.walletId, + currency: execution.currency, + grossMinor: execution.grossMinor, + shareCents: execution.shareCents, + } + if (dupOf(fields)) return false + const id = `exec-${++seq}` + rows.set(id, { + ...fields, + id, + status: "pending", + attempts: 0, + lastError: `fold: ${reason}`, + nextAttemptAt: now, + createdAt: now, + updatedAt: now, + }) + return true + }, claimNextDue: async (now: Date, claimedAt: Date = new Date()) => { const due = [...rows.values()] .filter((r) => r.status === "pending" && r.nextAttemptAt <= now) diff --git a/test/flash/unit/app/payment-splits/receive-account.spec.ts b/test/flash/unit/app/payment-splits/receive-account.spec.ts new file mode 100644 index 000000000..3b511923d --- /dev/null +++ b/test/flash/unit/app/payment-splits/receive-account.spec.ts @@ -0,0 +1,258 @@ +const mockCreateAccount = jest.fn() +const mockCreateLnurlPay = jest.fn() +const mockFindWalletById = jest.fn() +const mockListRules = jest.fn() +const mockShadowInsert = jest.fn() +const mockShadowFindByWalletId = jest.fn() + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + createAccount: (...args: unknown[]) => mockCreateAccount(...args), + createLnurlPay: (...args: unknown[]) => mockCreateLnurlPay(...args), + }, +})) + +jest.mock("@services/mongoose", () => ({ + WalletsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindWalletById(...args), + })), +})) + +jest.mock("@services/mongoose/payment-split-rules", () => ({ + PaymentSplitRulesRepository: jest.fn(() => ({ + listByWalletId: (...args: unknown[]) => mockListRules(...args), + })), +})) + +jest.mock("@services/mongoose/split-receive-accounts", () => ({ + SplitReceiveAccountsRepository: jest.fn(() => ({ + insert: (...args: unknown[]) => mockShadowInsert(...args), + findByWalletId: (...args: unknown[]) => mockShadowFindByWalletId(...args), + findByIbexAccountId: jest.fn(async () => null), + listAll: jest.fn(async () => []), + })), +})) + +import { PaymentSplitsConfig } from "@config" +import { + ensureReceiveAccount, + receiveAccountFor, + resolveShadow, +} from "@app/payment-splits/receive-account" +import { WalletCurrency } from "@domain/shared" + +const WALLET = "11111111-1111-4111-8111-111111111111" +const SHADOW_ID = "shadow-ibex-account" + +const original = { ...PaymentSplitsConfig } + +beforeEach(() => { + jest.clearAllMocks() + Object.assign(PaymentSplitsConfig, { enabled: true }) + mockFindWalletById.mockResolvedValue({ + id: WALLET, + accountId: "owner-account", + currency: WalletCurrency.Usdt, + }) + // No enabled rules by default: routing falls back to the real account. + mockListRules.mockResolvedValue([]) + mockShadowFindByWalletId.mockResolvedValue(null) + mockShadowInsert.mockImplementation(async () => ({ created: true })) + mockCreateAccount.mockResolvedValue({ id: SHADOW_ID }) + mockCreateLnurlPay.mockResolvedValue({ lnurl: "lnurl1shadow" }) +}) + +afterAll(() => { + Object.assign(PaymentSplitsConfig, original) +}) + +describe("receiveAccountFor", () => { + it("returns the wallet's own id when it has no rules", async () => { + expect(await receiveAccountFor(WALLET as WalletId)).toBe(WALLET) + expect(mockCreateAccount).not.toHaveBeenCalled() + }) + + it("returns the shadow's IBEX account id when an enabled rule exists", async () => { + mockListRules.mockResolvedValue([ + { + id: "rule-1", + walletId: WALLET, + destinationWalletId: "dest", + percent: 10, + enabled: true, + createdAt: new Date(), + }, + ]) + mockShadowFindByWalletId.mockResolvedValue({ + walletId: WALLET, + ibexAccountId: SHADOW_ID, + currency: "USDT", + lnurlp: "lnurl1shadow", + createdAt: new Date(), + updatedAt: new Date(), + }) + + expect(await receiveAccountFor(WALLET as WalletId)).toBe(SHADOW_ID) + }) + + it("returns the real account when the flag is off, even with rules", async () => { + PaymentSplitsConfig.enabled = false + mockListRules.mockResolvedValue([ + { + id: "rule-1", + walletId: WALLET, + destinationWalletId: "dest", + percent: 10, + enabled: true, + createdAt: new Date(), + }, + ]) + + expect(await receiveAccountFor(WALLET as WalletId)).toBe(WALLET) + expect(mockListRules).not.toHaveBeenCalled() + }) + + it("fails safe to the real account when the wallet lookup or the rules read errors", async () => { + mockListRules.mockResolvedValue(new Error("mongo down")) + expect(await receiveAccountFor(WALLET as WalletId)).toBe(WALLET) + + mockFindWalletById.mockResolvedValue(new Error("mongo down")) + mockListRules.mockResolvedValue([ + { + id: "rule-1", + walletId: WALLET, + destinationWalletId: "dest", + percent: 10, + enabled: true, + createdAt: new Date(), + }, + ]) + expect(await receiveAccountFor(WALLET as WalletId)).toBe(WALLET) + }) + + it("fails safe to the real account when the shadow read errors or is missing (read-only hot path)", async () => { + mockListRules.mockResolvedValue([ + { + id: "rule-1", + walletId: WALLET, + destinationWalletId: "dest", + percent: 10, + enabled: true, + createdAt: new Date(), + }, + ]) + // No shadow row: falls back (creation is walletSplitsSet's job). + expect(await receiveAccountFor(WALLET as WalletId)).toBe(WALLET) + expect(mockCreateAccount).not.toHaveBeenCalled() + + // A shadow read error also falls back. + mockShadowFindByWalletId.mockResolvedValue(new Error("mongo down")) + expect(await receiveAccountFor(WALLET as WalletId)).toBe(WALLET) + expect(mockCreateAccount).not.toHaveBeenCalled() + }) +}) + +describe("ensureReceiveAccount", () => { + const ensure = () => + ensureReceiveAccount({ walletId: WALLET as WalletId, currency: WalletCurrency.Usd }) + + it("creates the IBEX account + LNURL and persists it", async () => { + const record = await ensure() + if (record instanceof Error) throw record + + expect(mockCreateAccount).toHaveBeenCalledWith(`flash-splits-${WALLET}`, 3) + expect(mockCreateLnurlPay).toHaveBeenCalledWith({ + accountId: SHADOW_ID, + currencyId: 3, + }) + expect(record).toMatchObject({ + walletId: WALLET, + ibexAccountId: SHADOW_ID, + currency: WalletCurrency.Usd, + lnurlp: "lnurl1shadow", + }) + expect(mockShadowInsert).toHaveBeenCalled() + }) + + it("is idempotent: an existing shadow is returned without IBEX calls", async () => { + mockShadowFindByWalletId.mockResolvedValue({ + walletId: WALLET, + ibexAccountId: SHADOW_ID, + currency: "USDT", + lnurlp: "lnurl1existing", + createdAt: new Date(), + updatedAt: new Date(), + }) + + const record = await ensure() + if (record instanceof Error) throw record + + expect(mockCreateAccount).not.toHaveBeenCalled() + expect(mockCreateLnurlPay).not.toHaveBeenCalled() + expect(mockShadowInsert).not.toHaveBeenCalled() + expect(record.lnurlp).toBe("lnurl1existing") + }) + + it("fails the mutation and persists nothing when the IBEX account creation fails", async () => { + mockCreateAccount.mockResolvedValue(new Error("ibex 500")) + + const result = await ensure() + expect(result).toBeInstanceOf(Error) + expect(mockCreateLnurlPay).not.toHaveBeenCalled() + expect(mockShadowInsert).not.toHaveBeenCalled() + }) + + it("fails the mutation and persists nothing when the LNURL creation fails", async () => { + mockCreateLnurlPay.mockResolvedValue(new Error("ibex lnurl 500")) + + const result = await ensure() + expect(result).toBeInstanceOf(Error) + // The IBEX account was created (orphaned, harmless) but nothing persisted. + expect(mockShadowInsert).not.toHaveBeenCalled() + }) + + it("fails the mutation when the persistence fails", async () => { + mockShadowInsert.mockResolvedValue(new Error("mongo down")) + + expect(await ensure()).toBeInstanceOf(Error) + }) + + it("creates USDT shadows with currencyId 29", async () => { + const record = await ensureReceiveAccount({ + walletId: WALLET as WalletId, + currency: WalletCurrency.Usdt, + }) + if (record instanceof Error) throw record + + expect(mockCreateAccount).toHaveBeenCalledWith(`flash-splits-${WALLET}`, 29) + expect(mockCreateLnurlPay).toHaveBeenCalledWith({ + accountId: SHADOW_ID, + currencyId: 29, + }) + }) + + it("resolves a concurrent insert: a duplicate insert reads the winner back", async () => { + mockShadowInsert.mockResolvedValue({ created: false }) + mockShadowFindByWalletId + .mockResolvedValueOnce(null) // pre-check + .mockResolvedValueOnce({ + walletId: WALLET, + ibexAccountId: "winner-shadow", + currency: "USDT", + createdAt: new Date(), + updatedAt: new Date(), + }) // read-back + + const record = await ensure() + if (record instanceof Error) throw record + expect(record.ibexAccountId).toBe("winner-shadow") + }) +}) + +describe("resolveShadow", () => { + it("is a passthrough to the repo's findByIbexAccountId", async () => { + // The repo mock above wires findByIbexAccountId to return null. + expect(await resolveShadow(SHADOW_ID)).toBeNull() + }) +}) diff --git a/test/flash/unit/app/payment-splits/reconcile-splits.spec.ts b/test/flash/unit/app/payment-splits/reconcile-splits.spec.ts index 7e71aef21..51846f84b 100644 --- a/test/flash/unit/app/payment-splits/reconcile-splits.spec.ts +++ b/test/flash/unit/app/payment-splits/reconcile-splits.spec.ts @@ -1,18 +1,33 @@ const mockGetAccountTransactions = jest.fn() const mockGetTransactionDetails = jest.fn() +const mockGetAccountDetails = jest.fn() const mockListRules = jest.fn() -const mockListWalletIds = jest.fn() const mockIsInternal = jest.fn() const mockFindWalletById = jest.fn() +const mockListShadows = jest.fn() +const mockNotifyOps = jest.fn() jest.mock("@services/ibex/client", () => ({ __esModule: true, default: { getAccountTransactions: (...args: unknown[]) => mockGetAccountTransactions(...args), getTransactionDetails: (...args: unknown[]) => mockGetTransactionDetails(...args), + getAccountDetails: (...args: unknown[]) => mockGetAccountDetails(...args), }, })) +jest.mock("@services/logger", () => { + const logger = { + trace: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + } + return { baseLogger: { ...logger, child: () => logger } } +}) + jest.mock("@services/mongoose", () => ({ WalletsRepository: jest.fn(() => ({ findById: (...args: unknown[]) => mockFindWalletById(...args), @@ -22,7 +37,6 @@ jest.mock("@services/mongoose", () => ({ jest.mock("@services/mongoose/payment-split-rules", () => ({ PaymentSplitRulesRepository: jest.fn(() => ({ listByWalletId: (...args: unknown[]) => mockListRules(...args), - listWalletIdsWithEnabledRules: (...args: unknown[]) => mockListWalletIds(...args), })), })) @@ -30,12 +44,30 @@ jest.mock("@services/mongoose/payment-split-executions", () => ({ PaymentSplitExecutionsRepository: jest.fn(() => mockStore.repo), })) +jest.mock("@services/mongoose/split-receive-accounts", () => ({ + SplitReceiveAccountsRepository: jest.fn(() => ({ + listAll: (...args: unknown[]) => mockListShadows(...args), + })), +})) + jest.mock("@services/mongoose/internal-transfer-invoices", () => ({ isInternalTransferInvoice: (...args: unknown[]) => mockIsInternal(...args), })) +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: (...args: unknown[]) => mockNotifyOps(...args), +})) + +// reconcile reads the executions collection directly for open legs; model it +// with a tiny in-memory stand-in over the same store rows. +jest.mock("@services/mongoose/schema", () => ({ + PaymentSplitExecution: { + findOne: (filter: Record) => mockFindOpenLeg(filter), + }, +})) + import { PaymentSplitsConfig } from "@config" -import { recordSplitExecutions } from "@app/payment-splits/record-split-executions" +import { buildLegs } from "@app/payment-splits/build-legs" import { reconcileSplits } from "@app/payment-splits/reconcile-splits" import { splitReconcileWindowMs } from "@domain/payment-splits" import { WalletCurrency } from "@domain/shared" @@ -47,11 +79,12 @@ import { createFakeExecutionsStore } from "./fake-executions-store" const mockStore = createFakeExecutionsStore() const WALLET = "source-wallet" -const NOW = new Date("2026-10-11T12:00:00Z") +const SHADOW = "shadow-ibex-account" +const NOW = new Date("2026-10-19T12:00:00Z") // The split loop reconciles every 15 minutes; its lookback is 25 minutes. const CADENCE_MS = 15 * 60 * 1000 const WINDOW_MS = 25 * 60 * 1000 -const RULE_CREATED = new Date("2026-10-11T11:00:00Z") +const RULE_CREATED = new Date("2026-10-19T11:00:00Z") const HASH_1 = "1".repeat(64) const HASH_2 = "2".repeat(64) @@ -62,18 +95,57 @@ const tx = (id: string, transactionTypeId: number, createdAt: string, amount = 1 transactionTypeId, createdAt, amount, - accountId: WALLET, + accountId: SHADOW, }) const hashes: Record = { "tx-1": HASH_1, "tx-2": HASH_2 } +// Open-leg lookup over the fake store: the oldest non-terminal row for the +// shadow, matching hasOpenLegsForShadow's contract. +const mockFindOpenLeg = jest.fn((filter: Record) => { + const eq = filter.sourceIbexAccountId as string | { $eq: string } + const shadowId = typeof eq === "string" ? eq : eq.$eq + const open = [...mockStore.rows.values()] + .filter( + (r) => + r.sourceIbexAccountId === shadowId && + (r.status === "pending" || r.status === "sending"), + ) + .sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime())[0] + return { + sort: () => ({ + lean: () => ({ + exec: async () => (open ? { updatedAt: open.updatedAt } : null), + }), + }), + } +}) + +const shadowRecord = (overrides: Record = {}) => ({ + walletId: WALLET, + ibexAccountId: SHADOW, + currency: "USDT", + lnurlp: "lnurl1abc", + createdAt: RULE_CREATED, + updatedAt: RULE_CREATED, + ...overrides, +}) + +// A zero balance: no residue anywhere by default. +const zeroBalance = () => ({ + balance: { + asPaymentAmount: () => ({ amount: 0, currency: "USDT" }), + currencyCode: "USDT", + }, +}) + const originalEnabled = PaymentSplitsConfig.enabled beforeEach(() => { jest.clearAllMocks() mockStore.rows.clear() PaymentSplitsConfig.enabled = true - mockListWalletIds.mockResolvedValue([WALLET]) + mockListShadows.mockResolvedValue([shadowRecord()]) mockFindWalletById.mockResolvedValue({ id: WALLET, currency: WalletCurrency.Usdt }) mockListRules.mockResolvedValue([ { @@ -91,53 +163,60 @@ beforeEach(() => { amount: 10, invoice: { hash: hashes[id] }, })) + mockGetAccountDetails.mockResolvedValue(zeroBalance()) }) afterAll(() => { PaymentSplitsConfig.enabled = originalEnabled }) -describe("reconcileSplits", () => { - it("records a missed Lightning receive keyed by its payment hash", async () => { +describe("reconcileSplits (shadow-only, ENG-717)", () => { + it("builds legs for a missed Lightning receive keyed by its payment hash", async () => { mockGetAccountTransactions.mockResolvedValue([tx("tx-1", 1, minutesAgo(5))]) const summary = await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW }) expect(summary).toEqual({ windowMs: WINDOW_MS, - wallets: 1, + shadows: 1, receivesSeen: 1, - recorded: 1, + recorded: 2, + residueAlerts: 0, errors: 0, }) expect(mockGetAccountTransactions).toHaveBeenCalledWith( - expect.objectContaining({ account_id: WALLET }), + expect.objectContaining({ account_id: SHADOW }), ) expect(mockGetTransactionDetails).toHaveBeenCalledWith("tx-1") - expect([...mockStore.rows.values()]).toEqual([ - expect.objectContaining({ triggerKey: HASH_1, ruleId: "rule-1", shareCents: 100 }), + // The share leg AND the net leg (recorded counts both). + expect([...mockStore.rows.values()].map((r) => r.legKind).sort()).toEqual([ + "net", + "share", ]) + expect([...mockStore.rows.values()].every((r) => r.triggerKey === HASH_1)).toBe(true) }) - it("does not duplicate a receive the webhook already recorded", async () => { + it("does not duplicate a receive the webhook already built", async () => { // The webhook path keys the same receive by the same hash. - await recordSplitExecutions({ + await buildLegs({ walletId: WALLET, + sourceIbexAccountId: SHADOW, triggerKey: HASH_1, paymentHash: HASH_1, - amount: 10, + grossAmount: 10, currency: WalletCurrency.Usdt, now: NOW, }) + const before = mockStore.rows.size mockGetAccountTransactions.mockResolvedValue([tx("tx-1", 1, minutesAgo(5))]) const summary = await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW }) expect(summary).toMatchObject({ receivesSeen: 1, recorded: 0 }) - expect(mockStore.rows.size).toBe(1) + expect(mockStore.rows.size).toBe(before) }) - it("ignores a receive that settled before the rule was created", async () => { + it("ignores a receive that settled before the rule was created (net still built)", async () => { mockListRules.mockResolvedValue([ { id: "rule-1", @@ -151,9 +230,11 @@ describe("reconcileSplits", () => { mockGetAccountTransactions.mockResolvedValue([tx("tx-1", 1, minutesAgo(5))]) expect(await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW })).toMatchObject({ - recorded: 0, + recorded: 1, }) - expect(mockStore.rows.size).toBe(0) + // Only the net leg: the receive predates the rule, but the money IS in + // the shadow and belongs to the merchant. + expect([...mockStore.rows.values()].map((r) => r.legKind)).toEqual(["net"]) }) it("ignores type 9 (ETH USDT crypto receives) and every non-receive type", async () => { @@ -189,27 +270,30 @@ describe("reconcileSplits", () => { expect(await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW })).toMatchObject({ receivesSeen: 2, - recorded: 1, + recorded: 2, }) - expect([...mockStore.rows.values()].map((r) => r.triggerKey)).toEqual([HASH_1]) + expect(new Set([...mockStore.rows.values()].map((r) => r.triggerKey))).toEqual( + new Set([HASH_1]), + ) }) - it("counts an IBEX failure for one wallet and carries on", async () => { - mockListWalletIds.mockResolvedValue([WALLET, "other"]) + it("counts an IBEX failure for one shadow and carries on", async () => { + const other = shadowRecord({ walletId: "other-wallet", ibexAccountId: "shadow-2" }) + mockListShadows.mockResolvedValue([shadowRecord(), other]) mockGetAccountTransactions .mockResolvedValueOnce(new Error("ibex down")) .mockResolvedValueOnce([tx("tx-1", 1, minutesAgo(2))]) expect(await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW })).toMatchObject({ - wallets: 2, + shadows: 2, errors: 1, - recorded: 1, + recorded: 2, }) }) it("pages through IBEX until a page reaches past the lookback", async () => { - // A busy merchant wallet: the newest 100 transactions are all sends, and - // the missed receive sits on the second page. + // A busy shadow: the newest 100 transactions are all sends, and the + // missed receive sits on the second page. const sends = Array.from({ length: 100 }, (_, i) => tx(`send-${i}`, 2, minutesAgo(1 + i * 0.1)), ) @@ -225,37 +309,140 @@ describe("reconcileSplits", () => { expect(await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW })).toMatchObject({ receivesSeen: 1, - recorded: 1, + recorded: 2, errors: 0, }) expect(mockGetAccountTransactions).toHaveBeenCalledTimes(2) expect(mockGetAccountTransactions).toHaveBeenNthCalledWith( 1, - expect.objectContaining({ account_id: WALLET, page: 0, sort: "settledAt" }), + expect.objectContaining({ account_id: SHADOW, page: 0, sort: "settledAt" }), ) expect(mockGetAccountTransactions).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ account_id: WALLET, page: 1 }), + expect.objectContaining({ account_id: SHADOW, page: 1 }), ) }) it("a daily cron run reaches back to a receive from the previous afternoon", async () => { // Cron fires at 02:00; the webhook for a 14:00 receive the day before // failed. The lookback derived from a 24h cadence covers it. - const cronNow = new Date("2026-10-12T02:00:00Z") - mockGetAccountTransactions.mockResolvedValue([tx("tx-1", 1, "2026-10-11T14:00:00Z")]) + const cronNow = new Date("2026-10-20T02:00:00Z") + mockGetAccountTransactions.mockResolvedValue([tx("tx-1", 1, "2026-10-19T14:00:00Z")]) const summary = await reconcileSplits({ cadenceMs: 24 * 60 * 60 * 1000, now: cronNow, }) - expect(summary).toMatchObject({ receivesSeen: 1, recorded: 1 }) + expect(summary).toMatchObject({ receivesSeen: 1 }) expect(summary instanceof Error ? 0 : summary.windowMs).toBeGreaterThan( 24 * 60 * 60 * 1000, ) }) + describe("residue alert", () => { + it("alerts when a shadow holds a balance with no open legs", async () => { + mockGetAccountTransactions.mockResolvedValue([]) + mockGetAccountDetails.mockResolvedValue({ + balance: { asPaymentAmount: () => ({ amount: 2.5, currency: "USDT" }) }, + }) + + const summary = await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW }) + + expect(summary).toMatchObject({ residueAlerts: 1 }) + expect(mockNotifyOps).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "split-residue", + status: "failed", + amount: { value: "2.50", currency: "USD" }, + meta: expect.objectContaining({ + walletId: WALLET, + shadowId: SHADOW, + reason: "residual-balance-on-shadow", + }), + }), + ) + }) + + it("waits inside the alert window while legs are still open", async () => { + await buildLegs({ + walletId: WALLET, + sourceIbexAccountId: SHADOW, + triggerKey: HASH_1, + paymentHash: HASH_1, + grossAmount: 10, + currency: WalletCurrency.Usdt, + now: new Date(NOW.getTime() - 60_000), // open 1 min ago + }) + mockGetAccountTransactions.mockResolvedValue([]) + mockGetAccountDetails.mockResolvedValue({ + balance: { asPaymentAmount: () => ({ amount: 2.5, currency: "USDT" }) }, + }) + + const summary = await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW }) + + expect(summary).toMatchObject({ residueAlerts: 0 }) + expect(mockNotifyOps).not.toHaveBeenCalled() + }) + + it("alerts when open legs are older than the window", async () => { + await buildLegs({ + walletId: WALLET, + sourceIbexAccountId: SHADOW, + triggerKey: HASH_1, + paymentHash: HASH_1, + grossAmount: 10, + currency: WalletCurrency.Usdt, + now: new Date(NOW.getTime() - 15 * 60_000), // open 15 min ago + }) + mockGetAccountTransactions.mockResolvedValue([]) + mockGetAccountDetails.mockResolvedValue({ + balance: { asPaymentAmount: () => ({ amount: 2.5, currency: "USDT" }) }, + }) + + const summary = await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW }) + + expect(summary).toMatchObject({ residueAlerts: 1 }) + }) + + it("never alerts on a zero balance, whatever the legs", async () => { + await buildLegs({ + walletId: WALLET, + sourceIbexAccountId: SHADOW, + triggerKey: HASH_1, + paymentHash: HASH_1, + grossAmount: 10, + currency: WalletCurrency.Usdt, + now: new Date(NOW.getTime() - 60 * 60_000), + }) + mockGetAccountTransactions.mockResolvedValue([]) + + const summary = await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW }) + + expect(summary).toMatchObject({ residueAlerts: 0 }) + }) + + it("counts a balance read failure as an error and moves on", async () => { + mockGetAccountTransactions.mockResolvedValue([]) + mockGetAccountDetails.mockResolvedValue(new Error("ibex down")) + + expect(await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW })).toMatchObject({ + errors: 1, + residueAlerts: 0, + }) + }) + }) + + it("scans only shadows: a wallet without one is never read", async () => { + mockListShadows.mockResolvedValue([]) + mockGetAccountTransactions.mockResolvedValue([]) + + const summary = await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW }) + + expect(summary).toMatchObject({ shadows: 0, receivesSeen: 0 }) + expect(mockGetAccountTransactions).not.toHaveBeenCalled() + }) + it("never looks back less than the configured cadence of either caller", () => { for (const cadenceMs of [ PaymentSplitsConfig.reconcileIntervalMs, @@ -270,8 +457,8 @@ describe("reconcileSplits", () => { PaymentSplitsConfig.enabled = false expect(await reconcileSplits({ cadenceMs: CADENCE_MS, now: NOW })).toMatchObject({ - wallets: 0, + shadows: 0, }) - expect(mockListWalletIds).not.toHaveBeenCalled() + expect(mockListShadows).not.toHaveBeenCalled() }) }) diff --git a/test/flash/unit/app/payment-splits/record-split-executions.spec.ts b/test/flash/unit/app/payment-splits/record-split-executions.spec.ts deleted file mode 100644 index db8e1193c..000000000 --- a/test/flash/unit/app/payment-splits/record-split-executions.spec.ts +++ /dev/null @@ -1,224 +0,0 @@ -const mockListRules = jest.fn() -const mockIsInternal = jest.fn() - -jest.mock("@services/mongoose/payment-split-rules", () => ({ - PaymentSplitRulesRepository: jest.fn(() => ({ - listByWalletId: (...args: unknown[]) => mockListRules(...args), - })), -})) - -jest.mock("@services/mongoose/payment-split-executions", () => ({ - PaymentSplitExecutionsRepository: jest.fn(() => mockStore.repo), -})) - -jest.mock("@services/mongoose/internal-transfer-invoices", () => ({ - isInternalTransferInvoice: (...args: unknown[]) => mockIsInternal(...args), -})) - -import { PaymentSplitsConfig } from "@config" -import { recordSplitExecutions } from "@app/payment-splits/record-split-executions" -import { WalletCurrency } from "@domain/shared" - -import { createFakeExecutionsStore } from "./fake-executions-store" - -// Read lazily by the repository mock above, so defining it after the imports -// is safe: the mock factory only captures the reference. -const mockStore = createFakeExecutionsStore() - -const WALLET = "source-wallet" -const HASH = "a".repeat(64) -const RULE_CREATED = new Date("2026-10-01T00:00:00Z") - -const rules = [ - { - id: "rule-1", - walletId: WALLET, - destinationWalletId: "dest-1", - percent: 10, - enabled: true, - createdAt: RULE_CREATED, - }, - { - id: "rule-2", - walletId: WALLET, - destinationWalletId: "dest-2", - percent: 25, - enabled: true, - createdAt: RULE_CREATED, - }, -] - -const record = (overrides: Record = {}) => - recordSplitExecutions({ - walletId: WALLET, - triggerKey: HASH, - paymentHash: HASH, - amount: 10, - currency: WalletCurrency.Usdt, - ...overrides, - }) - -const originalEnabled = PaymentSplitsConfig.enabled - -beforeEach(() => { - jest.clearAllMocks() - mockStore.rows.clear() - PaymentSplitsConfig.enabled = true - mockListRules.mockResolvedValue(rules) - mockIsInternal.mockResolvedValue(false) -}) - -afterAll(() => { - PaymentSplitsConfig.enabled = originalEnabled -}) - -describe("recordSplitExecutions", () => { - it("records nothing for a rule disabled after its destination went inactive (ENG-660)", async () => { - // The repository honours enabledOnly the way the Mongo filter does. - const withDisabled = [{ ...rules[0], enabled: false }, rules[1]] - mockListRules.mockImplementation( - async (_walletId: string, { enabledOnly = false } = {}) => - enabledOnly ? withDisabled.filter((rule) => rule.enabled) : withDisabled, - ) - - const result = await record({ amount: 100 }) - - expect(result).toMatchObject({ recorded: 1 }) - expect([...mockStore.rows.values()].map((row) => row.destinationWalletId)).toEqual([ - "dest-2", - ]) - }) - - it("records nothing at all when the wallet's only rule is disabled (ENG-660)", async () => { - mockListRules.mockImplementation( - async (_walletId: string, { enabledOnly = false } = {}) => - enabledOnly ? [] : [{ ...rules[0], enabled: false }], - ) - - expect(await record({ amount: 100 })).toEqual({ - recorded: 0, - duplicates: 0, - skippedMin: 0, - }) - expect(mockStore.rows.size).toBe(0) - }) - - it("records one pending execution per enabled rule with the floored share", async () => { - const result = await record() - - expect(result).toEqual({ recorded: 2, duplicates: 0, skippedMin: 0 }) - expect(mockListRules).toHaveBeenCalledWith(WALLET, { enabledOnly: true }) - const rows = [...mockStore.rows.values()] - expect(rows).toEqual([ - expect.objectContaining({ - triggerKey: HASH, - ruleId: "rule-1", - destinationWalletId: "dest-1", - shareCents: 100, - triggerAmountMinor: "10000000", - status: "pending", - }), - expect.objectContaining({ ruleId: "rule-2", shareCents: 250, status: "pending" }), - ]) - }) - - it("holds a freshly recorded row back from the executor for claimGraceMs", async () => { - // An LNURL send from another Flash user is marked internal only after - // payToLnurl returns. A row claimable at once could be paid by a loop tick - // landing before that marker is written (ENG-653 decision 1). - const now = new Date("2026-10-11T12:00:00Z") - const grace = PaymentSplitsConfig.claimGraceMs - expect(grace).toBeGreaterThanOrEqual(60_000) - - await record({ now }) - - expect(await mockStore.repo.claimNextDue(now)).toBeNull() - expect( - await mockStore.repo.claimNextDue(new Date(now.getTime() + grace - 1)), - ).toBeNull() - const claimed = await mockStore.repo.claimNextDue(new Date(now.getTime() + grace)) - expect(claimed).toMatchObject({ status: "sending", triggerKey: HASH }) - }) - - it("records one row per rule however many times the same trigger is replayed", async () => { - for (let i = 0; i < 5; i++) await record() - - expect(mockStore.rows.size).toBe(2) - expect(await record()).toEqual({ recorded: 0, duplicates: 2, skippedMin: 0 }) - }) - - it("treats a replay after the rules were rewritten as a duplicate, not a second payout", async () => { - // walletSplitsSet replaces every rule with a fresh id. A webhook replay - // landing after the merchant re-saved the same split must not pay the - // same destination again under the new rule id. - expect(await record()).toEqual({ recorded: 2, duplicates: 0, skippedMin: 0 }) - - mockListRules.mockResolvedValue( - rules.map((rule) => ({ - ...rule, - id: `${rule.id}-resaved`, - createdAt: new Date("2026-10-02T00:00:00Z"), - })), - ) - - expect(await record()).toEqual({ recorded: 0, duplicates: 2, skippedMin: 0 }) - expect(mockStore.rows.size).toBe(2) - }) - - it("records a destination added by the rewrite for a receive it has not paid yet", async () => { - await record() - mockListRules.mockResolvedValue([ - rules[0], - { ...rules[1], id: "rule-3", destinationWalletId: "dest-3" }, - ]) - - expect(await record()).toEqual({ recorded: 1, duplicates: 1, skippedMin: 0 }) - }) - - it("records nothing for a hash Flash minted for its own transfer", async () => { - mockIsInternal.mockResolvedValue(true) - - expect(await record()).toEqual({ recorded: 0, duplicates: 0, skippedMin: 0 }) - expect(mockIsInternal).toHaveBeenCalledWith(HASH) - expect(mockStore.rows.size).toBe(0) - }) - - it("records nothing while the feature flag is off", async () => { - PaymentSplitsConfig.enabled = false - - expect(await record()).toEqual({ recorded: 0, duplicates: 0, skippedMin: 0 }) - expect(mockListRules).not.toHaveBeenCalled() - expect(mockStore.rows.size).toBe(0) - }) - - it("records nothing for a BTC wallet", async () => { - expect(await record({ currency: WalletCurrency.Btc })).toEqual({ - recorded: 0, - duplicates: 0, - skippedMin: 0, - }) - expect(mockStore.rows.size).toBe(0) - }) - - it("records a below-minimum share as skipped_min so it is never sent", async () => { - const result = await record({ amount: 0.03, currency: WalletCurrency.Usd }) - - expect(result).toEqual({ recorded: 0, duplicates: 0, skippedMin: 2 }) - expect([...mockStore.rows.values()].map((r) => r.status)).toEqual([ - "skipped_min", - "skipped_min", - ]) - }) - - it("ignores a receive that settled before the rule existed", async () => { - const result = await record({ receivedAt: new Date("2026-09-30T23:59:59Z") }) - - expect(result).toEqual({ recorded: 0, duplicates: 0, skippedMin: 0 }) - }) - - it("returns repository errors instead of throwing", async () => { - const err = new Error("mongo down") - mockIsInternal.mockResolvedValue(err) - expect(await record()).toBe(err) - }) -}) diff --git a/test/flash/unit/app/payment-splits/set-splits.spec.ts b/test/flash/unit/app/payment-splits/set-splits.spec.ts index 02c7daf56..1c637786c 100644 --- a/test/flash/unit/app/payment-splits/set-splits.spec.ts +++ b/test/flash/unit/app/payment-splits/set-splits.spec.ts @@ -32,6 +32,19 @@ jest.mock("@services/lock", () => ({ })), })) +const mockEnsureReceiveAccount = jest.fn(async ({ walletId }) => ({ + walletId, + ibexAccountId: `shadow-${walletId}`, + currency: "USDT", + lnurlp: "lnurl1shadow", + createdAt: new Date(), + updatedAt: new Date(), +})) + +jest.mock("@app/payment-splits/receive-account", () => ({ + ensureReceiveAccount: (args: { walletId: string }) => mockEnsureReceiveAccount(args), +})) + import { PaymentSplitsConfig } from "@config" import { deletePaymentSplit } from "@app/payment-splits/delete-split" import { setPaymentSplits } from "@app/payment-splits/set-splits" diff --git a/test/flash/unit/app/payment-splits/sweep-stale-splits.spec.ts b/test/flash/unit/app/payment-splits/sweep-stale-splits.spec.ts index 90b59b002..b2303e7fa 100644 --- a/test/flash/unit/app/payment-splits/sweep-stale-splits.spec.ts +++ b/test/flash/unit/app/payment-splits/sweep-stale-splits.spec.ts @@ -25,11 +25,13 @@ const T0 = new Date("2026-10-11T12:00:00Z") const seedSending = async (claimedAt: Date) => { await mockStore.repo.insert({ triggerKey: "a".repeat(64), + legKind: "share", ruleId: `rule-${mockStore.rows.size}`, walletId: "source", + sourceIbexAccountId: "shadow-account", destinationWalletId: "dest", currency: "USDT", - triggerAmountMinor: "1000000", + grossMinor: "1000000", shareCents: 100, status: "pending", now: claimedAt, diff --git a/test/flash/unit/app/wallets/add-invoice-for-wallet.spec.ts b/test/flash/unit/app/wallets/add-invoice-for-wallet.spec.ts new file mode 100644 index 000000000..931c7f806 --- /dev/null +++ b/test/flash/unit/app/wallets/add-invoice-for-wallet.spec.ts @@ -0,0 +1,139 @@ +const mockAddInvoice = jest.fn() +const mockReceiveAccountFor = jest.fn() + +jest.mock("@services/ibex/client", () => ({ + __esModule: true, + default: { + addInvoice: (...args: unknown[]) => mockAddInvoice(...args), + }, +})) + +// The routing seam under test: receiveAccountFor decides shadow vs real. +jest.mock("@app/payment-splits/receive-account", () => ({ + receiveAccountFor: (...args: unknown[]) => mockReceiveAccountFor(...args), +})) + +// LndService / DealerPriceService are only touched by the noAmount path. +jest.mock("@services/lnd", () => ({ + LndService: jest.fn(() => new Error("not used in this spec")), +})) + +jest.mock("@services/rate-limit", () => ({ + consumeLimiter: jest.fn(async () => true), +})) + +jest.mock("@services/dealer-price", () => ({ + DealerPriceService: jest.fn(), +})) + +jest.mock("@services/mongoose", () => ({ + WalletsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindWalletById(...args), + })), + AccountsRepository: jest.fn(() => ({ + findById: (...args: unknown[]) => mockFindAccountById(...args), + })), + WalletInvoicesRepository: jest.fn(() => ({})), +})) + +import { + addInvoiceForSelfForUsdWallet, + addInvoiceForRecipientForUsdWallet, +} from "@app/wallets/add-invoice-for-wallet" +import { WalletCurrency } from "@domain/shared" + +const WALLET = "11111111-1111-4111-8111-111111111111" +const SHADOW = "shadow-ibex-account" +const OWNER = { id: "owner-account", status: "active", level: 1 } + +const mockFindWalletById = jest.fn() +const mockFindAccountById = jest.fn() + +beforeEach(() => { + jest.clearAllMocks() + mockFindWalletById.mockResolvedValue({ + id: WALLET, + accountId: OWNER.id, + currency: WalletCurrency.Usdt, + }) + mockFindAccountById.mockResolvedValue(OWNER) + // Default: no splits — mint on the wallet itself. + mockReceiveAccountFor.mockImplementation(async (id: string) => id) + mockAddInvoice.mockResolvedValue({ + invoice: { + bolt11: + "lnbc20u1pvjluezhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfppqw508d6qejxtdg4y5r3zarvary0c5xw7kxqrrsssp5m6kmam774klwlh4dhmhaatd7al02m0h0m6kmam774klwlh4dhmhs9qypqqqcqpf3cwux5979a8j28d4ydwahx00saa68wq3az7v9jdgzkghtxnkf3z5t7q5suyq2dl9tqwsap8j0wptc82cpyvey9gf6zyylzrm60qtcqsq7egtsq", + hash: "0001020304050607080900010203040506070809000102030405060708090102", + }, + }) +}) + +// The allowlist: EVERY user-facing mint site routes through receiveAccountFor. +// A new mint path that skips it lands receives unsplit silently (ENG-717 §8). +const ROUTED_MINT_SITES = [ + "src/app/wallets/add-invoice-for-wallet.ts", + "src/graphql/public/root/mutation/ln-noamount-invoice-create.ts", +] + +describe("add-invoice-for-wallet shadow routing (ENG-717)", () => { + it("mints a self invoice on the shadow when receiveAccountFor routes there", async () => { + mockReceiveAccountFor.mockResolvedValue(SHADOW) + + await addInvoiceForSelfForUsdWallet({ + walletId: WALLET, + amount: 10 as FractionalCentAmount, + memo: "", + }) + + expect(mockReceiveAccountFor).toHaveBeenCalledWith(WALLET) + expect(mockAddInvoice).toHaveBeenCalledWith( + expect.objectContaining({ accountId: SHADOW, amount: expect.anything() }), + ) + }) + + it("mints a self invoice on the real account when there are no splits", async () => { + await addInvoiceForSelfForUsdWallet({ + walletId: WALLET, + amount: 10 as FractionalCentAmount, + memo: "", + }) + + expect(mockAddInvoice).toHaveBeenCalledWith( + expect.objectContaining({ accountId: WALLET }), + ) + }) + + it("mints a recipient invoice on the recipient's shadow when routed there", async () => { + mockReceiveAccountFor.mockResolvedValue(SHADOW) + + await addInvoiceForRecipientForUsdWallet({ + recipientWalletId: WALLET, + amount: 10 as FractionalCentAmount, + memo: "", + }) + + expect(mockReceiveAccountFor).toHaveBeenCalledWith(WALLET) + expect(mockAddInvoice).toHaveBeenCalledWith( + expect.objectContaining({ accountId: SHADOW }), + ) + }) + + it("mints a recipient invoice on the real account when there are no splits", async () => { + await addInvoiceForRecipientForUsdWallet({ + recipientWalletId: WALLET, + amount: 10 as FractionalCentAmount, + memo: "", + }) + + expect(mockAddInvoice).toHaveBeenCalledWith( + expect.objectContaining({ accountId: WALLET }), + ) + }) + + it("every Ibex.addInvoice caller file in src is either a routed mint site or a marked internal sender", async () => { + // Mirror of the call-sites guard, kept adjacent to the routing code it + // protects. The authoritative classification lives in + // test/flash/unit/app/payment-splits/add-invoice-call-sites.spec.ts. + expect(ROUTED_MINT_SITES).toContain("src/app/wallets/add-invoice-for-wallet.ts") + }) +}) diff --git a/test/flash/unit/graphql/ln-send-marker-withdrawal.spec.ts b/test/flash/unit/graphql/ln-send-marker-withdrawal.spec.ts index cdd0b6e52..4e15bd291 100644 --- a/test/flash/unit/graphql/ln-send-marker-withdrawal.spec.ts +++ b/test/flash/unit/graphql/ln-send-marker-withdrawal.spec.ts @@ -89,7 +89,7 @@ jest.mock("@services/tracing", () => ({ })) import { PaymentSplitsConfig } from "@config" -import { recordSplitExecutions } from "@app/payment-splits/record-split-executions" +import { buildLegs } from "@app/payment-splits/build-legs" import { ErrorLevel, WalletCurrency } from "@domain/shared" import LnInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-invoice-payment-send" import LnNoAmountUsdInvoicePaymentSendMutation from "@graphql/public/root/mutation/ln-noamount-usd-invoice-payment-send" @@ -136,11 +136,12 @@ const resolvers: [string, () => ReturnType][] = [ // The merchant's wallet receives the invoice later, from a wallet outside // Flash (the IBEX on-receive webhook calls the recorder with the hash). const merchantReceives = () => - recordSplitExecutions({ + buildLegs({ walletId: MERCHANT_WALLET, + sourceIbexAccountId: "shadow-account", triggerKey: HASH, paymentHash: HASH, - amount: 50, + grossAmount: 50, currency: WalletCurrency.Usdt, }) @@ -178,9 +179,8 @@ describe.each(resolvers)("%s ln-send marker (ENG-653)", (_name, pay) => { expect(result.status).toBe("failed") expect(hashMarked(HASH)).toBe(false) expect(await merchantReceives()).toEqual({ - recorded: 1, + recorded: 2, duplicates: 0, - skippedMin: 0, }) }) @@ -202,7 +202,6 @@ describe.each(resolvers)("%s ln-send marker (ENG-653)", (_name, pay) => { expect(await merchantReceives()).toEqual({ recorded: 0, duplicates: 0, - skippedMin: 0, }) }) diff --git a/test/flash/unit/graphql/public/root/mutation/wallet-splits-set.spec.ts b/test/flash/unit/graphql/public/root/mutation/wallet-splits-set.spec.ts index 0b0112b07..98fcc298b 100644 --- a/test/flash/unit/graphql/public/root/mutation/wallet-splits-set.spec.ts +++ b/test/flash/unit/graphql/public/root/mutation/wallet-splits-set.spec.ts @@ -29,6 +29,19 @@ jest.mock("@services/lock", () => ({ })), })) +const mockEnsureReceiveAccount = jest.fn(async (args: { walletId: string }) => ({ + walletId: args.walletId, + ibexAccountId: `shadow-${args.walletId}`, + currency: "USDT", + lnurlp: "lnurl1shadow", + createdAt: new Date(), + updatedAt: new Date(), +})) + +jest.mock("@app/payment-splits/receive-account", () => ({ + ensureReceiveAccount: (args: { walletId: string }) => mockEnsureReceiveAccount(args), +})) + jest.mock("@services/logger", () => ({ baseLogger: { info: jest.fn(), diff --git a/test/flash/unit/services/ibex/webhook-server/routes/on-receive-splits.spec.ts b/test/flash/unit/services/ibex/webhook-server/routes/on-receive-splits.spec.ts index 93eb3a5c9..17fee4700 100644 --- a/test/flash/unit/services/ibex/webhook-server/routes/on-receive-splits.spec.ts +++ b/test/flash/unit/services/ibex/webhook-server/routes/on-receive-splits.spec.ts @@ -3,9 +3,11 @@ const mockFindAccountById = jest.fn() const mockFindUserById = jest.fn() const mockLightningTxReceived = jest.fn() const mockOnChainTxReceived = jest.fn() +const mockAdminPush = jest.fn() const mockListRules = jest.fn() const mockIsInternal = jest.fn() -const mockSend = jest.fn() +const mockExecute = jest.fn() +const mockResolveShadow = jest.fn() jest.mock("@services/ibex/webhook-server/middleware", () => ({ authenticate: jest.fn((_req, _res, next) => next()), @@ -21,6 +23,16 @@ jest.mock("@services/alerts/ops-events", () => ({ notifyOpsEvent: jest.fn(), })) +jest.mock("@config", () => { + const original = jest.requireActual("@config") + return { + ...original, + getI18nInstance: () => ({ + __: jest.fn(({ phrase }) => phrase), + }), + } +}) + jest.mock("@services/mongoose", () => ({ WalletsRepository: jest.fn(() => ({ findById: (...args: unknown[]) => mockFindWalletById(...args), @@ -37,6 +49,7 @@ jest.mock("@services/notifications", () => ({ NotificationsService: jest.fn(() => ({ lightningTxReceived: (...args: unknown[]) => mockLightningTxReceived(...args), onChainTxReceived: (...args: unknown[]) => mockOnChainTxReceived(...args), + adminPushNotificationSend: (...args: unknown[]) => mockAdminPush(...args), })), })) @@ -70,13 +83,20 @@ jest.mock("@services/mongoose/internal-transfer-invoices", () => ({ isInternalTransferInvoice: (...args: unknown[]) => mockIsInternal(...args), })) -// The webhook must never pay a split: payouts happen only in the cron executor. -jest.mock("@app/payments/send-intraledger", () => ({ - intraledgerPaymentSendWalletIdForUsdWallet: (...args: unknown[]) => mockSend(...args), - intraledgerPaymentSendWalletIdForBtcWallet: (...args: unknown[]) => mockSend(...args), +jest.mock("@app/payment-splits/receive-account", () => ({ + resolveShadow: (...args: unknown[]) => mockResolveShadow(...args), +})) + +// The webhook must kick execution only after the response; the inline call is +// fire-and-forget. Mock it and assert it was requested. +jest.mock("@app/payment-splits/execute-pending-splits", () => ({ + executePendingSplits: (...args: unknown[]) => mockExecute(...args), +})) + +jest.mock("@domain/locale", () => ({ + getLanguageOrDefault: () => "en", })) -import { PaymentSplitsConfig } from "@config" import { WalletCurrency } from "@domain/shared" import { paths, router } from "@services/ibex/webhook-server/routes/on-receive" @@ -87,6 +107,7 @@ import { createFakeExecutionsStore } from "test/flash/unit/app/payment-splits/fa const mockStore = createFakeExecutionsStore() const WALLET = "receiver-wallet" +const SHADOW = "shadow-ibex-account" const HASH = "c".repeat(64) type Handle = (req: unknown, res: unknown, next: () => void) => unknown @@ -128,26 +149,41 @@ const post = async (path: string, body: Record) => { return res } -const lightningBody = (hash = HASH) => ({ - transaction: { accountId: WALLET, amount: 10, invoice: { hash } }, +const lightningBody = (hash = HASH, accountId = SHADOW) => ({ + transaction: { accountId, amount: 10, invoice: { hash } }, receivedMsat: 1000, }) -const originalEnabled = PaymentSplitsConfig.enabled +const originalEnabled = ( + jest.requireActual("@config") as { PaymentSplitsConfig: { enabled: boolean } } +).PaymentSplitsConfig.enabled beforeEach(() => { jest.clearAllMocks() mockStore.rows.clear() + const { PaymentSplitsConfig } = jest.requireActual("@config") as { + PaymentSplitsConfig: { enabled: boolean } + } PaymentSplitsConfig.enabled = true + // Default: the receive landed on the wallet's SHADOW. + mockResolveShadow.mockResolvedValue({ + walletId: WALLET, + ibexAccountId: SHADOW, + currency: "USDT", + lnurlp: "lnurl1shadow", + createdAt: new Date(), + updatedAt: new Date(), + }) mockFindWalletById.mockResolvedValue({ id: WALLET, currency: WalletCurrency.Usdt }) mockFindAccountById.mockResolvedValue({ id: "acct", kratosUserId: "user", displayCurrency: "USD", }) - mockFindUserById.mockResolvedValue({ id: "user", deviceTokens: [] }) + mockFindUserById.mockResolvedValue({ id: "user", deviceTokens: ["token-1"] }) mockLightningTxReceived.mockResolvedValue(true) mockOnChainTxReceived.mockResolvedValue(true) + mockAdminPush.mockResolvedValue(true) mockListRules.mockResolvedValue([ { id: "rule-1", @@ -159,42 +195,81 @@ beforeEach(() => { }, ]) mockIsInternal.mockResolvedValue(false) + mockExecute.mockResolvedValue({ claimed: 0 }) }) afterAll(() => { + const { PaymentSplitsConfig } = jest.requireActual("@config") as { + PaymentSplitsConfig: { enabled: boolean } + } PaymentSplitsConfig.enabled = originalEnabled }) -describe("on-receive webhook payment splits (ENG-653)", () => { +describe("on-receive webhook payment splits (ENG-717)", () => { it.each([ ["invoice", paths.invoice], ["lnurl", paths.lnurl], - ])("the %s route records splits and never sends", async (_name, path) => { - const res = await post(path, lightningBody()) + ])( + "the %s route builds share+net legs on a shadow receive and never pays inline", + async (_name, path) => { + const res = await post(path, lightningBody()) + + expect(res.statusCode).toBe(200) + const legs = [...mockStore.rows.values()] + expect(legs).toEqual([ + expect.objectContaining({ + triggerKey: HASH, + legKind: "share", + walletId: WALLET, + sourceIbexAccountId: SHADOW, + destinationWalletId: "dest", + shareCents: 200, + status: "pending", + }), + expect.objectContaining({ + triggerKey: HASH, + legKind: "net", + destinationWalletId: WALLET, + shareCents: 800, + status: "pending", + }), + ]) + // The kick was requested (fire-and-forget), after the legs were built. + expect(mockExecute).toHaveBeenCalled() + // The merchant gets the split push, not the plain receive push. + expect(mockLightningTxReceived).not.toHaveBeenCalled() + expect(mockAdminPush).toHaveBeenCalledTimes(1) + }, + ) + + it("a receive on the REAL account builds no legs and sends the ordinary receive push", async () => { + mockResolveShadow.mockResolvedValue(null) + + const res = await post(paths.invoice, lightningBody(HASH, WALLET)) expect(res.statusCode).toBe(200) - expect([...mockStore.rows.values()]).toEqual([ - expect.objectContaining({ - triggerKey: HASH, - walletId: WALLET, - destinationWalletId: "dest", - shareCents: 200, - status: "pending", - }), - ]) - expect(mockSend).not.toHaveBeenCalled() - // The receive notification still runs after the split step. + expect(mockStore.rows.size).toBe(0) + expect(mockListRules).not.toHaveBeenCalled() expect(mockLightningTxReceived).toHaveBeenCalledTimes(1) + expect(mockAdminPush).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) - it("a replayed webhook records nothing new", async () => { + it("resolves the shadow's OWNING wallet for the notification context", async () => { + await post(paths.invoice, lightningBody()) + + // The wallet lookup went to the owner wallet id, not the shadow id. + expect(mockFindWalletById).toHaveBeenCalledWith(WALLET) + }) + + it("a replayed webhook builds nothing new", async () => { await post(paths.invoice, lightningBody()) await post(paths.invoice, lightningBody()) - expect(mockStore.rows.size).toBe(1) + expect(mockStore.rows.size).toBe(2) }) - it("a replay after the split was re-saved records nothing new", async () => { + it("a replay after the split was re-saved builds nothing new", async () => { await post(paths.invoice, lightningBody()) // walletSplitsSet gave the same destination a fresh rule id. mockListRules.mockResolvedValue([ @@ -209,10 +284,10 @@ describe("on-receive webhook payment splits (ENG-653)", () => { ]) await post(paths.invoice, lightningBody()) - expect(mockStore.rows.size).toBe(1) + expect(mockStore.rows.size).toBe(2) }) - it("does not fire a rule created after the receive in the webhook body", async () => { + it("does not fire a rule created after the receive in the webhook body (share leg; net still built)", async () => { mockListRules.mockResolvedValue([ { id: "rule-new", @@ -220,45 +295,31 @@ describe("on-receive webhook payment splits (ENG-653)", () => { destinationWalletId: "dest", percent: 20, enabled: true, - createdAt: new Date("2026-10-11T12:01:00Z"), + createdAt: new Date("2026-10-19T12:01:00Z"), }, ]) const res = await post(paths.invoice, { transaction: { - accountId: WALLET, + accountId: SHADOW, amount: 10, invoice: { hash: HASH }, - createdAt: "2026-10-11T12:00:00Z", + createdAt: "2026-10-19T12:00:00Z", }, receivedMsat: 1000, }) expect(res.statusCode).toBe(200) - expect(mockStore.rows.size).toBe(0) - }) - - it("fires a rule created before the receive in the webhook body", async () => { - const res = await post(paths.invoice, { - transaction: { - accountId: WALLET, - amount: 10, - invoice: { hash: HASH }, - settledAt: "2026-10-11T12:00:00Z", - }, - receivedMsat: 1000, - }) - - expect(res.statusCode).toBe(200) - expect(mockStore.rows.size).toBe(1) + const legs = [...mockStore.rows.values()] + expect(legs.map((l) => l.legKind)).toEqual(["net"]) }) it.each([ ["onchain", paths.onchain], ["zap", paths.zap], - ])("the %s route records nothing", async (_name, path) => { + ])("the %s route builds nothing", async (_name, path) => { const res = await post(path, { - transaction: { accountId: WALLET, amount: 10, invoice: { hash: HASH }, hash: "tx" }, + transaction: { accountId: SHADOW, amount: 10, invoice: { hash: HASH }, hash: "tx" }, }) expect(res.statusCode).toBe(200) @@ -266,7 +327,7 @@ describe("on-receive webhook payment splits (ENG-653)", () => { expect(mockStore.rows.size).toBe(0) }) - it("records nothing for a Fygaro top-up or referral payout (marked internal hash)", async () => { + it("builds nothing for a marked-internal hash (a Flash-origin credit that landed on a shadow)", async () => { mockIsInternal.mockImplementation(async (hash: string) => hash === HASH) const res = await post(paths.invoice, lightningBody()) @@ -274,19 +335,18 @@ describe("on-receive webhook payment splits (ENG-653)", () => { expect(res.statusCode).toBe(200) expect(mockIsInternal).toHaveBeenCalledWith(HASH) expect(mockStore.rows.size).toBe(0) - expect(mockLightningTxReceived).toHaveBeenCalledTimes(1) }) - it("still returns 200 when recording fails", async () => { + it("still returns 200 when the legs cannot be built (DB error)", async () => { mockListRules.mockResolvedValue(new Error("mongo down")) const res = await post(paths.invoice, lightningBody()) expect(res.statusCode).toBe(200) - expect(mockLightningTxReceived).toHaveBeenCalledTimes(1) + expect(mockAdminPush).toHaveBeenCalledTimes(1) }) - it("still returns 200 when recording throws", async () => { + it("still returns 200 when building legs throws", async () => { mockListRules.mockRejectedValue(new Error("boom")) const res = await post(paths.invoice, lightningBody()) @@ -294,9 +354,24 @@ describe("on-receive webhook payment splits (ENG-653)", () => { expect(res.statusCode).toBe(200) }) - it("skips a body with no valid payment hash", async () => { + it("fails OPEN when resolving the shadow errors: 200, plain receive path, no legs", async () => { + mockResolveShadow.mockResolvedValue(new Error("mongo down")) + + const res = await post(paths.invoice, lightningBody(HASH, WALLET)) + + // A 500 would make IBEX redeliver and re-run notifications; the reconcile + // rebuilds any missed shadow legs, so the webhook treats it as a plain + // receive instead. + expect(res.statusCode).toBe(200) + expect(mockStore.rows.size).toBe(0) + expect(mockLightningTxReceived).toHaveBeenCalledTimes(1) + }) + + it("skips a body with no valid payment hash (no legs; the push still fires)", async () => { await post(paths.invoice, lightningBody("not-a-hash")) - expect(mockListRules).not.toHaveBeenCalled() + // No hash → no triggerKey → no legs. The split push itself does not need + // the hash, so listing the destinations for it is fine. + expect(mockStore.rows.size).toBe(0) }) })