diff --git a/src/actions/transaction-detail.ts b/src/actions/transaction-detail.ts index a7d6e8e9..14394b8a 100644 --- a/src/actions/transaction-detail.ts +++ b/src/actions/transaction-detail.ts @@ -121,6 +121,88 @@ export async function updateTransactionFields( } +/** + * Confirms a low-confidence transfer suggestion (transferSource="suggested", + * set by the single-leg pattern pass in transfer-detection.ts) from the + * review queue. Split into a *Scoped core + session-authorized wrapper, same + * shape as updateTransactionCategoryScoped/updateTransactionCategory, so the + * MCP transfer tool can call the core directly with its own householdId. + */ +export async function confirmTransferSuggestionScoped( + householdId: string, + transactionId: string, + db: LedgrDb = defaultDb, +): Promise<{ success: true } | { error: string }> { + const parsedId = transactionIdSchema.safeParse(transactionId); + if (!parsedId.success) return { error: "Invalid input" }; + + return withHousehold(householdId, async (tx) => { + const scoped = scopedQuery(householdId, tx); + const [existing] = await tx + .select({ id: transactions.id }) + .from(transactions) + .where(scoped.where(transactions, eq(transactions.id, parsedId.data), notDeleted(transactions))) + .limit(1); + + if (!existing) return { error: "Transaction not found" }; + + await tx.update(transactions) + .set({ isTransfer: true, transferSource: "manual", updatedAt: new Date() }) + .where(eq(transactions.id, existing.id)); + + return { success: true }; + }, db); +} + +export async function confirmTransferSuggestion( + transactionId: string, + db: LedgrDb = defaultDb, +): Promise<{ success: true } | { error: string }> { + const auth = await authorizeAction(); + if ("error" in auth) return auth; + return confirmTransferSuggestionScoped(auth.householdId, transactionId, db); +} + +/** + * Rejects a transfer suggestion — keeps it as real spending/income and, via + * transferSource="manual_rejected", stops it from ever being re-suggested by + * a later sync (same guard applyTransferDetection already honors for pairs). + */ +export async function rejectTransferSuggestionScoped( + householdId: string, + transactionId: string, + db: LedgrDb = defaultDb, +): Promise<{ success: true } | { error: string }> { + const parsedId = transactionIdSchema.safeParse(transactionId); + if (!parsedId.success) return { error: "Invalid input" }; + + return withHousehold(householdId, async (tx) => { + const scoped = scopedQuery(householdId, tx); + const [existing] = await tx + .select({ id: transactions.id }) + .from(transactions) + .where(scoped.where(transactions, eq(transactions.id, parsedId.data), notDeleted(transactions))) + .limit(1); + + if (!existing) return { error: "Transaction not found" }; + + await tx.update(transactions) + .set({ isTransfer: false, transferSource: "manual_rejected", updatedAt: new Date() }) + .where(eq(transactions.id, existing.id)); + + return { success: true }; + }, db); +} + +export async function rejectTransferSuggestion( + transactionId: string, + db: LedgrDb = defaultDb, +): Promise<{ success: true } | { error: string }> { + const auth = await authorizeAction(); + if ("error" in auth) return auth; + return rejectTransferSuggestionScoped(auth.householdId, transactionId, db); +} + export async function upsertSplit( transactionId: string, splitId: string | null, diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 465b2b78..2412774f 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -13,12 +13,13 @@ import { import { getAccountsByInstitution } from "@/queries/accounts"; import { getBudgetForMonth } from "@/queries/budgets"; import { getUpcomingBills } from "@/queries/recurring"; -import { getTransactionSummary } from "@/queries/transactions"; +import { getTransactionSummary, getSuggestedTransferCount } from "@/queries/transactions"; import { getCurrentMonth, shiftMonth, formatMonthLong } from "@/lib/date-utils"; import { uncategorizedShare } from "@/lib/uncategorized-share"; import { budgetPace } from "@/lib/budget-pace"; import { rangeSupport } from "@/lib/net-worth-range"; import { ReviewNudge } from "@/components/molecules/review-nudge"; +import { TransferReviewNudge } from "@/components/molecules/transfer-review-nudge"; import { getLayoutForUser } from "@/queries/dashboard-layout"; import { getDefaultLayout } from "@/components/organisms/widgets/registry"; import { getSession } from "@/lib/auth/session"; @@ -49,7 +50,7 @@ export default async function DashboardPage() { const heroRange = rangeSupport(fullCoverageSince, new Date()).find((r) => r.recommended)?.range ?? "1M"; - const [summary, prevSummary, netWorthHistory, monthlySpending, cashFlow, recentTransactions, accountGroups, budgetData, upcomingBills, investmentsData, savedLayout, unreviewedSummary] = + const [summary, prevSummary, netWorthHistory, monthlySpending, cashFlow, recentTransactions, accountGroups, budgetData, upcomingBills, investmentsData, savedLayout, unreviewedSummary, suggestedTransferCount] = await Promise.all([ withHousehold(householdId, (tx) => getDashboardSummary(householdId, spendingMonth, tx)), withHousehold(householdId, (tx) => getDashboardSummary(householdId, prevMonth, tx)), @@ -63,6 +64,7 @@ export default async function DashboardPage() { getInvestmentsSummary(householdId), session ? getLayoutForUser(session.user.id) : null, withHousehold(householdId, (tx) => getTransactionSummary(householdId, { reviewed: false }, tx)), + withHousehold(householdId, (tx) => getSuggestedTransferCount(householdId, tx)), ]); // Flattened from the institution grouping so the balances widget can show the @@ -124,6 +126,7 @@ export default async function DashboardPage() { share={uncategorizedShare(monthlySpending)} monthLabel={formatMonthLong(spendingMonth)} /> + k !== "reviewed") .some(([, v]) => v !== undefined); - const [page, allCategories, allAccounts, summary, unreviewedSummary] = await Promise.all([ + const [page, allCategories, allAccounts, summary, unreviewedSummary, suggestedTransfers] = await Promise.all([ withHousehold(householdId, (tx) => getTransactions(householdId, filters, undefined, undefined, tx)), getCategories(householdId), getAccounts(householdId), @@ -35,6 +36,7 @@ export default async function TransactionsPage({ // arithmetic on it at all — per-day subtotals, and nothing for the whole. withHousehold(householdId, (tx) => getTransactionSummary(householdId, filters, tx)), withHousehold(householdId, (tx) => getTransactionSummary(householdId, { reviewed: false }, tx)), + withHousehold(householdId, (tx) => getSuggestedTransfers(householdId, tx)), ]); const accountOptions = allAccounts.map((a) => ({ id: a.id, name: a.name })); @@ -60,7 +62,7 @@ export default async function TransactionsPage({ /> )} - {page.rows.length === 0 ? ( + {page.rows.length === 0 && suggestedTransfers.length === 0 ? ( ) : ( )} diff --git a/src/components/molecules/transfer-review-card.tsx b/src/components/molecules/transfer-review-card.tsx new file mode 100644 index 00000000..e4687e2a --- /dev/null +++ b/src/components/molecules/transfer-review-card.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { EntityAvatar } from "@/components/molecules/entity-avatar"; +import { AmountDisplay } from "@/components/atoms/amount-display"; +import { formatDateShort } from "@/lib/date-utils"; +import type { TransactionRow } from "@/queries/transactions"; + +interface TransferReviewCardProps { + transaction: TransactionRow; + direction: "forward" | "back"; +} + +export function TransferReviewCard({ transaction, direction }: TransferReviewCardProps) { + const isIncome = transaction.normalizedAmount > 0; + + return ( +
+
+ +
+

{transaction.name}

+

+ {transaction.accountName} · {formatDateShort(transaction.date)} +

+
+
+ +
+
+ +
+
+ +

+ {isIncome + ? "This looks like it could be money moving in from another account rather than income — is it a transfer?" + : "This looks like it could be money moving to another account rather than a purchase — is it a transfer?"} +

+
+ ); +} diff --git a/src/components/molecules/transfer-review-nudge.tsx b/src/components/molecules/transfer-review-nudge.tsx new file mode 100644 index 00000000..804140e2 --- /dev/null +++ b/src/components/molecules/transfer-review-nudge.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import Link from "next/link"; +import { X, ArrowLeftRight } from "lucide-react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Alert, AlertTitle, AlertDescription, AlertAction } from "@/components/ui/alert"; +import { cn } from "@/lib/utils"; + +const DISMISS_KEY = "ledgr:transfer-review-nudge-dismissed"; +const DISMISS_EVENT = "ledgr:transfer-review-nudge-dismiss"; + +// Same useSyncExternalStore approach as ReviewNudge, for the same reason: +// sessionStorage doesn't exist during SSR, so the server snapshot is always +// "not dismissed" and React reconciles to the real value on hydration. +function subscribe(onChange: () => void) { + window.addEventListener(DISMISS_EVENT, onChange); + return () => window.removeEventListener(DISMISS_EVENT, onChange); +} + +let dismissedInMemory = false; + +function isDismissed() { + if (dismissedInMemory) return true; + try { + return sessionStorage.getItem(DISMISS_KEY) === "1"; + } catch { + return false; + } +} + +function notDismissedOnServer() { + return false; +} + +interface TransferReviewNudgeProps { + /** Single-leg transfer suggestions awaiting confirmation. Does not render at zero. */ + suggestedCount: number; +} + +export function TransferReviewNudge({ suggestedCount }: TransferReviewNudgeProps) { + const dismissed = useSyncExternalStore(subscribe, isDismissed, notDismissedOnServer); + + function dismiss() { + dismissedInMemory = true; + try { + sessionStorage.setItem(DISMISS_KEY, "1"); + } catch { + // Storage unavailable. The event below still hides it for this view. + } + window.dispatchEvent(new Event(DISMISS_EVENT)); + } + + if (suggestedCount === 0 || dismissed) return null; + + return ( + + + + {suggestedCount.toLocaleString()}{" "} + {suggestedCount === 1 ? "transaction looks" : "transactions look"} like a transfer + + + Payments like Zelle or Venmo can be real spending or just money moving between your own + accounts — confirm which before they skew your totals. + + + + Review + + + + + ); +} diff --git a/src/components/organisms/transaction-list.tsx b/src/components/organisms/transaction-list.tsx index f5ed67b7..8d3ed4d4 100644 --- a/src/components/organisms/transaction-list.tsx +++ b/src/components/organisms/transaction-list.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ReviewCardDialog } from "@/components/organisms/review-card-dialog"; +import { TransferReviewDialog } from "@/components/organisms/transfer-review-dialog"; import { TransactionRow, TRANSACTION_GRID_COLS } from "@/components/molecules/transaction-row"; import { TransactionDateHeader } from "@/components/molecules/transaction-date-header"; import { BulkActionBar } from "@/components/molecules/bulk-action-bar"; @@ -22,6 +23,7 @@ interface TransactionListProps { nextCursor: string | null; categories: CategoryGroup[]; filters: TransactionFilters; + suggestedTransfers?: TxnRow[]; } export function TransactionList({ @@ -29,6 +31,7 @@ export function TransactionList({ nextCursor, categories, filters, + suggestedTransfers = [], }: TransactionListProps) { const router = useRouter(); const isMobile = useIsMobile(); @@ -38,7 +41,9 @@ export function TransactionList({ const [loadingMore, setLoadingMore] = useState(false); const { selectedId, select, clear } = useSelectedTransaction(); const urlSearchParams = useSearchParams(); - const isReviewMode = urlSearchParams.get("mode") === "review"; + const mode = urlSearchParams.get("mode"); + const isReviewMode = mode === "review"; + const isTransferReviewMode = mode === "review-transfers"; const groups = useMemo(() => groupByDate(rows), [rows]); @@ -98,6 +103,8 @@ export function TransactionList({ router.push(`/transactions${params.toString() ? `?${params.toString()}` : ""}`); }, [router, urlSearchParams]); + const handleTransferReviewDone = handleReviewDone; + const hasBulkSelection = selected.size > 0; return ( @@ -174,7 +181,7 @@ export function TransactionList({ {/* Detail Panel Column */} - {isPanelOpen && !isReviewMode && ( + {isPanelOpen && !isReviewMode && !isTransferReviewMode && (
)} + + {isTransferReviewMode && ( + + )}
); } diff --git a/src/components/organisms/transfer-review-dialog.tsx b/src/components/organisms/transfer-review-dialog.tsx new file mode 100644 index 00000000..356e157c --- /dev/null +++ b/src/components/organisms/transfer-review-dialog.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { + Dialog, + DialogContent, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { TransferReviewCard } from "@/components/molecules/transfer-review-card"; +import { ReviewProgressBar } from "@/components/atoms/review-progress-bar"; +import { useTransferReviewQueue, type TransferDecision } from "@/hooks/use-transfer-review-queue"; +import { confirmTransferSuggestion, rejectTransferSuggestion } from "@/actions/transaction-detail"; +import type { TransactionRow } from "@/queries/transactions"; + +interface TransferReviewDialogProps { + rows: TransactionRow[]; + onDone: () => void; +} + +export function TransferReviewDialog({ rows, onDone }: TransferReviewDialogProps) { + const router = useRouter(); + + const handleDecide = useCallback(async (transactionId: string, decision: TransferDecision) => { + if (decision === "transfer") { + await confirmTransferSuggestion(transactionId); + } else { + await rejectTransferSuggestion(transactionId); + } + }, []); + + const { + phase, + currentIndex, + currentTransaction, + queueLength, + sessionResolvedCount, + direction, + start, + decide, + retreat, + exit, + } = useTransferReviewQueue(rows, handleDecide); + + useEffect(() => { + start(); + }, [start]); + + const handleExit = useCallback(() => { + exit(); + router.refresh(); + onDone(); + }, [exit, router, onDone]); + + const isSaving = phase === "SAVING"; + const isOpen = phase !== "IDLE"; + const keepLabel = currentTransaction && currentTransaction.normalizedAmount > 0 ? "Keep as income" : "Keep as spending"; + + return ( + { if (!open) handleExit(); }} + > + + Transfer Review + + {phase === "COMPLETE" ? ( +
+

All caught up

+

+ {sessionResolvedCount} transaction{sessionResolvedCount !== 1 ? "s" : ""} resolved +

+ +
+ ) : currentTransaction ? ( +
+ + + + +
+ +
+ + +
+
+
+ ) : null} +
+
+ ); +} diff --git a/src/db/schema/transactions.ts b/src/db/schema/transactions.ts index 2925ad4f..9c4f457d 100644 --- a/src/db/schema/transactions.ts +++ b/src/db/schema/transactions.ts @@ -44,9 +44,15 @@ export const transactions = pgTable( isTransfer: boolean("is_transfer").default(false), // Provenance for isTransfer/transferPairId, mirroring categorySource: // manual and manual_rejected are user decisions and must never be - // overwritten by the lower tiers (pfc at ingestion, auto pair-detection). + // overwritten by the lower tiers (pfc at ingestion, auto/pattern + // detection). `pattern` is a single-leg, high-confidence name/memo match + // (e.g. a credit-card payoff memo) — trusted immediately like `auto`, but + // tagged separately since it never has a transferPairId. `suggested` is a + // single-leg, low-confidence match (e.g. a bare P2P processor name) — + // isTransfer stays false until a human confirms via the review queue, so + // it keeps counting toward spend/income until then. transferSource: text("transfer_source", { - enum: ["pfc", "auto", "manual", "manual_rejected"], + enum: ["pfc", "auto", "pattern", "suggested", "manual", "manual_rejected"], }), deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), diff --git a/src/hooks/use-transfer-review-queue.ts b/src/hooks/use-transfer-review-queue.ts new file mode 100644 index 00000000..aac729ba --- /dev/null +++ b/src/hooks/use-transfer-review-queue.ts @@ -0,0 +1,75 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { TransactionRow } from "@/queries/transactions"; + +export type TransferReviewPhase = "IDLE" | "VIEWING" | "SAVING" | "COMPLETE"; +export type TransferDecision = "transfer" | "spending"; + +export function useTransferReviewQueue( + rows: TransactionRow[], + onDecide?: (transactionId: string, decision: TransferDecision) => void | Promise, +) { + const [phase, setPhase] = useState("IDLE"); + const [currentIndex, setCurrentIndex] = useState(0); + const [sessionResolvedCount, setSessionResolvedCount] = useState(0); + const [queue, setQueue] = useState([]); + const [direction, setDirection] = useState<"forward" | "back">("forward"); + + const queueLength = queue.length; + const currentTransaction = phase !== "IDLE" && phase !== "COMPLETE" + ? queue[currentIndex] ?? null + : null; + + const start = useCallback(() => { + setQueue(rows); + setCurrentIndex(0); + setSessionResolvedCount(0); + setDirection("forward"); + setPhase(rows.length === 0 ? "COMPLETE" : "VIEWING"); + }, [rows]); + + const decide = useCallback(async (decision: TransferDecision) => { + const txn = queue[currentIndex]; + if (!txn) return; + + setPhase("SAVING"); + try { + await onDecide?.(txn.id, decision); + setSessionResolvedCount((c) => c + 1); + setDirection("forward"); + if (currentIndex + 1 >= queue.length) { + setPhase("COMPLETE"); + } else { + setCurrentIndex((i) => i + 1); + setPhase("VIEWING"); + } + } catch { + setPhase("VIEWING"); + } + }, [currentIndex, queue, onDecide]); + + const retreat = useCallback(() => { + if (currentIndex > 0) { + setDirection("back"); + setCurrentIndex((i) => i - 1); + } + }, [currentIndex]); + + const exit = useCallback(() => { + setPhase("IDLE"); + }, []); + + return { + phase, + currentIndex, + currentTransaction, + queueLength, + sessionResolvedCount, + direction, + start, + decide, + retreat, + exit, + }; +} diff --git a/src/lib/jobs/backfill-transfers.ts b/src/lib/jobs/backfill-transfers.ts index 603fbbfb..40f1c676 100644 --- a/src/lib/jobs/backfill-transfers.ts +++ b/src/lib/jobs/backfill-transfers.ts @@ -14,14 +14,21 @@ import { assertCanEnumerateHouseholds } from "@/lib/jobs/cross-household"; * withHousehold transaction (applyTransferDetection already does this * internally per call). */ -export async function backfillTransfers(db: LedgrDb = defaultDb): Promise<{ households: number; tagged: number }> { +export async function backfillTransfers( + db: LedgrDb = defaultDb, +): Promise<{ households: number; tagged: number; patternsTagged: number; suggestedFlagged: number }> { await assertCanEnumerateHouseholds(db); const allHouseholds = await db.select({ id: households.id }).from(households); let tagged = 0; + let patternsTagged = 0; + let suggestedFlagged = 0; for (const { id: householdId } of allHouseholds) { - tagged += await applyTransferDetection(householdId, db); + const result = await applyTransferDetection(householdId, db); + tagged += result.pairs; + patternsTagged += result.patterns; + suggestedFlagged += result.suggested; } - return { households: allHouseholds.length, tagged }; + return { households: allHouseholds.length, tagged, patternsTagged, suggestedFlagged }; } diff --git a/src/lib/mcp/constants.ts b/src/lib/mcp/constants.ts index 79d71151..828d662c 100644 --- a/src/lib/mcp/constants.ts +++ b/src/lib/mcp/constants.ts @@ -13,7 +13,7 @@ export const DEFAULT_SCOPE = "ledgr:read ledgr:write ledgr:sync"; export const SCOPE_LABELS: Record = { "ledgr:read": "View your accounts, transactions, budgets, and reports", - "ledgr:write": "Update transaction categories and budget allocations", + "ledgr:write": "Update transaction categories, transfer status, and budget allocations", "ledgr:sync": "Trigger bank account syncs", }; diff --git a/src/lib/mcp/tools/index.ts b/src/lib/mcp/tools/index.ts index 7eb1466e..f1680326 100644 --- a/src/lib/mcp/tools/index.ts +++ b/src/lib/mcp/tools/index.ts @@ -2,7 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; import type { AccessTokenClaims } from "../auth/token"; import { registerAccountTools } from "./accounts"; import { registerDashboardTools } from "./dashboard"; -import { registerTransactionTools } from "./transactions"; +import { registerTransactionTools, registerTransactionWriteTools } from "./transactions"; import { registerBudgetReadTools, registerBudgetWriteTools } from "./budgets"; import { registerReportTools } from "./reports"; import { registerRecurringTools } from "./recurring"; @@ -30,6 +30,7 @@ export function registerAllTools(server: McpServer, claims: AccessTokenClaims) { if (scopes.includes("ledgr:write")) { registerCategoryWriteTools(server, householdId); registerBudgetWriteTools(server, householdId); + registerTransactionWriteTools(server, householdId); } if (scopes.includes("ledgr:sync")) { diff --git a/src/lib/mcp/tools/transactions.ts b/src/lib/mcp/tools/transactions.ts index d709742d..3238376b 100644 --- a/src/lib/mcp/tools/transactions.ts +++ b/src/lib/mcp/tools/transactions.ts @@ -1,9 +1,10 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/server"; import { getTransactions } from "@/queries/transactions"; +import { confirmTransferSuggestionScoped, rejectTransferSuggestionScoped } from "@/actions/transaction-detail"; import { centsToDisplay } from "@/lib/money"; import { withHousehold } from "@/lib/household-context"; -import { READ_ANNOTATIONS } from "../constants"; +import { READ_ANNOTATIONS, WRITE_ANNOTATIONS } from "../constants"; import { JSON_RESULT_SCHEMA, jsonResult } from "../tool-result"; export function registerTransactionTools(server: McpServer, householdId: string) { @@ -54,3 +55,28 @@ export function registerTransactionTools(server: McpServer, householdId: string) }, ); } + +export function registerTransactionWriteTools(server: McpServer, householdId: string) { + server.registerTool( + "mark_transaction_transfer", + { + title: "Mark Transaction As Transfer", + description: + "Mark a transaction as a transfer (money moving between accounts, e.g. a credit card payoff or a savings transfer) so it's excluded from spending/income totals, or clear that flag to keep it as real spending/income. This is the fix for a transaction Ledgr didn't auto-detect as a transfer — for example a payment to a credit card or bank account that isn't itself connected to Ledgr.", + inputSchema: z.object({ + transactionId: z.string().min(1).describe("The transaction ID to update"), + isTransfer: z + .boolean() + .describe("true to mark as a transfer (excluded from totals), false to keep it as real spending/income"), + }), + outputSchema: JSON_RESULT_SCHEMA, + annotations: WRITE_ANNOTATIONS, + }, + async (args) => { + const result = args.isTransfer + ? await confirmTransferSuggestionScoped(householdId, args.transactionId) + : await rejectTransferSuggestionScoped(householdId, args.transactionId); + return jsonResult(result); + }, + ); +} diff --git a/src/lib/transfer-detection.ts b/src/lib/transfer-detection.ts index 0bfacc75..24dd21e4 100644 --- a/src/lib/transfer-detection.ts +++ b/src/lib/transfer-detection.ts @@ -1,9 +1,10 @@ import { eq, isNull, ne, or } from "drizzle-orm"; import { db as defaultDb, type LedgrDb } from "@/db"; -import { transactions } from "@/db/schema"; +import { transactions, merchants } from "@/db/schema"; import { scopedQuery } from "@/lib/scoped-query"; import { notDeleted } from "@/lib/query-helpers"; import { withHousehold } from "@/lib/household-context"; +import { classifySingleLegTransfer } from "@/lib/transfer-patterns"; export interface TransferCandidate { id: string; @@ -71,22 +72,58 @@ export function detectTransferPairs( return pairs; } +export interface TransferDetectionResult { + /** Pairs matched across two of the household's own accounts. */ + pairs: number; + /** Single-leg, high-confidence name/memo matches — tagged isTransfer=true immediately. */ + patterns: number; + /** Single-leg, low-confidence matches (bare P2P processor names) — left isTransfer=false, routed to the review queue. */ + suggested: number; +} + /** - * Applies detectTransferPairs to a household's untagged transactions and - * persists the result. Idempotent: only ever operates on rows that are still - * untagged (isTransfer=false, transferPairId IS NULL), so already-tagged - * pairs are naturally skipped on a repeat call. Returns the number of pairs - * tagged. + * Applies transfer detection to a household's untagged transactions and + * persists the result, in two tiers: + * + * 1. detectTransferPairs — two of the household's own accounts, opposite + * sign, exact amount, short date window. + * 2. Whatever pairing leaves untagged is tried against name/memo patterns + * (classifySingleLegTransfer) — for a transaction whose other leg isn't a + * Ledgr account at all (an external credit card payoff, a savings account + * at another bank, a P2P counterparty). High-confidence matches are + * trusted immediately like a pair match; low-confidence matches are + * flagged for the review queue without touching isTransfer. + * + * Idempotent: both tiers only ever operate on rows still untagged + * (isTransfer=false, transferPairId IS NULL, transferSource not yet + * decided), so a repeat call naturally skips already-tagged rows. */ export async function applyTransferDetection( householdId: string, db: LedgrDb = defaultDb, -): Promise { +): Promise { return withHousehold( householdId, async (tx) => { const scoped = scopedQuery(householdId, tx); + // A user who un-marked a transfer must not have it silently re-paired + // on the next sync. NULL means "never decided", so it has to be + // spelled out — `!= 'manual_rejected'` alone is NULL (and therefore + // falsy) for those rows. + const untagged = () => + scoped.where( + transactions, + notDeleted(transactions), + eq(transactions.isTransfer, false), + isNull(transactions.transferPairId), + eq(transactions.pending, false), + or( + isNull(transactions.transferSource), + ne(transactions.transferSource, "manual_rejected"), + ), + ); + const rows = await tx .select({ id: transactions.id, @@ -95,23 +132,7 @@ export async function applyTransferDetection( normalizedAmount: transactions.normalizedAmount, }) .from(transactions) - .where( - scoped.where( - transactions, - notDeleted(transactions), - eq(transactions.isTransfer, false), - isNull(transactions.transferPairId), - eq(transactions.pending, false), - // A user who un-marked a transfer must not have it silently - // re-paired on the next sync. NULL means "never decided", so it - // has to be spelled out — `!= 'manual_rejected'` alone is NULL - // (and therefore falsy) for those rows. - or( - isNull(transactions.transferSource), - ne(transactions.transferSource, "manual_rejected"), - ), - ), - ); + .where(untagged()); const pairs = detectTransferPairs(rows); @@ -124,7 +145,41 @@ export async function applyTransferDetection( .where(eq(transactions.id, pair.inflowId)); } - return pairs.length; + // Re-select rather than filter `rows` in memory: the UPDATEs above are + // visible to this same transaction, so `untagged()` already excludes + // the rows just paired, and this query additionally needs merchantName + // (a join `rows` above never fetched). + const singleLegCandidates = await tx + .select({ + id: transactions.id, + name: transactions.name, + merchantName: merchants.name, + }) + .from(transactions) + .leftJoin(merchants, eq(transactions.merchantId, merchants.id)) + .where(untagged()); + + let patternCount = 0; + let suggestedCount = 0; + + for (const row of singleLegCandidates) { + const match = classifySingleLegTransfer(row.name, row.merchantName ?? null); + if (!match) continue; + + if (match === "pattern") { + patternCount++; + await tx.update(transactions) + .set({ isTransfer: true, transferSource: "pattern", updatedAt: new Date() }) + .where(eq(transactions.id, row.id)); + } else { + suggestedCount++; + await tx.update(transactions) + .set({ transferSource: "suggested", updatedAt: new Date() }) + .where(eq(transactions.id, row.id)); + } + } + + return { pairs: pairs.length, patterns: patternCount, suggested: suggestedCount }; }, db, ); diff --git a/src/lib/transfer-patterns.test.ts b/src/lib/transfer-patterns.test.ts new file mode 100644 index 00000000..62456e1b --- /dev/null +++ b/src/lib/transfer-patterns.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { test } from "@fast-check/vitest"; +import * as fc from "fast-check"; +import { classifySingleLegTransfer } from "./transfer-patterns"; + +describe("classifySingleLegTransfer", () => { + it("recognizes a known card-issuer payoff memo as high confidence", () => { + expect(classifySingleLegTransfer("Applecard Gsbank Payment Xxxxx4415", "Apple")).toBe("pattern"); + }); + + it("recognizes a generic credit card payment memo as high confidence", () => { + expect(classifySingleLegTransfer("ONLINE CREDIT CARD PAYMENT - THANK YOU", null)).toBe("pattern"); + }); + + it("recognizes a named self-transfer to savings as high confidence", () => { + expect(classifySingleLegTransfer("Apple GS Savings Transfer", "Apple")).toBe("pattern"); + }); + + it("recognizes a named self-transfer to a brokerage as high confidence", () => { + expect(classifySingleLegTransfer("Transfer to Brokerage Account", null)).toBe("pattern"); + }); + + it("does not flag an unrelated merchant charge that merely contains the word transfer", () => { + expect(classifySingleLegTransfer("Wire Transfer Fee - City Bank", null)).toBeNull(); + }); + + it("recognizes a bare Zelle transaction name as a suggested (low-confidence) transfer", () => { + expect(classifySingleLegTransfer("Zelle", null)).toBe("suggested"); + }); + + it("recognizes a bare Venmo merchant name as suggested", () => { + expect(classifySingleLegTransfer("Venmo Payment", "Venmo")).toBe("suggested"); + }); + + it("recognizes Cash App and PayPal as suggested", () => { + expect(classifySingleLegTransfer("Cash App", null)).toBe("suggested"); + expect(classifySingleLegTransfer("Paypal Transfer", "PayPal")).toBe("suggested"); + }); + + it("does not flag an ordinary merchant purchase", () => { + expect(classifySingleLegTransfer("Uber", "Uber")).toBeNull(); + expect(classifySingleLegTransfer("Amazon.com*5Q2MG7LQ2", "Amazon")).toBeNull(); + }); + + it("does not flag a person's name with no processor or transfer keyword", () => { + // Genuinely ambiguous P2P payments with no lexical hook (e.g. a bank's own + // "sender name" memo) are out of scope for text matching — they still need + // a human to notice via the existing manual isTransfer toggle. + expect(classifySingleLegTransfer("Bahar Rabiei", null)).toBeNull(); + }); + + it("is case-insensitive", () => { + expect(classifySingleLegTransfer("gsbank payment", null)).toBe("pattern"); + expect(classifySingleLegTransfer("ZELLE", null)).toBe("suggested"); + }); + + test.prop([fc.string()])("never throws on arbitrary input", (name) => { + expect(() => classifySingleLegTransfer(name, null)).not.toThrow(); + }); +}); diff --git a/src/lib/transfer-patterns.ts b/src/lib/transfer-patterns.ts new file mode 100644 index 00000000..1b429f88 --- /dev/null +++ b/src/lib/transfer-patterns.ts @@ -0,0 +1,47 @@ +export type SingleLegTransferSource = "pattern" | "suggested"; + +// Curated, narrow on purpose — these fire immediately (isTransfer=true, no +// human in the loop), so a false positive here silently drops real spending +// from totals. Extend by adding to the list, not by loosening the matcher. +const CARD_PAYOFF_MEMOS = [ + "gsbank payment", + "applecard", + "autopay", + "credit card payment", + "cc payment thank you", +]; + +// Self-transfer phrasing: requires both "transfer" and a self-account keyword +// so an unrelated "Wire Transfer Fee" merchant charge doesn't match. +const SELF_TRANSFER_KEYWORD = /\btransfer\b/i; +const SELF_ACCOUNT_KEYWORD = /\b(savings|brokerage|ira)\b/i; + +// Bare P2P processor names — lower confidence than the patterns above because +// the same rail is used for both real payments to people and moving your own +// money, so these land in the review queue instead of auto-excluding. +const P2P_PROCESSORS = ["zelle", "venmo", "cash app", "cashapp", "paypal"]; + +function matchesAny(haystack: string, needles: string[]): boolean { + return needles.some((needle) => haystack.includes(needle)); +} + +/** + * Classifies a single transaction (no matching leg required) as a likely + * transfer from its name/merchant text alone. Returns "pattern" for + * high-confidence matches (known card payoff memos, named self-transfers to + * savings/brokerage/IRA — trusted immediately), "suggested" for low-confidence + * matches (bare P2P processor names — routed to manual review instead), or + * null when nothing matches. + */ +export function classifySingleLegTransfer( + name: string, + merchantName: string | null, +): SingleLegTransferSource | null { + const text = `${name} ${merchantName ?? ""}`.toLowerCase().trim(); + + if (matchesAny(text, CARD_PAYOFF_MEMOS)) return "pattern"; + if (SELF_TRANSFER_KEYWORD.test(text) && SELF_ACCOUNT_KEYWORD.test(text)) return "pattern"; + if (matchesAny(text, P2P_PROCESSORS)) return "suggested"; + + return null; +} diff --git a/src/queries/transactions.ts b/src/queries/transactions.ts index ca326f2b..3a0afd25 100644 --- a/src/queries/transactions.ts +++ b/src/queries/transactions.ts @@ -263,6 +263,39 @@ export async function getTransactionSummary( }; } +/** + * Transactions flagged by the single-leg pattern pass (transfer-detection.ts) + * as a likely transfer but too low-confidence to auto-exclude — a bare P2P + * processor name (Zelle, Venmo, Cash App, PayPal). Still counts toward + * spend/income until a human confirms or rejects it via the review queue. + */ +export async function getSuggestedTransfers( + householdId: string, + db: LedgrDb = defaultDb, +): Promise { + const { rows } = await fetchTransactionPage( + householdId, + [notDeleted(transactions), eq(transactions.transferSource, "suggested")], + 200, + null, + db, + ); + return rows; +} + +export async function getSuggestedTransferCount( + householdId: string, + db: LedgrDb = defaultDb, +): Promise { + const scoped = scopedQuery(householdId, db); + const [result] = await db + .select({ count: countRows() }) + .from(transactions) + .where(scoped.where(transactions, notDeleted(transactions), eq(transactions.transferSource, "suggested"))) + .limit(1); + return result?.count ?? 0; +} + export async function getTransactionDetail( householdId: string, transactionId: string, diff --git a/tests/integration/transaction-detail.test.ts b/tests/integration/transaction-detail.test.ts index 37167330..df7bc1cc 100644 --- a/tests/integration/transaction-detail.test.ts +++ b/tests/integration/transaction-detail.test.ts @@ -29,6 +29,8 @@ import { updateTransactionFields, upsertSplit, deleteSplit, + confirmTransferSuggestionScoped, + rejectTransferSuggestionScoped, } from "@/actions/transaction-detail"; let db: LedgrDb; @@ -309,6 +311,65 @@ describe("deleteSplit", () => { }); }); +describe("confirmTransferSuggestionScoped / rejectTransferSuggestionScoped", () => { + it("confirms a suggested transfer", async () => { + const suggestedId = uuid(); + await db.insert(transactions).values({ + id: suggestedId, + accountId, + householdId, + date: "2026-05-11", + originalName: "ZELLE", + name: "Zelle", + amount: 7000, + normalizedAmount: -7000, + isTransfer: false, + transferSource: "suggested", + }); + + const result = await confirmTransferSuggestionScoped(householdId, suggestedId, db); + expect(result).toEqual({ success: true }); + + const [row] = await db + .select({ isTransfer: transactions.isTransfer, transferSource: transactions.transferSource }) + .from(transactions) + .where(eq(transactions.id, suggestedId)); + expect(row!.isTransfer).toBe(true); + expect(row!.transferSource).toBe("manual"); + }); + + it("rejects a suggested transfer, keeping it as real spending and blocking re-suggestion", async () => { + const suggestedId = uuid(); + await db.insert(transactions).values({ + id: suggestedId, + accountId, + householdId, + date: "2026-05-11", + originalName: "ZELLE", + name: "Zelle", + amount: 4200, + normalizedAmount: -4200, + isTransfer: false, + transferSource: "suggested", + }); + + const result = await rejectTransferSuggestionScoped(householdId, suggestedId, db); + expect(result).toEqual({ success: true }); + + const [row] = await db + .select({ isTransfer: transactions.isTransfer, transferSource: transactions.transferSource }) + .from(transactions) + .where(eq(transactions.id, suggestedId)); + expect(row!.isTransfer).toBe(false); + expect(row!.transferSource).toBe("manual_rejected"); + }); + + it("returns an error for a transaction in a different household", async () => { + const result = await confirmTransferSuggestionScoped("other-household", txnId, db); + expect(result).toEqual({ error: "Transaction not found" }); + }); +}); + describe("split remaining balance math", () => { test.prop([ fc.integer({ min: 100, max: 10_000_00 }), diff --git a/tests/integration/transaction-queries.test.ts b/tests/integration/transaction-queries.test.ts index 7eec4b77..3ef28729 100644 --- a/tests/integration/transaction-queries.test.ts +++ b/tests/integration/transaction-queries.test.ts @@ -7,7 +7,7 @@ import { insertCategoryGroup, insertCategory, } from "./helpers"; -import { getTransactions } from "../../src/queries/transactions"; +import { getTransactions, getSuggestedTransfers, getSuggestedTransferCount } from "../../src/queries/transactions"; import type { LedgrDb } from "../../src/db"; describe("getTransactions", () => { @@ -160,3 +160,63 @@ describe("getTransactions", () => { } }); }); + +describe("getSuggestedTransfers / getSuggestedTransferCount", () => { + let db: LedgrDb; + let close: () => Promise; + let householdId: string; + + beforeAll(async () => { + ({ db, close } = await createTestDb()); + ({ householdId } = await insertHousehold(db)); + const { accountId } = await insertAccount(db, householdId, { name: "Checking" }); + + await insertTransaction(db, householdId, accountId, { + name: "Zelle", + date: "2026-05-01", + amount: 7000, + normalizedAmount: -7000, + isTransfer: false, + transferSource: "suggested", + }); + await insertTransaction(db, householdId, accountId, { + name: "Venmo", + date: "2026-05-02", + amount: 2500, + normalizedAmount: -2500, + isTransfer: false, + transferSource: "suggested", + }); + // Not suggested — should be excluded from both. + await insertTransaction(db, householdId, accountId, { + name: "Uber", + date: "2026-05-03", + amount: 2400, + normalizedAmount: -2400, + }); + // Already resolved by a human — no longer "suggested". + await insertTransaction(db, householdId, accountId, { + name: "Cash App", + date: "2026-05-04", + amount: 1000, + normalizedAmount: -1000, + isTransfer: true, + transferSource: "manual", + }); + }); + + afterAll(async () => { + await close(); + }); + + it("returns only transactions still pending transfer review", async () => { + const rows = await getSuggestedTransfers(householdId, db); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.name).sort()).toEqual(["Venmo", "Zelle"]); + }); + + it("counts only transactions still pending transfer review", async () => { + const count = await getSuggestedTransferCount(householdId, db); + expect(count).toBe(2); + }); +}); diff --git a/tests/integration/transfer-detection.test.ts b/tests/integration/transfer-detection.test.ts index d99cb171..bce21b37 100644 --- a/tests/integration/transfer-detection.test.ts +++ b/tests/integration/transfer-detection.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { eq } from "drizzle-orm"; import { createTestDb } from "./setup"; -import { insertHousehold, insertAccount, insertTransaction } from "./helpers"; +import { insertHousehold, insertAccount, insertTransaction, insertMerchant } from "./helpers"; import { applyTransferDetection } from "@/lib/transfer-detection"; import { transactions } from "@/db/schema"; @@ -25,7 +25,7 @@ describe("applyTransferDetection", () => { }); const tagged = await applyTransferDetection(householdId, db); - expect(tagged).toBe(1); + expect(tagged).toEqual({ pairs: 1, patterns: 0, suggested: 0 }); const rows = await db.select().from(transactions).where(eq(transactions.householdId, householdId)); const outflow = rows.find((r) => r.id === outflowId)!; @@ -63,7 +63,7 @@ describe("applyTransferDetection", () => { }); const tagged = await applyTransferDetection(householdId, db); - expect(tagged).toBe(0); + expect(tagged).toEqual({ pairs: 0, patterns: 0, suggested: 0 }); const rows = await db.select().from(transactions).where(eq(transactions.householdId, householdId)); for (const id of [outflowId, inflowId]) { @@ -95,7 +95,7 @@ describe("applyTransferDetection", () => { amount: -50000, }); - expect(await applyTransferDetection(householdId, db)).toBe(1); + expect(await applyTransferDetection(householdId, db)).toEqual({ pairs: 1, patterns: 0, suggested: 0 }); const rows = await db.select().from(transactions).where(eq(transactions.householdId, householdId)); expect(rows.every((r) => r.transferSource === "auto")).toBe(true); @@ -123,10 +123,10 @@ describe("applyTransferDetection", () => { }); const firstRun = await applyTransferDetection(householdId, db); - expect(firstRun).toBe(1); + expect(firstRun).toEqual({ pairs: 1, patterns: 0, suggested: 0 }); const secondRun = await applyTransferDetection(householdId, db); - expect(secondRun).toBe(0); + expect(secondRun).toEqual({ pairs: 0, patterns: 0, suggested: 0 }); } finally { await close(); } @@ -188,7 +188,7 @@ describe("applyTransferDetection", () => { }); const tagged = await applyTransferDetection(householdId, db); - expect(tagged).toBe(1); + expect(tagged).toEqual({ pairs: 1, patterns: 0, suggested: 0 }); const [otherOut] = await db.select().from(transactions).where(eq(transactions.id, otherOutId)); const [otherIn] = await db.select().from(transactions).where(eq(transactions.id, otherInId)); @@ -199,3 +199,146 @@ describe("applyTransferDetection", () => { } }); }); + +describe("applyTransferDetection — single-leg pattern pass", () => { + it("tags a high-confidence name match immediately, with no pair", async () => { + const { db, close } = await createTestDb(); + try { + const { householdId } = await insertHousehold(db); + const { accountId } = await insertAccount(db, householdId, { type: "checking" }); + const { transactionId } = await insertTransaction(db, householdId, accountId, { + name: "Applecard Gsbank Payment Xxxxx4415", + normalizedAmount: -6861, + amount: 6861, + }); + + const result = await applyTransferDetection(householdId, db); + expect(result).toEqual({ pairs: 0, patterns: 1, suggested: 0 }); + + const [row] = await db.select().from(transactions).where(eq(transactions.id, transactionId)); + expect(row.isTransfer).toBe(true); + expect(row.transferPairId).toBeNull(); + expect(row.transferSource).toBe("pattern"); + } finally { + await close(); + } + }); + + it("flags a low-confidence P2P name for review without touching isTransfer", async () => { + const { db, close } = await createTestDb(); + try { + const { householdId } = await insertHousehold(db); + const { accountId } = await insertAccount(db, householdId, { type: "checking" }); + const { transactionId } = await insertTransaction(db, householdId, accountId, { + name: "Zelle", + normalizedAmount: -7000, + amount: 7000, + }); + + const result = await applyTransferDetection(householdId, db); + expect(result).toEqual({ pairs: 0, patterns: 0, suggested: 1 }); + + const [row] = await db.select().from(transactions).where(eq(transactions.id, transactionId)); + expect(row.isTransfer).toBe(false); + expect(row.transferSource).toBe("suggested"); + } finally { + await close(); + } + }); + + it("matches on merchant name as well as transaction name", async () => { + const { db, close } = await createTestDb(); + try { + const { householdId } = await insertHousehold(db); + const { accountId } = await insertAccount(db, householdId, { type: "checking" }); + const { merchantId } = await insertMerchant(db, householdId, { name: "Venmo" }); + const { transactionId } = await insertTransaction(db, householdId, accountId, { + name: "Venmo Payment", + merchantId, + normalizedAmount: -2500, + amount: 2500, + }); + + await applyTransferDetection(householdId, db); + + const [row] = await db.select().from(transactions).where(eq(transactions.id, transactionId)); + expect(row.transferSource).toBe("suggested"); + } finally { + await close(); + } + }); + + it("never re-flags a transaction the user manually rejected", async () => { + const { db, close } = await createTestDb(); + try { + const { householdId } = await insertHousehold(db); + const { accountId } = await insertAccount(db, householdId, { type: "checking" }); + const { transactionId } = await insertTransaction(db, householdId, accountId, { + name: "Zelle", + normalizedAmount: -7000, + amount: 7000, + isTransfer: false, + transferSource: "manual_rejected", + }); + + const result = await applyTransferDetection(householdId, db); + expect(result).toEqual({ pairs: 0, patterns: 0, suggested: 0 }); + + const [row] = await db.select().from(transactions).where(eq(transactions.id, transactionId)); + expect(row.transferSource).toBe("manual_rejected"); + } finally { + await close(); + } + }); + + it("is idempotent — a repeat call does not re-count already-flagged rows", async () => { + const { db, close } = await createTestDb(); + try { + const { householdId } = await insertHousehold(db); + const { accountId } = await insertAccount(db, householdId, { type: "checking" }); + await insertTransaction(db, householdId, accountId, { + name: "Apple GS Savings Transfer", + normalizedAmount: -70000, + amount: 70000, + }); + + const first = await applyTransferDetection(householdId, db); + expect(first.patterns).toBe(1); + + const second = await applyTransferDetection(householdId, db); + expect(second).toEqual({ pairs: 0, patterns: 0, suggested: 0 }); + } finally { + await close(); + } + }); + + it("prefers a real pair match over a name pattern when both apply", async () => { + const { db, close } = await createTestDb(); + try { + const { householdId } = await insertHousehold(db); + const { accountId: checkingId } = await insertAccount(db, householdId, { type: "checking" }); + const { accountId: savingsId } = await insertAccount(db, householdId, { type: "savings" }); + const { transactionId: outId } = await insertTransaction(db, householdId, checkingId, { + name: "Transfer to Savings", + date: "2026-05-10", + normalizedAmount: -10000, + amount: 10000, + }); + const { transactionId: inId } = await insertTransaction(db, householdId, savingsId, { + name: "Transfer from Checking", + date: "2026-05-10", + normalizedAmount: 10000, + amount: -10000, + }); + + const result = await applyTransferDetection(householdId, db); + expect(result).toEqual({ pairs: 1, patterns: 0, suggested: 0 }); + + const [out] = await db.select().from(transactions).where(eq(transactions.id, outId)); + expect(out.transferSource).toBe("auto"); + expect(out.transferPairId).toBe(inId); + } finally { + await close(); + } + }); +});