12 Commits

Author SHA1 Message Date
JoeBot
dfd1bccb89 feat: add Playwright E2E and API regression test suite
Production-ready test infrastructure with Page Object Model pattern,
reusable fixtures for auth/DB/test-data, and example tests covering
login flow, dashboard, accounts CRUD API, and visual regression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 12:40:05 -04:00
629d112850 Merge pull request 'Add k6 load testing suite and CLAUDE.md' (#9) from claude/beautiful-gauss into main
Reviewed-on: #9
2026-04-02 17:42:35 -04:00
32506d6a2e Merge branch 'main' into claude/beautiful-gauss 2026-04-02 17:42:24 -04:00
9a60970837 Updated Version 2026-04-02 17:41:49 -04:00
1ade446187 Merge pull request 'ideation-feature' (#11) from ideation-feature into main
Reviewed-on: #11
2026-04-02 17:39:32 -04:00
JoeBot
d430b96b51 feat: add admin ideas management page with private notes
Adds a dedicated super admin page for managing idea submissions across
all tenants. Includes status summary cards, filterable/searchable table,
detail modal with status updates, and private admin notes for internal
tracking (sprint refs, thoughts, follow-ups). Notes are not visible to
tenant users.

- Database: admin_note column on shared.ideas (019 migration)
- Backend: PUT /admin/ideas/:id/note endpoint
- Frontend: AdminIdeasPage with table, filters, detail modal
- Sidebar: "Idea Submissions" nav link in admin sections
- Routing: /admin/ideas route under SuperAdminRoute guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:35:30 -04:00
JoeBot
140cd7acb7 feat: add ideation feature with per-tenant toggle
Adds idea submission capability gated by a per-tenant feature flag.
Super admins can enable/disable ideation for specific tenants via the
admin tenant detail drawer. Users see a lightbulb icon in the header
when enabled, opening a modal to submit ideas (title + description).
Ideas are stored in shared schema for cross-tenant backlog querying.

- Database: shared.ideas table (018-ideas.sql migration)
- Backend: Ideas NestJS module (entity, service, controller)
- Admin API: GET /admin/ideas, PUT /admin/ideas/:id/status,
  PUT /admin/organizations/:id/settings
- Frontend: IdeaModal component, lightbulb ActionIcon in header
- Admin UI: Feature Toggles card with ideation Switch in drawer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:20:37 -04:00
2f6297ae68 fix: reserve fund health AI prompt uses planned dates instead of remaining life years
Remaining life years is documentation-only reference info. The board's
planned project date is the authoritative timeline for urgency assessment.
Updates data gathering, prompt construction, and system instructions to
base all urgency on target_year/target_month instead of remaining_life_years.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 14:28:37 -04:00
121b8138e3 fix: investment scenario detail blank screen and auto-renew refresh
Move useMemo hook above early returns to satisfy React Rules of Hooks,
fixing blank screen when navigating to scenario detail. Also re-fetch
scenario after projection updates so auto-renew renewal records appear
automatically without requiring manual navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 15:04:44 -04:00
2b331bb3ef feat: investment chart alignment, auto-renew records, fund transfers, capital planning report, and upcoming activities (v2026.3.24)
- Lock InvestmentTimeline and ProjectionChart to shared X axis range
- Auto-create renewal scenario_investments records when auto_renew is true
- Add fund transfer mechanism between asset accounts with journal entries
- Add Capital Planning Report (5-year forecast grouped by category)
- Add Upcoming Investment Activities dashboard card (maturities + planned purchases)
- Bump version to 2026.3.24

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 14:41:17 -04:00
ae856bfb2f Upload files to "load-tests" 2026-03-19 16:12:09 -04:00
31f8274b8d Upload files to "load-tests"
load test files
2026-03-19 16:11:32 -04:00
60 changed files with 4229 additions and 54 deletions

28
.env.test.example Normal file
View File

@@ -0,0 +1,28 @@
# ─── Playwright E2E Test Environment ────────────────────────────────
# Copy to .env.test and fill in values for your local or CI setup.
# Base URL of the running application (nginx proxy)
# Local dev: http://localhost (Docker Compose nginx on port 80)
# Production: https://your-production-domain.com
BASE_URL=http://localhost
# ─── Test Database ──────────────────────────────────────────────────
# Direct Postgres connection for test data seeding/cleanup.
# Use the SAME database as Docker Compose postgres service.
# WARNING: Tests will create/delete data — never point at production.
TEST_DB_URL=postgresql://hoafinance:change_me@localhost:5432/hoafinance
# ─── Test User Credentials ──────────────────────────────────────────
# Pre-seeded user for authenticated test flows.
# The seed script (tests/fixtures/db.fixture.ts) creates this user.
TEST_USER_EMAIL=e2e-treasurer@test.hoaledgeriq.com
TEST_USER_PASSWORD=TestPass123!
TEST_USER_ROLE=treasurer
# ─── API Base URL ───────────────────────────────────────────────────
# Backend API base (through nginx). Usually same as BASE_URL + /api
API_BASE_URL=http://localhost/api
# ─── CI Settings ────────────────────────────────────────────────────
# CI=true is typically set by CI providers automatically.
# CI=true

10
.gitignore vendored
View File

@@ -44,3 +44,13 @@ coverage/
# TypeScript
*.tsbuildinfo
# Playwright
/test-results/
/playwright-report/
/blob-report/
tests/.auth/
*-snapshots/
# Test environment
.env.test

349
TESTING_CONVENTIONS.md Normal file
View File

@@ -0,0 +1,349 @@
# Testing Conventions — HOA LedgerIQ E2E & API Tests
This document is the single source of truth for writing, organizing, and running Playwright-based E2E and API regression tests in this project.
---
## Architecture
| Component | Technology | Port |
|-----------|-----------|------|
| Reverse proxy | nginx | :80 |
| Backend API | NestJS 10 | :3000 (internal) |
| Frontend | React 18 + Vite | :5173 (internal) |
| Database | PostgreSQL 15 | :5432 |
| Cache | Redis 7 | :6379 |
| Test runner | Playwright | host |
Tests run on the **host machine** against the app running in **Docker Compose**. The `BASE_URL` defaults to `http://localhost` (nginx).
---
## Folder Structure
```
tests/
├── .auth/ # Stored auth state (gitignored)
│ └── user.json # Browser state from auth.setup.ts
├── fixtures/
│ ├── auth.fixture.ts # API login helpers, token management
│ ├── base.fixture.ts # Extended test object with typed fixtures
│ ├── db.fixture.ts # Postgres seed/cleanup via pg driver
│ └── test-data.ts # Shared constants (users, sample data)
├── page-objects/
│ ├── index.ts # Re-exports all page objects
│ ├── BasePage.ts # Abstract base with shared helpers
│ ├── LoginPage.ts # /login page
│ ├── DashboardPage.ts # /dashboard page
│ └── AccountsPage.ts # /accounts page
├── e2e/ # Browser-based end-to-end tests
│ ├── auth.spec.ts # Login/logout UI flows
│ ├── dashboard.spec.ts # Dashboard load + navigation
│ └── visual.spec.ts # Screenshot regression tests
├── api/ # API-only tests (no browser)
│ ├── auth.api.spec.ts # /api/auth/* endpoints
│ └── accounts.api.spec.ts # /api/accounts/* CRUD
└── auth.setup.ts # One-time auth setup project
```
---
## Naming Conventions
| What | Convention | Example |
|------|-----------|---------|
| E2E test files | `tests/e2e/<feature>.spec.ts` | `auth.spec.ts` |
| API test files | `tests/api/<resource>.api.spec.ts` | `accounts.api.spec.ts` |
| Page objects | `tests/page-objects/<PageName>.ts` | `LoginPage.ts` |
| Fixtures | `tests/fixtures/<purpose>.fixture.ts` | `db.fixture.ts` |
| Test data | `tests/fixtures/test-data.ts` | single file |
| Snapshot baselines | auto-generated in `*-snapshots/` dirs | `login-page.png` |
### Test descriptions
Use `test.describe('Feature or Endpoint')` and `test('should <behavior>')`:
```ts
test.describe('POST /api/auth/login', () => {
test('should return access token for valid credentials', async ({ request }) => {
// ...
});
});
```
---
## How to Write New Tests
### 1. E2E (browser) test
```ts
// tests/e2e/invoices.spec.ts
import { test, expect } from '../fixtures/base.fixture';
import { InvoicesPage } from '../page-objects';
test.describe('Invoices', () => {
let invoicesPage: InvoicesPage;
test.beforeEach(async ({ page }) => {
invoicesPage = new InvoicesPage(page);
await invoicesPage.goto();
});
test('should display invoice list', async () => {
await invoicesPage.assertOnPage();
// ... assertions
});
});
```
### 2. API test
```ts
// tests/api/payments.api.spec.ts
import { test, expect } from '@playwright/test';
import { apiLogin, apiSwitchOrg, authHeaders } from '../fixtures/auth.fixture';
import { TEST_USERS } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
let accessToken: string;
test.beforeAll(async ({ request }) => {
const tokens = await apiLogin(request, TEST_USERS.treasurer);
if (tokens.organizations?.length > 0) {
const switched = await apiSwitchOrg(request, tokens.accessToken, (tokens.organizations[0] as any).id);
accessToken = switched.accessToken;
} else {
accessToken = tokens.accessToken;
}
});
test.describe('GET /api/payments', () => {
test('should return payments list', async ({ request }) => {
const response = await request.get(`${API_BASE}/payments`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
});
});
```
### 3. Visual regression test
```ts
test('invoices page should match baseline', async ({ page }) => {
await page.goto('/invoices');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500); // Let animations settle
await expect(page).toHaveScreenshot('invoices-page.png', {
fullPage: true,
mask: [page.locator('time')], // Mask dynamic dates
});
});
```
Update baselines: `npx playwright test --update-snapshots`
---
## How to Add New Page Objects
1. Create `tests/page-objects/MyPage.ts`:
```ts
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class MyPage extends BasePage {
readonly path = '/my-path';
// Locators — prefer role/label selectors over CSS
get heading() {
return this.page.getByRole('heading', { name: /my page/i });
}
get createButton() {
return this.page.getByRole('button', { name: /create/i });
}
// Actions
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
await expect(this.heading).toBeVisible();
}
async createItem(name: string): Promise<void> {
await this.createButton.click();
await this.page.getByLabel(/name/i).fill(name);
await this.page.getByRole('button', { name: /save/i }).click();
}
}
```
2. Export from `tests/page-objects/index.ts`:
```ts
export { MyPage } from './MyPage';
```
### Page object rules
- Extend `BasePage` and set `readonly path`
- Override `waitForReady()` for page-specific loading
- Use **role/label locators** (not CSS selectors): `getByRole()`, `getByLabel()`, `getByText()`
- Expose **locators as getters** and **actions as methods**
- Keep assertions in test files, not page objects (except `assertOnPage()`)
---
## Authentication in Tests
### Pre-authenticated tests (default)
Most tests use stored auth state from `auth.setup.ts`. This runs once via the `auth-setup` Playwright project and saves browser state to `tests/.auth/user.json`.
Tests automatically get this state via `storageState` in `playwright.config.ts`.
### Unauthenticated tests
For testing the login flow itself, opt out:
```ts
test.use({ storageState: { cookies: [], origins: [] } });
```
### API tests
Use the `apiLogin()` and `authHeaders()` helpers:
```ts
import { apiLogin, authHeaders } from '../fixtures/auth.fixture';
const tokens = await apiLogin(request, TEST_USERS.treasurer);
const response = await request.get(url, {
headers: authHeaders(tokens.accessToken),
});
```
---
## Database Seeding & Cleanup
### When to use direct DB access
- Verifying backend wrote correct data
- Seeding complex state that's hard to create via API
- Cleanup after tests
### How
```ts
import { test } from '../fixtures/base.fixture';
test('should verify data', async ({ db }) => {
const result = await db.query('SELECT * FROM schema.table WHERE ...');
expect(result.rows.length).toBeGreaterThan(0);
});
```
### Cleanup convention
- Prefix all test-created data with `E2E_` (use `TEST_PREFIX` from test-data.ts)
- The `db.cleanup()` method deletes rows matching this prefix
- Call `db.cleanup()` in `test.afterAll` for write-path tests
---
## Running Tests
### Prerequisites
1. Docker Compose services running: `docker-compose up -d`
2. Test user seeded in the database (use the backend seed script or create manually)
3. Environment configured: `cp .env.test.example .env.test` and fill in values
### Commands
```bash
# Install Playwright (first time)
npx playwright install --with-deps
# Run all tests
npx playwright test
# Run only E2E tests
npx playwright test tests/e2e/
# Run only API tests
npx playwright test --project=api
# Run in specific browser
npx playwright test --project=chromium
# Run in headed mode (see the browser)
npx playwright test --headed
# Run a single test file
npx playwright test tests/e2e/auth.spec.ts
# Debug mode (step through tests)
npx playwright test --debug
# Update visual regression baselines
npx playwright test tests/e2e/visual.spec.ts --update-snapshots
# View HTML report
npx playwright show-report
# Run against production
BASE_URL=https://your-prod-domain.com npx playwright test --project=api
```
### npm scripts (from project root)
```bash
npm run test:e2e # All Playwright tests
npm run test:e2e:chromium # Chromium only
npm run test:e2e:api # API tests only
npm run test:e2e:headed # Headed mode
npm run test:e2e:debug # Debug mode
```
---
## Environment Variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `BASE_URL` | `http://localhost` | App URL (nginx) |
| `API_BASE_URL` | `http://localhost/api` | Backend API base |
| `TEST_DB_URL` | `postgresql://hoafinance:change_me@localhost:5432/hoafinance` | Direct Postgres for seeding |
| `TEST_USER_EMAIL` | `e2e-treasurer@test.hoaledgeriq.com` | Test user email |
| `TEST_USER_PASSWORD` | `TestPass123!` | Test user password |
| `CI` | — | Set by CI providers; enables retries, single worker |
---
## Style Rules
1. **Import `test` from `../fixtures/base.fixture`** for tests needing DB or auth fixtures. Import from `@playwright/test` for basic tests.
2. **One `test.describe` per feature or endpoint** per file.
3. **No `page.waitForTimeout()` except in visual tests** — use `waitForLoadState`, `waitForURL`, or `waitForResponse` instead.
4. **No hardcoded URLs** — use `BASE_URL`, `API_BASE`, or page object paths.
5. **No test interdependencies** — each test should work in isolation (use `test.beforeEach` for setup).
6. **Clean up after write tests** — use `TEST_PREFIX` and `db.cleanup()`.
7. **API tests go in `tests/api/`**, E2E tests in `tests/e2e/`** — don't mix.
8. **Locators**: prefer `getByRole` > `getByLabel` > `getByText` > `getByTestId` > CSS selectors.
---
## Adding Tests for a New Feature (Quick Checklist)
- [ ] Create page object in `tests/page-objects/` if it's a new page
- [ ] Export it from `tests/page-objects/index.ts`
- [ ] Create `tests/e2e/<feature>.spec.ts` for UI flows
- [ ] Create `tests/api/<resource>.api.spec.ts` for API endpoints
- [ ] Add sample data constants to `tests/fixtures/test-data.ts` if needed
- [ ] Run `npx playwright test tests/e2e/<feature>.spec.ts` to verify
- [ ] Update visual baselines if the feature changes existing pages

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.3.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-backend",
"version": "2026.3.17",
"version": "2026.3.19",
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.19",
"version": "2026.3.24",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {

View File

@@ -33,6 +33,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
import { BillingModule } from './modules/billing/billing.module';
import { EmailModule } from './modules/email/email.module';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
import { IdeasModule } from './modules/ideas/ideas.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
@@ -88,6 +89,7 @@ import { ScheduleModule } from '@nestjs/schedule';
BillingModule,
EmailModule,
OnboardingModule,
IdeasModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],

View File

@@ -58,6 +58,14 @@ export class AccountsController {
return this.accountsService.adjustBalance(id, dto);
}
@Post('transfer')
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
transferFunds(
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
) {
return this.accountsService.transferFunds(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get account by ID' })
findOne(@Param('id') id: string) {

View File

@@ -360,6 +360,62 @@ export class AccountsService {
return journalEntry;
}
async transferFunds(dto: {
fromAccountId: string;
toAccountId: string;
amount: number;
transferDate: string;
memo?: string;
}) {
if (dto.amount <= 0) throw new BadRequestException('Transfer amount must be positive');
if (dto.fromAccountId === dto.toAccountId) throw new BadRequestException('Cannot transfer to the same account');
const fromAccount = await this.findOne(dto.fromAccountId);
const toAccount = await this.findOne(dto.toAccountId);
if (fromAccount.account_type !== 'asset') throw new BadRequestException('Source account must be an asset account');
if (toAccount.account_type !== 'asset') throw new BadRequestException('Destination account must be an asset account');
// Find fiscal period
const asOf = new Date(dto.transferDate);
const year = asOf.getFullYear();
const month = asOf.getMonth() + 1;
const periods = await this.tenant.query(
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
[year, month],
);
if (!periods.length) {
throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`);
}
const memo = dto.memo || `Transfer from ${fromAccount.name} to ${toAccount.name}`;
// Create journal entry: debit destination (increase), credit source (decrease)
const jeRows = await this.tenant.query(
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4)
RETURNING *`,
[dto.transferDate, memo, periods[0].id, '00000000-0000-0000-0000-000000000000'],
);
const je = jeRows[0];
// Credit source account (reduces asset balance)
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
VALUES ($1, $2, 0, $3, $4)`,
[je.id, dto.fromAccountId, dto.amount, memo],
);
// Debit destination account (increases asset balance)
await this.tenant.query(
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
VALUES ($1, $2, $3, 0, $4)`,
[je.id, dto.toAccountId, dto.amount, memo],
);
return je;
}
async getTrialBalance(asOfDate?: string) {
const dateFilter = asOfDate
? `AND je.entry_date <= $1`

View File

@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { OrganizationsService } from '../organizations/organizations.service';
import { AdminAnalyticsService } from './admin-analytics.service';
import { IdeasService } from '../ideas/ideas.service';
import * as bcrypt from 'bcryptjs';
@ApiTags('admin')
@@ -17,6 +18,7 @@ export class AdminController {
private usersService: UsersService,
private orgService: OrganizationsService,
private analyticsService: AdminAnalyticsService,
private ideasService: IdeasService,
) {}
private async requireSuperadmin(req: any) {
@@ -196,4 +198,45 @@ export class AdminController {
return { success: true, organization: org };
}
// ── Ideation ──
@Get('ideas')
async listAllIdeas(@Req() req: any) {
await this.requireSuperadmin(req);
return this.ideasService.findAll();
}
@Put('ideas/:id/status')
async updateIdeaStatus(
@Req() req: any,
@Param('id') id: string,
@Body() body: { status: string },
) {
await this.requireSuperadmin(req);
const idea = await this.ideasService.updateStatus(id, body.status);
return { success: true, idea };
}
@Put('ideas/:id/note')
async updateIdeaNote(
@Req() req: any,
@Param('id') id: string,
@Body() body: { adminNote: string },
) {
await this.requireSuperadmin(req);
const idea = await this.ideasService.updateNote(id, body.adminNote);
return { success: true, idea };
}
@Put('organizations/:id/settings')
async updateOrgSettings(
@Req() req: any,
@Param('id') id: string,
@Body() body: Record<string, any>,
) {
await this.requireSuperadmin(req);
const org = await this.orgService.updateSettings(id, body);
return { success: true, organization: org };
}
}

View File

@@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
import { OrganizationsModule } from '../organizations/organizations.module';
import { IdeasModule } from '../ideas/ideas.module';
@Module({
imports: [
UsersModule,
OrganizationsModule,
IdeasModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],

View File

@@ -25,12 +25,15 @@ export class BoardPlanningProjectionService {
return this.computeProjection(scenarioId);
}
/** Compute full projection for a scenario. */
/** Compute full projection for a scenario. Also auto-creates renewal records for auto_renew investments. */
async computeProjection(scenarioId: string) {
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
const scenario = scenarioRows[0];
// Auto-create renewal investment records for auto_renew investments that have maturity dates
await this.ensureRenewalRecords(scenarioId);
const investments = await this.tenant.query(
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
);
@@ -152,6 +155,53 @@ export class BoardPlanningProjectionService {
// ── Private Helpers ──
/**
* For each auto_renew investment with a maturity_date, ensure a corresponding
* renewal investment record exists (starting at maturity_date, same term).
* The renewal record has auto_renew=false so it won't create infinite chains.
*/
private async ensureRenewalRecords(scenarioId: string) {
const autoRenewInvestments = await this.tenant.query(
`SELECT * FROM scenario_investments
WHERE scenario_id = $1 AND auto_renew = true AND maturity_date IS NOT NULL AND executed_investment_id IS NULL`,
[scenarioId],
);
for (const inv of autoRenewInvestments) {
// Check if a renewal record already exists (linked by notes convention or same label pattern)
const renewalLabel = `${inv.label} (Renewal)`;
const existing = await this.tenant.query(
`SELECT id FROM scenario_investments WHERE scenario_id = $1 AND label = $2 AND purchase_date = $3`,
[scenarioId, renewalLabel, inv.maturity_date],
);
if (existing.length > 0) continue; // Already created
// Compute new maturity date from original term
let newMaturityDate: string | null = null;
const termMonths = parseInt(inv.term_months) || 0;
if (termMonths > 0 && inv.maturity_date) {
const d = new Date(inv.maturity_date);
d.setMonth(d.getMonth() + termMonths);
newMaturityDate = d.toISOString().split('T')[0];
}
await this.tenant.query(
`INSERT INTO scenario_investments
(scenario_id, label, investment_type, fund_type, principal, interest_rate,
term_months, institution, purchase_date, maturity_date, auto_renew, notes, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, false, $11, $12)`,
[
scenarioId, renewalLabel, inv.investment_type, inv.fund_type,
inv.principal, inv.interest_rate, inv.term_months || null,
inv.institution, inv.maturity_date, newMaturityDate,
`Auto-created renewal of "${inv.label}". Modify as needed.`,
(parseInt(inv.sort_order) || 0) + 1,
],
);
}
}
private async getBaselineState(startYear: number, months: number) {
// Current balances from asset accounts
const opCashRows = await this.tenant.query(`
@@ -403,11 +453,9 @@ export class BoardPlanningProjectionService {
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
else { resCashFlow += maturityTotal; resInvChange -= principal; }
// Auto-renew: immediately reinvest
if (inv.auto_renew) {
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
else { resCashFlow -= principal; resInvChange += principal; }
}
// Note: auto_renew investments now create separate renewal records
// (via ensureRenewalRecords), so the renewal purchase is handled by
// that record's purchase_date logic above — no inline reinvest needed.
}
}
}

View File

@@ -625,14 +625,16 @@ export class HealthScoresService {
.filter((b: any) => b.account_type === 'expense')
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
// Components needing replacement within 5 years — use whichever source has data
const urgentComponents = useComponentsTable
? reserveComponents.filter(
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
)
: reserveProjects.filter(
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
);
// Projects due within 5 years — based on planned date (target_year/target_month),
// NOT remaining_life_years. The planned date is the board's decision on when to act;
// remaining life is documentation-only reference info.
const now = new Date();
const fiveYearsFromNow = new Date(now.getFullYear() + 5, now.getMonth(), 1);
const urgentProjects = reserveProjects.filter((p: any) => {
if (!p.target_year) return false;
const targetDate = new Date(parseInt(p.target_year), (parseInt(p.target_month) || 6) - 1, 1);
return targetDate <= fiveYearsFromNow;
});
// ── Build 12-month forward reserve cash flow projection ──
@@ -773,7 +775,7 @@ export class HealthScoresService {
totalProjectCost,
annualReserveContribution,
annualReserveExpenses,
urgentComponents,
urgentProjects,
monthlySpecialAssessmentIncome,
year,
forecast,
@@ -940,12 +942,13 @@ SCORING GUIDELINES:
KEY FACTORS TO EVALUATE:
1. Percent funded (total reserve assets vs total replacement costs)
2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?)
3. Component urgency (components due within 5 years and their funding status)
4. Capital project readiness (are planned projects adequately funded?)
2. Annual contribution adequacy (is annual contribution enough to keep pace with planned projects?)
3. Project urgency — based ONLY on the "Planned Date" field. The Planned Date is the board's decision on when a project will be executed. Do NOT use "Useful Life" or "Remaining Life" to determine urgency — those are reference information only. A project is only urgent if its Planned Date falls within the next 1-3 years.
4. Capital project readiness (are planned projects adequately funded by their planned dates?)
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
6. Diversity of reserve components (is the full building covered?)
6. Diversity of reserve components (is the full scope of community infrastructure tracked?)
7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low.
8. IMPORTANT — Projects with no Planned Date or with "Not scheduled" should be noted but NOT treated as urgent or imminent. Only assess urgency for projects with actual planned dates.
RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences):
@@ -974,7 +977,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
).join('\n');
// Build component lines from reserve_components if available, otherwise from reserve-funded projects
// Build component lines from reserve_components if available, otherwise from reserve-funded projects.
// Use planned date (target_year/target_month) as the authoritative timeline, not remaining_life_years.
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
const componentLines = componentSource.length === 0
? 'No reserve components or reserve projects tracked.'
@@ -982,7 +986,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
const plannedDate = c.target_year ? `${c.target_year}/${c.target_month || '?'}` : 'Not scheduled';
return `- ${c.name} [${c.category || 'N/A'}] | Planned Date: ${plannedDate} | Useful Life: ${c.useful_life_years || '?'}yr (reference only) | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
}).join('\n');
const projectLines = data.projects.length === 0
@@ -995,13 +1000,14 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
.join('\n') || 'No reserve budget line items.';
const urgentLines = data.urgentComponents.length === 0
? 'None — no components due within 5 years.'
: data.urgentComponents.map((c: any) => {
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const urgentLines = data.urgentProjects.length === 0
? 'None — no reserve projects planned within 5 years.'
: data.urgentProjects.map((p: any) => {
const cost = parseFloat(p.estimated_cost || '0');
const funded = parseFloat(p.current_fund_balance || '0');
const gap = cost - funded;
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
const targetDate = `${p.target_year}/${p.target_month || '?'}`;
return `- ${p.name}: planned for ${targetDate}, Cost: $${cost.toFixed(0)}, $${gap.toFixed(0)} funding gap`;
}).join('\n');
const userPrompt = `Evaluate this HOA's reserve fund health.
@@ -1027,10 +1033,10 @@ ${accountLines}
=== RESERVE INVESTMENTS ===
${investmentLines}
=== RESERVE COMPONENTS (ordered by urgency) ===
=== RESERVE COMPONENTS (ordered by planned date) ===
${componentLines}
=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) ===
=== PROJECTS PLANNED WITHIN 5 YEARS (by planned date) ===
${urgentLines}
=== CAPITAL PROJECTS ===

View File

@@ -0,0 +1,12 @@
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class CreateIdeaDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
title: string;
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,49 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Organization } from '../../organizations/entities/organization.entity';
import { User } from '../../users/entities/user.entity';
@Entity({ schema: 'shared', name: 'ideas' })
export class Idea {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'org_id' })
orgId: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ length: 255 })
title: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 20, default: 'new' })
status: string;
@Column({ name: 'admin_note', type: 'text', nullable: true })
adminNote: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'org_id' })
organization: Organization;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@@ -0,0 +1,27 @@
import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { IdeasService } from './ideas.service';
import { CreateIdeaDto } from './dto/create-idea.dto';
@ApiTags('ideas')
@Controller('ideas')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class IdeasController {
constructor(private ideasService: IdeasService) {}
@Post()
async create(@Req() req: any, @Body() dto: CreateIdeaDto) {
const orgId = req.user.orgId;
const userId = req.user.userId || req.user.sub;
const idea = await this.ideasService.create(orgId, userId, dto);
return { success: true, idea };
}
@Get()
async findByOrg(@Req() req: any) {
const orgId = req.user.orgId;
return this.ideasService.findByOrg(orgId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Idea } from './entities/idea.entity';
import { Organization } from '../organizations/entities/organization.entity';
import { IdeasController } from './ideas.controller';
import { IdeasService } from './ideas.service';
@Module({
imports: [TypeOrmModule.forFeature([Idea, Organization])],
controllers: [IdeasController],
providers: [IdeasService],
exports: [IdeasService],
})
export class IdeasModule {}

View File

@@ -0,0 +1,89 @@
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Idea } from './entities/idea.entity';
import { Organization } from '../organizations/entities/organization.entity';
import { CreateIdeaDto } from './dto/create-idea.dto';
@Injectable()
export class IdeasService {
constructor(
@InjectRepository(Idea)
private ideasRepository: Repository<Idea>,
@InjectRepository(Organization)
private orgRepository: Repository<Organization>,
) {}
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
const org = await this.orgRepository.findOne({ where: { id: orgId } });
if (!org) {
throw new NotFoundException('Organization not found');
}
if (org.settings?.ideationEnabled !== true) {
throw new ForbiddenException('Ideation is not enabled for this organization');
}
const idea = this.ideasRepository.create({
orgId,
userId,
title: dto.title,
description: dto.description,
});
return this.ideasRepository.save(idea);
}
async findByOrg(orgId: string): Promise<Idea[]> {
return this.ideasRepository.find({
where: { orgId },
order: { createdAt: 'DESC' },
});
}
async findAll(): Promise<any[]> {
return this.ideasRepository
.createQueryBuilder('idea')
.leftJoin('idea.organization', 'org')
.leftJoin('idea.user', 'user')
.select([
'idea.id AS id',
'idea.title AS title',
'idea.description AS description',
'idea.status AS status',
'idea.createdAt AS "createdAt"',
'idea.adminNote AS "adminNote"',
'org.id AS "orgId"',
'org.name AS "orgName"',
'user.id AS "userId"',
'user.email AS "userEmail"',
'user.firstName AS "userFirstName"',
'user.lastName AS "userLastName"',
])
.orderBy('idea.createdAt', 'DESC')
.getRawMany();
}
async updateStatus(id: string, status: string): Promise<Idea> {
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
if (!validStatuses.includes(status)) {
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
const idea = await this.ideasRepository.findOne({ where: { id } });
if (!idea) {
throw new NotFoundException('Idea not found');
}
idea.status = status;
return this.ideasRepository.save(idea);
}
async updateNote(id: string, adminNote: string): Promise<Idea> {
const idea = await this.ideasRepository.findOne({ where: { id } });
if (!idea) {
throw new NotFoundException('Idea not found');
}
idea.adminNote = adminNote;
return this.ideasRepository.save(idea);
}
}

View File

@@ -65,6 +65,11 @@ export class ReportsController {
return this.reportsService.getDashboardKPIs();
}
@Get('upcoming-investment-activities')
getUpcomingInvestmentActivities() {
return this.reportsService.getUpcomingInvestmentActivities();
}
@Get('cash-flow-forecast')
getCashFlowForecast(
@Query('startYear') startYear?: string,
@@ -75,6 +80,13 @@ export class ReportsController {
return this.reportsService.getCashFlowForecast(yr, mo);
}
@Get('capital-planning')
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
return this.reportsService.getCapitalPlanningReport(
parseInt(startYear || '') || undefined,
);
}
@Get('quarterly')
getQuarterlyFinancial(
@Query('year') year?: string,

View File

@@ -780,6 +780,78 @@ export class ReportsService {
};
}
async getUpcomingInvestmentActivities() {
const now = new Date();
const in45Days = new Date(now);
in45Days.setDate(in45Days.getDate() + 45);
const in60Days = new Date(now);
in60Days.setDate(in60Days.getDate() + 60);
// 1. Investments maturing within 45 days
const maturingInvestments = await this.tenant.query(`
SELECT id, name, institution, investment_type, fund_type, current_value, principal,
interest_rate, maturity_date, purchase_date
FROM investment_accounts
WHERE is_active = true
AND maturity_date IS NOT NULL
AND maturity_date BETWEEN CURRENT_DATE AND $1::date
ORDER BY maturity_date ASC
`, [in45Days.toISOString().split('T')[0]]);
// Compute interest earned and days remaining for each
const maturing = maturingInvestments.map((inv: any) => {
const principal = parseFloat(inv.principal) || parseFloat(inv.current_value) || 0;
const rate = parseFloat(inv.interest_rate) || 0;
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : now;
const maturityDate = new Date(inv.maturity_date);
const daysHeld = Math.max((maturityDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
const interestEarned = principal * (rate / 100) * (daysHeld / 365);
const daysRemaining = Math.max(Math.ceil((maturityDate.getTime() - now.getTime()) / 86400000), 0);
return {
...inv,
interest_earned: interestEarned.toFixed(2),
maturity_value: (principal + interestEarned).toFixed(2),
days_remaining: daysRemaining,
activity_type: 'maturity',
};
});
// 2. Approved scenario investments due to execute within 60 days
let scenarioItems: any[] = [];
try {
scenarioItems = await this.tenant.query(`
SELECT si.id, si.label, si.investment_type, si.fund_type, si.principal,
si.interest_rate, si.purchase_date, si.maturity_date, si.institution,
bs.name as scenario_name, bs.status as scenario_status
FROM scenario_investments si
JOIN board_scenarios bs ON bs.id = si.scenario_id
WHERE bs.status = 'approved'
AND si.executed_investment_id IS NULL
AND si.purchase_date IS NOT NULL
AND si.purchase_date BETWEEN CURRENT_DATE AND $1::date
ORDER BY si.purchase_date ASC
`, [in60Days.toISOString().split('T')[0]]);
} catch {
// scenario tables may not exist
}
const upcoming = scenarioItems.map((si: any) => {
const purchaseDate = new Date(si.purchase_date);
const daysUntil = Math.max(Math.ceil((purchaseDate.getTime() - now.getTime()) / 86400000), 0);
return {
...si,
days_until: daysUntil,
activity_type: 'planned_purchase',
};
});
return {
maturing_investments: maturing,
upcoming_scenario_investments: upcoming,
total_activities: maturing.length + upcoming.length,
};
}
/**
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
@@ -1264,4 +1336,120 @@ export class ReportsService {
over_budget_items: overBudgetItems,
};
}
async getCapitalPlanningReport(startYear?: number) {
const baseYear = startYear || new Date().getFullYear();
const years = [baseYear, baseYear + 1, baseYear + 2, baseYear + 3, baseYear + 4];
// Get all active projects
const projects = await this.tenant.query(
`SELECT id, name, description, category, estimated_cost, target_year, target_month,
useful_life_years, last_replacement_date, next_replacement_date, fund_source,
status, priority, condition_rating
FROM projects
WHERE is_active = true
ORDER BY category NULLS LAST, priority, name`,
);
// Also try capital_projects table
let capitalProjects: any[] = [];
try {
capitalProjects = await this.tenant.query(
`SELECT id, name, description, estimated_cost, target_year, target_month,
fund_source, status, priority, notes
FROM capital_projects
WHERE status NOT IN ('cancelled')
ORDER BY priority, name`,
);
} catch {
// Table may not exist
}
// Merge and group by category
const allProjects = [
...projects.map((p: any) => ({
id: p.id,
name: p.name,
description: p.description,
category: p.category || 'Uncategorized',
estimated_cost: parseFloat(p.estimated_cost) || 0,
target_year: parseInt(p.target_year) || null,
useful_life_years: parseInt(p.useful_life_years) || null,
last_replacement_date: p.last_replacement_date,
fund_source: p.fund_source || 'reserve',
status: p.status,
priority: parseInt(p.priority) || 3,
condition_rating: parseInt(p.condition_rating) || null,
})),
...capitalProjects
.filter((cp: any) => !projects.some((p: any) => p.name === cp.name && p.target_year === cp.target_year))
.map((cp: any) => ({
id: cp.id,
name: cp.name,
description: cp.description,
category: 'Capital Projects',
estimated_cost: parseFloat(cp.estimated_cost) || 0,
target_year: parseInt(cp.target_year) || null,
useful_life_years: null,
last_replacement_date: null,
fund_source: cp.fund_source || 'reserve',
status: cp.status,
priority: parseInt(cp.priority) || 3,
condition_rating: null,
})),
];
// Group by category
const categories: Record<string, any[]> = {};
for (const project of allProjects) {
const cat = project.category;
if (!categories[cat]) categories[cat] = [];
categories[cat].push(project);
}
// Build year columns for each project
const categoryData = Object.entries(categories).map(([category, items]) => ({
category,
projects: items.map((p) => {
const yearAmounts: Record<number, number> = {};
let beyond = 0;
if (p.target_year) {
if (p.target_year >= years[0] && p.target_year <= years[4]) {
yearAmounts[p.target_year] = p.estimated_cost;
} else if (p.target_year > years[4]) {
beyond = p.estimated_cost;
}
}
return {
...p,
year_amounts: yearAmounts,
beyond,
};
}),
}));
// Compute totals per year
const yearTotals: Record<number, number> = {};
let beyondTotal = 0;
for (const y of years) yearTotals[y] = 0;
for (const cat of categoryData) {
for (const p of cat.projects) {
for (const y of years) {
yearTotals[y] += p.year_amounts[y] || 0;
}
beyondTotal += p.beyond;
}
}
return {
title: `${years[4] - years[0] + 1}-YEAR CAPITAL PROJECT FORECAST`,
start_year: years[0],
years,
categories: categoryData,
year_totals: yearTotals,
beyond_total: beyondTotal,
grand_total: Object.values(yearTotals).reduce((a, b) => a + b, 0) + beyondTotal,
generated_at: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,15 @@
-- Ideation feature: shared ideas table for cross-tenant idea submissions
CREATE TABLE IF NOT EXISTS shared.ideas (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id);
CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status);
CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC);

View File

@@ -0,0 +1,2 @@
-- Add private admin note column to ideas table
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.3.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.17",
"version": "2026.3.19",
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.19",
"version": "2026.3.24",
"private": true,
"type": "module",
"scripts": {

View File

@@ -24,10 +24,12 @@ import { CashFlowPage } from './pages/reports/CashFlowPage';
import { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage';
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
import { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage';
import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
import { AdminPage } from './pages/admin/AdminPage';
import { AdminIdeasPage } from './pages/admin/AdminIdeasPage';
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
@@ -132,6 +134,7 @@ export function App() {
}
>
<Route index element={<AdminPage />} />
<Route path="ideas" element={<AdminIdeasPage />} />
</Route>
{/* Main app routes (require auth + org) */}
@@ -167,6 +170,7 @@ export function App() {
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} />
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
<Route path="reports/capital-planning" element={<CapitalPlanningPage />} />
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />

View File

@@ -0,0 +1,69 @@
import { useState } from 'react';
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import api from '../../services/api';
interface IdeaModalProps {
opened: boolean;
onClose: () => void;
}
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const submitIdea = useMutation({
mutationFn: async () => {
const { data } = await api.post('/ideas', { title, description });
return data;
},
onSuccess: () => {
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
setTitle('');
setDescription('');
onClose();
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to submit idea',
color: 'red',
});
},
});
const handleClose = () => {
setTitle('');
setDescription('');
onClose();
};
return (
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
<Stack>
<TextInput
label="Title"
placeholder="Brief summary of your idea"
required
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={255}
/>
<Textarea
label="Description"
placeholder="Describe your idea in more detail (optional)"
minRows={4}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Button
onClick={() => submitIdea.mutate()}
loading={submitIdea.isPending}
disabled={!title.trim()}
>
Submit Idea
</Button>
</Stack>
</Modal>
);
}

View File

@@ -11,6 +11,7 @@ import {
IconEyeOff,
IconSun,
IconMoon,
IconBulb,
} from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
@@ -18,6 +19,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import { IdeaModal } from '../ideas/IdeaModal';
import logoSrc from '../../assets/logo.png';
export function AppLayout() {
@@ -28,6 +30,10 @@ export function AppLayout() {
const location = useLocation();
const isImpersonating = !!impersonationOriginal;
// ── Ideation State ──
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
// ── Onboarding State ──
const [showTour, setShowTour] = useState(false);
const [showWizard, setShowWizard] = useState(false);
@@ -121,6 +127,13 @@ export function AppLayout() {
{currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
)}
{ideationEnabled && (
<Tooltip label="Submit an idea">
<ActionIcon variant="default" size="lg" onClick={openIdeaModal} aria-label="Submit idea">
<IconBulb size={18} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
@@ -209,6 +222,9 @@ export function AppLayout() {
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
{/* ── Ideation Modal ── */}
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
</AppShell>
);
}

View File

@@ -20,6 +20,7 @@ import {
IconCalculator,
IconGitCompare,
IconScale,
IconBulb,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
@@ -94,6 +95,7 @@ const navSections = [
{ label: 'Sankey Diagram', path: '/reports/sankey' },
{ label: 'Year-End', path: '/reports/year-end' },
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
{ label: 'Capital Planning', path: '/reports/capital-planning' },
],
},
],
@@ -131,6 +133,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin')}
color="red"
/>
<NavLink
label="Idea Submissions"
leftSection={<IconBulb size={18} />}
active={location.pathname === '/admin/ideas'}
onClick={() => go('/admin/ideas')}
color="yellow"
/>
{organizations && organizations.length > 0 && (
<>
<Divider my="sm" />
@@ -229,6 +238,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin')}
color="red"
/>
<NavLink
label="Idea Submissions"
leftSection={<IconBulb size={18} />}
active={location.pathname === '/admin/ideas'}
onClick={() => go('/admin/ideas')}
color="yellow"
/>
</>
)}
</ScrollArea>

View File

@@ -37,6 +37,7 @@ import {
IconStarFilled,
IconAdjustments,
IconInfoCircle,
IconArrowsTransferDown,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -126,6 +127,7 @@ export function AccountsPage() {
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
@@ -283,6 +285,39 @@ export function AccountsPage() {
},
});
// ── Transfer form ──
const transferForm = useForm({
initialValues: {
fromAccountId: '',
toAccountId: '',
amount: 0,
transferDate: new Date() as Date | null,
memo: '',
},
validate: {
fromAccountId: (v) => (v ? null : 'Required'),
toAccountId: (v, values) => !v ? 'Required' : v === values.fromAccountId ? 'Must be different from source' : null,
amount: (v) => (v > 0 ? null : 'Must be greater than 0'),
transferDate: (v) => (v ? null : 'Required'),
},
});
const transferMutation = useMutation({
mutationFn: (values: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo: string }) =>
api.post('/accounts/transfer', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
notifications.show({ message: 'Transfer completed successfully', color: 'green' });
closeTransfer();
transferForm.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Transfer failed', color: 'red' });
},
});
// ── Investment edit form ──
const invForm = useForm({
initialValues: {
@@ -408,6 +443,9 @@ export function AccountsPage() {
const activeAccounts = filtered.filter((a) => a.is_active);
const archivedAccounts = filtered.filter((a) => !a.is_active);
// Asset accounts for transfer modal (all active asset accounts, not just filtered by search)
const assetAccounts = accounts.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset');
// ── Investments split by fund type ──
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
@@ -505,9 +543,14 @@ export function AccountsPage() {
size="sm"
/>
{!isReadOnly && (
<>
<Button variant="light" leftSection={<IconArrowsTransferDown size={16} />} onClick={openTransfer}>
Transfer Funds
</Button>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
</>
)}
</Group>
</Group>
@@ -854,6 +897,69 @@ export function AccountsPage() {
)}
</Modal>
{/* Transfer Funds Modal */}
<Modal opened={transferOpened} onClose={closeTransfer} title="Transfer Funds Between Accounts" size="md" closeOnClickOutside={false}>
<form onSubmit={transferForm.onSubmit((values) => {
transferMutation.mutate({
...values,
transferDate: values.transferDate ? values.transferDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
});
})}>
<Stack>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
This creates a journal entry transferring funds between asset accounts.
Both accounts will be updated in the general ledger.
</Alert>
<Select
label="From Account"
placeholder="Select source account"
required
data={assetAccounts.map((a) => ({
value: a.id,
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
}))}
searchable
{...transferForm.getInputProps('fromAccountId')}
/>
<Select
label="To Account"
placeholder="Select destination account"
required
data={assetAccounts
.filter((a) => a.id !== transferForm.values.fromAccountId)
.map((a) => ({
value: a.id,
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
}))}
searchable
{...transferForm.getInputProps('toAccountId')}
/>
<NumberInput
label="Amount"
required
prefix="$"
decimalScale={2}
thousandSeparator=","
min={0.01}
{...transferForm.getInputProps('amount')}
/>
<DateInput
label="Transfer Date"
required
{...transferForm.getInputProps('transferDate')}
/>
<TextInput
label="Memo (optional)"
placeholder="e.g. Monthly reserve contribution"
{...transferForm.getInputProps('memo')}
/>
<Button type="submit" leftSection={<IconArrowsTransferDown size={16} />} loading={transferMutation.isPending}>
Complete Transfer
</Button>
</Stack>
</form>
</Modal>
{/* Investment Edit Modal */}
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
{editingInvestment && (

View File

@@ -0,0 +1,308 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
Tooltip, Paper,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconBulb, IconSearch, IconNote, IconFilter,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface AdminIdea {
id: string;
title: string;
description: string | null;
status: string;
createdAt: string;
adminNote: string | null;
orgId: string;
orgName: string;
userId: string;
userEmail: string;
userFirstName: string;
userLastName: string;
}
const statusColor: Record<string, string> = {
new: 'blue',
reviewed: 'yellow',
accepted: 'green',
rejected: 'red',
};
const statusOptions = [
{ value: 'new', label: 'New' },
{ value: 'reviewed', label: 'Reviewed' },
{ value: 'accepted', label: 'Accepted' },
{ value: 'rejected', label: 'Rejected' },
];
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString();
}
function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleString();
}
export function AdminIdeasPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [noteText, setNoteText] = useState('');
const queryClient = useQueryClient();
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
queryKey: ['admin-ideas'],
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
});
const updateStatus = useMutation({
mutationFn: async ({ id, status }: { id: string; status: string }) => {
await api.put(`/admin/ideas/${id}/status`, { status });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
notifications.show({ message: 'Status updated', color: 'green' });
},
});
const updateNote = useMutation({
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
await api.put(`/admin/ideas/${id}/note`, { adminNote });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
notifications.show({ message: 'Note saved', color: 'green' });
},
});
const openIdeaDetail = (idea: AdminIdea) => {
setSelectedIdea(idea);
setNoteText(idea.adminNote || '');
openDetail();
};
const handleSaveNote = () => {
if (selectedIdea) {
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
}
};
const filtered = (ideas || []).filter((idea) => {
const matchesSearch = !search ||
idea.title.toLowerCase().includes(search.toLowerCase()) ||
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
idea.userEmail.toLowerCase().includes(search.toLowerCase());
const matchesStatus = !statusFilter || idea.status === statusFilter;
return matchesSearch && matchesStatus;
});
const counts = {
total: ideas?.length || 0,
new: ideas?.filter(i => i.status === 'new').length || 0,
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
};
if (isLoading) {
return <Center h={400}><Loader /></Center>;
}
return (
<Stack>
<Group justify="space-between">
<Group>
<IconBulb size={28} />
<Title order={2}>Idea Submissions</Title>
</Group>
<Badge size="lg" variant="light">{counts.total} total</Badge>
</Group>
{/* Summary cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }}>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
</Paper>
</SimpleGrid>
{/* Filters */}
<Group>
<TextInput
placeholder="Search ideas, tenants, users..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
leftSection={<IconFilter size={16} />}
data={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
clearable
w={160}
/>
</Group>
{/* Ideas table */}
<Card withBorder p={0}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th>
<Table.Th>Tenant</Table.Th>
<Table.Th>Submitted By</Table.Th>
<Table.Th>Title</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th w={40}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text ta="center" c="dimmed" py="lg">
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
</Text>
</Table.Td>
</Table.Tr>
) : (
filtered.map((idea) => (
<Table.Tr
key={idea.id}
style={{ cursor: 'pointer' }}
onClick={() => openIdeaDetail(idea)}
>
<Table.Td>
<Text size="xs">{formatDate(idea.createdAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>{idea.orgName}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
{idea.status}
</Badge>
</Table.Td>
<Table.Td>
{idea.adminNote && (
<Tooltip label="Has admin note">
<IconNote size={16} color="gray" />
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Card>
{/* Detail Modal */}
<Modal
opened={detailOpened}
onClose={closeDetail}
title={<Text fw={600}>Idea Detail</Text>}
size="lg"
>
{selectedIdea && (
<Stack>
<Card withBorder>
<SimpleGrid cols={2} spacing="xs">
<Text size="xs" c="dimmed">Tenant</Text>
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
<Text size="xs" c="dimmed">Submitted By</Text>
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
<Text size="xs" c="dimmed">Date</Text>
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Title</Text>
<Text size="sm">{selectedIdea.title}</Text>
{selectedIdea.description && (
<>
<Text fw={600} mt="md" mb="xs">Description</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
</>
)}
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Status</Text>
<Select
data={statusOptions}
value={selectedIdea.status}
onChange={(val) => {
if (val && val !== selectedIdea.status) {
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
onSuccess: () => {
setSelectedIdea({ ...selectedIdea, status: val });
},
});
}
}}
w={200}
/>
</Card>
<Card withBorder>
<Group justify="space-between" mb="xs">
<Text fw={600}>Private Admin Note</Text>
<Text size="xs" c="dimmed">Only visible to super admins</Text>
</Group>
<Textarea
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
minRows={3}
value={noteText}
onChange={(e) => setNoteText(e.currentTarget.value)}
/>
<Button
size="xs"
variant="light"
mt="xs"
onClick={handleSaveNote}
loading={updateNote.isPending}
disabled={noteText === (selectedIdea.adminNote || '')}
>
Save Note
</Button>
</Card>
</Stack>
)}
</Modal>
</Stack>
);
}

View File

@@ -11,7 +11,7 @@ import {
IconCrown, IconPlus, IconArchive, IconChevronDown,
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
@@ -211,6 +211,16 @@ export function AdminPage() {
},
});
const toggleIdeation = useMutation({
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
},
});
const impersonateUser = useMutation({
mutationFn: async (userId: string) => {
const { data } = await api.post(`/admin/impersonate/${userId}`);
@@ -782,6 +792,27 @@ export function AdminPage() {
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Feature Toggles</Text>
<Group justify="space-between">
<Group gap="xs">
<IconBulb size={16} />
<div>
<Text size="sm">Ideation</Text>
<Text size="xs" c="dimmed">Allow users to submit feature ideas</Text>
</div>
</Group>
<Switch
checked={tenantDetail.organization.settings?.ideationEnabled === true}
onChange={(e) => {
if (selectedOrgId) {
toggleIdeation.mutate({ orgId: selectedOrgId, enabled: e.currentTarget.checked });
}
}}
/>
</Group>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Subscription</Text>
<Stack gap="xs">

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import {
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
@@ -40,7 +40,7 @@ export function InvestmentScenarioDetailPage() {
},
});
const { data: projection, isLoading: projLoading } = useQuery({
const { data: projection, isLoading: projLoading, dataUpdatedAt: projUpdatedAt } = useQuery({
queryKey: ['board-planning-projection', id],
queryFn: async () => {
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
@@ -49,6 +49,17 @@ export function InvestmentScenarioDetailPage() {
enabled: !!id,
});
// When projection refreshes (which may create auto-renew records on the backend),
// re-fetch the scenario so the investments list picks up any new renewal records.
const [lastProjUpdate, setLastProjUpdate] = useState(0);
if (projUpdatedAt && projUpdatedAt !== lastProjUpdate) {
setLastProjUpdate(projUpdatedAt);
if (lastProjUpdate > 0) {
// Only re-fetch after a real update (not the initial load)
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
}
}
const addMutation = useMutation({
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
onSuccess: () => {
@@ -100,12 +111,40 @@ export function InvestmentScenarioDetailPage() {
},
});
// Compute shared time range for aligned charts (must be above early returns to satisfy Rules of Hooks)
const investments = scenario?.investments || [];
const summary = projection?.summary;
const { sharedStartDate, sharedEndDate } = useMemo(() => {
const allDates: Date[] = [];
// Dates from investments
for (const inv of investments) {
if (inv.purchase_date) allDates.push(new Date(inv.purchase_date));
if (inv.maturity_date) allDates.push(new Date(inv.maturity_date));
}
// Dates from projection datapoints
const dps = projection?.datapoints || [];
if (dps.length > 0) {
allDates.push(new Date(dps[0].year, dps[0].monthNum - 1, 1));
const last = dps[dps.length - 1];
allDates.push(new Date(last.year, last.monthNum - 1, 1));
}
if (allDates.length === 0) return { sharedStartDate: undefined, sharedEndDate: undefined };
const min = new Date(Math.min(...allDates.map((d) => d.getTime())));
const max = new Date(Math.max(...allDates.map((d) => d.getTime())));
return {
sharedStartDate: new Date(min.getFullYear(), min.getMonth(), 1),
sharedEndDate: new Date(max.getFullYear(), max.getMonth(), 1),
};
}, [investments, projection]);
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
const investments = scenario.investments || [];
const summary = projection?.summary;
// Build a lookup of per-investment interest from the projection
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
if (summary?.investment_interest_details) {
@@ -259,7 +298,13 @@ export function InvestmentScenarioDetailPage() {
</Card>
{/* Investment Timeline */}
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
{investments.length > 0 && (
<InvestmentTimeline
investments={investments}
sharedStartDate={sharedStartDate}
sharedEndDate={sharedEndDate}
/>
)}
{/* Projection Chart */}
{projection && (
@@ -267,6 +312,8 @@ export function InvestmentScenarioDetailPage() {
datapoints={projection.datapoints || []}
title="Scenario Projection"
summary={projection.summary}
sharedStartDate={sharedStartDate}
sharedEndDate={sharedEndDate}
/>
)}
{projLoading && <Center py="xl"><Loader /></Center>}

View File

@@ -13,9 +13,12 @@ const typeColors: Record<string, string> = {
interface Props {
investments: any[];
/** Optional shared time range to align with ProjectionChart */
sharedStartDate?: Date;
sharedEndDate?: Date;
}
export function InvestmentTimeline({ investments }: Props) {
export function InvestmentTimeline({ investments, sharedStartDate, sharedEndDate }: Props) {
const { items, startDate, endDate, totalMonths } = useMemo(() => {
const now = new Date();
const items = investments
@@ -28,16 +31,24 @@ export function InvestmentTimeline({ investments }: Props) {
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
// Use shared range if provided (to align with ProjectionChart), otherwise compute from investments
let startDate: Date;
let endDate: Date;
if (sharedStartDate && sharedEndDate) {
startDate = sharedStartDate;
endDate = sharedEndDate;
} else {
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
}
const totalMonths = Math.max(
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
1,
);
return { items, startDate, endDate, totalMonths };
}, [investments]);
}, [investments, sharedStartDate, sharedEndDate]);
if (!items.length) return null;

View File

@@ -23,18 +23,31 @@ interface Props {
datapoints: Datapoint[];
title?: string;
summary?: any;
/** Optional shared time range to align with InvestmentTimeline */
sharedStartDate?: Date;
sharedEndDate?: Date;
}
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary, sharedStartDate, sharedEndDate }: Props) {
const [fundFilter, setFundFilter] = useState('all');
const chartData = useMemo(() => {
return datapoints.map((d) => ({
let filtered = datapoints;
// If shared range provided, filter datapoints to match
if (sharedStartDate && sharedEndDate) {
const startKey = sharedStartDate.getFullYear() * 12 + sharedStartDate.getMonth();
const endKey = sharedEndDate.getFullYear() * 12 + sharedEndDate.getMonth();
filtered = datapoints.filter((d) => {
const dpKey = d.year * 12 + (d.monthNum - 1);
return dpKey >= startKey && dpKey <= endKey;
});
}
return filtered.map((d) => ({
...d,
label: `${d.month}`,
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
}));
}, [datapoints]);
}, [datapoints, sharedStartDate, sharedEndDate]);
// Find first forecast month for reference line
const forecastStart = chartData.findIndex((d) => d.is_forecast);

View File

@@ -15,6 +15,8 @@ import {
IconHeartbeat,
IconRefresh,
IconInfoCircle,
IconCoin,
IconCalendarEvent,
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -362,6 +364,16 @@ export function DashboardPage() {
enabled: !!currentOrg,
});
const { data: investmentActivities } = useQuery<{
maturing_investments: any[];
upcoming_scenario_investments: any[];
total_activities: number;
}>({
queryKey: ['upcoming-investment-activities'],
queryFn: async () => { const { data } = await api.get('/reports/upcoming-investment-activities'); return data; },
enabled: !!currentOrg,
});
const { data: healthScores } = useQuery<HealthScoresData>({
queryKey: ['health-scores'],
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
@@ -531,6 +543,97 @@ export function DashboardPage() {
</Card>
</SimpleGrid>
{/* Upcoming Investment Activities */}
{(investmentActivities?.total_activities || 0) > 0 && (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="sm">
<Group gap="xs">
<ThemeIcon color="teal" variant="light" size={28} radius="md">
<IconCalendarEvent size={16} />
</ThemeIcon>
<Title order={4}>Upcoming Investment Activities</Title>
</Group>
<Badge variant="light" color="teal">{investmentActivities?.total_activities} upcoming</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Activity</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Timeline</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(investmentActivities?.maturing_investments || []).map((inv: any) => (
<Table.Tr key={`mat-${inv.id}`}>
<Table.Td>
<Group gap={6}>
<IconCoin size={14} color="var(--mantine-color-orange-6)" />
<Text size="sm" fw={500}>{inv.name}</Text>
</Group>
{inv.institution && <Text size="xs" c="dimmed">{inv.institution}</Text>}
</Table.Td>
<Table.Td>
<Badge size="xs" color="orange" variant="light">Maturing</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={500}>{fmt(inv.maturity_value)}</Text>
<Text size="xs" c="green">+{fmt(inv.interest_earned)} interest</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color={inv.days_remaining <= 14 ? 'red' : inv.days_remaining <= 30 ? 'yellow' : 'gray'} variant="light">
{inv.days_remaining} days
</Badge>
</Table.Td>
</Table.Tr>
))}
{(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => (
<Table.Tr key={`plan-${si.id}`}>
<Table.Td>
<Group gap={6}>
<IconTrendingUp size={14} color="var(--mantine-color-blue-6)" />
<Text size="sm" fw={500}>{si.label}</Text>
</Group>
<Text size="xs" c="dimmed">Scenario: {si.scenario_name}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" color="blue" variant="light">Planned Purchase</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" color={si.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
{si.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={500}>{fmt(si.principal)}</Text>
{si.interest_rate && <Text size="xs" c="dimmed">{parseFloat(si.interest_rate).toFixed(2)}% APY</Text>}
</Table.Td>
<Table.Td>
<Text size="sm">{new Date(si.purchase_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color={si.days_until <= 14 ? 'red' : si.days_until <= 30 ? 'yellow' : 'gray'} variant="light">
{si.days_until} days
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Card withBorder padding="lg" radius="md">
<Title order={4}>Quick Stats</Title>

View File

@@ -0,0 +1,196 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
Button, NumberInput,
} from '@mantine/core';
import { IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
interface ProjectItem {
id: string;
name: string;
description: string;
category: string;
estimated_cost: number;
target_year: number | null;
useful_life_years: number | null;
last_replacement_date: string | null;
fund_source: string;
status: string;
priority: number;
condition_rating: number | null;
year_amounts: Record<number, number>;
beyond: number;
}
interface CategoryGroup {
category: string;
projects: ProjectItem[];
}
interface CapitalPlanningData {
title: string;
start_year: number;
years: number[];
categories: CategoryGroup[];
year_totals: Record<number, number>;
beyond_total: number;
grand_total: number;
generated_at: string;
}
const fmt = (v: number) =>
v === 0 ? '-' : v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
export function CapitalPlanningPage() {
const [startYear, setStartYear] = useState(new Date().getFullYear());
const { data, isLoading } = useQuery<CapitalPlanningData>({
queryKey: ['capital-planning', startYear],
queryFn: async () => {
const { data } = await api.get(`/reports/capital-planning?startYear=${startYear}`);
return data;
},
});
if (isLoading) return <Center h={300}><Loader /></Center>;
const years = data?.years || [];
const hasProjects = (data?.categories || []).some((c) => c.projects.length > 0);
return (
<Stack>
<Group justify="space-between">
<div>
<Title order={2}>Capital Planning Report</Title>
<Text c="dimmed" size="sm">{data?.title || '5-Year Capital Project Forecast'}</Text>
</div>
<Group>
<NumberInput
size="xs"
w={100}
value={startYear}
onChange={(v) => v && setStartYear(Number(v))}
min={2020}
max={2050}
/>
<Button
variant="light"
leftSection={<IconPrinter size={16} />}
onClick={() => window.print()}
>
Print / PDF
</Button>
</Group>
</Group>
{!hasProjects ? (
<Card withBorder p="xl">
<Text ta="center" c="dimmed" py="lg">
No capital projects found. Add projects on the Projects page to generate this report.
</Text>
</Card>
) : (
<Card withBorder p="lg" className="capital-planning-print">
<Title order={3} ta="center" mb="xs">{data?.title}</Title>
<Text ta="center" c="dimmed" size="sm" mb="md">
Generated {new Date(data?.generated_at || '').toLocaleDateString()}
</Text>
<Table striped withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th ta="center" w={60}>Life (yr)</Table.Th>
<Table.Th ta="center" w={90}>Last Done</Table.Th>
{years.map((y) => (
<Table.Th key={y} ta="right" w={100}>{y}</Table.Th>
))}
<Table.Th ta="right" w={100}>Beyond</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(data?.categories || []).map((cat) => {
const catTotals: Record<number, number> = {};
let catBeyond = 0;
for (const y of years) catTotals[y] = 0;
for (const p of cat.projects) {
for (const y of years) catTotals[y] += p.year_amounts[y] || 0;
catBeyond += p.beyond;
}
return [
<Table.Tr key={`cat-${cat.category}`} style={{ background: 'var(--mantine-color-blue-0)' }}>
<Table.Td colSpan={3 + years.length + 1}>
<Text fw={700} size="sm">{cat.category}</Text>
</Table.Td>
</Table.Tr>,
...cat.projects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td>
<Text size="sm">{p.name}</Text>
{p.status !== 'planned' && (
<Badge size="xs" variant="light" ml={4}
color={p.status === 'completed' ? 'green' : p.status === 'in_progress' ? 'blue' : 'gray'}>
{p.status}
</Badge>
)}
</Table.Td>
<Table.Td ta="center">
<Text size="sm">{p.useful_life_years || '-'}</Text>
</Table.Td>
<Table.Td ta="center">
<Text size="sm">
{p.last_replacement_date
? new Date(p.last_replacement_date).getFullYear()
: '-'}
</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text size="sm">{fmt(p.year_amounts[y] || 0)}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text size="sm">{fmt(p.beyond)}</Text>
</Table.Td>
</Table.Tr>
)),
<Table.Tr key={`subtotal-${cat.category}`} style={{ borderTop: '2px solid var(--mantine-color-gray-4)' }}>
<Table.Td colSpan={3}>
<Text size="sm" fw={600} fs="italic">Subtotal {cat.category}</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text size="sm" fw={600}>{fmt(catTotals[y])}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={600}>{fmt(catBeyond)}</Text>
</Table.Td>
</Table.Tr>,
];
})}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr style={{ background: 'var(--mantine-color-dark-0)' }}>
<Table.Td colSpan={3}>
<Text fw={700}>TOTAL</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text fw={700}>{fmt(data?.year_totals[y] || 0)}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text fw={700}>{fmt(data?.beyond_total || 0)}</Text>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
</Card>
)}
</Stack>
);
}

View File

@@ -237,7 +237,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.03.18</Badge>
<Badge variant="light">2026.4.2</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

@@ -0,0 +1,183 @@
/**
* HOALedgerIQ Auth + Dashboard Load Test
* Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout
*
* Covers the highest-frequency production flow: a treasurer or admin
* opening the app, loading the dashboard, and reviewing financial reports.
*/
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { SharedArray } from 'k6/data';
import { Trend, Rate, Counter } from 'k6/metrics';
// ── Custom metrics ──────────────────────────────────────────────────────────
const loginDuration = new Trend('login_duration', true);
const dashboardDuration = new Trend('dashboard_duration', true);
const refreshDuration = new Trend('refresh_duration', true);
const authErrorRate = new Rate('auth_error_rate');
const dashboardErrorRate = new Rate('dashboard_error_rate');
const tokenRefreshCount = new Counter('token_refresh_count');
// ── User pool ────────────────────────────────────────────────────────────────
const users = new SharedArray('users', function () {
return open('../config/user-pool.csv')
.split('\n')
.slice(1) // skip header row
.filter(line => line.trim())
.map(line => {
const [email, password, orgId, role] = line.split(',');
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
});
});
// ── Environment config ───────────────────────────────────────────────────────
const ENV = __ENV.TARGET_ENV || 'staging';
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
const BASE_URL = envConfig.baseUrl;
// ── Test options ─────────────────────────────────────────────────────────────
export const options = {
scenarios: {
auth_dashboard: {
executor: 'ramping-vus',
stages: [
{ duration: '2m', target: 20 }, // warm up
{ duration: '5m', target: 100 }, // ramp to target load
{ duration: '5m', target: 100 }, // sustained load
{ duration: '3m', target: 200 }, // peak spike
{ duration: '2m', target: 0 }, // ramp down
],
},
},
thresholds: {
// Latency targets per environment (overridden by environments.json)
'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`],
'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`],
'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
},
};
// ── Helpers ──────────────────────────────────────────────────────────────────
function authHeaders(token) {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
}
// ── Main scenario ────────────────────────────────────────────────────────────
export default function () {
const user = users[__VU % users.length];
let accessToken = null;
// ── 1. Login ────────────────────────────────────────────────────────────
group('auth:login', () => {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ email: user.email, password: user.password }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
);
loginDuration.add(res.timings.duration);
const ok = check(res, {
'login 200': r => r.status === 200,
'has access_token': r => r.json('access_token') !== undefined,
'has orgId in body': r => r.json('user.orgId') !== undefined,
});
authErrorRate.add(!ok);
if (!ok) { sleep(1); return; }
accessToken = res.json('access_token');
// httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar
});
if (!accessToken) return;
sleep(1.5); // think time user lands on dashboard
// ── 2. Load dashboard & key reports in parallel ─────────────────────────
group('dashboard:load', () => {
const requests = {
dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`],
balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`],
income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`],
profile: ['GET', `${BASE_URL}/api/auth/profile`],
accounts: ['GET', `${BASE_URL}/api/accounts`],
};
const responses = http.batch(
Object.entries(requests).map(([name, [method, url]]) => ({
method, url,
params: { headers: authHeaders(accessToken), tags: { name } },
}))
);
let allOk = true;
responses.forEach((res, i) => {
const name = Object.keys(requests)[i];
dashboardDuration.add(res.timings.duration, { endpoint: name });
const ok = check(res, {
[`${name} 200`]: r => r.status === 200,
[`${name} has body`]: r => r.body && r.body.length > 0,
});
if (!ok) allOk = false;
});
dashboardErrorRate.add(!allOk);
});
sleep(2); // user reads the dashboard
// ── 3. Simulate token refresh (happens automatically in-app at 55min) ────
// In the load test we trigger it early to validate the refresh path under load
group('auth:refresh', () => {
const res = http.post(
`${BASE_URL}/api/auth/refresh`,
null,
{
headers: authHeaders(accessToken),
tags: { name: 'refresh' },
// k6 sends the httpOnly cookie from the jar automatically
}
);
refreshDuration.add(res.timings.duration);
tokenRefreshCount.add(1);
const ok = check(res, {
'refresh 200': r => r.status === 200,
'new access_token': r => r.json('access_token') !== undefined,
});
authErrorRate.add(!ok);
if (ok) accessToken = res.json('access_token');
});
sleep(1);
// ── 4. Drill into one report (cash-flow forecast typically slowest) ────
group('dashboard:drill', () => {
const res = http.get(
`${BASE_URL}/api/reports/cash-flow-forecast`,
{ headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } }
);
dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' });
dashboardErrorRate.add(res.status !== 200);
check(res, { 'forecast 200': r => r.status === 200 });
});
sleep(2);
// ── 5. Logout ────────────────────────────────────────────────────────────
group('auth:logout', () => {
const res = http.post(
`${BASE_URL}/api/auth/logout`,
null,
{ headers: authHeaders(accessToken), tags: { name: 'logout' } }
);
check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 });
});
sleep(1);
}

45
load-tests/baseline.json Normal file
View File

@@ -0,0 +1,45 @@
{
"_meta": {
"description": "Baseline p50/p95/p99 latency targets per endpoint. Update after each cycle where improvements are confirmed. Claude Code will tighten k6 thresholds in environments.json to match.",
"last_updated": "YYYY-MM-DD",
"last_run_cycle": 0,
"units": "milliseconds"
},
"auth": {
"POST /api/auth/login": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/auth/refresh": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/auth/logout": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/auth/profile": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"reports": {
"GET /api/reports/dashboard": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/balance-sheet": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/income-statement": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/cash-flow": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/cash-flow-forecast": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/aging": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/reports/quarterly": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"accounts": {
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"journal_entries": {
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"budgets": {
"GET /api/budgets/:year": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"GET /api/budgets/:year/vs-actual": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"invoices": {
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/invoices/generate-preview": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "error_rate": null }
},
"payments": {
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null },
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null }
}
}

259
load-tests/crud-flow.js Normal file
View File

@@ -0,0 +1,259 @@
/**
* HOALedgerIQ Core CRUD Workflow Load Test
* Journey: Login → Create Journal Entry → Post It → Create Invoice →
* Record Payment → View Accounts → Budget vs Actual → Logout
*
* This scenario exercises write-heavy paths gated by WriteAccessGuard
* and the TenantMiddleware schema-switch. Run this alongside
* auth-dashboard-flow.js to simulate a realistic mixed workload.
*
* Role used: treasurer (has full write access, most common power user)
*/
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { SharedArray } from 'k6/data';
import { Trend, Rate } from 'k6/metrics';
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
// ── Custom metrics ──────────────────────────────────────────────────────────
const journalEntryDuration = new Trend('journal_entry_duration', true);
const invoiceDuration = new Trend('invoice_duration', true);
const paymentDuration = new Trend('payment_duration', true);
const accountsReadDuration = new Trend('accounts_read_duration', true);
const budgetDuration = new Trend('budget_vs_actual_duration',true);
const crudErrorRate = new Rate('crud_error_rate');
const writeGuardErrorRate = new Rate('write_guard_error_rate');
// ── User pool (treasurer + admin roles only for write access) ────────────────
const users = new SharedArray('users', function () {
return open('../config/user-pool.csv')
.split('\n')
.slice(1)
.filter(line => line.trim())
.map(line => {
const [email, password, orgId, role] = line.split(',');
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
})
.filter(u => ['treasurer', 'admin', 'president', 'manager'].includes(u.role));
});
// ── Environment config ───────────────────────────────────────────────────────
const ENV = __ENV.TARGET_ENV || 'staging';
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
const BASE_URL = envConfig.baseUrl;
// ── Test options ─────────────────────────────────────────────────────────────
export const options = {
scenarios: {
crud_workflow: {
executor: 'ramping-vus',
stages: [
{ duration: '2m', target: 10 }, // warm up (writes need more care)
{ duration: '5m', target: 50 }, // ramp to target
{ duration: '5m', target: 50 }, // sustained
{ duration: '3m', target: 100 }, // peak
{ duration: '2m', target: 0 }, // ramp down
],
},
},
thresholds: {
'journal_entry_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
'invoice_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
'payment_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
'accounts_read_duration': [`p(95)<${envConfig.thresholds.read_p95}`],
'budget_vs_actual_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
'crud_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
'write_guard_error_rate': ['rate<0.001'], // write-guard failures should be near-zero
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
},
};
// ── Helpers ──────────────────────────────────────────────────────────────────
function jsonHeaders(token) {
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
}
function currentYear() {
return new Date().getFullYear();
}
// ── Main scenario ────────────────────────────────────────────────────────────
export default function () {
const user = users[__VU % users.length];
let accessToken = null;
// ── 1. Login ────────────────────────────────────────────────────────────
group('auth:login', () => {
const res = http.post(
`${BASE_URL}/api/auth/login`,
JSON.stringify({ email: user.email, password: user.password }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
);
const ok = check(res, {
'login 200': r => r.status === 200,
'has access_token': r => r.json('access_token') !== undefined,
});
crudErrorRate.add(!ok);
if (!ok) { sleep(1); return; }
accessToken = res.json('access_token');
});
if (!accessToken) return;
sleep(1);
// ── 2. Read accounts (needed to pick valid account IDs for journal entry) ─
let debitAccountId = null;
let creditAccountId = null;
group('accounts:list', () => {
const res = http.get(
`${BASE_URL}/api/accounts`,
{ headers: jsonHeaders(accessToken), tags: { name: 'accounts_list' } }
);
accountsReadDuration.add(res.timings.duration);
const ok = check(res, {
'accounts 200': r => r.status === 200,
'accounts non-empty': r => Array.isArray(r.json()) && r.json().length > 0,
});
crudErrorRate.add(!ok);
if (ok) {
const accounts = res.json();
// Pick first two distinct accounts for the journal entry
debitAccountId = accounts[0]?.id;
creditAccountId = accounts[1]?.id;
}
});
if (!debitAccountId || !creditAccountId) { sleep(1); return; }
sleep(1.5);
// ── 3. Create journal entry (draft) ────────────────────────────────────
let journalEntryId = null;
group('journal:create', () => {
const payload = {
date: new Date().toISOString().split('T')[0],
description: `Load test entry ${uuidv4().slice(0, 8)}`,
lines: [
{ accountId: debitAccountId, type: 'debit', amount: 100.00, description: 'Load test debit' },
{ accountId: creditAccountId, type: 'credit', amount: 100.00, description: 'Load test credit' },
],
};
const res = http.post(
`${BASE_URL}/api/journal-entries`,
JSON.stringify(payload),
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_create' } }
);
journalEntryDuration.add(res.timings.duration);
// Watch for WriteAccessGuard rejections (403)
writeGuardErrorRate.add(res.status === 403);
const ok = check(res, {
'journal create 201': r => r.status === 201,
'journal has id': r => r.json('id') !== undefined,
});
crudErrorRate.add(!ok);
if (ok) journalEntryId = res.json('id');
});
sleep(1);
// ── 4. Post the journal entry ────────────────────────────────────────────
if (journalEntryId) {
group('journal:post', () => {
const res = http.post(
`${BASE_URL}/api/journal-entries/${journalEntryId}/post`,
null,
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_post' } }
);
journalEntryDuration.add(res.timings.duration);
writeGuardErrorRate.add(res.status === 403);
const ok = check(res, { 'journal post 200': r => r.status === 200 });
crudErrorRate.add(!ok);
});
sleep(1.5);
}
// ── 5. Generate invoice preview ─────────────────────────────────────────
let invoicePreviewOk = false;
group('invoice:preview', () => {
const res = http.post(
`${BASE_URL}/api/invoices/generate-preview`,
JSON.stringify({ period: currentYear() }),
{ headers: jsonHeaders(accessToken), tags: { name: 'invoice_preview' } }
);
invoiceDuration.add(res.timings.duration);
invoicePreviewOk = check(res, { 'invoice preview 200': r => r.status === 200 });
crudErrorRate.add(!invoicePreviewOk);
});
sleep(2); // user reviews invoice preview
// ── 6. Create a payment record ───────────────────────────────────────────
group('payment:create', () => {
const payload = {
amount: 150.00,
date: new Date().toISOString().split('T')[0],
method: 'check',
description: `Load test payment ${uuidv4().slice(0, 8)}`,
};
const res = http.post(
`${BASE_URL}/api/payments`,
JSON.stringify(payload),
{ headers: jsonHeaders(accessToken), tags: { name: 'payment_create' } }
);
paymentDuration.add(res.timings.duration);
writeGuardErrorRate.add(res.status === 403);
const ok = check(res, {
'payment create 201 or 200': r => r.status === 201 || r.status === 200,
});
crudErrorRate.add(!ok);
});
sleep(1.5);
// ── 7. Budget vs actual (typically the heaviest read query) ─────────────
group('budget:vs-actual', () => {
const year = currentYear();
const res = http.get(
`${BASE_URL}/api/budgets/${year}/vs-actual`,
{ headers: jsonHeaders(accessToken), tags: { name: 'budget_vs_actual' } }
);
budgetDuration.add(res.timings.duration);
const ok = check(res, { 'budget vs-actual 200': r => r.status === 200 });
crudErrorRate.add(!ok);
});
sleep(1);
// ── 8. Trial balance read ────────────────────────────────────────────────
group('accounts:trial-balance', () => {
const res = http.get(
`${BASE_URL}/api/accounts/trial-balance`,
{ headers: jsonHeaders(accessToken), tags: { name: 'trial_balance' } }
);
accountsReadDuration.add(res.timings.duration);
check(res, { 'trial balance 200': r => r.status === 200 });
});
sleep(1);
// ── 9. Logout ────────────────────────────────────────────────────────────
group('auth:logout', () => {
http.post(
`${BASE_URL}/api/auth/logout`,
null,
{ headers: jsonHeaders(accessToken), tags: { name: 'logout' } }
);
});
sleep(1);
}

View File

@@ -0,0 +1,117 @@
# HOALedgerIQ Load Test Improvement Report
**Cycle:** 001
**Date:** YYYY-MM-DD
**Test window:** HH:MM HH:MM UTC
**Environments:** Staging (`staging.hoaledgeriq.com`)
**Scenarios run:** `auth-dashboard-flow.js` + `crud-flow.js`
**Peak VUs:** 200 (dashboard) / 100 (CRUD)
**New Relic app:** `HOALedgerIQ_App`
---
## Executive Summary
> _[One paragraph: what load the system handled, what broke first, at what VU threshold, and the estimated user-facing impact. Written by Claude Code from New Relic data.]_
**Threshold breaches this cycle:**
| Metric | Target | Actual | Status |
|--------|--------|--------|--------|
| login p95 | < 300ms | | 🔴 / 🟢 |
| dashboard p95 | < 1000ms | | 🔴 / 🟢 |
| budget vs-actual p95 | < 1000ms | | 🔴 / 🟢 |
| journal entry write p95 | < 1200ms | | 🔴 / 🟢 |
| error rate | < 1% | | 🔴 / 🟢 |
---
## Findings
### 🔴 P0 Fix Before Next Deploy
#### Finding 001 [Short title]
- **Symptom:** _e.g., `GET /api/reports/cash-flow-forecast` p95 = 3,400ms at 100 VUs_
- **New Relic evidence:** _e.g., DatastoreSegment shows 47 sequential DB calls per request_
- **Root cause hypothesis:** _e.g., N+1 on `reserve_components` each component triggers a separate `SELECT` for `monthly_actuals`_
- **File:** `backend/src/modules/reports/cash-flow.service.ts:83`
- **Recommended fix:**
```typescript
// BEFORE N+1: one query per component
for (const component of components) {
const actuals = await this.actualsRepo.findBy({ componentId: component.id });
}
// AFTER batch load with WHERE IN
const actuals = await this.actualsRepo.findBy({
componentId: In(components.map(c => c.id))
});
```
- **Expected improvement:** ~70% latency reduction on this endpoint
- **Effort:** Low (12 hours)
---
### 🟠 P1 Fix Within This Sprint
#### Finding 002 [Short title]
- **Symptom:**
- **New Relic evidence:**
- **Root cause hypothesis:**
- **File:**
- **Recommended fix:**
- **Expected improvement:**
- **Effort:**
#### Finding 003 [Short title]
- _(same structure)_
---
### 🟡 P2 Backlog
#### Finding 004 [Short title]
- **Symptom:**
- **Root cause hypothesis:**
- **Recommended fix:**
- **Effort:**
---
## Regression Net — Re-Test Criteria
After implementing P0 + P1 fixes, the next BlazeMeter run must pass these gates before merging to staging:
| Endpoint | Previous p95 | Target p95 | k6 Threshold |
|----------|-------------|------------|-------------|
| `GET /api/reports/cash-flow-forecast` | — | — | `p(95)<XXX` |
| `POST /api/journal-entries` | — | — | `p(95)<XXX` |
| `GET /api/budgets/:year/vs-actual` | — | — | `p(95)<XXX` |
> **Claude Code update command (run after confirming fixes):**
> ```bash
> claude "Update load-tests/analysis/baseline.json with the p95 values from
> load-tests/reports/cycle-001.md findings. Tighten the k6 thresholds in
> load-tests/config/environments.json staging block to match. Do not loosen
> any threshold that already passes."
> ```
---
## Baseline Delta
| Endpoint | Cycle 000 p95 | Cycle 001 p95 | Δ |
|----------|--------------|--------------|---|
| _(populated after first run)_ | — | — | — |
---
## Notes & Observations
- _Any anomalies, flaky tests, or infrastructure events during the run_
- _Redis / BullMQ queue depth observations_
- _Rate limiter (Throttler) trip count — if >0, note which endpoints and at what VU count_
- _TenantMiddleware cache hit rate (if observable via New Relic custom attributes)_
---
_Generated by Claude Code. Source data in `load-tests/analysis/raw/`. Next cycle target: implement P0+P1, re-run at same peak VUs, update baselines._

View File

@@ -0,0 +1,38 @@
{
"local": {
"baseUrl": "http://localhost:3000",
"thresholds": {
"auth_p95": 500,
"refresh_p95": 300,
"read_p95": 1000,
"write_p95": 1500,
"dashboard_p95": 1500,
"global_p99": 3000,
"error_rate": 0.05
}
},
"staging": {
"baseUrl": "https://staging.hoaledgeriq.com",
"thresholds": {
"auth_p95": 300,
"refresh_p95": 200,
"read_p95": 800,
"write_p95": 1200,
"dashboard_p95": 1000,
"global_p99": 2000,
"error_rate": 0.01
}
},
"production": {
"baseUrl": "https://app.hoaledgeriq.com",
"thresholds": {
"auth_p95": 200,
"refresh_p95": 150,
"read_p95": 500,
"write_p95": 800,
"dashboard_p95": 700,
"global_p99": 1500,
"error_rate": 0.005
}
}
}

274
load-tests/nrql-queries.sql Normal file
View File

@@ -0,0 +1,274 @@
-- ============================================================
-- HOALedgerIQ New Relic NRQL Query Library
-- App name: HOALedgerIQ_App
-- Usage: Run in New Relic Query Builder. Replace time windows as needed.
-- ============================================================
-- ── SECTION 1: OVERVIEW HEALTH ────────────────────────────────────────────
-- 1.1 Apdex score over last test window
SELECT apdex(duration, t: 0.5) AS 'Apdex'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES 1 minute
-- 1.2 Overall throughput (requests per minute)
SELECT rate(count(*), 1 minute) AS 'RPM'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES 1 minute
-- 1.3 Error rate over time
SELECT percentage(count(*), WHERE error IS true) AS 'Error %'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
SINCE 1 hour ago
TIMESERIES 1 minute
-- ── SECTION 2: LATENCY BY ENDPOINT ────────────────────────────────────────
-- 2.1 p50 / p95 / p99 latency by transaction name
SELECT percentile(duration, 50, 95, 99) AS 'ms'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET name
SINCE 1 hour ago
LIMIT 30
-- 2.2 Slowest endpoints (p95) during load test window
SELECT percentile(duration, 95) AS 'p95 ms'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET name
SINCE 1 hour ago
ORDER BY percentile(duration, 95) DESC
LIMIT 20
-- 2.3 Auth endpoint latency breakdown
SELECT percentile(duration, 50, 95, 99)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND name LIKE '%auth%'
FACET name
SINCE 1 hour ago
-- 2.4 Report endpoint latency (typically slowest reads)
SELECT percentile(duration, 50, 95, 99)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND name LIKE '%reports%'
FACET name
SINCE 1 hour ago
-- 2.5 Write endpoint latency (journal-entries, payments, invoices)
SELECT percentile(duration, 50, 95, 99)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND (name LIKE '%journal-entries%' OR name LIKE '%payments%' OR name LIKE '%invoices%')
FACET name
SINCE 1 hour ago
-- 2.6 Latency heatmap over time for dashboard load
SELECT histogram(duration, width: 100, buckets: 20)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND name LIKE '%reports/dashboard%'
SINCE 1 hour ago
-- ── SECTION 3: DATABASE PERFORMANCE ──────────────────────────────────────
-- 3.1 Slowest database queries (top 20)
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
FROM DatastoreSegment
WHERE appName = 'HOALedgerIQ_App'
FACET statement
SINCE 1 hour ago
ORDER BY average(duration) DESC
LIMIT 20
-- 3.2 Database call count by operation type
SELECT count(*)
FROM DatastoreSegment
WHERE appName = 'HOALedgerIQ_App'
FACET operation
SINCE 1 hour ago
-- 3.3 N+1 detection high-call-count queries
SELECT count(*) AS 'call count', average(duration) AS 'avg ms'
FROM DatastoreSegment
WHERE appName = 'HOALedgerIQ_App'
FACET statement
SINCE 1 hour ago
ORDER BY count(*) DESC
LIMIT 20
-- 3.4 DB time as % of total transaction time (per endpoint)
SELECT average(databaseDuration) / average(duration) * 100 AS '% DB time'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND databaseDuration IS NOT NULL
FACET name
SINCE 1 hour ago
ORDER BY average(databaseDuration) / average(duration) DESC
LIMIT 20
-- 3.5 Connection pool pressure (slow queries that may indicate pool exhaustion)
SELECT count(*) AS 'slow queries (>500ms)'
FROM DatastoreSegment
WHERE appName = 'HOALedgerIQ_App'
AND duration > 0.5
FACET statement
SINCE 1 hour ago
-- 3.6 Multi-tenant schema switch overhead (TenantMiddleware)
SELECT average(duration) AS 'avg ms'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND name NOT LIKE '%auth/login%'
AND name NOT LIKE '%auth/refresh%'
FACET name
SINCE 1 hour ago
ORDER BY average(duration) DESC
LIMIT 20
-- ── SECTION 4: ERROR ANALYSIS ─────────────────────────────────────────────
-- 4.1 All errors by class and message
SELECT count(*), latest(errorMessage)
FROM TransactionError
WHERE appName = 'HOALedgerIQ_App'
FACET errorClass, errorMessage
SINCE 1 hour ago
LIMIT 30
-- 4.2 Error rate by HTTP status code
SELECT count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode >= 400
FACET httpResponseCode
SINCE 1 hour ago
TIMESERIES 1 minute
-- 4.3 403 errors (WriteAccessGuard rejections under load)
SELECT count(*) AS '403 Forbidden'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode = 403
FACET name
SINCE 1 hour ago
-- 4.4 429 errors (rate limiter Throttler)
SELECT count(*) AS '429 Rate Limited'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode = 429
TIMESERIES 1 minute
SINCE 1 hour ago
-- 4.5 500 errors by endpoint
SELECT count(*), latest(errorMessage)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND httpResponseCode = 500
FACET name, errorMessage
SINCE 1 hour ago
-- 4.6 JWT / auth failures
SELECT count(*)
FROM TransactionError
WHERE appName = 'HOALedgerIQ_App'
AND (errorMessage LIKE '%jwt%' OR errorMessage LIKE '%token%' OR errorMessage LIKE '%unauthorized%')
FACET errorMessage
SINCE 1 hour ago
-- ── SECTION 5: INFRASTRUCTURE (during test window) ───────────────────────
-- 5.1 CPU utilization
SELECT average(cpuPercent) AS 'CPU %'
FROM SystemSample
WHERE hostname LIKE '%hoaledgeriq%'
SINCE 1 hour ago
TIMESERIES 1 minute
-- 5.2 Memory utilization
SELECT average(memoryUsedPercent) AS 'Memory %'
FROM SystemSample
WHERE hostname LIKE '%hoaledgeriq%'
SINCE 1 hour ago
TIMESERIES 1 minute
-- 5.3 Network I/O
SELECT average(transmitBytesPerSecond) AS 'TX bytes/s',
average(receiveBytesPerSecond) AS 'RX bytes/s'
FROM NetworkSample
WHERE hostname LIKE '%hoaledgeriq%'
SINCE 1 hour ago
TIMESERIES 1 minute
-- ── SECTION 6: REDIS / BULLMQ ─────────────────────────────────────────────
-- 6.1 External call latency (Redis)
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
FROM ExternalSegment
WHERE appName = 'HOALedgerIQ_App'
AND (name LIKE '%redis%' OR host LIKE '%redis%')
FACET name
SINCE 1 hour ago
-- 6.2 All external service latency
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
FROM ExternalSegment
WHERE appName = 'HOALedgerIQ_App'
FACET host
SINCE 1 hour ago
ORDER BY average(duration) DESC
-- ── SECTION 7: BASELINE COMPARISON ───────────────────────────────────────
-- 7.1 Compare this run vs last run (adjust SINCE/UNTIL for your windows)
SELECT percentile(duration, 95) AS 'p95 this run'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET name
SINCE '2025-01-01 10:00:00' UNTIL '2025-01-01 11:00:00'
-- Run again with previous window dates to compare
-- 7.2 Regression check endpoints that crossed p95 threshold
SELECT percentile(duration, 95) AS 'p95 ms'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND percentile(duration, 95) > 800 -- adjust to your staging threshold
FACET name
SINCE 1 hour ago
-- ── SECTION 8: TENANT-AWARE ANALYSIS ──────────────────────────────────────
-- 8.1 Performance by org (if orgId is in custom attributes)
SELECT percentile(duration, 95) AS 'p95 ms', count(*) AS 'requests'
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
FACET custom.orgId
SINCE 1 hour ago
LIMIT 20
-- 8.2 Transactions without orgId (potential TenantMiddleware misses)
SELECT count(*)
FROM Transaction
WHERE appName = 'HOALedgerIQ_App'
AND custom.orgId IS NULL
AND name NOT LIKE '%auth/login%'
AND name NOT LIKE '%auth/register%'
AND name NOT LIKE '%health%'
FACET name
SINCE 1 hour ago

15
load-tests/user-pool.csv Normal file
View File

@@ -0,0 +1,15 @@
email,password,orgId,role
treasurer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,treasurer
treasurer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,treasurer
treasurer03@loadtest.hoaledgeriq.com,LoadTest123!,org-003,treasurer
admin01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,admin
admin02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,admin
president01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,president
president02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,president
manager01@loadtest.hoaledgeriq.com,LoadTest123!,org-003,manager
manager02@loadtest.hoaledgeriq.com,LoadTest123!,org-004,manager
viewer01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,viewer
viewer02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,viewer
homeowner01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,homeowner
homeowner02@loadtest.hoaledgeriq.com,LoadTest123!,org-002,homeowner
member01@loadtest.hoaledgeriq.com,LoadTest123!,org-001,member_at_large
1 email password orgId role
2 treasurer01@loadtest.hoaledgeriq.com LoadTest123! org-001 treasurer
3 treasurer02@loadtest.hoaledgeriq.com LoadTest123! org-002 treasurer
4 treasurer03@loadtest.hoaledgeriq.com LoadTest123! org-003 treasurer
5 admin01@loadtest.hoaledgeriq.com LoadTest123! org-001 admin
6 admin02@loadtest.hoaledgeriq.com LoadTest123! org-002 admin
7 president01@loadtest.hoaledgeriq.com LoadTest123! org-001 president
8 president02@loadtest.hoaledgeriq.com LoadTest123! org-002 president
9 manager01@loadtest.hoaledgeriq.com LoadTest123! org-003 manager
10 manager02@loadtest.hoaledgeriq.com LoadTest123! org-004 manager
11 viewer01@loadtest.hoaledgeriq.com LoadTest123! org-001 viewer
12 viewer02@loadtest.hoaledgeriq.com LoadTest123! org-002 viewer
13 homeowner01@loadtest.hoaledgeriq.com LoadTest123! org-001 homeowner
14 homeowner02@loadtest.hoaledgeriq.com LoadTest123! org-002 homeowner
15 member01@loadtest.hoaledgeriq.com LoadTest123! org-001 member_at_large

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "hoa-ledgeriq-tests",
"version": "1.0.0",
"private": true,
"description": "Root package for Playwright E2E & API tests",
"scripts": {
"test:e2e": "npx playwright test",
"test:e2e:chromium": "npx playwright test --project=chromium",
"test:e2e:firefox": "npx playwright test --project=firefox",
"test:e2e:webkit": "npx playwright test --project=webkit",
"test:e2e:api": "npx playwright test --project=api",
"test:e2e:headed": "npx playwright test --headed",
"test:e2e:debug": "npx playwright test --debug",
"test:e2e:ui": "npx playwright test --ui",
"test:e2e:report": "npx playwright show-report",
"test:e2e:update-snapshots": "npx playwright test --update-snapshots"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"dotenv": "^16.4.0",
"pg": "^8.13.0",
"@types/pg": "^8.11.0"
}
}

127
playwright.config.ts Normal file
View File

@@ -0,0 +1,127 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
/**
* Playwright configuration for HOA LedgerIQ E2E + API regression tests.
*
* Architecture: Docker Compose (nginx :80 -> backend :3000 + frontend :5173)
* - Local dev: `docker-compose up` then `npm run test:e2e`
* - CI: starts Docker services automatically
* - Production: set BASE_URL to skip webServer start
*/
// Load test-specific env from .env.test (falls back to .env)
require('dotenv').config({ path: path.resolve(__dirname, '.env.test') });
const BASE_URL = process.env.BASE_URL || 'http://localhost';
const IS_CI = !!process.env.CI;
// Skip auto-starting services when pointing at an external URL
const isExternalTarget =
BASE_URL !== 'http://localhost' && BASE_URL !== 'http://localhost:80';
export default defineConfig({
testDir: './tests',
testMatch: ['**/*.spec.ts'],
/* Run tests in parallel where safe */
fullyParallel: true,
/* Fail CI builds if test.only was left in */
forbidOnly: IS_CI,
/* Retry on CI to handle transient failures */
retries: IS_CI ? 2 : 0,
/* Limit parallel workers on CI */
workers: IS_CI ? 1 : undefined,
/* Reporter configuration */
reporter: IS_CI
? [['github'], ['html', { open: 'never' }]]
: [['list'], ['html', { open: 'on-failure' }]],
/* Shared settings for all projects */
use: {
baseURL: BASE_URL,
/* Collect trace on first retry for debugging */
trace: 'on-first-retry',
/* Screenshot on failure */
screenshot: 'only-on-failure',
/* Video on failure in CI */
video: IS_CI ? 'on-first-retry' : 'off',
/* Default timeout for actions (click, fill, etc.) */
actionTimeout: 10_000,
/* Navigation timeout */
navigationTimeout: 30_000,
},
/* Global test timeout */
timeout: 60_000,
/* Assertion timeout */
expect: {
timeout: 10_000,
toHaveScreenshot: {
maxDiffPixelRatio: 0.02,
},
},
/* Browser projects */
projects: [
/* Auth setup — runs once, stores auth state for other tests */
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
/* API tests — no browser needed, runs in chromium for request context */
{
name: 'api',
testMatch: ['**/api/**/*.spec.ts'],
use: { ...devices['Desktop Chrome'] },
dependencies: [],
},
],
/* Start Docker services before tests when running locally */
...(!isExternalTarget && {
webServer: {
command: 'docker-compose up -d && sleep 5 && docker-compose exec backend echo "ready"',
url: BASE_URL,
reuseExistingServer: !IS_CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
}),
});

View File

@@ -0,0 +1,140 @@
/**
* API regression tests for the Accounts endpoint.
*
* Tests CRUD operations on /api/accounts.
* Requires authentication — logs in first to get a Bearer token.
*/
import { test, expect } from '@playwright/test';
import { apiLogin, apiSwitchOrg, authHeaders } from '../fixtures/auth.fixture';
import { TEST_USERS, TEST_PREFIX } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
let accessToken: string;
test.beforeAll(async ({ request }) => {
// Login and switch to test org
const tokens = await apiLogin(request, TEST_USERS.treasurer);
if (tokens.organizations?.length > 0) {
const orgId = (tokens.organizations[0] as any).id;
try {
const switched = await apiSwitchOrg(request, tokens.accessToken, orgId);
accessToken = switched.accessToken;
} catch {
accessToken = tokens.accessToken;
}
} else {
accessToken = tokens.accessToken;
}
});
test.describe('GET /api/accounts', () => {
test('should return accounts list', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const accounts = await response.json();
expect(Array.isArray(accounts)).toBe(true);
});
test('should return 401 without auth', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts`);
expect(response.status()).toBe(401);
});
test('should filter by fund type', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts?fundType=operating`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const accounts = await response.json();
expect(Array.isArray(accounts)).toBe(true);
});
});
test.describe('Accounts CRUD', () => {
let createdAccountId: string;
test('should create a new account', async ({ request }) => {
const response = await request.post(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
data: {
name: `${TEST_PREFIX}Operating Checking`,
number: '1099',
accountType: 'asset',
fundType: 'operating',
description: 'E2E test account — safe to delete',
},
});
expect(response.status()).toBe(201);
const account = await response.json();
expect(account).toHaveProperty('id');
expect(account.name).toBe(`${TEST_PREFIX}Operating Checking`);
createdAccountId = account.id;
});
test('should get the created account', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.get(`${API_BASE}/accounts/${createdAccountId}`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const account = await response.json();
expect(account.id).toBe(createdAccountId);
expect(account.name).toBe(`${TEST_PREFIX}Operating Checking`);
});
test('should update the account', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.put(`${API_BASE}/accounts/${createdAccountId}`, {
headers: authHeaders(accessToken),
data: {
name: `${TEST_PREFIX}Updated Checking`,
description: 'Updated by E2E test',
},
});
expect(response.status()).toBe(200);
const account = await response.json();
expect(account.name).toBe(`${TEST_PREFIX}Updated Checking`);
});
test('should appear in accounts list', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.get(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
});
const accounts = await response.json();
const found = accounts.find((a: any) => a.id === createdAccountId);
expect(found).toBeTruthy();
expect(found.name).toBe(`${TEST_PREFIX}Updated Checking`);
});
});
test.describe('GET /api/accounts/trial-balance', () => {
test('should return trial balance data', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts/trial-balance`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const data = await response.json();
// Trial balance returns an object or array with debit/credit totals
expect(data).toBeDefined();
});
});

124
tests/api/auth.api.spec.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* API regression tests for authentication endpoints.
*
* Tests the NestJS auth controller directly via HTTP.
* No browser needed — uses Playwright's request context.
*/
import { test, expect } from '@playwright/test';
import { TEST_USERS } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
test.describe('POST /api/auth/login', () => {
test('should return access token for valid credentials', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body).toHaveProperty('accessToken');
expect(body).toHaveProperty('user');
expect(body.user).toHaveProperty('email', TEST_USERS.treasurer.email);
expect(body).toHaveProperty('organizations');
expect(Array.isArray(body.organizations)).toBe(true);
});
test('should return 401 for invalid password', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: 'WrongPassword123!',
},
});
expect(response.status()).toBe(401);
});
test('should return 401 for non-existent user', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: 'nonexistent@example.com',
password: 'AnyPassword123!',
},
});
expect(response.status()).toBe(401);
});
test('should set httpOnly refresh cookie on success', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
expect(response.status()).toBe(201);
// Check for Set-Cookie header with the refresh token
const setCookie = response.headers()['set-cookie'] || '';
expect(setCookie).toContain('ledgeriq_rt');
expect(setCookie).toContain('HttpOnly');
});
});
test.describe('GET /api/auth/profile', () => {
test('should return 401 without auth header', async ({ request }) => {
const response = await request.get(`${API_BASE}/auth/profile`);
expect(response.status()).toBe(401);
});
test('should return user profile with valid token', async ({ request }) => {
// Login first
const loginResponse = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
const { accessToken } = await loginResponse.json();
// Fetch profile
const response = await request.get(`${API_BASE}/auth/profile`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(response.status()).toBe(200);
const profile = await response.json();
expect(profile).toHaveProperty('email', TEST_USERS.treasurer.email);
});
});
test.describe('POST /api/auth/logout', () => {
test('should return success and clear cookie', async ({ request }) => {
// Login first to get the cookie
await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
// Logout
const response = await request.post(`${API_BASE}/auth/logout`);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toEqual({ success: true });
});
});
test.describe('POST /api/auth/refresh', () => {
test('should return 400 without refresh cookie', async ({ request }) => {
// Create a fresh context without cookies
const response = await request.post(`${API_BASE}/auth/refresh`);
// Should fail — no refresh token cookie
expect([400, 401]).toContain(response.status());
});
});

50
tests/auth.setup.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Auth setup project — runs ONCE before all browser-based tests.
*
* Logs in via the UI, then saves the authenticated browser state
* (cookies + localStorage) to tests/.auth/user.json so that every
* subsequent test starts already logged in.
*
* This matches Playwright's recommended "global setup via project
* dependencies" pattern.
*/
import { test as setup, expect } from '@playwright/test';
import { TEST_USERS } from './fixtures/test-data';
import path from 'path';
const authFile = path.join(__dirname, '.auth', 'user.json');
setup('authenticate as test treasurer', async ({ page }) => {
// Navigate to login page
await page.goto('/login');
// Fill login form — uses Mantine component labels
await page.getByLabel('Email').fill(TEST_USERS.treasurer.email);
await page.getByLabel('Password').fill(TEST_USERS.treasurer.password);
// Submit
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for redirect away from login page.
// After login, the app redirects to /select-org (multi-org) or /dashboard.
await page.waitForURL(/\/(select-org|dashboard|admin|onboarding)/, {
timeout: 15_000,
});
// If we land on org selection, pick the first org
if (page.url().includes('/select-org')) {
// Click the first organization card/button
const orgButton = page.getByRole('button').first();
if (await orgButton.isVisible({ timeout: 3_000 }).catch(() => false)) {
await orgButton.click();
await page.waitForURL(/\/dashboard/, { timeout: 10_000 });
}
}
// Verify we're authenticated — page should not be on /login
expect(page.url()).not.toContain('/login');
// Save authenticated state
await page.context().storageState({ path: authFile });
});

97
tests/e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* E2E tests for the authentication flow.
*
* Tests login via UI (not reusing stored auth state).
* These tests use a fresh browser context per test.
*/
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects';
import { TEST_USERS } from '../fixtures/test-data';
// Don't use stored auth state — we're testing the login flow itself
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Login Page', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('should display login form', async () => {
await expect(loginPage.emailInput).toBeVisible();
await expect(loginPage.passwordInput).toBeVisible();
await expect(loginPage.signInButton).toBeVisible();
});
test('should show error for invalid credentials', async () => {
await loginPage.login('invalid@example.com', 'WrongPassword!');
// Wait for the error alert to appear
await loginPage.assertError(/invalid|unauthorized|failed/i);
await loginPage.assertStillOnLogin();
});
test('should show validation error for empty fields', async ({ page }) => {
// Try to submit with empty password
await loginPage.emailInput.fill('test@example.com');
await loginPage.signInButton.click();
// Should remain on login page (Mantine form validation)
await loginPage.assertStillOnLogin();
});
test('should login successfully with valid credentials', async () => {
await loginPage.loginAndWaitForRedirect(
TEST_USERS.treasurer.email,
TEST_USERS.treasurer.password,
);
// Should be redirected away from login
await expect(loginPage.page).not.toHaveURL(/\/login/);
});
test('should show register link', async () => {
await expect(loginPage.registerLink).toBeVisible();
});
test('should redirect unauthenticated users to login', async ({ page }) => {
// Try accessing a protected route directly
await page.goto('/dashboard');
// Should be redirected to login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('Logout', () => {
test('should clear auth state on logout', async ({ page }) => {
// First login
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginAndWaitForRedirect(
TEST_USERS.treasurer.email,
TEST_USERS.treasurer.password,
);
// Handle org selection if needed
if (page.url().includes('/select-org')) {
await page.getByRole('button').first().click();
await page.waitForURL(/\/dashboard/, { timeout: 10_000 });
}
// Look for logout in user menu/settings
const userMenu = page.locator('[class*="avatar"], [class*="Avatar"]').first();
if (await userMenu.isVisible({ timeout: 3_000 }).catch(() => false)) {
await userMenu.click();
}
const logoutButton = page.getByRole('button', { name: /logout|sign out/i }).first();
if (await logoutButton.isVisible({ timeout: 3_000 }).catch(() => false)) {
await logoutButton.click();
await expect(page).toHaveURL(/\/login/);
}
});
});

View File

@@ -0,0 +1,76 @@
/**
* E2E tests for the Dashboard page.
*
* Uses pre-authenticated state from auth.setup.ts.
* Verifies dashboard loads, displays data, and navigation works.
*/
import { test, expect } from '../fixtures/base.fixture';
import { DashboardPage } from '../page-objects';
test.describe('Dashboard', () => {
let dashboard: DashboardPage;
test.beforeEach(async ({ page }) => {
dashboard = new DashboardPage(page);
await dashboard.goto();
});
test('should load the dashboard page', async ({ page }) => {
await dashboard.assertLoaded();
await expect(page).toHaveURL(/\/dashboard/);
});
test('should display main content area', async ({ page }) => {
// Dashboard should have a visible main content area
await expect(page.locator('main')).toBeVisible();
});
test('should have sidebar navigation', async ({ page }) => {
// Verify key navigation links are present
const nav = page.locator('nav').first();
if (await nav.isVisible({ timeout: 5_000 }).catch(() => false)) {
await expect(nav).toBeVisible();
// Check for common navigation items
const links = ['Accounts', 'Transactions', 'Budgets'];
for (const linkName of links) {
const link = nav.getByRole('link', { name: new RegExp(linkName, 'i') });
if (await link.isVisible({ timeout: 2_000 }).catch(() => false)) {
await expect(link).toBeVisible();
}
}
}
});
test('should navigate to accounts page', async ({ page }) => {
const accountsLink = page.getByRole('link', { name: /accounts/i }).first();
if (await accountsLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
await accountsLink.click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/accounts/);
}
});
});
test.describe('Dashboard with DB verification', () => {
test('should reflect database state', async ({ page, db }) => {
// Query the database to get expected account count
const result = await db.query(
`SELECT COUNT(*) as count FROM e2e_test_hoa.accounts`,
).catch(() => ({ rows: [{ count: '0' }] }));
const expectedCount = parseInt(result.rows[0].count, 10);
// Navigate to dashboard
const dashboard = new DashboardPage(page);
await dashboard.goto();
await dashboard.assertLoaded();
// If accounts exist, the dashboard should show some financial data
if (expectedCount > 0) {
// Dashboard should contain some numeric content indicating balances
await expect(page.locator('main')).not.toBeEmpty();
}
});
});

53
tests/e2e/visual.spec.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Visual regression tests using Playwright's toHaveScreenshot().
*
* These capture pixel-level snapshots and compare against baselines.
* First run creates the baseline images in tests/e2e/visual.spec.ts-snapshots/.
*
* Update baselines: npx playwright test tests/e2e/visual.spec.ts --update-snapshots
*/
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects';
// Use a clean context for login page screenshots
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Visual Regression', () => {
test('login page should match baseline', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.waitForReady();
// Wait for any animations to settle
await page.waitForTimeout(500);
await expect(page).toHaveScreenshot('login-page.png', {
fullPage: true,
// Mask dynamic content that changes between runs
mask: [
// Mask any CSRF tokens or dynamic ids in the DOM
page.locator('input[type="hidden"]'),
],
});
});
});
test.describe('Authenticated Visual Regression', () => {
test('dashboard should match baseline', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// Wait for charts/data to render
await page.waitForTimeout(1_000);
await expect(page).toHaveScreenshot('dashboard.png', {
fullPage: true,
// Mask dates and dynamic numbers that change between runs
mask: [
page.locator('time'),
page.locator('[data-testid="current-date"]'),
],
});
});
});

108
tests/fixtures/auth.fixture.ts vendored Normal file
View File

@@ -0,0 +1,108 @@
/**
* Authentication fixture for E2E tests.
*
* Provides helpers to authenticate via the API and manage JWT tokens.
* The auth.setup.ts project uses this to create persistent auth state
* that other tests reuse (avoiding login in every test).
*/
import { type APIRequestContext, type BrowserContext } from '@playwright/test';
import { TEST_USERS } from './test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
export interface AuthTokens {
accessToken: string;
user: Record<string, unknown>;
organizations: Array<Record<string, unknown>>;
}
/**
* Login via the API and return tokens.
* Uses Playwright's request context (no browser needed).
*/
export async function apiLogin(
request: APIRequestContext,
credentials: { email: string; password: string } = TEST_USERS.treasurer,
): Promise<AuthTokens> {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: credentials.email,
password: credentials.password,
},
});
if (!response.ok()) {
const body = await response.text();
throw new Error(`Login failed (${response.status()}): ${body}`);
}
return response.json();
}
/**
* Switch to a specific organization after login.
*/
export async function apiSwitchOrg(
request: APIRequestContext,
accessToken: string,
organizationId: string,
): Promise<AuthTokens> {
const response = await request.post(`${API_BASE}/auth/switch-org`, {
data: { organizationId },
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok()) {
const body = await response.text();
throw new Error(`Switch org failed (${response.status()}): ${body}`);
}
return response.json();
}
/**
* Create an authenticated API request context with a Bearer token.
* Useful for API-level tests that don't need a browser.
*/
export function authHeaders(accessToken: string) {
return {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
}
/**
* Inject auth state into a browser context's localStorage.
* Matches the frontend's Zustand authStore shape.
*/
export async function injectAuthState(
context: BrowserContext,
tokens: AuthTokens,
orgId?: string,
): Promise<void> {
const baseURL = process.env.BASE_URL || 'http://localhost';
// The frontend uses Zustand with persist middleware in localStorage
// under key 'auth-storage'. We inject it so the frontend thinks
// the user is already logged in.
const selectedOrg = orgId
? tokens.organizations.find((o: any) => o.id === orgId)
: tokens.organizations[0];
const authState = {
state: {
token: tokens.accessToken,
user: tokens.user,
organizations: tokens.organizations,
currentOrg: selectedOrg
? { id: (selectedOrg as any).id, name: (selectedOrg as any).name, role: (selectedOrg as any).role }
: null,
},
version: 0,
};
await context.addInitScript((authData) => {
window.localStorage.setItem('auth-storage', JSON.stringify(authData));
}, authState);
}

87
tests/fixtures/base.fixture.ts vendored Normal file
View File

@@ -0,0 +1,87 @@
/**
* Base Playwright fixture that combines auth + DB helpers.
*
* Extends the default `test` object so every test file can
* import from here and get typed access to fixtures.
*
* Usage:
* import { test, expect } from '../fixtures/base.fixture';
* test('my test', async ({ authedPage, apiContext }) => { ... });
*/
import { test as base, type Page, type APIRequestContext } from '@playwright/test';
import { apiLogin, apiSwitchOrg, authHeaders, type AuthTokens } from './auth.fixture';
import { testDb } from './db.fixture';
import { TEST_USERS } from './test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
type TestFixtures = {
/** Pre-authenticated API request context with Bearer token */
authedRequest: APIRequestContext & { _tokens: AuthTokens };
/** Access token string for manual header construction */
accessToken: string;
};
type WorkerFixtures = {
/** Shared database connection (one per worker) */
db: typeof testDb;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
/**
* Worker-scoped database connection.
* Connects once per worker, disconnects when the worker exits.
*/
db: [
async ({}, use) => {
await testDb.connect();
await use(testDb);
await testDb.disconnect();
},
{ scope: 'worker' },
],
/**
* Per-test authenticated API request context.
* Logs in as the default test user and attaches the Bearer token.
*/
authedRequest: async ({ playwright }, use) => {
const requestContext = await playwright.request.newContext({
baseURL: API_BASE,
});
const tokens = await apiLogin(requestContext, TEST_USERS.treasurer);
// If user belongs to orgs, switch to the first one
let finalTokens = tokens;
if (tokens.organizations?.length > 0) {
const orgId = (tokens.organizations[0] as any).id;
try {
finalTokens = await apiSwitchOrg(requestContext, tokens.accessToken, orgId);
} catch {
// switch-org may not be needed if token already scoped
finalTokens = tokens;
}
}
// Create a new context with the auth header baked in
const authedContext = await playwright.request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: authHeaders(finalTokens.accessToken),
});
// Attach tokens for tests that need them
(authedContext as any)._tokens = finalTokens;
await use(authedContext as any);
await authedContext.dispose();
await requestContext.dispose();
},
accessToken: async ({ authedRequest }, use) => {
await use((authedRequest as any)._tokens.accessToken);
},
});
export { expect } from '@playwright/test';

124
tests/fixtures/db.fixture.ts vendored Normal file
View File

@@ -0,0 +1,124 @@
/**
* Database fixture for E2E tests.
*
* Provides helpers to seed and clean up test data in Postgres.
* Uses the `pg` driver directly (same driver the backend uses)
* to avoid coupling tests to the backend's TypeORM setup.
*
* Usage in tests:
* import { testDb } from '../fixtures/db.fixture';
* test.beforeAll(async () => { await testDb.connect(); });
* test.afterAll(async () => { await testDb.cleanup(); await testDb.disconnect(); });
*/
import { Pool, type PoolClient } from 'pg';
import { TEST_PREFIX } from './test-data';
const TEST_DB_URL =
process.env.TEST_DB_URL ||
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
class TestDatabase {
private pool: Pool | null = null;
/** Connect to the test database */
async connect(): Promise<void> {
this.pool = new Pool({ connectionString: TEST_DB_URL, max: 5 });
// Verify connectivity
const client = await this.pool.connect();
try {
await client.query('SELECT 1');
} finally {
client.release();
}
}
/** Get a client from the pool */
async getClient(): Promise<PoolClient> {
if (!this.pool) throw new Error('TestDatabase not connected');
return this.pool.connect();
}
/** Execute a query */
async query(sql: string, params?: unknown[]) {
if (!this.pool) throw new Error('TestDatabase not connected');
return this.pool.query(sql, params);
}
/**
* Clean up all test-created data.
* Removes rows that match the TEST_PREFIX in name/description/memo fields.
* This runs within a transaction so it's atomic.
*/
async cleanup(): Promise<void> {
if (!this.pool) return;
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Clean up test data from tenant schemas.
// We look for the e2e test org schema if it exists.
const schemas = await client.query(
`SELECT schema_name FROM shared.organizations
WHERE name LIKE '${TEST_PREFIX}%' OR schema_name = 'e2e_test_hoa'`,
);
for (const row of schemas.rows) {
const schema = row.schema_name;
// Delete test journal entries, accounts, etc. in tenant schema
await client.query(
`DELETE FROM "${schema}".journal_entries WHERE memo LIKE $1`,
[`${TEST_PREFIX}%`],
).catch(() => {}); // Table may not exist
await client.query(
`DELETE FROM "${schema}".accounts WHERE name LIKE $1`,
[`${TEST_PREFIX}%`],
).catch(() => {});
}
// Clean up test users from shared schema
await client.query(
`DELETE FROM shared.users WHERE email LIKE 'e2e-%@test.hoaledgeriq.com'`,
).catch(() => {});
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
console.error('Test cleanup failed:', err);
} finally {
client.release();
}
}
/**
* Seed the minimum data needed for authenticated test flows:
* - A test organization (if not exists)
* - A test user with known credentials (if not exists)
* - Associates user to org with the given role
*
* This is idempotent — safe to call multiple times.
*/
async seedTestUser(user: {
email: string;
password: string;
fullName: string;
role: string;
}): Promise<void> {
if (!this.pool) throw new Error('TestDatabase not connected');
// This is done via API calls in auth.setup.ts instead of direct SQL,
// because the backend handles password hashing, schema creation, etc.
// This method is available for advanced scenarios that need direct DB access.
console.log(`[TestDB] Seed user ${user.email} — use API-based seeding in auth.setup.ts`);
}
/** Disconnect from the database */
async disconnect(): Promise<void> {
if (this.pool) {
await this.pool.end();
this.pool = null;
}
}
}
export const testDb = new TestDatabase();

48
tests/fixtures/test-data.ts vendored Normal file
View File

@@ -0,0 +1,48 @@
/**
* Shared test data constants for E2E and API tests.
*
* Credentials match what the DB seed fixture creates.
* Money values are in cents (matching the backend convention).
*/
export const TEST_USERS = {
treasurer: {
email: process.env.TEST_USER_EMAIL || 'e2e-treasurer@test.hoaledgeriq.com',
password: process.env.TEST_USER_PASSWORD || 'TestPass123!',
role: 'treasurer',
fullName: 'E2E Test Treasurer',
},
viewer: {
email: 'e2e-viewer@test.hoaledgeriq.com',
password: 'TestPass123!',
role: 'viewer',
fullName: 'E2E Test Viewer',
},
} as const;
export const TEST_ORG = {
name: 'E2E Test HOA',
schemaName: 'e2e_test_hoa',
} as const;
/** Sample account used for CRUD tests */
export const SAMPLE_ACCOUNT = {
name: 'E2E Operating Checking',
accountType: 'asset',
fundType: 'operating',
number: '1010',
description: 'E2E test operating account',
} as const;
/** Sample journal entry for write-path tests */
export const SAMPLE_JOURNAL_ENTRY = {
date: new Date().toISOString().split('T')[0],
memo: 'E2E test journal entry',
lines: [
{ accountId: '', debit: 100_00, credit: 0 },
{ accountId: '', credit: 100_00, debit: 0 },
],
} as const;
/** Unique prefix for test-created data — makes cleanup queries simple */
export const TEST_PREFIX = 'E2E_' as const;

View File

@@ -0,0 +1,82 @@
/**
* Page object for the Accounts page (/accounts).
*
* Maps to: frontend/src/pages/accounts/AccountsPage.tsx
* Data: GET /api/accounts, POST /api/accounts, PUT /api/accounts/:id
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class AccountsPage extends BasePage {
readonly path = '/accounts';
// ─── Locators ────────────────────────────────────────────────
get heading() {
return this.page.getByRole('heading', { name: /accounts|chart of accounts/i }).first();
}
get addAccountButton() {
return this.page.getByRole('button', { name: /add|create|new/i }).first();
}
/** Account name input in the create/edit modal */
get nameInput() {
return this.page.getByLabel(/name/i).first();
}
/** Account number input */
get numberInput() {
return this.page.getByLabel(/number/i).first();
}
/** Account type select */
get typeSelect() {
return this.page.getByLabel(/type/i).first();
}
/** Save/submit button in modal */
get saveButton() {
return this.page.getByRole('button', { name: /save|create|submit/i }).first();
}
/** Cancel button in modal */
get cancelButton() {
return this.page.getByRole('button', { name: /cancel/i }).first();
}
// ─── Actions ─────────────────────────────────────────────────
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
// Wait for the accounts list or heading to be visible
await expect(this.page.locator('main')).toBeVisible();
}
/** Get the count of visible account rows */
async getAccountCount(): Promise<number> {
return this.tableRows.count();
}
/** Find an account row by name */
accountRow(name: string) {
return this.tableBody.locator('tr').filter({ hasText: name });
}
/** Assert an account exists in the table */
async assertAccountExists(name: string): Promise<void> {
await expect(this.accountRow(name)).toBeVisible();
}
/** Assert an account does NOT exist in the table */
async assertAccountNotExists(name: string): Promise<void> {
await expect(this.accountRow(name)).not.toBeVisible();
}
/** Open the create account modal */
async openCreateModal(): Promise<void> {
await this.addAccountButton.click();
await expect(this.nameInput).toBeVisible();
}
}

View File

@@ -0,0 +1,73 @@
/**
* Base page object with shared helpers.
*
* All page objects extend this class to inherit common navigation,
* waiting, and assertion patterns.
*/
import { type Page, type Locator, expect } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
/** The path segment for this page (e.g. '/dashboard', '/accounts') */
abstract readonly path: string;
/** Navigate to this page */
async goto(): Promise<void> {
await this.page.goto(this.path);
await this.waitForReady();
}
/**
* Override in subclasses to wait for page-specific readiness signals.
* Default: waits for network idle.
*/
async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
/** Assert the page URL contains the expected path */
async assertOnPage(): Promise<void> {
await expect(this.page).toHaveURL(new RegExp(this.path));
}
/** Get the page title text (Mantine AppShell header or h1) */
async getPageHeading(): Promise<string> {
const heading = this.page.getByRole('heading', { level: 1 }).first();
return heading.innerText();
}
/** Wait for a Mantine notification to appear with the given text */
async waitForNotification(text: string | RegExp): Promise<void> {
await expect(
this.page.locator('.mantine-Notification-root').filter({ hasText: text }),
).toBeVisible({ timeout: 10_000 });
}
/** Click a navigation link in the sidebar */
async navigateTo(linkText: string): Promise<void> {
await this.page.getByRole('link', { name: linkText }).click();
await this.page.waitForLoadState('networkidle');
}
/** Get a Mantine table body locator */
get tableBody(): Locator {
return this.page.locator('tbody');
}
/** Get all table rows */
get tableRows(): Locator {
return this.tableBody.locator('tr');
}
/** Wait for API response on a specific endpoint pattern */
async waitForApi(urlPattern: string | RegExp): Promise<void> {
await this.page.waitForResponse(
(response) =>
(typeof urlPattern === 'string'
? response.url().includes(urlPattern)
: urlPattern.test(response.url())) && response.status() < 400,
);
}
}

View File

@@ -0,0 +1,56 @@
/**
* Page object for the Dashboard (/dashboard).
*
* Maps to: frontend/src/pages/dashboard/DashboardPage.tsx
* Data: GET /api/reports/dashboard
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
readonly path = '/dashboard';
// ─── Locators ────────────────────────────────────────────────
/** Main dashboard heading */
get heading() {
return this.page.getByRole('heading', { name: /dashboard/i }).first();
}
/** Account balance summary cards */
get balanceCards() {
return this.page.locator('[class*="Card"]').filter({ hasText: /balance|total/i });
}
/** Sidebar navigation */
get sidebar() {
return this.page.locator('nav').first();
}
/** Sidebar links */
sidebarLink(name: string) {
return this.sidebar.getByRole('link', { name });
}
// ─── Actions ─────────────────────────────────────────────────
/** Wait for dashboard data to load */
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
// Dashboard typically loads report data
await expect(this.page.locator('main')).toBeVisible();
}
/** Assert the dashboard has loaded with content */
async assertLoaded(): Promise<void> {
await this.assertOnPage();
await expect(this.page.locator('main')).not.toBeEmpty();
}
/** Navigate to a section via sidebar */
async navigateToSection(section: string): Promise<void> {
await this.sidebarLink(section).click();
await this.page.waitForLoadState('networkidle');
}
}

View File

@@ -0,0 +1,85 @@
/**
* Page object for the Login page (/login).
*
* Maps to: frontend/src/pages/auth/LoginPage.tsx
* Auth: POST /api/auth/login (Passport local strategy)
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
readonly path = '/login';
// ─── Locators (resilient, role/label based) ──────────────────
get emailInput() {
return this.page.getByLabel('Email');
}
get passwordInput() {
return this.page.getByLabel('Password');
}
get signInButton() {
return this.page.getByRole('button', { name: 'Sign in' });
}
get passkeyButton() {
return this.page.getByRole('button', { name: /passkey/i });
}
get errorAlert() {
return this.page.getByRole('alert');
}
get registerLink() {
return this.page.getByRole('link', { name: /register/i });
}
// ─── MFA locators ────────────────────────────────────────────
get mfaHeading() {
return this.page.getByText('Two-Factor Authentication');
}
get mfaPinInput() {
return this.page.locator('.mantine-PinInput-input').first();
}
get mfaVerifyButton() {
return this.page.getByRole('button', { name: 'Verify' });
}
// ─── Actions ─────────────────────────────────────────────────
/** Fill and submit login credentials */
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
/** Login and wait for successful redirect */
async loginAndWaitForRedirect(
email: string,
password: string,
): Promise<void> {
await this.login(email, password);
await this.page.waitForURL(/\/(select-org|dashboard|admin|onboarding)/, {
timeout: 15_000,
});
}
/** Assert an error message is displayed */
async assertError(message: string | RegExp): Promise<void> {
await expect(this.errorAlert).toContainText(message);
}
/** Assert we're still on the login page */
async assertStillOnLogin(): Promise<void> {
await expect(this.page).toHaveURL(/\/login/);
}
override async waitForReady(): Promise<void> {
await expect(this.signInButton).toBeVisible();
}
}

View File

@@ -0,0 +1,10 @@
/**
* Re-export all page objects for convenient imports.
*
* Usage: import { LoginPage, DashboardPage } from '../page-objects';
*/
export { BasePage } from './BasePage';
export { LoginPage } from './LoginPage';
export { DashboardPage } from './DashboardPage';
export { AccountsPage } from './AccountsPage';