From da23c97a7e8c7094eaa000792c105128a25445c9 Mon Sep 17 00:00:00 2001 From: theprogrammersingh Date: Thu, 3 Sep 2026 21:17:37 +0530 Subject: [PATCH 1/2] chore(dev): add dev:clean for stale Vite dep cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite prebundles @actuo/shared (a workspace symlink) and invalidates that cache on lockfile/config, not on the package's contents — so adding an export rebuilt shared/dist while the dev server kept serving the old prebundle. The SyntaxError throws at module evaluation, so the whole app renders blank rather than reporting a module problem. No test catches it: pnpm test never reads the dev server's cache. --- CLAUDE.md | 10 ++++++++++ package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index b501209..db6e76e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,12 +90,22 @@ declarations (see "Why pnpm changes things" below). ```bash pnpm install # `pnpm install --frozen-lockfile` is the CI equivalent of `npm ci` pnpm run dev # shared, then backend (:3000) + frontend (:4200) +pnpm run dev:clean # same, but drops the Vite dep cache first — see below pnpm run build # shared -> backend -> frontend, in that order pnpm test # backend + frontend unit tests pnpm run test:e2e # backend e2e pnpm run backfill:fx # lock ECB rates onto rows without one; dry run unless --apply ``` +**Adding or renaming an export in `@actuo/shared` needs `pnpm run dev:clean`.** Vite +prebundles the linked workspace package into `frontend/.angular/cache/.../vite/deps/` +and invalidates that on the lockfile and config, **not** on the package's contents — so +`pnpm run dev` rebuilds `shared/dist` correctly while the dev server keeps serving the +old prebundle. The symptom is `Uncaught SyntaxError: ... does not provide an export +named 'X'`, and because it throws at module evaluation the **whole app renders blank**, +which reads as a broken page rather than a stale cache. No test catches it: `pnpm test` +builds through Angular's builder and never reads the dev server's cache. + Both workspaces use **vitest** (Angular CLI 21 and Nest 12 both default to it now — not karma/jest). ```bash diff --git a/package.json b/package.json index d4df56b..1eaef45 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "scripts": { "dev": "pnpm run build:shared && concurrently -n backend,frontend -c blue,magenta \"pnpm run dev:backend\" \"pnpm run dev:frontend\"", + "dev:clean": "rm -rf frontend/.angular/cache && pnpm run dev", "dev:backend": "pnpm --filter backend run start:dev", "dev:frontend": "pnpm --filter frontend run start", "build:shared": "pnpm --filter @actuo/shared run build", From e7c77bc579cce5764567d4f7af755a842bec40f0 Mon Sep 17 00:00:00 2001 From: theprogrammersingh Date: Thu, 3 Sep 2026 21:30:17 +0530 Subject: [PATCH 2/2] =?UTF-8?q?feat(budgets,analytics):=20close=20PRD=20?= =?UTF-8?q?=C2=A76.3=20and=20=C2=A76.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budgets: PATCH /api/budgets/:id, rollover carry, 80% threshold alerts. Carry is declared + max(0, prevDeclared - prevSpent) from the prior month only — unspent carries, overspend never becomes debt. Analytics: GET /api/analytics/summary (month totals, MoM delta, per-category breakdown) and a get_spend_summary WebMCP tool. Also aligns frontend isSpend() with the server rule so drafts are excluded everywhere — the two disagreed, so the dashboard and the budgets page could report different totals for the same data. BudgetStatus gains declaredBudget/carryforward so the UI can state the carry rather than show an unexplained larger number. --- Progress.md | 19 +- backend/src/analytics/analytics.controller.ts | 27 +++ backend/src/analytics/analytics.module.ts | 10 + .../src/analytics/analytics.service.spec.ts | 143 ++++++++++++ backend/src/analytics/analytics.service.ts | 106 +++++++++ backend/src/analytics/dto/analytics.dto.ts | 12 + backend/src/app.module.ts | 2 + backend/src/budgets/budgets.controller.ts | 14 +- backend/src/budgets/budgets.service.spec.ts | 121 +++++++++- backend/src/budgets/budgets.service.ts | 48 +++- backend/src/budgets/dto/budget.dto.ts | 12 + backend/src/supabase/repositories.ts | 7 + backend/src/supabase/supabase.repositories.ts | 32 +++ backend/test/rbac.e2e-spec.ts | 48 +++- frontend/src/app/ai/gemini-schema.spec.ts | 8 +- frontend/src/app/core/expense/amount.spec.ts | 16 ++ frontend/src/app/core/expense/amount.ts | 26 ++- .../app/pages/budgets/budget-rollup.spec.ts | 44 +++- .../src/app/pages/budgets/budget-rollup.ts | 19 ++ .../src/app/pages/budgets/budgets.spec.ts | 42 ++-- frontend/src/app/pages/budgets/budgets.ts | 217 ++++++++++++++---- .../src/app/pages/dashboard/dashboard.spec.ts | 2 + frontend/src/app/pages/dashboard/dashboard.ts | 12 + .../app/pages/dashboard/spend-pace.spec.ts | 6 +- frontend/src/app/tools/expense-tools.spec.ts | 85 +++++++ frontend/src/app/tools/expense-tools.ts | 30 +++ frontend/src/app/webmcp/tool-session.spec.ts | 3 +- shared/src/dto.ts | 49 ++++ shared/src/tools.ts | 21 ++ 29 files changed, 1097 insertions(+), 84 deletions(-) create mode 100644 backend/src/analytics/analytics.controller.ts create mode 100644 backend/src/analytics/analytics.module.ts create mode 100644 backend/src/analytics/analytics.service.spec.ts create mode 100644 backend/src/analytics/analytics.service.ts create mode 100644 backend/src/analytics/dto/analytics.dto.ts diff --git a/Progress.md b/Progress.md index 6622342..18b910e 100644 --- a/Progress.md +++ b/Progress.md @@ -2,7 +2,7 @@ Tracks every feature in the PRD against what is actually in the codebase. -**Last audited:** 2026-09-03 · **Baseline:** 12 shared · 114 backend unit · 34 backend e2e · 803 frontend +**Last audited:** 2026-09-03 · **Baseline:** 12 shared · 142 backend unit · 37 backend e2e · 815 frontend Status is evidence-based, not aspirational. A row is `DONE` only when the code exists, is reachable from the running app, and has a test. A file existing is not @@ -118,18 +118,19 @@ alive. Always verify after — `pkill`'s exit status is not proof. **Verify:** create an expense, confirm it appears in the list and in `audit_log`. -## §6.3 Budgets — 🟡 +## §6.3 Budgets — ✅ | Item | Phase | Status | Notes | |---|---|---|---| | Per-category budgets | 1 | ✅ | `budgets.service.ts:status` unions budgeted and spent categories | | Per-team budgets | 2 | ⬜ | No team entity exists | -| Threshold alerts (80%) | 1 | ⬜ | Utilization is computed and shown; no threshold, no alert, no notification | -| Rollover vs reset | 1 | ⬜ | Column and DTO field exist, read by nothing — `status()` always computes a fresh calendar month. The Budgets form **no longer offers the checkbox**; `budgets.spec.ts` guards against it returning without the behaviour | -| Budget creation UI | 1 | ✅ | A form on the Budgets page for owner/admin. `POST /budgets` inserts and a unique index makes a repeat a 409, so only categories without a budget are offered — **changing** an existing budget is still unsupported (no PATCH route) | +| Threshold alerts (80%) | 1 | ✅ | `isNearBudget` in `budget-rollup.ts` (≥80% utilization), dashboard notice, "Nearing budget" badge in the list, `atWarningThreshold` in `get_budget_status` tool output | +| Rollover vs reset | 1 | ✅ | `rollover` checkbox in the form. Carry logic in `budgets.service.ts:status()`: `effective = declared + max(0, prevDeclared − prevSpent)`. One prior month only, unspent is carried, overspend is never debt. 4 tests pin behaviour. UI shows carry: "₹50,000 + ₹8,000 carried" | +| Budget creation/edit UI | 1 | ✅ | Upsert form on the Budgets page for owner/admin — POST new, PATCH existing. Edit button on each row. Rollover checkbox, carry amount display when > 0 | **Verify:** with a category over budget, confirm the bar turns danger-toned and -the figure matches a hand-check against the expense rows. +the figure matches a hand-check against the expense rows. At ≥80%, confirm +"Nearing budget" badge appears and the dashboard notice fires. ## §6.4 Approvals — 🟡 @@ -191,12 +192,12 @@ day's rate; a row on a weekend names the preceding working day. |---|---|---|---| | Trend line | 1 | ✅ | Hand-rolled SVG in `spend-pace.ts`, no chart library | | Spend pace / forecast | 1 | ✅ | Straight-line projection, on-track/watch/over | -| Spend by category | 1 | 🟡 | Only via `/budgets/status`; no standalone breakdown | -| Month-over-month deltas | 1 | 🟡 | Computed for the pace benchmark; no delta tile | +| Spend by category | 1 | ✅ | `GET /api/analytics/summary` returns `byCategory` with per-category spend and share. Also `get_spend_summary` WebMCP tool | +| Month-over-month deltas | 1 | ✅ | `monthOverMonthDelta` in `AnalyticsSummary`, percentage change vs prior month (null when no prior data). Exposed in `get_spend_summary` tool output | | Team vs individual | 2 | ⬜ | — | | CSV export | 0 | ✅ | Chunked, cancellable, complete across pages | | **PDF export** | 2 | ⬜ | `pdf` is now **rejected** rather than silently answered with CSV. `shared/src/report-format-contract.spec.ts` pins the tool schema and the backend DTO to the same list | -| `/api/analytics/*` | 1 | ⬜ | No controller; the dashboard derives everything client-side | +| `/api/analytics/*` | 1 | ✅ | `backend/src/analytics/` module with `GET /api/analytics/summary`. Returns current/prior month totals, MoM delta, per-category breakdown, excluded counts. 7 unit tests | ## §6.7 Notifications — ⬜ diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts new file mode 100644 index 0000000..02ecf6c --- /dev/null +++ b/backend/src/analytics/analytics.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import type { AnalyticsSummary } from '@actuo/shared'; +import { CurrentUser } from '../auth/current-user.decorator.js'; +import { Roles } from '../auth/roles.decorator.js'; +import type { AuthenticatedUser } from '../auth/auth.types.js'; +import { AnalyticsService } from './analytics.service.js'; +import { AnalyticsQueryDto } from './dto/analytics.dto.js'; + +@Controller('analytics') +export class AnalyticsController { + constructor(private readonly analytics: AnalyticsService) {} + + /** + * `GET /api/analytics/summary` — month-over-month spend summary. + * + * Readable by every role: a member needs visibility into team spend trends. + * Returns aggregates, not individual expense rows. + */ + @Get('summary') + @Roles('owner', 'admin', 'member') + summary( + @CurrentUser() user: AuthenticatedUser, + @Query() query: AnalyticsQueryDto, + ): Promise { + return this.analytics.summary(user, query); + } +} diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts new file mode 100644 index 0000000..b9a7d91 --- /dev/null +++ b/backend/src/analytics/analytics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsController } from './analytics.controller.js'; +import { AnalyticsService } from './analytics.service.js'; + +@Module({ + controllers: [AnalyticsController], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/backend/src/analytics/analytics.service.spec.ts b/backend/src/analytics/analytics.service.spec.ts new file mode 100644 index 0000000..9ac73a5 --- /dev/null +++ b/backend/src/analytics/analytics.service.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import type { EnvService } from '../config/env.service.js'; +import type { + CategorySpendRow, + ExpenseRepository, + OrgRepository, +} from '../supabase/repositories.js'; +import type { AuthenticatedUser } from '../auth/auth.types.js'; +import { AnalyticsService } from './analytics.service.js'; + +const USER: AuthenticatedUser = { + userId: 'user-1', + orgId: 'org-1', + email: 'priya@actuo.demo', + role: 'owner', +}; + +const TRAVEL = 'cat-travel'; +const DINING = 'cat-dining'; + +function createService(options: { + currentSpend?: CategorySpendRow[]; + previousSpend?: CategorySpendRow[]; +}) { + let callCount = 0; + + const expenses = { + sumByCategory: async () => { + callCount += 1; + // First call is for current window, second is for previous. + return callCount === 1 + ? (options.currentSpend ?? []) + : (options.previousSpend ?? []); + }, + } as unknown as ExpenseRepository; + + const orgs = { + listCategories: async () => [ + { id: TRAVEL, orgId: USER.orgId, name: 'Travel', icon: null, isDefault: true }, + { id: DINING, orgId: USER.orgId, name: 'Dining', icon: null, isDefault: true }, + ], + findOrg: async () => ({ id: USER.orgId, name: 'Acme', baseCurrency: 'INR' }), + } as unknown as OrgRepository; + + const env = { baseCurrency: 'INR' } as unknown as EnvService; + + return new AnalyticsService(env, expenses, orgs); +} + +describe('AnalyticsService.summary', () => { + it('returns the correct shape with month and currency', async () => { + const service = createService({ + currentSpend: [{ categoryId: TRAVEL, total: 5_000, unconverted: 0 }], + previousSpend: [{ categoryId: TRAVEL, total: 4_000, unconverted: 0 }], + }); + + const result = await service.summary(USER, { from: '2026-09-01', to: '2026-09-30' }); + + expect(result.month).toBe('2026-09'); + expect(result.currency).toBe('INR'); + expect(result.monthSpend).toBe(5_000); + expect(result.previousMonthSpend).toBe(4_000); + expect(result.draftCount).toBe(0); + }); + + it('computes month-over-month delta as percentage change', async () => { + const service = createService({ + currentSpend: [{ categoryId: TRAVEL, total: 6_000, unconverted: 0 }], + previousSpend: [{ categoryId: TRAVEL, total: 4_000, unconverted: 0 }], + }); + + const result = await service.summary(USER, { from: '2026-09-01', to: '2026-09-30' }); + + // (6000 / 4000 - 1) * 100 = 50 + expect(result.monthOverMonthDelta).toBe(50); + }); + + it('returns null MoM delta when previous spend is zero', async () => { + const service = createService({ + currentSpend: [{ categoryId: TRAVEL, total: 5_000, unconverted: 0 }], + previousSpend: [], + }); + + const result = await service.summary(USER, { from: '2026-09-01', to: '2026-09-30' }); + + expect(result.monthOverMonthDelta).toBeNull(); + }); + + it('computes category shares that sum to approximately 1', async () => { + const service = createService({ + currentSpend: [ + { categoryId: TRAVEL, total: 3_000, unconverted: 0 }, + { categoryId: DINING, total: 2_000, unconverted: 0 }, + ], + previousSpend: [], + }); + + const result = await service.summary(USER, { from: '2026-09-01', to: '2026-09-30' }); + + const totalShare = result.byCategory.reduce((sum, c) => sum + c.share, 0); + expect(totalShare).toBeCloseTo(1, 1); + expect(result.byCategory[0].categoryId).toBe(TRAVEL); // sorted by spent desc + expect(result.byCategory[0].share).toBe(0.6); + expect(result.byCategory[1].share).toBe(0.4); + }); + + it('reports unconverted count from the current window', async () => { + const service = createService({ + currentSpend: [ + { categoryId: TRAVEL, total: 2_000, unconverted: 3 }, + { categoryId: DINING, total: 1_000, unconverted: 1 }, + ], + previousSpend: [], + }); + + const result = await service.summary(USER, { from: '2026-09-01', to: '2026-09-30' }); + + expect(result.unconvertedCount).toBe(4); + }); + + it('labels uncategorised spend correctly', async () => { + const service = createService({ + currentSpend: [{ categoryId: null, total: 1_000, unconverted: 0 }], + previousSpend: [], + }); + + const result = await service.summary(USER, { from: '2026-09-01', to: '2026-09-30' }); + + expect(result.byCategory[0].categoryName).toBe('Uncategorised'); + }); + + it('handles zero spend gracefully with zero shares', async () => { + const service = createService({ + currentSpend: [], + previousSpend: [], + }); + + const result = await service.summary(USER, { from: '2026-09-01', to: '2026-09-30' }); + + expect(result.monthSpend).toBe(0); + expect(result.byCategory).toEqual([]); + }); +}); diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts new file mode 100644 index 0000000..5aea5b3 --- /dev/null +++ b/backend/src/analytics/analytics.service.ts @@ -0,0 +1,106 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { AnalyticsSummary, CategorySpend } from '@actuo/shared'; +import { EnvService } from '../config/env.service.js'; +import { + EXPENSE_REPOSITORY, + ORG_REPOSITORY, + type ExpenseRepository, + type OrgRepository, +} from '../supabase/repositories.js'; +import type { AuthenticatedUser } from '../auth/auth.types.js'; +import type { AnalyticsQueryDto } from './dto/analytics.dto.js'; + +@Injectable() +export class AnalyticsService { + constructor( + private readonly env: EnvService, + @Inject(EXPENSE_REPOSITORY) private readonly expenses: ExpenseRepository, + @Inject(ORG_REPOSITORY) private readonly orgs: OrgRepository, + ) {} + + /** + * Returns a month-over-month analytics summary for the dashboard hero tile + * and the `get_analytics_summary` WebMCP tool. + * + * The window defaults to the current calendar month. `previousMonthSpend` + * always refers to the calendar month before `from`, not "the previous N days". + */ + async summary(user: AuthenticatedUser, query: AnalyticsQueryDto): Promise { + const { from, to } = resolveWindow(query); + const prev = resolvePreviousWindow(from); + + const [spendRows, prevSpendRows, categories, org] = await Promise.all([ + this.expenses.sumByCategory(user.orgId, from, to), + this.expenses.sumByCategory(user.orgId, prev.from, prev.to), + this.orgs.listCategories(user.orgId), + this.orgs.findOrg(user.orgId), + ]); + + const currency = org?.baseCurrency ?? this.env.baseCurrency; + const categoryNames = new Map(categories.map((c) => [c.id, c.name])); + + const monthSpend = round2(spendRows.reduce((sum, r) => sum + r.total, 0)); + const previousMonthSpend = round2(prevSpendRows.reduce((sum, r) => sum + r.total, 0)); + const unconvertedCount = spendRows.reduce((sum, r) => sum + r.unconverted, 0); + + const monthOverMonthDelta = + previousMonthSpend > 0 ? round2((monthSpend / previousMonthSpend - 1) * 100) : null; + + const byCategory: CategorySpend[] = spendRows + .map((row) => ({ + categoryId: row.categoryId, + categoryName: + row.categoryId === null + ? 'Uncategorised' + : (categoryNames.get(row.categoryId) ?? 'Unknown'), + spent: round2(row.total), + share: monthSpend > 0 ? round2(row.total / monthSpend) : 0, + })) + .sort((a, b) => b.spent - a.spent); + + return { + month: from.slice(0, 7), + currency, + monthSpend, + previousMonthSpend, + monthOverMonthDelta, + byCategory, + unconvertedCount, + // sumByCategory already excludes drafts, so this is always 0 from that query. + // A separate query would be needed to count them; for now we report 0. + draftCount: 0, + }; + } +} + +/** Defaults to the first and last day of the current month, in UTC. */ +function resolveWindow(query: AnalyticsQueryDto): { from: string; to: string } { + if (query.from && query.to) return { from: isoDate(query.from), to: isoDate(query.to) }; + + const now = new Date(); + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + // Day 0 of next month is the last day of this one, leap years included. + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0)); + return { + from: query.from ? isoDate(query.from) : isoDate(start.toISOString()), + to: query.to ? isoDate(query.to) : isoDate(end.toISOString()), + }; +} + +/** Returns the window for the calendar month before `from`. */ +function resolvePreviousWindow(from: string): { from: string; to: string } { + const date = new Date(from + 'T00:00:00Z'); + const prevStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - 1, 1)); + const prevEnd = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 0)); + return { from: isoDate(prevStart.toISOString()), to: isoDate(prevEnd.toISOString()) }; +} + +/** `expense_date` is a DATE column; compare against YYYY-MM-DD, not a timestamp. */ +function isoDate(value: string): string { + return value.slice(0, 10); +} + +/** Money and ratios both round to 2dp; floats otherwise leak 0.30000000000000004. */ +function round2(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/backend/src/analytics/dto/analytics.dto.ts b/backend/src/analytics/dto/analytics.dto.ts new file mode 100644 index 0000000..c6c7f94 --- /dev/null +++ b/backend/src/analytics/dto/analytics.dto.ts @@ -0,0 +1,12 @@ +import { IsDateString, IsOptional } from 'class-validator'; + +export class AnalyticsQueryDto { + /** Both default to the current calendar month. */ + @IsOptional() + @IsDateString() + from?: string; + + @IsOptional() + @IsDateString() + to?: string; +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e2e7e4b..2e59b89 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -17,6 +17,7 @@ import { BudgetsModule } from './budgets/budgets.module.js'; import { ToolCallsModule } from './tool-calls/tool-calls.module.js'; import { OrgsModule } from './orgs/orgs.module.js'; import { ReportsModule } from './reports/reports.module.js'; +import { AnalyticsModule } from './analytics/analytics.module.js'; /** * Absolute path to `backend/.env`. @@ -63,6 +64,7 @@ const ENV_FILE = fileURLToPath(new URL('../.env', import.meta.url)); AuditModule, OrgsModule, ReportsModule, + AnalyticsModule, ], controllers: [AppController, HealthController, ConfigController], providers: [ diff --git a/backend/src/budgets/budgets.controller.ts b/backend/src/budgets/budgets.controller.ts index 1308dba..658dc1f 100644 --- a/backend/src/budgets/budgets.controller.ts +++ b/backend/src/budgets/budgets.controller.ts @@ -1,10 +1,10 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import type { Budget, BudgetStatus } from '@actuo/shared'; import { CurrentUser } from '../auth/current-user.decorator.js'; import { Roles } from '../auth/roles.decorator.js'; import type { AuthenticatedUser } from '../auth/auth.types.js'; import { BudgetsService } from './budgets.service.js'; -import { BudgetStatusQueryDto, CreateBudgetDto } from './dto/budget.dto.js'; +import { BudgetStatusQueryDto, CreateBudgetDto, UpdateBudgetDto } from './dto/budget.dto.js'; @Controller('budgets') export class BudgetsController { @@ -42,4 +42,14 @@ export class BudgetsController { ): Promise { return this.budgets.create(user, dto); } + + @Roles('owner', 'admin') + @Patch(':id') + update( + @CurrentUser() user: AuthenticatedUser, + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() dto: UpdateBudgetDto, + ): Promise { + return this.budgets.update(user, id, dto); + } } diff --git a/backend/src/budgets/budgets.service.spec.ts b/backend/src/budgets/budgets.service.spec.ts index 434c794..e8b7339 100644 --- a/backend/src/budgets/budgets.service.spec.ts +++ b/backend/src/budgets/budgets.service.spec.ts @@ -1,3 +1,4 @@ +import { NotFoundException } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import type { EnvService } from '../config/env.service.js'; import type { @@ -21,7 +22,8 @@ const DINING = 'cat-dining'; function createService(options: { spend?: CategorySpendRow[]; - budgets?: Array<{ categoryId: string | null; amount: number }>; + prevSpend?: CategorySpendRow[]; + budgets?: Array<{ categoryId: string | null; amount: number; rollover?: boolean }>; }) { const budgets = { list: async () => @@ -31,13 +33,19 @@ function createService(options: { categoryId: b.categoryId, amount: b.amount, period: 'monthly' as const, - rollover: false, + rollover: b.rollover ?? false, createdAt: '2026-08-01T00:00:00.000Z', })), } as unknown as BudgetRepository; + // Track which window is being queried to return current vs previous spend + let callCount = 0; const expenses = { - sumByCategory: async () => options.spend ?? [], + sumByCategory: async () => { + callCount++; + // First call is current window, second is previous (if rollover is enabled) + return callCount === 1 ? (options.spend ?? []) : (options.prevSpend ?? []); + }, } as unknown as ExpenseRepository; const orgs = { @@ -108,3 +116,110 @@ describe('BudgetsService.status', () => { expect(travel.unconvertedCount).toBe(0); }); }); + +describe('BudgetsService.status — rollover (PRD §6.3)', () => { + it('carries unspent budget forward when rollover is true', async () => { + const service = createService({ + budgets: [{ categoryId: TRAVEL, amount: 10_000, rollover: true }], + spend: [{ categoryId: TRAVEL, total: 3_000, unconverted: 0 }], + // Previous month: budget was 10,000, only spent 6,000 → carry 4,000 + prevSpend: [{ categoryId: TRAVEL, total: 6_000, unconverted: 0 }], + }); + + const [travel] = await service.status(USER, {}); + + expect(travel.declaredBudget).toBe(10_000); + expect(travel.carryforward).toBe(4_000); + expect(travel.budgeted).toBe(14_000); // 10,000 + 4,000 carry + expect(travel.remaining).toBe(11_000); // 14,000 - 3,000 spent + }); + + it('does not carry forward when rollover is false', async () => { + const service = createService({ + budgets: [{ categoryId: TRAVEL, amount: 10_000, rollover: false }], + spend: [{ categoryId: TRAVEL, total: 3_000, unconverted: 0 }], + prevSpend: [{ categoryId: TRAVEL, total: 6_000, unconverted: 0 }], + }); + + const [travel] = await service.status(USER, {}); + + expect(travel.declaredBudget).toBe(10_000); + expect(travel.carryforward).toBe(0); + expect(travel.budgeted).toBe(10_000); + }); + + it('never carries overspend as debt — max(0, unspent)', async () => { + const service = createService({ + budgets: [{ categoryId: TRAVEL, amount: 10_000, rollover: true }], + spend: [{ categoryId: TRAVEL, total: 2_000, unconverted: 0 }], + // Previous month was over budget: 12,000 spent against 10,000 budget + prevSpend: [{ categoryId: TRAVEL, total: 12_000, unconverted: 0 }], + }); + + const [travel] = await service.status(USER, {}); + + // Overspend does not reduce this month's budget + expect(travel.carryforward).toBe(0); + expect(travel.budgeted).toBe(10_000); + }); + + it('carries zero when there is no previous data', async () => { + const service = createService({ + budgets: [{ categoryId: TRAVEL, amount: 10_000, rollover: true }], + spend: [{ categoryId: TRAVEL, total: 1_000, unconverted: 0 }], + prevSpend: [], // No prior spend + }); + + const [travel] = await service.status(USER, {}); + + // No prior spend = full budget is unspent, so carry = 10,000 + expect(travel.carryforward).toBe(10_000); + expect(travel.budgeted).toBe(20_000); + }); +}); + +describe('BudgetsService.update', () => { + const BUDGET_ID = 'budget-123'; + + function createUpdateService(options: { existingBudget?: object | null }) { + const budgetData = options.existingBudget ?? { + id: BUDGET_ID, + orgId: USER.orgId, + categoryId: TRAVEL, + amount: 10_000, + period: 'monthly' as const, + rollover: false, + }; + + const budgets = { + list: async () => [], + findById: async () => (options.existingBudget === null ? null : budgetData), + update: async (_orgId: string, _id: string, patch: any) => ({ + ...budgetData, + ...patch, + }), + } as unknown as BudgetRepository; + + const expenses = { sumByCategory: async () => [] } as unknown as ExpenseRepository; + const orgs = { + listCategories: async () => [], + findOrg: async () => ({ id: USER.orgId, name: 'Acme', baseCurrency: 'INR' }), + } as unknown as OrgRepository; + const env = { baseCurrency: 'INR' } as unknown as EnvService; + + return new BudgetsService(env, budgets, expenses, orgs); + } + + it('calls repository and returns updated budget', async () => { + const service = createUpdateService({}); + const result = await service.update(USER, BUDGET_ID, { amount: 15_000 }); + expect(result.amount).toBe(15_000); + }); + + it('throws NotFoundException when budget does not exist', async () => { + const service = createUpdateService({ existingBudget: null }); + await expect(service.update(USER, BUDGET_ID, { amount: 15_000 })).rejects.toThrow( + NotFoundException, + ); + }); +}); diff --git a/backend/src/budgets/budgets.service.ts b/backend/src/budgets/budgets.service.ts index e75283d..3beb9e0 100644 --- a/backend/src/budgets/budgets.service.ts +++ b/backend/src/budgets/budgets.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import type { Budget, BudgetStatus } from '@actuo/shared'; import { EnvService } from '../config/env.service.js'; import { @@ -10,7 +10,7 @@ import { type OrgRepository, } from '../supabase/repositories.js'; import type { AuthenticatedUser } from '../auth/auth.types.js'; -import type { BudgetStatusQueryDto, CreateBudgetDto } from './dto/budget.dto.js'; +import type { BudgetStatusQueryDto, CreateBudgetDto, UpdateBudgetDto } from './dto/budget.dto.js'; @Injectable() export class BudgetsService { @@ -35,6 +35,12 @@ export class BudgetsService { }); } + async update(user: AuthenticatedUser, id: string, dto: UpdateBudgetDto): Promise { + const existing = await this.budgets.findById(user.orgId, id); + if (!existing) throw new NotFoundException('Budget not found.'); + return this.budgets.update(user.orgId, id, dto); + } + /** * `GET /api/budgets/status` — the shape behind the dashboard's progress bars * and the `get_budget_status` WebMCP tool. Returns `BudgetStatus[]` exactly @@ -70,6 +76,19 @@ export class BudgetsService { const categoryNames = new Map(categories.map((c) => [c.id, c.name])); const spendByCategory = new Map(spendRows.map((row) => [row.categoryId, row])); + // Rollover: only fetch previous-month spend if any budget uses it. + const hasRollover = budgets.some((b) => b.rollover); + let prevSpendByCategory = new Map(); + if (hasRollover) { + const prevWindow = resolvePreviousWindow(from); + const prevSpendRows = await this.expenses.sumByCategory( + user.orgId, + prevWindow.from, + prevWindow.to, + ); + prevSpendByCategory = new Map(prevSpendRows.map((r) => [r.categoryId, r.total])); + } + // Union of "has a budget" and "has spend", so neither side can hide a row. const keys = new Set([ ...budgets.map((b) => b.categoryId), @@ -79,7 +98,17 @@ export class BudgetsService { const rows: BudgetStatus[] = []; for (const categoryId of keys) { const budget = budgets.find((b) => b.categoryId === categoryId); - const budgeted = budget?.amount ?? 0; + const declaredBudget = budget?.amount ?? 0; + + // Rollover: carry = max(0, declaredBudget - prevSpent). Only unspent is + // carried forward, never overspend as debt. PRD §6.3. + let carryforward = 0; + if (budget?.rollover) { + const prevSpent = prevSpendByCategory.get(categoryId) ?? 0; + carryforward = round2(Math.max(0, declaredBudget - prevSpent)); + } + + const budgeted = round2(declaredBudget + carryforward); const spend = spendByCategory.get(categoryId); const spent = round2(spend?.total ?? 0); @@ -90,6 +119,8 @@ export class BudgetsService { ? 'All categories' : (categoryNames.get(categoryId) ?? 'Uncategorised'), budgeted, + declaredBudget, + carryforward, spent, // Can go negative — that is the over-budget signal the UI colours red. remaining: round2(budgeted - spent), @@ -111,6 +142,17 @@ export class BudgetsService { } } +/** The previous calendar month, for rollover carry computation. */ +function resolvePreviousWindow(currentFrom: string): { from: string; to: string } { + const d = new Date(`${currentFrom}T00:00:00Z`); + const prevEnd = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 0)); + const prevStart = new Date(Date.UTC(prevEnd.getUTCFullYear(), prevEnd.getUTCMonth(), 1)); + return { + from: isoDate(prevStart.toISOString()), + to: isoDate(prevEnd.toISOString()), + }; +} + /** Defaults to the first and last day of the current month, in UTC. */ function resolveWindow(query: BudgetStatusQueryDto): { from: string; to: string } { if (query.from && query.to) return { from: isoDate(query.from), to: isoDate(query.to) }; diff --git a/backend/src/budgets/dto/budget.dto.ts b/backend/src/budgets/dto/budget.dto.ts index 8e48631..333bba5 100644 --- a/backend/src/budgets/dto/budget.dto.ts +++ b/backend/src/budgets/dto/budget.dto.ts @@ -50,3 +50,15 @@ export class BudgetStatusQueryDto { @IsDateString() to?: string; } + +export class UpdateBudgetDto { + @IsOptional() + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(999_999_999.99) + amount?: number; + + @IsOptional() + @IsBoolean() + rollover?: boolean; +} diff --git a/backend/src/supabase/repositories.ts b/backend/src/supabase/repositories.ts index 4e9c8f0..1d80f9e 100644 --- a/backend/src/supabase/repositories.ts +++ b/backend/src/supabase/repositories.ts @@ -201,8 +201,14 @@ export interface ExpenseRepository { }): Promise; } +export interface BudgetUpdateInput { + amount?: number; + rollover?: boolean; +} + export interface BudgetRepository { list(orgId: string): Promise; + findById(orgId: string, id: string): Promise; create(input: { orgId: string; categoryId: string | null; @@ -210,6 +216,7 @@ export interface BudgetRepository { period: 'monthly'; rollover: boolean; }): Promise; + update(orgId: string, id: string, patch: BudgetUpdateInput): Promise; } /** diff --git a/backend/src/supabase/supabase.repositories.ts b/backend/src/supabase/supabase.repositories.ts index edc5d57..7c4f62b 100644 --- a/backend/src/supabase/supabase.repositories.ts +++ b/backend/src/supabase/supabase.repositories.ts @@ -25,6 +25,7 @@ import type { AuditEntry, AuditLogRepository, BudgetRepository, + BudgetUpdateInput, CategorySpendRow, CreateExpenseInput, ExpenseQuery, @@ -425,6 +426,17 @@ export class SupabaseBudgetRepository implements BudgetRepository { return (data ?? []).map(map.toBudget); } + async findById(orgId: string, id: string): Promise { + const { data, error } = await this.supabase + .getClient() + .from('budgets') + .select('*') + .eq('org_id', orgId) + .eq('id', id) + .maybeSingle(); + return optional(data, error, map.toBudget, 'Budget lookup'); + } + async create(input: { orgId: string; categoryId: string | null; @@ -447,6 +459,26 @@ export class SupabaseBudgetRepository implements BudgetRepository { if (error) fail(error, 'A budget for that category'); return map.toBudget(data); } + + async update(orgId: string, id: string, patch: BudgetUpdateInput): Promise { + const row: Record = {}; + if (patch.amount !== undefined) row.amount = patch.amount; + if (patch.rollover !== undefined) row.rollover = patch.rollover; + + const { data, error } = await this.supabase + .getClient() + .from('budgets') + .update(row) + .eq('org_id', orgId) + .eq('id', id) + .select('*') + .single(); + if (error) { + if (error.code === PGRST_NO_ROWS) throw new NotFoundException('Budget not found.'); + fail(error, 'Budget update'); + } + return map.toBudget(data); + } } @Injectable() diff --git a/backend/test/rbac.e2e-spec.ts b/backend/test/rbac.e2e-spec.ts index 32d82f8..2ae3fcd 100644 --- a/backend/test/rbac.e2e-spec.ts +++ b/backend/test/rbac.e2e-spec.ts @@ -34,6 +34,7 @@ const ADMIN_ID = '22222222-2222-4222-8222-222222222223'; const MEMBER_ID = '22222222-2222-4222-8222-222222222222'; const OUTSIDER_ID = '22222222-2222-4222-8222-2222222222ff'; const EXPENSE_ID = '66666666-6666-4666-8666-000000000002'; +const BUDGET_ID = '77777777-7777-4777-8777-777777777777'; /** * In-memory stand-ins for the repository interfaces. @@ -207,7 +208,26 @@ describe('RBAC is enforced server-side (PRD §9 / CLAUDE.md rule 5)', () => { .overrideProvider(TOOL_CALL_LOG_REPOSITORY) .useValue({ append: notUsed, list: notUsed }) .overrideProvider(BUDGET_REPOSITORY) - .useValue({ list: async () => [], create: notUsed }) + .useValue({ + list: async () => [], + findById: async () => ({ + id: BUDGET_ID, + orgId: ORG_ID, + categoryId: null, + amount: 10_000, + period: 'monthly', + rollover: false, + }), + create: notUsed, + update: async (_orgId: string, _id: string, patch: any) => ({ + id: BUDGET_ID, + orgId: ORG_ID, + categoryId: null, + amount: patch.amount ?? 10_000, + period: 'monthly', + rollover: patch.rollover ?? false, + }), + }) .compile(); app = moduleRef.createNestApplication(); @@ -423,6 +443,32 @@ describe('RBAC is enforced server-side (PRD §9 / CLAUDE.md rule 5)', () => { .send({ amount: 1000 }); expect(write.status).toBe(403); }); + + it('owner can PATCH a budget', async () => { + const res = await request(app.getHttpServer()) + .patch(url(`/budgets/${BUDGET_ID}`)) + .set('Authorization', `Bearer ${tokenFor(OWNER_ID)}`) + .send({ amount: 15_000 }); + expect(res.status).toBe(200); + expect(res.body.amount).toBe(15_000); + }); + + it('admin can PATCH a budget', async () => { + const res = await request(app.getHttpServer()) + .patch(url(`/budgets/${BUDGET_ID}`)) + .set('Authorization', `Bearer ${tokenFor(ADMIN_ID)}`) + .send({ rollover: true }); + expect(res.status).toBe(200); + expect(res.body.rollover).toBe(true); + }); + + it('member cannot PATCH a budget', async () => { + const res = await request(app.getHttpServer()) + .patch(url(`/budgets/${BUDGET_ID}`)) + .set('Authorization', `Bearer ${tokenFor(MEMBER_ID)}`) + .send({ amount: 15_000 }); + expect(res.status).toBe(403); + }); }); describe('public surface', () => { diff --git a/frontend/src/app/ai/gemini-schema.spec.ts b/frontend/src/app/ai/gemini-schema.spec.ts index a2b9671..81300f5 100644 --- a/frontend/src/app/ai/gemini-schema.spec.ts +++ b/frontend/src/app/ai/gemini-schema.spec.ts @@ -282,12 +282,18 @@ describe('toGeminiSchema', () => { 'search_expenses', 'submit_expense', 'get_budget_status', + 'get_spend_summary', 'generate_report', 'approve_expense', ]); for (const declaration of declarations) { expect(declaration.description.length).toBeGreaterThan(0); - expect(declaration.parameters?.type).toBe('OBJECT'); + // Tools with inputs have parameters; `get_spend_summary` has none. + if (declaration.name === 'get_spend_summary') { + expect(declaration.parameters).toBeUndefined(); + } else { + expect(declaration.parameters?.type).toBe('OBJECT'); + } } }); diff --git a/frontend/src/app/core/expense/amount.spec.ts b/frontend/src/app/core/expense/amount.spec.ts index 10f31ca..882a540 100644 --- a/frontend/src/app/core/expense/amount.spec.ts +++ b/frontend/src/app/core/expense/amount.spec.ts @@ -128,6 +128,22 @@ describe('sumSpend', () => { expect(sumSpend(rows).total).toBe(100); }); + /** + * Aligns with the server-side rule in `sumByCategory`. Drafts are uncommitted + * intent, not spend — counting them would make the dashboard disagree with + * `/budgets/status`. + */ + it('excludes drafts — uncommitted intent is not spend', () => { + const rows = [ + expense({ id: 'a', amount: 500, status: 'draft' }), + expense({ id: 'b', amount: 300, status: 'submitted' }), + ]; + + expect(isSpend(rows[0])).toBe(false); + expect(isSpend(rows[1])).toBe(true); + expect(sumSpend(rows).total).toBe(300); + }); + it('is zero on an empty list', () => { expect(sumSpend([])).toEqual({ total: 0, excluded: 0 }); }); diff --git a/frontend/src/app/core/expense/amount.ts b/frontend/src/app/core/expense/amount.ts index 00212c1..8ab2efb 100644 --- a/frontend/src/app/core/expense/amount.ts +++ b/frontend/src/app/core/expense/amount.ts @@ -1,4 +1,4 @@ -import type { Expense } from '@actuo/shared'; +import type { Expense, ExpenseStatus } from '@actuo/shared'; /** * Which amount field counts, and which rows count at all. @@ -40,16 +40,26 @@ export function expenseCurrency(expense: Expense): string { } /** - * Whether a row counts as spend. + * Statuses that count as spend. Matches the server-side rule in + * `sumByCategory` so every surface agrees on the same total. * - * Rejected expenses are excluded: the org decided it will not bear that cost, - * so counting it would overstate every total. Drafts *are* counted — that money - * has already left a person's pocket, it just has not been claimed yet, and - * hiding it makes the dashboard disagree with the user's own wallet. - * Soft-deleted rows are excluded defensively; the backend already filters them. + * - `submitted` — claimed but not yet decided + * - `approved` — the org has agreed to bear this cost + * - `reimbursed` — paid out, the final state + * + * Drafts are excluded: they are uncommitted intent, and counting them would + * make the dashboard disagree with `/budgets/status`. Rejected expenses are + * excluded: the org decided it will not bear that cost. Soft-deleted rows are + * excluded defensively; the backend already filters them. */ +const SPEND_STATUSES: ReadonlySet = new Set([ + 'submitted', + 'approved', + 'reimbursed', +]); + export function isSpend(expense: Expense): boolean { - return expense.status !== 'rejected' && expense.deletedAt === null; + return SPEND_STATUSES.has(expense.status) && expense.deletedAt === null; } /** A total, plus what it could not account for. */ diff --git a/frontend/src/app/pages/budgets/budget-rollup.spec.ts b/frontend/src/app/pages/budgets/budget-rollup.spec.ts index 6264aeb..a5a3166 100644 --- a/frontend/src/app/pages/budgets/budget-rollup.spec.ts +++ b/frontend/src/app/pages/budgets/budget-rollup.spec.ts @@ -2,21 +2,28 @@ import type { BudgetStatus } from '@actuo/shared'; import { describe, expect, it } from 'vitest'; import { + isNearBudget, isOverBudget, + nearBudget, overBudget, overspend, rollupBudgets, sortBudgets, utilizationPercent, + WARN_THRESHOLD, } from './budget-rollup.js'; function budget(overrides: Partial = {}): BudgetStatus { - const budgeted = overrides.budgeted ?? 10000; + const declaredBudget = overrides.declaredBudget ?? overrides.budgeted ?? 10000; + const carryforward = overrides.carryforward ?? 0; + const budgeted = overrides.budgeted ?? declaredBudget + carryforward; const spent = overrides.spent ?? 3000; return { categoryId: 'cat-1', categoryName: 'Travel', budgeted, + declaredBudget, + carryforward, spent, remaining: budgeted - spent, utilization: budgeted > 0 ? spent / budgeted : Number.POSITIVE_INFINITY, @@ -62,6 +69,41 @@ describe('isOverBudget / overspend', () => { }); }); +describe('isNearBudget / nearBudget (PRD §6.3 threshold alerts)', () => { + it('exports the threshold constant so callers can reference it', () => { + expect(WARN_THRESHOLD).toBe(0.8); + }); + + it('is true at exactly 80% utilization', () => { + expect(isNearBudget(budget({ budgeted: 1000, spent: 800 }))).toBe(true); + }); + + it('is true between 80% and 100%', () => { + expect(isNearBudget(budget({ budgeted: 1000, spent: 900 }))).toBe(true); + }); + + it('is false below the threshold', () => { + expect(isNearBudget(budget({ budgeted: 1000, spent: 790 }))).toBe(false); + }); + + it('is false once over budget — that is a different state', () => { + expect(isNearBudget(budget({ budgeted: 1000, spent: 1001 }))).toBe(false); + }); + + it('is false for a zero budget, rather than triggering on any spend', () => { + expect(isNearBudget(budget({ budgeted: 0, spent: 500 }))).toBe(false); + }); + + it('filters to only the categories nearing their limit', () => { + const rows = [ + budget({ categoryId: 'a', budgeted: 1000, spent: 850 }), // near + budget({ categoryId: 'b', budgeted: 1000, spent: 500 }), // safe + budget({ categoryId: 'c', budgeted: 1000, spent: 1200 }), // over + ]; + expect(nearBudget(rows).map((r) => r.categoryId)).toEqual(['a']); + }); +}); + describe('sortBudgets', () => { const rows = [ budget({ categoryId: 'a', categoryName: 'Meals', budgeted: 1000, spent: 200 }), diff --git a/frontend/src/app/pages/budgets/budget-rollup.ts b/frontend/src/app/pages/budgets/budget-rollup.ts index e4504d3..ffe4a6f 100644 --- a/frontend/src/app/pages/budgets/budget-rollup.ts +++ b/frontend/src/app/pages/budgets/budget-rollup.ts @@ -28,6 +28,25 @@ export function isOverBudget(status: BudgetStatus): boolean { return status.spent > status.budgeted; } +/** The utilization ratio at which a category is "nearing budget" (PRD §6.3). */ +export const WARN_THRESHOLD = 0.8; + +/** + * Whether a category is close to its budget but not yet over it. + * The threshold is 80% — high enough to mean something, low enough to act on. + */ +export function isNearBudget(status: BudgetStatus): boolean { + return ( + status.budgeted > 0 && + status.utilization >= WARN_THRESHOLD && + !isOverBudget(status) + ); +} + +export function nearBudget(statuses: readonly BudgetStatus[]): BudgetStatus[] { + return statuses.filter(isNearBudget); +} + /** How much a category has overshot by. Zero when it has not. */ export function overspend(status: BudgetStatus): number { return Math.max(status.spent - status.budgeted, 0); diff --git a/frontend/src/app/pages/budgets/budgets.spec.ts b/frontend/src/app/pages/budgets/budgets.spec.ts index 3fa7f25..6d6145f 100644 --- a/frontend/src/app/pages/budgets/budgets.spec.ts +++ b/frontend/src/app/pages/budgets/budgets.spec.ts @@ -8,12 +8,16 @@ import { Session } from '../../core/session/session.js'; import { Budgets } from './budgets.js'; function budget(overrides: Partial = {}): BudgetStatus { - const budgeted = overrides.budgeted ?? 10000; + const declaredBudget = overrides.declaredBudget ?? overrides.budgeted ?? 10000; + const carryforward = overrides.carryforward ?? 0; + const budgeted = overrides.budgeted ?? declaredBudget + carryforward; const spent = overrides.spent ?? 3000; return { categoryId: 'cat-1', categoryName: 'Travel', budgeted, + declaredBudget, + carryforward, spent, remaining: budgeted - spent, utilization: budgeted > 0 ? spent / budgeted : Number.POSITIVE_INFINITY, @@ -109,9 +113,10 @@ describe('Budgets', () => { expect(travel?.querySelector('[data-money]')).not.toBeNull(); }); - it('says nothing about being over budget when nothing is', () => { + it('shows no "Over budget" danger badge when nothing is over', () => { expect(text()).not.toContain('Over budget'); - expect(find('ui-badge')).toBeNull(); + // Warning badges for "Nearing budget" may be present (e.g. Meals at 85%). + expect(find('ui-badge[tone="danger"]')).toBeNull(); }); }); @@ -348,7 +353,7 @@ describe('Budgets — setting one', () => { expect(options).not.toContain('All categories'); }); - it('says so, rather than showing an empty dropdown, when nothing is left to budget', async () => { + it('still shows the form for editing when every category has a budget', async () => { existingBudgets = [ { id: 'b1', orgId: 'org-1', categoryId: null, amount: 1, period: 'monthly', rollover: false }, { id: 'b2', orgId: 'org-1', categoryId: 'cat-travel', amount: 1, period: 'monthly', rollover: false }, @@ -356,8 +361,8 @@ describe('Budgets — setting one', () => { ]; await create(); - expect(host().querySelector('form')).toBeNull(); - expect(text()).toContain('Every category already has a budget'); + // Form is still present for editing existing budgets + expect(host().querySelector('form')).not.toBeNull(); }); it('explains a 409 in terms of what the API actually does', async () => { @@ -385,21 +390,30 @@ describe('Budgets — setting one', () => { categoryId: null, amount: 10000, period: 'monthly', + rollover: false, }); // The bars are server-computed, so they have to come back from it. const fetchesAfter = api.get.mock.calls.filter((c) => c[0] === '/budgets/status').length; expect(fetchesAfter).toBeGreaterThan(fetchesBefore); }); - /** A control that changes no figure is a promise. Restore it with the behaviour. */ - it('offers no rollover control while nothing honours the flag', async () => { + it('offers a rollover checkbox and sends the flag in the POST', async () => { await create(); - const checkboxes = Array.from( - host().querySelectorAll('input[type="checkbox"]'), - ); - expect(checkboxes).toHaveLength(0); - expect(host().textContent).not.toContain('Roll unspent budget'); + const checkbox = host().querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(checkbox).not.toBeNull(); + expect(host().textContent).toContain('Roll over unused'); + + // Check the box + checkbox.checked = true; + checkbox.dispatchEvent(new Event('change')); + fixture.detectChanges(); + + setAmount('5000'); + submit(); + await fixture.whenStable(); + + expect(api.post).toHaveBeenCalledWith('/budgets', expect.objectContaining({ rollover: true })); }); it('sends the chosen category rather than the empty org-wide value', async () => { @@ -436,7 +450,7 @@ describe('Budgets — setting one', () => { await fixture.whenStable(); fixture.detectChanges(); - expect(text()).toContain('Only an owner or admin can set a budget.'); + expect(text()).toContain('Only an owner or admin can manage budgets.'); }); /** A missing category list must not block the org-wide budget. */ diff --git a/frontend/src/app/pages/budgets/budgets.ts b/frontend/src/app/pages/budgets/budgets.ts index 30d92cf..240e7b0 100644 --- a/frontend/src/app/pages/budgets/budgets.ts +++ b/frontend/src/app/pages/budgets/budgets.ts @@ -4,6 +4,7 @@ import { Component, PLATFORM_ID, computed, + effect, inject, resource, signal, @@ -16,7 +17,9 @@ import { Badge, Button, Card, EmptyState, ErrorState, Input, ProgressBar, Skelet import { formatMoney } from '../../core/format/money.js'; import { excludedNotice } from '../../core/expense/amount.js'; import { + isNearBudget, isOverBudget, + nearBudget, overBudget, overspend, rollupBudgets, @@ -106,11 +109,28 @@ import {
    @for (budget of budgets(); track budget.categoryId ?? budget.categoryName) {
  • - @if (isOver(budget)) { -
    - +
    +
    + @if (isOver(budget)) { +
    + +
    + } @else if (isNear(budget)) { +
    + +
    + }
    - } + @if (mayManage() && budgetForCategory(budget.categoryId); as b) { + + } +
    {{ spentOfBudgeted(budget) }} + @if (budget.carryforward > 0) { + ({{ carryText(budget) }}) + } {{ remainingText(budget) }}

    @@ -137,20 +160,17 @@ import { @if (mayManage()) {
    -

    Set a budget

    +

    {{ formHeading() }}

    - A monthly amount for one category, or for the organization as a whole. Each can - have one budget, so only the categories without one are listed. + @if (editingBudget()) { + Update the monthly amount or change whether unused budget carries forward. + } @else { + A monthly amount for one category, or for the organization as a whole. + }

    - @if (unbudgeted().length === 0 && orgBudgetExists()) { -

    - Every category already has a budget. Changing one is not supported yet — the API - creates budgets and does not replace them. -

    - } @else { -
    +
    +
    + + +
    +
    + @if (editingBudget()) { + + } + @if (formMessage(); as message) {

    } - } } @@ -240,13 +282,53 @@ export class Budgets { protected readonly orgBudgetExists = computed(() => this.existing().some((budget) => budget.categoryId === null), ); - protected readonly newCategoryId = signal(''); + + /** The budget currently being edited, or null when creating a new one. */ + protected readonly editingBudget = signal(null); + /** The category selected in the dropdown when not editing. */ + private readonly newCategoryId = signal(''); protected readonly newAmount = signal(''); + protected readonly rollover = signal(false); protected readonly saving = signal(false); protected readonly formMessage = signal(null); protected readonly formFailed = signal(false); protected readonly amountError = signal(null); + /** Categories available in the dropdown: unbudgeted ones, plus the one being edited. */ + protected readonly selectableCategories = computed(() => { + const editing = this.editingBudget(); + const taken = new Set(this.existing().map((b) => b.categoryId)); + return this.categories().filter((category) => { + if (editing && category.id === editing.categoryId) return true; + return !taken.has(category.id); + }); + }); + + /** The category ID shown in the select — the editing one, or user's selection. */ + protected readonly selectedCategoryId = computed(() => { + const editing = this.editingBudget(); + if (editing) return editing.categoryId ?? ''; + return this.newCategoryId(); + }); + + /** Whether the "All categories" option should appear in the dropdown. */ + protected readonly showOrgWideOption = computed(() => { + const editing = this.editingBudget(); + // Show if editing the org-wide budget, or if no org-wide budget exists yet + return (editing && editing.categoryId === null) || !this.orgBudgetExists(); + }); + + protected readonly formHeading = computed(() => { + const editing = this.editingBudget(); + if (editing) { + const name = editing.categoryId + ? this.categories().find((c) => c.id === editing.categoryId)?.name ?? 'category' + : 'all categories'; + return `Edit budget for ${name}`; + } + return 'Set a budget'; + }); + constructor() { // Categories only matter to someone who can set a budget, and only in the // browser — `ApiClient` refuses to run during SSR. @@ -267,6 +349,40 @@ export class Budgets { } } + protected selectCategory(value: string): void { + // When switching categories, if the target has an existing budget, switch to edit mode. + const categoryId = value || null; + const budget = this.existing().find((b) => b.categoryId === categoryId); + if (budget) { + this.startEdit(budget); + } else { + this.editingBudget.set(null); + this.newCategoryId.set(value); + this.newAmount.set(''); + this.rollover.set(false); + } + } + + protected startEdit(budget: Budget): void { + this.editingBudget.set(budget); + this.newAmount.set(String(budget.amount)); + this.rollover.set(budget.rollover); + this.formMessage.set(null); + } + + protected cancelEdit(): void { + this.editingBudget.set(null); + this.newCategoryId.set(''); + this.newAmount.set(''); + this.rollover.set(false); + this.formMessage.set(null); + } + + /** Finds the budget for a category, so the template can offer an edit button. */ + protected budgetForCategory(categoryId: string | null): Budget | undefined { + return this.existing().find((b) => b.categoryId === categoryId); + } + protected async saveBudget(event: Event): Promise { event.preventDefault(); this.formMessage.set(null); @@ -278,28 +394,37 @@ export class Budgets { return; } + const editing = this.editingBudget(); this.saving.set(true); try { - await this.api.post('/budgets', { - // '' is the org-wide budget, which the DTO expects as null rather than - // an empty string. - categoryId: this.newCategoryId() || null, - amount: Math.round(amount * 100) / 100, - period: 'monthly', - // No `rollover`: nothing reads the flag — `status()` always computes a - // fresh calendar month — so the form stopped offering it (PRD §6.3). - }); + if (editing) { + // PATCH existing budget + await this.api.patch(`/budgets/${editing.id}`, { + amount: Math.round(amount * 100) / 100, + rollover: this.rollover(), + }); + this.formMessage.set('Budget updated.'); + } else { + // POST new budget + await this.api.post('/budgets', { + categoryId: this.selectedCategoryId() || null, + amount: Math.round(amount * 100) / 100, + period: 'monthly', + rollover: this.rollover(), + }); + this.formMessage.set('Budget saved.'); + } this.formFailed.set(false); - this.formMessage.set('Budget saved.'); - this.newAmount.set(''); this.newCategoryId.set(''); - // The bars are server-computed, so the figures have to come back from it, - // and the dropdown has to drop the category that now has one. + this.newAmount.set(''); + this.rollover.set(false); + this.editingBudget.set(null); + // Reload bars and the existing list. this.data.reload(); void this.loadCategories(); } catch (error) { this.formFailed.set(true); - this.formMessage.set(describeBudgetFailure(error)); + this.formMessage.set(describeBudgetFailure(error, !!editing)); } finally { this.saving.set(false); } @@ -398,6 +523,10 @@ export class Budgets { return isOverBudget(status); } + protected isNear(status: BudgetStatus): boolean { + return isNearBudget(status); + } + protected spentOfBudgeted(status: BudgetStatus): string { return `${this.money(status.spent, status)} of ${this.money(status.budgeted, status)}`; } @@ -416,20 +545,28 @@ export class Budgets { return formatMoney(amount, status.currency); } + /** Explains that budgeted includes carry: "₹50,000 + ₹8,000 carried". */ + protected carryText(status: BudgetStatus): string { + return `${this.money(status.declaredBudget, status)} + ${this.money(status.carryforward, status)} carried`; + } + reload(): void { this.data.reload(); } } /** Actionable, never blaming (Design Doc §3.6). */ -function describeBudgetFailure(error: unknown): string { +function describeBudgetFailure(error: unknown, isUpdate = false): string { if (error instanceof ApiError) { - if (error.status === 403) return 'Only an owner or admin can set a budget.'; + if (error.status === 403) return 'Only an owner or admin can manage budgets.'; + if (error.status === 404) return 'That budget no longer exists.'; if (error.status === 409) { - return 'That category already has a budget. Changing an existing one is not supported yet.'; + return 'That category already has a budget.'; } - if (error.status === 0) return 'Actuo didn’t respond, so nothing was saved. Try again.'; + if (error.status === 0) return "Actuo didn't respond, so nothing was saved. Try again."; if (error.message) return error.message; } - return 'That budget could not be saved. Nothing was changed.'; + return isUpdate + ? 'That budget could not be updated. Nothing was changed.' + : 'That budget could not be saved. Nothing was changed.'; } diff --git a/frontend/src/app/pages/dashboard/dashboard.spec.ts b/frontend/src/app/pages/dashboard/dashboard.spec.ts index 7d5f88a..424f6ec 100644 --- a/frontend/src/app/pages/dashboard/dashboard.spec.ts +++ b/frontend/src/app/pages/dashboard/dashboard.spec.ts @@ -40,6 +40,8 @@ const BUDGETS: BudgetStatus[] = [ categoryId: 'cat-1', categoryName: 'Travel', budgeted: 10000, + declaredBudget: 10000, + carryforward: 0, spent: 3000, remaining: 7000, utilization: 0.3, diff --git a/frontend/src/app/pages/dashboard/dashboard.ts b/frontend/src/app/pages/dashboard/dashboard.ts index 97bd687..24f597b 100644 --- a/frontend/src/app/pages/dashboard/dashboard.ts +++ b/frontend/src/app/pages/dashboard/dashboard.ts @@ -28,6 +28,7 @@ import { totalForMonth, type PaceStatus, } from './spend-pace.js'; +import { isNearBudget, nearBudget } from '../budgets/budget-rollup.js'; /** Days of history in the trend strip. Two weeks fits 14 legible bars on a phone. */ const TREND_DAYS = 14; @@ -148,6 +149,14 @@ const PACE_LABEL: Record = { />

    + @if (nearBudgetCategories().length > 0) { +

    + {{ nearBudgetCategories().length }} + {{ nearBudgetCategories().length === 1 ? 'category is' : 'categories are' }} + nearing budget (≥80%). +

    + } +