Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/actions/transaction-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)),
Expand All @@ -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
Expand Down Expand Up @@ -124,6 +126,7 @@ export default async function DashboardPage() {
share={uncategorizedShare(monthlySpending)}
monthLabel={formatMonthLong(spendingMonth)}
/>
<TransferReviewNudge suggestedCount={suggestedTransferCount} />
<NetWorthHero
netWorth={summary.netWorth}
initialHistory={netWorthHistory}
Expand Down
7 changes: 5 additions & 2 deletions src/app/(dashboard)/transactions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { withHousehold } from "@/lib/household-context";
import {
getTransactions,
getTransactionSummary,
getSuggestedTransfers,
} from "@/queries/transactions";
import { getCategories } from "@/queries/categories";
import { getAccounts } from "@/queries/accounts";
Expand All @@ -26,7 +27,7 @@ export default async function TransactionsPage({
.filter(([k]) => 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),
Expand All @@ -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 }));

Expand All @@ -60,7 +62,7 @@ export default async function TransactionsPage({
/>
)}

{page.rows.length === 0 ? (
{page.rows.length === 0 && suggestedTransfers.length === 0 ? (
<TransactionEmptyState hasFilters={hasAnyFilters} />
) : (
<TransactionList
Expand All @@ -69,6 +71,7 @@ export default async function TransactionsPage({
nextCursor={page.nextCursor}
categories={allCategories}
filters={filters}
suggestedTransfers={suggestedTransfers}
/>
)}
</div>
Expand Down
54 changes: 54 additions & 0 deletions src/components/molecules/transfer-review-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
key={transaction.id}
tabIndex={-1}
className="outline-none space-y-4"
data-direction={direction}
style={{
animation: `slide-in-${direction === "forward" ? "right" : "left"} 150ms ease-out`,
}}
>
<div className="flex items-center gap-3">
<EntityAvatar
logoUrl={transaction.merchantLogoUrl}
name={transaction.merchantName ?? transaction.name}
pfcPrimary={transaction.pfcPrimary}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="font-semibold truncate">{transaction.name}</p>
<p className="text-xs text-muted-foreground">
{transaction.accountName} &middot; {formatDateShort(transaction.date)}
</p>
</div>
</div>

<div className="text-center py-2">
<div className="text-3xl font-semibold tabular-nums">
<AmountDisplay amount={transaction.normalizedAmount} currency={transaction.currency} />
</div>
</div>

<p className="text-sm text-muted-foreground text-center text-balance">
{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?"}
</p>
</div>
);
}
83 changes: 83 additions & 0 deletions src/components/molecules/transfer-review-nudge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Alert variant="warning" className="mb-6 pr-2 sm:pr-44">
<ArrowLeftRight />
<AlertTitle>
{suggestedCount.toLocaleString()}{" "}
{suggestedCount === 1 ? "transaction looks" : "transactions look"} like a transfer
</AlertTitle>
<AlertDescription>
Payments like Zelle or Venmo can be real spending or just money moving between your own
accounts — confirm which before they skew your totals.
</AlertDescription>
<AlertAction className="flex items-center gap-1">
<Link href="/transactions?mode=review-transfers" className={cn(buttonVariants({ size: "sm" }))}>
Review
</Link>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={dismiss}
aria-label="Dismiss until next visit"
>
<X />
</Button>
</AlertAction>
</Alert>
);
}
18 changes: 16 additions & 2 deletions src/components/organisms/transaction-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,13 +23,15 @@ interface TransactionListProps {
nextCursor: string | null;
categories: CategoryGroup[];
filters: TransactionFilters;
suggestedTransfers?: TxnRow[];
}

export function TransactionList({
initialRows,
nextCursor,
categories,
filters,
suggestedTransfers = [],
}: TransactionListProps) {
const router = useRouter();
const isMobile = useIsMobile();
Expand All @@ -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]);

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -174,7 +181,7 @@ export function TransactionList({
</div>

{/* Detail Panel Column */}
{isPanelOpen && !isReviewMode && (
{isPanelOpen && !isReviewMode && !isTransferReviewMode && (
<div
className={cn(
"border-l bg-background",
Expand Down Expand Up @@ -205,6 +212,13 @@ export function TransactionList({
onDone={handleReviewDone}
/>
)}

{isTransferReviewMode && (
<TransferReviewDialog
rows={suggestedTransfers}
onDone={handleTransferReviewDone}
/>
)}
</div>
);
}
Loading
Loading