Skip to content
83 changes: 81 additions & 2 deletions src/app/payment-splits/execute-pending-splits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { authorizeSend } from "@app/payments/authorize-send"
import { isDefinitivePaymentRejection } from "@app/payments/mark-outgoing-invoice"
import { intraledgerPaymentSendWalletIdForUsdWallet } from "@app/payments/send-intraledger"

import { AccountValidator } from "@domain/accounts"
import { PaymentSendStatus } from "@domain/bitcoin/lightning"
import { CouldNotFindError } from "@domain/errors"
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"
Expand All @@ -21,6 +22,7 @@ import {
PaymentSplitExecutionsRepository,
type PaymentSplitExecutionRecord,
} from "@services/mongoose/payment-split-executions"
import { PaymentSplitRulesRepository } from "@services/mongoose/payment-split-rules"

const logger = baseLogger.child({ module: "payment-splits" })

Expand Down Expand Up @@ -111,7 +113,7 @@ const formatCents = (cents: number): string => `US$${(cents / 100).toFixed(2)}`

const notifyOwner = async (
accountId: string,
outcome: "sent" | "failed",
outcome: "sent" | "failed" | "disabled",
execution: PaymentSplitExecutionRecord,
): Promise<void> =>
sendOutcomeNotificationBestEffort({
Expand All @@ -128,6 +130,21 @@ const notifyOwner = async (
logContext: { executionId: execution.id, walletId: execution.walletId },
})

/**
* 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.
*/
const isDestinationInactive = async (destinationWalletId: string): Promise<boolean> => {
const wallet = await WalletsRepository().findById(destinationWalletId as WalletId)
if (wallet instanceof Error) return false
const account = await AccountsRepository().findById(wallet.accountId)
if (account instanceof Error) return false
return AccountValidator(account).isActive() instanceof Error
}

type ExecuteOutcome =
| "sent"
| "ibex_pending"
Expand Down Expand Up @@ -179,6 +196,62 @@ const executeOne = async (
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) => {
await finish({ status: "failed", attempts, lastError: error.name })
const rules = PaymentSplitRulesRepository()
const logContext = {
executionId: execution.id,
ruleId: execution.ruleId,
walletId: execution.walletId,
destinationWalletId: execution.destinationWalletId,
}
const disabled = await rules.disableForDestination(
execution.walletId,
execution.destinationWalletId,
{ now: stamp() },
)
if (disabled instanceof Error) {
logger.error(
{ ...logContext, error: disabled },
"could not disable payment split rule with an inactive destination",
)
await notifyOwner(accountId, "failed", execution)
return "failed" as const
}
if (disabled) {
logger.warn(
logContext,
"payment split rule disabled: destination account is not active",
)
await notifyOwner(accountId, "disabled", execution)
return "failed" as const
}
// 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
// deleted), in which case nobody has told the owner about this payout.
const exists = await rules.existsForDestination(
execution.walletId,
execution.destinationWalletId,
)
if (exists instanceof Error) {
logger.error(
{ ...logContext, error: exists },
"could not check payment split rule for an inactive destination",
)
}
if (exists !== true) await notifyOwner(accountId, "failed", execution)
return "failed" as const
}

const retry = async (error: Error) => {
await finish({
status: "pending",
Expand Down Expand Up @@ -288,6 +361,12 @@ const executeOne = async (
{ executionId: execution.id, walletId: execution.walletId, error: result },
"payment split failed",
)
if (
result instanceof InactiveAccountError &&
(await isDestinationInactive(execution.destinationWalletId))
) {
return failAndDisableRule(result, account.id)
}
return fail(result, account.id)
}

Expand Down
29 changes: 28 additions & 1 deletion src/app/payment-splits/set-splits.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { PaymentSplitsConfig } from "@config"

import { checkedToUsername } from "@domain/accounts"
import { AccountValidator, checkedToUsername } from "@domain/accounts"
import { InvalidWalletForAccountError } from "@domain/accounts/errors"
import {
PaymentSplitsDisabledError,
SplitDestinationInactiveError,
SplitDestinationNotFoundError,
validateSplitSet,
type SplitSetEntry,
Expand Down Expand Up @@ -40,6 +41,24 @@ export const resolveOwnedWallet = async ({
return wallet
}

/**
* ENG-660: a destination whose account is not active (closed, locked, pending)
* would fail every payout with InactiveAccountError, so it is refused at save
* time. Uses the same AccountValidator check the intraledger send applies to
* the recipient.
*/
const checkDestinationActive = (
account: Account,
destination: string,
): true | SplitDestinationInactiveError => {
if (AccountValidator(account).isActive() instanceof Error) {
return new SplitDestinationInactiveError(
`Split destination account is not active: ${destination}`,
)
}
return true
}

/**
* A destination is either a walletId or a Flash username. A username resolves
* to that account's wallet in the SOURCE wallet's currency — not to its
Expand All @@ -60,6 +79,12 @@ const resolveDestination = async ({
if (wallet instanceof Error) {
return new SplitDestinationNotFoundError(`No wallet found for ${trimmed}`)
}
const owner = await AccountsRepository().findById(wallet.accountId)
if (owner instanceof Error) {
return new SplitDestinationNotFoundError(`No wallet found for ${trimmed}`)
}
const active = checkDestinationActive(owner, trimmed)
if (active instanceof Error) return active
return wallet
}

Expand All @@ -72,6 +97,8 @@ const resolveDestination = async ({
if (account instanceof Error) {
return new SplitDestinationNotFoundError(`No Flash user found for ${trimmed}`)
}
const active = checkDestinationActive(account, trimmed)
if (active instanceof Error) return active

const wallets = await WalletsRepository().listByAccountId(account.id)
if (wallets instanceof Error) return wallets
Expand Down
4 changes: 4 additions & 0 deletions src/config/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@
"failed": {
"title": "Payment split failed",
"body": "We could not send a {{amount}} payment split. The money is still in your wallet."
},
"disabled": {
"title": "Payment split turned off",
"body": "We could not send a {{amount}} payment split because the recipient's account is no longer active. We turned this split off. The money is still in your wallet."
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/config/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@
"failed": {
"title": "La división de pago falló",
"body": "No pudimos enviar una división de pago de {{amount}}. El dinero sigue en tu billetera."
},
"disabled": {
"title": "División de pago desactivada",
"body": "No pudimos enviar una división de pago de {{amount}} porque la cuenta del destinatario ya no está activa. Desactivamos esta división. El dinero sigue en tu billetera."
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/domain/payment-splits/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export class SplitRuleLimitExceededError extends ValidationError {}
// The destination username or walletId does not resolve to a wallet in the
// source wallet's currency.
export class SplitDestinationNotFoundError extends ValidationError {}
// ENG-660: the destination account's current status is not active (closed,
// locked, pending). Every payout to it would fail with InactiveAccountError.
export class SplitDestinationInactiveError extends ValidationError {}
export class SelfSplitNotAllowedError extends ValidationError {}
// The destination wallet's currency differs from the source wallet's, or the
// source wallet is not a USD/USDT wallet. send-intraledger rejects
Expand Down
4 changes: 4 additions & 0 deletions src/graphql/error-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export const mapError = (error: ApplicationError): CustomApolloError => {
message = error.message
return new ValidationInternalError({ message, logger: baseLogger })

case "SplitDestinationInactiveError":
message = "Split destination account is not active"
return new ValidationInternalError({ message, logger: baseLogger })

case "SplitRuleNotFoundError":
message = "Payment split not found"
return new NotFoundError({ message, logger: baseLogger })
Expand Down
46 changes: 46 additions & 0 deletions src/services/mongoose/payment-split-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,50 @@ export const PaymentSplitRulesRepository = () => {
}
}

// ENG-660: turns off every enabled rule from `walletId` to a destination
// whose account went inactive. Matched by (wallet, destination), not by rule
// id: replaceForWallet re-inserts every rule with a new _id on each save, so
// an execution's ruleId may name a rule that no longer exists while a
// replacement to the same dead destination is still enabled. Only enabled
// rules are updated, so of several executions failing on the same
// destination exactly one sees `true` and notifies the owner.
const disableForDestination = async (
walletId: string,
destinationWalletId: string,
{ now = new Date() }: { now?: Date } = {},
): Promise<boolean | RepositoryError> => {
try {
const result = await PaymentSplitRuleModel.updateMany(
{
walletId: { $eq: walletId },
destinationWalletId: { $eq: destinationWalletId },
enabled: true,
},
{ $set: { enabled: false, updatedAt: now } },
)
return result.modifiedCount > 0
} catch (error) {
return toError(error)
}
}

// Whether any rule (enabled or not) from `walletId` to `destinationWalletId`
// exists.
const existsForDestination = async (
walletId: string,
destinationWalletId: string,
): Promise<boolean | RepositoryError> => {
try {
const doc = await PaymentSplitRuleModel.exists({
walletId: { $eq: walletId },
destinationWalletId: { $eq: destinationWalletId },
})
return doc !== null
} catch (error) {
return toError(error)
}
}

const listWalletIdsWithEnabledRules = async (): Promise<string[] | RepositoryError> => {
try {
const ids = await PaymentSplitRuleModel.distinct("walletId", { enabled: true })
Expand All @@ -131,6 +175,8 @@ export const PaymentSplitRulesRepository = () => {
findById,
replaceForWallet,
deleteById,
disableForDestination,
existsForDestination,
listWalletIdsWithEnabledRules,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,91 @@ describe("payment split repositories (ENG-653)", () => {
}
})

it("disables a rule once, and a disabled rule records nothing (ENG-660)", async () => {
const enabled = PaymentSplitsConfig.enabled
PaymentSplitsConfig.enabled = true
try {
const rules = PaymentSplitRulesRepository()
const saved = await rules.replaceForWallet("wallet-d", [
{ destinationWalletId: "closed-partner", percent: 10 },
{ destinationWalletId: "open-partner", percent: 20 },
])
if (saved instanceof Error) throw saved
const closed = saved.find((r) => r.destinationWalletId === "closed-partner")
if (!closed) throw new Error("rule not saved")

const disabledAt = new Date("2026-10-12T12:00:00Z")
// Only the first call flips the flag, so only it notifies the owner.
expect(
await rules.disableForDestination("wallet-d", "closed-partner", {
now: disabledAt,
}),
).toBe(true)
expect(await rules.disableForDestination("wallet-d", "closed-partner")).toBe(false)
expect(await rules.disableForDestination("wallet-d", "no-such-partner")).toBe(false)
expect(await rules.findById(closed.id)).toMatchObject({
enabled: false,
updatedAt: disabledAt,
})
// The disabled rule still exists; a destination never configured does not.
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.
const triggerKey = "d".repeat(64)
const result = await recordSplitExecutions({
walletId: "wallet-d",
triggerKey,
paymentHash: triggerKey,
amount: 100,
currency: "USDT",
})
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(["open-partner"])

// A wallet whose only rule is disabled is not reconciled at all.
const only = await rules.replaceForWallet("wallet-e", [
{ destinationWalletId: "closed-partner", percent: 10 },
])
if (only instanceof Error) throw only
expect(await rules.disableForDestination("wallet-e", "closed-partner")).toBe(true)
expect(await rules.listWalletIdsWithEnabledRules()).toEqual(["wallet-d"])
} finally {
PaymentSplitsConfig.enabled = enabled
}
})

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 () => {
const repo = PaymentSplitExecutionsRepository()
await repo.insert(executionFields("e".repeat(64), "rule-1"))
Expand Down
Loading
Loading