feat: add transaction split adjustments across multiple categories

Allow users to split a transaction across multiple categories directly
from the transactions table. Split children are hidden from the list
and automatically included in dashboard, report, and budget aggregates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-16 23:51:36 +00:00
parent c7baf85cbb
commit 142c240a00
8 changed files with 513 additions and 16 deletions

View file

@ -0,0 +1,286 @@
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { X, Plus, Trash2 } from "lucide-react";
import type { TransactionRow, Category, SplitChild } from "../../shared/types";
import CategoryCombobox from "../shared/CategoryCombobox";
interface SplitEntry {
category_id: number | null;
amount: string;
description: string;
}
interface Props {
transaction: TransactionRow;
categories: Category[];
onLoadChildren: (parentId: number) => Promise<SplitChild[]>;
onSave: (
parentId: number,
entries: Array<{ category_id: number; amount: number; description: string }>
) => Promise<void>;
onDelete: (parentId: number) => Promise<void>;
onClose: () => void;
}
export default function SplitAdjustmentModal({
transaction,
categories,
onLoadChildren,
onSave,
onDelete,
onClose,
}: Props) {
const { t } = useTranslation();
const [entries, setEntries] = useState<SplitEntry[]>([
{ category_id: null, amount: "", description: "" },
]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const isExpense = transaction.amount < 0;
const absOriginal = Math.abs(transaction.amount);
// Load existing children if this is already split
useEffect(() => {
if (!transaction.is_split) return;
setLoading(true);
onLoadChildren(transaction.id).then((children) => {
// Filter out the offset child (same category as parent)
const splitEntries = children.filter(
(c) => c.category_id !== transaction.category_id || Math.sign(c.amount) === Math.sign(transaction.amount)
);
if (splitEntries.length > 0) {
setEntries(
splitEntries.map((c) => ({
category_id: c.category_id,
amount: Math.abs(c.amount).toFixed(2),
description: c.description,
}))
);
}
setLoading(false);
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const parsedAmounts = useMemo(
() => entries.map((e) => parseFloat(e.amount) || 0),
[entries]
);
const splitTotal = useMemo(
() => parsedAmounts.reduce((s, a) => s + a, 0),
[parsedAmounts]
);
const remainder = +(absOriginal - splitTotal).toFixed(2);
const isValid =
entries.length > 0 &&
entries.every((e) => e.category_id !== null && (parseFloat(e.amount) || 0) > 0) &&
splitTotal > 0 &&
remainder >= 0;
const updateEntry = (index: number, field: keyof SplitEntry, value: string | number | null) => {
setEntries((prev) =>
prev.map((e, i) => (i === index ? { ...e, [field]: value } : e))
);
};
const addEntry = () => {
setEntries((prev) => [...prev, { category_id: null, amount: "", description: "" }]);
};
const removeEntry = (index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
};
const handleSave = async () => {
if (!isValid) return;
setSaving(true);
try {
await onSave(
transaction.id,
entries.map((e) => ({
category_id: e.category_id!,
amount: isExpense
? -Math.abs(parseFloat(e.amount))
: Math.abs(parseFloat(e.amount)),
description: e.description || transaction.description,
}))
);
onClose();
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
setSaving(true);
try {
await onDelete(transaction.id);
onClose();
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--card)] rounded-xl shadow-xl w-full max-w-lg border border-[var(--border)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div>
<h2 className="text-base font-semibold">{t("transactions.splitAdjustment")}</h2>
<p className="text-sm text-[var(--muted-foreground)] truncate max-w-xs">
{transaction.description}
<span
className={`ml-2 font-mono ${
transaction.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
{transaction.amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</p>
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-[var(--muted)] transition-colors"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="px-4 py-3 space-y-3 max-h-[60vh] overflow-y-auto">
{loading ? (
<p className="text-sm text-[var(--muted-foreground)]">{t("common.loading")}</p>
) : (
<>
{/* Original category remainder row */}
<div className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-[var(--muted)] text-sm">
<span className="shrink-0 text-[var(--muted-foreground)]">
{t("transactions.splitBase")}:
</span>
{transaction.category_color && (
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: transaction.category_color }}
/>
)}
<span className="truncate">{transaction.category_name}</span>
<span className="ml-auto font-mono whitespace-nowrap">
{isExpense ? "-" : ""}
{remainder.toFixed(2)}
</span>
</div>
{/* Split entry rows */}
{entries.map((entry, index) => (
<div key={index} className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<CategoryCombobox
categories={categories}
value={entry.category_id}
onChange={(id) => updateEntry(index, "category_id", id)}
placeholder={t("transactions.splitCategory")}
compact
/>
</div>
<input
type="number"
min="0"
step="0.01"
value={entry.amount}
onChange={(e) => updateEntry(index, "amount", e.target.value)}
placeholder={t("transactions.splitAmount")}
className="w-24 px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] text-right font-mono focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
<input
type="text"
value={entry.description}
onChange={(e) => updateEntry(index, "description", e.target.value)}
placeholder={t("transactions.splitDescription")}
className="w-32 px-2 py-1.5 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
<button
onClick={() => removeEntry(index)}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors shrink-0"
>
<Trash2 size={14} />
</button>
</div>
))}
{/* Add row */}
<button
onClick={addEntry}
className="flex items-center gap-1 text-sm text-[var(--primary)] hover:underline"
>
<Plus size={14} />
{t("transactions.splitAddRow")}
</button>
{/* Validation message */}
{remainder < 0 && (
<p className="text-xs text-[var(--negative)]">
{t("transactions.splitTotal")}
</p>
)}
</>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border)]">
<div>
{transaction.is_split && !confirmDelete && (
<button
onClick={() => setConfirmDelete(true)}
className="text-sm text-[var(--negative)] hover:underline"
>
{t("transactions.splitRemove")}
</button>
)}
{confirmDelete && (
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--negative)]">
{t("transactions.splitDeleteConfirm")}
</span>
<button
onClick={handleDelete}
disabled={saving}
className="px-2 py-1 text-xs rounded bg-[var(--negative)] text-white hover:opacity-90 disabled:opacity-50"
>
{t("common.delete")}
</button>
<button
onClick={() => setConfirmDelete(false)}
className="px-2 py-1 text-xs rounded border border-[var(--border)] hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
</div>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={handleSave}
disabled={!isValid || saving}
className="px-4 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{t("common.save")}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,12 +1,14 @@
import { Fragment, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronUp, ChevronDown, MessageSquare, Tag } from "lucide-react";
import { ChevronUp, ChevronDown, MessageSquare, Tag, Split } from "lucide-react";
import type {
TransactionRow,
TransactionSort,
Category,
SplitChild,
} from "../../shared/types";
import CategoryCombobox from "../shared/CategoryCombobox";
import SplitAdjustmentModal from "./SplitAdjustmentModal";
interface TransactionTableProps {
rows: TransactionRow[];
@ -16,6 +18,9 @@ interface TransactionTableProps {
onCategoryChange: (txId: number, categoryId: number | null) => void;
onNotesChange: (txId: number, notes: string) => void;
onAddKeyword: (categoryId: number, keyword: string) => Promise<void>;
onLoadSplitChildren: (parentId: number) => Promise<SplitChild[]>;
onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise<void>;
onDeleteSplit: (parentId: number) => Promise<void>;
}
function SortIcon({
@ -42,6 +47,9 @@ export default function TransactionTable({
onCategoryChange,
onNotesChange,
onAddKeyword,
onLoadSplitChildren,
onSaveSplit,
onDeleteSplit,
}: TransactionTableProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<number | null>(null);
@ -49,6 +57,7 @@ export default function TransactionTable({
const [keywordRowId, setKeywordRowId] = useState<number | null>(null);
const [keywordText, setKeywordText] = useState("");
const [keywordSaved, setKeywordSaved] = useState<number | null>(null);
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
const noCategoryExtra = useMemo(
() => [{ value: "", label: t("transactions.table.noCategory") }],
[t]
@ -155,17 +164,30 @@ export default function TransactionTable({
onExtraSelect={() => onCategoryChange(row.id, null)}
/>
{row.category_id !== null && (
<button
onClick={() => toggleKeyword(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
keywordSaved === row.id
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
>
<Tag size={14} />
</button>
<>
<button
onClick={() => toggleKeyword(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
keywordSaved === row.id
? "text-[var(--positive)]"
: "text-[var(--muted-foreground)]"
}`}
title={keywordSaved === row.id ? t("transactions.keywordAdded") : t("transactions.addKeyword")}
>
<Tag size={14} />
</button>
<button
onClick={() => setSplitRow(row)}
className={`p-1 rounded hover:bg-[var(--muted)] transition-colors shrink-0 ${
row.is_split
? "text-[var(--primary)]"
: "text-[var(--muted-foreground)]"
}`}
title={t("transactions.splitAdjustment")}
>
<Split size={14} />
</button>
</>
)}
</div>
</td>
@ -245,6 +267,16 @@ export default function TransactionTable({
))}
</tbody>
</table>
{splitRow && (
<SplitAdjustmentModal
transaction={splitRow}
categories={categories}
onLoadChildren={onLoadSplitChildren}
onSave={onSaveSplit}
onDelete={onDeleteSplit}
onClose={() => setSplitRow(null)}
/>
)}
</div>
);
}

View file

@ -6,6 +6,7 @@ import type {
TransactionPageResult,
Category,
ImportSource,
SplitChild,
} from "../shared/types";
import {
getTransactionPage,
@ -14,6 +15,9 @@ import {
getAllCategories,
getAllImportSources,
autoCategorizeTransactions,
getSplitChildren,
saveSplitAdjustment,
deleteSplitAdjustment,
} from "../services/transactionService";
import { createKeyword } from "../services/categoryService";
@ -308,6 +312,54 @@ export function useTransactions() {
[]
);
const loadSplitChildren = useCallback(
async (parentId: number): Promise<SplitChild[]> => {
try {
return await getSplitChildren(parentId);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
return [];
}
},
[]
);
const saveSplit = useCallback(
async (
parentId: number,
entries: Array<{ category_id: number; amount: number; description: string }>
) => {
try {
await saveSplitAdjustment(parentId, entries);
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
},
[state.sort, state.page, state.pageSize, fetchData]
);
const deleteSplit = useCallback(
async (parentId: number) => {
try {
await deleteSplitAdjustment(parentId);
fetchData(debouncedFiltersRef.current, state.sort, state.page, state.pageSize);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
},
[state.sort, state.page, state.pageSize, fetchData]
);
return {
state,
setFilter,
@ -317,5 +369,8 @@ export function useTransactions() {
saveNotes,
autoCategorize,
addKeywordToCategory,
loadSplitChildren,
saveSplit,
deleteSplit,
};
}

View file

@ -216,6 +216,16 @@
"addKeyword": "Add keyword",
"keywordAdded": "Keyword added",
"keywordPlaceholder": "Keyword to match...",
"splitAdjustment": "Split adjustment",
"splitBase": "Base",
"splitAdjusted": "Adjusted",
"splitCategory": "Category",
"splitAmount": "Amount",
"splitDescription": "Description",
"splitAddRow": "Add split",
"splitRemove": "Remove split",
"splitTotal": "Total must equal original amount",
"splitDeleteConfirm": "Remove this split adjustment?",
"help": {
"title": "How to use Transactions",
"tips": [

View file

@ -216,6 +216,16 @@
"addKeyword": "Ajouter un mot-clé",
"keywordAdded": "Mot-clé ajouté",
"keywordPlaceholder": "Mot-clé à rechercher...",
"splitAdjustment": "Répartition",
"splitBase": "Base",
"splitAdjusted": "Ajusté",
"splitCategory": "Catégorie",
"splitAmount": "Montant",
"splitDescription": "Description",
"splitAddRow": "Ajouter une répartition",
"splitRemove": "Supprimer la répartition",
"splitTotal": "Le total doit égaler le montant original",
"splitDeleteConfirm": "Supprimer cette répartition ?",
"help": {
"title": "Comment utiliser les Transactions",
"tips": [

View file

@ -10,7 +10,7 @@ import TransactionPagination from "../components/transactions/TransactionPaginat
export default function TransactionsPage() {
const { t } = useTranslation();
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory } =
const { state, setFilter, setSort, setPage, updateCategory, saveNotes, autoCategorize, addKeywordToCategory, loadSplitChildren, saveSplit, deleteSplit } =
useTransactions();
const [resultMessage, setResultMessage] = useState<string | null>(null);
@ -81,6 +81,9 @@ export default function TransactionsPage() {
onCategoryChange={updateCategory}
onNotesChange={saveNotes}
onAddKeyword={addKeywordToCategory}
onLoadSplitChildren={loadSplitChildren}
onSaveSplit={saveSplit}
onDeleteSplit={deleteSplit}
/>
<TransactionPagination

View file

@ -8,6 +8,7 @@ import type {
TransactionPageResult,
Category,
ImportSource,
SplitChild,
} from "../shared/types";
export async function insertBatch(
@ -138,8 +139,10 @@ export async function getTransactionPage(
paramIndex++;
}
const whereSQL =
whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
// Always exclude split children from the transaction list
whereClauses.push(`t.parent_transaction_id IS NULL`);
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
// Map sort column to SQL
const sortColumnMap: Record<string, string> = {
@ -156,7 +159,8 @@ export async function getTransactionPage(
const rowsSQL = `
SELECT t.id, t.date, t.description, t.amount, t.category_id,
c.name AS category_name, c.color AS category_color,
s.name AS source_name, t.notes, t.is_manually_categorized
s.name AS source_name, t.notes, t.is_manually_categorized,
t.is_split
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN import_sources s ON t.source_id = s.id
@ -267,3 +271,90 @@ export async function autoCategorizeTransactions(): Promise<number> {
return count;
}
export async function getSplitChildren(parentId: number): Promise<SplitChild[]> {
const db = await getDb();
return db.select<SplitChild[]>(
`SELECT t.id, t.category_id, c.name AS category_name, c.color AS category_color,
t.amount, t.description
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.parent_transaction_id = $1
ORDER BY t.id`,
[parentId]
);
}
export async function saveSplitAdjustment(
parentId: number,
entries: Array<{ category_id: number; amount: number; description: string }>
): Promise<void> {
const db = await getDb();
// Delete any existing children
await db.execute(
`DELETE FROM transactions WHERE parent_transaction_id = $1`,
[parentId]
);
// Fetch parent transaction
const [parent] = await db.select<Transaction[]>(
`SELECT * FROM transactions WHERE id = $1`,
[parentId]
);
if (!parent) throw new Error("Parent transaction not found");
// Insert each split child
let offsetTotal = 0;
for (const entry of entries) {
await db.execute(
`INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, original_description, parent_transaction_id, is_split)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1)`,
[
parent.date,
entry.description,
entry.amount,
entry.category_id,
parent.source_id ?? null,
parent.file_id ?? null,
parent.original_description ?? "",
parentId,
]
);
offsetTotal += entry.amount;
}
// Insert offset child (cancels the redistributed portion from the original category)
await db.execute(
`INSERT INTO transactions (date, description, amount, category_id, source_id, file_id, original_description, parent_transaction_id, is_split)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1)`,
[
parent.date,
parent.description,
-offsetTotal,
parent.category_id ?? null,
parent.source_id ?? null,
parent.file_id ?? null,
parent.original_description ?? "",
parentId,
]
);
// Mark parent as split
await db.execute(
`UPDATE transactions SET is_split = 1, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[parentId]
);
}
export async function deleteSplitAdjustment(parentId: number): Promise<void> {
const db = await getDb();
await db.execute(
`DELETE FROM transactions WHERE parent_transaction_id = $1`,
[parentId]
);
await db.execute(
`UPDATE transactions SET is_split = 0, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[parentId]
);
}

View file

@ -358,6 +358,16 @@ export interface TransactionRow {
source_name: string | null;
notes: string | null;
is_manually_categorized: boolean;
is_split: boolean;
}
export interface SplitChild {
id: number;
category_id: number | null;
category_name: string | null;
category_color: string | null;
amount: number;
description: string;
}
export interface TransactionFilters {