Compare commits

..

No commits in common. "b08c054f6a8f280d3d7eba3777e0dce4d7b29096" and "d9545d6fd5909169924c7ef55de466913e24676d" have entirely different histories.

62 changed files with 6007 additions and 4401 deletions

View File

@ -61,14 +61,6 @@ NODE_ENV=production
# #
# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars # TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars
# WebAuthn / passkeys Relying Party ID. REQUIRED for passkeys to work behind a
# real domain: credentials are cryptographically bound to this hostname, so the
# "localhost" default only works in local dev. Use the bare hostname users sign
# in on (no scheme, no port) — e.g. bills.example.com. Changing it later
# invalidates already-registered keys.
#
# WEBAUTHN_RP_ID=bills.example.com
# ── Bank Sync (SimpleFIN) ───────────────────────────────────────────────────── # ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
# Enable/disable bank sync from the Admin panel. Users connect their own # Enable/disable bank sync from the Admin panel. Users connect their own
# SimpleFIN Bridge from the Data page. No environment config required. # SimpleFIN Bridge from the Data page. No environment config required.

View File

@ -30,10 +30,8 @@ jobs:
| tar -xz -C /usr/local/bin gitleaks | tar -xz -C /usr/local/bin gitleaks
gitleaks detect --source=. --redact --no-banner gitleaks detect --source=. --redact --no-banner
- name: Dependency audit (fail on high or critical) - name: Dependency audit (fail on critical)
# Raised from critical after clearing the nodemailer/xlsx highs run: npm audit --omit=dev --audit-level=critical
# (QA-B0-02) — high vulns must fail CI, not pass silently.
run: npm audit --omit=dev --audit-level=high
- name: Format check (Prettier) - name: Format check (Prettier)
run: npm run format:check run: npm run format:check

View File

@ -1,23 +1,5 @@
# Bill Tracker — Changelog # Bill Tracker — Changelog
## v0.41.1
### 🔐 Passkeys / security keys — WebAuthn finally reachable
- **[Auth] Passkey & hardware-security-key sign-in shipped end to end.** The WebAuthn backend existed since v0.36 but no UI ever called it — a ghost feature (was QA-B1-01). Now: the login page runs a security-key second step that mirrors TOTP (prompt fires right after the password; cancel/retry handled), and Profile gains a **Passkeys & Security Keys** card (enroll with a name, list keys, password-gated remove / remove-all). Deploy note: set **`WEBAUTHN_RP_ID`** (see `.env.example`) — keys bind to the hostname, and a boot warning now fires if it's unset in production. Origin verification also accepts the browser's real origin when its hostname matches the RP ID, fixing enrollment behind the Vite dev/e2e proxy. Covered end to end by `e2e/webauthn.probe.spec.js` using Playwright's virtual authenticator — no human with a YubiKey required.
### 🐛 QA Fixes (2026-07-10 blind-spot recon)
- **[Auth] Login self-lockout for WebAuthn-enabled users.** `authService.login` returned a `requires_webauthn` payload, but `POST /login` only handled `requires_totp` and crashed into a 500 on every attempt — anyone who enabled WebAuthn via the API was permanently locked out. The route now returns the two-step payload, and a stale `webauthn_enabled` flag with no usable credentials falls back to password login instead of hard-failing. Test: `tests/authLoginWebauthnRoute.test.js`. (was QA-B1-01, lockout part)
- **[Client] Session expiry mid-use now redirects to login.** `useAuth` only checked `/auth/me` on mount, so an expired session left the app half-alive with raw 401 toasts. `api.ts` dispatches `auth:expired` on a 401 outside `/auth/*` (under `/auth/*` a 401 means wrong credential input and must not log you out); `AuthProvider` clears the user and `RequireAuth` redirects preserving the return path. Test: `client/api.sessionExpiry.test.ts`. (error-handling audit)
- **[API] Raw `err.message` no longer leaks on 5xx.** Ten call sites returned internal error text to clients on server failures (admin bank-sync/rollback, notification test-sends, spending, bulk unmatch, import); the conversion sweep found ten more of the same class (dataSources, subscriptions, bills). All masked now — intentional user-facing messages survive only via explicit `ApiError` wraps or 4xx service statuses. (was QA-B13-02)
- **[Auth] Dead duplicate admin routes removed from `routes/auth.cts`.** The "MOUNTED AT /api/admin" section was false — the canonical copies live in `admin.cts`; the duplicates were unreachable and additionally exposed `/api/auth/has-users` without authentication. (was QA-B17-01)
- **[Version] `package.json` synced with the release (0.40.0 → 0.41.x drift).** `/api/version`, the release-notes badge, and the update checker all read package.json and were under-reporting. `tests/versionSync.test.js` now fails the suite if package.json and the top `HISTORY.md` heading ever drift again. (was QA-B0-03)
### 🧹 QA — standards unification (throw-pattern complete + enforced)
- **[Server] All 29 route files now emit errors through the canonical throw-pattern** (`utils/apiError.cts` factories → terminal handler), finishing the program CODE_STANDARDS tracked at 6/29. Wire shapes preserved (probe suite proves it over real HTTP); client-visible codes kept (`ALREADY_MATCHED`, `DUPLICATE_SUSPECTED`, `TRANSACTION_PAYMENT_LOCKED`, `NO_DEBTS`, `INVALID_PLAN_STATE`, …); deliberate exceptions documented inline and in CODE_STANDARDS.md. Enforced from here on by an eslint `no-restricted-imports` ratchet on `routes/**`, plus `no-console` on the server (logger-only, two sanctioned bootstrap exceptions). `client/api.ts` builds every thrown error through a single `toApiError()`.
## v0.41.0 ## v0.41.0
### 🚀 Express 4 → 5 (Phase 2) — modern framework + native async error handling ### 🚀 Express 4 → 5 (Phase 2) — modern framework + native async error handling

View File

@ -1,51 +0,0 @@
// @vitest-environment jsdom
// 1e (QA follow-up) — a 401 outside /auth/* means the session died mid-use:
// api.ts must dispatch 'auth:expired' (AuthProvider clears the user and
// RequireAuth redirects). A 401 under /auth/* is "wrong credential input"
// (e.g. change-password with a bad current password) and must NOT dispatch.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { api } from '@/api';
function mock401(): typeof fetch {
return vi.fn(
async () =>
new Response(
JSON.stringify({ error: 'AUTH_ERROR', message: 'Unauthorized', code: 'AUTH_ERROR' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
},
),
) as unknown as typeof fetch;
}
describe('session-expiry dispatch on 401', () => {
let expiredEvents: number;
const onExpired = () => {
expiredEvents += 1;
};
beforeEach(() => {
expiredEvents = 0;
window.addEventListener('auth:expired', onExpired);
vi.stubGlobal('fetch', mock401());
});
afterEach(() => {
window.removeEventListener('auth:expired', onExpired);
vi.unstubAllGlobals();
});
it('dispatches auth:expired for a 401 on a data endpoint', async () => {
await expect(api.bills()).rejects.toMatchObject({ status: 401 });
expect(expiredEvents).toBe(1);
});
it('does NOT dispatch for a 401 under /auth/* (wrong credential input)', async () => {
await expect(
api.changePassword({ current_password: 'wrong', new_password: 'longenough1' }),
).rejects.toMatchObject({ status: 401 });
expect(expiredEvents).toBe(0);
});
});

View File

@ -51,19 +51,6 @@ export interface ApiError extends Error {
code?: string; code?: string;
} }
/** Build the ApiError every non-ok response throws — one shape, one place. */
function toApiError(
res: Response,
data: { message?: string; error?: string; code?: string; details?: unknown[] } | null,
): ApiError {
const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data || {};
err.details = data?.details || [];
err.code = data?.code;
return err;
}
/** Result of toggling a tracker row paid/unpaid. */ /** Result of toggling a tracker row paid/unpaid. */
export interface TogglePaidResult { export interface TogglePaidResult {
paymentId?: number; paymentId?: number;
@ -147,14 +134,12 @@ async function _fetch<T = unknown>(
_csrfFetch = null; _csrfFetch = null;
return _fetch<T>(method, path, body, true); return _fetch<T>(method, path, body, true);
} }
// Session expired mid-use: a 401 outside /auth/* means the session cookie const err = new Error(data?.message || data?.error || `HTTP ${res.status}`) as ApiError;
// died (under /auth/* a 401 is "wrong credential input" — e.g. bad current err.status = res.status;
// password — and must NOT log the user out). AuthProvider listens and err.data = data || {};
// clears the user, so RequireAuth redirects to /login preserving state.from. err.details = data?.details || [];
if (res.status === 401 && !path.startsWith('/auth/')) { err.code = data?.code;
window.dispatchEvent(new Event('auth:expired')); throw err;
}
throw toApiError(res, data);
} }
return (data ?? {}) as T; return (data ?? {}) as T;
} }
@ -189,13 +174,7 @@ export const api = {
get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'), get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
authMode: () => get('/auth/mode'), authMode: () => get('/auth/mode'),
login: (data: Body) => login: (data: Body) =>
post<{ post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
requires_totp?: boolean;
requires_webauthn?: boolean;
challenge_token?: string;
webauthn_options?: unknown;
user?: User;
}>('/auth/login', data),
logout: () => post('/auth/logout'), logout: () => post('/auth/logout'),
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'), logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'), restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
@ -283,7 +262,12 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw toApiError(res, data); const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },
@ -520,7 +504,12 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw toApiError(res, data); const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },
@ -539,7 +528,12 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw toApiError(res, data); const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },
@ -558,7 +552,12 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw toApiError(res, data); const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },
@ -622,7 +621,12 @@ export const api = {
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
throw toApiError(res, data); const err = new Error(data.message || data.error || `HTTP ${res.status}`) as ApiError;
err.status = res.status;
err.data = data;
err.details = data.details || [];
err.code = data.code;
throw err;
} }
return data; return data;
}, },

View File

@ -56,14 +56,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.catch(() => setUser(null)); .catch(() => setUser(null));
}, []); }, []);
// Session expired mid-use (api.ts dispatches on a 401 outside /auth/*):
// clearing the user makes RequireAuth redirect to /login with state.from.
useEffect(() => {
const onExpired = () => setUser(null);
window.addEventListener('auth:expired', onExpired);
return () => window.removeEventListener('auth:expired', onExpired);
}, []);
const logout = async () => { const logout = async () => {
await api.logout(); await api.logout();
setUser(null); setUser(null);

View File

@ -2,7 +2,6 @@ import { useState, useEffect, type ComponentType, type ReactNode } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react'; import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { startAuthentication } from '@simplewebauthn/browser';
import { api } from '@/api'; import { api } from '@/api';
import { errMessage } from '@/lib/utils'; import { errMessage } from '@/lib/utils';
import { useAuth, type User } from '@/hooks/useAuth'; import { useAuth, type User } from '@/hooks/useAuth';
@ -82,12 +81,6 @@ export default function LoginPage() {
const [totpLoading, setTotpLoading] = useState(false); const [totpLoading, setTotpLoading] = useState(false);
const [useRecovery, setUseRecovery] = useState(false); const [useRecovery, setUseRecovery] = useState(false);
// WebAuthn second step — mirrors the TOTP flow: password first, then the key.
const [webauthnChallenge, setWebauthnChallenge] = useState<string | null>(null);
const [webauthnOptions, setWebauthnOptions] = useState<unknown>(null);
const [webauthnError, setWebauthnError] = useState('');
const [webauthnLoading, setWebauthnLoading] = useState(false);
const [newPw, setNewPw] = useState(''); const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false); const [pwLoading, setPwLoading] = useState(false);
@ -162,14 +155,6 @@ export default function LoginPage() {
setUseRecovery(false); setUseRecovery(false);
return; return;
} }
if (data.requires_webauthn) {
setWebauthnChallenge(data.challenge_token ?? null);
setWebauthnOptions(data.webauthn_options ?? null);
setWebauthnError('');
// Prompt for the key immediately — the browser dialog is the whole step.
void verifyWebauthn(data.challenge_token ?? null, data.webauthn_options ?? null);
return;
}
handlePostLogin(data.user!); handlePostLogin(data.user!);
} catch (err) { } catch (err) {
setError(errMessage(err, 'Login failed.')); setError(errMessage(err, 'Login failed.'));
@ -178,32 +163,6 @@ export default function LoginPage() {
} }
}; };
const verifyWebauthn = async (challengeToken: string | null, options: unknown) => {
if (!challengeToken || !options) {
setWebauthnError('Security-key sign-in is unavailable. Please sign in again.');
return;
}
setWebauthnError('');
setWebauthnLoading(true);
try {
const response = await startAuthentication({
optionsJSON: options as Parameters<typeof startAuthentication>[0]['optionsJSON'],
});
const data = await api.webauthnChallenge({ challenge_token: challengeToken, response });
handlePostLogin((data as { user?: User }).user!);
} catch (err) {
// NotAllowedError = user cancelled / timed out at the browser prompt.
const cancelled = (err as Error)?.name === 'NotAllowedError';
setWebauthnError(
cancelled
? 'Security key prompt was cancelled. Try again, or go back and sign in again.'
: errMessage(err, 'Security key verification failed.'),
);
} finally {
setWebauthnLoading(false);
}
};
const handleTotpSubmit = async (e: React.FormEvent) => { const handleTotpSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setTotpError(''); setTotpError('');
@ -381,52 +340,8 @@ export default function LoginPage() {
</div> </div>
</div> </div>
)} )}
{/* WebAuthn step — shown after password is accepted for key-protected accounts */} {/* Sign-in card — hidden while auth mode resolves or during TOTP step */}
{webauthnChallenge && (
<div className="surface-elevated p-8 space-y-5">
<div className="flex gap-3">
<BrandGlyph name="security" className="h-11 w-11 rounded-2xl" />
<div>
<h1 className="text-lg font-semibold">Security key</h1>
<p className="text-sm text-muted-foreground mt-1">
Confirm sign-in with your passkey or hardware security key.
</p>
</div>
</div>
{webauthnError && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{webauthnError}
</div>
)}
<Button
type="button"
className="w-full"
disabled={webauthnLoading}
onClick={() => void verifyWebauthn(webauthnChallenge, webauthnOptions)}
>
{webauthnLoading ? 'Waiting for your key…' : 'Use security key'}
</Button>
<div className="text-center">
<button
type="button"
className="text-xs text-muted-foreground underline-offset-4 hover:underline hover:text-foreground transition-colors"
onClick={() => {
setWebauthnChallenge(null);
setWebauthnOptions(null);
setWebauthnError('');
}}
>
Back to sign in
</button>
</div>
</div>
)}
{/* Sign-in card — hidden while auth mode resolves or during a 2FA step */}
{!totpChallenge && {!totpChallenge &&
!webauthnChallenge &&
(authMode === null ? ( (authMode === null ? (
<div className="surface-elevated flex min-h-[120px] items-center justify-center p-8"> <div className="surface-elevated flex min-h-[120px] items-center justify-center p-8">
<span className="text-sm text-muted-foreground">Loading</span> <span className="text-sm text-muted-foreground">Loading</span>

View File

@ -1040,7 +1040,6 @@ function ChangePassword() {
return ( return (
<> <>
<TotpSection /> <TotpSection />
<PasskeySection />
<SectionCard <SectionCard
title="Change Password" title="Change Password"
icon={KeyRound} icon={KeyRound}
@ -1368,235 +1367,6 @@ function TotpSection() {
); );
} }
interface PasskeyCredential {
id: number;
credential_id: string;
credential_name: string;
created_at?: string;
}
function PasskeySection() {
const { singleUserMode } = useAuth();
const [enabled, setEnabled] = useState<boolean | null>(null); // null = loading
const [credentials, setCredentials] = useState<PasskeyCredential[]>([]);
const [keyName, setKeyName] = useState('');
const [naming, setNaming] = useState(false); // show the name-your-key form
const [password, setPassword] = useState('');
const [removeTarget, setRemoveTarget] = useState<'all' | string | null>(null); // credential_id or 'all'
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
if (singleUserMode) return;
api
.webauthnStatus()
.then((raw) => setEnabled(!!(raw as { enabled?: boolean }).enabled))
.catch(() => setEnabled(false));
api
.webauthnCredentials()
.then((raw) =>
setCredentials((raw as { credentials?: PasskeyCredential[] }).credentials ?? []),
)
.catch(() => setCredentials([]));
}, [singleUserMode]);
useEffect(() => {
load();
}, [load]);
if (singleUserMode) return null;
if (enabled === null) return null;
const enroll = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const { startRegistration } = await import('@simplewebauthn/browser');
const setup = (await api.webauthnSetup()) as { options?: unknown; challengeId?: string };
const response = await startRegistration({
optionsJSON: setup.options as Parameters<typeof startRegistration>[0]['optionsJSON'],
});
await api.webauthnEnable({
challengeId: setup.challengeId,
response,
credential_name: keyName.trim() || 'Security Key',
});
toast.success('Security key added.');
setNaming(false);
setKeyName('');
load();
} catch (err) {
const cancelled = (err as Error)?.name === 'NotAllowedError';
toast.error(
cancelled
? 'Security key prompt was cancelled.'
: errMessage(err, 'Failed to add security key.'),
);
} finally {
setSaving(false);
}
};
const confirmRemove = async (e: React.FormEvent) => {
e.preventDefault();
if (!removeTarget) return;
setSaving(true);
try {
if (removeTarget === 'all') {
await api.webauthnDisable({ password });
toast.success('Security keys removed.');
} else {
await api.webauthnDeleteCred(removeTarget, { password });
toast.success('Security key removed.');
}
setRemoveTarget(null);
setPassword('');
load();
} catch (err) {
toast.error(errMessage(err, 'Failed to remove security key.'));
} finally {
setSaving(false);
}
};
return (
<SectionCard
title="Passkeys & Security Keys"
icon={KeyRound}
subtitle="Phishing-resistant sign-in with a passkey, fingerprint, or hardware key. If an authenticator app is also enabled, the app code is used at sign-in."
>
<div className="px-6 py-5 space-y-5">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div
className={`h-2.5 w-2.5 rounded-full ${enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`}
/>
<span className="text-sm">
{enabled
? `${credentials.length} security ${credentials.length === 1 ? 'key' : 'keys'} registered`
: 'Not configured'}
</span>
</div>
<div className="flex gap-2">
{enabled && credentials.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setPassword('');
setRemoveTarget('all');
}}
>
Remove all
</Button>
)}
<Button
size="sm"
onClick={() => {
setKeyName('');
setNaming(true);
}}
disabled={saving}
>
Add key
</Button>
</div>
</div>
{credentials.length > 0 && (
<ul className="divide-y divide-border rounded-md border border-border">
{credentials.map((cred) => (
<li
key={cred.credential_id}
className="flex items-center justify-between px-4 py-2.5"
>
<div>
<div className="text-sm">{cred.credential_name || 'Security Key'}</div>
{cred.created_at && (
<div className="text-xs text-muted-foreground">
Added {new Date(cred.created_at).toLocaleDateString()}
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setPassword('');
setRemoveTarget(cred.credential_id);
}}
>
Remove
</Button>
</li>
))}
</ul>
)}
{naming && (
<form onSubmit={enroll} className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<label htmlFor="passkey-name" className="text-xs font-medium text-muted-foreground">
Key name
</label>
<Input
id="passkey-name"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
placeholder="e.g. YubiKey, This laptop"
maxLength={60}
autoFocus
/>
</div>
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Waiting for your key…' : 'Register key'}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setNaming(false)}>
Cancel
</Button>
</form>
)}
{removeTarget !== null && (
<form onSubmit={confirmRemove} className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<label
htmlFor="passkey-remove-password"
className="text-xs font-medium text-muted-foreground"
>
Confirm with your password to{' '}
{removeTarget === 'all' ? 'remove all keys' : 'remove this key'}
</label>
<Input
id="passkey-remove-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
/>
</div>
<Button type="submit" variant="destructive" size="sm" disabled={saving || !password}>
{saving ? 'Removing…' : 'Remove'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setRemoveTarget(null);
setPassword('');
}}
>
Cancel
</Button>
</form>
)}
</div>
</SectionCard>
);
}
function PrivacySettings({ function PrivacySettings({
settings, settings,
onSaved, onSaved,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 116 KiB

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 220" role="img" aria-labelledby="title desc"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 220" role="img" aria-labelledby="title desc">
<title id="title">Bill Tracker logo</title> <title id="title">Bill Tracker logo</title>
<desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc> <desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc>
<defs> <defs>
@ -11,7 +11,7 @@
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.12"/> <feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.12"/>
</filter> </filter>
</defs> </defs>
<rect width="760" height="220" rx="34" fill="none"/> <rect width="720" height="220" rx="34" fill="none"/>
<g filter="url(#bt-soft-shadow)"> <g filter="url(#bt-soft-shadow)">
<rect x="50" y="111" width="25" height="53" rx="7" fill="url(#bt-trust)" opacity="0.86"/> <rect x="50" y="111" width="25" height="53" rx="7" fill="url(#bt-trust)" opacity="0.86"/>
<rect x="87" y="84" width="25" height="80" rx="7" fill="url(#bt-trust)" opacity="0.94"/> <rect x="87" y="84" width="25" height="80" rx="7" fill="url(#bt-trust)" opacity="0.94"/>
@ -19,7 +19,7 @@
<path d="M50 69 84 103 171 36" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> <path d="M50 69 84 103 171 36" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
<path d="M50 69 84 103 171 36" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/> <path d="M50 69 84 103 171 36" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/>
</g> </g>
<text x="205" y="118" fill="#101417" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="56" font-weight="700" letter-spacing="0">Bill</text> <text x="205" y="119" fill="#101417" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="58" font-weight="760" letter-spacing="-1">Bill</text>
<text x="307" y="118" fill="#1f9f62" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="56" font-weight="700" letter-spacing="0">Tracker</text> <text x="312" y="119" fill="#1f9f62" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="58" font-weight="760" letter-spacing="-1">Tracker</text>
<text x="208" y="154" fill="#5f716d" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="0">CALM COMMAND FOR HOUSEHOLD MONEY</text> <text x="208" y="154" fill="#5f716d" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="17" font-weight="560" letter-spacing="1.8">CALM COMMAND FOR HOUSEHOLD MONEY</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 220" role="img" aria-labelledby="title desc"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 220" role="img" aria-labelledby="title desc">
<title id="title">Bill Tracker logo</title> <title id="title">Bill Tracker logo</title>
<desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc> <desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc>
<defs> <defs>
@ -11,7 +11,7 @@
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.18"/> <feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.18"/>
</filter> </filter>
</defs> </defs>
<rect width="760" height="220" rx="34" fill="none"/> <rect width="720" height="220" rx="34" fill="none"/>
<g filter="url(#bt-soft-shadow)"> <g filter="url(#bt-soft-shadow)">
<rect x="50" y="111" width="25" height="53" rx="7" fill="url(#bt-trust)" opacity="0.86"/> <rect x="50" y="111" width="25" height="53" rx="7" fill="url(#bt-trust)" opacity="0.86"/>
<rect x="87" y="84" width="25" height="80" rx="7" fill="url(#bt-trust)" opacity="0.94"/> <rect x="87" y="84" width="25" height="80" rx="7" fill="url(#bt-trust)" opacity="0.94"/>
@ -19,7 +19,7 @@
<path d="M50 69 84 103 171 36" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/> <path d="M50 69 84 103 171 36" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
<path d="M50 69 84 103 171 36" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/> <path d="M50 69 84 103 171 36" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/>
</g> </g>
<text x="205" y="118" fill="#f8faf9" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="56" font-weight="700" letter-spacing="0">Bill</text> <text x="205" y="119" fill="#f8faf9" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="58" font-weight="760" letter-spacing="-1">Bill</text>
<text x="307" y="118" fill="#40c878" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="56" font-weight="700" letter-spacing="0">Tracker</text> <text x="312" y="119" fill="#40c878" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="58" font-weight="760" letter-spacing="-1">Tracker</text>
<text x="208" y="154" fill="#90a39d" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="16" font-weight="700" letter-spacing="0">CALM COMMAND FOR HOUSEHOLD MONEY</text> <text x="208" y="154" fill="#90a39d" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="17" font-weight="560" letter-spacing="1.8">CALM COMMAND FOR HOUSEHOLD MONEY</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

@ -2,6 +2,14 @@
<title id="title">Bill Tracker social preview</title> <title id="title">Bill Tracker social preview</title>
<desc id="desc">Premium calm Bill Tracker brand preview with the logo and tagline.</desc> <desc id="desc">Premium calm Bill Tracker brand preview with the logo and tagline.</desc>
<defs> <defs>
<radialGradient id="glow-a" cx="28%" cy="18%" r="58%">
<stop offset="0" stop-color="#40c878" stop-opacity="0.26"/>
<stop offset="1" stop-color="#40c878" stop-opacity="0"/>
</radialGradient>
<radialGradient id="glow-b" cx="80%" cy="80%" r="52%">
<stop offset="0" stop-color="#23b6a8" stop-opacity="0.18"/>
<stop offset="1" stop-color="#23b6a8" stop-opacity="0"/>
</radialGradient>
<linearGradient id="card" x1="150" x2="1050" y1="105" y2="525" gradientUnits="userSpaceOnUse"> <linearGradient id="card" x1="150" x2="1050" y1="105" y2="525" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#1b2428"/> <stop offset="0" stop-color="#1b2428"/>
<stop offset="1" stop-color="#11181b"/> <stop offset="1" stop-color="#11181b"/>
@ -13,10 +21,9 @@
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="1200" height="630" fill="#101417"/> <rect width="1200" height="630" fill="#101417"/>
<rect width="1200" height="630" fill="url(#glow-a)"/>
<rect width="1200" height="630" fill="url(#glow-b)"/>
<rect x="92" y="72" width="1016" height="486" rx="44" fill="url(#card)" stroke="#314047"/> <rect x="92" y="72" width="1016" height="486" rx="44" fill="url(#card)" stroke="#314047"/>
<path d="M164 479h872" stroke="#26333a" stroke-width="2" stroke-linecap="round"/>
<path d="M164 448h872" stroke="#26333a" stroke-width="2" stroke-linecap="round" opacity="0.62"/>
<path d="M164 417h872" stroke="#26333a" stroke-width="2" stroke-linecap="round" opacity="0.36"/>
<g transform="translate(390 168)"> <g transform="translate(390 168)">
<rect x="0" y="126" width="32" height="70" rx="9" fill="url(#trust)" opacity="0.86"/> <rect x="0" y="126" width="32" height="70" rx="9" fill="url(#trust)" opacity="0.86"/>
<rect x="47" y="91" width="32" height="105" rx="9" fill="url(#trust)" opacity="0.94"/> <rect x="47" y="91" width="32" height="105" rx="9" fill="url(#trust)" opacity="0.94"/>
@ -24,8 +31,8 @@
<path d="M1 72 43 114 153 28" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="20"/> <path d="M1 72 43 114 153 28" fill="none" stroke="#96e6a1" stroke-linecap="round" stroke-linejoin="round" stroke-width="20"/>
<path d="M1 72 43 114 153 28" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/> <path d="M1 72 43 114 153 28" fill="none" stroke="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
</g> </g>
<text x="575" y="290" fill="#f8faf9" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="76" font-weight="700" letter-spacing="0">Bill</text> <text x="575" y="290" fill="#f8faf9" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="78" font-weight="760" letter-spacing="-1.5">Bill</text>
<text x="716" y="290" fill="#40c878" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="76" font-weight="700" letter-spacing="0">Tracker</text> <text x="728" y="290" fill="#40c878" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="78" font-weight="760" letter-spacing="-1.5">Tracker</text>
<text x="575" y="348" fill="#9cafaa" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="27" font-weight="600" letter-spacing="0">Calm command for household money.</text> <text x="575" y="348" fill="#9cafaa" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="27" font-weight="560">Calm command for household money.</text>
<text x="575" y="411" fill="#6f837e" font-family="Arial, Helvetica, ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="700" letter-spacing="0">SELF-HOSTED BILL PLANNING</text> <text x="575" y="411" fill="#6f837e" font-family="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="650" letter-spacing="3">SELF-HOSTED BILL PLANNING</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View File

@ -33,28 +33,15 @@ rolled out. Update this doc when a standard changes.
- **Wire format:** JSON, **snake_case** field names (mirrors DB columns), money as integer - **Wire format:** JSON, **snake_case** field names (mirrors DB columns), money as integer
cents. cents.
- **Error responses (enforced):** every error body is `{ error, message, code, field? }` with - **Error responses (target):** every error body is `{ error, message, code, field? }` with
the correct HTTP status. Throw the factories from `utils/apiError.cts` the correct HTTP status. Throw the factories from `utils/apiError.cts`
(`ValidationError`, `NotFoundError`, `ConflictError`, `AuthError`, `ForbiddenError`, or (`ValidationError`, `NotFoundError`, `ConflictError`, …) and let the single terminal error
`new ApiError(code, message, status, { field })` for anything else) and let the single handler format them — do not hand-roll `res.status().json({ error })`. Express 5 forwards
terminal error handler format them — do not hand-roll `res.status().json({ error })`. rejected async handlers automatically, so route bodies should not wrap everything in
Express 5 forwards rejected async handlers automatically, so route bodies do not wrap `try/catch`. Clients read `message`/`code` + status. **Canonical example:**
everything in `try/catch`; a thrown `ApiError` keeps its message on the wire while any `routes/categories.cts`. (The terminal handler already normalizes any legacy
other 5xx is masked + logged by the terminal handler (that masking is the whitelist: `standardizeError`/`{ error }` body to the same shape, so converted and not-yet-converted
wrap a message in `ApiError` only when it is deliberately user-facing). Clients read routes emit identical responses — convert opportunistically.)
`message`/`code` + status. **Canonical example:** `routes/categories.cts`.
**Enforced by:** the eslint `no-restricted-imports` ratchet on `routes/**` (all 29 route
files converted 2026-07). **Documented exceptions** (each commented at the site):
`routes/transactions.cts` (internal `{ error }` parse-result convention, rethrown at the
boundary via `throwStandardized`), `routes/import.cts` (`sendImportError` error-id
envelope), `routes/admin.cts` `sendError` (backup download can fail mid-stream after
headers are sent), payments' `DUPLICATE_SUSPECTED` 409 and transactions' bulk-unmatch 500
(both carry extra payload fields the client renders), `routes/calendarFeed.cts`
(text/plain for ICS clients).
- **Client error shape:** `client/api.ts` builds every thrown error through `toApiError()`
one construction site; on a 401 outside `/auth/*` it dispatches `auth:expired` so
`AuthProvider`/`RequireAuth` redirect to login. Mutations follow the React Query patterns
in `client/hooks/useQueries.ts` / `usePaymentActions.ts` (invalidate + toast on error).
- **Success envelope (target):** reads return the bare resource/list; mutations return - **Success envelope (target):** reads return the bare resource/list; mutations return
`{ success: true, … }`. `{ success: true, … }`.
- **Validation (target):** validate inputs with the shared validators (throwing - **Validation (target):** validate inputs with the shared validators (throwing
@ -69,7 +56,7 @@ rolled out. Update this doc when a standard changes.
- Never log secrets, tokens, or raw financial values. - Never log secrets, tokens, or raw financial values.
- Fail fast at boot on missing required config (e.g. `TOKEN_ENCRYPTION_KEY` in production). - Fail fast at boot on missing required config (e.g. `TOKEN_ENCRYPTION_KEY` in production).
## Logging (enforced) ## Logging (target)
- Use `utils/logger.cts` (`const { log } = require('.../utils/logger.cts')`) — `log.debug/info/warn/error`, - Use `utils/logger.cts` (`const { log } = require('.../utils/logger.cts')`) — `log.debug/info/warn/error`,
console-compatible. Level via `LOG_LEVEL` (default: debug in dev, info in prod). It **redacts** console-compatible. Level via `LOG_LEVEL` (default: debug in dev, info in prod). It **redacts**

View File

@ -1,16 +1,14 @@
# BillTracker — Master QA Plan (living document) # BillTracker — Master QA Plan (living document)
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-10 **Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-05
**Cycle 1: COMPLETE ✅** — all 18 batches (B0→B16 + B-UI) run; **15 findings fixed**, verified & archived (3× S2); automated re-run of existing batches clean (0 new); the added **B16** (migrations/secrets/deploy) surfaced + fixed 1 (version-check opt-out). Guard suite green. External-infra items (live TOTP/WebAuthn/OIDC, SMTP delivery, cross-browser, PWA-offline, load, container build) carried to Cycle 2 as non-blocking. **Cycle 1: COMPLETE ✅** — all 18 batches (B0→B16 + B-UI) run; **15 findings fixed**, verified & archived (3× S2); automated re-run of existing batches clean (0 new); the added **B16** (migrations/secrets/deploy) surfaced + fixed 1 (version-check opt-out). Guard suite green. External-infra items (live TOTP/WebAuthn/OIDC, SMTP delivery, cross-browser, PWA-offline, load, container build) carried to Cycle 2 as non-blocking.
**2026-07-10 blind-spot recon (pre-Cycle 2):** a dedicated deps/dead-code/coverage audit logged **6 open findings** (1× S2 — high-severity `xlsx`/`nodemailer` vulns invisible to CI's critical-only audit gate; plus version drift, the WebAuthn ghost feature, vacuous `@ts-nocheck` typecheck coverage, `err.message` 5xx leaks, dead duplicate admin routes) and **4 IMP** rows — see §2/§2.1 and the Cycle Log. B0 gained four standing recon steps so these classes can't hide again.
This is a **living, operational** QA document, not a static spec. Claude runs it, This is a **living, operational** QA document, not a static spec. Claude runs it,
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
**archiving** each fixed finding to `HISTORY.md`. Update this document whenever a **archiving** each fixed finding to `HISTORY.md`. Update this document whenever a
better approach, a new risk area, or a missed surface is discovered. better approach, a new risk area, or a missed surface is discovered.
> **The prime directive:** don't just confirm the happy path — try to _break_ > **The prime directive:** don't just confirm the happy path — try to *break*
> the product. Every batch should end with the tree green, the Findings Log > the product. Every batch should end with the tree green, the Findings Log
> up to date, and any fixes archived to `HISTORY.md`. > up to date, and any fixes archived to `HISTORY.md`.
@ -32,7 +30,7 @@ better approach, a new risk area, or a missed surface is discovered.
## 0. Execution model — find, then fix, then repeat ## 0. Execution model — find, then fix, then repeat
**Separate finding from fixing.** During a QA pass we _hunt and log_ — we do **not** **Separate finding from fixing.** During a QA pass we *hunt and log* — we do **not**
fix as we go (except show-stoppers, see below). Only after the whole plan has run fix as we go (except show-stoppers, see below). Only after the whole plan has run
do we enter a dedicated **fix phase** and fix **every** logged finding. Then we run do we enter a dedicated **fix phase** and fix **every** logged finding. Then we run
the **entire** QA plan again from the top. Repeat until a full pass finds **zero** the **entire** QA plan again from the top. Repeat until a full pass finds **zero**
@ -62,7 +60,7 @@ errors. Two nested loops:
mark batch status in §1 → next batch. (No fixing here.) mark batch status in §1 → next batch. (No fixing here.)
``` ```
**Show-stopper exception.** A _show-stopper_ is a finding that **blocks continued **Show-stopper exception.** A *show-stopper* is a finding that **blocks continued
QA** — the app won't boot, you can't log in, or a page crashes so hard you can't QA** — the app won't boot, you can't log in, or a page crashes so hard you can't
test the rest of it. Only these get fixed immediately (mid-pass), because you test the rest of it. Only these get fixed immediately (mid-pass), because you
can't proceed otherwise. Log it, fix it, verify, and note it was a mid-pass fix; can't proceed otherwise. Log it, fix it, verify, and note it was a mid-pass fix;
@ -70,30 +68,28 @@ then continue the find pass. **Everything else is logged and left for Phase 2**
no matter how tempting or trivial. no matter how tempting or trivial.
**Discipline (for best results)** **Discipline (for best results)**
- **Phase 1 is log-only.** Resist fixing. A clean, complete inventory of findings beats a scattered fix-as-you-go pass and produces better batching. - **Phase 1 is log-only.** Resist fixing. A clean, complete inventory of findings beats a scattered fix-as-you-go pass and produces better batching.
- Keep each find batch tight and focused — one batch per session — so probing stays thorough. - Keep each find batch tight and focused — one batch per session — so probing stays thorough.
- **Phase 2 fixes everything**, not just S1/S2. Root-cause over surface patch; add/extend a test in `tests/` or `client/**/*.test.*` for every logic bug so it can't silently return. - **Phase 2 fixes everything**, not just S1/S2. Root-cause over surface patch; add/extend a test in `tests/` or `client/**/*.test.*` for every logic bug so it can't silently return.
- Never leave the repo red at the end of Phase 3 — `npm run ci` must be green before archiving. - Never leave the repo red at the end of Phase 3 — `npm run ci` must be green before archiving.
- Touch product behavior? Run the `/verify` skill on the affected flow before archiving. - Touch product behavior? Run the `/verify` skill on the affected flow before archiving.
- **The exit is empirical:** you're done only when an entire find pass (B0→B15) turns up zero new findings — not when you _think_ it's clean. Log the cycle result in the [Cycle Log](#11-qa-cycle-log) each time. - **The exit is empirical:** you're done only when an entire find pass (B0→B15) turns up zero new findings — not when you *think* it's clean. Log the cycle result in the [Cycle Log](#11-qa-cycle-log) each time.
- Improve THIS plan whenever a pass reveals a missed surface, a better repro, or a batch that should be reordered/split. - Improve THIS plan whenever a pass reveals a missed surface, a better repro, or a batch that should be reordered/split.
**Improvement lens (not just bug-hunting).** QA here is also about making the product **Improvement lens (not just bug-hunting).** QA here is also about making the product
_better_, not only _correct_. On every batch, in addition to logging bugs, actively *better*, not only *correct*. On every batch, in addition to logging bugs, actively
look through three improvement lenses and log what you find as **IMP** items in the look through three improvement lenses and log what you find as **IMP** items in the
[Improvement Backlog (§2.1)](#21-improvement-backlog): [Improvement Backlog (§2.1)](#21-improvement-backlog):
- **Code health & consolidation** — duplication to DRY up, dead code to delete, - **Code health & consolidation** — duplication to DRY up, dead code to delete,
overlapping modules to merge, oversized files to split, one canonical path per overlapping modules to merge, oversized files to split, one canonical path per
concern. _Consolidate only where it genuinely reduces surface area and is concern. *Consolidate only where it genuinely reduces surface area and is
behavior-preserving._ (Dedicated pass: **B17**.) behavior-preserving.* (Dedicated pass: **B17**.)
- **User experience** — friction in core flows, unclear states (empty/loading/error), - **User experience** — friction in core flows, unclear states (empty/loading/error),
weak feedback/affordances, inconsistent patterns, mobile parity. (Dedicated pass: **B18**.) weak feedback/affordances, inconsistent patterns, mobile parity. (Dedicated pass: **B18**.)
- **Information architecture / menus** — features that are buried or only reachable by - **Information architecture / menus** — features that are buried or only reachable by
URL, actions that belong in a menu (nav, overflow, context, settings groupings), and URL, actions that belong in a menu (nav, overflow, context, settings groupings), and
groupings that would make the app more discoverable. _Put things where a user would groupings that would make the app more discoverable. *Put things where a user would
look for them._ (Dedicated pass: **B18**.) look for them.* (Dedicated pass: **B18**.)
IMP items are **proposals**, not silent changes: log the candidate with a concrete IMP items are **proposals**, not silent changes: log the candidate with a concrete
recommendation, agree the direction, then implement behind a test. They **don't block recommendation, agree the direction, then implement behind a test. They **don't block
@ -109,28 +105,28 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
**Status key:** ⬜ Not started · 🔄 In progress · ✅ Done (green, findings archived) · 🔁 Needs recheck **Status key:** ⬜ Not started · 🔄 In progress · ✅ Done (green, findings archived) · 🔁 Needs recheck
| # | Batch | Primary surface | Data state | Status | Open / Fixed | | # | Batch | Primary surface | Data state | Status | Open / Fixed |
| ---- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | ------ | ------------ | |---|-------|-----------------|-----------|--------|--------------|
| B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | ✅ | 0 / 1 | | B0 | Baseline, tooling & **coverage recon** | `npm run ci`/`check`, app boots, console clean, **re-scan routes/pages/API vs plan & update it**, **control census** | any | ✅ | 0 / 1 |
| B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | ✅ | 0 / 0 | | B-UI | **Design-system primitives** | each `client/components/ui/*` × state matrix (default/hover/focus/active/disabled/loading/error/read-only) × light/dark × keyboard | any | ✅ | 0 / 0 |
| B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ✅ | 0 / 0 | | B1 | Auth & authorization | login (pw/OIDC/TOTP/WebAuthn), roles, single-user, CSRF, data isolation | multi + single user | ✅ | 0 / 0 |
| B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ✅ | 0 / 0 | | B2 | Tracker (core) | `/` buckets, pay/skip/notes/overrides, balance cards, overdue, ledger, drift | seeded + adversarial | ✅ | 0 / 0 |
| B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ✅ | 0 / 0 | | B3 | Bills & schedules | `/bills` CRUD, custom schedules, reorder, merchant rules, historical import | adversarial | ✅ | 0 / 0 |
| B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ✅ | 0 / 0 | | B4 | Subscriptions & Categories | `/subscriptions`, catalog, `/categories`, groups, reorder | seeded | ✅ | 0 / 0 |
| B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large + **live SimpleFIN DB** | ✅ | 0 / 4 | | B5 | Reporting reconciliation | `/summary`, `/calendar`, `/analytics`, `/health` cross-check totals | seeded + large + **live SimpleFIN DB** | ✅ | 0 / 4 |
| B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | ✅ | 0 / 1 | | B6 | Spending | `/spending` YNAB view, averages, cover-overspending, safe-to-spend | seeded + edge months | ✅ | 0 / 1 |
| B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | ✅ | 0 / 2 | | B7 | Debt planning (math) | `/snowball`, `/payoff` APR/amortization vs hand-calc | edge (APR=0, $0 debt) | ✅ | 0 / 2 |
| B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns + **live SimpleFIN sync** | ✅ | 0 / 0 | | B8 | Banking & bank sync | `/bank-transactions`, SimpleFIN sync, matching, merchant/store, advisory filter | seeded txns + **live SimpleFIN sync** | ✅ | 0 / 0 |
| B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | ✅ | 0 / 1 | | B9 | Data lifecycle | `/data` import (XLSX/CSV/SQLite), export, ICS feed, backups round-trip | empty + seeded | ✅ | 0 / 1 |
| B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ✅ | 0 / 1 | | B10 | Notifications & workers | email + ntfy/Gotify/Discord/Telegram, reminders, cron workers | seeded | ✅ | 0 / 1 |
| B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ✅ | 0 / 0 | | B11 | Admin panel | users, login mode, auth methods, backups, cleanup, status, onboarding | admin | ✅ | 0 / 0 |
| B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ✅ | 0 / 0 | | B12 | Settings, Profile & global UI | `/settings`, `/profile`, static pages, command palette, sidebar/nav | any | ✅ | 0 / 0 |
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | ✅ | 0 / 1 | | B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | ✅ | 0 / 1 |
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | ✅ | 0 / 4 | | B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | ✅ | 0 / 4 |
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ✅ | 0 / 0 | | B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ✅ | 0 / 0 |
| B16 | Migrations, secrets & deploy | migration idempotency/rollback/fresh==migrated, encryption-key lifecycle, `docker-entrypoint` (perms/first-run/migrate), update-check phone-home | scratch + docker | ✅ | 0 / 1 | | B16 | Migrations, secrets & deploy | migration idempotency/rollback/fresh==migrated, encryption-key lifecycle, `docker-entrypoint` (perms/first-run/migrate), update-check phone-home | scratch + docker | ✅ | 0 / 1 |
| B17 | **Code health & consolidation** (IMP) | duplication/DRY, dead code, overlapping modules to merge, oversized files to split, one canonical path per concern | whole repo | ⬜ | 0 / 0 | | B17 | **Code health & consolidation** (IMP) | duplication/DRY, dead code, overlapping modules to merge, oversized files to split, one canonical path per concern | whole repo | ⬜ | 0 / 0 |
| B18 | **UX & information architecture** (IMP) | core-flow friction, empty/loading/error states, feedback/affordances, nav/menu discoverability, surfacing actions into sensible menus | any | ⬜ | 0 / 0 | | B18 | **UX & information architecture** (IMP) | core-flow friction, empty/loading/error states, feedback/affordances, nav/menu discoverability, surfacing actions into sensible menus | any | ⬜ | 0 / 0 |
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new > After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
> cycle from B0 against the next build/version. > cycle from B0 against the next build/version.
@ -145,10 +141,10 @@ human** and were **not** exercised — they are **non-blocking** for Cycle 1 sig
carried to Cycle 2: carried to Cycle 2:
- **B1** — live TOTP enrollment, WebAuthn/passkeys (browser/OS prompts), OIDC SSO round-trip. (Password login, roles, CSRF, data-isolation, admin authz **are** covered.) - **B1** — live TOTP enrollment, WebAuthn/passkeys (browser/OS prompts), OIDC SSO round-trip. (Password login, roles, CSRF, data-isolation, admin authz **are** covered.)
- **B10** — real SMTP _delivery_ (push delivery + email-HTML building/escaping **are** covered by `tests/notificationDelivery.test.js`). - **B10** — real SMTP *delivery* (push delivery + email-HTML building/escaping **are** covered by `tests/notificationDelivery.test.js`).
- **B11** — backup create/restore on a scratch instance (authorization + last-admin guards **are** covered). - **B11** — backup create/restore on a scratch instance (authorization + last-admin guards **are** covered).
- **B14** — Firefox/Safari cross-browser, PWA install/offline, and large/stress load+perf. (axe a11y on 8 pages, XSS/escaping, and prod-bundle perf **are** covered.) - **B14** — Firefox/Safari cross-browser, PWA install/offline, and large/stress load+perf. (axe a11y on 8 pages, XSS/escaping, and prod-bundle perf **are** covered.)
- **B9** — spreadsheet/CSV import _from real files_ end-to-end (money-unit handling + SQLite export→import round-trip **are** covered by tests). - **B9** — spreadsheet/CSV import *from real files* end-to-end (money-unit handling + SQLite export→import round-trip **are** covered by tests).
### 1.1 QA Cycle Log ### 1.1 QA Cycle Log
@ -156,14 +152,13 @@ One row per full QA cycle (Phase 1 find → Phase 2 fix → … → Phase 5 re-r
cycle is only "clean" when its **find pass logged zero findings**. Keep going cycle is only "clean" when its **find pass logged zero findings**. Keep going
until you get a clean cycle. until you get a clean cycle.
| Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result | | Cycle | Started | Build / commit | Findings logged | Fixed / archived | Result |
| ---------------- | ---------- | -------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |-------|---------|----------------|-----------------|------------------|--------|
| 1 | 2026-07-02 | `bdbf231`→`5ffe2db` (dev) | 14 | **14 → all fixed, verified & archived** (3× S2 incl. broken "Send test push", email XSS, reconciliation family, seed 100× cents) | 🔁 Phase 2 complete — 0 open. Every batch B0→B15 (+B-UI) run; 16 QA commits; guard suite green. | | 1 | 2026-07-02 | `bdbf231`→`5ffe2db` (dev) | 14 | **14 → all fixed, verified & archived** (3× S2 incl. broken "Send test push", email XSS, reconciliation family, seed 100× cents) | 🔁 Phase 2 complete — 0 open. Every batch B0→B15 (+B-UI) run; 16 QA commits; guard suite green. |
| 1·re-run | 2026-07-02 | `5ffe2db` (dev) | **0 new** | — | ✅ **Automated re-run clean.** CI (server 109 + client 34, build), UI E2E 27, probe 16 (authz 403, Tracker↔Summary↔Analytics reconcile exactly, seed guard, a11y 8/8), prod-smoke PASS. **All 17 batches ✅ for automatable scope; external-infra residuals listed below are non-blocking and carried to Cycle 2.** | | 1·re-run | 2026-07-02 | `5ffe2db` (dev) | **0 new** | — | ✅ **Automated re-run clean.** CI (server 109 + client 34, build), UI E2E 27, probe 16 (authz 403, Tracker↔Summary↔Analytics reconcile exactly, seed guard, a11y 8/8), prod-smoke PASS. **All 17 batches ✅ for automatable scope; external-infra residuals listed below are non-blocking and carried to Cycle 2.** |
| 1·simplefin-live | 2026-07-03 | `5ffe2db` (dev) vs prod DB | **1** (QA-B5-04) | **1 → fixed, verified & archived** | 🔁 Probed a **copy of the live SimpleFIN DB** (19 MB, v1.06: 3 users, 44 bills, 1,159 txns, 19 accounts, active SimpleFIN source). Integrity checks: dedup (1159/1159 distinct), money=integer cents, no double-match, pending have provider ids, no orphan-account txns — all pass **except** 3 matched txns with NULL bill → QA-B5-04 (retention GC + `ON DELETE SET NULL`). Fixed in `cleanupService`; healing verified on a DB copy (3→0, 0 txns lost). **Also ran a real end-to-end sync** (`syncDataSource`, the Sync-button path) against the live connection off a working copy: token decrypted via db-key fallback (no env key), bridge fetch OK (2.2s), 18 accounts upserted, 145 fetched txns **skipped not duplicated**, 0 new, 1159→1159 distinct — **dedup/upsert idempotency proven on the real connection.** | | 1·simplefin-live | 2026-07-03 | `5ffe2db` (dev) vs prod DB | **1** (QA-B5-04) | **1 → fixed, verified & archived** | 🔁 Probed a **copy of the live SimpleFIN DB** (19 MB, v1.06: 3 users, 44 bills, 1,159 txns, 19 accounts, active SimpleFIN source). Integrity checks: dedup (1159/1159 distinct), money=integer cents, no double-match, pending have provider ids, no orphan-account txns — all pass **except** 3 matched txns with NULL bill → QA-B5-04 (retention GC + `ON DELETE SET NULL`). Fixed in `cleanupService`; healing verified on a DB copy (3→0, 0 txns lost). **Also ran a real end-to-end sync** (`syncDataSource`, the Sync-button path) against the live connection off a working copy: token decrypted via db-key fallback (no env key), bridge fetch OK (2.2s), 18 accounts upserted, 145 fetched txns **skipped not duplicated**, 0 new, 1159→1159 distinct — **dedup/upsert idempotency proven on the real connection.** |
| 1·data-overhaul | 2026-07-03 | `78ad63d`→`d53a64b` (dev) | **0 defects** (feature work) | — | ✅ **Data page overhaul, 7 batches, all green.** Two-pane goal layout + 5-state connection hero + `?section=` deep-linking + nav badges/health dots + lazy panes (B0B2); **new features** OFX/QFX import (B3), richer export JSON + date-range (B4), "Erase my data" danger zone (B5). Verified: server 139 tests, client 46, build; **axe on `/data` zero critical/serious** (added to `a11y.authed`); full probe suite 17/17. Section internals + the 7 SimpleFIN buttons untouched. | | 1·data-overhaul | 2026-07-03 | `78ad63d`→`d53a64b` (dev) | **0 defects** (feature work) | — | ✅ **Data page overhaul, 7 batches, all green.** Two-pane goal layout + 5-state connection hero + `?section=` deep-linking + nav badges/health dots + lazy panes (B0B2); **new features** OFX/QFX import (B3), richer export JSON + date-range (B4), "Erase my data" danger zone (B5). Verified: server 139 tests, client 46, build; **axe on `/data` zero critical/serious** (added to `a11y.authed`); full probe suite 17/17. Section internals + the 7 SimpleFIN buttons untouched. |
| 2·recon | 2026-07-10 | `d9545d6` (dev) | **6** (1× S2, 4× S3, 1× S4) + 4 IMP | 0 (find-only) | 🔄 **Blind-spot recon pass** (deps/dead-code/coverage audit, no UI batches). Logged: high-vuln deps invisible to CI's critical-only gate (QA-B0-02) · package.json 0.40.0 vs HISTORY v0.41.0 drift (QA-B0-03) · WebAuthn ghost feature — backend + wrappers, zero UI (QA-B1-01) · 38 `@ts-nocheck` files incl. all 29 routes ⇒ vacuous typecheck (QA-B0-04) · 10 raw `err.message` 5xx leaks (QA-B13-02) · dead duplicate admin routes in auth.cts (QA-B17-01). IMP: dead files/exports (2.2 MB unused image, broken legacy test scripts, knip items) · oversized pages · runtime seed JSONs in `docs/` · automated control census. **Plan updated:** stale `.js`→`.cts` refs fixed; B0 gained dependency-freshness/dead-code/ts-nocheck/version-coherence recon steps; B13 gained error-shape + orphan-endpoint sweeps; Appendix C gained `/admin/about`, `/admin/roadmap`, `/status`-redirect + ICS-feed rows; Appendix E marked never-filled. Verified-clean along the way: no SQL-injection paths found (whitelists + params), no `dangerouslySetInnerHTML`/`as any` in client, calendar feed token-gated, no data/build artifacts tracked in git, all 29 route files mounted. |
**Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete) **Result key:** 🔄 in progress · 🔁 findings fixed, re-run required · ✅ clean (zero findings — QA complete)
@ -180,14 +175,9 @@ fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
**Severity:** S1 Critical · S2 Major · S3 Minor · S4 Cosmetic · IMP Improvement (see [Appendix A](#appendix-a--severity-definitions)). **Severity:** S1 Critical · S2 Major · S3 Minor · S4 Cosmetic · IMP Improvement (see [Appendix A](#appendix-a--severity-definitions)).
**Status:** 🔴 Open → 🟡 Fixing → 🟢 Fixed (verified, awaiting archive) → then remove on 📦 Archive. **Status:** 🔴 Open → 🟡 Fixing → 🟢 Fixed (verified, awaiting archive) → then remove on 📦 Archive.
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro | | ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
| --------- | --- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |----|-----|--------------------|---------|--------|---------------|
| QA-B0-02 | S2 | `package.json` deps | **Known high-severity vulns ship in prod deps and CI can't see them**: `nodemailer` <9 (raw-option file-read/SSRF, GHSA-p6gq-j5cr-w38f; fix = major bump to 9.x) and `xlsx` (prototype pollution GHSA-4r6h-8v6p-xvw6 + ReDoS GHSA-5pgg-2g8v-p4x9, **no registry fix**; it parses **user-uploaded** spreadsheets in `spreadsheetImportService`). CI audit gate is `--audit-level=critical` so high vulns pass silently. | 🔴 Open | `npm audit` (2026-07-10: 4 vulns 3 high, 1 moderate). Fix: bump nodemailer9 (check API changes at call sites in `notificationService`); for xlsx either move to the maintained SheetJS dist/`exceljs`, or document risk-acceptance + tighten import validation. Raise CI gate to `--audit-level=high` once clean. | | _(none — all Cycle 1 findings fixed, verified & archived to `HISTORY.md` v0.41.0)_ | | | | | |
| QA-B0-03 | S3 | `package.json:3` vs `HISTORY.md:1` | **Version drift**: `package.json` says `0.40.0` but `HISTORY.md`'s latest release is `v0.41.0`. `/api/version`, `/api/auth/me` (`has_new_version`/release-notes badge) and `updateCheckService` all read `package.json`, so the app under-reports its version and the release-notes "new version" logic keys off the wrong value. | 🔴 Open | `grep version package.json` vs `head HISTORY.md`. Fix: bump package.json to match the shipped release; add a check (test or CI step) that `package.json` version == top `HISTORY.md` heading. |
| QA-B1-01 | S2 | `routes/auth.cts:600-824` + `auth.cts:82-111`, `services/authService.cts:86-94`, `client/api.ts:203-210` | **WebAuthn is a ghost feature — and a live self-lockout hazard**: full server implementation (routes, service, DB tables) and client API wrappers exist, but **no UI component ever calls them** (commit `99abca9` shipped backend only). Worse: `authService.login` returns `{requires_webauthn, challenge_token, webauthn_options}` when `webauthn_enabled=1`, but **POST /login has no `requires_webauthn` branch** — it falls through to `result.user.id` (undefined) → TypeError → 500 "Login failed" on **every** login attempt. The enable endpoint is reachable today (any session + CSRF via curl), so a user can permanently brick their own login; recovery requires a DB edit. | 🔴 Open | `grep -rn webauthn client --include='*.tsx'` → 0 hits; `grep -n requires_webauthn routes/auth.cts` → 0 hits. Decide: build the Login/Profile passkey UI (client work mirrors `TotpSection`; `@simplewebauthn/browser` already installed; Playwright's CDP **virtual authenticator** makes it CI-testable — it does NOT need a human/external infra as Cycle 1 assumed), **or** remove/feature-flag the server surface + dep. **Either way, fix or guard the login fall-through first** — it's a lockout bug independent of the UI decision. |
| QA-B0-04 | S3 | 38 server files (`grep -rl @ts-nocheck`) | **Server typecheck gate is vacuous where it matters most**: all 29 `routes/*.cts` + `server.cts` + `db/database.cts` + both migration modules + 4 large services carry `@ts-nocheck`, so `npm run typecheck:server` green ≠ routes type-checked. The "TS migration complete" claim is about file extensions, not checking. | 🔴 Open | Burn-down: remove `@ts-nocheck` file-by-file (routes are thin and mostly typed already — start with small routes like `version`, `privacy`, `about`); track the count in B0 each cycle (**must only go down**). |
| QA-B13-02 | S3 | `routes/admin.cts:515,550,553` · `notifications.cts:82,190` · `spending.cts:85,198` · `transactions.cts:999` · `import.cts:45` | **Raw `err.message` returned to clients on 5xx** in 10 places — leaks internal error text and breaks the standardized error shape (`standardizeError`/`ApiError`). | 🔴 Open | `grep -rn "error: err.message" routes`. Fold into the route throw-pattern standardization (28 routes remaining); where the message is intentionally user-facing (e.g. SMTP test-send feedback) whitelist it explicitly. |
| QA-B17-01 | S4 | `routes/auth.cts:530-598` | **Dead duplicate admin routes**: auth.cts's "ADMIN ROUTES (MOUNTED AT /api/admin)" section duplicates `admin.cts` (`/has-users`, GET/POST `/users`) but auth.cts is only mounted at `/api/auth` — the client calls the `/api/admin/*` copies. Side effect: `/api/auth/has-users` is reachable **unauthenticated** (minor "instance has users" disclosure). | 🔴 Open | Delete the section (and the stale comment); verify nothing calls `/api/auth/has-users` / `/api/auth/users`; keep the admin.cts copies as canonical. |
**Finding template** (paste a new row above; keep the full write-up here until archived): **Finding template** (paste a new row above; keep the full write-up here until archived):
@ -224,18 +214,14 @@ graduate to `roadmap.md`/`FUTURE.md`.
**Status:** 🔵 Noted (proposal) → 🟡 Doing → then archive to `HISTORY.md` on implement. **Status:** 🔵 Noted (proposal) → 🟡 Doing → then archive to `HISTORY.md` on implement.
| ID | Lens | Area (`file`/page) | Proposal (what & why) | Effort | Status | | ID | Lens | Area (`file`/page) | Proposal (what & why) | Effort | Status |
| ----------- | ---- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | |----|------|--------------------|-----------------------|--------|--------|
| IMP-CODE-01 | Code | `client/lib/money.js` (+16 files) | ~~No shared client money formatter.~~ **Shipped `a15f00c`:** added `client/lib/money.js` (`formatUSD`/`formatUSDWhole`/`formatCentsUSD`); `lib/utils.fmt` delegates to it and 15 local formatters were removed. `null`/`NaN`/`-0` all handled. Test `client/lib/money.test.js`; full client suite + build green. | M | ✅ Shipped | | IMP-CODE-01 | Code | `client/lib/money.js` (+16 files) | ~~No shared client money formatter.~~ **Shipped `a15f00c`:** added `client/lib/money.js` (`formatUSD`/`formatUSDWhole`/`formatCentsUSD`); `lib/utils.fmt` delegates to it and 15 local formatters were removed. `null`/`NaN`/`-0` all handled. Test `client/lib/money.test.js`; full client suite + build green. | M | ✅ Shipped |
| IMP-CODE-02 | Code | `db/database.js` (4,174→1,297 ln) | ~~Oversized module.~~ **Shipped `7f2faea`,`026c6a5`,`12d9d4c`:** split into `subscriptionCatalogSeed.js` (data), `migrations/versionedMigrations.js` (~1,740 ln factory), `migrations/legacyReconcileMigrations.js` (~830 ln factory) — db + helpers injected, behavior byte-identical. Verified: suite 125, fresh DB 79 migrations idempotent, real prod DB no-op with data intact. Test `tests/migrationModules.test.js`. | L | ✅ Shipped | | IMP-CODE-02 | Code | `db/database.js` (4,174→1,297 ln) | ~~Oversized module.~~ **Shipped `7f2faea`,`026c6a5`,`12d9d4c`:** split into `subscriptionCatalogSeed.js` (data), `migrations/versionedMigrations.js` (~1,740 ln factory), `migrations/legacyReconcileMigrations.js` (~830 ln factory) — db + helpers injected, behavior byte-identical. Verified: suite 125, fresh DB 79 migrations idempotent, real prod DB no-op with data intact. Test `tests/migrationModules.test.js`. | L | ✅ Shipped |
| IMP-CODE-03 | Code | `services/transactionMatchState.js` | ~~Overlapping match logic.~~ **Shipped `fa24322`:** added canonical `markMatched`/`markUnmatched`/`markIgnored`; routed the 6 single-transaction transitions through it (guarded bulk sweeps keep their own queries by design). Test `tests/transactionMatchState.test.js`. | M | ✅ Shipped | | IMP-CODE-03 | Code | `services/transactionMatchState.js` | ~~Overlapping match logic.~~ **Shipped `fa24322`:** added canonical `markMatched`/`markUnmatched`/`markIgnored`; routed the 6 single-transaction transitions through it (guarded bulk sweeps keep their own queries by design). Test `tests/transactionMatchState.test.js`. | M | ✅ Shipped |
| IMP-IA-01 | IA | Sidebar · `/data` | ~~Central features under an overflow menu.~~ **Shipped `0b1c6a8`:** Data moved into the main app nav (desktop dropdown + mobile) alongside Bills/Categories/Spending; same default-admin gate preserved; removed the redundant account-dropdown entry. | S | ✅ Shipped | | IMP-IA-01 | IA | Sidebar · `/data` | ~~Central features under an overflow menu.~~ **Shipped `0b1c6a8`:** Data moved into the main app nav (desktop dropdown + mobile) alongside Bills/Categories/Spending; same default-admin gate preserved; removed the redundant account-dropdown entry. | S | ✅ Shipped |
| IMP-UX-01 | UX | Bills delete | ~~Retention isn't surfaced.~~ **Shipped `aace5a4`:** Bills shows a "Recently deleted (N)" button opening a restore dialog (amount, category, days-left); `GET /api/bills/deleted` (30-day window). Test `tests/billsDeletedRoute.test.js`. _Categories/payments could get the same treatment later._ | M | ✅ Shipped | | IMP-UX-01 | UX | Bills delete | ~~Retention isn't surfaced.~~ **Shipped `aace5a4`:** Bills shows a "Recently deleted (N)" button opening a restore dialog (amount, category, days-left); `GET /api/bills/deleted` (30-day window). Test `tests/billsDeletedRoute.test.js`. _Categories/payments could get the same treatment later._ | M | ✅ Shipped |
| IMP-UX-02 | UX | all list pages | **Audited — no gaps.** Checked all 11 main pages (Tracker/Bills/Subscriptions/Categories/Spending/Banking/Snowball/Payoff/Analytics/Summary/Calendar): each has a skeleton/`animate-pulse` **loading** state, an **empty** state, and a **rendered recoverable error** panel with a Retry/Try-again button (e.g. Summary 459-463, Calendar 899-903, BankTransactions 671-750, Payoff 276-302). No dead ends found; no change warranted. Keep as a standing B18/B14 check for new pages. | M | ✅ Audited | | IMP-UX-02 | UX | all list pages | **Audited — no gaps.** Checked all 11 main pages (Tracker/Bills/Subscriptions/Categories/Spending/Banking/Snowball/Payoff/Analytics/Summary/Calendar): each has a skeleton/`animate-pulse` **loading** state, an **empty** state, and a **rendered recoverable error** panel with a Retry/Try-again button (e.g. Summary 459-463, Calendar 899-903, BankTransactions 671-750, Payoff 276-302). No dead ends found; no change warranted. Keep as a standing B18/B14 check for new pages. | M | ✅ Audited |
| IMP-CODE-04 | Code | dead files/exports (knip + manual, 2026-07-10) | **Delete dead weight**: `client/public/img/doingmypart.jpg` (2.2 MB, referenced nowhere, copied into every build) · root `test-functional.js` (imports bare `playwright`, not a dependency — broken) + `run-functional-test.js` (legacy pre-Playwright harness) · unused exports `dollarsToCents`/`SUPPORTED_CURRENCIES` (`client/lib/money.ts`) and type `BillDraft` (`client/lib/billDrafts.ts`) · `@simplewebauthn/browser` dep (unused until QA-B1-01 is resolved — remove or keep per that decision) · apply knip.json config hints (redundant entry patterns, `types/**` ignore). | S | 🔵 Noted |
| IMP-CODE-05 | Code | `client/pages/SubscriptionsPage.tsx` (2,371 ln), `TrackerPage.tsx` (1,943 ln) | Largest two pages exceed the size BillModal was decomposed at (1,733). Same treatment: extract section components/hooks behind existing tests, behavior-preserving. SpendingPage (1,642) and BillsPage (1,586) next tier. | L | 🔵 Noted |
| IMP-CODE-06 | Code | `docs/` runtime data | `advisory_non_bill_transaction_filters_us_ms_5000.json` (4.3 MB) + `merchant_store_match_us_nems_online_5k_v0_2.json` (3.1 MB) are **runtime seed data** loaded by `db/database.cts` but live in `docs/` next to actual documentation. Move to `db/data/` (or similar) so docs stays docs and the Docker COPY surface is explicit. | S | 🔵 Noted |
| IMP-CODE-07 | Code | `e2e/` + Appendix E | **Automate the control census**: Appendix E has never been filled in manually (see Cycle-Log 2·recon). Add an e2e spec that walks each page and snapshots the list of interactive elements (role/name via `getByRole` enumeration) — a new/removed/renamed control then shows up as a reviewable diff every cycle instead of relying on a hand-maintained table. | M | 🔵 Noted |
--- ---
@ -260,11 +246,10 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
``` ```
**Rules** **Rules**
- One bullet per finding; include the old `QA-B?-??` id in parentheses for traceability. - One bullet per finding; include the old `QA-B?-??` id in parentheses for traceability.
- If a fix added/changed a test, say which (`tests/…` or `client/…test.*`). - If a fix added/changed a test, say which (`tests/…` or `client/…test.*`).
- Don't archive until the fix is verified (repro gone + `npm run ci` green). - Don't archive until the fix is verified (repro gone + `npm run ci` green).
- IMP items that were implemented are archived the same way; IMP items merely _noted_ stay in the Findings Log (or graduate to `FUTURE.md`/`roadmap.md` if deferred). - IMP items that were implemented are archived the same way; IMP items merely *noted* stay in the Findings Log (or graduate to `FUTURE.md`/`roadmap.md` if deferred).
--- ---
@ -272,12 +257,12 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
### 4.1 Running the app ### 4.1 Running the app
| Mode | Command | URL | | Mode | Command | URL |
| -------------------------- | -------------------------------- | -------------------------------------------------- | |------|---------|-----|
| Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) | | Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) |
| API only | `npm run dev:api` | `http://localhost:3000` | | API only | `npm run dev:api` | `http://localhost:3000` |
| Production build | `npm run build` then `npm start` | `http://localhost:3000` | | Production build | `npm run build` then `npm start` | `http://localhost:3000` |
| Docker | `docker-compose up` | per compose config | | Docker | `docker-compose up` | per compose config |
- Backend: Node/Express on `PORT` (default `3000`). Frontend dev: Vite on `5173`. - Backend: Node/Express on `PORT` (default `3000`). Frontend dev: Vite on `5173`.
- Data: SQLite at `db/bills.db` (WAL). **Back it up before destructive tests** (`backups/` or a manual copy). Prefer a scratch DB for B9/B11 restore tests. - Data: SQLite at `db/bills.db` (WAL). **Back it up before destructive tests** (`backups/` or a manual copy). Prefer a scratch DB for B9/B11 restore tests.
@ -288,19 +273,18 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
Full functional pass across reasonable combinations; smoke (B15) across all. Full functional pass across reasonable combinations; smoke (B15) across all.
| Dimension | Values | | Dimension | Values |
| ---------- | --------------------------------------------------------------- | |-----------|--------|
| Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) | | Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) |
| Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 | | Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 |
| Theme | Light, Dark, system-follow | | Theme | Light, Dark, system-follow |
| Role | `user`, `admin`, default admin (first-run) | | Role | `user`, `admin`, default admin (first-run) |
| Auth mode | Multi-user, single-user | | Auth mode | Multi-user, single-user |
| Density | Normal + compact desktop | | Density | Normal + compact desktop |
| Network | Online, Slow 3G, offline (PWA shell) | | Network | Online, Slow 3G, offline (PWA shell) |
| Data state | Empty, seeded demo, large/stress, adversarial | | Data state | Empty, seeded demo, large/stress, adversarial |
### 4.3 Accounts to prepare ### 4.3 Accounts to prepare
- `admin`, `user`, a **second** `user` (data-isolation), a single-user-mode instance (separate DB). - `admin`, `user`, a **second** `user` (data-isolation), a single-user-mode instance (separate DB).
- Demo reference: `guest / guest123` (do not run destructive flows on any shared demo server). - Demo reference: `guest / guest123` (do not run destructive flows on any shared demo server).
@ -308,12 +292,12 @@ Full functional pass across reasonable combinations; smoke (B15) across all.
Manual passes prove a button works **once**; they don't stop it regressing next cycle. The Playwright suite is the regression net — it drives real clicks in a real browser, and it's where visual-regression, axe-a11y, and fault-injection (§B14) are wired so they re-run every cycle for free. Manual passes prove a button works **once**; they don't stop it regressing next cycle. The Playwright suite is the regression net — it drives real clicks in a real browser, and it's where visual-regression, axe-a11y, and fault-injection (§B14) are wired so they re-run every cycle for free.
| Command | What it does | | Command | What it does |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | |---------|--------------|
| `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) | | `npm run test:e2e` | run the E2E suite headless (boots the app via `webServer`) |
| `npm run test:e2e:ui` | Playwright UI mode — watch/debug interactively | | `npm run test:e2e:ui` | Playwright UI mode — watch/debug interactively |
| `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) | | `npm run test:e2e:update` | re-baseline visual-regression screenshots (review the diff before committing) |
| `npm run smoke:prod` | **B15 production-build smoke** — builds, boots `node server.cts` (serving dist/), drives the real artifact so the split vendor chunks are validated at runtime | | `npm run smoke:prod` | **B15 production-build smoke** — builds, boots `node server.js` (dist/), drives the real artifact so the split vendor chunks are validated at runtime |
- **Setup (one-time):** `npm install` then `npx playwright install chromium`. Config: `playwright.config.js`; specs in `e2e/`. - **Setup (one-time):** `npm install` then `npx playwright install chromium`. Config: `playwright.config.js`; specs in `e2e/`.
- **Scope:** the suite is a **thin critical-path smoke**, not a replacement for the manual playbooks — it locks the happy paths (login → pay bill → skip → note → reconcile), the primitive state matrix, per-page axe scans, and page screenshots. Grow it whenever a manual pass finds a UI regression that a click-test could have caught. - **Scope:** the suite is a **thin critical-path smoke**, not a replacement for the manual playbooks — it locks the happy paths (login → pay bill → skip → note → reconcile), the primitive state matrix, per-page axe scans, and page screenshots. Grow it whenever a manual pass finds a UI regression that a click-test could have caught.
@ -365,83 +349,72 @@ Run on **every** page during its batch — don't assume a shared component behav
Each batch below is the detailed script for the matching row in [§1](#1-batch-plan--progress-tracker). Apply [§6](#6-cross-cutting-checks-every-page) throughout. Each batch below is the detailed script for the matching row in [§1](#1-batch-plan--progress-tracker). Apply [§6](#6-cross-cutting-checks-every-page) throughout.
### B0 — Baseline, tooling & coverage recon ### B0 — Baseline, tooling & coverage recon
**Run FIRST in every cycle.** This is where the plan re-syncs with reality — new **Run FIRST in every cycle.** This is where the plan re-syncs with reality — new
pages, routes, endpoints, or features added since the last cycle get discovered pages, routes, endpoints, or features added since the last cycle get discovered
and folded in **before** testing, so coverage never silently rots. and folded in **before** testing, so coverage never silently rots.
**Tooling baseline** **Tooling baseline**
- [ ] `npm run ci` — record any failing server/client test or build error as a finding (S1/S2). - [ ] `npm run ci` — record any failing server/client test or build error as a finding (S1/S2).
- [ ] `npm run check` — server syntax + build clean. - [ ] `npm run check` — server syntax + build clean.
- [ ] App boots via `npm run dev` **and** production `npm start`; note startup warnings. - [ ] App boots via `npm run dev` **and** production `npm start`; note startup warnings.
- [ ] Load the app; browser console + server logs clean on first load and first navigation. - [ ] Load the app; browser console + server logs clean on first load and first navigation.
- [ ] Confirm which auth mode / seed state the DB is in; snapshot a backup before proceeding. - [ ] Confirm which auth mode / seed state the DB is in; snapshot a backup before proceeding.
**Coverage recon — enumerate the _actual_ product and diff it against this plan.** **Coverage recon — enumerate the *actual* product and diff it against this plan.**
Run these, then compare the output to the batch playbooks (§7) and the [route map](#appendix-c--page--route--api-quick-map): Run these, then compare the output to the batch playbooks (§7) and the [route map](#appendix-c--page--route--api-quick-map):
- [ ] **Client routes**`grep -nE "<Route" client/App.tsx` — every path present here must appear in a batch playbook and Appendix C. *(The whole client is now TypeScript — `find client -name '*.jsx'` returns 0 — so recon greps target `.tsx`/`.ts`; `npm run typecheck` is a guard alongside build/lint/tests.)*
- [ ] **Client routes**`grep -nE "<Route" client/App.tsx` — every path present here must appear in a batch playbook and Appendix C. _(The whole client is now TypeScript — `find client -name '*.jsx'` returns 0 — so recon greps target `.tsx`/`.ts`; `npm run typecheck` is a guard alongside build/lint/tests.)_
- [ ] **Pages**`ls client/pages/` — every page has an owning batch. - [ ] **Pages**`ls client/pages/` — every page has an owning batch.
- [ ] **Sidebar / nav entries**`grep -nE "to:|label:|Only" client/components/layout/Sidebar.tsx` — new nav links (incl. conditional ones like `simplefinOnly`) are covered. - [ ] **Sidebar / nav entries**`grep -nE "to:|label:|Only" client/components/layout/Sidebar.tsx` — new nav links (incl. conditional ones like `simplefinOnly`) are covered.
- [ ] **API route mounts**`grep -nE "require\('\./routes/" server.cts` (mounts are multi-line; grepping `app.use\('/api` alone misses ~half) — every mounted route group is in B13's list and mapped in Appendix C. - [ ] **API route mounts**`grep -nE "app.use\('/api" server.js` — every mounted route group is in B13's list and mapped in Appendix C.
- [ ] **Services & components**`ls services/` and `ls client/components/**/` — new service/component families have a home in a playbook. - [ ] **Services & components**`ls services/` and `ls client/components/**/` — new service/component families have a home in a playbook.
- [ ] **UI primitives**`ls client/components/ui/` — every shared primitive is covered by the [B-UI](#b-ui--design-system-primitives) playbook; a new primitive gets a row there. - [ ] **UI primitives**`ls client/components/ui/` — every shared primitive is covered by the [B-UI](#b-ui--design-system-primitives) playbook; a new primitive gets a row there.
- [ ] **Middleware & workers**`ls middleware/ workers/` (+ `services/*Worker*`, `*Scheduler*`) — each is covered (csrf/rateLimiter/securityHeaders/requireAuth → B13; dailyWorker/bankSyncWorker/backupScheduler → B10). - [ ] **Middleware & workers**`ls middleware/ workers/` (+ `services/*Worker*`, `*Scheduler*`) — each is covered (csrf/rateLimiter/securityHeaders/requireAuth → B13; dailyWorker/bankSyncWorker/backupScheduler → B10).
- [ ] **Migrations & deploy** — new migrations (`db/database.cts` + `db/migrations/versionedMigrations.cts`/`legacyReconcileMigrations.cts`), `Dockerfile`/`docker-entrypoint.sh` changes, and `encryptionService`/`updateCheckService` behavior are covered by [B16](#b16--migrations-secrets--deployment). - [ ] **Migrations & deploy** — new `db/database.js` migrations, `Dockerfile`/`docker-entrypoint.sh` changes, and `encryptionService`/`updateCheckService` behavior are covered by [B16](#b16--migrations-secrets--deployment).
- [ ] **Interactive-control census (makes "every button tested" _provable_)** — for each page, enumerate every button, link, toggle/switch, checkbox, select, text/number/date/file input, tab, menu, and filter control, and record it in a per-page control checklist (template: [Appendix E](#appendix-e--per-page-control-census)). A control that isn't on a checklist hasn't been tested — the census is the completeness guarantee the batch playbooks alone don't give you. Quick starting inventory: `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages client/components` and `grep -rn "onClick=" client/pages/<Page>.tsx`. - [ ] **Interactive-control census (makes "every button tested" *provable*)** — for each page, enumerate every button, link, toggle/switch, checkbox, select, text/number/date/file input, tab, menu, and filter control, and record it in a per-page control checklist (template: [Appendix E](#appendix-e--per-page-control-census)). A control that isn't on a checklist hasn't been tested — the census is the completeness guarantee the batch playbooks alone don't give you. Quick starting inventory: `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages client/components` and `grep -rn "onClick=" client/pages/<Page>.tsx`.
- [ ] **Feature flags / conditional surfaces** — search for `Only`, `enabled`, `featureFlag`, env gates that hide/show pages; ensure each state is tested. - [ ] **Feature flags / conditional surfaces** — search for `Only`, `enabled`, `featureFlag`, env gates that hide/show pages; ensure each state is tested.
- [ ] **What changed since last cycle** — skim `git log`/`HISTORY.md` since the previous cycle's commit (see [Cycle Log](#11-qa-cycle-log)) for new features/pages. - [ ] **What changed since last cycle** — skim `git log`/`HISTORY.md` since the previous cycle's commit (see [Cycle Log](#11-qa-cycle-log)) for new features/pages.
- [ ] **Dependency freshness (added 2026-07-10)** — run `npm outdated` and `npm audit` (full, not just CI's `--audit-level=critical` gate). Log every **high+ vulnerability** and every **major-version lag on a security-relevant dep** (auth, crypto, parsers of untrusted input: `xlsx`, `nodemailer`, `openid-client`, `express`, `better-sqlite3`) as a finding. CI passing ≠ deps healthy.
- [ ] **Dead-code scan (added 2026-07-10)** — run `npx knip`; every new unused file/export/dependency is an IMP-CODE candidate. Also grep for **API wrappers with no client callers** (`client/api.ts` methods never referenced in `client/**/*.tsx`) — that's how the WebAuthn ghost feature (QA-B1-01) hid for 4 cycles: the server surface existed, tests touched it, but no user could ever reach it.
- [ ] **Type-check debt census (added 2026-07-10)**`grep -rl "@ts-nocheck" routes services db workers middleware utils server.cts | wc -l` and record the count in the Cycle Log. It must be **≤ last cycle's** (baseline 2026-07-10: **38**, incl. all 29 routes — see QA-B0-04). A green `typecheck:server` means nothing for files on this list.
- [ ] **Version coherence (added 2026-07-10)**`package.json` version == top `HISTORY.md` release heading (`/api/version`, the release-notes badge and `updateCheckService` all read package.json — see QA-B0-03).
**Update the plan (do this now, not later)** — for anything the recon surfaced that isn't already covered: **Update the plan (do this now, not later)** — for anything the recon surfaced that isn't already covered:
- [ ] Add it to the relevant batch playbook (or create a new batch and a row in the [§1 table](#1-batch-plan--progress-tracker)). - [ ] Add it to the relevant batch playbook (or create a new batch and a row in the [§1 table](#1-batch-plan--progress-tracker)).
- [ ] Add/adjust its entry in [Appendix C](#appendix-c--page--route--api-quick-map). - [ ] Add/adjust its entry in [Appendix C](#appendix-c--page--route--api-quick-map).
- [ ] Note the plan update in the [Cycle Log](#11-qa-cycle-log) row for this cycle. - [ ] Note the plan update in the [Cycle Log](#11-qa-cycle-log) row for this cycle.
- [ ] If a whole surface is _missing_ from the product that the plan expected (page removed/renamed), reconcile the plan too — don't test ghosts. - [ ] If a whole surface is *missing* from the product that the plan expected (page removed/renamed), reconcile the plan too — don't test ghosts.
### B-UI — Design-system primitives ### B-UI — Design-system primitives
**Test each shared control once, thoroughly, in isolation — a bug here breaks every page at once.** Drive them wherever they're already mounted (or a scratch page); run each against the [per-control state matrix](#6-cross-cutting-checks-every-page) × light/dark × keyboard-only. One finding row per primitive. **Test each shared control once, thoroughly, in isolation — a bug here breaks every page at once.** Drive them wherever they're already mounted (or a scratch page); run each against the [per-control state matrix](#6-cross-cutting-checks-every-page) × light/dark × keyboard-only. One finding row per primitive.
| Primitive (`client/components/ui/`) | Must verify | | Primitive (`client/components/ui/`) | Must verify |
| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | |---|---|
| `button.tsx` | every variant (default/destructive/outline/ghost/link) + size; **disabled truly blocks click**; loading state; focus ring; Enter/Space activate | | `button.tsx` | every variant (default/destructive/outline/ghost/link) + size; **disabled truly blocks click**; loading state; focus ring; Enter/Space activate |
| `input.tsx` | text/number/password/date/search/file types; placeholder; disabled/read-only; error styling; paste/autofill; number-input rules above | | `input.tsx` | text/number/password/date/search/file types; placeholder; disabled/read-only; error styling; paste/autofill; number-input rules above |
| `select.tsx` (Radix) | opens by mouse **and** keyboard; type-ahead; long lists scroll; onChange fires in **Firefox+Safari**; disabled options; value persists; Esc closes | | `select.tsx` (Radix) | opens by mouse **and** keyboard; type-ahead; long lists scroll; onChange fires in **Firefox+Safari**; disabled options; value persists; Esc closes |
| `checkbox.tsx` / `switch.tsx` | toggles by click **and** Space; indeterminate (if used); disabled; label click toggles; controlled value round-trips | | `checkbox.tsx` / `switch.tsx` | toggles by click **and** Space; indeterminate (if used); disabled; label click toggles; controlled value round-trips |
| `dialog.tsx` / `alert-dialog.tsx` / `confirm-dialog.tsx` / `input-dialog.tsx` | open/close; **focus trap + restore**; Esc closes; overlay click behaves; **Cancel actually cancels (no side effect)**; Confirm fires once; scroll-lock releases | | `dialog.tsx` / `alert-dialog.tsx` / `confirm-dialog.tsx` / `input-dialog.tsx` | open/close; **focus trap + restore**; Esc closes; overlay click behaves; **Cancel actually cancels (no side effect)**; Confirm fires once; scroll-lock releases |
| `dropdown-menu.tsx` | keyboard arrow nav; Esc; submenu; disabled items; click-outside closes; no clipping at viewport edge | | `dropdown-menu.tsx` | keyboard arrow nav; Esc; submenu; disabled items; click-outside closes; no clipping at viewport edge |
| `tabs.tsx` | arrow-key nav; active state; content swaps; deep-link/refresh keeps tab (if applicable) | | `tabs.tsx` | arrow-key nav; active state; content swaps; deep-link/refresh keeps tab (if applicable) |
| `tooltip.tsx` | hover **and** keyboard-focus show it; dismiss on blur; touch behavior; not a11y-only info trap | | `tooltip.tsx` | hover **and** keyboard-focus show it; dismiss on blur; touch behavior; not a11y-only info trap |
| `table.tsx` | header/zebra/hover; horizontal scroll on narrow viewport (no page h-scroll); empty state | | `table.tsx` | header/zebra/hover; horizontal scroll on narrow viewport (no page h-scroll); empty state |
| `collapsible.tsx` | expand/collapse animation; state persists; keyboard operable | | `collapsible.tsx` | expand/collapse animation; state persists; keyboard operable |
| `sonner.tsx` (toast) | success/error/loading; **stack + dismiss**; auto-dismiss timing; doesn't cover primary actions; announced to SR | | `sonner.tsx` (toast) | success/error/loading; **stack + dismiss**; auto-dismiss timing; doesn't cover primary actions; announced to SR |
| `save-status.tsx` | idle/saving/saved/error transitions reflect real autosave (`useAutoSave.test.ts`) | | `save-status.tsx` | idle/saving/saved/error transitions reflect real autosave (`useAutoSave.test.ts`) |
| `Skeleton.tsx` | matches final layout (no jump); no infinite skeleton on error | | `Skeleton.tsx` | matches final layout (no jump); no infinite skeleton on error |
| `badge.tsx` / `card.tsx` / `separator.tsx` / `label.tsx` | contrast in dark mode; label `htmlFor` focuses its control; no overflow on long text | | `badge.tsx` / `card.tsx` / `separator.tsx` / `label.tsx` | contrast in dark mode; label `htmlFor` focuses its control; no overflow on long text |
| `theme-toggle.tsx` | light↔dark↔system; applied **before first paint** (no flash); persists across reload | | `theme-toggle.tsx` | light↔dark↔system; applied **before first paint** (no flash); persists across reload |
- [ ] Every primitive above passes its row in light **and** dark, keyboard-only, at mobile width. - [ ] Every primitive above passes its row in light **and** dark, keyboard-only, at mobile width.
- [ ] Axe scan (see B14) on a page densely using primitives → zero critical violations. - [ ] Axe scan (see B14) on a page densely using primitives → zero critical violations.
### B1 — Auth & authorization ### B1 — Auth & authorization
- [ ] **Password:** valid login → correct landing (Tracker for `user`, `/admin` for default admin); wrong password → clear error, no user-enumeration timing/message difference; logout clears session; expired session redirects and preserves `state.from`; session persists across refresh. - [ ] **Password:** valid login → correct landing (Tracker for `user`, `/admin` for default admin); wrong password → clear error, no user-enumeration timing/message difference; logout clears session; expired session redirects and preserves `state.from`; session persists across refresh.
- [ ] **Rate limiting:** repeated failed logins throttled (`loginLimiter`/`loginUsernameLimiter`), clear message, resets. - [ ] **Rate limiting:** repeated failed logins throttled (`loginLimiter`/`loginUsernameLimiter`), clear message, resets.
- [ ] **TOTP:** enroll (QR + secret), code accepted, backup codes work once, login prompts for TOTP, wrong code rejected+throttled, disable requires re-auth. - [ ] **TOTP:** enroll (QR + secret), code accepted, backup codes work once, login prompts for TOTP, wrong code rejected+throttled, disable requires re-auth.
- [ ] **WebAuthn:** **currently untestable — no UI exists** (QA-B1-01: backend + `api.ts` wrappers only, no component calls them). Blocked until the passkey UI ships or the surface is removed. When it ships: register/login/remove passkey in Chrome, Firefox, Safari; password fallback works. - [ ] **WebAuthn:** register/login/remove passkey in Chrome, Firefox, Safari; password fallback works.
- [ ] **OIDC/Authentik:** SSO flow creates/links account; admin config errors surface cleanly; `oidcLimiter` throttles. - [ ] **OIDC/Authentik:** SSO flow creates/links account; admin config errors surface cleanly; `oidcLimiter` throttles.
- [ ] **Roles/guards:** `user` blocked from `/admin*`, `/status` (redirect) and admin APIs (403); default admin forced to `/admin`; single-user bypass correct but admin surfaces still protected; unauth API → 401. - [ ] **Roles/guards:** `user` blocked from `/admin*`, `/status` (redirect) and admin APIs (403); default admin forced to `/admin`; single-user bypass correct but admin surfaces still protected; unauth API → 401.
- [ ] **Data isolation (critical):** user A cannot read/modify user B's bills, payments, transactions, categories, snowball plans — test by ID enumeration on the API. - [ ] **Data isolation (critical):** user A cannot read/modify user B's bills, payments, transactions, categories, snowball plans — test by ID enumeration on the API.
- [ ] **CSRF:** state-changing request without a valid token → rejected. - [ ] **CSRF:** state-changing request without a valid token → rejected.
### B2 — Tracker (`/`) ### B2 — Tracker (`/`)
- [ ] Month nav (prev/next/jump), current month highlighted, data reloads per month. - [ ] Month nav (prev/next/jump), current month highlighted, data reloads per month.
- [ ] Bills land in correct `114` / `1531` bucket by due date; pin-due sorting works. - [ ] Bills land in correct `114` / `1531` bucket by due date; pin-due sorting works.
- [ ] Quick pay marks paid + updates balance cards/progress; undo works; no double-count. - [ ] Quick pay marks paid + updates balance cards/progress; undo works; no double-count.
@ -455,7 +428,6 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
- [ ] Editable cells autosave; Esc cancels; invalid input handled. Mobile rows equal desktop actions. Compact mode intact. - [ ] Editable cells autosave; Esc cancels; invalid input handled. Mobile rows equal desktop actions. Compact mode intact.
### B3 — Bills (`/bills`) ### B3 — Bills (`/bills`)
- [ ] Create with all fields (name, amount, due date, category, schedule, account, autopay, active range). - [ ] Create with all fields (name, amount, due date, category, schedule, account, autopay, active range).
- [ ] Edit propagates to Tracker/Summary/Calendar/Analytics; delete confirms + handles orphan payments/history. - [ ] Edit propagates to Tracker/Summary/Calendar/Analytics; delete confirms + handles orphan payments/history.
- [ ] Custom schedules (weekly/biweekly/monthly/quarterly/annual/custom): next-due & occurrences correct across month/year boundaries. - [ ] Custom schedules (weekly/biweekly/monthly/quarterly/annual/custom): next-due & occurrences correct across month/year boundaries.
@ -464,52 +436,44 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
- [ ] BillModal open/close, validation, cancel discards unsaved changes. - [ ] BillModal open/close, validation, cancel discards unsaved changes.
### B4 — Subscriptions & Categories ### B4 — Subscriptions & Categories
- [ ] Subscriptions: add/edit/delete, active/cancelled, renewal & annual→monthly normalization; totals feed Tracker/Summary/Analytics. - [ ] Subscriptions: add/edit/delete, active/cancelled, renewal & annual→monthly normalization; totals feed Tracker/Summary/Analytics.
- [ ] Catalog: browse/search, add-from-catalog pre-fills. - [ ] Catalog: browse/search, add-from-catalog pre-fills.
- [ ] Categories: create/edit/delete (in-use handled: reassign/prevent); groups create/assign/reorder (`categoryGroups`/`categoryReorder` tests); colors/icons consistent on Tracker/Spending/Analytics. - [ ] Categories: create/edit/delete (in-use handled: reassign/prevent); groups create/assign/reorder (`categoryGroups`/`categoryReorder` tests); colors/icons consistent on Tracker/Spending/Analytics.
### B5 — Reporting reconciliation ### B5 — Reporting reconciliation
- [ ] Summary totals (paid/unpaid/overdue/remaining) reconcile with Tracker for the same month; income breakdown modal matches. - [ ] Summary totals (paid/unpaid/overdue/remaining) reconcile with Tracker for the same month; income breakdown modal matches.
- [ ] Calendar plots bills/payments on correct days (**timezone**: a bill due on the 1st must not render on the 31st); day totals correct. - [ ] Calendar plots bills/payments on correct days (**timezone**: a bill due on the 1st must not render on the 31st); day totals correct.
- [ ] Analytics charts render with data AND empty (no broken SVG/`NaN` axes); period selectors update all charts; figures reconcile with Summary/Tracker; large dataset perf OK. - [ ] Analytics charts render with data AND empty (no broken SVG/`NaN` axes); period selectors update all charts; figures reconcile with Summary/Tracker; large dataset perf OK.
- [ ] Health indicators compute from real data, no crash on empty; recommendations sane. - [ ] Health indicators compute from real data, no crash on empty; recommendations sane.
### B6 — Spending (`/spending`) ### B6 — Spending (`/spending`)
- [ ] Category-group view assigned/spent/available math correct; 3-month averages correct. - [ ] Category-group view assigned/spent/available math correct; 3-month averages correct.
- [ ] Cover-overspending reallocates funds correctly and is reversible. - [ ] Cover-overspending reallocates funds correctly and is reversible.
- [ ] Safe-to-spend matches Tracker (`safeToSpend.test.js`); month nav; empty/partial months handled. - [ ] Safe-to-spend matches Tracker (`safeToSpend.test.js`); month nav; empty/partial months handled.
### B7 — Debt planning (`/snowball`, `/payoff`) ### B7 — Debt planning (`/snowball`, `/payoff`)
- [ ] Add debts (balance/APR/min); snowball vs avalanche ordering correct. - [ ] Add debts (balance/APR/min); snowball vs avalanche ordering correct.
- [ ] Projection + amortization vs a **hand-calculated** example; APR=0 and already-paid debts correct. - [ ] Projection + amortization vs a **hand-calculated** example; APR=0 and already-paid debts correct.
- [ ] Extra-payment/budget updates payoff date + total interest; chart renders; plan history saves/restores; status banner accurate. - [ ] Extra-payment/budget updates payoff date + total interest; chart renders; plan history saves/restores; status banner accurate.
- [ ] Edge: single debt, many debts, `$0` debt, negative/absurd inputs rejected. - [ ] Edge: single debt, many debts, `$0` debt, negative/absurd inputs rejected.
### B8 — Banking (`/bank-transactions`) ### B8 — Banking (`/bank-transactions`)
- [ ] Ledger loads/virtualizes/filters (date/account/amount/merchant/status). - [ ] Ledger loads/virtualizes/filters (date/account/amount/merchant/status).
- [ ] Transaction matching (match/unmatch), auto-match review approve/reject, no double-match (`transactionMatchService.test.js`). - [ ] Transaction matching (match/unmatch), auto-match review approve/reject, no double-match (`transactionMatchService.test.js`).
- [ ] Merchant/store matching rules + confidence/duplicates; advisory non-bill filter flags/hides with override. - [ ] Merchant/store matching rules + confidence/duplicates; advisory non-bill filter flags/hides with override.
- [ ] Matched payments reflect on Tracker/ledger without double-counting; category picker persists. - [ ] Matched payments reflect on Tracker/ledger without double-counting; category picker persists.
### B9 — Data lifecycle (`/data`) ### B9 — Data lifecycle (`/data`)
- [ ] Imports: spreadsheet (XLSX/CSV) map/preview/commit, malformed rejected, dup/partial handled; transaction CSV (`csvTransactionImportService.test.js`) dedupe + parsing; SQLite user import version-checked + confirms overwrite; seed demo data safe; import history lists + rollback. - [ ] Imports: spreadsheet (XLSX/CSV) map/preview/commit, malformed rejected, dup/partial handled; transaction CSV (`csvTransactionImportService.test.js`) dedupe + parsing; SQLite user import version-checked + confirms overwrite; seed demo data safe; import history lists + rollback.
- [ ] Exports: download SQLite **round-trips** (export → fresh account → import → matches); Excel export opens uncorrupted; ICS calendar feed valid in a client AND properly **token-gated** (route mounts before auth — verify not open). - [ ] Exports: download SQLite **round-trips** (export → fresh account → import → matches); Excel export opens uncorrupted; ICS calendar feed valid in a client AND properly **token-gated** (route mounts before auth — verify not open).
- [ ] Backups: manual + scheduled restorable on a scratch instance; permissions not world-readable; old backups pruned (`backupAndCleanup.test.js`). - [ ] Backups: manual + scheduled restorable on a scratch instance; permissions not world-readable; old backups pruned (`backupAndCleanup.test.js`).
### B10 — Notifications & workers ### B10 — Notifications & workers
- [ ] Each channel (email/SMTP, ntfy, Gotify, Discord, Telegram): test message delivers; bad token/URL → clear error, logged, no secret leak. - [ ] Each channel (email/SMTP, ntfy, Gotify, Discord, Telegram): test message delivers; bad token/URL → clear error, logged, no secret leak.
- [ ] Reminders fire at configured lead time for upcoming/overdue; no duplicates; paid/skipped excluded; respects per-user prefs. - [ ] Reminders fire at configured lead time for upcoming/overdue; no duplicates; paid/skipped excluded; respects per-user prefs.
- [ ] Workers: `dailyWorker`, `bankSyncWorker` (interval + guardrails), `backupScheduler` run on schedule; errors caught/logged, don't crash server, next run unblocked. - [ ] Workers: `dailyWorker`, `bankSyncWorker` (interval + guardrails), `backupScheduler` run on schedule; errors caught/logged, don't crash server, next run unblocked.
### B11 — Admin panel (`/admin`) ### B11 — Admin panel (`/admin`)
- [ ] Onboarding wizard completes without a broken state. - [ ] Onboarding wizard completes without a broken state.
- [ ] Users table: add/edit-role/reset-pw/disable/delete; **cannot remove the last admin**. - [ ] Users table: add/edit-role/reset-pw/disable/delete; **cannot remove the last admin**.
- [ ] Login mode switch single↔multi verified live, no lockout; auth-methods enable/disable + bad config surfaced. - [ ] Login mode switch single↔multi verified live, no lockout; auth-methods enable/disable + bad config surfaced.
@ -518,29 +482,23 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
- [ ] Privacy admin edits reflect on public `/privacy`; system status metrics/versions/jobs accurate (`statusService.test.js`); admin actions rate-limited + audited (`auditService` — spot-check log). - [ ] Privacy admin edits reflect on public `/privacy`; system status metrics/versions/jobs accurate (`statusService.test.js`); admin actions rate-limited + audited (`auditService` — spot-check log).
### B12 — Settings, Profile & global UI ### B12 — Settings, Profile & global UI
- [ ] Settings: theme (light/dark/system) persists; notification prefs save + reflect in B10; display/density/period/search-panel prefs persist; invalid rejected. - [ ] Settings: theme (light/dark/system) persists; notification prefs save + reflect in B10; display/density/period/search-panel prefs persist; invalid rejected.
- [ ] Profile: change password (current required, invalidates sessions), manage 2FA/passkeys, sessions revoke (`profileRoute.test.js`). - [ ] Profile: change password (current required, invalidates sessions), manage 2FA/passkeys, sessions revoke (`profileRoute.test.js`).
- [ ] Static: About (public + admin, version shown), Privacy, Release Notes (dialog once per `user`, dismiss persists), Roadmap (admin), NotFound friendly + way home. - [ ] Static: About (public + admin, version shown), Privacy, Release Notes (dialog once per `user`, dismiss persists), Roadmap (admin), NotFound friendly + way home.
- [ ] Global: command palette (`Ctrl+K`) search/keyboard/Esc, hidden for default admin; sidebar collapse/expand + mobile overlay (check overflow issue in `docs/UI_IMPROVEMENTS.md`); toasts stack/dismiss; page transitions no flash/double-fetch; theme applied before first paint. - [ ] Global: command palette (`Ctrl+K`) search/keyboard/Esc, hidden for default admin; sidebar collapse/expand + mobile overlay (check overflow issue in `docs/UI_IMPROVEMENTS.md`); toasts stack/dismiss; page transitions no flash/double-fetch; theme applied before first paint.
### B13 — API / backend direct ### B13 — API / backend direct
Route groups: `auth`, `auth/oidc`, `admin`, `tracker`, `bills`, `subscriptions`, `payments`, `data-sources`, `transactions`, `matches`, `categories`, `settings`, `user`, `calendar`, `summary`, `monthly-starting-amounts`, `analytics`, `spending`, `snowball`, `notifications`, `status`, `about`, `about-admin`, `privacy`, `version`, `profile`, `export`, `import`/`imports`. Route groups: `auth`, `auth/oidc`, `admin`, `tracker`, `bills`, `subscriptions`, `payments`, `data-sources`, `transactions`, `matches`, `categories`, `settings`, `user`, `calendar`, `summary`, `monthly-starting-amounts`, `analytics`, `spending`, `snowball`, `notifications`, `status`, `about`, `about-admin`, `privacy`, `version`, `profile`, `export`, `import`/`imports`.
- [ ] Auth: unauth → 401, wrong role → 403, right role → 200. - [ ] Auth: unauth → 401, wrong role → 403, right role → 200.
- [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.cts`). - [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.js`).
- [ ] Validation: bad/missing body → structured 4xx (`middleware/errorFormatter.cts`, `utils/apiError.cts`), never a raw 500 stack. - [ ] Validation: bad/missing body → structured 4xx (`middleware/errorFormatter.js`, `utils/apiError.js`), never a raw 500 stack.
- [ ] **Error-shape audit (added 2026-07-10):** no route returns raw `err.message` on a 5xx — `grep -rn "err.message" routes` and verify each hit is either a whitelisted user-facing message or converted to `standardizeError`/`ApiError` (QA-B13-02 found 10 leaks).
- [ ] IDOR/isolation: other user's resource by id → 403/404, no leak. - [ ] IDOR/isolation: other user's resource by id → 403/404, no leak.
- [ ] Rate limits: login/admin/export/import/OIDC limiters trigger + reset (`middleware/rateLimiter.cts`). - [ ] Rate limits: login/admin/export/import/OIDC limiters trigger + reset (`middleware/rateLimiter.js`).
- [ ] **Orphan-endpoint sweep (added 2026-07-10):** for each route file, confirm each endpoint has a caller (client `api.ts`, worker, or documented external consumer like the ICS feed). Endpoints with no caller are dead surface to delete (found: auth.cts's duplicate admin block, QA-B17-01) — and note any that are **less protected** than their canonical twin.
- [ ] Money in **integer cents** end-to-end (per `docs/cents-migration-plan.md`); API and DB agree; no float drift. - [ ] Money in **integer cents** end-to-end (per `docs/cents-migration-plan.md`); API and DB agree; no float drift.
- [ ] Idempotency: repeated create doesn't duplicate; concurrent edits resolve sanely. - [ ] Idempotency: repeated create doesn't duplicate; concurrent edits resolve sanely.
- [ ] Consistent error JSON + correct status codes; security headers present (`middleware/securityHeaders.cts`); public routes (`about`/`privacy`/`version`/calendar feed) leak nothing sensitive. - [ ] Consistent error JSON + correct status codes; security headers present (`middleware/securityHeaders.js`); public routes (`about`/`privacy`/`version`/calendar feed) leak nothing sensitive.
### B14 — Non-functional ### B14 — Non-functional
- [ ] **a11y (manual):** keyboard-only reach/operate every control, visible focus, skip-link works; screen-reader labels/roles (Radix `aria-*`); WCAG-AA contrast light+dark; modals trap+restore focus, Esc closes; errors announced not color-only. - [ ] **a11y (manual):** keyboard-only reach/operate every control, visible focus, skip-link works; screen-reader labels/roles (Radix `aria-*`); WCAG-AA contrast light+dark; modals trap+restore focus, Esc closes; errors announced not color-only.
- [ ] **a11y (automated):** run **axe-core** on every page (`@axe-core/playwright`, or `jest-axe` for component-level) — **zero critical/serious** violations; triage moderate. Wire it into the E2E suite so it re-runs every cycle, not just once. - [ ] **a11y (automated):** run **axe-core** on every page (`@axe-core/playwright`, or `jest-axe` for component-level) — **zero critical/serious** violations; triage moderate. Wire it into the E2E suite so it re-runs every cycle, not just once.
- [ ] **Visual regression:** capture a baseline screenshot per page × {desktop, mobile} × {light, dark} (Playwright `toHaveScreenshot`); diff against baseline each cycle. Every non-trivial pixel diff is either an intended change (update the baseline in the same commit) or a finding — never ignore it. This is what makes "every page looks right" repeatable instead of eyeballed. - [ ] **Visual regression:** capture a baseline screenshot per page × {desktop, mobile} × {light, dark} (Playwright `toHaveScreenshot`); diff against baseline each cycle. Every non-trivial pixel diff is either an intended change (update the baseline in the same commit) or a finding — never ignore it. This is what makes "every page looks right" repeatable instead of eyeballed.
@ -552,9 +510,7 @@ Route groups: `auth`, `auth/oidc`, `admin`, `tracker`, `bills`, `subscriptions`,
- [ ] **Timezone/locale:** non-UTC tz + DST boundary — due dates and calendar stay correct. - [ ] **Timezone/locale:** non-UTC tz + DST boundary — due dates and calendar stay correct.
### B15 — Regression & sign-off ### B15 — Regression & sign-off
Run on the **production build** (`npm start`), not dev: Run on the **production build** (`npm start`), not dev:
- [ ] `npm run ci` green. Log in as `user` and `admin`. - [ ] `npm run ci` green. Log in as `user` and `admin`.
- [ ] `npm run test:e2e` green (Playwright smoke + axe + visual-regression baselines match, §4.4). - [ ] `npm run test:e2e` green (Playwright smoke + axe + visual-regression baselines match, §4.4).
- [ ] Tracker: create bill → quick-pay → skip another → add note; reflected on Summary/Calendar/Analytics. - [ ] Tracker: create bill → quick-pay → skip another → add note; reflected on Summary/Calendar/Analytics.
@ -566,112 +522,102 @@ Run on the **production build** (`npm start`), not dev:
- [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria). - [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria).
### B16 — Migrations, secrets & deployment ### B16 — Migrations, secrets & deployment
Added Cycle 1 (previously uncovered). These run on every boot / container start and Added Cycle 1 (previously uncovered). These run on every boot / container start and
touch money columns and at-rest secrets — a bug here corrupts data or leaks/breaks touch money columns and at-rest secrets — a bug here corrupts data or leaks/breaks
secrets silently. secrets silently.
**Migrations** (`db/database.cts` + `db/migrations/*.cts`, `scripts/migrate-db.js`, `schema_migrations`, `rollbackMigration`) **Migrations** (`db/database.js` migration system, `scripts/migrate-db.js`, `schema_migrations`, `rollbackMigration`)
- [ ] **Idempotent:** boot twice on the same DB → second run applies nothing ("Skipping already applied"), no errors, no duplicate rows/columns. - [ ] **Idempotent:** boot twice on the same DB → second run applies nothing ("Skipping already applied"), no errors, no duplicate rows/columns.
- [ ] **Fresh == migrated:** a brand-new DB (schema.sql + all migrations) has the same schema as a DB migrated up from an old version — same tables/columns/indexes, money columns are **integer cents**. - [ ] **Fresh == migrated:** a brand-new DB (schema.sql + all migrations) has the same schema as a DB migrated up from an old version — same tables/columns/indexes, money columns are **integer cents**.
- [ ] **Rollback:** `rollbackMigration` on the latest migration reverts cleanly and re-applying works; partial/failed migration leaves the DB consistent (transactions per migration). - [ ] **Rollback:** `rollbackMigration` on the latest migration reverts cleanly and re-applying works; partial/failed migration leaves the DB consistent (transactions per migration).
- [ ] **Money conversions correct:** v1.03 (dollars→cents) and v1.04 (template JSON) convert exact values, no ×100 drift, run once only. - [ ] **Money conversions correct:** v1.03 (dollars→cents) and v1.04 (template JSON) convert exact values, no ×100 drift, run once only.
- [ ] Migrating a large/real DB doesn't lose or duplicate bills/payments/categories. - [ ] Migrating a large/real DB doesn't lose or duplicate bills/payments/categories.
**Encryption-key lifecycle** (`services/encryptionService.cts`, `TOKEN_ENCRYPTION_KEY`, HKDF v1/v2) **Encryption-key lifecycle** (`services/encryptionService.js`, `TOKEN_ENCRYPTION_KEY`, HKDF v1/v2)
- [ ] **Key present:** secrets (SMTP pw, OIDC secret, push tokens, login IP/UA) encrypt at rest and decrypt correctly. - [ ] **Key present:** secrets (SMTP pw, OIDC secret, push tokens, login IP/UA) encrypt at rest and decrypt correctly.
- [ ] **Key missing:** app boots; secret features degrade gracefully (no crash); confirm secrets are **not** silently stored/served in plaintext. - [ ] **Key missing:** app boots; secret features degrade gracefully (no crash); confirm secrets are **not** silently stored/served in plaintext.
- [ ] **Key rotated/wrong:** old ciphertext fails to decrypt **gracefully** (no crash, no stack leak); `safeDecrypt` fallback path is sane; re-encryption migrations (v0.770.79) behave. - [ ] **Key rotated/wrong:** old ciphertext fails to decrypt **gracefully** (no crash, no stack leak); `safeDecrypt` fallback path is sane; re-encryption migrations (v0.770.79) behave.
- [ ] Encryption key is never committed, logged, or returned in any API response. - [ ] Encryption key is never committed, logged, or returned in any API response.
**Container / deploy** (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `deploy.sh`) **Container / deploy** (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `deploy.sh`)
- [ ] Image **builds**; container **starts**; app reachable; `/api/version` responds. - [ ] Image **builds**; container **starts**; app reachable; `/api/version` responds.
- [ ] Entrypoint: creates `DATA_DIR`/`DB_DIR`/`BACKUP_DIR`, sets **`chmod 700`** (not world-readable), `chown`s to the non-root `bill` user, runs migrations when `RUN_DB_MIGRATIONS=true`. - [ ] Entrypoint: creates `DATA_DIR`/`DB_DIR`/`BACKUP_DIR`, sets **`chmod 700`** (not world-readable), `chown`s to the non-root `bill` user, runs migrations when `RUN_DB_MIGRATIONS=true`.
- [ ] Data **persists** across container restart (mounted volume); DB not re-created. - [ ] Data **persists** across container restart (mounted volume); DB not re-created.
- [ ] Runs as **non-root**; secrets come from env, not baked into the image. - [ ] Runs as **non-root**; secrets come from env, not baked into the image.
**Update check / phone-home** (`services/updateCheckService.cts`) **Update check / phone-home** (`services/updateCheckService.js`)
- [ ] Confirm the external request to `REPO_API_URL` (default `dream.scheller.ltd`) is **disclosed** (privacy page) and **opt-out-able**; it must send no user data, only fetch the latest release; failure/offline degrades silently. - [ ] Confirm the external request to `REPO_API_URL` (default `dream.scheller.ltd`) is **disclosed** (privacy page) and **opt-out-able**; it must send no user data, only fetch the latest release; failure/offline degrades silently.
**Rate-limiter completeness** (`middleware/rateLimiter.cts`) — beyond B13's list **Rate-limiter completeness** (`middleware/rateLimiter.js`) — beyond B13's list
- [ ] `backupOperationLimiter` throttles admin backup/restore/cleanup; `skipRateLimitIfNoUsers` only relaxes limits on a genuinely empty instance (first-run), never afterward. - [ ] `backupOperationLimiter` throttles admin backup/restore/cleanup; `skipRateLimitIfNoUsers` only relaxes limits on a genuinely empty instance (first-run), never afterward.
### B17 — Code health & consolidation (IMP) ### B17 — Code health & consolidation (IMP)
An **improvement** batch: hunt for ways to make the codebase smaller, clearer, and more An **improvement** batch: hunt for ways to make the codebase smaller, clearer, and more
consistent — _without changing behavior_. Every candidate is logged as an `IMP-CODE-*` consistent — *without changing behavior*. Every candidate is logged as an `IMP-CODE-*`
row in §2.1 with a concrete proposal; nothing is refactored silently. Consolidation row in §2.1 with a concrete proposal; nothing is refactored silently. Consolidation
lands only when it's behavior-preserving **and** covered by existing or added tests. lands only when it's behavior-preserving **and** covered by existing or added tests.
- [ ] **Duplication / DRY:** find logic copy-pasted across services/routes/components and - [ ] **Duplication / DRY:** find logic copy-pasted across services/routes/components and
propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.mts` propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.js`
vs inline), the `resolveDueDate` occurrence gate (must stay one implementation), vs inline), the `resolveDueDate` occurrence gate (must stay one implementation),
error-response shaping (`utils/apiError.cts` vs ad-hoc), React data-fetch patterns error-response shaping (`utils/apiError.js` vs ad-hoc), React data-fetch patterns
(repeated `useQuery` + toast + error handling → shared hooks), **duplicate route (repeated `useQuery` + toast + error handling → shared hooks).
handlers across files** (auth.cts vs admin.cts, QA-B17-01).
- [ ] **Dead / unused code:** unused exports, unreachable branches, orphaned files, - [ ] **Dead / unused code:** unused exports, unreachable branches, orphaned files,
commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover
scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first). scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first).
- [ ] **Overlapping modules:** services that do similar work and could merge or share a - [ ] **Overlapping modules:** services that do similar work and could merge or share a
core — e.g. the matching family (`matchSuggestionService`, `transactionMatchService`, core — e.g. the matching family (`matchSuggestionService`, `transactionMatchService`,
`merchantStoreMatchService`), the bank-sync family (`bankSyncService`, `merchantStoreMatchService`), the bank-sync family (`bankSyncService`,
`bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities; `bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities;
propose a consolidation only where it removes real duplication, not just moves it. propose a consolidation only where it removes real duplication, not just moves it.
- [ ] **Oversized / low-cohesion files:** split by concern where it aids navigation - [ ] **Oversized / low-cohesion files:** split by concern where it aids navigation
(`db/database` was split in IMP-CODE-02; current largest: `SubscriptionsPage.tsx` (e.g. `db/database.js` is very large — migrations vs query helpers vs settings could
2,371 ln and `TrackerPage.tsx` 1,943 ln — see IMP-CODE-05). Propose the seams; be separate modules). Propose the seams; don't split for its own sake.
don't split for its own sake.
- [ ] **One canonical path per concern:** cents handling, date/tz, CSRF, error shape, - [ ] **One canonical path per concern:** cents handling, date/tz, CSRF, error shape,
pagination — confirm there's a single blessed way and flag divergences. pagination — confirm there's a single blessed way and flag divergences.
- [ ] **Consistency:** naming, file layout, async patterns, import ordering; a lint rule - [ ] **Consistency:** naming, file layout, async patterns, import ordering; a lint rule
that would prevent a class of the bugs found in earlier batches is itself an IMP. that would prevent a class of the bugs found in earlier batches is itself an IMP.
- [ ] **Test/infra dedupe:** repeated test setup → shared fixtures/helpers; flag coverage - [ ] **Test/infra dedupe:** repeated test setup → shared fixtures/helpers; flag coverage
gaps a consolidation would risk. gaps a consolidation would risk.
### B18 — UX & information architecture / menus (IMP) ### B18 — UX & information architecture / menus (IMP)
An **improvement** batch focused on the person using the app: is every feature An **improvement** batch focused on the person using the app: is every feature
_discoverable_, is every core flow _smooth_, and does every action live _where a user *discoverable*, is every core flow *smooth*, and does every action live *where a user
would look for it_? Candidates are logged as `IMP-UX-*` or `IMP-IA-*` in §2.1 with a would look for it*? Candidates are logged as `IMP-UX-*` or `IMP-IA-*` in §2.1 with a
concrete before/after proposal. Walk the app as a real user (both `user` and `admin`, concrete before/after proposal. Walk the app as a real user (both `user` and `admin`,
desktop and mobile, light and dark), not just as a tester. desktop and mobile, light and dark), not just as a tester.
**Information architecture & menus** **Information architecture & menus**
- [ ] **Discoverability:** is any feature buried, orphaned, or reachable only by typing a - [ ] **Discoverability:** is any feature buried, orphaned, or reachable only by typing a
URL? Everything should be reachable from the nav, a menu, or a clear in-page entry. URL? Everything should be reachable from the nav, a menu, or a clear in-page entry.
- [ ] **Navigation structure:** sidebar/nav grouping is logical; related pages sit - [ ] **Navigation structure:** sidebar/nav grouping is logical; related pages sit
together; admin vs user separation is clear; active state + page titles are correct. together; admin vs user separation is clear; active state + page titles are correct.
- [ ] **Menus where they belong:** actions that today are loose buttons or hidden should - [ ] **Menus where they belong:** actions that today are loose buttons or hidden should
be grouped into sensible menus — overflow (`⋯`) menus on rows/cards, context menus, be grouped into sensible menus — overflow (`⋯`) menus on rows/cards, context menus,
a consolidated **Settings** grouping, an account menu. Put related actions in one menu a consolidated **Settings** grouping, an account menu. Put related actions in one menu
rather than scattering them. Flag anything that would be easier to find as a menu item. rather than scattering them. Flag anything that would be easier to find as a menu item.
- [ ] **Command palette (`Ctrl+K`) coverage:** every page/primary action is reachable; - [ ] **Command palette (`Ctrl+K`) coverage:** every page/primary action is reachable;
no dead entries. no dead entries.
- [ ] **Redundancy:** the same action offered in three places with different labels, or - [ ] **Redundancy:** the same action offered in three places with different labels, or
two pages that do nearly the same thing — propose consolidating. two pages that do nearly the same thing — propose consolidating.
**Experience quality** **Experience quality**
- [ ] **Core-flow friction:** count the clicks for the top tasks (pay a bill, add a bill, - [ ] **Core-flow friction:** count the clicks for the top tasks (pay a bill, add a bill,
connect SimpleFIN, run a sync, import data). Propose shortcuts where a step is wasted. connect SimpleFIN, run a sync, import data). Propose shortcuts where a step is wasted.
- [ ] **States:** every list/page has a clear **empty state with a next-step CTA**, a - [ ] **States:** every list/page has a clear **empty state with a next-step CTA**, a
**loading** state (skeleton/spinner, no layout jump), and a **recoverable error** **loading** state (skeleton/spinner, no layout jump), and a **recoverable error**
state — no dead ends, no silent failures. state — no dead ends, no silent failures.
- [ ] **Feedback & safety:** state changes confirm (toast); destructive actions confirm - [ ] **Feedback & safety:** state changes confirm (toast); destructive actions confirm
and, where feasible, offer undo (bills already soft-delete — surface a restore path); and, where feasible, offer undo (bills already soft-delete — surface a restore path);
long actions show progress. long actions show progress.
- [ ] **Consistency:** primary-action placement, button hierarchy, iconography, - [ ] **Consistency:** primary-action placement, button hierarchy, iconography,
terminology, and confirmation patterns are consistent across pages. terminology, and confirmation patterns are consistent across pages.
- [ ] **Mobile parity:** every action available on desktop is reachable on mobile; touch - [ ] **Mobile parity:** every action available on desktop is reachable on mobile; touch
targets adequate; menus/overflow work on touch. targets adequate; menus/overflow work on touch.
- [ ] **Onboarding:** first-run and empty-account guidance explains the next step; advanced - [ ] **Onboarding:** first-run and empty-account guidance explains the next step; advanced
features (SimpleFIN, debt planning, backups) have a short in-context explanation. features (SimpleFIN, debt planning, backups) have a short in-context explanation.
--- ---
@ -679,18 +625,17 @@ desktop and mobile, light and dark), not just as a tester.
### Appendix A — Severity definitions ### Appendix A — Severity definitions
| Level | Definition | | Level | Definition |
| --------------------- | -------------------------------------------------------------------------------------------- | |-------|------------|
| **S1 Critical** | Data loss/corruption, security hole, crash/blank page, wrong money math, cannot log in/save. | | **S1 Critical** | Data loss/corruption, security hole, crash/blank page, wrong money math, cannot log in/save. |
| **S2 Major** | Feature broken/unusable, wrong results, broken navigation, unhandled error. | | **S2 Major** | Feature broken/unusable, wrong results, broken navigation, unhandled error. |
| **S3 Minor** | Works but wrong edge behavior, confusing UX, missing validation message. | | **S3 Minor** | Works but wrong edge behavior, confusing UX, missing validation message. |
| **S4 Cosmetic** | Visual/copy/alignment/dark-mode-contrast, non-blocking. | | **S4 Cosmetic** | Visual/copy/alignment/dark-mode-contrast, non-blocking. |
| **IMP Improvement** | Not a bug; enhancement or polish idea. | | **IMP Improvement** | Not a bug; enhancement or polish idea. |
### Appendix B — Exit / sign-off criteria ### Appendix B — Exit / sign-off criteria
A cycle is release-ready when: **(Cycle 1 — all met ✅)** A cycle is release-ready when: **(Cycle 1 — all met ✅)**
- [x] All batches B0B15 ✅ (Chromium desktop + mobile via the E2E projects; light + dark, `user` + `admin` exercised). Cross-browser Firefox/Safari carried to Cycle 2. - [x] All batches B0B15 ✅ (Chromium desktop + mobile via the E2E projects; light + dark, `user` + `admin` exercised). Cross-browser Firefox/Safari carried to Cycle 2.
- [x] B15 smoke green on the **production build** (`npm run smoke:prod`). - [x] B15 smoke green on the **production build** (`npm run smoke:prod`).
- [x] **Zero open S1/S2** in the Findings Log; S3/S4/IMP all fixed & archived. - [x] **Zero open S1/S2** in the Findings Log; S3/S4/IMP all fixed & archived.
@ -702,46 +647,38 @@ A cycle is release-ready when: **(Cycle 1 — all met ✅)**
### Appendix C — Page ↔ route ↔ API quick map ### Appendix C — Page ↔ route ↔ API quick map
| Page | Route | Primary API | | Page | Route | Primary API |
| ------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | |------|-------|-------------|
| Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` | | Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` |
| Calendar | `/calendar` | `/api/calendar` | | Calendar | `/calendar` | `/api/calendar` |
| Summary | `/summary` | `/api/summary` | | Summary | `/summary` | `/api/summary` |
| Bills | `/bills` | `/api/bills`, `/api/categories`, `/api/matches` | | Bills | `/bills` | `/api/bills`, `/api/categories`, `/api/matches` |
| Subscriptions / Catalog | `/subscriptions`, `/subscriptions/catalog` | `/api/subscriptions` | | Subscriptions / Catalog | `/subscriptions`, `/subscriptions/catalog` | `/api/subscriptions` |
| Categories | `/categories` | `/api/categories` | | Categories | `/categories` | `/api/categories` |
| Health | `/health` | `/api/analytics`, `/api/summary` | | Health | `/health` | `/api/analytics`, `/api/summary` |
| Analytics | `/analytics` | `/api/analytics` | | Analytics | `/analytics` | `/api/analytics` |
| Spending | `/spending` | `/api/spending` | | Spending | `/spending` | `/api/spending` |
| Banking | `/bank-transactions` | `/api/transactions`, `/api/matches`, `/api/data-sources` | | Banking | `/bank-transactions` | `/api/transactions`, `/api/matches`, `/api/data-sources` |
| Snowball / Payoff | `/snowball`, `/payoff` | `/api/snowball` | | Snowball / Payoff | `/snowball`, `/payoff` | `/api/snowball` |
| Settings | `/settings` | `/api/settings`, `/api/notifications` | | Settings | `/settings` | `/api/settings`, `/api/notifications` |
| Profile | `/profile` | `/api/profile`, `/api/user` | | Profile | `/profile` | `/api/profile`, `/api/user` |
| Data | `/data` | `/api/import`, `/api/export`, `/api/data-sources` | | Data | `/data` | `/api/import`, `/api/export`, `/api/data-sources` |
| Admin | `/admin`, `/admin/status` (`/status` → redirect), `/admin/about`, `/admin/roadmap` | `/api/admin`, `/api/status`, `/api/about-admin` | | Admin | `/admin`, `/admin/status` | `/api/admin`, `/api/status`, `/api/about-admin` |
| About / Privacy / Release Notes / Roadmap | `/about`, `/privacy`, `/release-notes`, `/roadmap` (admin-gated) | `/api/about`, `/api/privacy`, `/api/version` | | About / Privacy / Release Notes / Roadmap | `/about`, `/privacy`, `/release-notes`, `/roadmap` | `/api/about`, `/api/privacy`, `/api/version` |
| Calendar feed (no page — external ICS consumers) | — | `/api/calendar/feed.ics?token=…` (token-gated, mounted before auth) |
### Appendix D — Reference docs ### Appendix D — Reference docs
`SECURITY_AUDIT.md` (security depth) · `docs/UI_IMPROVEMENTS.md` (known UI issues) · `docs/cents-migration-plan.md` (money-as-cents) · `docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md` (sync limits) · `docs/CSRF-SPA-Setup.md`, `docs/RATE_LIMITING_ENHANCEMENT.md` (security middleware) · `REVIEW.md`, `DEVELOPMENT_LOG.md`, `roadmap.md`, `FUTURE.md` (context/known gaps) · `HISTORY.md` (changelog / fix archive) · `playwright.config.js` + `e2e/` (automated E2E/visual/a11y harness, §4.4). `SECURITY_AUDIT.md` (security depth) · `docs/UI_IMPROVEMENTS.md` (known UI issues) · `docs/cents-migration-plan.md` (money-as-cents) · `docs/SIMPLEFIN_CONSUMER_GUARDRAILS.md` (sync limits) · `docs/CSRF-SPA-Setup.md`, `docs/RATE_LIMITING_ENHANCEMENT.md` (security middleware) · `REVIEW.md`, `DEVELOPMENT_LOG.md`, `roadmap.md`, `FUTURE.md` (context/known gaps) · `HISTORY.md` (changelog / fix archive) · `playwright.config.js` + `e2e/` (automated E2E/visual/a11y harness, §4.4).
### Appendix E — Per-page control census ### Appendix E — Per-page control census
The completeness ledger behind "every button, textbox, slider is right." Fill one table **per page** during [B0](#b0--baseline-tooling--coverage-recon) and check every control off during that page's batch. A control not listed here is a control not tested. The completeness ledger behind "every button, textbox, slider is right." Fill one table **per page** during [B0](#b0--baseline-tooling--coverage-recon) and check every control off during that page's batch. A control not listed here is a control not tested. Build the starting list with `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages/<Page>.tsx` + `grep -n "onClick=\|<Button\|<Select\|<Switch\|<Checkbox" client/pages/<Page>.tsx`.
> ⚠ **Status (2026-07-10): no census has ever been filled in** — Cycle 1 signed off on
> playbooks alone. Either fill these during Cycle 2's B0, or (preferred) implement the
> automated control-census e2e spec (IMP-CODE-07) and let the diff be the ledger.
> Density for planning: SpendingPage has 28 `onClick` handlers, Tracker 25,
> Subscriptions 23, Bills 22, Banking 18 — the manual version is a multi-day job. Build the starting list with `grep -rnoE "type=[\"'][a-z]+[\"']" client/pages/<Page>.tsx` + `grep -n "onClick=\|<Button\|<Select\|<Switch\|<Checkbox" client/pages/<Page>.tsx`.
**Template** (copy per page): **Template** (copy per page):
| Control | Type | Expected action | States checked (default/focus/disabled/error/loading) | Keyboard | Result | | Control | Type | Expected action | States checked (default/focus/disabled/error/loading) | Keyboard | Result |
| ----------------------- | ------ | ------------------------------------------------------ | ----------------------------------------------------- | --------- | --------------- | |---------|------|-----------------|-------------------------------------------------------|----------|--------|
| _e.g._ Quick-pay button | button | marks bill paid, updates balance cards, undo available | default ✓ · disabled-while-saving ✓ | Enter ✓ | ✅ / finding id | | *e.g.* Quick-pay button | button | marks bill paid, updates balance cards, undo available | default ✓ · disabled-while-saving ✓ | Enter ✓ | ✅ / finding id |
| _e.g._ Amount input | number | per-month override, cents only, no wheel-scroll change | default ✓ · error-on-letters ✓ | Tab/Esc ✓ | ✅ / finding id | | *e.g.* Amount input | number | per-month override, cents only, no wheel-scroll change | default ✓ · error-on-letters ✓ | Tab/Esc ✓ | ✅ / finding id |
**Pages to census** (from `client/pages/`, keep in sync with [Appendix C](#appendix-c--page--route--api-quick-map)): Tracker, Calendar, Summary, Bills, Subscriptions, SubscriptionCatalog, Categories, Health, Analytics, Spending, Snowball, Payoff, BankTransactions, Data, Settings, Profile, Admin, Status, About, Privacy, ReleaseNotes, Roadmap, Login, NotFound — plus the shared **Sidebar/command-palette/header** chrome once. **Pages to census** (from `client/pages/`, keep in sync with [Appendix C](#appendix-c--page--route--api-quick-map)): Tracker, Calendar, Summary, Bills, Subscriptions, SubscriptionCatalog, Categories, Health, Analytics, Spending, Snowball, Payoff, BankTransactions, Data, Settings, Profile, Admin, Status, About, Privacy, ReleaseNotes, Roadmap, Login, NotFound — plus the shared **Sidebar/command-palette/header** chrome once.
</content> </content>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -1,76 +0,0 @@
// WebAuthn passkey flow (QA-B1-01) — driven end-to-end with Playwright's CDP
// virtual authenticator, which retires Cycle 1's "needs a human with a key"
// assumption: enroll on /profile → sign out → password + key sign-in → remove
// the key (restoring the seeded user to password-only for the other specs).
const { test, expect } = require('@playwright/test');
const { E2E_USER, E2E_PASS } = require('./constants');
// Fresh context: this spec exercises the real login flow, not saved state.
test.use({ storageState: { cookies: [], origins: [] } });
test('enroll passkey → sign in with key → remove key', async ({ page }) => {
// Virtual authenticator: an "internal" (platform) CTAP2 key that auto-approves
// presence/verification prompts, so the browser dialog never blocks CI.
const cdp = await page.context().newCDPSession(page);
await cdp.send('WebAuthn.enable');
await cdp.send('WebAuthn.addVirtualAuthenticator', {
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
// 1. Password sign-in.
await page.goto('/login');
await page.locator('#username').fill(E2E_USER);
await page.locator('#password').fill(E2E_PASS);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
await page
.getByRole('button', { name: 'Got it' })
.click({ timeout: 5000 })
.catch(() => {});
// 2. Enroll a key on /profile.
await page.goto('/profile');
const passkeyCard = page.locator('section, div').filter({
has: page.getByRole('heading', { name: /passkeys & security keys/i }),
});
await page.getByRole('button', { name: 'Add key' }).click();
await page.locator('#passkey-name').fill('Virtual E2E Key');
await page.getByRole('button', { name: 'Register key' }).click();
await expect(page.getByText('Security key added.')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Virtual E2E Key')).toBeVisible();
// 3. Sign out (API logout keeps the flow deterministic), then key sign-in.
const csrf = await (await page.request.get('/api/auth/csrf-token')).json();
await page.request.post('/api/auth/logout', { headers: { 'x-csrf-token': csrf.token } });
await page.goto('/login');
await page.locator('#username').fill(E2E_USER);
await page.locator('#password').fill(E2E_PASS);
await page.getByRole('button', { name: /sign in/i }).click();
// The security-key step auto-fires startAuthentication; the virtual
// authenticator approves it and we land authenticated.
await page.waitForURL((url) => !url.pathname.startsWith('/login'), { timeout: 15_000 });
await page
.getByRole('button', { name: 'Got it' })
.click({ timeout: 5000 })
.catch(() => {});
await expect(page.getByRole('button', { name: 'Add Bill' })).toBeVisible({ timeout: 10_000 });
// 4. Remove the key (password-gated) so the seeded user is password-only
// again for every other spec in the run.
await page.goto('/profile');
await passkeyCard.getByRole('button', { name: 'Remove', exact: true }).first().click();
await page.locator('#passkey-remove-password').fill(E2E_PASS);
await page.getByRole('button', { name: 'Remove', exact: true }).last().click();
await expect(page.getByText('Security key removed.')).toBeVisible({ timeout: 10_000 });
// Both the TOTP and the passkey card now read "Not configured" (the seeded
// user has neither) — count is the unambiguous assertion.
await expect(page.getByText('Not configured')).toHaveCount(2);
});

View File

@ -51,39 +51,6 @@ export default [
'no-irregular-whitespace': 'warn', // CSV/import parsers handle exotic whitespace 'no-irregular-whitespace': 'warn', // CSV/import parsers handle exotic whitespace
'no-misleading-character-class': 'warn', 'no-misleading-character-class': 'warn',
'no-extra-boolean-cast': 'warn', 'no-extra-boolean-cast': 'warn',
// Pillar D ratchet: server code logs through utils/logger.cts only.
// The two sanctioned exceptions (logger itself + env bootstrap, which runs
// before the logger exists) are carved out in the override block below.
'no-console': 'error',
},
},
{
// Sanctioned console users: the logger's own sink and fatal-config
// bootstrap output that must work before the logger is importable.
files: ['utils/logger.cts', 'utils/env.cts', 'utils/apiError.cts'],
rules: { 'no-console': 'off' },
},
{
// Error-shape ratchet (CODE_STANDARDS "Error responses", now enforced):
// routes throw utils/apiError.cts factories; hand-rolled standardizeError
// bodies can't come back. transactions.cts (internal {error} result
// convention rethrown at the boundary) and import.cts (sendImportError
// error-id envelope) are the two documented exceptions.
files: ['routes/**/*.cts'],
ignores: ['routes/transactions.cts', 'routes/import.cts'],
rules: {
'no-restricted-imports': [
'error',
{
paths: [
{
name: '../middleware/errorFormatter.cts',
message:
'Routes emit errors by throwing utils/apiError.cts factories (see CODE_STANDARDS.md); the terminal handler formats them.',
},
],
},
],
}, },
}, },
{ {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 144 KiB

5995
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.41.1", "version": "0.40.0",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.cts", "main": "server.cts",
"engines": { "engines": {
@ -48,7 +48,7 @@
"@tanstack/react-query": "^5.100.9", "@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9", "@tanstack/react-query-devtools": "^5.100.9",
"@tanstack/react-virtual": "^3.14.2", "@tanstack/react-virtual": "^3.14.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -58,36 +58,35 @@
"express-rate-limit": "^8.4.1", "express-rate-limit": "^8.4.1",
"framer-motion": "^12.40.0", "framer-motion": "^12.40.0",
"kysely": "^0.29.3", "kysely": "^0.29.3",
"lucide-react": "^1.24.0", "lucide-react": "^0.456.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^9.0.3", "nodemailer": "^8.0.9",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"otplib": "^13.4.1", "otplib": "^13.4.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1", "react-router-dom": "^6.26.2",
"sonner": "^2.0.7", "sonner": "^1.7.1",
"tailwind-merge": "^2.5.4", "tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" "xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.10.1", "@axe-core/playwright": "^4.10.1",
"@eslint/js": "^10.0.1", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^26.1.0", "@types/node": "^26.1.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^10.0.3", "concurrently": "^9.1.0",
"eslint": "^10.7.0", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.4.26",
"fast-check": "^4.8.0", "fast-check": "^4.8.0",
"globals": "^17.7.0", "globals": "^17.7.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
@ -98,7 +97,7 @@
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.62.1", "typescript-eslint": "^8.62.1",
"vite": "^8.1.4", "vite": "^5.4.10",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.8" "vitest": "^4.1.8"
}, },

View File

@ -59,7 +59,7 @@ module.exports = defineConfig({
// findings are fixed (it becomes a passing regression guard). // findings are fixed (it becomes a passing regression guard).
{ {
name: 'probe', name: 'probe',
testMatch: /(api\.probe|a11y\.authed|webauthn\.probe)\.spec\.js/, testMatch: /(api\.probe|a11y\.authed)\.spec\.js/,
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'], dependencies: ['setup'],
}, },

View File

@ -5,7 +5,6 @@ const { log } = require('../utils/logger.cts');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
const { ValidationError } = require('../utils/apiError.cts');
const router = express.Router(); const router = express.Router();
@ -595,7 +594,7 @@ router.get('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: R
router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => { router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => {
const { enabled } = req.body || {}; const { enabled } = req.body || {};
if (typeof enabled !== 'boolean') { if (typeof enabled !== 'boolean') {
throw ValidationError('enabled must be a boolean', 'enabled'); return res.status(400).json({ error: 'enabled must be a boolean' });
} }
setSetting('update_check_enabled', enabled ? 'true' : 'false'); setSetting('update_check_enabled', enabled ? 'true' : 'false');
res.json({ enabled }); res.json({ enabled });

View File

@ -4,12 +4,6 @@ const express = require('express');
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database.cts'); const { getDb, rollbackMigration } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { const {
getBankSyncConfig, getBankSyncConfig,
setBankSyncEnabled, setBankSyncEnabled,
@ -49,9 +43,6 @@ function parseUserId(params) {
return Number.isInteger(n) && n > 0 ? n : null; return Number.isInteger(n) && n > 0 ? n : null;
} }
// Backup-ops error path. Kept res-based (not throw-pattern) deliberately: the
// download route can fail mid-stream after headers are sent, where a throw
// can't produce a response. Masks 500s; 4xx service messages pass through.
function sendError(res, err) { function sendError(res, err) {
const status = err.status || 500; const status = err.status || 500;
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message }); res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
@ -179,70 +170,80 @@ router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => {
router.post('/users', async (req: Req, res: Res) => { router.post('/users', async (req: Req, res: Res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || username.length < 3) if (!username || username.length < 3)
throw ValidationError('Username must be at least 3 characters'); return res.status(400).json({ error: 'Username must be at least 3 characters' });
if (!password || password.length < 8) if (!password || password.length < 8)
throw ValidationError('Password must be at least 8 characters'); return res.status(400).json({ error: 'Password must be at least 8 characters' });
const db = getDb(); const db = getDb();
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username)) if (db.prepare('SELECT id FROM users WHERE username = ?').get(username))
throw ConflictError('Username already taken', 'username'); return res.status(409).json({ error: 'Username already taken' });
const hash = await hashPassword(password); try {
const result = db const hash = await hashPassword(password);
.prepare( const result = db
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)", .prepare(
) "INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)",
.run(username, hash); )
.run(username, hash);
const created = db const created = db
.prepare( .prepare(
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?', 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?',
) )
.get(result.lastInsertRowid); .get(result.lastInsertRowid);
logAudit({ logAudit({
user_id: req.user.id, user_id: req.user.id,
action: 'admin.user.create', action: 'admin.user.create',
entity_type: 'user', entity_type: 'user',
entity_id: created.id, entity_id: created.id,
details: { created_username: username }, details: { created_username: username },
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
res.status(201).json(created); res.status(201).json(created);
} catch (err) {
log.error('[admin] create-user error:', err.message);
res.status(500).json({ error: 'Failed to create user' });
}
}); });
// PUT /api/admin/users/:id/password // PUT /api/admin/users/:id/password
router.put('/users/:id/password', async (req: Req, res: Res) => { router.put('/users/:id/password', async (req: Req, res: Res) => {
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) throw ValidationError('Invalid user ID'); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const { password } = req.body; const { password } = req.body;
if (!password || password.length < 8) if (!password || password.length < 8)
throw ValidationError('Password must be at least 8 characters'); return res.status(400).json({ error: 'Password must be at least 8 characters' });
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) throw NotFoundError('User not found'); if (!user) return res.status(404).json({ error: 'User not found' });
const hash = await hashPassword(password); try {
db.prepare( const hash = await hashPassword(password);
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?", db.prepare(
).run(hash, targetId); "UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); ).run(hash, targetId);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
logAudit({ logAudit({
user_id: req.user.id, user_id: req.user.id,
action: 'admin.password.reset', action: 'admin.password.reset',
entity_type: 'user', entity_type: 'user',
entity_id: targetId, entity_id: targetId,
details: { target_username: user.username }, details: { target_username: user.username },
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
res.json({ success: true }); res.json({ success: true });
} catch (err) {
log.error('[admin] reset-password error:', err.message);
res.status(500).json({ error: 'Failed to reset password' });
}
}); });
// PUT /api/admin/users/:id/role // PUT /api/admin/users/:id/role
@ -250,24 +251,24 @@ router.put('/users/:id/password', async (req: Req, res: Res) => {
// changing your own role mid-session. // changing your own role mid-session.
router.put('/users/:id/role', (req: Req, res: Res) => { router.put('/users/:id/role', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) throw ValidationError('Invalid user ID'); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const { role } = req.body; const { role } = req.body;
if (!['admin', 'user'].includes(role)) { if (!['admin', 'user'].includes(role)) {
throw ValidationError('role must be "admin" or "user"'); return res.status(400).json({ error: 'role must be "admin" or "user"' });
} }
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) throw NotFoundError('User not found'); if (!user) return res.status(404).json({ error: 'User not found' });
if (req.user?.id === targetId) { if (req.user?.id === targetId) {
throw ValidationError('You cannot change your own admin role.'); return res.status(400).json({ error: 'You cannot change your own admin role.' });
} }
if (user.role === 'admin' && role === 'user') { if (user.role === 'admin' && role === 'user') {
const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n; const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n;
if (adminCount <= 1) { if (adminCount <= 1) {
throw ValidationError('Cannot remove the last admin account.'); return res.status(400).json({ error: 'Cannot remove the last admin account.' });
} }
} }
@ -304,14 +305,14 @@ router.put('/users/:id/role', (req: Req, res: Res) => {
// PUT /api/admin/users/:id/active // PUT /api/admin/users/:id/active
router.put('/users/:id/active', (req: Req, res: Res) => { router.put('/users/:id/active', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) throw ValidationError('Invalid user ID'); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const active = req.body?.active ? 1 : 0; const active = req.body?.active ? 1 : 0;
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) throw NotFoundError('User not found'); if (!user) return res.status(404).json({ error: 'User not found' });
if (req.user?.id === targetId) { if (req.user?.id === targetId) {
throw ValidationError('You cannot deactivate your own account.'); return res.status(400).json({ error: 'You cannot deactivate your own account.' });
} }
db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run( db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run(
@ -333,27 +334,27 @@ router.put('/users/:id/active', (req: Req, res: Res) => {
router.put('/users/:id/username', (req: Req, res: Res) => { router.put('/users/:id/username', (req: Req, res: Res) => {
const { username } = req.body; const { username } = req.body;
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
throw ValidationError('username is required'); return res.status(400).json({ error: 'username is required' });
} }
const trimmed = username.trim(); const trimmed = username.trim();
if (trimmed.length < 3) { if (trimmed.length < 3) {
throw ValidationError('Username must be at least 3 characters'); return res.status(400).json({ error: 'Username must be at least 3 characters' });
} }
if (trimmed.length > 50) { if (trimmed.length > 50) {
throw ValidationError('Username must be 50 characters or fewer'); return res.status(400).json({ error: 'Username must be 50 characters or fewer' });
} }
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) throw ValidationError('Invalid user ID'); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
if (!user) throw NotFoundError('User not found'); if (!user) return res.status(404).json({ error: 'User not found' });
const taken = db const taken = db
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?') .prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.get(trimmed, targetId); .get(trimmed, targetId);
if (taken) throw ConflictError('Username already taken', 'username'); if (taken) return res.status(409).json({ error: 'Username already taken' });
const previousUsername = user.username; const previousUsername = user.username;
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run( db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
@ -383,12 +384,13 @@ router.put('/users/:id/username', (req: Req, res: Res) => {
// DELETE /api/admin/users/:id // DELETE /api/admin/users/:id
router.delete('/users/:id', (req: Req, res: Res) => { router.delete('/users/:id', (req: Req, res: Res) => {
const targetId = parseUserId(req.params); const targetId = parseUserId(req.params);
if (!targetId) throw ValidationError('Invalid user ID'); if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) throw NotFoundError('User not found'); if (!user) return res.status(404).json({ error: 'User not found' });
if (req.user?.id === user.id) throw ValidationError('You cannot delete your own account.'); if (req.user?.id === user.id)
return res.status(400).json({ error: 'You cannot delete your own account.' });
const deleteUser = db.transaction(() => { const deleteUser = db.transaction(() => {
// These three tables have no FK/CASCADE to users — must delete explicitly. // These three tables have no FK/CASCADE to users — must delete explicitly.
@ -500,19 +502,25 @@ router.get('/bank-sync-config', (req: Req, res: Res) => {
// PUT /api/admin/bank-sync-config // PUT /api/admin/bank-sync-config
router.put('/bank-sync-config', (req: Req, res: Res) => { router.put('/bank-sync-config', (req: Req, res: Res) => {
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {}; const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
let config = getBankSyncConfig(); try {
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled); let config = getBankSyncConfig();
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours); if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
if (sync_days !== undefined) config = setSyncDays(sync_days); if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging); if (sync_days !== undefined) config = setSyncDays(sync_days);
res.json(config); if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
res.json(config);
} catch (err) {
res
.status(err.status || 500)
.json({ error: err.message || 'Failed to update bank sync config' });
}
}); });
// ── Migration Rollback ──────────────────────────────────────────────────────── // ── Migration Rollback ────────────────────────────────────────────────────────
router.post('/migrations/rollback', async (req: Req, res: Res) => { router.post('/migrations/rollback', async (req: Req, res: Res) => {
const { version } = req.body; const { version } = req.body;
if (!version) { if (!version) {
throw ValidationError('Version is required'); return res.status(400).json({ error: 'Version is required' });
} }
try { try {
@ -538,13 +546,13 @@ router.post('/migrations/rollback', async (req: Req, res: Res) => {
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
// Coded rollback errors are intentional user-facing messages; anything if (err.code === 'NOT_APPLIED') {
// else propagates raw and the terminal handler masks + logs it as a 5xx return res.status(404).json({ error: err.message });
// (the audit log above keeps err.message either way). }
if (err.code === 'NOT_APPLIED') throw NotFoundError(err.message); if (err.code === 'ROLLBACK_NOT_SUPPORTED') {
if (err.code === 'ROLLBACK_NOT_SUPPORTED') return res.status(422).json({ error: err.message });
throw new ApiError('ROLLBACK_NOT_SUPPORTED', err.message, 422); }
throw err; res.status(500).json({ error: 'Rollback failed', details: err.message });
} }
}); });

View File

@ -1,6 +1,7 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
let _appVersion; let _appVersion;
@ -30,15 +31,10 @@ const {
} = require('../services/authService.cts'); } = require('../services/authService.cts');
const { decryptSecret } = require('../services/encryptionService.cts'); const { decryptSecret } = require('../services/encryptionService.cts');
const { getCsrfToken } = require('../middleware/csrf.cts'); const { getCsrfToken } = require('../middleware/csrf.cts');
const { requireAuth } = require('../middleware/requireAuth.cts'); const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
const { getPublicOidcInfo } = require('../services/oidcService.cts'); const { getPublicOidcInfo } = require('../services/oidcService.cts');
const { const { ValidationError, formatError } = require('../utils/apiError.cts');
ApiError, const { standardizeError } = require('../middleware/errorFormatter.cts');
ValidationError,
AuthError,
ForbiddenError,
NotFoundError,
} = require('../utils/apiError.cts');
const { passwordLimiter } = require('../middleware/rateLimiter.cts'); const { passwordLimiter } = require('../middleware/rateLimiter.cts');
const { logAudit } = require('../services/auditService.cts'); const { logAudit } = require('../services/auditService.cts');
@ -58,58 +54,65 @@ router.post(
async (req: Req, res: Res) => { async (req: Req, res: Res) => {
// Respect admin-configured login method toggle // Respect admin-configured login method toggle
if (getSetting('local_login_enabled') === 'false') { if (getSetting('local_login_enabled') === 'false') {
throw ForbiddenError('Local username/password login is not enabled on this server.'); return res
.status(403)
.json(
standardizeError(
'Local username/password login is not enabled on this server.',
'FORBIDDEN',
),
);
} }
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) { if (!username || !password) {
throw ValidationError( return res
'Username and password are required', .status(400)
!username ? 'username' : 'password', .json(
); standardizeError(
'Username and password are required',
'VALIDATION_ERROR',
!username ? 'username' : 'password',
),
);
} }
const result = await login(username, password); try {
if (!result || result.error) { const result = await login(username, password);
if (!result || result.error) {
logAudit({
user_id: null,
action: 'login.failure',
details: { username },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
// Track failed attempt against known accounts (wrong password only — not unknown usernames)
if (result?.error === 'bad_password') {
recordFailedLogin(result.userId, req.ip, req.get('user-agent'));
}
return res.status(401).json(standardizeError('Invalid username or password', 'AUTH_ERROR'));
}
// TOTP required — don't create a session yet
if (result.requires_totp) {
return res.json({ requires_totp: true, challenge_token: result.challenge_token });
}
logAudit({ logAudit({
user_id: null, user_id: result.user.id,
action: 'login.failure', action: 'login.success',
details: { username },
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
// Track failed attempt against known accounts (wrong password only — not unknown usernames) recordLogin(result.user.id, req.ip, req.get('user-agent'), result.sessionId);
if (result?.error === 'bad_password') {
recordFailedLogin(result.userId, req.ip, req.get('user-agent')); res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
} res.json({ user: result.user });
throw AuthError('Invalid username or password'); } catch (err) {
log.error('Login error:', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
} }
// TOTP required — don't create a session yet
if (result.requires_totp) {
return res.json({ requires_totp: true, challenge_token: result.challenge_token });
}
// WebAuthn required — same two-step shape as TOTP; the session is only
// created after POST /webauthn/challenge verifies the key.
if (result.requires_webauthn) {
return res.json({
requires_webauthn: true,
challenge_token: result.challenge_token,
webauthn_options: result.webauthn_options,
});
}
logAudit({
user_id: result.user.id,
action: 'login.success',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(result.user.id, req.ip, req.get('user-agent'), result.sessionId);
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
res.json({ user: result.user });
}, },
); );
@ -253,14 +256,20 @@ const { encryptSecret: encTotpSecret } = require('../services/encryptionService.
router.post('/totp/challenge', async (req: Req, res: Res) => { router.post('/totp/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true; req.csrfSkip = true;
const { challenge_token, code, recovery_code } = req.body || {}; const { challenge_token, code, recovery_code } = req.body || {};
if (!challenge_token) throw ValidationError('challenge_token is required'); if (!challenge_token)
return res
.status(400)
.json(standardizeError('challenge_token is required', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const userId = consumeChallenge(db, challenge_token); const userId = consumeChallenge(db, challenge_token);
if (!userId) throw AuthError('Challenge expired or invalid. Please sign in again.'); if (!userId)
return res
.status(401)
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId); const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId);
if (!user) throw AuthError('User not found.'); if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR'));
let verified = false; let verified = false;
if (recovery_code) { if (recovery_code) {
@ -280,42 +289,70 @@ router.post('/totp/challenge', async (req: Req, res: Res) => {
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
throw AuthError('Invalid authenticator code.'); return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR'));
} }
const { createSession } = require('../services/authService.cts'); try {
const session = await createSession(userId); const { createSession } = require('../services/authService.cts');
if (!session) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500); const session = await createSession(userId);
if (!session)
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
logAudit({ logAudit({
user_id: userId, user_id: userId,
action: 'login.success', action: 'login.success',
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId); recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId);
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req)); res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
res.json({ user: session.user }); res.json({ user: session.user });
} catch (err) {
log.error('[totp/challenge]', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
}
}); });
// GET /api/auth/totp/setup — generate a new pending secret + QR code for the authenticated user. // GET /api/auth/totp/setup — generate a new pending secret + QR code for the authenticated user.
// The secret is NOT saved yet; the user must confirm a valid code via /totp/enable. // The secret is NOT saved yet; the user must confirm a valid code via /totp/enable.
router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => { router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.'); if (req.singleUserMode)
const secret = generateSecret(); return res
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id); .status(400)
const { uri, qr_data_url } = await generateQrCode(secret, user.username); .json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
res.json({ secret, uri, qr_data_url }); try {
const secret = generateSecret();
const user = getDb().prepare('SELECT username FROM users WHERE id = ?').get(req.user.id);
const { uri, qr_data_url } = await generateQrCode(secret, user.username);
res.json({ secret, uri, qr_data_url });
} catch (err) {
log.error('[totp/setup]', err);
res.status(500).json(standardizeError('Failed to generate setup data', 'SERVER_ERROR'));
}
}); });
// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP. // POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP.
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => { router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.'); if (req.singleUserMode)
return res
.status(400)
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
const { secret, code } = req.body || {}; const { secret, code } = req.body || {};
if (!secret || !code) throw ValidationError('secret and code are required'); if (!secret || !code)
return res
.status(400)
.json(standardizeError('secret and code are required', 'VALIDATION_ERROR'));
if (!verifyTokenRaw(secret, code)) if (!verifyTokenRaw(secret, code))
throw ValidationError('Invalid authenticator code. Check your app and try again.', 'code'); return res
.status(400)
.json(
standardizeError(
'Invalid authenticator code. Check your app and try again.',
'VALIDATION_ERROR',
'code',
),
);
const plainCodes = generateRecoveryCodes(); const plainCodes = generateRecoveryCodes();
const hashedCodes = plainCodes.map(hashRecoveryCode); const hashedCodes = plainCodes.map(hashRecoveryCode);
@ -341,7 +378,8 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
const { code, recovery_code } = req.body || {}; const { code, recovery_code } = req.body || {};
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!user?.totp_enabled) throw ValidationError('TOTP is not enabled.'); if (!user?.totp_enabled)
return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR'));
let verified = false; let verified = false;
if (recovery_code) { if (recovery_code) {
@ -350,7 +388,9 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
verified = verifyToken(user.totp_secret, code); verified = verifyToken(user.totp_secret, code);
} }
if (!verified) if (!verified)
throw new ApiError('AUTH_ERROR', 'Invalid authenticator code.', 401, { field: 'code' }); return res
.status(401)
.json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code'));
db.prepare( db.prepare(
`UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`, `UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`,
@ -402,7 +442,9 @@ router.get('/mode', (req: Req, res: Res) => {
// login without needing access to Admin routes. // login without needing access to Admin routes.
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => { router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') { if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
throw ValidationError('Single-user mode is not enabled.', 'auth_mode'); return res
.status(400)
.json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode'));
} }
setSetting('auth_mode', 'multi'); setSetting('auth_mode', 'multi');
@ -427,47 +469,132 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, r
const { current_password, new_password } = req.body; const { current_password, new_password } = req.body;
if (!new_password || new_password.length < 8) { if (!new_password || new_password.length < 8) {
throw ValidationError('New password must be at least 8 characters', 'new_password'); return res
.status(400)
.json(
standardizeError(
'New password must be at least 8 characters',
'VALIDATION_ERROR',
'new_password',
),
);
} }
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!user.must_change_password) { try {
const bcrypt = require('bcryptjs'); if (!user.must_change_password) {
const valid = await bcrypt.compare(current_password || '', user.password_hash); const bcrypt = require('bcryptjs');
if (!valid) const valid = await bcrypt.compare(current_password || '', user.password_hash);
throw new ApiError('AUTH_ERROR', 'Current password is incorrect', 401, { if (!valid)
field: 'current_password', return res
}); .status(401)
} .json(
standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'),
const hash = await hashPassword(new_password); );
db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
} }
const hash = await hashPassword(new_password);
db.prepare(
"UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ?",
).run(hash, req.user.id);
// Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
}
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true });
} catch (err) {
log.error('[auth] change-password error:', err.message);
res.status(500).json(standardizeError('Password change failed', 'SERVER_ERROR'));
}
});
// ─────────────────────────────────────────
// ADMIN ROUTES (MOUNTED AT /api/admin)
// ─────────────────────────────────────────
// GET /api/admin/has-users
router.get('/has-users', (req: Req, res: Res) => {
const count = getDb().prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'").get().n;
res.json({ has_users: count > 0 });
});
// GET /api/admin/users
router.get('/users', requireAuth, requireAdmin, (req: Req, res: Res) => {
const users = getDb()
.prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC',
)
.all();
res.json(users);
});
// POST /api/admin/users
router.post('/users', requireAuth, requireAdmin, async (req: Req, res: Res) => {
const { username, password } = req.body;
if (!username || username.length < 3) {
return res
.status(400)
.json(
standardizeError('Username must be at least 3 characters', 'VALIDATION_ERROR', 'username'),
);
} }
logAudit({ if (!password || password.length < 8) {
user_id: req.user.id, return res
action: 'password.change', .status(400)
ip_address: req.ip, .json(
user_agent: req.get('user-agent'), standardizeError('Password must be at least 8 characters', 'VALIDATION_ERROR', 'password'),
}); );
}
res.json({ success: true }); const db = getDb();
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (existing)
return res.status(409).json(standardizeError('Username already taken', 'CONFLICT', 'username'));
try {
const hash = await hashPassword(password);
const result = db
.prepare(
"INSERT INTO users (username, password_hash, role, first_login, last_seen_version) VALUES (?, ?, 'user', 1, ?)",
)
.run(username, hash, getAppVersion());
const created = db
.prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?',
)
.get(result.lastInsertRowid);
res.status(201).json(created);
} catch (err) {
log.error('[auth] create-user error:', err.message);
res.status(500).json(standardizeError('Failed to create user', 'SERVER_ERROR'));
}
}); });
// ── WebAuthn / FIDO2 security key ───────────────────────────────────────────── // ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
@ -501,103 +628,138 @@ router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => {
// GET /api/auth/webauthn/setup — begin registration // GET /api/auth/webauthn/setup — begin registration
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => { router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.'); if (req.singleUserMode)
const { options, challengeId } = await createRegistrationChallenge( return res
getDb(), .status(400)
req.user.id, .json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
req.user.username, try {
); const { options, challengeId } = await createRegistrationChallenge(
res.json({ options, challengeId }); getDb(),
req.user.id,
req.user.username,
);
res.json({ options, challengeId });
} catch (err) {
log.error('[webauthn/setup]', err);
res.status(500).json(standardizeError('Failed to generate setup options', 'SERVER_ERROR'));
}
}); });
// POST /api/auth/webauthn/enable — complete registration // POST /api/auth/webauthn/enable — complete registration
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => { router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.'); if (req.singleUserMode)
return res
.status(400)
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
const { challengeId, response, credential_name } = req.body || {}; const { challengeId, response, credential_name } = req.body || {};
if (!challengeId || !response) throw ValidationError('challengeId and response are required'); if (!challengeId || !response)
return res
.status(400)
.json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR'));
const db = getDb(); try {
const result = await verifyRegistration( const db = getDb();
db, const result = await verifyRegistration(
req.user.id, db,
challengeId, req.user.id,
response, challengeId,
credential_name, response,
req.get('origin'), credential_name,
); );
if (!result.verified) throw ValidationError(result.error || 'Registration failed'); if (!result.verified)
return res
.status(400)
.json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR'));
db.prepare( db.prepare(
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?", "UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id); ).run(req.user.id);
logAudit({ logAudit({
user_id: req.user.id, user_id: req.user.id,
action: 'webauthn.credential_added', action: 'webauthn.credential_added',
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
res.json({ enabled: true, credential_id: result.credentialId }); res.json({ enabled: true, credential_id: result.credentialId });
} catch (err) {
log.error('[webauthn/enable]', err);
res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR'));
}
}); });
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key // DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => { router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {}; const { password } = req.body || {};
if (!password) throw ValidationError('password is required'); if (!password)
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
const bcrypt = require('bcryptjs'); try {
if (!(await bcrypt.compare(password, user.password_hash))) const bcrypt = require('bcryptjs');
throw AuthError('Password is incorrect'); if (!(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
const result = deleteCredential(db, req.params.credentialId, req.user.id); const result = deleteCredential(db, req.params.credentialId, req.user.id);
if (result.changes === 0) throw NotFoundError('Credential not found'); if (result.changes === 0)
return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND'));
const { n } = db const { n } = db
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?') .prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
.get(req.user.id); .get(req.user.id);
if (n === 0) if (n === 0)
db.prepare( db.prepare(
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?", "UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id); ).run(req.user.id);
logAudit({ logAudit({
user_id: req.user.id, user_id: req.user.id,
action: 'webauthn.credential_removed', action: 'webauthn.credential_removed',
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
res.json({ success: true, webauthn_enabled: n > 0 }); res.json({ success: true, webauthn_enabled: n > 0 });
} catch (err) {
log.error('[webauthn/credentials/delete]', err);
res.status(500).json(standardizeError('Failed to remove credential', 'SERVER_ERROR'));
}
}); });
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn // POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => { router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
const { password } = req.body || {}; const { password } = req.body || {};
if (!password) throw ValidationError('password is required'); if (!password)
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const user = db const user = db
.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?') .prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?')
.get(req.user.id); .get(req.user.id);
if (!user?.webauthn_enabled) throw ValidationError('WebAuthn is not enabled.'); if (!user?.webauthn_enabled)
return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR'));
const bcrypt = require('bcryptjs'); try {
if (!(await bcrypt.compare(password, user.password_hash))) const bcrypt = require('bcryptjs');
throw AuthError('Password is incorrect'); if (!(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id); db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
db.prepare( db.prepare(
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?", "UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
).run(req.user.id); ).run(req.user.id);
logAudit({ logAudit({
user_id: req.user.id, user_id: req.user.id,
action: 'webauthn.disabled', action: 'webauthn.disabled',
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
res.json({ enabled: false }); res.json({ enabled: false });
} catch (err) {
log.error('[webauthn/disable]', err);
res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR'));
}
}); });
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled. // POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
@ -606,47 +768,59 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
req.csrfSkip = true; req.csrfSkip = true;
const { challenge_token, response } = req.body || {}; const { challenge_token, response } = req.body || {};
if (!challenge_token || !response) if (!challenge_token || !response)
throw ValidationError('challenge_token and response are required'); return res
.status(400)
.json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const session = consumeLoginChallenge(db, challenge_token); const session = consumeLoginChallenge(db, challenge_token);
if (!session) throw AuthError('Challenge expired or invalid. Please sign in again.'); if (!session)
return res
.status(401)
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId); const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId);
if (!user) throw AuthError('User not found.'); if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR'));
try {
const result = await verifyAuthentication(
db,
session.userId,
session.authChallengeId,
response,
);
if (!result.verified) {
logAudit({
user_id: session.userId,
action: 'webauthn.failure',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
return res
.status(401)
.json(standardizeError('Security key verification failed.', 'AUTH_ERROR'));
}
const { createSession } = require('../services/authService.cts');
const s = await createSession(session.userId);
if (!s)
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
const result = await verifyAuthentication(
db,
session.userId,
session.authChallengeId,
response,
req.get('origin'),
);
if (!result.verified) {
logAudit({ logAudit({
user_id: session.userId, user_id: session.userId,
action: 'webauthn.failure', action: 'login.success',
details: { method: 'webauthn' },
ip_address: req.ip, ip_address: req.ip,
user_agent: req.get('user-agent'), user_agent: req.get('user-agent'),
}); });
throw AuthError('Security key verification failed.'); recordLogin(session.userId, req.ip, req.get('user-agent'), s.sessionId);
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
res.json({ user: s.user });
} catch (err) {
log.error('[webauthn/challenge]', err);
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
} }
const { createSession } = require('../services/authService.cts');
const s = await createSession(session.userId);
if (!s) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
logAudit({
user_id: session.userId,
action: 'login.success',
details: { method: 'webauthn' },
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
recordLogin(session.userId, req.ip, req.get('user-agent'), s.sessionId);
res.cookie(COOKIE_NAME, s.sessionId, cookieOpts(req));
res.json({ user: s.user });
}); });
module.exports = router; module.exports = router;

View File

@ -16,12 +16,7 @@ const {
applyBalanceDelta, applyBalanceDelta,
} = require('../services/billsService.cts'); } = require('../services/billsService.cts');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts');
const { const { standardizeError } = require('../middleware/errorFormatter.cts');
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts'); const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
const { const {
addMerchantRule, addMerchantRule,
@ -105,7 +100,9 @@ router.put('/reorder', (req: Req, res: Res) => {
})); }));
if (entries.length === 0) { if (entries.length === 0) {
throw ValidationError('At least one bill order is required', 'reorder'); return res
.status(400)
.json(standardizeError('At least one bill order is required', 'VALIDATION_ERROR', 'reorder'));
} }
const invalid = entries.find( const invalid = entries.find(
@ -113,10 +110,15 @@ router.put('/reorder', (req: Req, res: Res) => {
!Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0, !Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0,
); );
if (invalid) { if (invalid) {
throw ValidationError( return res
'Reorder payload must map bill ids to non-negative integer positions', .status(400)
'reorder', .json(
); standardizeError(
'Reorder payload must map bill ids to non-negative integer positions',
'VALIDATION_ERROR',
'reorder',
),
);
} }
const ids = entries.map((item) => item.billId); const ids = entries.map((item) => item.billId);
@ -131,7 +133,9 @@ router.put('/reorder', (req: Req, res: Res) => {
) )
.all(req.user.id, ...ids); .all(req.user.id, ...ids);
if (owned.length !== ids.length) { if (owned.length !== ids.length) {
throw NotFoundError('One or more bills were not found', 'bill_id'); return res
.status(404)
.json(standardizeError('One or more bills were not found', 'NOT_FOUND', 'bill_id'));
} }
const update = db.prepare( const update = db.prepare(
@ -168,14 +172,19 @@ router.get('/audit', (req: Req, res: Res) => {
// ── GET /api/bills/drift-report ────────────────────────────────────────────── // ── GET /api/bills/drift-report ──────────────────────────────────────────────
router.get('/drift-report', (req: Req, res: Res) => { router.get('/drift-report', (req: Req, res: Res) => {
const { getDriftReport } = require('../services/driftService.cts'); const { getDriftReport } = require('../services/driftService.cts');
res.json(getDriftReport(req.user.id)); try {
res.json(getDriftReport(req.user.id));
} catch (err) {
res.status(500).json({ error: 'Failed to compute drift report' });
}
}); });
// GET /api/bills/merchant-rules — all rules for this user across all bills // GET /api/bills/merchant-rules — all rules for this user across all bills
router.get('/merchant-rules', (req: Req, res: Res) => { router.get('/merchant-rules', (req: Req, res: Res) => {
const rules = getDb() try {
.prepare( const rules = getDb()
` .prepare(
`
SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at, SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at,
b.id AS bill_id, b.name AS bill_name b.id AS bill_id, b.name AS bill_name
FROM bill_merchant_rules bmr FROM bill_merchant_rules bmr
@ -183,9 +192,13 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
WHERE bmr.user_id = ? WHERE bmr.user_id = ?
ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC
`, `,
) )
.all(req.user.id); .all(req.user.id);
res.json({ rules }); res.json({ rules });
} catch (err) {
log.error('[bills/merchant-rules GET]', err.message);
res.status(500).json({ error: 'Failed to load merchant rules' });
}
}); });
// ── POST /api/bills/:id/snooze-drift ───────────────────────────────────────── // ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
@ -193,11 +206,11 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
router.post('/:id/snooze-drift', (req: Req, res: Res) => { router.post('/:id/snooze-drift', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid id'); if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const bill = db const bill = db
.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL') .prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL')
.get(id); .get(id);
if (!bill || bill.user_id !== req.user.id) throw NotFoundError('Not found'); if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' });
const until = new Date(); const until = new Date();
until.setDate(until.getDate() + 30); until.setDate(until.getDate() + 30);
const untilStr = localDateString(until); const untilStr = localDateString(until);
@ -244,20 +257,36 @@ router.post('/templates', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
if (name.length < 2) { if (name.length < 2) {
throw ValidationError('Template name must be at least 2 characters', 'name'); return res
.status(400)
.json(
standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'),
);
} }
const data = sanitizeTemplateData(req.body.data || {}); const data = sanitizeTemplateData(req.body.data || {});
if (Object.keys(data).length === 0) { if (Object.keys(data).length === 0) {
throw ValidationError('Template data is required', 'data'); return res
.status(400)
.json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
} }
const validation = validateBillData(data); const validation = validateBillData(data);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
throw ValidationError(firstError.message, `data.${firstError.field}`); return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`));
} }
if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) {
throw ValidationError('category_id is invalid for this user', 'data.category_id'); return res
.status(400)
.json(
standardizeError(
'category_id is invalid for this user',
'VALIDATION_ERROR',
'data.category_id',
),
);
} }
const normalizedData = sanitizeTemplateData(validation.normalized); const normalizedData = sanitizeTemplateData(validation.normalized);
@ -294,12 +323,15 @@ router.delete('/templates/:templateId', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const templateId = parseInt(req.params.templateId, 10); const templateId = parseInt(req.params.templateId, 10);
if (!Number.isInteger(templateId)) { if (!Number.isInteger(templateId)) {
throw ValidationError('template_id must be an integer', 'template_id'); return res
.status(400)
.json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id'));
} }
const result = db const result = db
.prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?') .prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?')
.run(templateId, req.user.id); .run(templateId, req.user.id);
if (result.changes === 0) throw NotFoundError('Template not found', 'template_id'); if (result.changes === 0)
return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id'));
res.json({ success: true }); res.json({ success: true });
}); });
@ -309,12 +341,15 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
const body = req.body || {}; const body = req.body || {};
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
throw ValidationError('bill_id must be an integer', 'bill_id'); return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const source = db const source = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!source) throw NotFoundError('Bill not found', 'bill_id'); if (!source)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const draft = { const draft = {
...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(serializeBill(source)),
@ -324,11 +359,17 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
const validation = validateBillData(draft); const validation = validateBillData(draft);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
throw ValidationError(firstError.message, firstError.field); return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
throw ValidationError('category_id is invalid for this user', 'category_id'); return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
@ -343,14 +384,26 @@ router.get('/:id/monthly-state', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) )
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const year = parseInt(req.query.year, 10); const year = parseInt(req.query.year, 10);
const month = parseInt(req.query.month, 10); const month = parseInt(req.query.month, 10);
if (isNaN(year) || year < 2000 || year > 2100) if (isNaN(year) || year < 2000 || year > 2100)
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
if (isNaN(month) || month < 1 || month > 12) if (isNaN(month) || month < 1 || month > 12)
throw ValidationError('month must be an integer between 1 and 12', 'month'); return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
const mbs = db const mbs = db
.prepare( .prepare(
@ -377,29 +430,54 @@ router.put('/:id/monthly-state', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) )
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body; const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body;
const y = parseInt(year, 10); const y = parseInt(year, 10);
const m = parseInt(month, 10); const m = parseInt(month, 10);
if (isNaN(y) || y < 2000 || y > 2100) if (isNaN(y) || y < 2000 || y > 2100)
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
if (isNaN(m) || m < 1 || m > 12) if (isNaN(m) || m < 1 || m > 12)
throw ValidationError('month must be an integer between 1 and 12', 'month'); return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
if (actual_amount !== undefined && actual_amount !== null) { if (actual_amount !== undefined && actual_amount !== null) {
const amt = parseFloat(actual_amount); const amt = parseFloat(actual_amount);
if (isNaN(amt) || amt < 0) if (isNaN(amt) || amt < 0)
throw ValidationError('actual_amount must be a non-negative number or null', 'actual_amount'); return res
.status(400)
.json(
standardizeError(
'actual_amount must be a non-negative number or null',
'VALIDATION_ERROR',
'actual_amount',
),
);
} }
if (snoozed_until !== undefined && snoozed_until !== null) { if (snoozed_until !== undefined && snoozed_until !== null) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until)) if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until))
throw ValidationError( return res
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null', .status(400)
'snoozed_until', .json(
); standardizeError(
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
'VALIDATION_ERROR',
'snoozed_until',
),
);
} }
// Partial-update semantics: fields omitted from the request keep their // Partial-update semantics: fields omitted from the request keep their
@ -473,7 +551,8 @@ router.get('/:id', (req: Req, res: Res) => {
`, `,
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
let autopay_stats = null; let autopay_stats = null;
if (bill.autopay_enabled) { if (bill.autopay_enabled) {
@ -505,16 +584,25 @@ router.post('/:id/verify-autopay', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
throw ValidationError('Invalid id', 'bill_id'); return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id'));
} }
const bill = db const bill = db
.prepare( .prepare(
'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL', 'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
) )
.get(id, req.user.id); .get(id, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
if (!bill.autopay_enabled) { if (!bill.autopay_enabled) {
throw ValidationError('Bill does not have autopay enabled', 'autopay_enabled'); return res
.status(400)
.json(
standardizeError(
'Bill does not have autopay enabled',
'VALIDATION_ERROR',
'autopay_enabled',
),
);
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
db.prepare( db.prepare(
@ -536,12 +624,23 @@ router.post('/', (req: Req, res: Res) => {
) { ) {
const sourceBillId = parseInt(body.source_bill_id, 10); const sourceBillId = parseInt(body.source_bill_id, 10);
if (!Number.isInteger(sourceBillId)) { if (!Number.isInteger(sourceBillId)) {
throw ValidationError('source_bill_id must be an integer', 'source_bill_id'); return res
.status(400)
.json(
standardizeError(
'source_bill_id must be an integer',
'VALIDATION_ERROR',
'source_bill_id',
),
);
} }
const source = db const source = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(sourceBillId, req.user.id); .get(sourceBillId, req.user.id);
if (!source) throw NotFoundError('Source bill not found', 'source_bill_id'); if (!source)
return res
.status(404)
.json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
payload = { payload = {
...sanitizeTemplateData(serializeBill(source)), ...sanitizeTemplateData(serializeBill(source)),
...sanitizeTemplateData(body), ...sanitizeTemplateData(body),
@ -553,14 +652,20 @@ router.post('/', (req: Req, res: Res) => {
const validation = validateBillData(payload); const validation = validateBillData(payload);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
throw ValidationError(firstError.message, firstError.field); return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user // Validate category_id exists for this user
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
throw ValidationError('category_id is invalid for this user', 'category_id'); return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized))); res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
@ -572,20 +677,27 @@ router.put('/:id', (req: Req, res: Res) => {
const existing = db const existing = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!existing) throw NotFoundError('Bill not found', 'bill_id'); if (!existing)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// Validate and normalize bill data // Validate and normalize bill data
const validation = validateBillData(req.body, existing); const validation = validateBillData(req.body, existing);
if (validation.errors.length > 0) { if (validation.errors.length > 0) {
const firstError = validation.errors[0]; const firstError = validation.errors[0];
throw ValidationError(firstError.message, firstError.field); return res
.status(400)
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
} }
const { normalized } = validation; const { normalized } = validation;
// Validate category_id exists for this user if changed // Validate category_id exists for this user if changed
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) { if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
throw ValidationError('category_id is invalid for this user', 'category_id'); return res
.status(400)
.json(
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
);
} }
const inactiveReason = const inactiveReason =
@ -660,12 +772,13 @@ router.put('/:id/archived', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
throw ValidationError('Invalid id', 'bill_id'); return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id'));
} }
const bill = db const bill = db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(id, req.user.id); .get(id, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const archived = !!req.body?.archived; const archived = !!req.body?.archived;
db.prepare( db.prepare(
@ -684,7 +797,8 @@ router.delete('/:id', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
db.prepare( db.prepare(
"UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -704,7 +818,8 @@ router.post('/:id/restore', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) throw NotFoundError('Deleted bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
db.prepare( db.prepare(
"UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -724,14 +839,19 @@ router.post('/:id/restore', (req: Req, res: Res) => {
// backfill any missing payments. // backfill any missing payments.
router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => { router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) throw ValidationError('Invalid bill id'); if (!Number.isInteger(billId))
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const db = getDb(); const db = getDb();
const bill = db const bill = db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found'); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId); try {
res.json(result); const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR'));
}
}); });
// ── GET /api/bills/:id/payments?page=1&limit=20 ─────────────────────────────── // ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
@ -740,7 +860,8 @@ router.get('/:id/payments', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100); const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
const page = Math.max(parseInt(req.query.page || '1', 10), 1); const page = Math.max(parseInt(req.query.page || '1', 10), 1);
@ -772,13 +893,16 @@ router.get('/:id/transactions', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
throw ValidationError('bill_id must be an integer', 'bill_id'); return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const bill = db const bill = db
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const rows = db const rows = db
.prepare( .prepare(
@ -847,19 +971,40 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
) )
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// Scope to year/month if provided // Scope to year/month if provided
const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null; const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null;
const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null; const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null;
if ((year === null) !== (month === null)) { if ((year === null) !== (month === null)) {
throw ValidationError('year and month must both be provided or both omitted', 'year'); return res
.status(400)
.json(
standardizeError(
'year and month must both be provided or both omitted',
'VALIDATION_ERROR',
'year',
),
);
} }
if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) { if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) {
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
} }
if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) { if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) {
throw ValidationError('month must be an integer between 1 and 12', 'month'); return res
.status(400)
.json(
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
);
} }
let currentPayment; let currentPayment;
@ -923,8 +1068,11 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
{ amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' }, { amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' },
{ requireBillId: false }, { requireBillId: false },
); );
if (paymentValidation.error) if (paymentValidation.error) {
throw ValidationError(paymentValidation.error, paymentValidation.field); return res
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const payment = paymentValidation.normalized; const payment = paymentValidation.normalized;
// Compute balance delta for debt bills before inserting // Compute balance delta for debt bills before inserting
@ -964,7 +1112,7 @@ router.get('/:id/history-ranges', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const ranges = db const ranges = db
.prepare( .prepare(
@ -991,40 +1139,77 @@ router.post('/:id/history-ranges', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const { start_year, start_month, end_year, end_month, label } = req.body; const { start_year, start_month, end_year, end_month, label } = req.body;
const sy = parseInt(start_year, 10); const sy = parseInt(start_year, 10);
const sm = parseInt(start_month, 10); const sm = parseInt(start_month, 10);
if (isNaN(sy) || sy < 2000 || sy > 2100) if (isNaN(sy) || sy < 2000 || sy > 2100)
throw ValidationError('start_year must be between 2000 and 2100', 'start_year'); return res
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
if (isNaN(sm) || sm < 1 || sm > 12) if (isNaN(sm) || sm < 1 || sm > 12)
throw ValidationError('start_month must be between 1 and 12', 'start_month'); return res
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
let ey = null, let ey = null,
em = null; em = null;
if (end_year != null) { if (end_year != null) {
ey = parseInt(end_year, 10); ey = parseInt(end_year, 10);
if (isNaN(ey) || ey < 2000 || ey > 2100) if (isNaN(ey) || ey < 2000 || ey > 2100)
throw ValidationError('end_year must be between 2000 and 2100', 'end_year'); return res
.status(400)
.json(
standardizeError(
'end_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'end_year',
),
);
} }
if (end_month != null) { if (end_month != null) {
em = parseInt(end_month, 10); em = parseInt(end_month, 10);
if (isNaN(em) || em < 1 || em > 12) if (isNaN(em) || em < 1 || em > 12)
throw ValidationError('end_month must be between 1 and 12', 'end_month'); return res
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
} }
if ((ey == null) !== (em == null)) { if ((ey == null) !== (em == null)) {
throw ValidationError( return res
'end_year and end_month must both be provided or both omitted', .status(400)
'end_year', .json(
); standardizeError(
'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year',
),
);
} }
if (ey != null) { if (ey != null) {
const startVal = sy * 12 + sm; const startVal = sy * 12 + sm;
const endVal = ey * 12 + em; const endVal = ey * 12 + em;
if (endVal < startVal) if (endVal < startVal)
throw ValidationError('end date must be on or after start date', 'end_year'); return res
.status(400)
.json(
standardizeError(
'end date must be on or after start date',
'VALIDATION_ERROR',
'end_year',
),
);
} }
const result = db const result = db
@ -1050,21 +1235,36 @@ router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const range = db const range = db
.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
.get(req.params.rangeId, req.params.id); .get(req.params.rangeId, req.params.id);
if (!range) throw NotFoundError('History range not found', 'rangeId'); if (!range)
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
const { start_year, start_month, end_year, end_month, label } = req.body; const { start_year, start_month, end_year, end_month, label } = req.body;
const sy = start_year != null ? parseInt(start_year, 10) : range.start_year; const sy = start_year != null ? parseInt(start_year, 10) : range.start_year;
const sm = start_month != null ? parseInt(start_month, 10) : range.start_month; const sm = start_month != null ? parseInt(start_month, 10) : range.start_month;
if (isNaN(sy) || sy < 2000 || sy > 2100) if (isNaN(sy) || sy < 2000 || sy > 2100)
throw ValidationError('start_year must be between 2000 and 2100', 'start_year'); return res
.status(400)
.json(
standardizeError(
'start_year must be between 2000 and 2100',
'VALIDATION_ERROR',
'start_year',
),
);
if (isNaN(sm) || sm < 1 || sm > 12) if (isNaN(sm) || sm < 1 || sm > 12)
throw ValidationError('start_month must be between 1 and 12', 'start_month'); return res
.status(400)
.json(
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
);
let ey = range.end_year; let ey = range.end_year;
let em = range.end_month; let em = range.end_month;
@ -1072,16 +1272,33 @@ router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
if (end_month !== undefined) em = end_month != null ? parseInt(end_month, 10) : null; if (end_month !== undefined) em = end_month != null ? parseInt(end_month, 10) : null;
if (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100)) if (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100))
throw ValidationError('end_year must be between 2000 and 2100', 'end_year'); return res
.status(400)
.json(
standardizeError('end_year must be between 2000 and 2100', 'VALIDATION_ERROR', 'end_year'),
);
if (em != null && (isNaN(em) || em < 1 || em > 12)) if (em != null && (isNaN(em) || em < 1 || em > 12))
throw ValidationError('end_month must be between 1 and 12', 'end_month'); return res
.status(400)
.json(
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
);
if ((ey == null) !== (em == null)) if ((ey == null) !== (em == null))
throw ValidationError( return res
'end_year and end_month must both be provided or both omitted', .status(400)
'end_year', .json(
); standardizeError(
'end_year and end_month must both be provided or both omitted',
'VALIDATION_ERROR',
'end_year',
),
);
if (ey != null && ey * 12 + em < sy * 12 + sm) if (ey != null && ey * 12 + em < sy * 12 + sm)
throw ValidationError('end date must be on or after start date', 'end_year'); return res
.status(400)
.json(
standardizeError('end date must be on or after start date', 'VALIDATION_ERROR', 'end_year'),
);
db.prepare( db.prepare(
` `
@ -1114,12 +1331,15 @@ router.delete('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(req.params.id, req.user.id) .get(req.params.id, req.user.id)
) )
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const range = db const range = db
.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?') .prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
.get(req.params.rangeId, req.params.id); .get(req.params.rangeId, req.params.id);
if (!range) throw NotFoundError('History range not found', 'rangeId'); if (!range)
return res
.status(404)
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run( db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run(
req.params.rangeId, req.params.rangeId,
@ -1136,7 +1356,8 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const balance = fromCents(Number(bill.current_balance)); const balance = fromCents(Number(bill.current_balance));
const apr = Number(bill.interest_rate) || 0; const apr = Number(bill.interest_rate) || 0;
@ -1147,7 +1368,9 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
if (req.query.payment !== undefined) { if (req.query.payment !== undefined) {
const qp = parseFloat(req.query.payment); const qp = parseFloat(req.query.payment);
if (!Number.isFinite(qp) || qp <= 0) { if (!Number.isFinite(qp) || qp <= 0) {
throw ValidationError('payment must be a positive number', 'payment'); return res
.status(400)
.json(standardizeError('payment must be a positive number', 'VALIDATION_ERROR', 'payment'));
} }
payment = qp; payment = qp;
} }
@ -1201,7 +1424,7 @@ router.patch('/:id/snowball', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) { ) {
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
} }
const include = const include =
req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined; req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
@ -1217,7 +1440,8 @@ router.patch('/:id/snowball', (req: Req, res: Res) => {
parts.push('snowball_exempt = ?'); parts.push('snowball_exempt = ?');
vals.push(exempt); vals.push(exempt);
} }
if (parts.length === 0) throw ValidationError('Nothing to update'); if (parts.length === 0)
return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR'));
parts.push("updated_at = datetime('now')"); parts.push("updated_at = datetime('now')");
db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run( db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(
...vals, ...vals,
@ -1236,7 +1460,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) { ) {
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
} }
const raw = req.body.current_balance; const raw = req.body.current_balance;
@ -1244,7 +1468,15 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
if (raw !== null && raw !== '' && raw !== undefined) { if (raw !== null && raw !== '' && raw !== undefined) {
val = parseFloat(raw); val = parseFloat(raw);
if (!Number.isFinite(val) || val < 0) { if (!Number.isFinite(val) || val < 0) {
throw ValidationError('current_balance must be a non-negative number', 'current_balance'); return res
.status(400)
.json(
standardizeError(
'current_balance must be a non-negative number',
'VALIDATION_ERROR',
'current_balance',
),
);
} }
val = roundMoney(val); val = roundMoney(val);
} }
@ -1306,8 +1538,10 @@ function findConflicts(db, userId, billId, normalized) {
router.get('/:id/merchant-rules', (req: Req, res: Res) => { router.get('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id'); if (!Number.isInteger(billId) || billId < 1)
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rules = db const rules = db
.prepare( .prepare(
@ -1357,8 +1591,10 @@ router.get('/:id/merchant-rules', (req: Req, res: Res) => {
router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => { router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id'); if (!Number.isInteger(billId) || billId < 1)
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.query.merchant || '').trim(); const raw = String(req.query.merchant || '').trim();
const normalized = normalizeMerchant(raw); const normalized = normalizeMerchant(raw);
@ -1376,23 +1612,37 @@ router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
router.post('/:id/merchant-rules', (req: Req, res: Res) => { router.post('/:id/merchant-rules', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id'); if (!Number.isInteger(billId) || billId < 1)
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const raw = String(req.body?.merchant || '').trim(); const raw = String(req.body?.merchant || '').trim();
const normalized = normalizeMerchant(raw); const normalized = normalizeMerchant(raw);
if (!normalized || normalized.length < 2) if (!normalized || normalized.length < 2)
throw ValidationError('merchant must be at least 2 characters after normalisation', 'merchant'); return res
.status(400)
.json(
standardizeError(
'merchant must be at least 2 characters after normalisation',
'VALIDATION_ERROR',
'merchant',
),
);
const conflicts = findConflicts(db, req.user.id, billId, normalized); const conflicts = findConflicts(db, req.user.id, billId, normalized);
db.prepare( try {
` db.prepare(
`
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant) INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
`, `,
).run(req.user.id, billId, normalized); ).run(req.user.id, billId, normalized);
} catch (err) {
return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR'));
}
// Retroactively apply the new rule to existing unmatched transactions // Retroactively apply the new rule to existing unmatched transactions
const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId); const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
@ -1415,9 +1665,10 @@ router.post('/:id/merchant-rules', (req: Req, res: Res) => {
router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => { router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id'); if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const bill = requireBill(db, billId, req.user.id); const bill = requireBill(db, billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found'); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rules = db const rules = db
.prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?') .prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?')
@ -1499,16 +1750,22 @@ router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => { router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id'); if (!Number.isInteger(billId) || billId < 1)
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
const bill = requireBill(db, billId, req.user.id); const bill = requireBill(db, billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found'); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const ids = req.body?.transaction_ids; const ids = req.body?.transaction_ids;
if (!Array.isArray(ids) || ids.length === 0) if (!Array.isArray(ids) || ids.length === 0)
throw ValidationError('transaction_ids must be a non-empty array'); return res
.status(400)
.json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR'));
const validIds = ids.filter((id) => Number.isInteger(id) && id > 0); const validIds = ids.filter((id) => Number.isInteger(id) && id > 0);
if (validIds.length === 0) throw ValidationError('No valid transaction ids provided'); if (validIds.length === 0)
return res
.status(400)
.json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL'); const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
const getTx = db.prepare( const getTx = db.prepare(
@ -1580,7 +1837,7 @@ router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
})(); })();
} catch (err) { } catch (err) {
log.error('[import-historical] Transaction failed:', err.message); log.error('[import-historical] Transaction failed:', err.message);
throw new ApiError('DB_ERROR', 'Import failed', 500); return res.status(500).json(standardizeError('Import failed', 'DB_ERROR'));
} }
res.json({ imported, late_attributions: lateAttributions }); res.json({ imported, late_attributions: lateAttributions });
@ -1593,8 +1850,9 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10); const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1) if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
throw ValidationError('Invalid id'); return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const enabled = req.body?.enabled ? 1 : 0; const enabled = req.body?.enabled ? 1 : 0;
const changes = db const changes = db
@ -1603,7 +1861,7 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
) )
.run(enabled, ruleId, req.user.id, billId).changes; .run(enabled, ruleId, req.user.id, billId).changes;
if (changes === 0) throw NotFoundError('Rule not found'); if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
res.json({ id: ruleId, auto_attribute_late: enabled === 1 }); res.json({ id: ruleId, auto_attribute_late: enabled === 1 });
}); });
@ -1612,14 +1870,15 @@ router.delete('/:id/merchant-rules/:ruleId', (req: Req, res: Res) => {
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
const ruleId = parseInt(req.params.ruleId, 10); const ruleId = parseInt(req.params.ruleId, 10);
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1) if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
throw ValidationError('Invalid id'); return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found'); if (!requireBill(db, billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const changes = db const changes = db
.prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?') .prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?')
.run(ruleId, req.user.id, billId).changes; .run(ruleId, req.user.id, billId).changes;
if (changes === 0) throw NotFoundError('Rule not found'); if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
res.json({ success: true }); res.json({ success: true });
}); });

View File

@ -4,7 +4,7 @@ import type { Req, Res, Next } from '../types/http';
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts');
const { const {
decorateDataSource, decorateDataSource,
ensureManualDataSource, ensureManualDataSource,
@ -26,19 +26,10 @@ function cleanFilter(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
// SimpleFIN/service errors are deliberately surfaced to the user — but only function safeError(err, fallback) {
// after sanitizeErrorMessage strips tokens/URLs. Wrapping in ApiError keeps
// the sanitized message through the terminal handler (raw 5xx would be masked).
function toSourceError(err, fallback, code = 'SIMPLEFIN_ERROR') {
const msg = sanitizeErrorMessage(err?.message || fallback); const msg = sanitizeErrorMessage(err?.message || fallback);
const status = typeof err?.status === 'number' ? err.status : 500; const status = typeof err?.status === 'number' ? err.status : 500;
return new ApiError(err?.code || code, msg, status); return { msg, status };
}
function requireBankSyncEnabled() {
if (!getBankSyncConfig().enabled) {
throw new ApiError('BANK_SYNC_DISABLED', 'Bank sync is not enabled on this server', 503);
}
} }
// ─── GET /api/data-sources/accounts/all ────────────────────────────────────── // ─── GET /api/data-sources/accounts/all ──────────────────────────────────────
@ -46,10 +37,11 @@ function requireBankSyncEnabled() {
// Used by the bank tracking account picker. // Used by the bank tracking account picker.
router.get('/accounts/all', (req: Req, res: Res) => { router.get('/accounts/all', (req: Req, res: Res) => {
const db = getDb(); try {
const accounts = db const db = getDb();
.prepare( const accounts = db
` .prepare(
`
SELECT SELECT
fa.id, fa.name, fa.org_name, fa.account_type, fa.id, fa.name, fa.org_name, fa.account_type,
fa.balance, fa.available_balance, fa.currency, fa.balance, fa.available_balance, fa.currency,
@ -59,16 +51,19 @@ router.get('/accounts/all', (req: Req, res: Res) => {
WHERE fa.user_id = ? WHERE fa.user_id = ?
ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC
`, `,
) )
.all(req.user.id); .all(req.user.id);
res.json( res.json(
accounts.map((a) => ({ accounts.map((a) => ({
...a, ...a,
monitored: a.monitored === 1, monitored: a.monitored === 1,
balance_dollars: a.balance !== null ? a.balance / 100 : null, balance_dollars: a.balance !== null ? a.balance / 100 : null,
})), })),
); );
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
}
}); });
// ─── GET /api/data-sources ──────────────────────────────────────────────────── // ─── GET /api/data-sources ────────────────────────────────────────────────────
@ -81,9 +76,21 @@ router.get('/', (req: Req, res: Res) => {
const status = cleanFilter(req.query.status); const status = cleanFilter(req.query.status);
if (type && !VALID_TYPES.has(type)) if (type && !VALID_TYPES.has(type))
throw ValidationError('type must be manual, file_import, or provider_sync', 'type'); return res
.status(400)
.json(
standardizeError(
'type must be manual, file_import, or provider_sync',
'VALIDATION_ERROR',
'type',
),
);
if (status && !VALID_STATUSES.has(status)) if (status && !VALID_STATUSES.has(status))
throw ValidationError('status must be active, inactive, or error', 'status'); return res
.status(400)
.json(
standardizeError('status must be active, inactive, or error', 'VALIDATION_ERROR', 'status'),
);
let query = ` let query = `
SELECT SELECT
@ -152,11 +159,17 @@ router.get('/simplefin/status', (req: Req, res: Res) => {
// ─── POST /api/data-sources/simplefin/connect ──────────────────────────────── // ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
router.post('/simplefin/connect', async (req: Req, res: Res) => { router.post('/simplefin/connect', async (req: Req, res: Res) => {
requireBankSyncEnabled(); if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : ''; const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : '';
if (!setupToken) { if (!setupToken) {
throw ValidationError('setupToken is required', 'setupToken'); return res
.status(400)
.json(standardizeError('setupToken is required', 'VALIDATION_ERROR', 'setupToken'));
} }
try { try {
@ -164,7 +177,8 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
const result = await connectSimplefin(db, req.user.id, setupToken); const result = await connectSimplefin(db, req.user.id, setupToken);
res.status(201).json(result); res.status(201).json(result);
} catch (err) { } catch (err) {
throw toSourceError(err, 'Failed to connect SimpleFIN'); const { msg, status } = safeError(err, 'Failed to connect SimpleFIN');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
@ -173,19 +187,23 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
router.get('/:sourceId/accounts', (req: Req, res: Res) => { router.get('/:sourceId/accounts', (req: Req, res: Res) => {
const sourceId = parseInt(req.params.sourceId, 10); const sourceId = parseInt(req.params.sourceId, 10);
if (!Number.isInteger(sourceId) || sourceId < 1) { if (!Number.isInteger(sourceId) || sourceId < 1) {
throw ValidationError('Invalid data source id', 'sourceId'); return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId'));
} }
const db = getDb(); try {
const db = getDb();
const source = db const source = db
.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?') .prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?')
.get(sourceId, req.user.id); .get(sourceId, req.user.id);
if (!source) throw NotFoundError('Data source not found'); if (!source)
return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND'));
const accounts = db const accounts = db
.prepare( .prepare(
` `
SELECT SELECT
fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type, fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type,
fa.balance, fa.available_balance, fa.currency, fa.monitored, fa.balance, fa.available_balance, fa.currency, fa.monitored,
@ -197,10 +215,10 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
GROUP BY fa.id GROUP BY fa.id
ORDER BY fa.name COLLATE NOCASE ASC ORDER BY fa.name COLLATE NOCASE ASC
`, `,
) )
.all(sourceId, req.user.id); .all(sourceId, req.user.id);
const txStmt = db.prepare(` const txStmt = db.prepare(`
SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency, SELECT t.id, t.posted_date, t.transacted_at, t.amount, t.currency,
t.payee, t.description, t.memo, t.match_status, t.ignored, t.payee, t.description, t.memo, t.match_status, t.ignored,
t.matched_bill_id, b.name AS matched_bill_name t.matched_bill_id, b.name AS matched_bill_name
@ -211,13 +229,16 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
LIMIT 50 LIMIT 50
`); `);
const result = accounts.map((acc) => ({ const result = accounts.map((acc) => ({
...acc, ...acc,
monitored: acc.monitored === 1, monitored: acc.monitored === 1,
transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [], transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [],
})); }));
res.json(result); res.json(result);
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
}
}); });
// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ───────────────────── // ─── PUT /api/data-sources/:sourceId/accounts/:accountId ─────────────────────
@ -231,39 +252,52 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
!Number.isInteger(accountId) || !Number.isInteger(accountId) ||
accountId < 1 accountId < 1
) { ) {
throw ValidationError('Invalid id'); return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
} }
if (typeof req.body?.monitored !== 'boolean') { if (typeof req.body?.monitored !== 'boolean') {
throw ValidationError('monitored must be a boolean', 'monitored'); return res
.status(400)
.json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored'));
} }
const db = getDb(); try {
const result = db const db = getDb();
.prepare( const result = db
` .prepare(
`
UPDATE financial_accounts UPDATE financial_accounts
SET monitored = ?, updated_at = datetime('now') SET monitored = ?, updated_at = datetime('now')
WHERE id = ? AND data_source_id = ? AND user_id = ? WHERE id = ? AND data_source_id = ? AND user_id = ?
`, `,
) )
.run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id); .run(req.body.monitored ? 1 : 0, accountId, sourceId, req.user.id);
if (result.changes === 0) throw NotFoundError('Account not found'); if (result.changes === 0)
return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND'));
const account = db const account = db
.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?') .prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?')
.get(accountId); .get(accountId);
res.json({ ...account, monitored: account.monitored === 1 }); res.json({ ...account, monitored: account.monitored === 1 });
} catch (err) {
res.status(500).json(standardizeError(err.message || 'Failed to update account', 'DB_ERROR'));
}
}); });
// ─── POST /api/data-sources/:id/sync ───────────────────────────────────────── // ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => { router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
requireBankSyncEnabled(); if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
throw ValidationError('Invalid data source id', 'id'); return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
@ -271,7 +305,8 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
const result = await syncDataSource(db, req.user.id, id); const result = await syncDataSource(db, req.user.id, id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
throw toSourceError(err, 'Sync failed'); const { msg, status } = safeError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
@ -279,7 +314,11 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
// Syncs every SimpleFIN source for the current user. Returns aggregated stats. // Syncs every SimpleFIN source for the current user. Returns aggregated stats.
router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => { router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
requireBankSyncEnabled(); if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
try { try {
const db = getDb(); const db = getDb();
@ -290,7 +329,7 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
.all(req.user.id); .all(req.user.id);
if (sources.length === 0) { if (sources.length === 0) {
throw NotFoundError('No SimpleFIN connections found'); return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND'));
} }
let accountsUpserted = 0; let accountsUpserted = 0;
@ -325,18 +364,25 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
errors, errors,
}); });
} catch (err) { } catch (err) {
throw toSourceError(err, 'Sync failed'); const { msg, status } = safeError(err, 'Sync failed');
res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR'));
} }
}); });
// ─── POST /api/data-sources/:id/backfill ───────────────────────────────────── // ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => { router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
requireBankSyncEnabled(); if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
throw ValidationError('Invalid data source id', 'id'); return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
@ -344,25 +390,33 @@ router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
const result = await backfillDataSource(db, req.user.id, id); const result = await backfillDataSource(db, req.user.id, id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
throw toSourceError(err, 'Backfill failed'); const { msg, status } = safeError(err, 'Backfill failed');
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
} }
}); });
// ─── DELETE /api/data-sources/:id ──────────────────────────────────────────── // ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
router.delete('/:id', (req: Req, res: Res) => { router.delete('/:id', (req: Req, res: Res) => {
requireBankSyncEnabled(); if (!getBankSyncConfig().enabled) {
return res
.status(503)
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
}
const id = parseInt(req.params.id, 10); const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) { if (!Number.isInteger(id) || id < 1) {
throw ValidationError('Invalid data source id', 'id'); return res
.status(400)
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
} }
try { try {
disconnectDataSource(getDb(), req.user.id, id); disconnectDataSource(getDb(), req.user.id, id);
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { } catch (err) {
throw toSourceError(err, 'Failed to disconnect', 'DISCONNECT_ERROR'); const { msg, status } = safeError(err, 'Failed to disconnect');
res.status(status).json(standardizeError(msg, err?.code || 'DISCONNECT_ERROR'));
} }
}); });

View File

@ -342,19 +342,11 @@ function buildUserDbExportFile(userId) {
router.get('/user-db', (req: Req, res: Res) => { router.get('/user-db', (req: Req, res: Res) => {
const file = buildUserDbExportFile(req.user.id); const file = buildUserDbExportFile(req.user.id);
// root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep) res.download(file, 'bill-tracker-user-export.sqlite', () => {
res.download( try {
path.basename(file), fs.unlinkSync(file);
'bill-tracker-user-export.sqlite', } catch {}
{ });
root: path.dirname(file),
},
() => {
try {
fs.unlinkSync(file);
} catch {}
},
);
}); });
// Full portable JSON export of the user's data (same assembly as SQLite/Excel). // Full portable JSON export of the user's data (same assembly as SQLite/Excel).

View File

@ -39,9 +39,7 @@ function makeErrorId() {
} }
function sendImportError(res, err, fallback, defaultCode) { function sendImportError(res, err, fallback, defaultCode) {
// Only 4xx service errors carry a user-facing message; a thrown error with if (err.status) {
// an explicit 5xx status must fall through to the masked error-id branch.
if (err.status && err.status < 500) {
return res.status(err.status).json({ return res.status(err.status).json({
error: fallback, error: fallback,
message: err.message, message: err.message,

View File

@ -1,13 +1,9 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const router = require('express').Router(); const router = require('express').Router();
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts');
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { const {
applyBankPaymentAsSourceOfTruth, applyBankPaymentAsSourceOfTruth,
reactivatePaymentsOverriddenBy, reactivatePaymentsOverriddenBy,
@ -21,23 +17,32 @@ const { markMatched, markUnmatched } = require('../services/transactionMatchStat
const { serializePayment } = require('../services/paymentValidation.cts'); const { serializePayment } = require('../services/paymentValidation.cts');
const { todayLocal } = require('../utils/dates.mts'); const { todayLocal } = require('../utils/dates.mts');
// 4xx service errors keep their message/code on the wire; anything else is function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
// rethrown raw so the terminal handler masks + logs it as a 5xx. if (err.status) {
function toMatchError(err) { return res
if (err.status && err.status < 500) { .status(err.status)
return new ApiError(err.code || 'MATCH_ERROR', err.message, err.status, { field: err.field }); .json(standardizeError(err.message, err.code || 'MATCH_ERROR', err.field));
} }
return err; log.error('[matches] service error:', err.stack || err.message);
return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR'));
} }
// GET /api/matches/suggestions // GET /api/matches/suggestions
router.get('/suggestions', (req: Req, res: Res) => { router.get('/suggestions', (req: Req, res: Res) => {
res.json(listMatchSuggestions(req.user.id, req.query)); try {
res.json(listMatchSuggestions(req.user.id, req.query));
} catch (err) {
return sendMatchError(res, err, 'Match suggestions failed');
}
}); });
// POST /api/matches/:id/reject // POST /api/matches/:id/reject
router.post('/:id/reject', (req: Req, res: Res) => { router.post('/:id/reject', (req: Req, res: Res) => {
res.json(rejectMatchSuggestion(req.user.id, req.params.id)); try {
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
} catch (err) {
return sendMatchError(res, err, 'Rejecting match suggestion failed');
}
}); });
// POST /api/matches/confirm — link a transaction to a bill and record a payment // POST /api/matches/confirm — link a transaction to a bill and record a payment
@ -45,36 +50,46 @@ router.post('/confirm', (req: Req, res: Res) => {
const txId = parseInt(req.body?.transaction_id, 10); const txId = parseInt(req.body?.transaction_id, 10);
const billId = parseInt(req.body?.bill_id, 10); const billId = parseInt(req.body?.bill_id, 10);
if (!Number.isInteger(txId) || !Number.isInteger(billId)) { if (!Number.isInteger(txId) || !Number.isInteger(billId)) {
throw ValidationError('transaction_id and bill_id are required integers'); return res
.status(400)
.json(
standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR'),
);
} }
const db = getDb(); const db = getDb();
const tx = db const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
.get(txId, req.user.id); .get(txId, req.user.id);
if (!tx) throw NotFoundError('Transaction not found', 'transaction_id'); if (!tx)
return res
.status(404)
.json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id'));
if (tx.match_status === 'matched') { if (tx.match_status === 'matched') {
throw ConflictError( return res
'Transaction is already matched to a bill', .status(409)
'transaction_id', .json(
'ALREADY_MATCHED', standardizeError(
); 'Transaction is already matched to a bill',
'ALREADY_MATCHED',
'transaction_id',
),
);
} }
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const existing = db const existing = db
.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL')
.get(txId); .get(txId);
if (existing) if (existing)
throw ConflictError( return res
'A payment is already linked to this transaction', .status(409)
undefined, .json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH'));
'DUPLICATE_MATCH',
);
const paidDate = const paidDate =
tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal()); tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal());
@ -119,7 +134,7 @@ router.post('/confirm', (req: Req, res: Res) => {
try { try {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
} catch {} } catch {}
throw toMatchError(err); return sendMatchError(res, err, 'Failed to confirm match');
} }
}); });
@ -127,16 +142,18 @@ router.post('/confirm', (req: Req, res: Res) => {
router.post('/:transactionId/unmatch', (req: Req, res: Res) => { router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
const txId = parseInt(req.params.transactionId, 10); const txId = parseInt(req.params.transactionId, 10);
if (!Number.isInteger(txId)) { if (!Number.isInteger(txId)) {
throw ValidationError('transactionId must be an integer'); return res
.status(400)
.json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR'));
} }
const db = getDb(); const db = getDb();
const tx = db const tx = db
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?') .prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
.get(txId, req.user.id); .get(txId, req.user.id);
if (!tx) throw NotFoundError('Transaction not found'); if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND'));
if (tx.match_status !== 'matched') { if (tx.match_status !== 'matched') {
throw ConflictError('Transaction is not matched', undefined, 'NOT_MATCHED'); return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED'));
} }
try { try {
@ -166,7 +183,7 @@ router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
try { try {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
} catch {} } catch {}
throw toMatchError(err); return sendMatchError(res, err, 'Failed to unmatch transaction');
} }
}); });

View File

@ -9,7 +9,6 @@ const { sendTestEmail } = require('../services/notificationService.cts');
const { sendTestPush } = require('../services/notificationService.cts')._push || {}; const { sendTestPush } = require('../services/notificationService.cts')._push || {};
const { decryptSecret } = require('../services/encryptionService.cts'); const { decryptSecret } = require('../services/encryptionService.cts');
const { encryptSecret } = require('../services/encryptionService.cts'); const { encryptSecret } = require('../services/encryptionService.cts');
const { ApiError, ValidationError } = require('../utils/apiError.cts');
// ── Admin: SMTP configuration ───────────────────────────────────────────────── // ── Admin: SMTP configuration ─────────────────────────────────────────────────
@ -75,16 +74,13 @@ router.put('/admin', requireAuth, requireAdmin, (req: Req, res: Res) => {
// POST /api/notifications/test — send a test email // POST /api/notifications/test — send a test email
router.post('/test', requireAuth, requireAdmin, async (req: Req, res: Res) => { router.post('/test', requireAuth, requireAdmin, async (req: Req, res: Res) => {
const { to } = req.body; const { to } = req.body;
if (!to) throw ValidationError('Recipient address required', 'to'); if (!to) return res.status(400).json({ error: 'Recipient address required' });
try { try {
await sendTestEmail(to); await sendTestEmail(to);
res.json({ success: true });
} catch (err) { } catch (err) {
// Deliberate pass-through: the SMTP error text is the admin's own config res.status(500).json({ error: err.message });
// feedback (bad host/auth/TLS). ApiError messages survive formatError;
// anything not wrapped would be masked as a generic 5xx.
throw new ApiError('NOTIFICATION_TEST_FAILED', err.message || 'Test email failed', 502);
} }
res.json({ success: true });
}); });
// ── User: notification preferences ─────────────────────────────────────────── // ── User: notification preferences ───────────────────────────────────────────
@ -168,7 +164,7 @@ router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) =
.get(req.user.id); .get(req.user.id);
if (!user?.push_channel || !user?.push_url) { if (!user?.push_channel || !user?.push_url) {
throw ValidationError('Push notification channel not configured', 'push_channel'); return res.status(400).json({ error: 'Push notification channel not configured' });
} }
// Decrypt for sending // Decrypt for sending
@ -189,12 +185,10 @@ router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) =
try { try {
if (!sendTestPush) throw new Error('Push service not initialised'); if (!sendTestPush) throw new Error('Push service not initialised');
await sendTestPush(userForPush); await sendTestPush(userForPush);
res.json({ success: true });
} catch (err) { } catch (err) {
// Deliberate pass-through: the push-provider error is the user's own res.status(500).json({ error: err.message });
// channel-config feedback (bad URL/token) — same rationale as /test above.
throw new ApiError('NOTIFICATION_TEST_FAILED', err.message || 'Test push failed', 502);
} }
res.json({ success: true });
}); });
module.exports = router; module.exports = router;

View File

@ -1,13 +1,8 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts');
const {
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts'); const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts');
@ -32,22 +27,38 @@ function isTransactionLinkedPayment(payment) {
return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null; return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
} }
function rejectTransactionLinkedPayment() { function rejectTransactionLinkedPayment(res) {
throw ConflictError( return res
'Transaction-linked payments must be changed through transaction match controls', .status(409)
'transaction_id', .json(
'TRANSACTION_PAYMENT_LOCKED', standardizeError(
); 'Transaction-linked payments must be changed through transaction match controls',
'TRANSACTION_PAYMENT_LOCKED',
'transaction_id',
),
);
} }
function parseYearMonth(body) { function parseYearMonth(body) {
const year = parseInt(body.year, 10); const year = parseInt(body.year, 10);
const month = parseInt(body.month, 10); const month = parseInt(body.month, 10);
if (!Number.isInteger(year) || year < 2000 || year > 2100) { if (!Number.isInteger(year) || year < 2000 || year > 2100) {
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); return {
error: standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
};
} }
if (!Number.isInteger(month) || month < 1 || month > 12) { if (!Number.isInteger(month) || month < 1 || month > 12) {
throw ValidationError('month must be an integer between 1 and 12', 'month'); return {
error: standardizeError(
'month must be an integer between 1 and 12',
'VALIDATION_ERROR',
'month',
),
};
} }
return { year, month }; return { year, month };
} }
@ -62,9 +73,17 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
`, `,
) )
.get(billId, userId); .get(billId, userId);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') { if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
throw ValidationError('Bill is not eligible for autopay suggestions', 'bill_id'); return {
error: standardizeError(
'Bill is not eligible for autopay suggestions',
'VALIDATION_ERROR',
'bill_id',
),
status: 400,
};
} }
const state = db const state = db
@ -77,12 +96,26 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
) )
.get(bill.id, year, month); .get(bill.id, year, month);
if (state?.is_skipped) { if (state?.is_skipped) {
throw ValidationError('Skipped bills cannot be suggested for payment', 'bill_id'); return {
error: standardizeError(
'Skipped bills cannot be suggested for payment',
'VALIDATION_ERROR',
'bill_id',
),
status: 400,
};
} }
const dueDate = resolveDueDate(bill, year, month); const dueDate = resolveDueDate(bill, year, month);
if (!dueDate) { if (!dueDate) {
throw ValidationError('Bill does not occur in the selected month', 'month'); return {
error: standardizeError(
'Bill does not occur in the selected month',
'VALIDATION_ERROR',
'month',
),
status: 400,
};
} }
const amount = fromCents(state?.actual_amount ?? bill.expected_amount); const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
return { bill, dueDate, amount }; return { bill, dueDate, amount };
@ -95,7 +128,15 @@ router.get('/', (req: Req, res: Res) => {
// Validate year/month when provided // Validate year/month when provided
if ((year || month) && !(year && month)) { if ((year || month) && !(year && month)) {
throw ValidationError('Both year and month are required when filtering by date', 'year'); return res
.status(400)
.json(
standardizeError(
'Both year and month are required when filtering by date',
'VALIDATION_ERROR',
'year',
),
);
} }
let y, m; let y, m;
@ -103,10 +144,26 @@ router.get('/', (req: Req, res: Res) => {
y = parseInt(year, 10); y = parseInt(year, 10);
m = parseInt(month, 10); m = parseInt(month, 10);
if (!Number.isInteger(y) || y < 2000 || y > 2100) { if (!Number.isInteger(y) || y < 2000 || y > 2100) {
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); return res
.status(400)
.json(
standardizeError(
'year must be a 4-digit integer between 2000 and 2100',
'VALIDATION_ERROR',
'year',
),
);
} }
if (!Number.isInteger(m) || m < 1 || m > 12) { if (!Number.isInteger(m) || m < 1 || m > 12) {
throw ValidationError('month must be an integer between 1 and 12', 'month'); return res
.status(400)
.json(
standardizeError(
'month must be an integer between 1 and 12',
'VALIDATION_ERROR',
'month',
),
);
} }
} }
@ -171,7 +228,8 @@ router.get('/:id', (req: Req, res: Res) => {
`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) throw NotFoundError('Payment not found', 'id'); if (!payment)
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
res.json(serializePayment(payment)); res.json(serializePayment(payment));
}); });
@ -188,42 +246,51 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) throw NotFoundError('Payment not found', 'id'); if (!payment)
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (payment.payment_source !== 'provider_sync') { if (payment.payment_source !== 'provider_sync') {
throw ConflictError( return res
'Only provider_sync payments can be undone here', .status(409)
undefined, .json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH'));
'NOT_AUTO_MATCH',
);
} }
if (!payment.transaction_id) { if (!payment.transaction_id) {
throw ConflictError('Payment has no linked transaction', undefined, 'NO_TRANSACTION'); return res
.status(409)
.json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION'));
} }
db.transaction(() => { try {
// Restore balance (same logic as DELETE /:id) db.transaction(() => {
if (!payment.accounting_excluded && payment.balance_delta != null) { // Restore balance (same logic as DELETE /:id)
const bill = db if (!payment.accounting_excluded && payment.balance_delta != null) {
.prepare('SELECT current_balance FROM bills WHERE id = ?') const bill = db
.get(payment.bill_id); .prepare('SELECT current_balance FROM bills WHERE id = ?')
if (bill?.current_balance != null) { .get(payment.bill_id);
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta)); if (bill?.current_balance != null) {
db.prepare( const restored = Math.max(
` 0,
Number(bill.current_balance) - Number(payment.balance_delta),
);
db.prepare(
`
UPDATE bills UPDATE bills
SET current_balance = ?, SET current_balance = ?,
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END, interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`, `,
).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id); ).run(restored, payment.interest_delta != null ? 1 : 0, payment.bill_id);
}
} }
} reactivatePaymentsOverriddenBy(db, payment.id);
reactivatePaymentsOverriddenBy(db, payment.id); db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id); markUnmatched(db, req.user.id, payment.transaction_id);
markUnmatched(db, req.user.id, payment.transaction_id); })();
})(); res.json({ success: true });
res.json({ success: true }); } catch (err) {
log.error('[payments] undo-auto error:', err.message);
res.status(500).json(standardizeError('Failed to undo auto-match', 'SERVER_ERROR'));
}
}); });
// POST /api/payments — create single payment // POST /api/payments — create single payment
@ -246,13 +313,18 @@ router.post('/', (req: Req, res: Res) => {
paid_date, paid_date,
payment_source: payment_source ?? 'manual', payment_source: payment_source ?? 'manual',
}); });
if (validation.error) throw ValidationError(validation.error, validation.field); if (validation.error) {
return res
.status(400)
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const payment = validation.normalized; const payment = validation.normalized;
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(payment.bill_id, req.user.id); .get(payment.bill_id, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
// The deliberate manual add must not *silently* drop a same-key payment — a user // The deliberate manual add must not *silently* drop a same-key payment — a user
// may legitimately record two identical payments on the same day, and dropping // may legitimately record two identical payments on the same day, and dropping
@ -320,12 +392,17 @@ router.post('/quick', (req: Req, res: Res) => {
{ bill_id }, { bill_id },
{ requireAmount: false, requirePaidDate: false }, { requireAmount: false, requirePaidDate: false },
); );
if (billValidation.error) throw ValidationError(billValidation.error, billValidation.field); if (billValidation.error) {
return res
.status(400)
.json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
}
const bill = db const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billValidation.normalized.bill_id, req.user.id); .get(billValidation.normalized.bill_id, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
{ {
@ -335,8 +412,11 @@ router.post('/quick', (req: Req, res: Res) => {
}, },
{ requireBillId: false }, { requireBillId: false },
); );
if (paymentValidation.error) if (paymentValidation.error) {
throw ValidationError(paymentValidation.error, paymentValidation.field); return res
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const payAmount = paymentValidation.normalized.amount; const payAmount = paymentValidation.normalized.amount;
const payDate = paymentValidation.normalized.paid_date; const payDate = paymentValidation.normalized.paid_date;
const paySource = paymentValidation.normalized.payment_source; const paySource = paymentValidation.normalized.payment_source;
@ -388,23 +468,32 @@ router.post('/quick', (req: Req, res: Res) => {
router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => { router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const ym = parseYearMonth(req.body); const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10); const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
throw ValidationError('bill_id must be an integer', 'bill_id'); return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month); const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month);
if (context.error) return res.status(context.status).json(context.error);
const { bill, dueDate, amount } = context; const { bill, dueDate, amount } = context;
if (dueDate > todayLocal()) { if (dueDate > todayLocal()) {
throw ValidationError('Autopay suggestion is not due yet', 'paid_date'); return res
.status(400)
.json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
} }
const paymentValidation = validatePaymentInput( const paymentValidation = validatePaymentInput(
{ amount, paid_date: dueDate }, { amount, paid_date: dueDate },
{ requireBillId: false }, { requireBillId: false },
); );
if (paymentValidation.error) if (paymentValidation.error) {
throw ValidationError(paymentValidation.error, paymentValidation.field); return res
.status(400)
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const suggestedPayment = paymentValidation.normalized; const suggestedPayment = paymentValidation.normalized;
const suggestionRange = getCycleRange(ym.year, ym.month, bill); const suggestionRange = getCycleRange(ym.year, ym.month, bill);
@ -471,17 +560,20 @@ router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => { router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const ym = parseYearMonth(req.body); const ym = parseYearMonth(req.body);
if (ym.error) return res.status(400).json(ym.error);
const billId = parseInt(req.params.billId, 10); const billId = parseInt(req.params.billId, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
throw ValidationError('bill_id must be an integer', 'bill_id'); return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
if ( if (
!db !db
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id) .get(billId, req.user.id)
) { ) {
throw NotFoundError('Bill not found', 'bill_id'); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
} }
db.prepare( db.prepare(
@ -510,11 +602,23 @@ router.post('/bulk', (req: Req, res: Res) => {
// Validate request body has payments array // Validate request body has payments array
if (!payments || !Array.isArray(payments)) if (!payments || !Array.isArray(payments))
throw ValidationError('Request body must contain a `payments` array', 'payments'); return res
.status(400)
.json(
standardizeError(
'Request body must contain a `payments` array',
'VALIDATION_ERROR',
'payments',
),
);
// Validate max items per request (50) // Validate max items per request (50)
if (payments.length > 50) if (payments.length > 50)
throw ValidationError('Maximum 50 items allowed per request', 'payments'); return res
.status(400)
.json(
standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments'),
);
// Validate each payment item // Validate each payment item
for (let i = 0; i < payments.length; i++) { for (let i = 0; i < payments.length; i++) {
@ -524,7 +628,15 @@ router.post('/bulk', (req: Req, res: Res) => {
{ fieldPrefix: `payments[${i}].` }, { fieldPrefix: `payments[${i}].` },
); );
if (validation.error) { if (validation.error) {
throw ValidationError(`Payment at index ${i}: ${validation.error}`, validation.field); return res
.status(400)
.json(
standardizeError(
`Payment at index ${i}: ${validation.error}`,
'VALIDATION_ERROR',
validation.field,
),
);
} }
} }
@ -604,15 +716,20 @@ router.put('/:id', (req: Req, res: Res) => {
`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!existing) throw NotFoundError('Payment not found', 'id'); if (!existing)
if (isTransactionLinkedPayment(existing)) rejectTransactionLinkedPayment(); return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);
const { amount, paid_date, method, notes, payment_source } = req.body; const { amount, paid_date, method, notes, payment_source } = req.body;
const validation = validatePaymentInput( const validation = validatePaymentInput(
{ amount, paid_date, payment_source }, { amount, paid_date, payment_source },
{ requireBillId: false, requireAmount: false, requirePaidDate: false }, { requireBillId: false, requireAmount: false, requirePaidDate: false },
); );
if (validation.error) throw ValidationError(validation.error, validation.field); if (validation.error) {
return res
.status(400)
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const nextAmount = validation.normalized.amount ?? existing.amount; const nextAmount = validation.normalized.amount ?? existing.amount;
const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date; const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
@ -707,8 +824,9 @@ router.delete('/:id', (req: Req, res: Res) => {
`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`, `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) throw NotFoundError('Payment not found', 'id'); if (!payment)
if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment(); return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Atomic: reverse the balance delta and soft-delete the payment together, so a // Atomic: reverse the balance delta and soft-delete the payment together, so a
// failure can't leave the balance restored while the payment is still active // failure can't leave the balance restored while the payment is still active
@ -750,8 +868,9 @@ router.post('/:id/restore', (req: Req, res: Res) => {
'SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL', 'SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.deleted_at IS NOT NULL AND b.user_id = ? AND b.deleted_at IS NULL',
) )
.get(req.params.id, req.user.id); .get(req.params.id, req.user.id);
if (!payment) throw NotFoundError('Deleted payment not found', 'id'); if (!payment)
if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment(); return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
// Atomic: re-apply the balance delta and un-delete the payment together, so a // Atomic: re-apply the balance delta and un-delete the payment together, so a
// failure can't leave the balance re-applied while the payment stays deleted // failure can't leave the balance re-applied while the payment stays deleted
@ -804,72 +923,90 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const paymentId = parseInt(req.params.id, 10); const paymentId = parseInt(req.params.id, 10);
if (!Number.isInteger(paymentId) || paymentId < 1) { if (!Number.isInteger(paymentId) || paymentId < 1) {
throw ValidationError('Invalid payment id'); return res.status(400).json(standardizeError('Invalid payment id', 'VALIDATION_ERROR'));
} }
const { paid_date } = req.body; const { paid_date } = req.body;
if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) { if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
throw ValidationError('paid_date must be YYYY-MM-DD', 'paid_date'); return res
.status(400)
.json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
} }
// Validate it is a real calendar date // Validate it is a real calendar date
const newDate = new Date(paid_date + 'T00:00:00Z'); const newDate = new Date(paid_date + 'T00:00:00Z');
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) { if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
throw ValidationError('paid_date is not a valid calendar date', 'paid_date'); return res
.status(400)
.json(
standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'),
);
} }
const payment = db try {
.prepare( const payment = db
` .prepare(
`
SELECT p.* FROM payments p SELECT p.* FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL
`, `,
) )
.get(paymentId, req.user.id); .get(paymentId, req.user.id);
if (!payment) throw NotFoundError('Payment not found'); if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND'));
// Only allow date-only reclassification for provider_sync payments // Only allow date-only reclassification for provider_sync payments
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') { if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
throw ConflictError( return res
'Only bank-synced payments can be reclassified to a different month', .status(409)
undefined, .json(
'RECLASSIFY_ONLY_SYNC', standardizeError(
'Only bank-synced payments can be reclassified to a different month',
'RECLASSIFY_ONLY_SYNC',
),
);
}
// Sanity check: new date must be in the month immediately before the original date
const orig = new Date(payment.paid_date + 'T00:00:00');
const origYM = orig.getFullYear() * 12 + orig.getMonth();
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
if (newYM !== origYM - 1) {
return res
.status(400)
.json(
standardizeError(
'The new paid_date must be in the month immediately before the original payment date',
'VALIDATION_ERROR',
'paid_date',
),
);
}
db.transaction(() => {
db.prepare(
"UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?",
).run(paid_date, paymentId);
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(payment.bill_id, req.user.id);
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
})();
res.json(
serializePayment(
db
.prepare(
`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
)
.get(paymentId, req.user.id),
),
); );
} catch (err) {
log.error('[payments] attribute-to-month error:', err.message);
res.status(500).json(standardizeError('Failed to reclassify payment date', 'DB_ERROR'));
} }
// Sanity check: new date must be in the month immediately before the original date
const orig = new Date(payment.paid_date + 'T00:00:00');
const origYM = orig.getFullYear() * 12 + orig.getMonth();
const newYM = newDate.getFullYear() * 12 + newDate.getMonth();
if (newYM !== origYM - 1) {
throw ValidationError(
'The new paid_date must be in the month immediately before the original payment date',
'paid_date',
);
}
db.transaction(() => {
db.prepare("UPDATE payments SET paid_date = ?, updated_at = datetime('now') WHERE id = ?").run(
paid_date,
paymentId,
);
const bill = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(payment.bill_id, req.user.id);
if (bill) markProvisionalManualPaymentsOverridden(db, bill, { ...payment, paid_date });
})();
res.json(
serializePayment(
db
.prepare(
`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`,
)
.get(paymentId, req.user.id),
),
);
}); });
module.exports = router; module.exports = router;

View File

@ -3,7 +3,7 @@ import type { Req, Res, Next } from '../types/http';
('use strict'); ('use strict');
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { passwordLimiter } = require('../middleware/rateLimiter.cts'); const { passwordLimiter } = require('../middleware/rateLimiter.cts');
@ -18,14 +18,7 @@ const {
} = require('../services/authService.cts'); } = require('../services/authService.cts');
const { getImportHistory } = require('../services/spreadsheetImportService.cts'); const { getImportHistory } = require('../services/spreadsheetImportService.cts');
const { logAudit } = require('../services/auditService.cts'); const { logAudit } = require('../services/auditService.cts');
const { const { standardizeError } = require('../middleware/errorFormatter.cts');
ApiError,
ValidationError,
AuthError,
ForbiddenError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts'); const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts');
// All profile routes require authentication — enforced in server.js. // All profile routes require authentication — enforced in server.js.
@ -37,7 +30,9 @@ function dataImportEnabled() {
function requireDataImportEnabled(req, res, next) { function requireDataImportEnabled(req, res, next) {
if (!dataImportEnabled()) { if (!dataImportEnabled()) {
throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false'); return res
.status(403)
.json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
} }
next(); next();
} }
@ -78,7 +73,7 @@ router.get('/', (req: Req, res: Res) => {
) )
.get(req.user.id); .get(req.user.id);
if (!user) throw NotFoundError('User not found'); if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ res.json({
...profileResponse(user), ...profileResponse(user),
@ -113,20 +108,20 @@ router.patch('/', (req: Req, res: Res) => {
if (username !== undefined) { if (username !== undefined) {
if (typeof username !== 'string') { if (typeof username !== 'string') {
throw ValidationError('username must be a string', 'username'); return res.status(400).json({ error: 'username must be a string' });
} }
const trimmedUsername = username.trim(); const trimmedUsername = username.trim();
if (trimmedUsername.length < 3) { if (trimmedUsername.length < 3) {
throw ValidationError('username must be at least 3 characters', 'username'); return res.status(400).json({ error: 'username must be at least 3 characters' });
} }
if (trimmedUsername.length > 50) { if (trimmedUsername.length > 50) {
throw ValidationError('username must be 50 characters or fewer', 'username'); return res.status(400).json({ error: 'username must be 50 characters or fewer' });
} }
const taken = db const taken = db
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?') .prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
.get(trimmedUsername, req.user.id); .get(trimmedUsername, req.user.id);
if (taken) { if (taken) {
throw ConflictError('Username already taken', 'username'); return res.status(409).json({ error: 'Username already taken' });
} }
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run( db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
trimmedUsername, trimmedUsername,
@ -142,11 +137,11 @@ router.patch('/', (req: Req, res: Res) => {
if (displayNameInput !== undefined) { if (displayNameInput !== undefined) {
if (typeof displayNameInput !== 'string') { if (typeof displayNameInput !== 'string') {
throw ValidationError('display_name must be a string', 'display_name'); return res.status(400).json({ error: 'display_name must be a string' });
} }
const trimmed = displayNameInput.trim(); const trimmed = displayNameInput.trim();
if (trimmed.length > 100) { if (trimmed.length > 100) {
throw ValidationError('display_name must be 100 characters or fewer', 'display_name'); return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
} }
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run( db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(
@ -193,7 +188,7 @@ router.get('/settings', (req: Req, res: Res) => {
) )
.get(req.user.id); .get(req.user.id);
if (!user) throw NotFoundError('User not found'); if (!user) return res.status(404).json({ error: 'User not found' });
// Decrypt push secrets — return masked indicator for token (never the raw value) // Decrypt push secrets — return masked indicator for token (never the raw value)
const pushUrlDecrypted = user.push_url const pushUrlDecrypted = user.push_url
@ -251,10 +246,10 @@ router.patch('/settings', (req: Req, res: Res) => {
if (nextEmail !== undefined && nextEmail !== null) { if (nextEmail !== undefined && nextEmail !== null) {
if (typeof nextEmail !== 'string') { if (typeof nextEmail !== 'string') {
throw ValidationError('notification_email must be a string', 'notification_email'); return res.status(400).json({ error: 'notification_email must be a string' });
} }
if (nextEmail.trim().length > 255) { if (nextEmail.trim().length > 255) {
throw ValidationError('notification_email is too long', 'notification_email'); return res.status(400).json({ error: 'notification_email is too long' });
} }
} }
@ -355,64 +350,67 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
const { current_password, new_password, confirm_new_password } = req.body; const { current_password, new_password, confirm_new_password } = req.body;
if (!current_password) { if (!current_password) {
throw ValidationError('current_password is required', 'current_password'); return res.status(400).json({ error: 'current_password is required' });
} }
if (!new_password) { if (!new_password) {
throw ValidationError('new_password is required', 'new_password'); return res.status(400).json({ error: 'new_password is required' });
} }
if (!confirm_new_password) { if (!confirm_new_password) {
throw ValidationError('confirm_new_password is required', 'confirm_new_password'); return res.status(400).json({ error: 'confirm_new_password is required' });
} }
if (new_password !== confirm_new_password) { if (new_password !== confirm_new_password) {
throw ValidationError('new passwords do not match', 'confirm_new_password'); return res.status(400).json({ error: 'new passwords do not match' });
} }
if (new_password.length < 8) { if (new_password.length < 8) {
throw ValidationError('new password must be at least 8 characters', 'new_password'); return res.status(400).json({ error: 'new password must be at least 8 characters' });
} }
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
if (!user) throw NotFoundError('User not found'); if (!user) return res.status(404).json({ error: 'User not found' });
const valid = await bcrypt.compare(current_password, user.password_hash); try {
if (!valid) { const valid = await bcrypt.compare(current_password, user.password_hash);
throw new ApiError('AUTH_ERROR', 'current password is incorrect', 401, { if (!valid) {
field: 'current_password', return res.status(401).json({ error: 'current password is incorrect' });
}); }
}
const hash = await hashPassword(new_password); const hash = await hashPassword(new_password);
db.prepare( db.prepare(
` `
UPDATE users UPDATE users
SET password_hash = ?, must_change_password = 0, SET password_hash = ?, must_change_password = 0,
last_password_change_at = datetime('now'), last_password_change_at = datetime('now'),
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`, `,
).run(hash, req.user.id); ).run(hash, req.user.id);
// Invalidate all other sessions for this user // Invalidate all other sessions for this user
const currentSessionId = req.cookies?.[COOKIE_NAME]; const currentSessionId = req.cookies?.[COOKIE_NAME];
if (currentSessionId) { if (currentSessionId) {
invalidateOtherSessions(req.user.id, currentSessionId); invalidateOtherSessions(req.user.id, currentSessionId);
// Rotate the current session ID for security // Rotate the current session ID for security
const newSessionId = rotateSessionId(currentSessionId, req.user.id); const newSessionId = rotateSessionId(currentSessionId, req.user.id);
if (newSessionId) { if (newSessionId) {
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req)); res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
}
} }
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true });
} catch (err) {
log.error('[profile] change-password error:', err.message);
res.status(500).json({ error: 'Password change failed' });
} }
logAudit({
user_id: req.user.id,
action: 'password.change',
ip_address: req.ip,
user_agent: req.get('user-agent'),
});
res.json({ success: true });
}); });
// ── GET /api/profile/exports ────────────────────────────────────────────────── // ── GET /api/profile/exports ──────────────────────────────────────────────────
@ -443,8 +441,13 @@ router.get('/exports', (req: Req, res: Res) => {
// Returns the signed-in user's import history. // Returns the signed-in user's import history.
// Delegates to the same service as GET /api/import/history. // Delegates to the same service as GET /api/import/history.
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => { router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
const history = getImportHistory(req.user.id); try {
res.json({ history }); const history = getImportHistory(req.user.id);
res.json({ history });
} catch (err) {
log.error('[profile] import-history error:', err.message);
res.status(500).json({ error: 'Failed to load import history' });
}
}); });
module.exports = router; module.exports = router;

View File

@ -1,9 +1,10 @@
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services). // @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
import type { Req, Res, Next } from '../types/http'; import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { ValidationError, NotFoundError } = require('../utils/apiError.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts');
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts'); const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts');
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts'); const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts');
const { serializeBill } = require('../services/billsService.cts'); const { serializeBill } = require('../services/billsService.cts');
@ -121,7 +122,15 @@ router.patch('/settings', (req: Req, res: Res) => {
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') { if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
val = parseFloat(extra_payment); val = parseFloat(extra_payment);
if (!Number.isFinite(val) || val < 0) { if (!Number.isFinite(val) || val < 0) {
throw ValidationError('extra_payment must be a non-negative number', 'extra_payment'); return res
.status(400)
.json(
standardizeError(
'extra_payment must be a non-negative number',
'VALIDATION_ERROR',
'extra_payment',
),
);
} }
} }
@ -252,7 +261,9 @@ function buildComparison(snowball, minimum_only) {
router.patch('/order', (req: Req, res: Res) => { router.patch('/order', (req: Req, res: Res) => {
const items = req.body; const items = req.body;
if (!Array.isArray(items)) { if (!Array.isArray(items)) {
throw ValidationError('Request body must be an array'); return res
.status(400)
.json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
} }
if (items.length === 0) { if (items.length === 0) {
return res.json({ success: true, updated: 0 }); return res.json({ success: true, updated: 0 });
@ -265,12 +276,24 @@ router.patch('/order', (req: Req, res: Res) => {
const id = parseInt(row?.id, 10); const id = parseInt(row?.id, 10);
const order = parseInt(row?.snowball_order, 10); const order = parseInt(row?.snowball_order, 10);
if (!Number.isInteger(id) || id <= 0) { if (!Number.isInteger(id) || id <= 0) {
throw ValidationError(`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`); return res
.status(400)
.json(
standardizeError(
`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`,
'VALIDATION_ERROR',
),
);
} }
if (!Number.isInteger(order) || order < 0) { if (!Number.isInteger(order) || order < 0) {
throw ValidationError( return res
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`, .status(400)
); .json(
standardizeError(
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
'VALIDATION_ERROR',
),
);
} }
parsed.push({ id, order }); parsed.push({ id, order });
} }
@ -350,170 +373,204 @@ function enrichPlanWithProgress(db, plan) {
// POST /api/snowball/plans — start a new snowball plan // POST /api/snowball/plans — start a new snowball plan
router.post('/plans', (req: Req, res: Res) => { router.post('/plans', (req: Req, res: Res) => {
const db = getDb(); try {
const userId = req.user.id; const db = getDb();
const { name, method, notes } = req.body; const userId = req.user.id;
const { name, method, notes } = req.body;
const planName = const planName =
typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan'; typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan';
const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball'; const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball';
const ramseyMode = isRamseyMode(userId); const ramseyMode = isRamseyMode(userId);
const debts = getDebtBills(userId, ramseyMode); const debts = getDebtBills(userId, ramseyMode);
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0); const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
if (activeDebts.length === 0) { if (activeDebts.length === 0) {
throw ValidationError( return res
'No debts with a balance found. Add a balance to at least one bill.', .status(400)
undefined, .json(
'NO_DEBTS', standardizeError(
'No debts with a balance found. Add a balance to at least one bill.',
'NO_DEBTS',
),
);
}
// Money fields on `debts` are stored as integer cents; the snowball/APR
// math and plan_snapshot are dollar-denominated, so convert before computing.
const debtsForMath = debts.map((b) => ({
...b,
current_balance: fromCents(b.current_balance),
minimum_payment: fromCents(b.minimum_payment),
}));
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId);
const extraCents = user?.snowball_extra_payment ?? 0;
const extra = fromCents(extraCents);
const now = new Date();
const snowball =
planMethod === 'avalanche'
? calculateAvalanche(debtsForMath, extra, now)
: calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
const interestSaved = Math.max(
0,
Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) /
100,
); );
}
// Money fields on `debts` are stored as integer cents; the snowball/APR const debtSnaps = debtsForMath.map((b, i) => {
// math and plan_snapshot are dollar-denominated, so convert before computing. const proj = snowball.debts?.find((d) => d.id === b.id);
const debtsForMath = debts.map((b) => ({ return {
...b, bill_id: b.id,
current_balance: fromCents(b.current_balance), name: b.name,
minimum_payment: fromCents(b.minimum_payment), starting_balance: b.current_balance ?? 0,
})); minimum_payment: b.minimum_payment ?? 0,
interest_rate: b.interest_rate ?? 0,
projected_payoff_month: proj?.payoff_month ?? null,
projected_payoff_date: proj?.payoff_date ?? null,
projected_total_interest: proj?.total_interest ?? null,
order: i,
};
});
const user = db.prepare('SELECT snowball_extra_payment FROM users WHERE id = ?').get(userId); const planSnapshot = JSON.stringify({
const extraCents = user?.snowball_extra_payment ?? 0; projected_payoff_date: snowball.payoff_date ?? null,
const extra = fromCents(extraCents); projected_months: snowball.months_to_freedom ?? null,
const now = new Date(); projected_total_interest: snowball.total_interest_paid ?? null,
minimum_only_months: minOnly.months_to_freedom ?? null,
interest_saved: interestSaved,
debts: debtSnaps,
});
const snowball = // Abandon any existing active/paused plan first
planMethod === 'avalanche' db.prepare(
? calculateAvalanche(debtsForMath, extra, now) `
: calculateSnowball(debtsForMath, extra, now);
const minOnly = calculateMinimumOnly(debtsForMath, now);
const interestSaved = Math.max(
0,
Math.round(((minOnly.total_interest_paid ?? 0) - (snowball.total_interest_paid ?? 0)) * 100) /
100,
);
const debtSnaps = debtsForMath.map((b, i) => {
const proj = snowball.debts?.find((d) => d.id === b.id);
return {
bill_id: b.id,
name: b.name,
starting_balance: b.current_balance ?? 0,
minimum_payment: b.minimum_payment ?? 0,
interest_rate: b.interest_rate ?? 0,
projected_payoff_month: proj?.payoff_month ?? null,
projected_payoff_date: proj?.payoff_date ?? null,
projected_total_interest: proj?.total_interest ?? null,
order: i,
};
});
const planSnapshot = JSON.stringify({
projected_payoff_date: snowball.payoff_date ?? null,
projected_months: snowball.months_to_freedom ?? null,
projected_total_interest: snowball.total_interest_paid ?? null,
minimum_only_months: minOnly.months_to_freedom ?? null,
interest_saved: interestSaved,
debts: debtSnaps,
});
// Abandon any existing active/paused plan first
db.prepare(
`
UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now') UPDATE snowball_plans SET status = 'abandoned', updated_at = datetime('now')
WHERE user_id = ? AND status IN ('active', 'paused') WHERE user_id = ? AND status IN ('active', 'paused')
`, `,
).run(userId); ).run(userId);
const result = db const result = db
.prepare( .prepare(
` `
INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at) INSERT INTO snowball_plans (user_id, name, method, status, extra_payment, plan_snapshot, notes, started_at, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now')) VALUES (?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
`, `,
) )
.run(userId, planName, planMethod, extraCents, planSnapshot, notes || null); .run(userId, planName, planMethod, extraCents, planSnapshot, notes || null);
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid); const plan = db
res.status(201).json(enrichPlanWithProgress(db, plan)); .prepare('SELECT * FROM snowball_plans WHERE id = ?')
.get(result.lastInsertRowid);
res.status(201).json(enrichPlanWithProgress(db, plan));
} catch (err) {
log.error('[snowball plans] POST error:', err.message);
res.status(500).json(standardizeError('Failed to start plan', 'PLAN_CREATE_ERROR'));
}
}); });
// GET /api/snowball/plans — list all plans for user // GET /api/snowball/plans — list all plans for user
router.get('/plans', (req: Req, res: Res) => { router.get('/plans', (req: Req, res: Res) => {
const db = getDb(); try {
const plans = db const db = getDb();
.prepare( const plans = db
` .prepare(
`
SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC
`, `,
) )
.all(req.user.id); .all(req.user.id);
res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) }); res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) });
} catch (err) {
log.error('[snowball plans] GET /plans error:', err.message);
res.status(500).json(standardizeError('Failed to load plans', 'PLAN_LIST_ERROR'));
}
}); });
// GET /api/snowball/plans/active — return the active or paused plan (or null) // GET /api/snowball/plans/active — return the active or paused plan (or null)
router.get('/plans/active', (req: Req, res: Res) => { router.get('/plans/active', (req: Req, res: Res) => {
const db = getDb(); try {
const plan = db const db = getDb();
.prepare( const plan = db
` .prepare(
`
SELECT * FROM snowball_plans SELECT * FROM snowball_plans
WHERE user_id = ? AND status IN ('active', 'paused') WHERE user_id = ? AND status IN ('active', 'paused')
ORDER BY created_at DESC LIMIT 1 ORDER BY created_at DESC LIMIT 1
`, `,
) )
.get(req.user.id); .get(req.user.id);
res.json(plan ? enrichPlanWithProgress(db, plan) : null); res.json(plan ? enrichPlanWithProgress(db, plan) : null);
} catch (err) {
log.error('[snowball plans] GET /plans/active error:', err.message);
res.status(500).json(standardizeError('Failed to load active plan', 'PLAN_ACTIVE_ERROR'));
}
}); });
// PATCH /api/snowball/plans/:id — update name or notes // PATCH /api/snowball/plans/:id — update name or notes
router.patch('/plans/:id', (req: Req, res: Res) => { router.patch('/plans/:id', (req: Req, res: Res) => {
const db = getDb(); try {
const id = parseInt(req.params.id, 10); const db = getDb();
if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid plan id', 'id'); const id = parseInt(req.params.id, 10);
const plan = db if (!Number.isInteger(id) || id <= 0)
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?') return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id'));
.get(id, req.user.id); const plan = db
if (!plan) throw NotFoundError('Plan not found'); .prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND'));
const { name, notes } = req.body; const { name, notes } = req.body;
const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name; const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name;
const newNotes = notes !== undefined ? notes || null : plan.notes; const newNotes = notes !== undefined ? notes || null : plan.notes;
db.prepare( db.prepare(
` `
UPDATE snowball_plans SET name = ?, notes = ?, updated_at = datetime('now') WHERE id = ? UPDATE snowball_plans SET name = ?, notes = ?, updated_at = datetime('now') WHERE id = ?
`, `,
).run(newName, newNotes, id); ).run(newName, newNotes, id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id); const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated)); res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
log.error('[snowball plans] PATCH error:', err.message);
res.status(500).json(standardizeError('Failed to update plan', 'PLAN_UPDATE_ERROR'));
}
}); });
// Shared status-transition handler for pause / resume / complete / abandon. // Shared status-transition handler for pause / resume / complete / abandon.
// `setSql` is a fixed constant per route (never user input). // `setSql` is a fixed constant per route (never user input).
function transitionPlan(req, res, { allowedFrom, setSql, past }) { function transitionPlan(req, res, { allowedFrom, setSql, action, past }) {
const db = getDb(); try {
const id = parseInt(req.params.id, 10); const db = getDb();
if (!Number.isInteger(id) || id <= 0) { const id = parseInt(req.params.id, 10);
throw ValidationError('Invalid plan id', 'id'); if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json(standardizeError('Invalid plan id', 'VALIDATION_ERROR', 'id'));
}
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) return res.status(404).json(standardizeError('Plan not found', 'NOT_FOUND'));
if (!allowedFrom.includes(plan.status)) {
return res
.status(400)
.json(
standardizeError(
`Only ${allowedFrom.join(' or ')} plans can be ${past}`,
'INVALID_PLAN_STATE',
),
);
}
db.prepare(
`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`,
).run(id);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} catch (err) {
log.error(`[snowball plans] ${action} error:`, err.message);
res.status(500).json(standardizeError(`Failed to ${action} plan`, 'PLAN_TRANSITION_ERROR'));
} }
const plan = db
.prepare('SELECT * FROM snowball_plans WHERE id = ? AND user_id = ?')
.get(id, req.user.id);
if (!plan) throw NotFoundError('Plan not found');
if (!allowedFrom.includes(plan.status)) {
throw ValidationError(
`Only ${allowedFrom.join(' or ')} plans can be ${past}`,
undefined,
'INVALID_PLAN_STATE',
);
}
db.prepare(`UPDATE snowball_plans SET ${setSql}, updated_at = datetime('now') WHERE id = ?`).run(
id,
);
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
res.json(enrichPlanWithProgress(db, updated));
} }
// POST /api/snowball/plans/:id/{pause,resume,complete,abandon} // POST /api/snowball/plans/:id/{pause,resume,complete,abandon}

View File

@ -3,9 +3,9 @@ import type { Req, Res, Next } from '../types/http';
('use strict'); ('use strict');
const express = require('express'); const express = require('express');
const { log } = require('../utils/logger.cts');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { ValidationError } = require('../utils/apiError.cts');
const { const {
getSpendingSummary, getSpendingSummary,
getSpendingTransactions, getSpendingTransactions,
@ -18,27 +18,31 @@ const {
getIncomeTransactions, getIncomeTransactions,
} = require('../services/spendingService.cts'); } = require('../services/spendingService.cts');
// Throws ValidationError on bad input; service errors propagate to the
// terminal handler (4xx with .status pass their message through, 5xx are
// masked + logged there — no per-route try/catch, per CODE_STANDARDS.md).
function parseYM(source) { function parseYM(source) {
const now = new Date(); const now = new Date();
const year = parseInt(source.year || now.getFullYear(), 10); const year = parseInt(source.year || now.getFullYear(), 10);
const month = parseInt(source.month || now.getMonth() + 1, 10); const month = parseInt(source.month || now.getMonth() + 1, 10);
if (isNaN(year) || year < 2000 || year > 2100) throw ValidationError('Invalid year', 'year'); if (isNaN(year) || year < 2000 || year > 2100) return { error: 'Invalid year' };
if (isNaN(month) || month < 1 || month > 12) throw ValidationError('Invalid month', 'month'); if (isNaN(month) || month < 1 || month > 12) return { error: 'Invalid month' };
return { year, month }; return { year, month };
} }
// GET /api/spending/summary?year=&month= // GET /api/spending/summary?year=&month=
router.get('/summary', (req: Req, res: Res) => { router.get('/summary', (req: Req, res: Res) => {
const ym = parseYM(req.query); const ym = parseYM(req.query);
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month)); if (ym.error) return res.status(400).json({ error: ym.error });
try {
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month));
} catch (err) {
log.error('[spending/summary]', err.message);
res.status(500).json({ error: 'Failed to load spending summary' });
}
}); });
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit= // GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
router.get('/transactions', (req: Req, res: Res) => { router.get('/transactions', (req: Req, res: Res) => {
const ym = parseYM(req.query); const ym = parseYM(req.query);
if (ym.error) return res.status(400).json({ error: ym.error });
const { category_id, page, limit } = req.query; const { category_id, page, limit } = req.query;
const categoryId = const categoryId =
@ -48,38 +52,56 @@ router.get('/transactions', (req: Req, res: Res) => {
? parseInt(category_id, 10) ? parseInt(category_id, 10)
: undefined; : undefined;
res.json( try {
getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, { res.json(
categoryId, getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, {
uncategorizedOnly: category_id === 'null', categoryId,
page: parseInt(page || '1', 10), uncategorizedOnly: category_id === 'null',
limit: Math.min(parseInt(limit || '50', 10), 200), page: parseInt(page || '1', 10),
}), limit: Math.min(parseInt(limit || '50', 10), 200),
); }),
);
} catch (err) {
log.error('[spending/transactions]', err.message);
res.status(500).json({ error: 'Failed to load transactions' });
}
}); });
// PATCH /api/spending/transactions/:id/category // PATCH /api/spending/transactions/:id/category
router.patch('/transactions/:id/category', (req: Req, res: Res) => { router.patch('/transactions/:id/category', (req: Req, res: Res) => {
const txId = parseInt(req.params.id, 10); const txId = parseInt(req.params.id, 10);
if (isNaN(txId)) throw ValidationError('Invalid transaction ID', 'id'); if (isNaN(txId)) return res.status(400).json({ error: 'Invalid transaction ID' });
const { category_id, save_rule } = req.body || {}; const { category_id, save_rule } = req.body || {};
const categoryId = const categoryId =
category_id === null || category_id === undefined ? null : parseInt(category_id, 10); category_id === null || category_id === undefined ? null : parseInt(category_id, 10);
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule); try {
res.json({ ok: true }); categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
res.json({ ok: true });
} catch (err) {
res
.status(err.status || 500)
.json({ error: err.message || 'Failed to categorize transaction' });
}
}); });
// GET /api/spending/budgets?year=&month= // GET /api/spending/budgets?year=&month=
router.get('/budgets', (req: Req, res: Res) => { router.get('/budgets', (req: Req, res: Res) => {
const ym = parseYM(req.query); const ym = parseYM(req.query);
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) }); if (ym.error) return res.status(400).json({ error: ym.error });
try {
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
} catch (err) {
log.error('[spending/budgets GET]', err.message);
res.status(500).json({ error: 'Failed to load budgets' });
}
}); });
// POST /api/spending/budgets/copy — copy all budgets from previous month into target month // POST /api/spending/budgets/copy — copy all budgets from previous month into target month
router.post('/budgets/copy', (req: Req, res: Res) => { router.post('/budgets/copy', (req: Req, res: Res) => {
const ym = parseYM(req.body || {}); const ym = parseYM(req.body || {});
if (ym.error) return res.status(400).json({ error: ym.error });
// Previous month // Previous month
let prevYear = ym.year, let prevYear = ym.year,
@ -89,24 +111,25 @@ router.post('/budgets/copy', (req: Req, res: Res) => {
prevYear--; prevYear--;
} }
const db = getDb(); try {
const prev = db const db = getDb();
.prepare( const prev = db
` .prepare(
`
SELECT category_id, amount FROM spending_budgets SELECT category_id, amount FROM spending_budgets
WHERE user_id = ? AND year = ? AND month = ? WHERE user_id = ? AND year = ? AND month = ?
`, `,
) )
.all(req.user.id, prevYear, prevMonth); .all(req.user.id, prevYear, prevMonth);
if (prev.length === 0) { if (prev.length === 0) {
return res.json({ return res.json({
copied: 0, copied: 0,
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month), budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
}); });
} }
const upsert = db.prepare(` const upsert = db.prepare(`
INSERT INTO spending_budgets (user_id, category_id, year, month, amount, updated_at) INSERT INTO spending_budgets (user_id, category_id, year, month, amount, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
@ -114,66 +137,95 @@ router.post('/budgets/copy', (req: Req, res: Res) => {
updated_at = datetime('now') updated_at = datetime('now')
`); `);
db.transaction(() => { db.transaction(() => {
for (const row of prev) upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount); for (const row of prev)
})(); upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
})();
res.json({ res.json({
copied: prev.length, copied: prev.length,
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month), budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
}); });
} catch (err) {
log.error('[spending/budgets/copy]', err.message);
res.status(500).json({ error: 'Failed to copy budgets' });
}
}); });
// PUT /api/spending/budgets — { category_id, year, month, amount } // PUT /api/spending/budgets — { category_id, year, month, amount }
router.put('/budgets', (req: Req, res: Res) => { router.put('/budgets', (req: Req, res: Res) => {
const { category_id, year, month, amount } = req.body || {}; const { category_id, year, month, amount } = req.body || {};
if (!category_id) throw ValidationError('category_id required', 'category_id'); if (!category_id) return res.status(400).json({ error: 'category_id required' });
const ym = parseYM({ year, month }); const ym = parseYM({ year, month });
if (ym.error) return res.status(400).json({ error: ym.error });
setSpendingBudget( try {
getDb(), setSpendingBudget(
req.user.id, getDb(),
parseInt(category_id, 10), req.user.id,
ym.year, parseInt(category_id, 10),
ym.month, ym.year,
amount ?? null, ym.month,
); amount ?? null,
res.json({ ok: true }); );
res.json({ ok: true });
} catch (err) {
log.error('[spending/budgets PUT]', err.message);
res.status(500).json({ error: 'Failed to save budget' });
}
}); });
// GET /api/spending/category-rules // GET /api/spending/category-rules
router.get('/category-rules', (req: Req, res: Res) => { router.get('/category-rules', (req: Req, res: Res) => {
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) }); try {
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
} catch (err) {
log.error('[spending/category-rules GET]', err.message);
res.status(500).json({ error: 'Failed to load rules' });
}
}); });
// POST /api/spending/category-rules — { category_id, merchant } // POST /api/spending/category-rules — { category_id, merchant }
router.post('/category-rules', (req: Req, res: Res) => { router.post('/category-rules', (req: Req, res: Res) => {
const { category_id, merchant } = req.body || {}; const { category_id, merchant } = req.body || {};
if (!category_id || !merchant) if (!category_id || !merchant)
throw ValidationError( return res.status(400).json({ error: 'category_id and merchant required' });
'category_id and merchant required', try {
!category_id ? 'category_id' : 'merchant', addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
); res.json({ ok: true });
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant); } catch (err) {
res.json({ ok: true }); log.error('[spending/category-rules POST]', err.message);
res.status(err.status || 500).json({ error: err.message || 'Failed to save rule' });
}
}); });
// DELETE /api/spending/category-rules/:id // DELETE /api/spending/category-rules/:id
router.delete('/category-rules/:id', (req: Req, res: Res) => { router.delete('/category-rules/:id', (req: Req, res: Res) => {
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10)); try {
res.json({ ok: true }); deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
res.json({ ok: true });
} catch (err) {
log.error('[spending/category-rules DELETE]', err.message);
res.status(500).json({ error: 'Failed to delete rule' });
}
}); });
// GET /api/spending/income?year=&month=&page= // GET /api/spending/income?year=&month=&page=
router.get('/income', (req: Req, res: Res) => { router.get('/income', (req: Req, res: Res) => {
const ym = parseYM(req.query); const ym = parseYM(req.query);
res.json( if (ym.error) return res.status(400).json({ error: ym.error });
getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, { try {
page: parseInt(req.query.page || '1', 10), res.json(
limit: Math.min(parseInt(req.query.limit || '50', 10), 200), getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
includeIgnored: req.query.include_ignored === 'true', page: parseInt(req.query.page || '1', 10),
}), limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
); includeIgnored: req.query.include_ignored === 'true',
}),
);
} catch (err) {
log.error('[spending/income]', err.message);
res.status(500).json({ error: 'Failed to load income transactions' });
}
}); });
module.exports = router; module.exports = router;

View File

@ -3,12 +3,7 @@ import type { Req, Res, Next } from '../types/http';
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, ensureUserDefaultCategories } = require('../db/database.cts'); const { getDb, ensureUserDefaultCategories } = require('../db/database.cts');
const { const { standardizeError } = require('../middleware/errorFormatter.cts');
ApiError,
ValidationError,
NotFoundError,
ConflictError,
} = require('../utils/apiError.cts');
const { fromCents } = require('../utils/money.mts'); const { fromCents } = require('../utils/money.mts');
const { const {
createSubscriptionFromRecommendation, createSubscriptionFromRecommendation,
@ -41,26 +36,45 @@ router.get('/recommendations', (req: Req, res: Res) => {
}); });
router.get('/transaction-matches', (req: Req, res: Res) => { router.get('/transaction-matches', (req: Req, res: Res) => {
res.json({ try {
transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query), res.json({
}); transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query),
});
} catch (err) {
res
.status(500)
.json(
standardizeError(
err.message || 'Failed to search subscription transactions',
'SUBSCRIPTION_SEARCH_ERROR',
),
);
}
}); });
router.post('/recommendations/decline', (req: Req, res: Res) => { router.post('/recommendations/decline', (req: Req, res: Res) => {
const { decline_key } = req.body || {}; const { decline_key } = req.body || {};
if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) { if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) {
throw ValidationError('decline_key is required', 'decline_key'); return res
.status(400)
.json(standardizeError('decline_key is required', 'VALIDATION_ERROR', 'decline_key'));
}
try {
const db = getDb();
declineRecommendation(db, req.user.id, decline_key);
recordSubscriptionFeedback(db, req.user.id, {
action: 'decline',
catalog_id: req.body?.catalog_id,
merchant: req.body?.merchant,
confidence: req.body?.confidence,
metadata: { decline_key },
});
res.json({ ok: true });
} catch (err) {
res
.status(500)
.json(standardizeError(err.message || 'Failed to decline recommendation', 'DECLINE_ERROR'));
} }
const db = getDb();
declineRecommendation(db, req.user.id, decline_key);
recordSubscriptionFeedback(db, req.user.id, {
action: 'decline',
catalog_id: req.body?.catalog_id,
merchant: req.body?.merchant,
confidence: req.body?.confidence,
metadata: { decline_key },
});
res.json({ ok: true });
}); });
// POST /api/subscriptions/recommendations/match-bill // POST /api/subscriptions/recommendations/match-bill
@ -74,10 +88,20 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => {
.slice(0, 50); .slice(0, 50);
if (!Number.isInteger(billId) || billId < 1) { if (!Number.isInteger(billId) || billId < 1) {
throw ValidationError('bill_id is required', 'bill_id'); return res
.status(400)
.json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
} }
if (txIds.length === 0) { if (txIds.length === 0) {
throw ValidationError('transaction_ids must be a non-empty array', 'transaction_ids'); return res
.status(400)
.json(
standardizeError(
'transaction_ids must be a non-empty array',
'VALIDATION_ERROR',
'transaction_ids',
),
);
} }
const db = getDb(); const db = getDb();
@ -86,7 +110,8 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => {
'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL', 'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
) )
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found', 'bill_id'); if (!bill)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const catalogId = parseInt(req.body?.catalog_id, 10); const catalogId = parseInt(req.body?.catalog_id, 10);
const catalogEntry = const catalogEntry =
Number.isInteger(catalogId) && catalogId > 0 Number.isInteger(catalogId) && catalogId > 0
@ -162,7 +187,15 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
.get(categoryId, req.user.id) .get(categoryId, req.user.id)
: null; : null;
if (!category) { if (!category) {
throw ValidationError('category_id is invalid for this user', 'category_id'); return res
.status(400)
.json(
standardizeError(
'category_id is invalid for this user',
'VALIDATION_ERROR',
'category_id',
),
);
} }
} }
try { try {
@ -179,14 +212,15 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
}); });
res.status(201).json(created); res.status(201).json(created);
} catch (err) { } catch (err) {
// Service validation errors are user-facing at 4xx (parity with the old res
// behavior: no-status service throws surfaced at 400, never as masked 5xx). .status(err.status || 400)
throw new ApiError( .json(
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR', standardizeError(
err.message || 'Could not create subscription', err.message || 'Could not create subscription',
err.status || 400, err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
{ field: err.field || null }, err.field || null,
); ),
);
} }
}); });
@ -194,23 +228,24 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
router.get('/catalog', (req: Req, res: Res) => { router.get('/catalog', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const catalogEntries = db try {
.prepare( const catalogEntries = db
` .prepare(
`
SELECT id, rank, name, category, subcategory, subscription_type, SELECT id, rank, name, category, subcategory, subscription_type,
website, starting_monthly_usd, starting_annual_usd website, starting_monthly_usd, starting_annual_usd
FROM subscription_catalog FROM subscription_catalog
ORDER BY rank ASC ORDER BY rank ASC
`, `,
) )
.all(); .all();
if (!catalogEntries.length) return res.json({ catalog: [] }); if (!catalogEntries.length) return res.json({ catalog: [] });
// User's subscription bills that are linked to a catalog entry // User's subscription bills that are linked to a catalog entry
const matchedBills = db const matchedBills = db
.prepare( .prepare(
` `
SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id, SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id,
b.cycle_type, b.billing_cycle b.cycle_type, b.billing_cycle
FROM bills b FROM bills b
@ -219,49 +254,56 @@ router.get('/catalog', (req: Req, res: Res) => {
AND b.deleted_at IS NULL AND b.deleted_at IS NULL
AND b.catalog_id IS NOT NULL AND b.catalog_id IS NOT NULL
`, `,
) )
.all(req.user.id);
const billByCatalogId = new Map(matchedBills.map((b) => [b.catalog_id, b]));
// User's custom descriptors
let userDescriptors = [];
try {
userDescriptors = db
.prepare('SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?')
.all(req.user.id); .all(req.user.id);
} catch {
/* pre-v0.96 */ const billByCatalogId = new Map(matchedBills.map((b) => [b.catalog_id, b]));
// User's custom descriptors
let userDescriptors = [];
try {
userDescriptors = db
.prepare(
'SELECT id, catalog_id, descriptor FROM user_catalog_descriptors WHERE user_id = ?',
)
.all(req.user.id);
} catch {
/* pre-v0.96 */
}
const userDescsByCatalogId = new Map();
for (const d of userDescriptors) {
if (!userDescsByCatalogId.has(d.catalog_id)) userDescsByCatalogId.set(d.catalog_id, []);
userDescsByCatalogId.get(d.catalog_id).push({ id: d.id, descriptor: d.descriptor });
}
const catalog = catalogEntries.map((entry) => {
const bill = billByCatalogId.get(entry.id) ?? null;
return {
...entry,
matched_bill: bill
? {
id: bill.id,
name: bill.name,
expected_amount: fromCents(bill.expected_amount),
active: !!bill.active,
monthly_equivalent: monthlyEquivalent(
fromCents(bill.expected_amount),
bill.cycle_type,
bill.billing_cycle,
),
}
: null,
user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
};
});
res.json({ catalog });
} catch (err) {
res
.status(500)
.json(standardizeError(err.message || 'Failed to load catalog', 'CATALOG_ERROR'));
} }
const userDescsByCatalogId = new Map();
for (const d of userDescriptors) {
if (!userDescsByCatalogId.has(d.catalog_id)) userDescsByCatalogId.set(d.catalog_id, []);
userDescsByCatalogId.get(d.catalog_id).push({ id: d.id, descriptor: d.descriptor });
}
const catalog = catalogEntries.map((entry) => {
const bill = billByCatalogId.get(entry.id) ?? null;
return {
...entry,
matched_bill: bill
? {
id: bill.id,
name: bill.name,
expected_amount: fromCents(bill.expected_amount),
active: !!bill.active,
monthly_equivalent: monthlyEquivalent(
fromCents(bill.expected_amount),
bill.cycle_type,
bill.billing_cycle,
),
}
: null,
user_descriptors: userDescsByCatalogId.get(entry.id) ?? [],
};
});
res.json({ catalog });
}); });
// Update which catalog entry a bill is linked to (or unlink with null) // Update which catalog entry a bill is linked to (or unlink with null)
@ -269,7 +311,7 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId) || billId < 1) { if (!Number.isInteger(billId) || billId < 1) {
throw ValidationError('Invalid bill ID'); return res.status(400).json(standardizeError('Invalid bill ID', 'VALIDATION_ERROR'));
} }
const bill = db const bill = db
@ -277,7 +319,7 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL', 'SELECT id, name, catalog_id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
) )
.get(billId, req.user.id); .get(billId, req.user.id);
if (!bill) throw NotFoundError('Bill not found'); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
const rawCatalogId = req.body?.catalog_id; const rawCatalogId = req.body?.catalog_id;
@ -296,12 +338,23 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
const catalogId = parseInt(rawCatalogId, 10); const catalogId = parseInt(rawCatalogId, 10);
if (!Number.isInteger(catalogId) || catalogId < 1) { if (!Number.isInteger(catalogId) || catalogId < 1) {
throw ValidationError('catalog_id must be a positive integer or null', 'catalog_id'); return res
.status(400)
.json(
standardizeError(
'catalog_id must be a positive integer or null',
'VALIDATION_ERROR',
'catalog_id',
),
);
} }
const catalogEntry = db const catalogEntry = db
.prepare('SELECT id FROM subscription_catalog WHERE id = ?') .prepare('SELECT id FROM subscription_catalog WHERE id = ?')
.get(catalogId); .get(catalogId);
if (!catalogEntry) throw NotFoundError('Catalog entry not found', 'catalog_id'); if (!catalogEntry)
return res
.status(404)
.json(standardizeError('Catalog entry not found', 'NOT_FOUND', 'catalog_id'));
db.prepare( db.prepare(
"UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?", "UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
@ -321,49 +374,70 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const catalogId = parseInt(req.params.catalogId, 10); const catalogId = parseInt(req.params.catalogId, 10);
if (!Number.isInteger(catalogId) || catalogId < 1) { if (!Number.isInteger(catalogId) || catalogId < 1) {
throw ValidationError('Invalid catalog ID'); return res.status(400).json(standardizeError('Invalid catalog ID', 'VALIDATION_ERROR'));
} }
const catalogEntry = db const catalogEntry = db
.prepare('SELECT id FROM subscription_catalog WHERE id = ?') .prepare('SELECT id FROM subscription_catalog WHERE id = ?')
.get(catalogId); .get(catalogId);
if (!catalogEntry) throw NotFoundError('Catalog entry not found'); if (!catalogEntry)
return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND'));
const descriptor = String(req.body?.descriptor ?? '').trim(); const descriptor = String(req.body?.descriptor ?? '').trim();
if (!descriptor) { if (!descriptor) {
throw ValidationError('descriptor is required', 'descriptor'); return res
.status(400)
.json(standardizeError('descriptor is required', 'VALIDATION_ERROR', 'descriptor'));
} }
if (descriptor.length > 100) { if (descriptor.length > 100) {
throw ValidationError('descriptor must be 100 characters or less', 'descriptor'); return res
.status(400)
.json(
standardizeError(
'descriptor must be 100 characters or less',
'VALIDATION_ERROR',
'descriptor',
),
);
} }
// Check for case-insensitive duplicate try {
const exists = db // Check for case-insensitive duplicate
.prepare( const exists = db
'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)', .prepare(
) 'SELECT id FROM user_catalog_descriptors WHERE user_id = ? AND catalog_id = ? AND LOWER(descriptor) = LOWER(?)',
.get(req.user.id, catalogId, descriptor); )
if (exists) { .get(req.user.id, catalogId, descriptor);
throw ConflictError( if (exists) {
'Descriptor already exists for this service', return res
'descriptor', .status(409)
'DUPLICATE_ERROR', .json(
); standardizeError(
'Descriptor already exists for this service',
'DUPLICATE_ERROR',
'descriptor',
),
);
}
const result = db
.prepare(
'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)',
)
.run(req.user.id, catalogId, descriptor);
recordSubscriptionFeedback(db, req.user.id, {
action: 'add_descriptor',
catalog_id: catalogId,
merchant: descriptor,
descriptor,
});
res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId });
} catch (err) {
res
.status(500)
.json(standardizeError(err.message || 'Failed to add descriptor', 'DESCRIPTOR_ADD_ERROR'));
} }
const result = db
.prepare(
'INSERT INTO user_catalog_descriptors (user_id, catalog_id, descriptor) VALUES (?, ?, ?)',
)
.run(req.user.id, catalogId, descriptor);
recordSubscriptionFeedback(db, req.user.id, {
action: 'add_descriptor',
catalog_id: catalogId,
merchant: descriptor,
descriptor,
});
res.status(201).json({ id: result.lastInsertRowid, descriptor, catalog_id: catalogId });
}); });
// Delete a user-added catalog descriptor // Delete a user-added catalog descriptor
@ -371,42 +445,53 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const descriptorId = parseInt(req.params.id, 10); const descriptorId = parseInt(req.params.id, 10);
if (!Number.isInteger(descriptorId) || descriptorId < 1) { if (!Number.isInteger(descriptorId) || descriptorId < 1) {
throw ValidationError('Invalid descriptor ID'); return res.status(400).json(standardizeError('Invalid descriptor ID', 'VALIDATION_ERROR'));
} }
const existing = db try {
.prepare( const existing = db
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?', .prepare(
) 'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?',
.get(descriptorId, req.user.id); )
const result = db .get(descriptorId, req.user.id);
.prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?') const result = db
.run(descriptorId, req.user.id); .prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?')
if (result.changes === 0) { .run(descriptorId, req.user.id);
throw NotFoundError('Descriptor not found'); if (result.changes === 0) {
return res.status(404).json(standardizeError('Descriptor not found', 'NOT_FOUND'));
}
if (existing) {
recordSubscriptionFeedback(db, req.user.id, {
action: 'delete_descriptor',
catalog_id: existing.catalog_id,
merchant: existing.descriptor,
descriptor: existing.descriptor,
});
}
res.json({ ok: true });
} catch (err) {
res
.status(500)
.json(
standardizeError(err.message || 'Failed to delete descriptor', 'DESCRIPTOR_DELETE_ERROR'),
);
} }
if (existing) {
recordSubscriptionFeedback(db, req.user.id, {
action: 'delete_descriptor',
catalog_id: existing.catalog_id,
merchant: existing.descriptor,
descriptor: existing.descriptor,
});
}
res.json({ ok: true });
}); });
router.patch('/:id', (req: Req, res: Res) => { router.patch('/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const billId = parseInt(req.params.id, 10); const billId = parseInt(req.params.id, 10);
if (!Number.isInteger(billId)) { if (!Number.isInteger(billId)) {
throw ValidationError('bill_id must be an integer', 'bill_id'); return res
.status(400)
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
} }
const existing = db const existing = db
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL') .prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
.get(billId, req.user.id); .get(billId, req.user.id);
if (!existing) throw NotFoundError('Bill not found', 'bill_id'); if (!existing)
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const allowedTypes = new Set([ const allowedTypes = new Set([
'streaming', 'streaming',
@ -445,7 +530,15 @@ router.patch('/:id', (req: Req, res: Res) => {
next.reminder_days_before < 0 || next.reminder_days_before < 0 ||
next.reminder_days_before > 30 next.reminder_days_before > 30
) { ) {
throw ValidationError('reminder_days_before must be between 0 and 30', 'reminder_days_before'); return res
.status(400)
.json(
standardizeError(
'reminder_days_before must be between 0 and 30',
'VALIDATION_ERROR',
'reminder_days_before',
),
);
} }
db.prepare( db.prepare(

View File

@ -8,7 +8,6 @@ const { getCycleRange, resolveDueDate } = require('../services/statusService.cts
const { getUserSettings } = require('../services/userSettings.cts'); const { getUserSettings } = require('../services/userSettings.cts');
const { accountingActiveSql } = require('../services/paymentAccountingService.cts'); const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
const { toCents, fromCents } = require('../utils/money.mts'); const { toCents, fromCents } = require('../utils/money.mts');
const { ValidationError } = require('../utils/apiError.cts');
const DEFAULT_INCOME_LABEL = 'Salary'; const DEFAULT_INCOME_LABEL = 'Salary';
const DEFAULT_PENDING_DAYS = 3; const DEFAULT_PENDING_DAYS = 3;
@ -128,10 +127,10 @@ function parseYearMonth(source) {
const month = parseInt(source.month || now.getMonth() + 1, 10); const month = parseInt(source.month || now.getMonth() + 1, 10);
if (Number.isNaN(year) || year < 2000 || year > 2100) { if (Number.isNaN(year) || year < 2000 || year > 2100) {
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year'); return { error: 'year must be a 4-digit integer between 2000 and 2100' };
} }
if (Number.isNaN(month) || month < 1 || month > 12) { if (Number.isNaN(month) || month < 1 || month > 12) {
throw ValidationError('month must be an integer between 1 and 12', 'month'); return { error: 'month must be an integer between 1 and 12' };
} }
return { year, month }; return { year, month };
@ -448,6 +447,7 @@ function buildSummary(db, userId, year, month) {
router.get('/', (req: Req, res: Res) => { router.get('/', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.query); const parsed = parseYearMonth(req.query);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const db = getDb(); const db = getDb();
res.json(buildSummary(db, req.user.id, parsed.year, parsed.month)); res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
@ -455,10 +455,11 @@ router.get('/', (req: Req, res: Res) => {
router.put('/income', (req: Req, res: Res) => { router.put('/income', (req: Req, res: Res) => {
const parsed = parseYearMonth(req.body || {}); const parsed = parseYearMonth(req.body || {});
if (parsed.error) return res.status(400).json({ error: parsed.error });
const amount = Number(req.body?.amount); const amount = Number(req.body?.amount);
if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) { if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) {
throw ValidationError('amount must be a number between 0 and 1000000000', 'amount'); return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' });
} }
const label = const label =

View File

@ -4,16 +4,6 @@ const router = require('express').Router();
const { log } = require('../utils/logger.cts'); const { log } = require('../utils/logger.cts');
const { getDb } = require('../db/database.cts'); const { getDb } = require('../db/database.cts');
const { standardizeError } = require('../middleware/errorFormatter.cts'); const { standardizeError } = require('../middleware/errorFormatter.cts');
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
// Boundary adapter: the internal parse/normalize helpers return
// { error: <standardized body> } result objects; at the HTTP boundary we
// rethrow them as ApiError so the terminal handler emits the same wire shape.
function throwStandardized(errBody, status = 400) {
throw new ApiError(errBody.code || 'VALIDATION_ERROR', errBody.message, status, {
field: errBody.field,
});
}
const { const {
decorateTransaction, decorateTransaction,
ensureManualDataSource, ensureManualDataSource,
@ -366,15 +356,14 @@ function rejectDirectMatchState(body = {}) {
); );
} }
// 4xx service errors keep their message/code on the wire; anything else is function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') {
// rethrown raw so the terminal handler masks + logs it as a 5xx. if (err.status) {
function toTransactionServiceError(err) { return res
if (err.status && err.status < 500) { .status(err.status)
return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, { .json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field));
field: err.field,
});
} }
return err; log.error('[transactions] service error:', err.stack || err.message);
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
} }
function emptyBankLedger(enabled, hasConnections = false) { function emptyBankLedger(enabled, hasConnections = false) {
@ -471,7 +460,7 @@ router.get('/', (req: Req, res: Res) => {
ensureManualDataSource(db, req.user.id); ensureManualDataSource(db, req.user.id);
const page = parseLimitOffset(req.query); const page = parseLimitOffset(req.query);
if (page.error) throwStandardized(page.error); if (page.error) return res.status(400).json(page.error);
const SORT_COLUMNS = { const SORT_COLUMNS = {
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)', date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
@ -545,7 +534,7 @@ router.get('/', (req: Req, res: Res) => {
for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) { for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
if (req.query[field] !== undefined) { if (req.query[field] !== undefined) {
const parsed = parseInteger(req.query[field], field); const parsed = parseInteger(req.query[field], field);
if (parsed.error) throwStandardized(parsed.error); if (parsed.error) return res.status(400).json(parsed.error);
where.push(`t.${field} = ?`); where.push(`t.${field} = ?`);
params.push(parsed.value); params.push(parsed.value);
} }
@ -553,13 +542,13 @@ router.get('/', (req: Req, res: Res) => {
if (req.query.start_date) { if (req.query.start_date) {
const parsed = parseDate(req.query.start_date, 'start_date'); const parsed = parseDate(req.query.start_date, 'start_date');
if (parsed.error) throwStandardized(parsed.error); if (parsed.error) return res.status(400).json(parsed.error);
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?'); where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?');
params.push(parsed.value); params.push(parsed.value);
} }
if (req.query.end_date) { if (req.query.end_date) {
const parsed = parseDate(req.query.end_date, 'end_date'); const parsed = parseDate(req.query.end_date, 'end_date');
if (parsed.error) throwStandardized(parsed.error); if (parsed.error) return res.status(400).json(parsed.error);
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?'); where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?');
params.push(parsed.value); params.push(parsed.value);
} }
@ -618,7 +607,7 @@ router.get('/', (req: Req, res: Res) => {
router.get('/bank-ledger', (req: Req, res: Res) => { router.get('/bank-ledger', (req: Req, res: Res) => {
const config = getBankSyncConfig(); const config = getBankSyncConfig();
const page = parseLimitOffset(req.query); const page = parseLimitOffset(req.query);
if (page.error) throwStandardized(page.error); if (page.error) return res.status(400).json(page.error);
const db = getDb(); const db = getDb();
const sources = db const sources = db
@ -664,7 +653,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
.all(req.user.id); .all(req.user.id);
const filtered = bankLedgerWhere(req.query, req.user.id); const filtered = bankLedgerWhere(req.query, req.user.id);
if (filtered.error) throwStandardized(filtered.error); if (filtered.error) return res.status(400).json(filtered.error);
const SORT_COLUMNS = { const SORT_COLUMNS = {
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)', date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
@ -769,14 +758,14 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
router.post('/manual', (req: Req, res: Res) => { router.post('/manual', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const directMatchState = rejectDirectMatchState(req.body); const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) throwStandardized(directMatchState); if (directMatchState) return res.status(400).json(directMatchState);
const validation = normalizeTransactionFields(db, req.user.id, req.body); const validation = normalizeTransactionFields(db, req.user.id, req.body);
if (validation.error) throwStandardized(validation.error, validation.status || 400); if (validation.error) return res.status(validation.status || 400).json(validation.error);
const tx = validation.normalized; const tx = validation.normalized;
const source = ensureManualDataSource(db, req.user.id); const source = ensureManualDataSource(db, req.user.id);
const state = resolveTransactionState(tx); const state = resolveTransactionState(tx);
if (state.error) throwStandardized(state.error); if (state.error) return res.status(400).json(state.error);
Object.assign(tx, state); Object.assign(tx, state);
const result = db const result = db
@ -814,19 +803,20 @@ router.post('/manual', (req: Req, res: Res) => {
router.put('/:id', (req: Req, res: Res) => { router.put('/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const directMatchState = rejectDirectMatchState(req.body); const directMatchState = rejectDirectMatchState(req.body);
if (directMatchState) throwStandardized(directMatchState); if (directMatchState) return res.status(400).json(directMatchState);
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) throwStandardized(id.error); if (id.error) return res.status(400).json(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) throw NotFoundError('Transaction not found', 'id'); if (!existing)
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true }); const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
if (validation.error) throwStandardized(validation.error, validation.status || 400); if (validation.error) return res.status(validation.status || 400).json(validation.error);
const tx = validation.normalized; const tx = validation.normalized;
const state = resolveTransactionState(tx, existing); const state = resolveTransactionState(tx, existing);
if (state.error) throwStandardized(state.error); if (state.error) return res.status(400).json(state.error);
Object.assign(tx, state); Object.assign(tx, state);
const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date; const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date;
@ -890,10 +880,11 @@ router.put('/:id', (req: Req, res: Res) => {
router.delete('/:id', (req: Req, res: Res) => { router.delete('/:id', (req: Req, res: Res) => {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) throwStandardized(id.error); if (id.error) return res.status(400).json(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) throw NotFoundError('Transaction not found', 'id'); if (!existing)
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
db.transaction(() => { db.transaction(() => {
unmatchTransaction(req.user.id, id.value); unmatchTransaction(req.user.id, id.value);
@ -914,7 +905,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
); );
res.json(result); res.json(result);
} catch (err) { } catch (err) {
throw toTransactionServiceError(err); return sendTransactionServiceError(res, err, 'Transaction match failed');
} }
}); });
@ -924,7 +915,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
const result = unmatchTransaction(req.user.id, req.params.id); const result = unmatchTransaction(req.user.id, req.params.id);
res.json(result); res.json(result);
} catch (err) { } catch (err) {
throw toTransactionServiceError(err); return sendTransactionServiceError(res, err, 'Transaction unmatch failed');
} }
}); });
@ -935,7 +926,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
router.post('/unmatch-bulk', (req: Req, res: Res) => { router.post('/unmatch-bulk', (req: Req, res: Res) => {
const matches = req.body?.matches; const matches = req.body?.matches;
if (!Array.isArray(matches) || matches.length === 0) { if (!Array.isArray(matches) || matches.length === 0) {
throw ValidationError('matches array required'); return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR'));
} }
if (matches.length > 50) { if (matches.length > 50) {
return res return res
@ -1005,19 +996,13 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
results.push({ transaction_id: txId, ok: true }); results.push({ transaction_id: txId, ok: true });
} }
} catch (err) { } catch (err) {
// Per-item feedback: service 4xx messages are user-facing; anything results.push({ transaction_id: txId, ok: false, error: err.message });
// else is masked — 500-class internals must not leak into the results.
const msg = err.status && err.status < 500 ? err.message : 'Unmatch failed';
results.push({ transaction_id: txId, ok: false, error: msg });
} }
} }
})(); })();
const failed = results.filter((r) => !r.ok); const failed = results.filter((r) => !r.ok);
if (failed.length > 0 && failed.length === results.length) { if (failed.length > 0 && failed.length === results.length) {
// Deliberate hand-rolled body: carries the per-item `results` payload the
// client renders; formatError has no extras channel (same as payments'
// DUPLICATE_SUSPECTED exception).
return res.status(500).json({ error: 'All unmatches failed', results }); return res.status(500).json({ error: 'All unmatches failed', results });
} }
res.json({ results, unmatched: results.filter((r) => r.ok).length }); res.json({ results, unmatched: results.filter((r) => r.ok).length });
@ -1029,7 +1014,7 @@ router.post('/:id/ignore', (req: Req, res: Res) => {
const result = ignoreTransaction(req.user.id, req.params.id); const result = ignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction); res.json(result.transaction);
} catch (err) { } catch (err) {
throw toTransactionServiceError(err); return sendTransactionServiceError(res, err, 'Transaction ignore failed');
} }
}); });
@ -1039,7 +1024,7 @@ router.post('/:id/unignore', (req: Req, res: Res) => {
const result = unignoreTransaction(req.user.id, req.params.id); const result = unignoreTransaction(req.user.id, req.params.id);
res.json(result.transaction); res.json(result.transaction);
} catch (err) { } catch (err) {
throw toTransactionServiceError(err); return sendTransactionServiceError(res, err, 'Transaction unignore failed');
} }
}); });
@ -1048,10 +1033,11 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
try { try {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) throwStandardized(id.error); if (id.error) return res.status(400).json(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) throw NotFoundError('Transaction not found', 'id'); if (!existing)
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const match = findMerchantMatch( const match = findMerchantMatch(
db, db,
@ -1059,7 +1045,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
); );
res.json({ match }); res.json({ match });
} catch (err) { } catch (err) {
throw toTransactionServiceError(err); return sendTransactionServiceError(res, err, 'Failed to look up merchant match');
} }
}); });
@ -1068,10 +1054,11 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
try { try {
const db = getDb(); const db = getDb();
const id = parseInteger(req.params.id, 'id'); const id = parseInteger(req.params.id, 'id');
if (id.error) throwStandardized(id.error); if (id.error) return res.status(400).json(id.error);
const existing = getTransactionForUser(db, req.user.id, id.value); const existing = getTransactionForUser(db, req.user.id, id.value);
if (!existing) throw NotFoundError('Transaction not found', 'id'); if (!existing)
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
const match = findMerchantMatch( const match = findMerchantMatch(
db, db,
@ -1088,7 +1075,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
display_name: match.display_name, display_name: match.display_name,
}); });
} catch (err) { } catch (err) {
throw toTransactionServiceError(err); return sendTransactionServiceError(res, err, 'Failed to apply merchant match');
} }
}); });
@ -1101,7 +1088,7 @@ router.post('/auto-categorize', (req: Req, res: Res) => {
}); });
res.json(result); res.json(result);
} catch (err) { } catch (err) {
throw toTransactionServiceError(err); return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions');
} }
}); });

View File

@ -60,7 +60,7 @@ router.get('/history', (req: Req, res: Res) => {
if (!fs.existsSync(HISTORY_PATH)) throw NotFoundError('Release history not found'); if (!fs.existsSync(HISTORY_PATH)) throw NotFoundError('Release history not found');
const history = fs.readFileSync(HISTORY_PATH, 'utf8'); const history = fs.readFileSync(HISTORY_PATH, 'utf8');
let updatedAt; let updatedAt = null;
try { try {
updatedAt = fs.statSync(HISTORY_PATH).mtime.toISOString(); updatedAt = fs.statSync(HISTORY_PATH).mtime.toISOString();
} catch { } catch {

View File

@ -273,8 +273,7 @@ const { getCsrfToken } = require('./middleware/csrf.cts');
app.get('/{*splat}', (req, res) => { app.get('/{*splat}', (req, res) => {
// Set CSRF cookie if not present (needed for SPA to read token) // Set CSRF cookie if not present (needed for SPA to read token)
getCsrfToken(req, res); getCsrfToken(req, res);
// root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep) res.sendFile(path.join(DIST, 'index.html'));
res.sendFile('index.html', { root: DIST });
}); });
// ── Global error handler ────────────────────────────────────────────────────── // ── Global error handler ──────────────────────────────────────────────────────

View File

@ -89,17 +89,9 @@ async function login(username: any, password: any) {
createAuthenticationChallenge, createAuthenticationChallenge,
createLoginChallenge, createLoginChallenge,
} = require('./webauthnService.cts'); } = require('./webauthnService.cts');
try { const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id); const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
const loginToken = createLoginChallenge(getDb(), user.id, challengeId); return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
} catch (err: any) {
// Stale flag (enabled but no usable credentials): a hard failure here would
// lock the account out entirely — fall through to password-only login instead.
log.warn(
`[auth] WebAuthn enabled for user ${user.id} but challenge creation failed (${err.message}); proceeding with password login`,
);
}
} }
// Clean up expired sessions for this user before creating new session // Clean up expired sessions for this user before creating new session

View File

@ -50,7 +50,7 @@ async function claimSetupToken(setupToken: unknown): Promise<string> {
const token = setupToken.trim(); const token = setupToken.trim();
// Base64-decode to get the claim URL; handle both raw base64 and data-URLs // Base64-decode to get the claim URL; handle both raw base64 and data-URLs
let claimUrl; let claimUrl = token;
try { try {
const decoded = Buffer.from(token, 'base64').toString('utf8').trim(); const decoded = Buffer.from(token, 'base64').toString('utf8').trim();
claimUrl = decoded.startsWith('http') ? decoded : token; claimUrl = decoded.startsWith('http') ? decoded : token;
@ -67,7 +67,7 @@ async function claimSetupToken(setupToken: unknown): Promise<string> {
throw new Error('Setup token must use a secure HTTPS URL'); throw new Error('Setup token must use a secure HTTPS URL');
} }
let accessUrl; let accessUrl = '';
try { try {
const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) }); const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) });
if (res.status === 403) { if (res.status === 403) {

View File

@ -18,24 +18,11 @@ function getRpId(): string {
return process.env.WEBAUTHN_RP_ID || 'localhost'; return process.env.WEBAUTHN_RP_ID || 'localhost';
} }
// WEBAUTHN_ORIGIN is authoritative when set. Otherwise accept the API origin function getOrigin(): string {
// plus the browser's actual origin when its hostname equals the RP ID — this
// covers dev/e2e where the Vite UI port differs from the API port. It does not
// widen the trust boundary: the browser already refuses a ceremony whose RP ID
// is not a registrable suffix of the page origin.
function getOrigin(requestOrigin?: string): string | string[] {
const o = process.env.WEBAUTHN_ORIGIN; const o = process.env.WEBAUTHN_ORIGIN;
if (o) return o; if (o) return o;
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
const accepted = [`http://localhost:${port}`]; return `http://localhost:${port}`;
if (requestOrigin) {
try {
if (new URL(requestOrigin).hostname === getRpId()) accepted.push(requestOrigin);
} catch {
/* malformed Origin header — ignore */
}
}
return accepted;
} }
// ── Registration ────────────────────────────────────────────────────────────── // ── Registration ──────────────────────────────────────────────────────────────
@ -93,7 +80,6 @@ async function verifyRegistration(
challengeId: string, challengeId: string,
response: any, response: any,
credentialName: any, credentialName: any,
requestOrigin?: string,
) { ) {
const row = db const row = db
.prepare( .prepare(
@ -106,7 +92,7 @@ async function verifyRegistration(
const verification = await verifyRegistrationResponse({ const verification = await verifyRegistrationResponse({
response, response,
expectedChallenge: row.challenge, expectedChallenge: row.challenge,
expectedOrigin: getOrigin(requestOrigin), expectedOrigin: getOrigin(),
expectedRPID: getRpId(), expectedRPID: getRpId(),
}); });
@ -176,13 +162,7 @@ async function createAuthenticationChallenge(db: Db, userId: number) {
return { options, challengeId }; return { options, challengeId };
} }
async function verifyAuthentication( async function verifyAuthentication(db: Db, userId: number, challengeId: string, response: any) {
db: Db,
userId: number,
challengeId: string,
response: any,
requestOrigin?: string,
) {
const row = db const row = db
.prepare( .prepare(
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')", "SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')",
@ -201,7 +181,7 @@ async function verifyAuthentication(
const verification = await verifyAuthenticationResponse({ const verification = await verifyAuthenticationResponse({
response, response,
expectedChallenge: row.challenge, expectedChallenge: row.challenge,
expectedOrigin: getOrigin(requestOrigin), expectedOrigin: getOrigin(),
expectedRPID: getRpId(), expectedRPID: getRpId(),
credential: { credential: {
id: cred.credential_id, id: cred.credential_id,

View File

@ -1,138 +0,0 @@
'use strict';
// QA-B1-01 — POST /login for a webauthn-enabled user must return the
// requires_webauthn two-step payload (not fall through and 500), and a user
// whose webauthn_enabled flag is stale (no credential rows) must still be able
// to log in with their password instead of being locked out.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-webauthn-login-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { hashPassword } = require('../services/authService.cts');
const router = require('../routes/auth.cts');
const PASSWORD = 'correct-horse-battery';
function handler(method, routePath) {
const layer = router.stack.find((l) => l.route?.path === routePath && l.route.methods[method]);
assert.ok(layer, `${method.toUpperCase()} ${routePath} exists`);
return layer.route.stack[layer.route.stack.length - 1].handle;
}
function callLogin(body) {
const h = handler('post', '/login');
return new Promise((resolve, reject) => {
const req = {
body,
ip: '127.0.0.1',
cookies: {},
headers: {},
secure: false,
get: () => 'node-test-agent',
};
const cookies = [];
const res = {
statusCode: 200,
status(code) {
this.statusCode = code;
return this;
},
cookie(name, value, opts) {
cookies.push({ name, value, opts });
},
json(data) {
resolve({ status: this.statusCode, body: data, cookies });
},
};
// Routes follow the throw-pattern: mirror the app's terminal error handler
// so thrown/rejected ApiErrors resolve to the same wire shape.
Promise.resolve(h(req, res)).catch((err) => {
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, body: formatError(err), cookies });
} else {
reject(err);
}
});
});
}
let webauthnUserId;
let staleFlagUserId;
test.before(async () => {
const db = getDb();
const hash = await hashPassword(PASSWORD);
const u1 = db
.prepare(
"INSERT INTO users (username, password_hash, role, webauthn_enabled) VALUES (?, ?, 'user', 1)",
)
.run('webauthn-user', hash);
webauthnUserId = u1.lastInsertRowid;
db.prepare(
'INSERT INTO webauthn_credentials (user_id, credential_id, public_key, sign_count, transports) VALUES (?, ?, ?, 0, ?)',
).run(webauthnUserId, 'dGVzdC1jcmVkLWlk', 'dGVzdC1wdWJsaWMta2V5', JSON.stringify(['internal']));
const u2 = db
.prepare(
"INSERT INTO users (username, password_hash, role, webauthn_enabled) VALUES (?, ?, 'user', 1)",
)
.run('stale-flag-user', hash);
staleFlagUserId = u2.lastInsertRowid;
});
test.after(() => {
closeDb();
for (const suffix of ['', '-shm', '-wal']) {
try {
fs.unlinkSync(dbPath + suffix);
} catch {}
}
});
test('webauthn-enabled user gets the two-step payload, not a 500', async () => {
const { status, body, cookies } = await callLogin({
username: 'webauthn-user',
password: PASSWORD,
});
assert.equal(status, 200);
assert.equal(body.requires_webauthn, true);
assert.equal(typeof body.challenge_token, 'string');
assert.ok(body.challenge_token.length > 0);
assert.ok(body.webauthn_options, 'webauthn_options present');
assert.equal(typeof body.webauthn_options.challenge, 'string');
assert.equal(cookies.length, 0, 'no session cookie before the key verifies');
assert.equal(body.user, undefined, 'no user payload before the key verifies');
const challenge = getDb()
.prepare(
"SELECT id FROM webauthn_challenges WHERE user_id = ? AND challenge_type = 'authentication'",
)
.get(webauthnUserId);
assert.ok(challenge, 'authentication challenge persisted');
});
test('stale webauthn flag (no credentials) falls through to password login, not lockout', async () => {
const { status, body, cookies } = await callLogin({
username: 'stale-flag-user',
password: PASSWORD,
});
assert.equal(status, 200);
assert.equal(body.requires_webauthn, undefined);
assert.ok(body.user, 'password login succeeded despite the stale flag');
assert.equal(body.user.id, staleFlagUserId);
assert.equal(cookies.length, 1, 'session cookie issued');
});
test('wrong password still 401s for a webauthn-enabled user', async () => {
const { status, body } = await callLogin({ username: 'webauthn-user', password: 'nope-wrong' });
assert.equal(status, 401);
assert.equal(body.code, 'AUTH_ERROR');
});

View File

@ -8,7 +8,6 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-reorder-test-${process.pid}.
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { getTracker } = require('../services/trackerService.cts'); const { getTracker } = require('../services/trackerService.cts');
function createUser(db, suffix) { function createUser(db, suffix) {
@ -61,13 +60,7 @@ function callBillsRoute(routePath, method, { userId, params = {}, query = {}, bo
try { try {
handler(req, res); handler(req, res);
} catch (err) { } catch (err) {
// Routes follow the throw-pattern: mirror the app's terminal error reject(err);
// handler so thrown ApiErrors resolve to the same wire shape.
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, data: formatError(err) });
} else {
reject(err);
}
} }
}); });
} }

View File

@ -15,7 +15,6 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-payments-${process.pid}.sqli
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const router = require('../routes/payments.cts'); const router = require('../routes/payments.cts');
function handler(method, routePath) { function handler(method, routePath) {
@ -37,13 +36,7 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
resolve({ status: this.statusCode, data: d }); resolve({ status: this.statusCode, data: d });
}, },
}; };
// Routes follow the throw-pattern: mirror the app's terminal error handler h(req, res);
// (server.cts) so thrown ApiErrors resolve to the same wire shape.
try {
h(req, res);
} catch (err) {
resolve({ status: err.status || 500, data: formatError(err) });
}
}); });
} }
function balanceOf(billId) { function balanceOf(billId) {

View File

@ -13,7 +13,6 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-snowball-plan-${process.pid}
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const router = require('../routes/snowball.cts'); const router = require('../routes/snowball.cts');
function handler(method, routePath) { function handler(method, routePath) {
@ -35,13 +34,7 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
resolve({ status: this.statusCode, data: d }); resolve({ status: this.statusCode, data: d });
}, },
}; };
// Routes follow the throw-pattern: mirror the app's terminal error handler h(req, res);
// (server.cts) so thrown ApiErrors resolve to the same wire shape.
try {
h(req, res);
} catch (err) {
resolve({ status: err.status || 500, data: formatError(err) });
}
}); });
} }

View File

@ -8,7 +8,6 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-transaction-match-test-${pro
process.env.DB_PATH = dbPath; process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database.cts'); const { getDb, closeDb } = require('../db/database.cts');
const { formatError } = require('../utils/apiError.cts');
const { ensureManualDataSource } = require('../services/transactionService.cts'); const { ensureManualDataSource } = require('../services/transactionService.cts');
const { getTracker } = require('../services/trackerService.cts'); const { getTracker } = require('../services/trackerService.cts');
const { const {
@ -132,13 +131,7 @@ function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
try { try {
handler(req, res); handler(req, res);
} catch (err) { } catch (err) {
// Routes follow the throw-pattern: mirror the app's terminal error reject(err);
// handler so thrown ApiErrors resolve to the same wire shape.
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, data: formatError(err) });
} else {
reject(err);
}
} }
}); });
} }
@ -171,13 +164,7 @@ function callPaymentsRoute(routePath, method, { userId, params = {}, query = {},
try { try {
handler(req, res); handler(req, res);
} catch (err) { } catch (err) {
// Routes follow the throw-pattern: mirror the app's terminal error reject(err);
// handler so thrown ApiErrors resolve to the same wire shape.
if (err && (err.status || err.name === 'ApiError')) {
resolve({ status: err.status || 500, data: formatError(err) });
} else {
reject(err);
}
} }
}); });
} }

View File

@ -1,26 +0,0 @@
'use strict';
// QA-B0-03 — package.json is the single source of truth for the app version
// (/api/version, the release-notes badge, updateCheckService all read it), and
// HISTORY.md's top heading is the release record. They drifted once (0.40.0 vs
// v0.41.0); this guard makes the drift a test failure instead of a silent lie.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
test('package.json version matches the top HISTORY.md release heading', () => {
const pkg = require('../package.json');
const history = fs.readFileSync(path.join(__dirname, '..', 'HISTORY.md'), 'utf8');
const match = history.match(/^## v(\d+\.\d+(?:\.\d+)?)/m);
assert.ok(match, 'HISTORY.md has a "## vX.Y[.Z]" release heading');
// Normalize: HISTORY sometimes uses two-part versions (v0.41) for a series.
const historyVersion = match[1];
const pkgVersion = pkg.version;
assert.ok(
pkgVersion === historyVersion || pkgVersion.startsWith(`${historyVersion}.`),
`package.json version (${pkgVersion}) must match the top HISTORY.md heading (v${historyVersion})`,
);
});

View File

@ -51,17 +51,6 @@ function validateEnv(): void {
'the DB) to a long random string.', 'the DB) to a long random string.',
); );
} }
// WebAuthn credentials are cryptographically bound to the Relying Party ID.
// With the localhost default, passkeys registered in production would bind to
// "localhost" and silently fail to verify on the real domain.
if (isProd() && !process.env.WEBAUTHN_RP_ID) {
console.warn(
'[config] WARNING: WEBAUTHN_RP_ID is not set in production (defaults to "localhost"). ' +
'Passkeys/security keys will not work behind your real domain. Set it to the bare ' +
'hostname users sign in on, e.g. WEBAUTHN_RP_ID=bills.example.com.',
);
}
} }
module.exports = { validateEnv }; module.exports = { validateEnv };

View File

@ -103,15 +103,18 @@ export default defineConfig({
output: { output: {
// QA-B0-01: split the shared vendor code out of the ~659 kB index chunk // QA-B0-01: split the shared vendor code out of the ~659 kB index chunk
// so large libs load/cache independently and the main bundle shrinks. // so large libs load/cache independently and the main bundle shrinks.
// Function form: vite 8 (rolldown core) dropped the object form. manualChunks: {
manualChunks(id) { 'vendor-react': ['react', 'react-dom', 'react-router-dom'],
if (!id.includes('node_modules')) return undefined; 'vendor-radix': [
if (/node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/.test(id)) '@radix-ui/react-dialog',
return 'vendor-react'; '@radix-ui/react-select',
if (/node_modules[\\/]@radix-ui[\\/]/.test(id)) return 'vendor-radix'; '@radix-ui/react-dropdown-menu',
if (/node_modules[\\/]framer-motion[\\/]/.test(id)) return 'vendor-motion'; '@radix-ui/react-tabs',
if (/node_modules[\\/]@tanstack[\\/]/.test(id)) return 'vendor-query'; '@radix-ui/react-tooltip',
return undefined; '@radix-ui/react-alert-dialog',
],
'vendor-motion': ['framer-motion'],
'vendor-query': ['@tanstack/react-query', '@tanstack/react-virtual'],
}, },
}, },
}, },