mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: idle cash alert and category hierarchy (subcategories)
Two of the four ideas picked from the Maybe/Sure feature audit: - Idle cash: a 5th proactive alert flags a depository account (checking/ savings/cash) sitting at or above a configurable balance with no transaction touching it for a configurable number of days. - Category hierarchy: categories can now have one level of subcategories (Groceries -> Alcohol). Dashboard/report aggregations roll a subcategory's spending into its parent's slice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
"""add idle cash thresholds to app_settings
|
||||||
|
|
||||||
|
Revision ID: 3b7c941bbadf
|
||||||
|
Revises: a71d9f0c3e58
|
||||||
|
Create Date: 2026-08-25 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '3b7c941bbadf'
|
||||||
|
down_revision: Union[str, None] = 'a71d9f0c3e58'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'app_settings',
|
||||||
|
sa.Column('idle_cash_threshold_amount', sa.Numeric(14, 2), nullable=False, server_default='1000'),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'app_settings',
|
||||||
|
sa.Column('idle_cash_threshold_days', sa.Integer(), nullable=False, server_default='60'),
|
||||||
|
)
|
||||||
|
op.alter_column('app_settings', 'idle_cash_threshold_amount', server_default=None)
|
||||||
|
op.alter_column('app_settings', 'idle_cash_threshold_days', server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('app_settings', 'idle_cash_threshold_days')
|
||||||
|
op.drop_column('app_settings', 'idle_cash_threshold_amount')
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""add category parent_id (subcategories)
|
||||||
|
|
||||||
|
Revision ID: 8e2f5a13c9d4
|
||||||
|
Revises: 3b7c941bbadf
|
||||||
|
Create Date: 2026-08-25 10:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '8e2f5a13c9d4'
|
||||||
|
down_revision: Union[str, None] = '3b7c941bbadf'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('categories', sa.Column('parent_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
'fk_categories_parent_id_categories', 'categories', 'categories', ['parent_id'], ['id'], ondelete='SET NULL'
|
||||||
|
)
|
||||||
|
op.create_index('ix_categories_parent_id', 'categories', ['parent_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_categories_parent_id', table_name='categories')
|
||||||
|
op.drop_constraint('fk_categories_parent_id_categories', 'categories', type_='foreignkey')
|
||||||
|
op.drop_column('categories', 'parent_id')
|
||||||
@@ -10,6 +10,21 @@ from app.schemas.category import CategoryCreate, CategoryRead, CategoryUpdate
|
|||||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_parent(session: AsyncSession, parent_id: int, kind: CategoryKind, category_id: int | None) -> None:
|
||||||
|
"""Subcategories are one level deep only: a parent must itself be
|
||||||
|
top-level, and must share the child's kind (an expense category can't
|
||||||
|
nest under an income one, or vice versa)."""
|
||||||
|
if parent_id == category_id:
|
||||||
|
raise HTTPException(status_code=400, detail="A category cannot be its own parent")
|
||||||
|
parent = await session.get(Category, parent_id)
|
||||||
|
if parent is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Parent category not found")
|
||||||
|
if parent.parent_id is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="Subcategories can only be one level deep")
|
||||||
|
if parent.kind != kind:
|
||||||
|
raise HTTPException(status_code=400, detail="A subcategory must have the same kind as its parent")
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[CategoryRead])
|
@router.get("", response_model=list[CategoryRead])
|
||||||
async def list_categories(
|
async def list_categories(
|
||||||
kind: CategoryKind | None = None, session: AsyncSession = Depends(get_session)
|
kind: CategoryKind | None = None, session: AsyncSession = Depends(get_session)
|
||||||
@@ -23,6 +38,8 @@ async def list_categories(
|
|||||||
|
|
||||||
@router.post("", response_model=CategoryRead, status_code=201)
|
@router.post("", response_model=CategoryRead, status_code=201)
|
||||||
async def create_category(payload: CategoryCreate, session: AsyncSession = Depends(get_session)) -> Category:
|
async def create_category(payload: CategoryCreate, session: AsyncSession = Depends(get_session)) -> Category:
|
||||||
|
if payload.parent_id is not None:
|
||||||
|
await _validate_parent(session, payload.parent_id, payload.kind, category_id=None)
|
||||||
category = Category(**payload.model_dump(), is_default=False)
|
category = Category(**payload.model_dump(), is_default=False)
|
||||||
session.add(category)
|
session.add(category)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -37,7 +54,15 @@ async def update_category(
|
|||||||
category = await session.get(Category, category_id)
|
category = await session.get(Category, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise HTTPException(status_code=404, detail="Category not found")
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
if "parent_id" in updates and updates["parent_id"] is not None:
|
||||||
|
await _validate_parent(session, updates["parent_id"], category.kind, category_id=category_id)
|
||||||
|
has_children = (
|
||||||
|
await session.execute(select(Category.id).where(Category.parent_id == category_id).limit(1))
|
||||||
|
).first()
|
||||||
|
if has_children is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="A category with subcategories cannot become a subcategory itself")
|
||||||
|
for field, value in updates.items():
|
||||||
setattr(category, field, value)
|
setattr(category, field, value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(category)
|
await session.refresh(category)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""A spending/income category, colored so it maps 1:1 to a dashboard chart slot."""
|
"""A spending/income category, colored so it maps 1:1 to a dashboard chart slot."""
|
||||||
from sqlalchemy import Boolean, Enum, Integer, String
|
from sqlalchemy import Boolean, Enum, ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -22,5 +22,12 @@ class Category(Base):
|
|||||||
# Fixed slot ordering keeps the donut chart's category order stable across renders.
|
# Fixed slot ordering keeps the donut chart's category order stable across renders.
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
# Self-referential, one level deep only (routes/categories.py rejects a
|
||||||
|
# parent that itself has a parent) — a subcategory ("Alcohol" under
|
||||||
|
# "Groceries"). SET NULL so deleting a parent turns its children back
|
||||||
|
# into top-level categories instead of cascading the delete.
|
||||||
|
parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
transactions: Mapped[list["Transaction"]] = relationship(back_populates="category")
|
transactions: Mapped[list["Transaction"]] = relationship(back_populates="category")
|
||||||
|
parent: Mapped["Category | None"] = relationship(remote_side=[id], back_populates="children")
|
||||||
|
children: Mapped[list["Category"]] = relationship(back_populates="parent")
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
single-row table (id is always 1). Primary display currency (see UPDATES.md
|
single-row table (id is always 1). Primary display currency (see UPDATES.md
|
||||||
for why this doesn't do currency conversion) and the proactive-alert
|
for why this doesn't do currency conversion) and the proactive-alert
|
||||||
thresholds consumed by services/insights_service.py."""
|
thresholds consumed by services/insights_service.py."""
|
||||||
from sqlalchemy import Integer, String
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, Numeric, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
@@ -21,3 +23,9 @@ class AppSettings(Base):
|
|||||||
# tiers before insights_service.py raises risky_allocation_exceeded —
|
# tiers before insights_service.py raises risky_allocation_exceeded —
|
||||||
# the classic "80% at zero risk, 20% at most exposed" rule.
|
# the classic "80% at zero risk, 20% at most exposed" rule.
|
||||||
risky_allocation_threshold_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
|
risky_allocation_threshold_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
|
||||||
|
# A depository account (checking/savings/cash) sitting at or above this
|
||||||
|
# balance with no transaction touching it in idle_cash_threshold_days
|
||||||
|
# raises insights_service.py's idle_cash alert — money that isn't
|
||||||
|
# working. In the app's display currency (see `currency` above).
|
||||||
|
idle_cash_threshold_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("1000"))
|
||||||
|
idle_cash_threshold_days: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class CategoryBackup(BaseModel):
|
|||||||
color: str
|
color: str
|
||||||
sort_order: int
|
sort_order: int
|
||||||
is_default: bool
|
is_default: bool
|
||||||
|
# Defaulted so a backup exported before subcategories existed still
|
||||||
|
# imports cleanly under the same format version.
|
||||||
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class TransactionBackup(BaseModel):
|
class TransactionBackup(BaseModel):
|
||||||
@@ -136,6 +139,10 @@ class AppSettingsBackup(BaseModel):
|
|||||||
# Defaulted so a backup exported before this threshold existed still
|
# Defaulted so a backup exported before this threshold existed still
|
||||||
# imports cleanly under the same format version.
|
# imports cleanly under the same format version.
|
||||||
risky_allocation_threshold_percent: int = 20
|
risky_allocation_threshold_percent: int = 20
|
||||||
|
# Defaulted so a backup exported before idle-cash thresholds existed
|
||||||
|
# still imports cleanly under the same format version.
|
||||||
|
idle_cash_threshold_amount: Decimal = Decimal("1000")
|
||||||
|
idle_cash_threshold_days: int = 60
|
||||||
|
|
||||||
|
|
||||||
class BackupPayload(BaseModel):
|
class BackupPayload(BaseModel):
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ class CategoryBase(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$")
|
color: str = Field(pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
# Subcategory parent, one level deep only — routes/categories.py rejects
|
||||||
|
# a parent that itself already has a parent.
|
||||||
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class CategoryCreate(CategoryBase):
|
class CategoryCreate(CategoryBase):
|
||||||
@@ -20,6 +23,10 @@ class CategoryUpdate(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
|
color: str | None = Field(default=None, pattern=r"^#[0-9a-fA-F]{6}$")
|
||||||
sort_order: int | None = None
|
sort_order: int | None = None
|
||||||
|
# The route uses exclude_unset=True, so sending parent_id explicitly as
|
||||||
|
# null (as opposed to omitting the field) is what turns a subcategory
|
||||||
|
# back into a top-level category.
|
||||||
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class CategoryRead(CategoryBase):
|
class CategoryRead(CategoryBase):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -6,6 +8,8 @@ class AppSettingsRead(BaseModel):
|
|||||||
negative_cash_flow_threshold_months: int
|
negative_cash_flow_threshold_months: int
|
||||||
net_worth_decline_threshold_months: int
|
net_worth_decline_threshold_months: int
|
||||||
risky_allocation_threshold_percent: int
|
risky_allocation_threshold_percent: int
|
||||||
|
idle_cash_threshold_amount: Decimal
|
||||||
|
idle_cash_threshold_days: int
|
||||||
|
|
||||||
|
|
||||||
class AppSettingsUpdate(BaseModel):
|
class AppSettingsUpdate(BaseModel):
|
||||||
@@ -24,3 +28,7 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
# Max % of capital allowed in medium/high risk tiers (the 80/20 rule's
|
# Max % of capital allowed in medium/high risk tiers (the 80/20 rule's
|
||||||
# "20% at most exposed" half) before risky_allocation_exceeded fires.
|
# "20% at most exposed" half) before risky_allocation_exceeded fires.
|
||||||
risky_allocation_threshold_percent: int | None = Field(default=None, ge=1, le=100)
|
risky_allocation_threshold_percent: int | None = Field(default=None, ge=1, le=100)
|
||||||
|
# Balance (in the app's display currency) and days of no activity a
|
||||||
|
# depository account needs to hit before idle_cash fires.
|
||||||
|
idle_cash_threshold_amount: Decimal | None = Field(default=None, ge=0, max_digits=14, decimal_places=2)
|
||||||
|
idle_cash_threshold_days: int | None = Field(default=None, ge=1, le=365)
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ def _validate_references(payload: BackupPayload) -> None:
|
|||||||
category_ids = {row.id for row in payload.categories}
|
category_ids = {row.id for row in payload.categories}
|
||||||
asset_ids = {row.id for row in payload.assets}
|
asset_ids = {row.id for row in payload.assets}
|
||||||
|
|
||||||
|
for c in payload.categories:
|
||||||
|
if c.parent_id is not None and c.parent_id not in category_ids:
|
||||||
|
raise HTTPException(400, f"Category {c.id} references unknown parent_id {c.parent_id}")
|
||||||
|
|
||||||
for t in payload.transactions:
|
for t in payload.transactions:
|
||||||
if t.account_id not in account_ids:
|
if t.account_id not in account_ids:
|
||||||
raise HTTPException(400, f"Transaction {t.id} references unknown account_id {t.account_id}")
|
raise HTTPException(400, f"Transaction {t.id} references unknown account_id {t.account_id}")
|
||||||
@@ -145,9 +149,14 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
|
|||||||
await session.execute(delete(Category))
|
await session.execute(delete(Category))
|
||||||
await session.execute(delete(Account))
|
await session.execute(delete(Account))
|
||||||
|
|
||||||
# Parents before children.
|
# Parents before children. Categories are additionally self-referential
|
||||||
|
# (parent_id points at another row in the same table) — sort
|
||||||
|
# top-level categories first so a subcategory's FK is never inserted
|
||||||
|
# ahead of the row it points to.
|
||||||
|
categories_in_order = sorted(payload.categories, key=lambda row: row.parent_id is not None)
|
||||||
|
|
||||||
session.add_all(Account(**row.model_dump()) for row in payload.accounts)
|
session.add_all(Account(**row.model_dump()) for row in payload.accounts)
|
||||||
session.add_all(Category(**row.model_dump()) for row in payload.categories)
|
session.add_all(Category(**row.model_dump()) for row in categories_in_order)
|
||||||
session.add_all(Asset(**row.model_dump()) for row in payload.assets)
|
session.add_all(Asset(**row.model_dump()) for row in payload.assets)
|
||||||
session.add_all(Transaction(**row.model_dump()) for row in payload.transactions)
|
session.add_all(Transaction(**row.model_dump()) for row in payload.transactions)
|
||||||
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
|
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.enums import TransactionType
|
from app.models.enums import TransactionType
|
||||||
@@ -37,22 +38,33 @@ async def get_dashboard_summary(session: AsyncSession, year: int, month: int) ->
|
|||||||
spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
|
spent = totals.get(TransactionType.EXPENSE, Decimal("0"))
|
||||||
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
|
transferred_out = totals.get(TransactionType.TRANSFER, Decimal("0"))
|
||||||
|
|
||||||
|
# A subcategory's spending rolls up into its parent's slice — Parent is a
|
||||||
|
# self-join to resolve each transaction's *top-level* category, falling
|
||||||
|
# back to the transaction's own category when it has no parent.
|
||||||
|
Parent = aliased(Category)
|
||||||
|
effective_id = func.coalesce(Category.parent_id, Category.id)
|
||||||
|
effective_name = func.coalesce(Parent.name, Category.name)
|
||||||
|
effective_color = func.coalesce(Parent.color, Category.color)
|
||||||
|
effective_icon = func.coalesce(Parent.icon, Category.icon)
|
||||||
|
effective_sort = func.coalesce(Parent.sort_order, Category.sort_order)
|
||||||
|
|
||||||
breakdown_stmt = (
|
breakdown_stmt = (
|
||||||
select(
|
select(
|
||||||
Category.id,
|
effective_id.label("category_id"),
|
||||||
Category.name,
|
effective_name.label("name"),
|
||||||
Category.color,
|
effective_color.label("color"),
|
||||||
Category.icon,
|
effective_icon.label("icon"),
|
||||||
func.coalesce(func.sum(Transaction.amount), 0).label("amount"),
|
func.coalesce(func.sum(Transaction.amount), 0).label("amount"),
|
||||||
)
|
)
|
||||||
.join(Category, Category.id == Transaction.category_id)
|
.join(Category, Category.id == Transaction.category_id)
|
||||||
|
.outerjoin(Parent, Parent.id == Category.parent_id)
|
||||||
.where(
|
.where(
|
||||||
Transaction.date >= start,
|
Transaction.date >= start,
|
||||||
Transaction.date <= end,
|
Transaction.date <= end,
|
||||||
Transaction.type == TransactionType.EXPENSE,
|
Transaction.type == TransactionType.EXPENSE,
|
||||||
)
|
)
|
||||||
.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order)
|
.group_by(effective_id, effective_name, effective_color, effective_icon, effective_sort)
|
||||||
.order_by(func.sum(Transaction.amount).desc(), Category.sort_order)
|
.order_by(func.sum(Transaction.amount).desc(), effective_sort)
|
||||||
)
|
)
|
||||||
breakdown_result = await session.execute(breakdown_stmt)
|
breakdown_result = await session.execute(breakdown_stmt)
|
||||||
rows = breakdown_result.all()
|
rows = breakdown_result.all()
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
"""Proactive early-warning checks, computed from data that already exists.
|
"""Proactive early-warning checks, computed from data that already exists.
|
||||||
Four signals: sustained negative monthly cash flow, a sustained net-worth
|
Five signals: sustained negative monthly cash flow, a sustained net-worth
|
||||||
decline, one or more over-budget categories, and too much capital in
|
decline, one or more over-budget categories, too much capital in medium/high
|
||||||
medium/high risk tiers (the "80% at zero risk, 20% at most exposed" rule).
|
risk tiers (the "80% at zero risk, 20% at most exposed" rule), and cash
|
||||||
The first two only look at fully-elapsed calendar months, so a same-month
|
sitting idle in a depository account. The first two only look at
|
||||||
false alarm (rent already paid, salary not landed yet) never fires — their
|
fully-elapsed calendar months, so a same-month false alarm (rent already
|
||||||
"sustained for how long" thresholds, and the risk-allocation percentage, are
|
paid, salary not landed yet) never fires — their "sustained for how long"
|
||||||
|
thresholds, the risk-allocation percentage, and the idle-cash amount/days are
|
||||||
all user-configurable (Settings page), stored on AppSettings, see
|
all user-configurable (Settings page), stored on AppSettings, see
|
||||||
get_or_create_app_settings. The budget check is the opposite: it deliberately
|
get_or_create_app_settings. The budget check is the opposite: it deliberately
|
||||||
looks at the CURRENT, still-in-progress month, since the whole point is to
|
looks at the CURRENT, still-in-progress month, since the whole point is to
|
||||||
catch overspending while there's still time to react.
|
catch overspending while there's still time to react.
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
from collections import defaultdict
|
||||||
|
from datetime import date, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.account import Account
|
||||||
|
from app.models.enums import AccountType, TransactionType
|
||||||
|
from app.models.transaction import Transaction
|
||||||
from app.schemas.insights import AlertsResponse, FinancialAlert
|
from app.schemas.insights import AlertsResponse, FinancialAlert
|
||||||
from app.schemas.net_worth import NetWorthSummary
|
from app.schemas.net_worth import NetWorthSummary
|
||||||
from app.services.budget_service import get_budget_status
|
from app.services.budget_service import get_budget_status
|
||||||
@@ -24,6 +30,11 @@ from app.services.settings_service import get_or_create_app_settings
|
|||||||
|
|
||||||
MAX_LOOKBACK_MONTHS = 24
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _previous_month(year: int, month: int) -> tuple[int, int]:
|
def _previous_month(year: int, month: int) -> tuple[int, int]:
|
||||||
return (year - 1, 12) if month == 1 else (year, month - 1)
|
return (year - 1, 12) if month == 1 else (year, month - 1)
|
||||||
@@ -68,6 +79,55 @@ def _net_worth_decline_streak(summary: NetWorthSummary) -> int:
|
|||||||
return streak
|
return streak
|
||||||
|
|
||||||
|
|
||||||
|
async def _idle_cash_account_count(session: AsyncSession, threshold_amount: Decimal, threshold_days: int) -> int:
|
||||||
|
eligible_ids = set(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Account.id).where(
|
||||||
|
Account.is_archived.is_(False), Account.type.in_(_IDLE_CASH_ACCOUNT_TYPES)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not eligible_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Same derivation account_service._account_balances uses — balance is
|
||||||
|
# never stored, only ever summed from the full transaction history — plus
|
||||||
|
# tracking the most recent date that touched each account along the way.
|
||||||
|
rows = await session.execute(
|
||||||
|
select(Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id, Transaction.date)
|
||||||
|
)
|
||||||
|
balances: dict[int, Decimal] = defaultdict(Decimal)
|
||||||
|
last_activity: dict[int, date] = {}
|
||||||
|
|
||||||
|
def touch(account_id: int | None, tx_date: date) -> None:
|
||||||
|
if account_id in eligible_ids and (account_id not in last_activity or tx_date > last_activity[account_id]):
|
||||||
|
last_activity[account_id] = tx_date
|
||||||
|
|
||||||
|
for tx_type, amount, account_id, transfer_account_id, tx_date in rows.all():
|
||||||
|
if tx_type == TransactionType.INCOME:
|
||||||
|
balances[account_id] += amount
|
||||||
|
elif tx_type == TransactionType.EXPENSE:
|
||||||
|
balances[account_id] -= amount
|
||||||
|
elif tx_type == TransactionType.TRANSFER:
|
||||||
|
balances[account_id] -= amount
|
||||||
|
if transfer_account_id is not None:
|
||||||
|
balances[transfer_account_id] += amount
|
||||||
|
touch(account_id, tx_date)
|
||||||
|
touch(transfer_account_id, tx_date)
|
||||||
|
|
||||||
|
cutoff = date.today() - timedelta(days=threshold_days)
|
||||||
|
return sum(
|
||||||
|
1
|
||||||
|
for account_id in eligible_ids
|
||||||
|
if balances.get(account_id, Decimal("0")) >= threshold_amount
|
||||||
|
and last_activity.get(account_id, cutoff) <= cutoff
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_financial_alerts(session: AsyncSession) -> AlertsResponse:
|
async def get_financial_alerts(session: AsyncSession) -> AlertsResponse:
|
||||||
settings = await get_or_create_app_settings(session)
|
settings = await get_or_create_app_settings(session)
|
||||||
alerts: list[FinancialAlert] = []
|
alerts: list[FinancialAlert] = []
|
||||||
@@ -116,4 +176,16 @@ async def get_financial_alerts(session: AsyncSession) -> AlertsResponse:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
idle_cash_count = await _idle_cash_account_count(
|
||||||
|
session, settings.idle_cash_threshold_amount, settings.idle_cash_threshold_days
|
||||||
|
)
|
||||||
|
if idle_cash_count > 0:
|
||||||
|
alerts.append(
|
||||||
|
FinancialAlert(
|
||||||
|
key="idle_cash",
|
||||||
|
severity="warning",
|
||||||
|
params={"count": idle_cash_count, "days": settings.idle_cash_threshold_days},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return AlertsResponse(alerts=alerts)
|
return AlertsResponse(alerts=alerts)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from decimal import Decimal
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import extract, func, select
|
from sqlalchemy import extract, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.enums import CategoryKind, TransactionType
|
from app.models.enums import CategoryKind, TransactionType
|
||||||
@@ -33,8 +34,18 @@ async def get_category_spending_report(
|
|||||||
if category is None:
|
if category is None:
|
||||||
raise HTTPException(status_code=404, detail="Category not found")
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
|
# A top-level category's own report folds in its subcategories' spending
|
||||||
|
# too (same rollup as the Dashboard breakdown); a subcategory picked
|
||||||
|
# directly shows just its own transactions — there's nothing beneath it.
|
||||||
|
category_ids: list[int] = [category_id]
|
||||||
|
if category.parent_id is None:
|
||||||
|
child_ids = (
|
||||||
|
await session.execute(select(Category.id).where(Category.parent_id == category_id))
|
||||||
|
).scalars().all()
|
||||||
|
category_ids.extend(child_ids)
|
||||||
|
|
||||||
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
|
bounds_stmt = select(func.min(Transaction.date), func.max(Transaction.date)).where(
|
||||||
Transaction.category_id == category_id
|
Transaction.category_id.in_(category_ids)
|
||||||
)
|
)
|
||||||
if start_date:
|
if start_date:
|
||||||
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
|
bounds_stmt = bounds_stmt.where(Transaction.date >= start_date)
|
||||||
@@ -68,7 +79,7 @@ async def get_category_spending_report(
|
|||||||
func.count(Transaction.id).label("cnt"),
|
func.count(Transaction.id).label("cnt"),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Transaction.category_id == category_id,
|
Transaction.category_id.in_(category_ids),
|
||||||
Transaction.date >= effective_start,
|
Transaction.date >= effective_start,
|
||||||
Transaction.date <= effective_end,
|
Transaction.date <= effective_end,
|
||||||
)
|
)
|
||||||
@@ -115,24 +126,34 @@ async def get_category_ranking_report(
|
|||||||
arbitrary period — "which category costs the most" across the whole
|
arbitrary period — "which category costs the most" across the whole
|
||||||
range, unlike the month-scoped Dashboard breakdown or the
|
range, unlike the month-scoped Dashboard breakdown or the
|
||||||
single-category detail above."""
|
single-category detail above."""
|
||||||
|
# Same subcategory-into-parent rollup as the Dashboard breakdown — see
|
||||||
|
# dashboard_service.get_dashboard_summary.
|
||||||
|
Parent = aliased(Category)
|
||||||
|
effective_id = func.coalesce(Category.parent_id, Category.id)
|
||||||
|
effective_name = func.coalesce(Parent.name, Category.name)
|
||||||
|
effective_color = func.coalesce(Parent.color, Category.color)
|
||||||
|
effective_icon = func.coalesce(Parent.icon, Category.icon)
|
||||||
|
effective_sort = func.coalesce(Parent.sort_order, Category.sort_order)
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(
|
select(
|
||||||
Category.id,
|
effective_id.label("category_id"),
|
||||||
Category.name,
|
effective_name.label("name"),
|
||||||
Category.color,
|
effective_color.label("color"),
|
||||||
Category.icon,
|
effective_icon.label("icon"),
|
||||||
func.sum(Transaction.amount).label("amount"),
|
func.sum(Transaction.amount).label("amount"),
|
||||||
func.count(Transaction.id).label("cnt"),
|
func.count(Transaction.id).label("cnt"),
|
||||||
)
|
)
|
||||||
.join(Category, Category.id == Transaction.category_id)
|
.join(Category, Category.id == Transaction.category_id)
|
||||||
|
.outerjoin(Parent, Parent.id == Category.parent_id)
|
||||||
.where(Category.kind == kind, Transaction.type == _KIND_TO_TRANSACTION_TYPE[kind])
|
.where(Category.kind == kind, Transaction.type == _KIND_TO_TRANSACTION_TYPE[kind])
|
||||||
)
|
)
|
||||||
if start_date:
|
if start_date:
|
||||||
stmt = stmt.where(Transaction.date >= start_date)
|
stmt = stmt.where(Transaction.date >= start_date)
|
||||||
if end_date:
|
if end_date:
|
||||||
stmt = stmt.where(Transaction.date <= end_date)
|
stmt = stmt.where(Transaction.date <= end_date)
|
||||||
stmt = stmt.group_by(Category.id, Category.name, Category.color, Category.icon, Category.sort_order)
|
stmt = stmt.group_by(effective_id, effective_name, effective_color, effective_icon, effective_sort)
|
||||||
stmt = stmt.order_by(func.sum(Transaction.amount).desc(), Category.sort_order)
|
stmt = stmt.order_by(func.sum(Transaction.amount).desc(), effective_sort)
|
||||||
|
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await session.execute(stmt)).all()
|
||||||
total_amount = sum((row.amount for row in rows), Decimal("0"))
|
total_amount = sum((row.amount for row in rows), Decimal("0"))
|
||||||
|
|||||||
69
backend/tests/test_categories.py
Normal file
69
backend/tests/test_categories.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Category subcategories (parent_id): creation rules and the one-level-deep
|
||||||
|
invariant enforced in api/routes/categories.py.
|
||||||
|
"""
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_subcategory_under_top_level_category(client: AsyncClient, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
resp = await client.post(
|
||||||
|
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["parent_id"] == groceries
|
||||||
|
|
||||||
|
|
||||||
|
async def test_subcategory_cannot_have_a_different_kind_than_its_parent(client: AsyncClient, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
resp = await client.post(
|
||||||
|
"/categories", json={"name": "Bad", "kind": "income", "color": "#e34948", "parent_id": groceries}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_subcategories_are_only_one_level_deep(client: AsyncClient, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
child = await client.post(
|
||||||
|
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries}
|
||||||
|
)
|
||||||
|
child_id = child.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/categories", json={"name": "Wine", "kind": "expense", "color": "#e34948", "parent_id": child_id}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_category_cannot_be_its_own_parent(client: AsyncClient, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
resp = await client.patch(f"/categories/{groceries}", json={"parent_id": groceries})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_category_with_subcategories_cannot_itself_become_a_subcategory(client: AsyncClient, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
dining = categories["Dining Out"]["id"]
|
||||||
|
await client.post(
|
||||||
|
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries}
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = await client.patch(f"/categories/{groceries}", json={"parent_id": dining})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deleting_a_parent_leaves_children_as_top_level(client: AsyncClient, categories):
|
||||||
|
# A default category can't be deleted at all (separate rule) — use a
|
||||||
|
# fresh, non-default parent so this test isolates the parent/child rule.
|
||||||
|
parent = await client.post("/categories", json={"name": "Custom Parent", "kind": "expense", "color": "#e34948"})
|
||||||
|
parent_id = parent.json()["id"]
|
||||||
|
created = await client.post(
|
||||||
|
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": parent_id}
|
||||||
|
)
|
||||||
|
child_id = created.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.delete(f"/categories/{parent_id}")
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
refetched = await client.get("/categories")
|
||||||
|
child = next(c for c in refetched.json() if c["id"] == child_id)
|
||||||
|
assert child["parent_id"] is None
|
||||||
@@ -81,6 +81,23 @@ async def test_spending_by_category_amounts_and_percent(client: AsyncClient, acc
|
|||||||
assert breakdown["Dining Out"]["percent"] == 25.0
|
assert breakdown["Dining Out"]["percent"] == 25.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_subcategory_spending_rolls_up_into_its_parent(client: AsyncClient, account_id, categories):
|
||||||
|
groceries = categories["Groceries"]["id"]
|
||||||
|
alcohol = await client.post(
|
||||||
|
"/categories", json={"name": "Alcohol", "kind": "expense", "color": "#e34948", "parent_id": groceries}
|
||||||
|
)
|
||||||
|
alcohol_id = alcohol.json()["id"]
|
||||||
|
|
||||||
|
await client.post("/transactions", json=_txn(account_id, amount="60.00", category_id=groceries, date="2026-08-01"))
|
||||||
|
await client.post("/transactions", json=_txn(account_id, amount="15.00", category_id=alcohol_id, date="2026-08-02"))
|
||||||
|
|
||||||
|
resp = await client.get("/dashboard/summary", params={"year": 2026, "month": 8})
|
||||||
|
breakdown = {row["name"]: row for row in resp.json()["spending_by_category"]}
|
||||||
|
|
||||||
|
assert "Alcohol" not in breakdown
|
||||||
|
assert money(breakdown["Groceries"]["amount"]) == Decimal("75.00")
|
||||||
|
|
||||||
|
|
||||||
async def test_more_than_eight_expense_categories_roll_up_into_other(client: AsyncClient, account_id, categories):
|
async def test_more_than_eight_expense_categories_roll_up_into_other(client: AsyncClient, account_id, categories):
|
||||||
expense_categories = [c for c in categories.values() if c["kind"] == "expense"]
|
expense_categories = [c for c in categories.values() if c["kind"] == "expense"]
|
||||||
# The default seed only ships 8 expense categories (see app/db/seed.py) —
|
# The default seed only ships 8 expense categories (see app/db/seed.py) —
|
||||||
|
|||||||
64
backend/tests/test_insights.py
Normal file
64
backend/tests/test_insights.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Proactive alerts (services/insights_service.py): only the idle-cash
|
||||||
|
signal is covered here — the other three (negative cash flow, net worth
|
||||||
|
decline, over-budget) predate this test file and aren't touched by this
|
||||||
|
change.
|
||||||
|
"""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from tests.helpers import txn_payload as _txn
|
||||||
|
|
||||||
|
STALE_DATE = (date.today() - timedelta(days=400)).isoformat()
|
||||||
|
RECENT_DATE = date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
async def _alert_keys(client: AsyncClient) -> set[str]:
|
||||||
|
resp = await client.get("/insights/alerts")
|
||||||
|
return {alert["key"] for alert in resp.json()["alerts"]}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_idle_cash_flags_a_stale_large_balance(client: AsyncClient, account_id, categories):
|
||||||
|
await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(account_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_ignores_a_recently_touched_account(client: AsyncClient, account_id, categories):
|
||||||
|
await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(account_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE),
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(account_id, type="expense", amount="1.00", category_id=categories["Groceries"]["id"], date=RECENT_DATE),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "idle_cash" not in await _alert_keys(client)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_idle_cash_ignores_investment_accounts(client: AsyncClient, categories):
|
||||||
|
investment = await client.post("/accounts", json={"name": "Brokerage", "type": "investment", "currency": "USD"})
|
||||||
|
investment_id = investment.json()["id"]
|
||||||
|
await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(investment_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "idle_cash" not in await _alert_keys(client)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_idle_cash_respects_custom_threshold(client: AsyncClient, account_id, categories):
|
||||||
|
await client.post(
|
||||||
|
"/transactions",
|
||||||
|
json=_txn(account_id, type="income", amount="5000.00", category_id=categories["Salary"]["id"], date=STALE_DATE),
|
||||||
|
)
|
||||||
|
assert "idle_cash" in await _alert_keys(client)
|
||||||
|
|
||||||
|
resp = await client.patch("/settings", json={"idle_cash_threshold_amount": "10000.00"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
assert "idle_cash" not in await _alert_keys(client)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "aurum-frontend",
|
"name": "aurum-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.23.2",
|
"version": "0.24.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { Input, Label, Select } from "@/components/ui/Input";
|
import { Input, Label, Select } from "@/components/ui/Input";
|
||||||
import { CategoryColorPicker } from "@/components/categories/CategoryColorPicker";
|
import { CategoryColorPicker } from "@/components/categories/CategoryColorPicker";
|
||||||
import { CategoryIconPicker } from "@/components/categories/CategoryIconPicker";
|
import { CategoryIconPicker } from "@/components/categories/CategoryIconPicker";
|
||||||
import { useCreateCategory, useUpdateCategory } from "@/hooks/useCategories";
|
import { useCategories, useCreateCategory, useUpdateCategory } from "@/hooks/useCategories";
|
||||||
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||||
import type { Category, CategoryKind } from "@/types";
|
import type { Category, CategoryKind } from "@/types";
|
||||||
|
|
||||||
@@ -20,11 +21,12 @@ interface CategoryFormModalProps {
|
|||||||
const KINDS: CategoryKind[] = ["expense", "income"];
|
const KINDS: CategoryKind[] = ["expense", "income"];
|
||||||
|
|
||||||
function emptyForm(kind: CategoryKind) {
|
function emptyForm(kind: CategoryKind) {
|
||||||
return { name: "", kind, icon: "wallet", color: "#2a78d6" };
|
return { name: "", kind, icon: "wallet", color: "#2a78d6", parent_id: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryFormModal({ open, onClose, category, defaultKind }: CategoryFormModalProps) {
|
export function CategoryFormModal({ open, onClose, category, defaultKind }: CategoryFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: allCategories } = useCategories();
|
||||||
const createCategory = useCreateCategory();
|
const createCategory = useCreateCategory();
|
||||||
const updateCategory = useUpdateCategory();
|
const updateCategory = useUpdateCategory();
|
||||||
|
|
||||||
@@ -35,26 +37,41 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setForm(
|
setForm(
|
||||||
category
|
category
|
||||||
? { name: category.name, kind: category.kind, icon: category.icon ?? "wallet", color: category.color }
|
? {
|
||||||
|
name: category.name,
|
||||||
|
kind: category.kind,
|
||||||
|
icon: category.icon ?? "wallet",
|
||||||
|
color: category.color,
|
||||||
|
parent_id: category.parent_id ? String(category.parent_id) : "",
|
||||||
|
}
|
||||||
: emptyForm(defaultKind)
|
: emptyForm(defaultKind)
|
||||||
);
|
);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [open, category, defaultKind]);
|
}, [open, category, defaultKind]);
|
||||||
|
|
||||||
|
// A category that already has subcategories can't become one itself
|
||||||
|
// (backend rejects it) — the parent picker is disabled in that case.
|
||||||
|
const hasChildren = category ? (allCategories ?? []).some((c) => c.parent_id === category.id) : false;
|
||||||
|
const parentCandidates = (allCategories ?? []).filter(
|
||||||
|
(c) => c.kind === form.kind && c.parent_id === null && c.id !== category?.id
|
||||||
|
);
|
||||||
|
|
||||||
const isSaving = createCategory.isPending || updateCategory.isPending;
|
const isSaving = createCategory.isPending || updateCategory.isPending;
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const parent_id = form.parent_id ? Number(form.parent_id) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (category) {
|
if (category) {
|
||||||
await updateCategory.mutateAsync({
|
await updateCategory.mutateAsync({
|
||||||
id: category.id,
|
id: category.id,
|
||||||
input: { name: form.name, icon: form.icon, color: form.color },
|
input: { name: form.name, icon: form.icon, color: form.color, parent_id },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createCategory.mutateAsync(form);
|
await createCategory.mutateAsync({ name: form.name, kind: form.kind, icon: form.icon, color: form.color, parent_id });
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -82,7 +99,9 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
|
|||||||
id="category-kind"
|
id="category-kind"
|
||||||
value={form.kind}
|
value={form.kind}
|
||||||
disabled={Boolean(category)}
|
disabled={Boolean(category)}
|
||||||
onChange={(event) => setForm((prev) => ({ ...prev, kind: event.target.value as CategoryKind }))}
|
onChange={(event) =>
|
||||||
|
setForm((prev) => ({ ...prev, kind: event.target.value as CategoryKind, parent_id: "" }))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{KINDS.map((kind) => (
|
{KINDS.map((kind) => (
|
||||||
<option key={kind} value={kind}>
|
<option key={kind} value={kind}>
|
||||||
@@ -92,6 +111,24 @@ export function CategoryFormModal({ open, onClose, category, defaultKind }: Cate
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="category-parent">{t("category.form.parentLabel")}</Label>
|
||||||
|
<Select
|
||||||
|
id="category-parent"
|
||||||
|
value={hasChildren ? "" : form.parent_id}
|
||||||
|
disabled={hasChildren}
|
||||||
|
onChange={(event) => setForm((prev) => ({ ...prev, parent_id: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">{t("category.form.noParent")}</option>
|
||||||
|
{parentCandidates.map((parent) => (
|
||||||
|
<option key={parent.id} value={parent.id}>
|
||||||
|
{translateCategoryName(parent.name)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{hasChildren && <p className="mt-1 text-xs text-text-muted">{t("category.form.hasChildrenHint")}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label>{t("category.form.iconLabel")}</Label>
|
<Label>{t("category.form.iconLabel")}</Label>
|
||||||
<CategoryIconPicker value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />
|
<CategoryIconPicker value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />
|
||||||
|
|||||||
@@ -10,6 +10,57 @@ interface CategoryListProps {
|
|||||||
onDelete: (category: Category) => void;
|
onDelete: (category: Category) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
category: Category;
|
||||||
|
indented: boolean;
|
||||||
|
onEdit: (category: Category) => void;
|
||||||
|
onDelete: (category: Category) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryRow({ category, indented, onEdit, onDelete }: RowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const Icon = getCategoryIcon(category.icon);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={`flex items-center gap-3 py-2.5 ${indented ? "pl-8" : ""}`}>
|
||||||
|
<span
|
||||||
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: `${category.color}1a`, color: category.color }}
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm font-medium text-text-primary">
|
||||||
|
<span className="truncate">{translateCategoryName(category.name)}</span>
|
||||||
|
{category.is_default && (
|
||||||
|
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none text-text-muted">
|
||||||
|
{t("category.defaultBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("common.edit")}
|
||||||
|
onClick={() => onEdit(category)}
|
||||||
|
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<Pencil size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
|
||||||
|
disabled={category.is_default}
|
||||||
|
onClick={() => onDelete(category)}
|
||||||
|
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) {
|
export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -17,50 +68,31 @@ export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) {
|
|||||||
return <p className="py-6 text-center text-sm text-text-muted">{t("category.empty")}</p>;
|
return <p className="py-6 text-center text-sm text-text-muted">{t("category.empty")}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `items` arrives pre-sorted (alphabetically, by CategoriesPage) — walk
|
||||||
|
// top-level categories in that order and splice each one's children in
|
||||||
|
// directly beneath it, so a subcategory renders indented under its parent
|
||||||
|
// while everything stays a single flat, evenly-divided list.
|
||||||
|
const topLevel = items.filter((category) => category.parent_id === null);
|
||||||
|
const topLevelIds = new Set(topLevel.map((category) => category.id));
|
||||||
|
// Defensive only — a child whose parent got filtered out (e.g. a kind
|
||||||
|
// mismatch) should never happen given the create/update validation, but
|
||||||
|
// falls back to a top-level row instead of silently disappearing.
|
||||||
|
const orphaned = items.filter((category) => category.parent_id !== null && !topLevelIds.has(category.parent_id));
|
||||||
|
|
||||||
|
const rows: { category: Category; indented: boolean }[] = [];
|
||||||
|
for (const parent of topLevel) {
|
||||||
|
rows.push({ category: parent, indented: false });
|
||||||
|
for (const child of items) {
|
||||||
|
if (child.parent_id === parent.id) rows.push({ category: child, indented: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const category of orphaned) rows.push({ category, indented: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="divide-y divide-gridline">
|
<ul className="divide-y divide-gridline">
|
||||||
{items.map((category) => {
|
{rows.map(({ category, indented }) => (
|
||||||
const Icon = getCategoryIcon(category.icon);
|
<CategoryRow key={category.id} category={category} indented={indented} onEdit={onEdit} onDelete={onDelete} />
|
||||||
|
))}
|
||||||
return (
|
|
||||||
<li key={category.id} className="flex items-center gap-3 py-2.5">
|
|
||||||
<span
|
|
||||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
|
||||||
style={{ backgroundColor: `${category.color}1a`, color: category.color }}
|
|
||||||
>
|
|
||||||
<Icon size={15} />
|
|
||||||
</span>
|
|
||||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm font-medium text-text-primary">
|
|
||||||
<span className="truncate">{translateCategoryName(category.name)}</span>
|
|
||||||
{category.is_default && (
|
|
||||||
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none text-text-muted">
|
|
||||||
{t("category.defaultBadge")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="flex shrink-0 gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t("common.edit")}
|
|
||||||
onClick={() => onEdit(category)}
|
|
||||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
|
|
||||||
>
|
|
||||||
<Pencil size={15} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t("common.delete")}
|
|
||||||
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
|
|
||||||
disabled={category.is_default}
|
|
||||||
onClick={() => onDelete(category)}
|
|
||||||
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted"
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const ALERT_MESSAGE_KEYS: Record<string, TranslationKey> = {
|
|||||||
net_worth_decline_streak: "insights.netWorthDecline",
|
net_worth_decline_streak: "insights.netWorthDecline",
|
||||||
budget_exceeded: "insights.budgetExceeded",
|
budget_exceeded: "insights.budgetExceeded",
|
||||||
risky_allocation_exceeded: "insights.riskyAllocationExceeded",
|
risky_allocation_exceeded: "insights.riskyAllocationExceeded",
|
||||||
|
idle_cash: "insights.idleCash",
|
||||||
};
|
};
|
||||||
|
|
||||||
function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation>["t"]): string {
|
function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||||
@@ -18,6 +19,9 @@ function alertMessage(alert: FinancialAlert, t: ReturnType<typeof useTranslation
|
|||||||
if (alert.key === "budget_exceeded" && alert.params.count === 1) {
|
if (alert.key === "budget_exceeded" && alert.params.count === 1) {
|
||||||
return t("insights.budgetExceededOne");
|
return t("insights.budgetExceededOne");
|
||||||
}
|
}
|
||||||
|
if (alert.key === "idle_cash" && alert.params.count === 1) {
|
||||||
|
return t("insights.idleCashOne", alert.params);
|
||||||
|
}
|
||||||
const messageKey = ALERT_MESSAGE_KEYS[alert.key];
|
const messageKey = ALERT_MESSAGE_KEYS[alert.key];
|
||||||
if (!messageKey) return alert.key;
|
if (!messageKey) return alert.key;
|
||||||
return t(messageKey, alert.params);
|
return t(messageKey, alert.params);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ const MIN_MONTHS = 1;
|
|||||||
const MAX_MONTHS = 24;
|
const MAX_MONTHS = 24;
|
||||||
const MIN_PERCENT = 1;
|
const MIN_PERCENT = 1;
|
||||||
const MAX_PERCENT = 100;
|
const MAX_PERCENT = 100;
|
||||||
|
const MIN_AMOUNT = 0;
|
||||||
|
const MIN_DAYS = 1;
|
||||||
|
const MAX_DAYS = 365;
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(max, Math.max(min, value));
|
return Math.min(max, Math.max(min, value));
|
||||||
@@ -21,12 +24,16 @@ export function AlertThresholdsCard() {
|
|||||||
const [cashFlowMonths, setCashFlowMonths] = useState("");
|
const [cashFlowMonths, setCashFlowMonths] = useState("");
|
||||||
const [netWorthMonths, setNetWorthMonths] = useState("");
|
const [netWorthMonths, setNetWorthMonths] = useState("");
|
||||||
const [riskyPercent, setRiskyPercent] = useState("");
|
const [riskyPercent, setRiskyPercent] = useState("");
|
||||||
|
const [idleCashAmount, setIdleCashAmount] = useState("");
|
||||||
|
const [idleCashDays, setIdleCashDays] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
setCashFlowMonths(String(settings.negative_cash_flow_threshold_months));
|
setCashFlowMonths(String(settings.negative_cash_flow_threshold_months));
|
||||||
setNetWorthMonths(String(settings.net_worth_decline_threshold_months));
|
setNetWorthMonths(String(settings.net_worth_decline_threshold_months));
|
||||||
setRiskyPercent(String(settings.risky_allocation_threshold_percent));
|
setRiskyPercent(String(settings.risky_allocation_threshold_percent));
|
||||||
|
setIdleCashAmount(settings.idle_cash_threshold_amount);
|
||||||
|
setIdleCashDays(String(settings.idle_cash_threshold_days));
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
function commitCashFlowMonths() {
|
function commitCashFlowMonths() {
|
||||||
@@ -53,13 +60,29 @@ export function AlertThresholdsCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commitIdleCashAmount() {
|
||||||
|
const value = Math.max(MIN_AMOUNT, Number(idleCashAmount) || MIN_AMOUNT).toFixed(2);
|
||||||
|
setIdleCashAmount(value);
|
||||||
|
if (settings && value !== settings.idle_cash_threshold_amount) {
|
||||||
|
updateSettings.mutate({ idle_cash_threshold_amount: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitIdleCashDays() {
|
||||||
|
const value = clamp(Number(idleCashDays) || MIN_DAYS, MIN_DAYS, MAX_DAYS);
|
||||||
|
setIdleCashDays(String(value));
|
||||||
|
if (settings && value !== settings.idle_cash_threshold_days) {
|
||||||
|
updateSettings.mutate({ idle_cash_threshold_days: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("settings.alertsTitle")}</CardTitle>
|
<CardTitle>{t("settings.alertsTitle")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="cash-flow-threshold">{t("settings.negativeCashFlowThresholdLabel")}</Label>
|
<Label htmlFor="cash-flow-threshold">{t("settings.negativeCashFlowThresholdLabel")}</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -99,6 +122,31 @@ export function AlertThresholdsCard() {
|
|||||||
onBlur={commitRiskyPercent}
|
onBlur={commitRiskyPercent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="idle-cash-amount-threshold">{t("settings.idleCashThresholdAmountLabel")}</Label>
|
||||||
|
<Input
|
||||||
|
id="idle-cash-amount-threshold"
|
||||||
|
type="number"
|
||||||
|
min={MIN_AMOUNT}
|
||||||
|
step={0.01}
|
||||||
|
value={idleCashAmount}
|
||||||
|
onChange={(event) => setIdleCashAmount(event.target.value)}
|
||||||
|
onBlur={commitIdleCashAmount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="idle-cash-days-threshold">{t("settings.idleCashThresholdDaysLabel")}</Label>
|
||||||
|
<Input
|
||||||
|
id="idle-cash-days-threshold"
|
||||||
|
type="number"
|
||||||
|
min={MIN_DAYS}
|
||||||
|
max={MAX_DAYS}
|
||||||
|
step={1}
|
||||||
|
value={idleCashDays}
|
||||||
|
onChange={(event) => setIdleCashDays(event.target.value)}
|
||||||
|
onBlur={commitIdleCashDays}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-muted">{t("settings.alertsHint")}</p>
|
<p className="text-xs text-text-muted">{t("settings.alertsHint")}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useCategories } from "@/hooks/useCategories";
|
|||||||
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
|
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { translateCategoryName } from "@/lib/categoryLabels";
|
import { translateCategoryName } from "@/lib/categoryLabels";
|
||||||
import type { Transaction, TransactionInput, TransactionType } from "@/types";
|
import type { Category, Transaction, TransactionInput, TransactionType } from "@/types";
|
||||||
|
|
||||||
interface TransactionFormModalProps {
|
interface TransactionFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -32,7 +32,7 @@ const EMPTY_FORM = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) {
|
export function TransactionFormModal({ open, onClose, transaction }: TransactionFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t, language } = useTranslation();
|
||||||
const { data: accounts } = useAccounts();
|
const { data: accounts } = useAccounts();
|
||||||
const { data: categories } = useCategories();
|
const { data: categories } = useCategories();
|
||||||
const createTransaction = useCreateTransaction();
|
const createTransaction = useCreateTransaction();
|
||||||
@@ -61,9 +61,19 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
setError(null);
|
setError(null);
|
||||||
}, [open, transaction, accounts]);
|
}, [open, transaction, accounts]);
|
||||||
|
|
||||||
const relevantCategories = (categories ?? []).filter((category) =>
|
// Subcategories are listed right under their parent (not scattered by
|
||||||
form.type === "income" ? category.kind === "income" : category.kind === "expense"
|
// sort_order) so the hierarchy set up on the Categories page reads the
|
||||||
);
|
// same way here.
|
||||||
|
const kindCategories = (categories ?? [])
|
||||||
|
.filter((category) => (form.type === "income" ? category.kind === "income" : category.kind === "expense"))
|
||||||
|
.sort((a, b) => translateCategoryName(a.name).localeCompare(translateCategoryName(b.name), language));
|
||||||
|
const relevantCategories: (Category & { indented?: boolean })[] = [];
|
||||||
|
for (const parent of kindCategories.filter((category) => category.parent_id === null)) {
|
||||||
|
relevantCategories.push(parent);
|
||||||
|
for (const child of kindCategories.filter((category) => category.parent_id === parent.id)) {
|
||||||
|
relevantCategories.push({ ...child, indented: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isSaving = createTransaction.isPending || updateTransaction.isPending;
|
const isSaving = createTransaction.isPending || updateTransaction.isPending;
|
||||||
|
|
||||||
@@ -217,6 +227,7 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
|
|||||||
<option value="">{t("transactions.form.noCategory")}</option>
|
<option value="">{t("transactions.form.noCategory")}</option>
|
||||||
{relevantCategories.map((category) => (
|
{relevantCategories.map((category) => (
|
||||||
<option key={category.id} value={category.id}>
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.indented ? ` ↳ ` : ""}
|
||||||
{translateCategoryName(category.name)}
|
{translateCategoryName(category.name)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ const ru = {
|
|||||||
"settings.negativeCashFlowThresholdLabel": "Расходы превышают доходы, мес. подряд",
|
"settings.negativeCashFlowThresholdLabel": "Расходы превышают доходы, мес. подряд",
|
||||||
"settings.netWorthDeclineThresholdLabel": "Капитал снижается, мес. подряд",
|
"settings.netWorthDeclineThresholdLabel": "Капитал снижается, мес. подряд",
|
||||||
"settings.riskyAllocationThresholdLabel": "Допустимая доля рискового капитала, %",
|
"settings.riskyAllocationThresholdLabel": "Допустимая доля рискового капитала, %",
|
||||||
|
"settings.idleCashThresholdAmountLabel": "Простаивающий кэш — сумма от",
|
||||||
|
"settings.idleCashThresholdDaysLabel": "Простаивающий кэш — без движения, дней",
|
||||||
|
|
||||||
"backup.title": "Резервное копирование",
|
"backup.title": "Резервное копирование",
|
||||||
"backup.description":
|
"backup.description":
|
||||||
@@ -226,6 +228,8 @@ const ru = {
|
|||||||
"insights.budgetExceededOne": "Превышен бюджет в 1 категории в этом месяце",
|
"insights.budgetExceededOne": "Превышен бюджет в 1 категории в этом месяце",
|
||||||
"insights.budgetExceeded": "Превышен бюджет в {{count}} категориях в этом месяце",
|
"insights.budgetExceeded": "Превышен бюджет в {{count}} категориях в этом месяце",
|
||||||
"insights.riskyAllocationExceeded": "Рискованная часть капитала — {{percent}}%, превышает установленный лимит {{threshold}}%",
|
"insights.riskyAllocationExceeded": "Рискованная часть капитала — {{percent}}%, превышает установленный лимит {{threshold}}%",
|
||||||
|
"insights.idleCashOne": "На 1 счёте лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
|
||||||
|
"insights.idleCash": "На {{count}} счетах лежит крупная сумма без движения {{days}}+ дней — возможно, эти деньги стоит куда-то вложить",
|
||||||
|
|
||||||
"budget.empty": "Бюджетов пока нет. Задайте лимит для категории расходов, чтобы отслеживать её здесь.",
|
"budget.empty": "Бюджетов пока нет. Задайте лимит для категории расходов, чтобы отслеживать её здесь.",
|
||||||
"budget.remaining": "{{amount}} осталось",
|
"budget.remaining": "{{amount}} осталось",
|
||||||
@@ -314,6 +318,9 @@ const ru = {
|
|||||||
"category.form.nameLabel": "Название",
|
"category.form.nameLabel": "Название",
|
||||||
"category.form.namePlaceholder": "Продукты, транспорт, подписки…",
|
"category.form.namePlaceholder": "Продукты, транспорт, подписки…",
|
||||||
"category.form.kindLabel": "Тип",
|
"category.form.kindLabel": "Тип",
|
||||||
|
"category.form.parentLabel": "Родительская категория",
|
||||||
|
"category.form.noParent": "— без родителя (верхний уровень) —",
|
||||||
|
"category.form.hasChildrenHint": "У категории уже есть подкатегории — она не может сама стать подкатегорией.",
|
||||||
"category.form.iconLabel": "Иконка",
|
"category.form.iconLabel": "Иконка",
|
||||||
"category.form.colorLabel": "Цвет",
|
"category.form.colorLabel": "Цвет",
|
||||||
"category.form.customColorLabel": "Свой цвет",
|
"category.form.customColorLabel": "Свой цвет",
|
||||||
@@ -559,6 +566,8 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"settings.negativeCashFlowThresholdLabel": "Expenses exceed income, consecutive months",
|
"settings.negativeCashFlowThresholdLabel": "Expenses exceed income, consecutive months",
|
||||||
"settings.netWorthDeclineThresholdLabel": "Net worth declining, consecutive months",
|
"settings.netWorthDeclineThresholdLabel": "Net worth declining, consecutive months",
|
||||||
"settings.riskyAllocationThresholdLabel": "Max % of capital allowed at risk",
|
"settings.riskyAllocationThresholdLabel": "Max % of capital allowed at risk",
|
||||||
|
"settings.idleCashThresholdAmountLabel": "Idle cash — balance at or above",
|
||||||
|
"settings.idleCashThresholdDaysLabel": "Idle cash — no activity for, days",
|
||||||
|
|
||||||
"backup.title": "Backup & Restore",
|
"backup.title": "Backup & Restore",
|
||||||
"backup.description":
|
"backup.description":
|
||||||
@@ -582,6 +591,8 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"insights.budgetExceededOne": "Budget exceeded in 1 category this month",
|
"insights.budgetExceededOne": "Budget exceeded in 1 category this month",
|
||||||
"insights.budgetExceeded": "Budget exceeded in {{count}} categories this month",
|
"insights.budgetExceeded": "Budget exceeded in {{count}} categories this month",
|
||||||
"insights.riskyAllocationExceeded": "Risky share of capital is {{percent}}%, above your {{threshold}}% limit",
|
"insights.riskyAllocationExceeded": "Risky share of capital is {{percent}}%, above your {{threshold}}% limit",
|
||||||
|
"insights.idleCashOne": "1 account is holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
|
||||||
|
"insights.idleCash": "{{count}} accounts are holding a large balance with no activity for {{days}}+ days — that money might be worth putting to work",
|
||||||
|
|
||||||
"budget.empty": "No budgets yet. Set a limit for an expense category to start tracking it here.",
|
"budget.empty": "No budgets yet. Set a limit for an expense category to start tracking it here.",
|
||||||
"budget.remaining": "{{amount}} left",
|
"budget.remaining": "{{amount}} left",
|
||||||
@@ -670,6 +681,9 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"category.form.nameLabel": "Name",
|
"category.form.nameLabel": "Name",
|
||||||
"category.form.namePlaceholder": "Groceries, transport, subscriptions…",
|
"category.form.namePlaceholder": "Groceries, transport, subscriptions…",
|
||||||
"category.form.kindLabel": "Type",
|
"category.form.kindLabel": "Type",
|
||||||
|
"category.form.parentLabel": "Parent category",
|
||||||
|
"category.form.noParent": "— no parent (top level) —",
|
||||||
|
"category.form.hasChildrenHint": "This category already has subcategories — it can't become a subcategory itself.",
|
||||||
"category.form.iconLabel": "Icon",
|
"category.form.iconLabel": "Icon",
|
||||||
"category.form.colorLabel": "Color",
|
"category.form.colorLabel": "Color",
|
||||||
"category.form.customColorLabel": "Custom color",
|
"category.form.customColorLabel": "Custom color",
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export interface Category {
|
|||||||
color: string;
|
color: string;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
|
// Subcategory parent, one level deep only (a category whose own parent_id
|
||||||
|
// is set can't itself have children — enforced backend-side).
|
||||||
|
parent_id: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryInput {
|
export interface CategoryInput {
|
||||||
@@ -42,6 +45,7 @@ export interface CategoryInput {
|
|||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
color: string;
|
color: string;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
|
parent_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// kind is fixed at creation on the backend (CategoryUpdate has no kind field).
|
// kind is fixed at creation on the backend (CategoryUpdate has no kind field).
|
||||||
@@ -50,6 +54,7 @@ export interface CategoryUpdateInput {
|
|||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
color?: string;
|
color?: string;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
|
parent_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
@@ -365,5 +370,7 @@ export interface AppSettings {
|
|||||||
negative_cash_flow_threshold_months: number;
|
negative_cash_flow_threshold_months: number;
|
||||||
net_worth_decline_threshold_months: number;
|
net_worth_decline_threshold_months: number;
|
||||||
risky_allocation_threshold_percent: number;
|
risky_allocation_threshold_percent: number;
|
||||||
|
idle_cash_threshold_amount: string;
|
||||||
|
idle_cash_threshold_days: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user