mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 11:06: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:
@@ -62,3 +62,32 @@ async def test_backup_import_rejects_transaction_with_unknown_tag_id(client: Asy
|
||||
|
||||
resp = await client.post("/backup/import", json=payload)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_backup_roundtrip_preserves_transaction_splits(client: AsyncClient, account_id, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
created = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00", "note": "household chemicals"},
|
||||
],
|
||||
),
|
||||
)
|
||||
txn_id = created.json()["id"]
|
||||
|
||||
payload = (await client.get("/backup/export")).json()
|
||||
assert len(payload["transaction_splits"]) == 2
|
||||
|
||||
import_resp = await client.post("/backup/import", json=payload)
|
||||
assert import_resp.status_code == 200, import_resp.text
|
||||
|
||||
refetched = next(t for t in (await client.get("/transactions")).json()["items"] if t["id"] == txn_id)
|
||||
splits_by_note = {s["note"]: s for s in refetched["splits"]}
|
||||
assert splits_by_note["household chemicals"]["category_id"] == shopping
|
||||
assert refetched["category"] is None
|
||||
|
||||
@@ -132,3 +132,28 @@ async def test_a_subcategory_keeps_its_own_budget_separate(client: AsyncClient,
|
||||
assert items[child_id]["is_over_budget"] is True
|
||||
assert money(items[parent_id]["spent"]) == money("900.00")
|
||||
assert items[parent_id]["is_over_budget"] is False
|
||||
|
||||
|
||||
async def test_budget_status_counts_a_split_transactions_share(client: AsyncClient, account_id, categories):
|
||||
"""A receipt split between Groceries and Shopping must count its 70.00
|
||||
slice toward the Groceries budget, not the full 100.00 or nothing."""
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
await client.post("/budgets", json={"category_id": groceries, "monthly_limit": "100.00"})
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=txn_payload(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
date="2026-08-10",
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
resp = await client.get("/budgets/status", params={"year": 2026, "month": 8})
|
||||
item = resp.json()["items"][0]
|
||||
assert money(item["spent"]) == Decimal("70.00")
|
||||
|
||||
@@ -125,3 +125,34 @@ async def test_more_than_eight_expense_categories_roll_up_into_other(client: Asy
|
||||
# The 9th (smallest, index 8) category's 10.00 must be folded into Other,
|
||||
# not silently dropped from the total.
|
||||
assert money(other[0]["amount"]) == Decimal("10.00")
|
||||
|
||||
|
||||
async def test_split_transaction_counts_each_split_toward_its_own_category(
|
||||
client: AsyncClient, account_id, categories
|
||||
):
|
||||
"""A hypermarket receipt split between Groceries and Shopping must show
|
||||
up as two separate slices on the donut, not one lump under whichever
|
||||
category happened to be picked."""
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
date="2026-08-01",
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8})
|
||||
body = resp.json()
|
||||
breakdown = {row["name"]: row for row in body["spending_by_category"]}
|
||||
|
||||
assert money(body["spent"]) == Decimal("100.00")
|
||||
assert money(breakdown["Groceries"]["amount"]) == Decimal("70.00")
|
||||
assert money(breakdown["Shopping"]["amount"]) == Decimal("30.00")
|
||||
|
||||
@@ -401,3 +401,227 @@ async def test_a_transfer_whose_destination_account_was_deleted_can_still_be_lis
|
||||
assert listing.status_code == 200
|
||||
assert txn_id in [item["id"] for item in listing.json()["items"]]
|
||||
assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204
|
||||
|
||||
|
||||
async def test_create_split_transaction_across_categories(client: AsyncClient, account_id, categories):
|
||||
"""The hypermarket-receipt case: one purchase, part groceries, part
|
||||
shopping — category_id stays empty on the row itself, and its splits
|
||||
carry the categorization instead."""
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
resp = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00", "note": "household chemicals"},
|
||||
],
|
||||
),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["category"] is None
|
||||
splits = {s["category"]["name"]: s for s in body["splits"]}
|
||||
assert money(splits["Groceries"]["amount"]) == Decimal("70.00")
|
||||
assert money(splits["Shopping"]["amount"]) == Decimal("30.00")
|
||||
assert splits["Shopping"]["note"] == "household chemicals"
|
||||
|
||||
|
||||
async def test_create_split_rejects_category_id_alongside_splits(client: AsyncClient, account_id, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
resp = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=groceries,
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_create_split_rejects_a_single_split(client: AsyncClient, account_id, categories):
|
||||
resp = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id, amount="100.00", category_id=None, splits=[{"category_id": categories["Groceries"]["id"], "amount": "100.00"}]
|
||||
),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_create_split_rejects_amounts_not_summing_to_total(client: AsyncClient, account_id, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
resp = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "20.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_create_split_rejects_on_transfer(client: AsyncClient, account_id, categories):
|
||||
other = (await client.post("/accounts", json={"name": "Savings", "type": "savings"})).json()
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
resp = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
type="transfer",
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
transfer_account_id=other["id"],
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_update_can_convert_a_transaction_into_a_split_and_back(client: AsyncClient, account_id, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
created = await client.post("/transactions", json=_txn(account_id, amount="100.00", category_id=groceries))
|
||||
txn_id = created.json()["id"]
|
||||
|
||||
split_resp = await client.patch(
|
||||
f"/transactions/{txn_id}",
|
||||
json={
|
||||
"category_id": None,
|
||||
"splits": [
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert split_resp.status_code == 200
|
||||
assert len(split_resp.json()["splits"]) == 2
|
||||
assert split_resp.json()["category"] is None
|
||||
|
||||
back_resp = await client.patch(f"/transactions/{txn_id}", json={"category_id": groceries, "splits": []})
|
||||
assert back_resp.status_code == 200
|
||||
assert back_resp.json()["splits"] == []
|
||||
assert back_resp.json()["category"]["id"] == groceries
|
||||
|
||||
|
||||
async def test_update_rejects_splits_not_summing_to_the_new_amount(client: AsyncClient, account_id, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
created = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
txn_id = created.json()["id"]
|
||||
|
||||
# Splits weren't touched by this patch, but the new amount no longer
|
||||
# matches their sum — the update must still be rejected.
|
||||
resp = await client.patch(f"/transactions/{txn_id}", json={"amount": "50.00"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_update_of_unrelated_field_tolerates_a_split_whose_category_was_deleted(
|
||||
client: AsyncClient, account_id, categories
|
||||
):
|
||||
"""Same lesson as the transfer_account_id/deleted-account case: deleting
|
||||
a category SET NULLs any split that pointed at it, and an edit to some
|
||||
other field on the transaction must not be blocked by that pre-existing
|
||||
state — the split's own category was never re-submitted, so nothing here
|
||||
needs it to still exist."""
|
||||
groceries = categories["Groceries"]["id"]
|
||||
extra = await client.post(
|
||||
"/categories", json={"name": "Deletable", "kind": "expense", "color": "#123456", "sort_order": 99}
|
||||
)
|
||||
extra_id = extra.json()["id"]
|
||||
created = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": extra_id, "amount": "30.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
txn_id = created.json()["id"]
|
||||
assert (await client.delete(f"/categories/{extra_id}")).status_code == 204
|
||||
|
||||
resp = await client.patch(f"/transactions/{txn_id}", json={"description": "renamed"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["description"] == "Renamed"
|
||||
assert resp.json()["splits"][1]["category"] is None
|
||||
|
||||
|
||||
async def test_category_filter_matches_transactions_split_into_that_category(
|
||||
client: AsyncClient, account_id, categories
|
||||
):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
resp = await client.get("/transactions", params={"category_id": shopping})
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
|
||||
async def test_delete_transaction_cascades_its_splits(client: AsyncClient, account_id, categories):
|
||||
groceries = categories["Groceries"]["id"]
|
||||
shopping = categories["Shopping"]["id"]
|
||||
created = await client.post(
|
||||
"/transactions",
|
||||
json=_txn(
|
||||
account_id,
|
||||
amount="100.00",
|
||||
category_id=None,
|
||||
splits=[
|
||||
{"category_id": groceries, "amount": "70.00"},
|
||||
{"category_id": shopping, "amount": "30.00"},
|
||||
],
|
||||
),
|
||||
)
|
||||
txn_id = created.json()["id"]
|
||||
|
||||
assert (await client.delete(f"/transactions/{txn_id}")).status_code == 204
|
||||
|
||||
# If the split rows outlived their parent, this budget/dashboard-style
|
||||
# category filter would still find a (now-orphaned) match.
|
||||
resp = await client.get("/transactions", params={"category_id": groceries})
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
Reference in New Issue
Block a user