mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
feat: split a transaction across multiple categories
Один чек часто смешивает несколько категорий трат (гипермаркет: часть — продукты, часть — бытовая химия). Раньше такую транзакцию приходилось огрублять до одной категории или дробить вручную на несколько записей. Теперь у транзакции есть второй режим: 2+ доли (TransactionSplit) с суммами по разным категориям, которые обязаны в точности складываться в сумму транзакции. - Новая модель TransactionSplit + миграция. - split_rule_violation() проверяет инварианты на создании и редактировании. - category_rollup.py — общий helper, которым теперь пользуются Dashboard, отчёт по категориям и бюджеты, учитывая доли сплитов наравне с обычными транзакциями. - Фильтр по категории в списке транзакций находит и сплит-транзакции. - Сплиты попадают в полный бэкап/восстановление БД. - 13 новых backend-тестов. - Фронтенд: переключатель «Разбить на несколько категорий» в форме транзакции с повторяемыми строками и индикатором остатка суммы; строка транзакции в списке показывает все категории сплита.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Label, Select } from "@/components/ui/Input";
|
||||
@@ -8,7 +9,8 @@ import { useCategories } from "@/hooks/useCategories";
|
||||
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||
import type { Category, Tag, Transaction, TransactionInput, TransactionType } from "@/types";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
import type { Category, Tag, Transaction, TransactionInput, TransactionSplitInput, TransactionType } from "@/types";
|
||||
|
||||
interface TransactionFormModalProps {
|
||||
open: boolean;
|
||||
@@ -32,6 +34,25 @@ const EMPTY_FORM = {
|
||||
date: todayIso(),
|
||||
};
|
||||
|
||||
interface SplitRowState {
|
||||
key: string;
|
||||
category_id: string;
|
||||
amount: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
function emptySplitRow(): SplitRowState {
|
||||
return { key: crypto.randomUUID(), category_id: "", amount: "", note: "" };
|
||||
}
|
||||
|
||||
// Cents, not floats — a plain Number sum of "0.10" + "0.20" style amounts can
|
||||
// drift from the transaction total by fractions of a cent, which would
|
||||
// falsely trip the "must add up exactly" check the backend also enforces.
|
||||
function toCents(value: string): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
}
|
||||
|
||||
export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) {
|
||||
const { t, language } = useTranslation();
|
||||
const { data: accounts } = useAccounts();
|
||||
@@ -41,6 +62,8 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
||||
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [splitMode, setSplitMode] = useState(false);
|
||||
const [splitRows, setSplitRows] = useState<SplitRowState[]>([emptySplitRow(), emptySplitRow()]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -58,9 +81,23 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
||||
date: transaction.date,
|
||||
});
|
||||
setTags(transaction.tags);
|
||||
const hasSplits = transaction.splits.length > 0;
|
||||
setSplitMode(hasSplits);
|
||||
setSplitRows(
|
||||
hasSplits
|
||||
? transaction.splits.map((split) => ({
|
||||
key: String(split.id),
|
||||
category_id: split.category_id ? String(split.category_id) : "",
|
||||
amount: split.amount,
|
||||
note: split.note ?? "",
|
||||
}))
|
||||
: [emptySplitRow(), emptySplitRow()]
|
||||
);
|
||||
} else {
|
||||
setForm({ ...EMPTY_FORM, account_id: accounts?.[0] ? String(accounts[0].id) : "" });
|
||||
setTags([]);
|
||||
setSplitMode(false);
|
||||
setSplitRows([emptySplitRow(), emptySplitRow()]);
|
||||
}
|
||||
setError(null);
|
||||
}, [open, transaction, accounts]);
|
||||
@@ -81,6 +118,22 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
||||
|
||||
const isSaving = createTransaction.isPending || updateTransaction.isPending;
|
||||
|
||||
function updateSplitRow(key: string, patch: Partial<SplitRowState>) {
|
||||
setSplitRows((prev) => prev.map((row) => (row.key === key ? { ...row, ...patch } : row)));
|
||||
}
|
||||
|
||||
function addSplitRow() {
|
||||
setSplitRows((prev) => [...prev, emptySplitRow()]);
|
||||
}
|
||||
|
||||
function removeSplitRow(key: string) {
|
||||
setSplitRows((prev) => (prev.length <= 2 ? prev : prev.filter((row) => row.key !== key)));
|
||||
}
|
||||
|
||||
const isSplitEditingNow = form.type !== "transfer" && splitMode;
|
||||
const splitAllocatedCents = splitRows.reduce((sum, row) => sum + toCents(row.amount), 0);
|
||||
const splitRemainingCents = toCents(form.amount) - splitAllocatedCents;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -98,10 +151,44 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
||||
return;
|
||||
}
|
||||
|
||||
// Undefined -> leave the transaction's existing splits untouched on
|
||||
// update, and create a normal single-category transaction. [] -> clears
|
||||
// splits that used to be there (the user turned split mode off, or
|
||||
// switched the transaction to a transfer, which can't carry splits).
|
||||
let splits: TransactionSplitInput[] | undefined;
|
||||
if (isSplitEditingNow) {
|
||||
const filledRows = splitRows.filter((row) => row.category_id || row.amount);
|
||||
if (filledRows.length < 2) {
|
||||
setError(t("transactions.form.errorSplitMinRows"));
|
||||
return;
|
||||
}
|
||||
if (filledRows.some((row) => !row.category_id || !row.amount || Number(row.amount) <= 0)) {
|
||||
setError(t("transactions.form.errorSplitIncomplete"));
|
||||
return;
|
||||
}
|
||||
const allocatedCents = filledRows.reduce((sum, row) => sum + toCents(row.amount), 0);
|
||||
if (allocatedCents !== toCents(form.amount)) {
|
||||
setError(t("transactions.form.errorSplitMismatch"));
|
||||
return;
|
||||
}
|
||||
splits = filledRows.map((row) => ({
|
||||
category_id: Number(row.category_id),
|
||||
amount: row.amount,
|
||||
note: row.note || null,
|
||||
}));
|
||||
} else if (transaction && transaction.splits.length > 0) {
|
||||
splits = [];
|
||||
}
|
||||
|
||||
const payload: TransactionInput = {
|
||||
type: form.type,
|
||||
account_id: Number(form.account_id),
|
||||
category_id: form.type === "transfer" ? null : form.category_id ? Number(form.category_id) : null,
|
||||
category_id:
|
||||
form.type === "transfer" || (splits && splits.length > 0)
|
||||
? null
|
||||
: form.category_id
|
||||
? Number(form.category_id)
|
||||
: null,
|
||||
transfer_account_id: form.type === "transfer" ? Number(form.transfer_account_id) : null,
|
||||
amount: form.amount,
|
||||
description: form.description,
|
||||
@@ -109,6 +196,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
||||
notes: form.notes || null,
|
||||
date: form.date,
|
||||
tag_ids: tags.map((tag) => tag.id),
|
||||
splits,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -135,9 +223,11 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
||||
<Select
|
||||
id="type"
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
setForm((prev) => ({ ...prev, type: event.target.value as TransactionType, category_id: "" }))
|
||||
}
|
||||
onChange={(event) => {
|
||||
const nextType = event.target.value as TransactionType;
|
||||
setForm((prev) => ({ ...prev, type: nextType, category_id: "" }));
|
||||
if (nextType === "transfer") setSplitMode(false);
|
||||
}}
|
||||
>
|
||||
<option value="expense">{t("transactions.form.typeExpense")}</option>
|
||||
<option value="income">{t("transactions.form.typeIncome")}</option>
|
||||
@@ -223,20 +313,106 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label htmlFor="category">{t("transactions.form.categoryLabel")}</Label>
|
||||
<Select
|
||||
id="category"
|
||||
value={form.category_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, category_id: event.target.value }))}
|
||||
>
|
||||
<option value="">{t("transactions.form.noCategory")}</option>
|
||||
{relevantCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.indented ? ` ↳ ` : ""}
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="category">{t("transactions.form.categoryLabel")}</Label>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 text-xs text-series-1 hover:underline"
|
||||
onClick={() => setSplitMode((prev) => !prev)}
|
||||
>
|
||||
{splitMode ? t("transactions.form.splitToggleOff") : t("transactions.form.splitToggle")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!splitMode ? (
|
||||
<Select
|
||||
id="category"
|
||||
value={form.category_id}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, category_id: event.target.value }))}
|
||||
>
|
||||
<option value="">{t("transactions.form.noCategory")}</option>
|
||||
{relevantCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.indented ? ` ↳ ` : ""}
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-text-muted">{t("transactions.form.splitHint")}</p>
|
||||
{splitRows.map((row) => (
|
||||
<div key={row.key} className="space-y-1.5 rounded-lg border border-border bg-surface-1 p-2">
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-2">
|
||||
<Select
|
||||
aria-label={t("transactions.form.splitCategoryPlaceholder")}
|
||||
className="sm:flex-1"
|
||||
value={row.category_id}
|
||||
onChange={(event) => updateSplitRow(row.key, { category_id: event.target.value })}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("transactions.form.splitCategoryPlaceholder")}
|
||||
</option>
|
||||
{relevantCategories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.indented ? ` ↳ ` : ""}
|
||||
{translateCategoryName(category.name)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
className="w-24"
|
||||
placeholder={t("transactions.form.amountLabel")}
|
||||
value={row.amount}
|
||||
onChange={(event) => updateSplitRow(row.key, { amount: event.target.value })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("transactions.form.splitRemoveRow")}
|
||||
onClick={() => removeSplitRow(row.key)}
|
||||
disabled={splitRows.length <= 2}
|
||||
className="shrink-0 rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:opacity-30"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
className="text-xs"
|
||||
placeholder={t("transactions.form.splitNotePlaceholder")}
|
||||
value={row.note}
|
||||
onChange={(event) => updateSplitRow(row.key, { note: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addSplitRow}
|
||||
className="flex items-center gap-1 rounded-md py-1 text-xs text-series-1 hover:underline"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("transactions.form.splitAddRow")}
|
||||
</button>
|
||||
<p className={`text-xs ${splitRemainingCents === 0 ? "text-success" : "text-text-muted"}`}>
|
||||
{splitRemainingCents > 0
|
||||
? t("transactions.form.splitRemainingLabel", {
|
||||
amount: formatCurrency(splitRemainingCents / 100),
|
||||
})
|
||||
: splitRemainingCents < 0
|
||||
? t("transactions.form.splitOverAllocatedLabel", {
|
||||
amount: formatCurrency(Math.abs(splitRemainingCents) / 100),
|
||||
})
|
||||
: t("transactions.form.splitFullyAllocatedLabel")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowLeftRight, CalendarSearch, Pencil, StickyNote, Trash2 } from "lucide-react";
|
||||
import { ArrowLeftRight, CalendarSearch, Pencil, SquareDivide, StickyNote, Trash2 } from "lucide-react";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { getCategoryIcon } from "@/lib/icons";
|
||||
import { formatCurrency, formatTransactionDate } from "@/lib/format";
|
||||
@@ -35,8 +35,14 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
|
||||
{items.map((tx) => {
|
||||
const isTransfer = tx.type === "transfer";
|
||||
const isExpense = tx.type === "expense";
|
||||
const Icon = isTransfer ? ArrowLeftRight : getCategoryIcon(tx.category?.icon);
|
||||
const color = isTransfer ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
|
||||
const isSplit = tx.splits.length > 0;
|
||||
const Icon = isTransfer ? ArrowLeftRight : isSplit ? SquareDivide : getCategoryIcon(tx.category?.icon);
|
||||
const color = isTransfer || isSplit ? "var(--text-muted)" : tx.category?.color ?? "var(--text-muted)";
|
||||
const categoryLabel = isSplit
|
||||
? tx.splits.map((split) => (split.category ? translateCategoryName(split.category.name) : "?")).join(" + ")
|
||||
: tx.category
|
||||
? translateCategoryName(tx.category.name)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li key={tx.id} className="group flex items-center gap-3 py-3">
|
||||
@@ -52,7 +58,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
|
||||
<span className="block truncate text-xs text-text-muted">
|
||||
{formatTransactionDate(tx.date, Boolean(onJumpToMonth))} · {tx.account.name}
|
||||
{isTransfer && tx.transfer_account_id ? ` ${t("transactions.transferSuffix")}` : ""}
|
||||
{tx.category ? ` · ${translateCategoryName(tx.category.name)}` : ""}
|
||||
{categoryLabel ? ` · ${categoryLabel}` : ""}
|
||||
{tx.tags.length > 0 ? ` · ${tx.tags.map((tag) => tag.name).join(", ")}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -272,6 +272,19 @@ const ru = {
|
||||
"transactions.form.errorSelectDestination": "Выберите счёт назначения для перевода",
|
||||
"transactions.form.errorSameAccount": "Счёт назначения должен отличаться от исходного счёта",
|
||||
"transactions.form.saveError": "Не удалось сохранить транзакцию. Проверьте данные и попробуйте снова.",
|
||||
"transactions.form.splitToggle": "Разбить на несколько категорий",
|
||||
"transactions.form.splitToggleOff": "Одна категория",
|
||||
"transactions.form.splitHint": "Одна покупка — несколько категорий, например часть чека — продукты, часть — бытовая химия.",
|
||||
"transactions.form.splitCategoryPlaceholder": "Выберите категорию",
|
||||
"transactions.form.splitNotePlaceholder": "Заметка (необязательно)",
|
||||
"transactions.form.splitAddRow": "Добавить категорию",
|
||||
"transactions.form.splitRemoveRow": "Убрать категорию",
|
||||
"transactions.form.splitRemainingLabel": "Осталось распределить: {{amount}}",
|
||||
"transactions.form.splitOverAllocatedLabel": "Превышение суммы на: {{amount}}",
|
||||
"transactions.form.splitFullyAllocatedLabel": "Вся сумма распределена",
|
||||
"transactions.form.errorSplitMinRows": "Нужно как минимум 2 категории для разбивки",
|
||||
"transactions.form.errorSplitIncomplete": "Выберите категорию и сумму для каждой строки разбивки",
|
||||
"transactions.form.errorSplitMismatch": "Сумма по категориям должна совпадать с суммой транзакции",
|
||||
|
||||
"reports.categoryLabel": "Категория",
|
||||
"reports.expenseGroup": "Расходы",
|
||||
@@ -734,6 +747,19 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"transactions.form.errorSelectDestination": "Select a destination account for the transfer",
|
||||
"transactions.form.errorSameAccount": "The destination account must be different from the source account",
|
||||
"transactions.form.saveError": "Failed to save the transaction. Check the data and try again.",
|
||||
"transactions.form.splitToggle": "Split across multiple categories",
|
||||
"transactions.form.splitToggleOff": "Single category",
|
||||
"transactions.form.splitHint": "One purchase, several categories — e.g. part of a receipt is groceries, part is household chemicals.",
|
||||
"transactions.form.splitCategoryPlaceholder": "Select a category",
|
||||
"transactions.form.splitNotePlaceholder": "Note (optional)",
|
||||
"transactions.form.splitAddRow": "Add category",
|
||||
"transactions.form.splitRemoveRow": "Remove category",
|
||||
"transactions.form.splitRemainingLabel": "Remaining to allocate: {{amount}}",
|
||||
"transactions.form.splitOverAllocatedLabel": "Over-allocated by: {{amount}}",
|
||||
"transactions.form.splitFullyAllocatedLabel": "Fully allocated",
|
||||
"transactions.form.errorSplitMinRows": "Splitting needs at least 2 categories",
|
||||
"transactions.form.errorSplitIncomplete": "Pick a category and an amount for every split row",
|
||||
"transactions.form.errorSplitMismatch": "Split amounts must add up to the transaction amount",
|
||||
|
||||
"reports.categoryLabel": "Category",
|
||||
"reports.expenseGroup": "Expenses",
|
||||
|
||||
@@ -62,6 +62,25 @@ export interface Tag {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// One category's slice of a transaction whose amount is divided across
|
||||
// several categories (one receipt, several kinds of goods) — see
|
||||
// TransactionInput.splits. category is null when the split's category was
|
||||
// since deleted; reading must still work even though creating/editing a
|
||||
// split always requires a live category.
|
||||
export interface TransactionSplit {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
category: Category | null;
|
||||
amount: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface TransactionSplitInput {
|
||||
category_id: number;
|
||||
amount: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
account_id: number;
|
||||
@@ -76,6 +95,7 @@ export interface Transaction {
|
||||
account: Account;
|
||||
category: Category | null;
|
||||
tags: Tag[];
|
||||
splits: TransactionSplit[];
|
||||
}
|
||||
|
||||
export interface TransactionPage {
|
||||
@@ -98,6 +118,11 @@ export interface TransactionInput {
|
||||
// Omitted -> tags untouched on update; sent (even as []) -> replaces the
|
||||
// full tag set. Always sent on create (defaults to []).
|
||||
tag_ids?: number[];
|
||||
// Omitted -> a normal single-category transaction (unchanged). 2+ entries
|
||||
// -> the amount is divided across categories instead, and category_id
|
||||
// above must then be null. On update, omitted leaves existing splits
|
||||
// untouched; sent (even as []) replaces the full split set.
|
||||
splits?: TransactionSplitInput[] | null;
|
||||
}
|
||||
|
||||
export interface RecurringTransaction {
|
||||
|
||||
Reference in New Issue
Block a user