add "debit_card" as a new AccountType (#16)

This commit is contained in:
whaley
2026-09-08 20:03:13 +03:00
committed by GitHub
parent ae89311a8e
commit 12c3a7d79c
10 changed files with 69 additions and 6 deletions

View File

@@ -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 to exactly one account (transfers touch two). Balances are **derived** from transactions, not
stored — there's no "set balance" endpoint. 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 | | Method | Path | Description |
|---|---|---| |---|---|---|

View File

@@ -4,6 +4,7 @@ import enum
class AccountType(str, enum.Enum): class AccountType(str, enum.Enum):
CHECKING = "checking" CHECKING = "checking"
DEBIT_CARD = "debit_card"
SAVINGS = "savings" SAVINGS = "savings"
CREDIT_CARD = "credit_card" CREDIT_CARD = "credit_card"
CASH = "cash" CASH = "cash"

View File

@@ -33,7 +33,7 @@ MAX_LOOKBACK_MONTHS = 24
# Accounts where a big, untouched balance means money isn't working — a # Accounts where a big, untouched balance means money isn't working — a
# credit card balance isn't "your cash", an investment account is already # credit card balance isn't "your cash", an investment account is already
# invested, and OTHER is too ambiguous to guess at. # 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]: def _previous_month(year: int, month: int) -> tuple[int, int]:

View File

@@ -2,7 +2,7 @@
asset class. asset class.
Design note: "Cash" is not stored anywhere — it's derived live from 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. Transactions feature stays the single source of truth for liquid money.
Investment accounts count here too: their balance is uninvested/unallocated Investment accounts count here too: their balance is uninvested/unallocated
money sitting on the account, not the market value of what's actually money sitting on the account, not the market value of what's actually
@@ -36,7 +36,13 @@ from app.schemas.net_worth import (
RiskLevelSummary, 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} RANGE_DAYS = {"30d": 30, "90d": 90, "1y": 365, "5y": 365 * 5}

View File

@@ -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")

View File

@@ -51,6 +51,25 @@ async def test_idle_cash_ignores_investment_accounts(client: AsyncClient, catego
assert "idle_cash" not in await _alert_keys(client) 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): async def test_idle_cash_respects_custom_threshold(client: AsyncClient, account_id, categories):
await client.post( await client.post(
"/transactions", "/transactions",

View File

@@ -12,7 +12,7 @@ interface AccountFormModalProps {
account?: Account | null; 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 }; const EMPTY_FORM = { name: "", type: "checking" as AccountType };

View File

@@ -12,6 +12,7 @@ interface AccountListProps {
const TYPE_ICONS: Record<AccountType, LucideIcon> = { const TYPE_ICONS: Record<AccountType, LucideIcon> = {
checking: Wallet, checking: Wallet,
debit_card: CreditCard,
savings: PiggyBank, savings: PiggyBank,
credit_card: CreditCard, credit_card: CreditCard,
cash: Banknote, cash: Banknote,

View File

@@ -424,6 +424,7 @@ const ru = {
"account.confirmDelete": "account.confirmDelete":
"Удалить счёт «{{name}}»?\n\nЭто безвозвратно удалит и ВСЕ транзакции по этому счёту. Если хотите сохранить историю, заархивируйте счёт вместо удаления.", "Удалить счёт «{{name}}»?\n\nЭто безвозвратно удалит и ВСЕ транзакции по этому счёту. Если хотите сохранить историю, заархивируйте счёт вместо удаления.",
"account.type.checking": "Текущий счёт", "account.type.checking": "Текущий счёт",
"account.type.debit_card": "Дебетовая карта",
"account.type.savings": "Накопительный счёт", "account.type.savings": "Накопительный счёт",
"account.type.credit_card": "Кредитная карта", "account.type.credit_card": "Кредитная карта",
"account.type.cash": "Наличные", "account.type.cash": "Наличные",
@@ -918,6 +919,7 @@ const en: Record<keyof typeof ru, string> = {
"account.confirmDelete": "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.", "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.checking": "Checking",
"account.type.debit_card": "Debit card",
"account.type.savings": "Savings", "account.type.savings": "Savings",
"account.type.credit_card": "Credit card", "account.type.credit_card": "Credit card",
"account.type.cash": "Cash", "account.type.cash": "Cash",

View File

@@ -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 CategoryKind = "income" | "expense";
export type TransactionType = "income" | "expense" | "transfer"; export type TransactionType = "income" | "expense" | "transfer";
export type RecurringFrequency = "weekly" | "monthly" | "yearly"; export type RecurringFrequency = "weekly" | "monthly" | "yearly";