Complete Phase 3, Phase 5 polish and hardening
Phase 3 — Investments: - Multi-currency support: holdings track purchase currency, FX rates convert to base for totals - Capital gains report using UK Section 104 pool method, grouped by tax year - Capital Gains tab added to Reports page Phase 5 — Polish & Hardening: - Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch - Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation - Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments - OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict - AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction - CLAUDE.md added for AI-assisted development context - README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
74e57a35c0
commit
fe4e69b9ad
40 changed files with 2079 additions and 127 deletions
|
|
@ -26,6 +26,8 @@ from app.schemas.report import (
|
|||
IncomeExpenseReport,
|
||||
NetWorthPoint,
|
||||
NetWorthReport,
|
||||
SavingsRatePoint,
|
||||
SavingsRateReport,
|
||||
SpendingTrendPoint,
|
||||
SpendingTrendsReport,
|
||||
)
|
||||
|
|
@ -402,6 +404,50 @@ async def get_balance_sheet(
|
|||
)
|
||||
|
||||
|
||||
async def get_savings_rate_report(
|
||||
db: AsyncSession, user_id: uuid.UUID, months: int = 12
|
||||
) -> SavingsRateReport:
|
||||
cutoff = date.today().replace(day=1) - relativedelta(months=months - 1)
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
TO_CHAR(date, 'YYYY-MM') AS month,
|
||||
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) AS income,
|
||||
SUM(CASE WHEN type = 'expense' THEN ABS(amount) ELSE 0 END) AS expenses
|
||||
FROM transactions
|
||||
WHERE user_id = CAST(:uid AS uuid)
|
||||
AND status != 'void'
|
||||
AND deleted_at IS NULL
|
||||
AND date >= :cutoff
|
||||
GROUP BY TO_CHAR(date, 'YYYY-MM')
|
||||
ORDER BY month ASC
|
||||
""").bindparams(uid=str(user_id), cutoff=cutoff)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
points = []
|
||||
for row in rows:
|
||||
inc = Decimal(str(row.income or 0))
|
||||
exp = Decimal(str(row.expenses or 0))
|
||||
savings = inc - exp
|
||||
rate = (savings / inc * 100).quantize(Decimal("0.01")) if inc > 0 else Decimal("0")
|
||||
points.append(SavingsRatePoint(
|
||||
month=row.month,
|
||||
income=inc,
|
||||
expenses=exp,
|
||||
savings=savings,
|
||||
savings_rate=rate,
|
||||
))
|
||||
|
||||
n = len(points) or 1
|
||||
avg_rate = sum(p.savings_rate for p in points) / n
|
||||
return SavingsRateReport(
|
||||
points=points,
|
||||
avg_savings_rate=avg_rate.quantize(Decimal("0.01")),
|
||||
currency="GBP",
|
||||
)
|
||||
|
||||
|
||||
async def take_net_worth_snapshot(db: AsyncSession, user_id: uuid.UUID, base_currency: str) -> None:
|
||||
today = date.today()
|
||||
existing = await db.execute(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue