diff --git a/DOCS.md b/DOCS.md index 3c57447..6c1e3e6 100644 --- a/DOCS.md +++ b/DOCS.md @@ -126,7 +126,7 @@ An account is a place money lives — a bank account, a card, a cash wallet. Eve to exactly one account (transfers touch two). Balances are **derived** from transactions, not stored — there's no "set balance" endpoint. -**`AccountType`:** `checking` · `savings` · `credit_card` · `cash` · `investment` · `other` +**`AccountType`:** `checking` · `debit_card` · `savings` · `credit_card` · `cash` · `investment` · `other` | Method | Path | Description | |---|---|---| diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 772c49e..94b06a4 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -4,6 +4,7 @@ import enum class AccountType(str, enum.Enum): CHECKING = "checking" + DEBIT_CARD = "debit_card" SAVINGS = "savings" CREDIT_CARD = "credit_card" CASH = "cash" diff --git a/backend/app/services/insights_service.py b/backend/app/services/insights_service.py index 8473e19..4da40fc 100644 --- a/backend/app/services/insights_service.py +++ b/backend/app/services/insights_service.py @@ -33,7 +33,7 @@ MAX_LOOKBACK_MONTHS = 24 # Accounts where a big, untouched balance means money isn't working — a # credit card balance isn't "your cash", an investment account is already # invested, and OTHER is too ambiguous to guess at. -_IDLE_CASH_ACCOUNT_TYPES = (AccountType.CHECKING, AccountType.SAVINGS, AccountType.CASH) +_IDLE_CASH_ACCOUNT_TYPES = (AccountType.CHECKING, AccountType.DEBIT_CARD, AccountType.SAVINGS, AccountType.CASH) def _previous_month(year: int, month: int) -> tuple[int, int]: diff --git a/backend/app/services/net_worth_service.py b/backend/app/services/net_worth_service.py index 617cc22..5128f48 100644 --- a/backend/app/services/net_worth_service.py +++ b/backend/app/services/net_worth_service.py @@ -2,7 +2,7 @@ asset class. Design note: "Cash" is not stored anywhere — it's derived live from -Account/Transaction data (checking/savings/cash/investment accounts) so the +Account/Transaction data (checking/debit-card/savings/cash/investment accounts) so the Transactions feature stays the single source of truth for liquid money. Investment accounts count here too: their balance is uninvested/unallocated money sitting on the account, not the market value of what's actually @@ -36,7 +36,13 @@ from app.schemas.net_worth import ( RiskLevelSummary, ) -CASH_ACCOUNT_TYPES = {AccountType.CHECKING, AccountType.SAVINGS, AccountType.CASH, AccountType.INVESTMENT} +CASH_ACCOUNT_TYPES = { + AccountType.CHECKING, + AccountType.DEBIT_CARD, + AccountType.SAVINGS, + AccountType.CASH, + AccountType.INVESTMENT, +} RANGE_DAYS = {"30d": 30, "90d": 90, "1y": 365, "5y": 365 * 5} diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py new file mode 100644 index 0000000..e968d45 --- /dev/null +++ b/backend/tests/test_accounts.py @@ -0,0 +1,34 @@ +"""Account-type behavior shared by the accounts API and financial summaries.""" +from datetime import date + +from httpx import AsyncClient + +from tests.helpers import money, txn_payload + + +async def test_debit_card_is_a_liquid_account_type(client: AsyncClient, categories): + response = await client.post( + "/accounts", + json={"name": "Debit card", "type": "debit_card", "currency": "RUB"}, + ) + + assert response.status_code == 201 + debit_card = response.json() + assert debit_card["type"] == "debit_card" + + transaction = await client.post( + "/transactions", + json=txn_payload( + debit_card["id"], + type="income", + amount="1000.00", + category_id=categories["Salary"]["id"], + date=date.today().isoformat(), + ), + ) + assert transaction.status_code == 201 + + summary = await client.get("/net-worth/summary", params={"range": "all"}) + + assert summary.status_code == 200 + assert money(summary.json()["current"]) == money("1000.00") diff --git a/backend/tests/test_insights.py b/backend/tests/test_insights.py index 2423aa4..fe0e14a 100644 --- a/backend/tests/test_insights.py +++ b/backend/tests/test_insights.py @@ -51,6 +51,25 @@ async def test_idle_cash_ignores_investment_accounts(client: AsyncClient, catego assert "idle_cash" not in await _alert_keys(client) +async def test_idle_cash_includes_debit_cards(client: AsyncClient, categories): + debit_card = await client.post( + "/accounts", json={"name": "Debit card", "type": "debit_card", "currency": "USD"} + ) + debit_card_id = debit_card.json()["id"] + await client.post( + "/transactions", + json=_txn( + debit_card_id, + type="income", + amount="5000.00", + category_id=categories["Salary"]["id"], + date=STALE_DATE, + ), + ) + + assert "idle_cash" in await _alert_keys(client) + + async def test_idle_cash_respects_custom_threshold(client: AsyncClient, account_id, categories): await client.post( "/transactions", diff --git a/frontend/src/components/accounts/AccountFormModal.tsx b/frontend/src/components/accounts/AccountFormModal.tsx index bc26604..cffb528 100644 --- a/frontend/src/components/accounts/AccountFormModal.tsx +++ b/frontend/src/components/accounts/AccountFormModal.tsx @@ -12,7 +12,7 @@ interface AccountFormModalProps { account?: Account | null; } -const ACCOUNT_TYPES: AccountType[] = ["checking", "savings", "credit_card", "cash", "investment", "other"]; +const ACCOUNT_TYPES: AccountType[] = ["checking", "debit_card", "savings", "credit_card", "cash", "investment", "other"]; const EMPTY_FORM = { name: "", type: "checking" as AccountType }; diff --git a/frontend/src/components/accounts/AccountList.tsx b/frontend/src/components/accounts/AccountList.tsx index 1253ad1..164f712 100644 --- a/frontend/src/components/accounts/AccountList.tsx +++ b/frontend/src/components/accounts/AccountList.tsx @@ -12,6 +12,7 @@ interface AccountListProps { const TYPE_ICONS: Record = { checking: Wallet, + debit_card: CreditCard, savings: PiggyBank, credit_card: CreditCard, cash: Banknote, diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index ab5bb98..80559d4 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -424,6 +424,7 @@ const ru = { "account.confirmDelete": "Удалить счёт «{{name}}»?\n\nЭто безвозвратно удалит и ВСЕ транзакции по этому счёту. Если хотите сохранить историю, заархивируйте счёт вместо удаления.", "account.type.checking": "Текущий счёт", + "account.type.debit_card": "Дебетовая карта", "account.type.savings": "Накопительный счёт", "account.type.credit_card": "Кредитная карта", "account.type.cash": "Наличные", @@ -918,6 +919,7 @@ const en: Record = { "account.confirmDelete": "Delete the account “{{name}}”?\n\nThis will permanently delete ALL transactions on this account too. If you want to keep the history, archive the account instead of deleting it.", "account.type.checking": "Checking", + "account.type.debit_card": "Debit card", "account.type.savings": "Savings", "account.type.credit_card": "Credit card", "account.type.cash": "Cash", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7334717..7d44162 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,4 +1,4 @@ -export type AccountType = "checking" | "savings" | "credit_card" | "cash" | "investment" | "other"; +export type AccountType = "checking" | "debit_card" | "savings" | "credit_card" | "cash" | "investment" | "other"; export type CategoryKind = "income" | "expense"; export type TransactionType = "income" | "expense" | "transfer"; export type RecurringFrequency = "weekly" | "monthly" | "yearly";