Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 10 additions & 9 deletions Progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/adminPOST 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 — 🟡

Expand Down Expand Up @@ -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 — ⬜

Expand Down
27 changes: 27 additions & 0 deletions backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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<AnalyticsSummary> {
return this.analytics.summary(user, query);
}
}
10 changes: 10 additions & 0 deletions backend/src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
143 changes: 143 additions & 0 deletions backend/src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
106 changes: 106 additions & 0 deletions backend/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -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<AnalyticsSummary> {
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;
}
12 changes: 12 additions & 0 deletions backend/src/analytics/dto/analytics.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -63,6 +64,7 @@ const ENV_FILE = fileURLToPath(new URL('../.env', import.meta.url));
AuditModule,
OrgsModule,
ReportsModule,
AnalyticsModule,
],
controllers: [AppController, HealthController, ConfigController],
providers: [
Expand Down
Loading
Loading