8000 Dev by amoghkini · Pull Request #3 · amoghkini/Hisaab · GitHub
[go: up one dir, main page]

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/app/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class AccountCategory(StrEnum):
# What the user owes, rather than owns. Their balances are negative when money is owed.
LIABILITY_CATEGORIES = frozenset({AccountCategory.CREDIT_CARD, AccountCategory.LOAN})

# Where a salary can be paid into.
SALARY_CATEGORIES = frozenset({AccountCategory.SAVINGS, AccountCategory.CURRENT})

_CATEGORY_VALUES = ", ".join(f"'{category}'" for category in AccountCategory)


Expand Down
5 changes: 4 additions & 1 deletion backend/app/accounts/service.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session

from app.accounts.models import CATEGORY_ORDER, LIABILITY_CATEGORIES, Account, AccountCategory
from app.accounts.models import CATEGORY_ORDER, LIABILITY_CATEGORIES, SALARY_CATEGORIES, Account, AccountCategory
from app.accounts.schemas import AccountIn, AccountOrderIn, AccountOut, AccountSummaryOut, AccountUpdate
from app.auth.models import User
from app.errors import ApiError
Expand Down Expand Up @@ -87,6 +87,8 @@ def update_account(db: Session, account: Account, data: AccountUpdate) -> Accoun
new_category = changes.get("category")
if new_category is not None and new_category != account.category:
account.display_order = _next_order(db, account.user_id, str(new_category)) # goes last in its new category
if new_category not in SALARY_CATEGORIES: # a loan or card cannot be where the salary is paid
db.execute(update(User).where(User.salary_account_id == account.id).values(salary_account_id=None))
for name, value in changes.items():
setattr(account, name, value)
_keep_card_fields_for_cards(account)
Expand All @@ -102,6 +104,7 @@ def delete_account(db: Session, account: Account) -> None:
"This account has transactions, so it cannot be deleted. Delete them first, or hide the account.",
)
db.execute(update(User).where(User.default_account_id == account.id).values(default_account_id=None))
db.execute(update(User).where(User.salary_account_id == account.id).values(salary_account_id=None))
db.delete(account)
db.commit()

Expand Down
3 changes: 3 additions & 0 deletions backend/app/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class User(Base):
# The account new transactions start on. No foreign key: SQLite here does not enforce them, so deleting an
# account clears it in code (app.accounts.service.delete_account).
default_account_id: Mapped[int | None] = mapped_column(Integer, default=None)
# The savings or current account the salary is paid into. Cleared in code like default_account_id, and also
# when the account stops being a savings or current account.
salary_account_id: Mapped[int | None] = mapped_column(Integer, default=None)
fiscal_year_start_month: Mapped[int] = mapped_column(
SmallInteger, default=DEFAULT_FISCAL_YEAR_START_MONTH, server_default=str(DEFAULT_FISCAL_YEAR_START_MONTH)
) # 1 is January ... 12 is December
Expand Down
6 changes: 5 additions & 1 deletion backend/app/auth/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,19 +70,23 @@ class UserOut(BaseModel):
expense_amount_color: AmountColor
income_amount_color: AmountColor
default_account_id: int | None = Field(description="The account new transactions start on, or null.")
salary_account_id: int | None = Field(
description="The savings or current account the salary is paid into, or null."
)
fiscal_year_start_month: int
digit_grouping: DigitGrouping
currency_display: CurrencyDisplay
created_at: datetime


class UpdateMeIn(BaseModel):
"""Only the fields sent are changed. `theme_color` or `default_account_id` null goes back to none."""
"""Only the fields sent are changed. Null `theme_color`, `default_account_id` or `salary_account_id` clears it."""

name: Name | None = None
# The e-mail is the login and cannot be changed; sending it is refused rather than ignored.
email: str | None = Field(default=None, description="Cannot be changed; sending it is refused.")
default_account_id: int | None = None
salary_account_id: int | None = None
fiscal_year_start_month: FiscalYearStartMonth | None = None
digit_grouping: DigitGrouping | None = None
currency_display: CurrencyDisplay | None = None
Expand Down
22 changes: 15 additions & 7 deletions backend/app/auth/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.accounts.models import Account
from app.accounts.models import SALARY_CATEGORIES, Account
from app.auth.models import AuthSession, User
from app.auth.schemas import PasswordChangeIn, SignInIn, SignUpIn, UpdateMeIn
from app.db import utcnow
Expand Down Expand Up @@ -118,12 +118,20 @@ def check_password(user: User, password: str, field: str = "password") -> None:

def update_me(db: Session, user: User, data: UpdateMeIn) -> User:
changes = data.changes()
account_id = changes.get("default_account_id")
if account_id is not None:
owner = db.scalar(select(Account.user_id).where(Account.id == account_id))
if owner != user.id:
message = "Choose one of your accounts."
raise ApiError(422, "validation_failed", message, [{"field": "default_account_id", "message": message}])
for field, allowed_categories, message in (
("default_account_id", None, "Choose one of your accounts."),
("salary_account_id", SALARY_CATEGORIES, "Choose one of your savings or current accounts."),
):
account_id = changes.get(field)
if account_id is None:
continue
found = db.execute(select(Account.user_id, Account.category).where(Account.id == account_id)).one_or_none()
if (
found is None
or found.user_id != user.id
or (allowed_categories and found.category not in allowed_categories)
):
raise ApiError(422, "validation_failed", message, [{"field": field, "message": message}])
for name, value in changes.items():
setattr(user, name, value)
db.commit()
Expand Down
1 change: 1 addition & 0 deletions backend/app/data/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ def clear_data(db: Session, user: User, everything: bool) -> None:
db.execute(delete(Category).where(Category.user_id == user.id, Category.parent_id.is_not(None)))
db.execute(delete(Category).where(Category.user_id == user.id))
user.default_account_id = None
user.salary_account_id = None
23 changes: 23 additions & 0 deletions backend/migrations/versions/0011_salary_account.py
10BC8
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""The account the user's salary is paid into.

Revision ID: 0011
Revises: 0010
"""

import sqlalchemy as sa
from alembic import op

revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None


def upgrade() -> None:
with op.batch_alter_table("users") as batch:
batch.add_column(sa.Column("salary_account_id", sa.Integer(), nullable=True))


def downgrade() -> None:
with op.batch_alter_table("users") as batch:
batch.drop_column("salary_account_id")
42 changes: 39 additions & 3 deletions backend/tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def _account(client: TestClient) -> int:

def test_new_settings_have_defaults(client: TestClient) -> None:
me = client.get(ME).json()
assert me["default_account_id"] is None
assert (me["default_account_id"], me["salary_account_id"]) == (None, None)
assert me["fiscal_year_start_month"] == 4
assert (me["digit_grouping"], me["currency_display"]) == ("indian", "symbol")
assert me["created_at"]
Expand Down Expand Up @@ -104,6 +104,41 @@ def test_default_account_must_be_ones_own(client: TestClient) -> None:
assert response.json()["error"]["details"][0]["field"] == "default_account_id"


def test_salary_account_is_set_cleared_and_forgotten_with_the_account(client: TestClient) -> None:
assert client.get(ME).json()["salary_account_id"] is None
account_id = _account(client)
assert client.patch(ME, json={"salary_account_id": account_id}).json()["salary_account_id"] == account_id
assert client.patch(ME, json={"salary_account_id": None}).json()["salary_account_id"] is None

client.patch(ME, json={"salary_account_id": account_id})
assert client.delete(f"/api/v1/accounts/{account_id}").status_code == 204
assert client.get(ME).json()["salary_account_id"] is None


def test_salary_account_must_be_ones_own_savings_or_current_account(client: TestClient) -> None:
other = TestClient(client.app)
other.post("/api/v1/auth/sign-up", json={**NEW_USER, "email": "ravi@example.com"})
theirs = _account(other)
card = client.post("/api/v1/accounts", json={**SAVINGS, "category": "credit_card", "name": "Card"}).json()["id"]
for bad in (theirs, 99999, card):
response = client.patch(ME, json={"salary_account_id": bad})
assert response.status_code == 422
assert response.json()["error"]["details"][0]["field"] == "salary_account_id"

current = client.post("/api/v1/accounts", json={**SAVINGS, "category": "current", "name": "Current"}).json()["id"]
assert client.patch(ME, json={"salary_account_id": current}).json()["salary_account_id"] == current


def test_salary_account_is_forgotten_when_it_stops_being_savings_or_current(client: TestClient) -> None:
account_id = _account(client)
client.patch(ME, json={"salary_account_id": account_id})
assert client.patch(f"/api/v1/accounts/{account_id}", json={"category": "current"}).status_code == 200
assert client.get(ME).json()["salary_account_id"] == account_id # still a place a salary can go

assert client.patch(f"/api/v1/accounts/{account_id}", json={"category": "wallet"}).status_code == 200
assert client.get(ME).json()["salary_account_id"] is None


def test_sessions_list_each_device_and_mark_this_one(client: TestClient) -> None:
_second_device(client)
sessions = client.get(SESSIONS).json()
Expand Down Expand Up @@ -202,11 +237,12 @@ def test_clear_transactions_keeps_accounts_and_categories(client: TestClient) ->

def test_clear_all_data(client: TestClient) -> None:
account_id = _add_data(client)
client.patch(ME, json={"default_account_id": account_id})
client.patch(ME, json={"default_account_id": account_id, "salary_account_id": account_id})
assert client.post("/api/v1/data/clear", json={"scope": "all", "password": "correct horse"}).status_code == 204
stats = client.get("/api/v1/data/stats").json()
assert (stats["accounts"], stats["categories"], stats["transactions"]) == (0, 0, 0)
assert client.get(ME).json()["default_account_id"] is None
me = client.get(ME).json()
assert (me["default_account_id"], me["salary_account_id"]) == (None, None)


def test_clear_needs_the_password_and_leaves_others_alone(client: TestClient) -> None:
Expand Down
3 changes: 2 additions & 1 deletion docs/content/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Dependencies point one way: `salary`, `investments`, `tax`, `schedules`, `budget

## Navigation

Transactions · **Accounts ▾** · **Planning ▾** · **Wealth ▾** · **Salary ▾** · **Taxes ▾** · Markets (*built*)
Transactions · **Accounts ▾** · **Planning ▾** · **Wealth ▾** · **Salary ▾** · **Taxes ▾** · **Stats ▾** · Markets (*built*)

| Item | Entries |
|---|---|
Expand All @@ -32,6 +32,7 @@ Transactions · **Accounts ▾** · **Planning ▾** · **Wealth ▾** · **Sala
| Wealth | Net worth; Investments (Portfolio, EPF, PPF and NPS, FDs and RDs, mutual funds and stocks, Sync from EPFO); Property and vehicles; Insurance |
| Salary | Payslips, Add a payslip, Employers and salary structure |
| Taxes | Tax summary, TDS and taxes paid, Deductions, Old vs new regime |
| Stats | Spending by category, Income vs expense, Trends, Monthly report |

- There is no Overview item: the logo goes to the dashboard at `/`.
- The dropdowns open on hover (`HoverMenu`). Entries not built yet say "not available yet".
Expand Down
3 changes: 3 additions & 0 deletions web/src/api/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export const ACCOUNT_CATEGORIES: AccountCategoryInfo[] = [
{ value: "investment", name: "Investment", icon: "chart-line", liability: false },
];

/** The accounts page offers "Set as Primary" and "Set as Salary Account" on these. The backend enforces it for salary only. */
export const SALARY_CATEGORIES: AccountCategory[] = ["savings", "current"];

export function categoryInfo(category: AccountCategory): AccountCategoryInfo {
return ACCOUNT_CATEGORIES.find((info) => info.value === category) ?? ACCOUNT_CATEGORIES[0]!;
}
Expand Down
7 changes: 5 additions & 2 deletions web/src/api/auth.ts
Or 10BC8 iginal file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface User {
income_amount_color: AmountColor;
/** The account new transactions start on, or null. */
default_account_id: number | null;
/** The savings or current account the salary is paid into, or null. */
salary_account_id: number | null;
/** 1 is January ... 12 is December. India's financial year starts in April (4). */
fiscal_year_start_month: number;
digit_grouping: DigitGrouping;
Expand Down Expand Up @@ -46,9 +48,10 @@ export interface Preferences {
income_amount_color: AmountColor;
}

/** Leave a field out to keep it; theme_color or default_account_id null goes back to none. */
/** Leave a field out to keep it; theme_color, default_account_id or salary_account_id null goes back to none. */
export type UpdateMeInput = Partial<
Preferences & Pick<User, "name" | "default_account_id" | "fiscal_year_start_month" | "digit_grouping" | "currency_display">
Preferences &
Pick<User, "name" | "default_account_id" | "salary_account_id" | "fiscal_year_start_month" | "digit_grouping" | "currency_display">
>;

export interface SignInInput {
Expand Down
2 changes: 1 addition & 1 deletion web/src/auth/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Navigate, Outlet, useLocation } from "react-router-dom";
import { fetchMe, type User } from "@/api/auth";
import { Loading } from "@/components/Loading";

const ME = ["me"];
export const ME = ["me"];
const HOME = "/";

/** Who is signed in: undefined while the first check runs, then a user or null. */
Expand Down
14 changes: 13 additions & 1 deletion web/src/layouts/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
mdiBriefcaseOutline,
mdiCalculatorVariantOutline,
mdiCalendarCheckOutline,
mdiChartBoxOutline,
mdiChevronDown,
mdiCogOutline,
mdiEyeOffOutline,
Expand All @@ -27,7 +28,7 @@ import { useColorScheme } from "@mui/material/styles";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { NavLink, Link as RouterLink, Outlet, useLocation, useNavigate } from "react-router-dom";

import { signOut, updateMe, type User } from "@/api/auth";
Expand Down Expand Up @@ -111,6 +112,12 @@ const NAV: (NavLinkItem | NavMenuItem)[] = [
icon: mdiCalculatorVariantOutline,
entries: [{ label: "Tax summary" }, { label: "TDS and taxes paid" }, { label: "Deductions (80C, 80D and others)" }, { label: "Old vs new regime" }],
},
{
kind: "menu",
label: "Stats",
icon: mdiChartBoxOutline,
entries: [{ label: "Spending by category" }, { label: "Income vs expense" }, { label: "Trends" }, { label: "Monthly report" }],
},
{ kind: "link", label: "Markets", icon: mdiFinance, to: "/markets" },
];

Expand All @@ -129,6 +136,11 @@ const navButtonSx = {
export function AppLayout() {
// The dashboard, accounts, transactions, markets and settings pages use the full width of the screen.
const path = useLocation().pathname;
// A new page starts at the top, not wherever the last one was scrolled to. Only the path counts, so changing a
// filter on the same page (which only changes the query string) keeps its place.
useEffect(() => {
window.scrollTo(0, 0);
}, [path]);
const fullWidth = path === "/" || ["/accounts", "/transactions", "/markets", "/settings"].some((prefix) => path.startsWith(prefix));
const maxWidth = fullWidth ? "none" : 1080;

Expand Down
Loading
0