Compare commits
20 Commits
d9545d6fd5
...
b08c054f6a
| Author | SHA1 | Date |
|---|---|---|
|
|
b08c054f6a | |
|
|
4294a1da8c | |
|
|
750dfebd15 | |
|
|
b76772b8eb | |
|
|
8248575b1e | |
|
|
af20d8de07 | |
|
|
94ed0b63f3 | |
|
|
55b2baee5f | |
|
|
88a32a983f | |
|
|
68ca7cbb43 | |
|
|
c481a5bdbf | |
|
|
d0ab375f9a | |
|
|
c0c3c2f347 | |
|
|
543e94288e | |
|
|
c4219b6020 | |
|
|
a8f8446743 | |
|
|
9efa74e12b | |
|
|
e73f51e91b | |
|
|
73915d38ce | |
|
|
3c82c30f3c |
|
|
@ -61,6 +61,14 @@ NODE_ENV=production
|
|||
#
|
||||
# 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) ─────────────────────────────────────────────────────
|
||||
# Enable/disable bank sync from the Admin panel. Users connect their own
|
||||
# SimpleFIN Bridge from the Data page. No environment config required.
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ jobs:
|
|||
| tar -xz -C /usr/local/bin gitleaks
|
||||
gitleaks detect --source=. --redact --no-banner
|
||||
|
||||
- name: Dependency audit (fail on critical)
|
||||
run: npm audit --omit=dev --audit-level=critical
|
||||
- name: Dependency audit (fail on high or critical)
|
||||
# Raised from critical after clearing the nodemailer/xlsx highs
|
||||
# (QA-B0-02) — high vulns must fail CI, not pass silently.
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
- name: Format check (Prettier)
|
||||
run: npm run format:check
|
||||
|
|
|
|||
18
HISTORY.md
|
|
@ -1,5 +1,23 @@
|
|||
# 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
|
||||
|
||||
### 🚀 Express 4 → 5 (Phase 2) — modern framework + native async error handling
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
// @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);
|
||||
});
|
||||
});
|
||||
|
|
@ -51,6 +51,19 @@ export interface ApiError extends Error {
|
|||
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. */
|
||||
export interface TogglePaidResult {
|
||||
paymentId?: number;
|
||||
|
|
@ -134,12 +147,14 @@ async function _fetch<T = unknown>(
|
|||
_csrfFetch = null;
|
||||
return _fetch<T>(method, path, body, true);
|
||||
}
|
||||
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;
|
||||
// Session expired mid-use: a 401 outside /auth/* means the session cookie
|
||||
// died (under /auth/* a 401 is "wrong credential input" — e.g. bad current
|
||||
// password — and must NOT log the user out). AuthProvider listens and
|
||||
// clears the user, so RequireAuth redirects to /login preserving state.from.
|
||||
if (res.status === 401 && !path.startsWith('/auth/')) {
|
||||
window.dispatchEvent(new Event('auth:expired'));
|
||||
}
|
||||
throw toApiError(res, data);
|
||||
}
|
||||
return (data ?? {}) as T;
|
||||
}
|
||||
|
|
@ -174,7 +189,13 @@ export const api = {
|
|||
get<{ user?: User | null; single_user_mode?: boolean; has_new_version?: boolean }>('/auth/me'),
|
||||
authMode: () => get('/auth/mode'),
|
||||
login: (data: Body) =>
|
||||
post<{ requires_totp?: boolean; challenge_token?: string; user?: User }>('/auth/login', data),
|
||||
post<{
|
||||
requires_totp?: boolean;
|
||||
requires_webauthn?: boolean;
|
||||
challenge_token?: string;
|
||||
webauthn_options?: unknown;
|
||||
user?: User;
|
||||
}>('/auth/login', data),
|
||||
logout: () => post('/auth/logout'),
|
||||
logoutOthers: () => post<{ success?: boolean; count?: number }>('/auth/logout-others'),
|
||||
restoreMultiUserMode: () => post('/auth/restore-multi-user-mode'),
|
||||
|
|
@ -262,12 +283,7 @@ export const api = {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
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;
|
||||
throw toApiError(res, data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
|
@ -504,12 +520,7 @@ export const api = {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
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;
|
||||
throw toApiError(res, data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
|
@ -528,12 +539,7 @@ export const api = {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
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;
|
||||
throw toApiError(res, data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
|
@ -552,12 +558,7 @@ export const api = {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
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;
|
||||
throw toApiError(res, data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
|
@ -621,12 +622,7 @@ export const api = {
|
|||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
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;
|
||||
throw toApiError(res, data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
.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 () => {
|
||||
await api.logout();
|
||||
setUser(null);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, type ComponentType, type ReactNode } from 'react';
|
|||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Gauge, KeyRound, LockKeyhole, ShieldCheck } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { api } from '@/api';
|
||||
import { errMessage } from '@/lib/utils';
|
||||
import { useAuth, type User } from '@/hooks/useAuth';
|
||||
|
|
@ -81,6 +82,12 @@ export default function LoginPage() {
|
|||
const [totpLoading, setTotpLoading] = 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 [confirmPw, setConfirmPw] = useState('');
|
||||
const [pwLoading, setPwLoading] = useState(false);
|
||||
|
|
@ -155,6 +162,14 @@ export default function LoginPage() {
|
|||
setUseRecovery(false);
|
||||
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!);
|
||||
} catch (err) {
|
||||
setError(errMessage(err, 'Login failed.'));
|
||||
|
|
@ -163,6 +178,32 @@ 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) => {
|
||||
e.preventDefault();
|
||||
setTotpError('');
|
||||
|
|
@ -340,8 +381,52 @@ export default function LoginPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Sign-in card — hidden while auth mode resolves or during TOTP step */}
|
||||
{/* WebAuthn step — shown after password is accepted for key-protected accounts */}
|
||||
{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 &&
|
||||
!webauthnChallenge &&
|
||||
(authMode === null ? (
|
||||
<div className="surface-elevated flex min-h-[120px] items-center justify-center p-8">
|
||||
<span className="text-sm text-muted-foreground">Loading…</span>
|
||||
|
|
|
|||
|
|
@ -1040,6 +1040,7 @@ function ChangePassword() {
|
|||
return (
|
||||
<>
|
||||
<TotpSection />
|
||||
<PasskeySection />
|
||||
<SectionCard
|
||||
title="Change Password"
|
||||
icon={KeyRound}
|
||||
|
|
@ -1367,6 +1368,235 @@ 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({
|
||||
settings,
|
||||
onSaved,
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 117 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 220" role="img" aria-labelledby="title desc">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 220" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Bill Tracker logo</title>
|
||||
<desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc>
|
||||
<defs>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.12"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="720" height="220" rx="34" fill="none"/>
|
||||
<rect width="760" height="220" rx="34" fill="none"/>
|
||||
<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="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="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/>
|
||||
</g>
|
||||
<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="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="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>
|
||||
<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="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="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>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 220" role="img" aria-labelledby="title desc">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 220" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Bill Tracker logo</title>
|
||||
<desc id="desc">Bill Tracker wordmark with a calm green checkmark bar chart.</desc>
|
||||
<defs>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<feDropShadow dx="0" dy="8" stdDeviation="12" flood-color="#061014" flood-opacity="0.18"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="720" height="220" rx="34" fill="none"/>
|
||||
<rect width="760" height="220" rx="34" fill="none"/>
|
||||
<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="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="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="8"/>
|
||||
</g>
|
||||
<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="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="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>
|
||||
<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="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="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>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 178 KiB |
|
|
@ -2,14 +2,6 @@
|
|||
<title id="title">Bill Tracker social preview</title>
|
||||
<desc id="desc">Premium calm Bill Tracker brand preview with the logo and tagline.</desc>
|
||||
<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">
|
||||
<stop offset="0" stop-color="#1b2428"/>
|
||||
<stop offset="1" stop-color="#11181b"/>
|
||||
|
|
@ -21,9 +13,10 @@
|
|||
</linearGradient>
|
||||
</defs>
|
||||
<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"/>
|
||||
<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)">
|
||||
<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"/>
|
||||
|
|
@ -31,8 +24,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="#40c878" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
|
||||
</g>
|
||||
<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="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="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="Inter, Roboto, ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="650" letter-spacing="3">SELF-HOSTED BILL PLANNING</text>
|
||||
<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="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="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="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>
|
||||
</svg>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 102 KiB |
|
|
@ -33,15 +33,28 @@ rolled out. Update this doc when a standard changes.
|
|||
|
||||
- **Wire format:** JSON, **snake_case** field names (mirrors DB columns), money as integer
|
||||
cents.
|
||||
- **Error responses (target):** every error body is `{ error, message, code, field? }` with
|
||||
- **Error responses (enforced):** every error body is `{ error, message, code, field? }` with
|
||||
the correct HTTP status. Throw the factories from `utils/apiError.cts`
|
||||
(`ValidationError`, `NotFoundError`, `ConflictError`, …) and let the single terminal error
|
||||
handler format them — do not hand-roll `res.status().json({ error })`. Express 5 forwards
|
||||
rejected async handlers automatically, so route bodies should not wrap everything in
|
||||
`try/catch`. Clients read `message`/`code` + status. **Canonical example:**
|
||||
`routes/categories.cts`. (The terminal handler already normalizes any legacy
|
||||
`standardizeError`/`{ error }` body to the same shape, so converted and not-yet-converted
|
||||
routes emit identical responses — convert opportunistically.)
|
||||
(`ValidationError`, `NotFoundError`, `ConflictError`, `AuthError`, `ForbiddenError`, or
|
||||
`new ApiError(code, message, status, { field })` for anything else) and let the single
|
||||
terminal error handler format them — do not hand-roll `res.status().json({ error })`.
|
||||
Express 5 forwards rejected async handlers automatically, so route bodies do not wrap
|
||||
everything in `try/catch`; a thrown `ApiError` keeps its message on the wire while any
|
||||
other 5xx is masked + logged by the terminal handler (that masking is the whitelist:
|
||||
wrap a message in `ApiError` only when it is deliberately user-facing). Clients read
|
||||
`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: true, … }`.
|
||||
- **Validation (target):** validate inputs with the shared validators (throwing
|
||||
|
|
@ -56,7 +69,7 @@ rolled out. Update this doc when a standard changes.
|
|||
- Never log secrets, tokens, or raw financial values.
|
||||
- Fail fast at boot on missing required config (e.g. `TOKEN_ENCRYPTION_KEY` in production).
|
||||
|
||||
## Logging (target)
|
||||
## Logging (enforced)
|
||||
|
||||
- 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**
|
||||
|
|
|
|||
397
docs/QA_PLAN.md
|
|
@ -1,14 +1,16 @@
|
|||
# BillTracker — Master QA Plan (living document)
|
||||
|
||||
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-05
|
||||
**Version target:** v0.41.x · **Executor:** Claude (active) · **Last updated:** 2026-07-10
|
||||
**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,
|
||||
in **batches**, actively hunting for bugs/errors/rough edges, **fixing** them, and
|
||||
**archiving** each fixed finding to `HISTORY.md`. Update this document whenever a
|
||||
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
|
||||
> up to date, and any fixes archived to `HISTORY.md`.
|
||||
|
||||
|
|
@ -30,7 +32,7 @@ better approach, a new risk area, or a missed surface is discovered.
|
|||
|
||||
## 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
|
||||
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**
|
||||
|
|
@ -60,7 +62,7 @@ errors. Two nested loops:
|
|||
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
|
||||
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;
|
||||
|
|
@ -68,28 +70,30 @@ then continue the find pass. **Everything else is logged and left for Phase 2**
|
|||
no matter how tempting or trivial.
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
**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
|
||||
[Improvement Backlog (§2.1)](#21-improvement-backlog):
|
||||
|
||||
- **Code health & consolidation** — duplication to DRY up, dead code to delete,
|
||||
overlapping modules to merge, oversized files to split, one canonical path per
|
||||
concern. *Consolidate only where it genuinely reduces surface area and is
|
||||
behavior-preserving.* (Dedicated pass: **B17**.)
|
||||
concern. _Consolidate only where it genuinely reduces surface area and is
|
||||
behavior-preserving._ (Dedicated pass: **B17**.)
|
||||
- **User experience** — friction in core flows, unclear states (empty/loading/error),
|
||||
weak feedback/affordances, inconsistent patterns, mobile parity. (Dedicated pass: **B18**.)
|
||||
- **Information architecture / menus** — features that are buried or only reachable by
|
||||
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
|
||||
look for them.* (Dedicated pass: **B18**.)
|
||||
groupings that would make the app more discoverable. _Put things where a user would
|
||||
look for them._ (Dedicated pass: **B18**.)
|
||||
|
||||
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
|
||||
|
|
@ -105,28 +109,28 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
|
|||
|
||||
**Status key:** ⬜ Not started · 🔄 In progress · ✅ Done (green, findings archived) · 🔁 Needs recheck
|
||||
|
||||
| # | 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| # | 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
> cycle from B0 against the next build/version.
|
||||
|
|
@ -141,10 +145,10 @@ human** and were **not** exercised — they are **non-blocking** for Cycle 1 sig
|
|||
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.)
|
||||
- **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).
|
||||
- **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
|
||||
|
||||
|
|
@ -152,13 +156,14 @@ 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
|
||||
until you get a clean cycle.
|
||||
|
||||
| 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·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.** |
|
||||
| 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·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·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 (B0–B2); **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)
|
||||
|
||||
|
|
@ -175,9 +180,14 @@ 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)).
|
||||
**Status:** 🔴 Open → 🟡 Fixing → 🟢 Fixed (verified, awaiting archive) → then remove on 📦 Archive.
|
||||
|
||||
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|
||||
|----|-----|--------------------|---------|--------|---------------|
|
||||
| _(none — all Cycle 1 findings fixed, verified & archived to `HISTORY.md` v0.41.0)_ | | | | | |
|
||||
| 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 nodemailer→9 (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. |
|
||||
| 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):
|
||||
|
||||
|
|
@ -214,14 +224,18 @@ graduate to `roadmap.md`/`FUTURE.md`.
|
|||
|
||||
**Status:** 🔵 Noted (proposal) → 🟡 Doing → then archive to `HISTORY.md` on implement.
|
||||
|
||||
| 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-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-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-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 |
|
||||
| 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-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-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-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 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -246,10 +260,11 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
|
|||
```
|
||||
|
||||
**Rules**
|
||||
|
||||
- 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.*`).
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -257,12 +272,12 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
|
|||
|
||||
### 4.1 Running the app
|
||||
|
||||
| Mode | Command | URL |
|
||||
|------|---------|-----|
|
||||
| Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) |
|
||||
| API only | `npm run dev:api` | `http://localhost:3000` |
|
||||
| Production build | `npm run build` then `npm start` | `http://localhost:3000` |
|
||||
| Docker | `docker-compose up` | per compose config |
|
||||
| Mode | Command | URL |
|
||||
| -------------------------- | -------------------------------- | -------------------------------------------------- |
|
||||
| Dev (API + UI, hot reload) | `npm run dev` | UI `http://localhost:5173` (proxies API → `:3000`) |
|
||||
| API only | `npm run dev:api` | `http://localhost:3000` |
|
||||
| Production build | `npm run build` then `npm start` | `http://localhost:3000` |
|
||||
| Docker | `docker-compose up` | per compose config |
|
||||
|
||||
- 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.
|
||||
|
|
@ -273,18 +288,19 @@ or `### 🧹 QA` (polish/improvements) section, matching the existing changelog
|
|||
|
||||
Full functional pass across reasonable combinations; smoke (B15) across all.
|
||||
|
||||
| Dimension | Values |
|
||||
|-----------|--------|
|
||||
| Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) |
|
||||
| Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 |
|
||||
| Theme | Light, Dark, system-follow |
|
||||
| Role | `user`, `admin`, default admin (first-run) |
|
||||
| Auth mode | Multi-user, single-user |
|
||||
| Density | Normal + compact desktop |
|
||||
| Network | Online, Slow 3G, offline (PWA shell) |
|
||||
| Data state | Empty, seeded demo, large/stress, adversarial |
|
||||
| Dimension | Values |
|
||||
| ---------- | --------------------------------------------------------------- |
|
||||
| Browser | Chrome/Chromium, Firefox, Safari (WebAuthn differs per browser) |
|
||||
| Viewport | Desktop ≥1280, tablet ~768, mobile ~375 (iPhone SE), ~414 |
|
||||
| Theme | Light, Dark, system-follow |
|
||||
| Role | `user`, `admin`, default admin (first-run) |
|
||||
| Auth mode | Multi-user, single-user |
|
||||
| Density | Normal + compact desktop |
|
||||
| Network | Online, Slow 3G, offline (PWA shell) |
|
||||
| Data state | Empty, seeded demo, large/stress, adversarial |
|
||||
|
||||
### 4.3 Accounts to prepare
|
||||
|
||||
- `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).
|
||||
|
||||
|
|
@ -292,12 +308,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.
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `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:update` | re-baseline visual-regression screenshots (review the diff before committing) |
|
||||
| `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 |
|
||||
| Command | What it does |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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: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 |
|
||||
|
||||
- **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.
|
||||
|
|
@ -349,72 +365,83 @@ 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.
|
||||
|
||||
### B0 — Baseline, tooling & coverage recon
|
||||
|
||||
**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
|
||||
and folded in **before** testing, so coverage never silently rots.
|
||||
|
||||
**Tooling baseline**
|
||||
|
||||
- [ ] `npm run ci` — record any failing server/client test or build error as a finding (S1/S2).
|
||||
- [ ] `npm run check` — server syntax + build clean.
|
||||
- [ ] 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.
|
||||
- [ ] 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):
|
||||
- [ ] **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.
|
||||
- [ ] **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 "app.use\('/api" server.js` — every mounted route group is in B13's list and mapped in Appendix C.
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
- [ ] **Middleware & workers** — `ls middleware/ workers/` (+ `services/*Worker*`, `*Scheduler*`) — each is covered (csrf/rateLimiter/securityHeaders/requireAuth → B13; dailyWorker/bankSyncWorker/backupScheduler → B10).
|
||||
- [ ] **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`.
|
||||
- [ ] **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).
|
||||
- [ ] **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.
|
||||
- [ ] **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:
|
||||
|
||||
- [ ] 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).
|
||||
- [ ] 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
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
| 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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
- [ ] 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.
|
||||
|
||||
### 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.
|
||||
- [ ] **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.
|
||||
- [ ] **WebAuthn:** register/login/remove passkey in Chrome, Firefox, Safari; password fallback works.
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
- [ ] **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.
|
||||
|
||||
### B2 — Tracker (`/`)
|
||||
|
||||
- [ ] Month nav (prev/next/jump), current month highlighted, data reloads per month.
|
||||
- [ ] Bills land in correct `1–14` / `15–31` bucket by due date; pin-due sorting works.
|
||||
- [ ] Quick pay marks paid + updates balance cards/progress; undo works; no double-count.
|
||||
|
|
@ -428,6 +455,7 @@ 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.
|
||||
|
||||
### B3 — Bills (`/bills`)
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] Custom schedules (weekly/biweekly/monthly/quarterly/annual/custom): next-due & occurrences correct across month/year boundaries.
|
||||
|
|
@ -436,44 +464,52 @@ Run these, then compare the output to the batch playbooks (§7) and the [route m
|
|||
- [ ] BillModal open/close, validation, cancel discards unsaved changes.
|
||||
|
||||
### B4 — Subscriptions & Categories
|
||||
|
||||
- [ ] Subscriptions: add/edit/delete, active/cancelled, renewal & annual→monthly normalization; totals feed Tracker/Summary/Analytics.
|
||||
- [ ] 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.
|
||||
|
||||
### B5 — Reporting reconciliation
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
|
||||
### B6 — Spending (`/spending`)
|
||||
|
||||
- [ ] Category-group view assigned/spent/available math correct; 3-month averages correct.
|
||||
- [ ] Cover-overspending reallocates funds correctly and is reversible.
|
||||
- [ ] Safe-to-spend matches Tracker (`safeToSpend.test.js`); month nav; empty/partial months handled.
|
||||
|
||||
### B7 — Debt planning (`/snowball`, `/payoff`)
|
||||
|
||||
- [ ] Add debts (balance/APR/min); snowball vs avalanche ordering 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.
|
||||
- [ ] Edge: single debt, many debts, `$0` debt, negative/absurd inputs rejected.
|
||||
|
||||
### B8 — Banking (`/bank-transactions`)
|
||||
|
||||
- [ ] Ledger loads/virtualizes/filters (date/account/amount/merchant/status).
|
||||
- [ ] 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.
|
||||
- [ ] Matched payments reflect on Tracker/ledger without double-counting; category picker persists.
|
||||
|
||||
### 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.
|
||||
- [ ] 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`).
|
||||
|
||||
### B10 — Notifications & workers
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] Workers: `dailyWorker`, `bankSyncWorker` (interval + guardrails), `backupScheduler` run on schedule; errors caught/logged, don't crash server, next run unblocked.
|
||||
|
||||
### B11 — Admin panel (`/admin`)
|
||||
|
||||
- [ ] Onboarding wizard completes without a broken state.
|
||||
- [ ] 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.
|
||||
|
|
@ -482,23 +518,29 @@ 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).
|
||||
|
||||
### 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.
|
||||
- [ ] 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.
|
||||
- [ ] 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
|
||||
|
||||
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.
|
||||
- [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.js`).
|
||||
- [ ] Validation: bad/missing body → structured 4xx (`middleware/errorFormatter.js`, `utils/apiError.js`), never a raw 500 stack.
|
||||
- [ ] CSRF: state-changing without valid token rejected; with token succeeds (`middleware/csrf.cts`).
|
||||
- [ ] Validation: bad/missing body → structured 4xx (`middleware/errorFormatter.cts`, `utils/apiError.cts`), 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.
|
||||
- [ ] Rate limits: login/admin/export/import/OIDC limiters trigger + reset (`middleware/rateLimiter.js`).
|
||||
- [ ] Rate limits: login/admin/export/import/OIDC limiters trigger + reset (`middleware/rateLimiter.cts`).
|
||||
- [ ] **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.
|
||||
- [ ] Idempotency: repeated create doesn't duplicate; concurrent edits resolve sanely.
|
||||
- [ ] Consistent error JSON + correct status codes; security headers present (`middleware/securityHeaders.js`); public routes (`about`/`privacy`/`version`/calendar feed) leak nothing sensitive.
|
||||
- [ ] Consistent error JSON + correct status codes; security headers present (`middleware/securityHeaders.cts`); public routes (`about`/`privacy`/`version`/calendar feed) leak nothing sensitive.
|
||||
|
||||
### 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 (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.
|
||||
|
|
@ -510,7 +552,9 @@ Route groups: `auth`, `auth/oidc`, `admin`, `tracker`, `bills`, `subscriptions`,
|
|||
- [ ] **Timezone/locale:** non-UTC tz + DST boundary — due dates and calendar stay correct.
|
||||
|
||||
### B15 — Regression & sign-off
|
||||
|
||||
Run on the **production build** (`npm start`), not dev:
|
||||
|
||||
- [ ] `npm run ci` green. Log in as `user` and `admin`.
|
||||
- [ ] `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.
|
||||
|
|
@ -522,102 +566,112 @@ Run on the **production build** (`npm start`), not dev:
|
|||
- [ ] Confirm [exit criteria](#appendix-b--exit--sign-off-criteria).
|
||||
|
||||
### B16 — Migrations, secrets & deployment
|
||||
|
||||
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
|
||||
secrets silently.
|
||||
|
||||
**Migrations** (`db/database.js` migration system, `scripts/migrate-db.js`, `schema_migrations`, `rollbackMigration`)
|
||||
**Migrations** (`db/database.cts` + `db/migrations/*.cts`, `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.
|
||||
- [ ] **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).
|
||||
- [ ] **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.
|
||||
|
||||
**Encryption-key lifecycle** (`services/encryptionService.js`, `TOKEN_ENCRYPTION_KEY`, HKDF v1/v2)
|
||||
**Encryption-key lifecycle** (`services/encryptionService.cts`, `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 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.77–0.79) behave.
|
||||
- [ ] Encryption key is never committed, logged, or returned in any API response.
|
||||
|
||||
**Container / deploy** (`Dockerfile`, `docker-compose.yml`, `docker-entrypoint.sh`, `deploy.sh`)
|
||||
|
||||
- [ ] 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`.
|
||||
- [ ] Data **persists** across container restart (mounted volume); DB not re-created.
|
||||
- [ ] Runs as **non-root**; secrets come from env, not baked into the image.
|
||||
|
||||
**Update check / phone-home** (`services/updateCheckService.js`)
|
||||
**Update check / phone-home** (`services/updateCheckService.cts`)
|
||||
|
||||
- [ ] 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.js`) — beyond B13's list
|
||||
**Rate-limiter completeness** (`middleware/rateLimiter.cts`) — beyond B13's list
|
||||
|
||||
- [ ] `backupOperationLimiter` throttles admin backup/restore/cleanup; `skipRateLimitIfNoUsers` only relaxes limits on a genuinely empty instance (first-run), never afterward.
|
||||
|
||||
### B17 — Code health & consolidation (IMP)
|
||||
|
||||
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
|
||||
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
|
||||
propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.js`
|
||||
vs inline), the `resolveDueDate` occurrence gate (must stay one implementation),
|
||||
error-response shaping (`utils/apiError.js` vs ad-hoc), React data-fetch patterns
|
||||
(repeated `useQuery` + toast + error handling → shared hooks).
|
||||
propose a shared helper. Known hot spots: money formatting/rounding (`utils/money.mts`
|
||||
vs inline), the `resolveDueDate` occurrence gate (must stay one implementation),
|
||||
error-response shaping (`utils/apiError.cts` vs ad-hoc), React data-fetch patterns
|
||||
(repeated `useQuery` + toast + error handling → shared hooks), **duplicate route
|
||||
handlers across files** (auth.cts vs admin.cts, QA-B17-01).
|
||||
- [ ] **Dead / unused code:** unused exports, unreachable branches, orphaned files,
|
||||
commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover
|
||||
scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first).
|
||||
commented-out blocks, unused deps (`depcheck`), unused UI components/CSS, leftover
|
||||
scaffolding. Propose deletion (verify no dynamic/`require`-by-string use first).
|
||||
- [ ] **Overlapping modules:** services that do similar work and could merge or share a
|
||||
core — e.g. the matching family (`matchSuggestionService`, `transactionMatchService`,
|
||||
`merchantStoreMatchService`), the bank-sync family (`bankSyncService`,
|
||||
`bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities;
|
||||
propose a consolidation only where it removes real duplication, not just moves it.
|
||||
core — e.g. the matching family (`matchSuggestionService`, `transactionMatchService`,
|
||||
`merchantStoreMatchService`), the bank-sync family (`bankSyncService`,
|
||||
`bankSyncWorker`, `bankSyncConfigService`, `simplefinService`). Map responsibilities;
|
||||
propose a consolidation only where it removes real duplication, not just moves it.
|
||||
- [ ] **Oversized / low-cohesion files:** split by concern where it aids navigation
|
||||
(e.g. `db/database.js` is very large — migrations vs query helpers vs settings could
|
||||
be separate modules). Propose the seams; don't split for its own sake.
|
||||
(`db/database` was split in IMP-CODE-02; current largest: `SubscriptionsPage.tsx`
|
||||
2,371 ln and `TrackerPage.tsx` 1,943 ln — see IMP-CODE-05). Propose the seams;
|
||||
don't split for its own sake.
|
||||
- [ ] **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
|
||||
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
|
||||
gaps a consolidation would risk.
|
||||
gaps a consolidation would risk.
|
||||
|
||||
### B18 — UX & information architecture / menus (IMP)
|
||||
|
||||
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
|
||||
would look for it*? Candidates are logged as `IMP-UX-*` or `IMP-IA-*` in §2.1 with a
|
||||
_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
|
||||
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.
|
||||
|
||||
**Information architecture & menus**
|
||||
|
||||
- [ ] **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
|
||||
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
|
||||
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
|
||||
rather than scattering them. Flag anything that would be easier to find as a menu item.
|
||||
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
|
||||
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;
|
||||
no dead entries.
|
||||
no dead entries.
|
||||
- [ ] **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**
|
||||
|
||||
- [ ] **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
|
||||
**loading** state (skeleton/spinner, no layout jump), and a **recoverable error**
|
||||
state — no dead ends, no silent failures.
|
||||
**loading** state (skeleton/spinner, no layout jump), and a **recoverable error**
|
||||
state — no dead ends, no silent failures.
|
||||
- [ ] **Feedback & safety:** state changes confirm (toast); destructive actions confirm
|
||||
and, where feasible, offer undo (bills already soft-delete — surface a restore path);
|
||||
long actions show progress.
|
||||
and, where feasible, offer undo (bills already soft-delete — surface a restore path);
|
||||
long actions show progress.
|
||||
- [ ] **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
|
||||
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
|
||||
features (SimpleFIN, debt planning, backups) have a short in-context explanation.
|
||||
features (SimpleFIN, debt planning, backups) have a short in-context explanation.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -625,17 +679,18 @@ desktop and mobile, light and dark), not just as a tester.
|
|||
|
||||
### Appendix A — Severity definitions
|
||||
|
||||
| Level | Definition |
|
||||
|-------|------------|
|
||||
| **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. |
|
||||
| **S3 – Minor** | Works but wrong edge behavior, confusing UX, missing validation message. |
|
||||
| **S4 – Cosmetic** | Visual/copy/alignment/dark-mode-contrast, non-blocking. |
|
||||
| **IMP – Improvement** | Not a bug; enhancement or polish idea. |
|
||||
| Level | Definition |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| **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. |
|
||||
| **S3 – Minor** | Works but wrong edge behavior, confusing UX, missing validation message. |
|
||||
| **S4 – Cosmetic** | Visual/copy/alignment/dark-mode-contrast, non-blocking. |
|
||||
| **IMP – Improvement** | Not a bug; enhancement or polish idea. |
|
||||
|
||||
### Appendix B — Exit / sign-off criteria
|
||||
|
||||
A cycle is release-ready when: **(Cycle 1 — all met ✅)**
|
||||
|
||||
- [x] All batches B0–B15 ✅ (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] **Zero open S1/S2** in the Findings Log; S3/S4/IMP all fixed & archived.
|
||||
|
|
@ -647,38 +702,46 @@ A cycle is release-ready when: **(Cycle 1 — all met ✅)**
|
|||
|
||||
### Appendix C — Page ↔ route ↔ API quick map
|
||||
|
||||
| Page | Route | Primary API |
|
||||
|------|-------|-------------|
|
||||
| Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` |
|
||||
| Calendar | `/calendar` | `/api/calendar` |
|
||||
| Summary | `/summary` | `/api/summary` |
|
||||
| Bills | `/bills` | `/api/bills`, `/api/categories`, `/api/matches` |
|
||||
| Subscriptions / Catalog | `/subscriptions`, `/subscriptions/catalog` | `/api/subscriptions` |
|
||||
| Categories | `/categories` | `/api/categories` |
|
||||
| Health | `/health` | `/api/analytics`, `/api/summary` |
|
||||
| Analytics | `/analytics` | `/api/analytics` |
|
||||
| Spending | `/spending` | `/api/spending` |
|
||||
| Banking | `/bank-transactions` | `/api/transactions`, `/api/matches`, `/api/data-sources` |
|
||||
| Snowball / Payoff | `/snowball`, `/payoff` | `/api/snowball` |
|
||||
| Settings | `/settings` | `/api/settings`, `/api/notifications` |
|
||||
| Profile | `/profile` | `/api/profile`, `/api/user` |
|
||||
| Data | `/data` | `/api/import`, `/api/export`, `/api/data-sources` |
|
||||
| Admin | `/admin`, `/admin/status` | `/api/admin`, `/api/status`, `/api/about-admin` |
|
||||
| About / Privacy / Release Notes / Roadmap | `/about`, `/privacy`, `/release-notes`, `/roadmap` | `/api/about`, `/api/privacy`, `/api/version` |
|
||||
| Page | Route | Primary API |
|
||||
| ------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| Tracker | `/` | `/api/tracker`, `/api/bills`, `/api/payments`, `/api/monthly-starting-amounts` |
|
||||
| Calendar | `/calendar` | `/api/calendar` |
|
||||
| Summary | `/summary` | `/api/summary` |
|
||||
| Bills | `/bills` | `/api/bills`, `/api/categories`, `/api/matches` |
|
||||
| Subscriptions / Catalog | `/subscriptions`, `/subscriptions/catalog` | `/api/subscriptions` |
|
||||
| Categories | `/categories` | `/api/categories` |
|
||||
| Health | `/health` | `/api/analytics`, `/api/summary` |
|
||||
| Analytics | `/analytics` | `/api/analytics` |
|
||||
| Spending | `/spending` | `/api/spending` |
|
||||
| Banking | `/bank-transactions` | `/api/transactions`, `/api/matches`, `/api/data-sources` |
|
||||
| Snowball / Payoff | `/snowball`, `/payoff` | `/api/snowball` |
|
||||
| Settings | `/settings` | `/api/settings`, `/api/notifications` |
|
||||
| Profile | `/profile` | `/api/profile`, `/api/user` |
|
||||
| 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` |
|
||||
| About / Privacy / Release Notes / Roadmap | `/about`, `/privacy`, `/release-notes`, `/roadmap` (admin-gated) | `/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
|
||||
|
||||
`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
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
> ⚠ **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):
|
||||
|
||||
| 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.* Amount input | number | per-month override, cents only, no wheel-scroll change | default ✓ · error-on-letters ✓ | Tab/Esc ✓ | ✅ / finding id |
|
||||
| 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._ 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.
|
||||
</content>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 27 KiB |
|
|
@ -0,0 +1,76 @@
|
|||
// 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);
|
||||
});
|
||||
|
|
@ -51,6 +51,39 @@ export default [
|
|||
'no-irregular-whitespace': 'warn', // CSV/import parsers handle exotic whitespace
|
||||
'no-misleading-character-class': '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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
BIN
img/logo.png
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 27 KiB |
BIN
img/logo_cut.png
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 27 KiB |
29
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.40.0",
|
||||
"version": "0.41.1",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.cts",
|
||||
"engines": {
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-query-devtools": "^5.100.9",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -58,35 +58,36 @@
|
|||
"express-rate-limit": "^8.4.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"kysely": "^0.29.3",
|
||||
"lucide-react": "^0.456.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.9",
|
||||
"nodemailer": "^9.0.3",
|
||||
"openid-client": "^5.7.1",
|
||||
"otplib": "^13.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"sonner": "^1.7.1",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.1",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"fast-check": "^4.8.0",
|
||||
"globals": "^17.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
@ -97,7 +98,7 @@
|
|||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "^5.4.10",
|
||||
"vite": "^8.1.4",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ module.exports = defineConfig({
|
|||
// findings are fixed (it becomes a passing regression guard).
|
||||
{
|
||||
name: 'probe',
|
||||
testMatch: /(api\.probe|a11y\.authed)\.spec\.js/,
|
||||
testMatch: /(api\.probe|a11y\.authed|webauthn\.probe)\.spec\.js/,
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const { log } = require('../utils/logger.cts');
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
|
||||
const { ValidationError } = require('../utils/apiError.cts');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -594,7 +595,7 @@ router.get('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: R
|
|||
router.put('/update-check-setting', requireAuth, requireAdmin, (req: Req, res: Res) => {
|
||||
const { enabled } = req.body || {};
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'enabled must be a boolean' });
|
||||
throw ValidationError('enabled must be a boolean', 'enabled');
|
||||
}
|
||||
setSetting('update_check_enabled', enabled ? 'true' : 'false');
|
||||
res.json({ enabled });
|
||||
|
|
|
|||
172
routes/admin.cts
|
|
@ -4,6 +4,12 @@ const express = require('express');
|
|||
const { log } = require('../utils/logger.cts');
|
||||
const router = express.Router();
|
||||
const { getDb, rollbackMigration } = require('../db/database.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const {
|
||||
getBankSyncConfig,
|
||||
setBankSyncEnabled,
|
||||
|
|
@ -43,6 +49,9 @@ function parseUserId(params) {
|
|||
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) {
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
|
||||
|
|
@ -170,80 +179,70 @@ router.delete('/backups/:id', backupOperationLimiter, (req: Req, res: Res) => {
|
|||
router.post('/users', async (req: Req, res: Res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || username.length < 3)
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
throw ValidationError('Username must be at least 3 characters');
|
||||
if (!password || password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
throw ValidationError('Password must be at least 8 characters');
|
||||
|
||||
const db = getDb();
|
||||
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username))
|
||||
return res.status(409).json({ error: 'Username already taken' });
|
||||
throw ConflictError('Username already taken', 'username');
|
||||
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
const result = db
|
||||
.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)",
|
||||
)
|
||||
.run(username, hash);
|
||||
const hash = await hashPassword(password);
|
||||
const result = db
|
||||
.prepare(
|
||||
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)",
|
||||
)
|
||||
.run(username, hash);
|
||||
|
||||
const created = db
|
||||
.prepare(
|
||||
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?',
|
||||
)
|
||||
.get(result.lastInsertRowid);
|
||||
const created = db
|
||||
.prepare(
|
||||
'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?',
|
||||
)
|
||||
.get(result.lastInsertRowid);
|
||||
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'admin.user.create',
|
||||
entity_type: 'user',
|
||||
entity_id: created.id,
|
||||
details: { created_username: username },
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'admin.user.create',
|
||||
entity_type: 'user',
|
||||
entity_id: created.id,
|
||||
details: { created_username: username },
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
log.error('[admin] create-user error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/password
|
||||
router.put('/users/:id/password', async (req: Req, res: Res) => {
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 8)
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||
throw ValidationError('Password must be at least 8 characters');
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
db.prepare(
|
||||
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
|
||||
).run(hash, targetId);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
|
||||
const hash = await hashPassword(password);
|
||||
db.prepare(
|
||||
"UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?",
|
||||
).run(hash, targetId);
|
||||
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId);
|
||||
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'admin.password.reset',
|
||||
entity_type: 'user',
|
||||
entity_id: targetId,
|
||||
details: { target_username: user.username },
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'admin.password.reset',
|
||||
entity_type: 'user',
|
||||
entity_id: targetId,
|
||||
details: { target_username: user.username },
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
log.error('[admin] reset-password error:', err.message);
|
||||
res.status(500).json({ error: 'Failed to reset password' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/role
|
||||
|
|
@ -251,24 +250,24 @@ router.put('/users/:id/password', async (req: Req, res: Res) => {
|
|||
// changing your own role mid-session.
|
||||
router.put('/users/:id/role', (req: Req, res: Res) => {
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
const { role } = req.body;
|
||||
if (!['admin', 'user'].includes(role)) {
|
||||
return res.status(400).json({ error: 'role must be "admin" or "user"' });
|
||||
throw ValidationError('role must be "admin" or "user"');
|
||||
}
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
if (req.user?.id === targetId) {
|
||||
return res.status(400).json({ error: 'You cannot change your own admin role.' });
|
||||
throw ValidationError('You cannot change your own admin role.');
|
||||
}
|
||||
|
||||
if (user.role === 'admin' && role === 'user') {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n;
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot remove the last admin account.' });
|
||||
throw ValidationError('Cannot remove the last admin account.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,14 +304,14 @@ router.put('/users/:id/role', (req: Req, res: Res) => {
|
|||
// PUT /api/admin/users/:id/active
|
||||
router.put('/users/:id/active', (req: Req, res: Res) => {
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
const active = req.body?.active ? 1 : 0;
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
if (req.user?.id === targetId) {
|
||||
return res.status(400).json({ error: 'You cannot deactivate your own account.' });
|
||||
throw ValidationError('You cannot deactivate your own account.');
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
|
|
@ -334,27 +333,27 @@ router.put('/users/:id/active', (req: Req, res: Res) => {
|
|||
router.put('/users/:id/username', (req: Req, res: Res) => {
|
||||
const { username } = req.body;
|
||||
if (!username || typeof username !== 'string') {
|
||||
return res.status(400).json({ error: 'username is required' });
|
||||
throw ValidationError('username is required');
|
||||
}
|
||||
const trimmed = username.trim();
|
||||
if (trimmed.length < 3) {
|
||||
return res.status(400).json({ error: 'Username must be at least 3 characters' });
|
||||
throw ValidationError('Username must be at least 3 characters');
|
||||
}
|
||||
if (trimmed.length > 50) {
|
||||
return res.status(400).json({ error: 'Username must be 50 characters or fewer' });
|
||||
throw ValidationError('Username must be 50 characters or fewer');
|
||||
}
|
||||
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
const taken = db
|
||||
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
|
||||
.get(trimmed, targetId);
|
||||
if (taken) return res.status(409).json({ error: 'Username already taken' });
|
||||
if (taken) throw ConflictError('Username already taken', 'username');
|
||||
|
||||
const previousUsername = user.username;
|
||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
|
|
@ -384,13 +383,12 @@ router.put('/users/:id/username', (req: Req, res: Res) => {
|
|||
// DELETE /api/admin/users/:id
|
||||
router.delete('/users/:id', (req: Req, res: Res) => {
|
||||
const targetId = parseUserId(req.params);
|
||||
if (!targetId) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
if (!targetId) throw ValidationError('Invalid user ID');
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (req.user?.id === user.id)
|
||||
return res.status(400).json({ error: 'You cannot delete your own account.' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
if (req.user?.id === user.id) throw ValidationError('You cannot delete your own account.');
|
||||
|
||||
const deleteUser = db.transaction(() => {
|
||||
// These three tables have no FK/CASCADE to users — must delete explicitly.
|
||||
|
|
@ -502,25 +500,19 @@ router.get('/bank-sync-config', (req: Req, res: Res) => {
|
|||
// PUT /api/admin/bank-sync-config
|
||||
router.put('/bank-sync-config', (req: Req, res: Res) => {
|
||||
const { enabled, sync_interval_hours, sync_days, debug_logging } = req.body || {};
|
||||
try {
|
||||
let config = getBankSyncConfig();
|
||||
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
|
||||
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
|
||||
if (sync_days !== undefined) config = setSyncDays(sync_days);
|
||||
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' });
|
||||
}
|
||||
let config = getBankSyncConfig();
|
||||
if (typeof enabled === 'boolean') config = setBankSyncEnabled(enabled);
|
||||
if (sync_interval_hours !== undefined) config = setSyncIntervalHours(sync_interval_hours);
|
||||
if (sync_days !== undefined) config = setSyncDays(sync_days);
|
||||
if (typeof debug_logging === 'boolean') config = setDebugLogging(debug_logging);
|
||||
res.json(config);
|
||||
});
|
||||
|
||||
// ── Migration Rollback ────────────────────────────────────────────────────────
|
||||
router.post('/migrations/rollback', async (req: Req, res: Res) => {
|
||||
const { version } = req.body;
|
||||
if (!version) {
|
||||
return res.status(400).json({ error: 'Version is required' });
|
||||
throw ValidationError('Version is required');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -546,13 +538,13 @@ router.post('/migrations/rollback', async (req: Req, res: Res) => {
|
|||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
if (err.code === 'NOT_APPLIED') {
|
||||
return res.status(404).json({ error: err.message });
|
||||
}
|
||||
if (err.code === 'ROLLBACK_NOT_SUPPORTED') {
|
||||
return res.status(422).json({ error: err.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Rollback failed', details: err.message });
|
||||
// Coded rollback errors are intentional user-facing messages; anything
|
||||
// else propagates raw and the terminal handler masks + logs it as a 5xx
|
||||
// (the audit log above keeps err.message either way).
|
||||
if (err.code === 'NOT_APPLIED') throw NotFoundError(err.message);
|
||||
if (err.code === 'ROLLBACK_NOT_SUPPORTED')
|
||||
throw new ApiError('ROLLBACK_NOT_SUPPORTED', err.message, 422);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
578
routes/auth.cts
|
|
@ -1,7 +1,6 @@
|
|||
// @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';
|
||||
const express = require('express');
|
||||
const { log } = require('../utils/logger.cts');
|
||||
const router = express.Router();
|
||||
|
||||
let _appVersion;
|
||||
|
|
@ -31,10 +30,15 @@ const {
|
|||
} = require('../services/authService.cts');
|
||||
const { decryptSecret } = require('../services/encryptionService.cts');
|
||||
const { getCsrfToken } = require('../middleware/csrf.cts');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/requireAuth.cts');
|
||||
const { requireAuth } = require('../middleware/requireAuth.cts');
|
||||
const { getPublicOidcInfo } = require('../services/oidcService.cts');
|
||||
const { ValidationError, formatError } = require('../utils/apiError.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
AuthError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
|
||||
const { logAudit } = require('../services/auditService.cts');
|
||||
|
||||
|
|
@ -54,65 +58,58 @@ router.post(
|
|||
async (req: Req, res: Res) => {
|
||||
// Respect admin-configured login method toggle
|
||||
if (getSetting('local_login_enabled') === 'false') {
|
||||
return res
|
||||
.status(403)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Local username/password login is not enabled on this server.',
|
||||
'FORBIDDEN',
|
||||
),
|
||||
);
|
||||
throw ForbiddenError('Local username/password login is not enabled on this server.');
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Username and password are required',
|
||||
'VALIDATION_ERROR',
|
||||
!username ? 'username' : 'password',
|
||||
),
|
||||
);
|
||||
throw ValidationError(
|
||||
'Username and password are required',
|
||||
!username ? 'username' : 'password',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
|
||||
const result = await login(username, password);
|
||||
if (!result || result.error) {
|
||||
logAudit({
|
||||
user_id: result.user.id,
|
||||
action: 'login.success',
|
||||
user_id: null,
|
||||
action: 'login.failure',
|
||||
details: { username },
|
||||
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 });
|
||||
} catch (err) {
|
||||
log.error('Login error:', err);
|
||||
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
||||
// 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'));
|
||||
}
|
||||
throw AuthError('Invalid username or password');
|
||||
}
|
||||
|
||||
// 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 });
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -256,20 +253,14 @@ const { encryptSecret: encTotpSecret } = require('../services/encryptionService.
|
|||
router.post('/totp/challenge', async (req: Req, res: Res) => {
|
||||
req.csrfSkip = true;
|
||||
const { challenge_token, code, recovery_code } = req.body || {};
|
||||
if (!challenge_token)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('challenge_token is required', 'VALIDATION_ERROR'));
|
||||
if (!challenge_token) throw ValidationError('challenge_token is required');
|
||||
|
||||
const db = getDb();
|
||||
const userId = consumeChallenge(db, challenge_token);
|
||||
if (!userId)
|
||||
return res
|
||||
.status(401)
|
||||
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
|
||||
if (!userId) throw AuthError('Challenge expired or invalid. Please sign in again.');
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(userId);
|
||||
if (!user) return res.status(401).json(standardizeError('User not found.', 'AUTH_ERROR'));
|
||||
if (!user) throw AuthError('User not found.');
|
||||
|
||||
let verified = false;
|
||||
if (recovery_code) {
|
||||
|
|
@ -289,70 +280,42 @@ router.post('/totp/challenge', async (req: Req, res: Res) => {
|
|||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
return res.status(401).json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR'));
|
||||
throw AuthError('Invalid authenticator code.');
|
||||
}
|
||||
|
||||
try {
|
||||
const { createSession } = require('../services/authService.cts');
|
||||
const session = await createSession(userId);
|
||||
if (!session)
|
||||
return res.status(500).json(standardizeError('Failed to create session', 'SERVER_ERROR'));
|
||||
const { createSession } = require('../services/authService.cts');
|
||||
const session = await createSession(userId);
|
||||
if (!session) throw new ApiError('SERVER_ERROR', 'Failed to create session', 500);
|
||||
|
||||
logAudit({
|
||||
user_id: userId,
|
||||
action: 'login.success',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId);
|
||||
logAudit({
|
||||
user_id: userId,
|
||||
action: 'login.success',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
recordLogin(userId, req.ip, req.get('user-agent'), session.sessionId);
|
||||
|
||||
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
|
||||
res.json({ user: session.user });
|
||||
} catch (err) {
|
||||
log.error('[totp/challenge]', err);
|
||||
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
||||
}
|
||||
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
|
||||
res.json({ user: session.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.
|
||||
router.get('/totp/setup', requireAuth, async (req: Req, res: Res) => {
|
||||
if (req.singleUserMode)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
|
||||
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'));
|
||||
}
|
||||
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
|
||||
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 });
|
||||
});
|
||||
|
||||
// POST /api/auth/totp/enable — verify a code against the submitted secret, then enable TOTP.
|
||||
router.post('/totp/enable', requireAuth, (req: Req, res: Res) => {
|
||||
if (req.singleUserMode)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('TOTP is not available in single-user mode.', 'VALIDATION_ERROR'));
|
||||
if (req.singleUserMode) throw ValidationError('TOTP is not available in single-user mode.');
|
||||
const { secret, code } = req.body || {};
|
||||
if (!secret || !code)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('secret and code are required', 'VALIDATION_ERROR'));
|
||||
if (!secret || !code) throw ValidationError('secret and code are required');
|
||||
if (!verifyTokenRaw(secret, code))
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Invalid authenticator code. Check your app and try again.',
|
||||
'VALIDATION_ERROR',
|
||||
'code',
|
||||
),
|
||||
);
|
||||
throw ValidationError('Invalid authenticator code. Check your app and try again.', 'code');
|
||||
|
||||
const plainCodes = generateRecoveryCodes();
|
||||
const hashedCodes = plainCodes.map(hashRecoveryCode);
|
||||
|
|
@ -378,8 +341,7 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
|
|||
const { code, recovery_code } = req.body || {};
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user?.totp_enabled)
|
||||
return res.status(400).json(standardizeError('TOTP is not enabled.', 'VALIDATION_ERROR'));
|
||||
if (!user?.totp_enabled) throw ValidationError('TOTP is not enabled.');
|
||||
|
||||
let verified = false;
|
||||
if (recovery_code) {
|
||||
|
|
@ -388,9 +350,7 @@ router.post('/totp/disable', requireAuth, (req: Req, res: Res) => {
|
|||
verified = verifyToken(user.totp_secret, code);
|
||||
}
|
||||
if (!verified)
|
||||
return res
|
||||
.status(401)
|
||||
.json(standardizeError('Invalid authenticator code.', 'AUTH_ERROR', 'code'));
|
||||
throw new ApiError('AUTH_ERROR', 'Invalid authenticator code.', 401, { field: 'code' });
|
||||
|
||||
db.prepare(
|
||||
`UPDATE users SET totp_enabled=0, totp_secret=NULL, totp_recovery_codes=NULL, updated_at=datetime('now') WHERE id=?`,
|
||||
|
|
@ -442,9 +402,7 @@ router.get('/mode', (req: Req, res: Res) => {
|
|||
// login without needing access to Admin routes.
|
||||
router.post('/restore-multi-user-mode', requireAuth, (req: Req, res: Res) => {
|
||||
if (!req.singleUserMode && getSetting('auth_mode') !== 'single') {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('Single-user mode is not enabled.', 'VALIDATION_ERROR', 'auth_mode'));
|
||||
throw ValidationError('Single-user mode is not enabled.', 'auth_mode');
|
||||
}
|
||||
|
||||
setSetting('auth_mode', 'multi');
|
||||
|
|
@ -469,132 +427,47 @@ router.post('/change-password', passwordLimiter, requireAuth, async (req: Req, r
|
|||
const { current_password, new_password } = req.body;
|
||||
|
||||
if (!new_password || new_password.length < 8) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'New password must be at least 8 characters',
|
||||
'VALIDATION_ERROR',
|
||||
'new_password',
|
||||
),
|
||||
);
|
||||
throw ValidationError('New password must be at least 8 characters', 'new_password');
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
try {
|
||||
if (!user.must_change_password) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
const valid = await bcrypt.compare(current_password || '', user.password_hash);
|
||||
if (!valid)
|
||||
return res
|
||||
.status(401)
|
||||
.json(
|
||||
standardizeError('Current password is incorrect', 'AUTH_ERROR', 'current_password'),
|
||||
);
|
||||
if (!user.must_change_password) {
|
||||
const bcrypt = require('bcryptjs');
|
||||
const valid = await bcrypt.compare(current_password || '', user.password_hash);
|
||||
if (!valid)
|
||||
throw new ApiError('AUTH_ERROR', 'Current password is incorrect', 401, {
|
||||
field: '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'),
|
||||
);
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('Password must be at least 8 characters', 'VALIDATION_ERROR', 'password'),
|
||||
);
|
||||
}
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'password.change',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
|
||||
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'));
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── WebAuthn / FIDO2 security key ─────────────────────────────────────────────
|
||||
|
|
@ -628,138 +501,103 @@ router.get('/webauthn/credentials', requireAuth, (req: Req, res: Res) => {
|
|||
|
||||
// GET /api/auth/webauthn/setup — begin registration
|
||||
router.get('/webauthn/setup', requireAuth, async (req: Req, res: Res) => {
|
||||
if (req.singleUserMode)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
|
||||
try {
|
||||
const { options, challengeId } = await createRegistrationChallenge(
|
||||
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'));
|
||||
}
|
||||
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
|
||||
const { options, challengeId } = await createRegistrationChallenge(
|
||||
getDb(),
|
||||
req.user.id,
|
||||
req.user.username,
|
||||
);
|
||||
res.json({ options, challengeId });
|
||||
});
|
||||
|
||||
// POST /api/auth/webauthn/enable — complete registration
|
||||
router.post('/webauthn/enable', requireAuth, async (req: Req, res: Res) => {
|
||||
if (req.singleUserMode)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('WebAuthn is not available in single-user mode.', 'VALIDATION_ERROR'));
|
||||
if (req.singleUserMode) throw ValidationError('WebAuthn is not available in single-user mode.');
|
||||
const { challengeId, response, credential_name } = req.body || {};
|
||||
if (!challengeId || !response)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('challengeId and response are required', 'VALIDATION_ERROR'));
|
||||
if (!challengeId || !response) throw ValidationError('challengeId and response are required');
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const result = await verifyRegistration(
|
||||
db,
|
||||
req.user.id,
|
||||
challengeId,
|
||||
response,
|
||||
credential_name,
|
||||
);
|
||||
if (!result.verified)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(result.error || 'Registration failed', 'VALIDATION_ERROR'));
|
||||
const db = getDb();
|
||||
const result = await verifyRegistration(
|
||||
db,
|
||||
req.user.id,
|
||||
challengeId,
|
||||
response,
|
||||
credential_name,
|
||||
req.get('origin'),
|
||||
);
|
||||
if (!result.verified) throw ValidationError(result.error || 'Registration failed');
|
||||
|
||||
db.prepare(
|
||||
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
|
||||
).run(req.user.id);
|
||||
db.prepare(
|
||||
"UPDATE users SET webauthn_enabled = 1, updated_at = datetime('now') WHERE id = ?",
|
||||
).run(req.user.id);
|
||||
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'webauthn.credential_added',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
res.json({ enabled: true, credential_id: result.credentialId });
|
||||
} catch (err) {
|
||||
log.error('[webauthn/enable]', err);
|
||||
res.status(500).json(standardizeError('Registration failed', 'SERVER_ERROR'));
|
||||
}
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'webauthn.credential_added',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
res.json({ enabled: true, credential_id: result.credentialId });
|
||||
});
|
||||
|
||||
// DELETE /api/auth/webauthn/credentials/:credentialId — remove one key
|
||||
router.delete('/webauthn/credentials/:credentialId', requireAuth, async (req: Req, res: Res) => {
|
||||
const { password } = req.body || {};
|
||||
if (!password)
|
||||
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
|
||||
if (!password) throw ValidationError('password is required');
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||
try {
|
||||
const bcrypt = require('bcryptjs');
|
||||
if (!(await bcrypt.compare(password, user.password_hash)))
|
||||
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
|
||||
const bcrypt = require('bcryptjs');
|
||||
if (!(await bcrypt.compare(password, user.password_hash)))
|
||||
throw AuthError('Password is incorrect');
|
||||
|
||||
const result = deleteCredential(db, req.params.credentialId, req.user.id);
|
||||
if (result.changes === 0)
|
||||
return res.status(404).json(standardizeError('Credential not found', 'NOT_FOUND'));
|
||||
const result = deleteCredential(db, req.params.credentialId, req.user.id);
|
||||
if (result.changes === 0) throw NotFoundError('Credential not found');
|
||||
|
||||
const { n } = db
|
||||
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
|
||||
.get(req.user.id);
|
||||
if (n === 0)
|
||||
db.prepare(
|
||||
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
|
||||
).run(req.user.id);
|
||||
const { n } = db
|
||||
.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?')
|
||||
.get(req.user.id);
|
||||
if (n === 0)
|
||||
db.prepare(
|
||||
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
|
||||
).run(req.user.id);
|
||||
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'webauthn.credential_removed',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
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'));
|
||||
}
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'webauthn.credential_removed',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
res.json({ success: true, webauthn_enabled: n > 0 });
|
||||
});
|
||||
|
||||
// POST /api/auth/webauthn/disable — remove all keys, disable WebAuthn
|
||||
router.post('/webauthn/disable', requireAuth, async (req: Req, res: Res) => {
|
||||
const { password } = req.body || {};
|
||||
if (!password)
|
||||
return res.status(400).json(standardizeError('password is required', 'VALIDATION_ERROR'));
|
||||
if (!password) throw ValidationError('password is required');
|
||||
|
||||
const db = getDb();
|
||||
const user = db
|
||||
.prepare('SELECT password_hash, webauthn_enabled FROM users WHERE id = ?')
|
||||
.get(req.user.id);
|
||||
if (!user?.webauthn_enabled)
|
||||
return res.status(400).json(standardizeError('WebAuthn is not enabled.', 'VALIDATION_ERROR'));
|
||||
if (!user?.webauthn_enabled) throw ValidationError('WebAuthn is not enabled.');
|
||||
|
||||
try {
|
||||
const bcrypt = require('bcryptjs');
|
||||
if (!(await bcrypt.compare(password, user.password_hash)))
|
||||
return res.status(401).json(standardizeError('Password is incorrect', 'AUTH_ERROR'));
|
||||
const bcrypt = require('bcryptjs');
|
||||
if (!(await bcrypt.compare(password, user.password_hash)))
|
||||
throw AuthError('Password is incorrect');
|
||||
|
||||
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
|
||||
db.prepare(
|
||||
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
|
||||
).run(req.user.id);
|
||||
db.prepare('DELETE FROM webauthn_credentials WHERE user_id = ?').run(req.user.id);
|
||||
db.prepare(
|
||||
"UPDATE users SET webauthn_enabled = 0, updated_at = datetime('now') WHERE id = ?",
|
||||
).run(req.user.id);
|
||||
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'webauthn.disabled',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
res.json({ enabled: false });
|
||||
} catch (err) {
|
||||
log.error('[webauthn/disable]', err);
|
||||
res.status(500).json(standardizeError('Failed to disable WebAuthn', 'SERVER_ERROR'));
|
||||
}
|
||||
logAudit({
|
||||
user_id: req.user.id,
|
||||
action: 'webauthn.disabled',
|
||||
ip_address: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
res.json({ enabled: false });
|
||||
});
|
||||
|
||||
// POST /api/auth/webauthn/challenge — second step of login when WebAuthn is enabled.
|
||||
|
|
@ -768,59 +606,47 @@ router.post('/webauthn/challenge', async (req: Req, res: Res) => {
|
|||
req.csrfSkip = true;
|
||||
const { challenge_token, response } = req.body || {};
|
||||
if (!challenge_token || !response)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('challenge_token and response are required', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('challenge_token and response are required');
|
||||
|
||||
const db = getDb();
|
||||
const session = consumeLoginChallenge(db, challenge_token);
|
||||
if (!session)
|
||||
return res
|
||||
.status(401)
|
||||
.json(standardizeError('Challenge expired or invalid. Please sign in again.', 'AUTH_ERROR'));
|
||||
if (!session) throw AuthError('Challenge expired or invalid. Please sign in again.');
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ? AND active = 1').get(session.userId);
|
||||
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'));
|
||||
if (!user) throw AuthError('User not found.');
|
||||
|
||||
const result = await verifyAuthentication(
|
||||
db,
|
||||
session.userId,
|
||||
session.authChallengeId,
|
||||
response,
|
||||
req.get('origin'),
|
||||
);
|
||||
if (!result.verified) {
|
||||
logAudit({
|
||||
user_id: session.userId,
|
||||
action: 'login.success',
|
||||
details: { method: 'webauthn' },
|
||||
action: 'webauthn.failure',
|
||||
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 });
|
||||
} catch (err) {
|
||||
log.error('[webauthn/challenge]', err);
|
||||
res.status(500).json(standardizeError('Login failed', 'SERVER_ERROR'));
|
||||
throw AuthError('Security key verification failed.');
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
505
routes/bills.cts
|
|
@ -16,7 +16,12 @@ const {
|
|||
applyBalanceDelta,
|
||||
} = require('../services/billsService.cts');
|
||||
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const { validatePaymentInput, serializePayment } = require('../services/paymentValidation.cts');
|
||||
const {
|
||||
addMerchantRule,
|
||||
|
|
@ -100,9 +105,7 @@ router.put('/reorder', (req: Req, res: Res) => {
|
|||
}));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('At least one bill order is required', 'VALIDATION_ERROR', 'reorder'));
|
||||
throw ValidationError('At least one bill order is required', 'reorder');
|
||||
}
|
||||
|
||||
const invalid = entries.find(
|
||||
|
|
@ -110,15 +113,10 @@ router.put('/reorder', (req: Req, res: Res) => {
|
|||
!Number.isInteger(billId) || billId <= 0 || !Number.isInteger(sortOrder) || sortOrder < 0,
|
||||
);
|
||||
if (invalid) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Reorder payload must map bill ids to non-negative integer positions',
|
||||
'VALIDATION_ERROR',
|
||||
'reorder',
|
||||
),
|
||||
);
|
||||
throw ValidationError(
|
||||
'Reorder payload must map bill ids to non-negative integer positions',
|
||||
'reorder',
|
||||
);
|
||||
}
|
||||
|
||||
const ids = entries.map((item) => item.billId);
|
||||
|
|
@ -133,9 +131,7 @@ router.put('/reorder', (req: Req, res: Res) => {
|
|||
)
|
||||
.all(req.user.id, ...ids);
|
||||
if (owned.length !== ids.length) {
|
||||
return res
|
||||
.status(404)
|
||||
.json(standardizeError('One or more bills were not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('One or more bills were not found', 'bill_id');
|
||||
}
|
||||
|
||||
const update = db.prepare(
|
||||
|
|
@ -172,19 +168,14 @@ router.get('/audit', (req: Req, res: Res) => {
|
|||
// ── GET /api/bills/drift-report ──────────────────────────────────────────────
|
||||
router.get('/drift-report', (req: Req, res: Res) => {
|
||||
const { getDriftReport } = require('../services/driftService.cts');
|
||||
try {
|
||||
res.json(getDriftReport(req.user.id));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to compute drift report' });
|
||||
}
|
||||
res.json(getDriftReport(req.user.id));
|
||||
});
|
||||
|
||||
// GET /api/bills/merchant-rules — all rules for this user across all bills
|
||||
router.get('/merchant-rules', (req: Req, res: Res) => {
|
||||
try {
|
||||
const rules = getDb()
|
||||
.prepare(
|
||||
`
|
||||
const rules = getDb()
|
||||
.prepare(
|
||||
`
|
||||
SELECT bmr.id, bmr.merchant, bmr.auto_attribute_late, bmr.created_at,
|
||||
b.id AS bill_id, b.name AS bill_name
|
||||
FROM bill_merchant_rules bmr
|
||||
|
|
@ -192,13 +183,9 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
|
|||
WHERE bmr.user_id = ?
|
||||
ORDER BY b.name COLLATE NOCASE ASC, LENGTH(bmr.merchant) DESC, bmr.merchant ASC
|
||||
`,
|
||||
)
|
||||
.all(req.user.id);
|
||||
res.json({ rules });
|
||||
} catch (err) {
|
||||
log.error('[bills/merchant-rules GET]', err.message);
|
||||
res.status(500).json({ error: 'Failed to load merchant rules' });
|
||||
}
|
||||
)
|
||||
.all(req.user.id);
|
||||
res.json({ rules });
|
||||
});
|
||||
|
||||
// ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
|
||||
|
|
@ -206,11 +193,11 @@ router.get('/merchant-rules', (req: Req, res: Res) => {
|
|||
router.post('/:id/snooze-drift', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
|
||||
if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid id');
|
||||
const bill = db
|
||||
.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL')
|
||||
.get(id);
|
||||
if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' });
|
||||
if (!bill || bill.user_id !== req.user.id) throw NotFoundError('Not found');
|
||||
const until = new Date();
|
||||
until.setDate(until.getDate() + 30);
|
||||
const untilStr = localDateString(until);
|
||||
|
|
@ -257,36 +244,20 @@ router.post('/templates', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (name.length < 2) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('Template name must be at least 2 characters', 'VALIDATION_ERROR', 'name'),
|
||||
);
|
||||
throw ValidationError('Template name must be at least 2 characters', 'name');
|
||||
}
|
||||
|
||||
const data = sanitizeTemplateData(req.body.data || {});
|
||||
if (Object.keys(data).length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('Template data is required', 'VALIDATION_ERROR', 'data'));
|
||||
throw ValidationError('Template data is required', 'data');
|
||||
}
|
||||
const validation = validateBillData(data);
|
||||
if (validation.errors.length > 0) {
|
||||
const firstError = validation.errors[0];
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', `data.${firstError.field}`));
|
||||
throw ValidationError(firstError.message, `data.${firstError.field}`);
|
||||
}
|
||||
if (!categoryBelongsToUser(db, validation.normalized.category_id, req.user.id)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'category_id is invalid for this user',
|
||||
'VALIDATION_ERROR',
|
||||
'data.category_id',
|
||||
),
|
||||
);
|
||||
throw ValidationError('category_id is invalid for this user', 'data.category_id');
|
||||
}
|
||||
const normalizedData = sanitizeTemplateData(validation.normalized);
|
||||
|
||||
|
|
@ -323,15 +294,12 @@ router.delete('/templates/:templateId', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const templateId = parseInt(req.params.templateId, 10);
|
||||
if (!Number.isInteger(templateId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('template_id must be an integer', 'VALIDATION_ERROR', 'template_id'));
|
||||
throw ValidationError('template_id must be an integer', 'template_id');
|
||||
}
|
||||
const result = db
|
||||
.prepare('DELETE FROM bill_templates WHERE id = ? AND user_id = ?')
|
||||
.run(templateId, req.user.id);
|
||||
if (result.changes === 0)
|
||||
return res.status(404).json(standardizeError('Template not found', 'NOT_FOUND', 'template_id'));
|
||||
if (result.changes === 0) throw NotFoundError('Template not found', 'template_id');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
|
@ -341,15 +309,12 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
|
|||
const body = req.body || {};
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
|
||||
throw ValidationError('bill_id must be an integer', 'bill_id');
|
||||
}
|
||||
const source = db
|
||||
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id);
|
||||
if (!source)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!source) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const draft = {
|
||||
...sanitizeTemplateData(serializeBill(source)),
|
||||
|
|
@ -359,17 +324,11 @@ router.post('/:id/duplicate', (req: Req, res: Res) => {
|
|||
const validation = validateBillData(draft);
|
||||
if (validation.errors.length > 0) {
|
||||
const firstError = validation.errors[0];
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
|
||||
throw ValidationError(firstError.message, firstError.field);
|
||||
}
|
||||
const { normalized } = validation;
|
||||
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
|
||||
);
|
||||
throw ValidationError('category_id is invalid for this user', 'category_id');
|
||||
}
|
||||
|
||||
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
|
||||
|
|
@ -384,26 +343,14 @@ router.get('/:id/monthly-state', (req: Req, res: Res) => {
|
|||
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id)
|
||||
)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const year = parseInt(req.query.year, 10);
|
||||
const month = parseInt(req.query.month, 10);
|
||||
if (isNaN(year) || year < 2000 || year > 2100)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'year must be a 4-digit integer between 2000 and 2100',
|
||||
'VALIDATION_ERROR',
|
||||
'year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
||||
if (isNaN(month) || month < 1 || month > 12)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
|
||||
);
|
||||
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
||||
|
||||
const mbs = db
|
||||
.prepare(
|
||||
|
|
@ -430,54 +377,29 @@ router.put('/:id/monthly-state', (req: Req, res: Res) => {
|
|||
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id)
|
||||
)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body;
|
||||
|
||||
const y = parseInt(year, 10);
|
||||
const m = parseInt(month, 10);
|
||||
if (isNaN(y) || y < 2000 || y > 2100)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'year must be a 4-digit integer between 2000 and 2100',
|
||||
'VALIDATION_ERROR',
|
||||
'year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
||||
if (isNaN(m) || m < 1 || m > 12)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
|
||||
);
|
||||
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
||||
|
||||
if (actual_amount !== undefined && actual_amount !== null) {
|
||||
const amt = parseFloat(actual_amount);
|
||||
if (isNaN(amt) || amt < 0)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'actual_amount must be a non-negative number or null',
|
||||
'VALIDATION_ERROR',
|
||||
'actual_amount',
|
||||
),
|
||||
);
|
||||
throw ValidationError('actual_amount must be a non-negative number or null', 'actual_amount');
|
||||
}
|
||||
|
||||
if (snoozed_until !== undefined && snoozed_until !== null) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until))
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
|
||||
'VALIDATION_ERROR',
|
||||
'snoozed_until',
|
||||
),
|
||||
);
|
||||
throw ValidationError(
|
||||
'snoozed_until must be an ISO date string (YYYY-MM-DD) or null',
|
||||
'snoozed_until',
|
||||
);
|
||||
}
|
||||
|
||||
// Partial-update semantics: fields omitted from the request keep their
|
||||
|
|
@ -551,8 +473,7 @@ router.get('/:id', (req: Req, res: Res) => {
|
|||
`,
|
||||
)
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
let autopay_stats = null;
|
||||
if (bill.autopay_enabled) {
|
||||
|
|
@ -584,25 +505,16 @@ router.post('/:id/verify-autopay', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id'));
|
||||
throw ValidationError('Invalid id', 'bill_id');
|
||||
}
|
||||
const bill = db
|
||||
.prepare(
|
||||
'SELECT id, autopay_enabled FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL',
|
||||
)
|
||||
.get(id, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
if (!bill.autopay_enabled) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Bill does not have autopay enabled',
|
||||
'VALIDATION_ERROR',
|
||||
'autopay_enabled',
|
||||
),
|
||||
);
|
||||
throw ValidationError('Bill does not have autopay enabled', 'autopay_enabled');
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
|
|
@ -624,23 +536,12 @@ router.post('/', (req: Req, res: Res) => {
|
|||
) {
|
||||
const sourceBillId = parseInt(body.source_bill_id, 10);
|
||||
if (!Number.isInteger(sourceBillId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'source_bill_id must be an integer',
|
||||
'VALIDATION_ERROR',
|
||||
'source_bill_id',
|
||||
),
|
||||
);
|
||||
throw ValidationError('source_bill_id must be an integer', 'source_bill_id');
|
||||
}
|
||||
const source = db
|
||||
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(sourceBillId, req.user.id);
|
||||
if (!source)
|
||||
return res
|
||||
.status(404)
|
||||
.json(standardizeError('Source bill not found', 'NOT_FOUND', 'source_bill_id'));
|
||||
if (!source) throw NotFoundError('Source bill not found', 'source_bill_id');
|
||||
payload = {
|
||||
...sanitizeTemplateData(serializeBill(source)),
|
||||
...sanitizeTemplateData(body),
|
||||
|
|
@ -652,20 +553,14 @@ router.post('/', (req: Req, res: Res) => {
|
|||
const validation = validateBillData(payload);
|
||||
if (validation.errors.length > 0) {
|
||||
const firstError = validation.errors[0];
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
|
||||
throw ValidationError(firstError.message, firstError.field);
|
||||
}
|
||||
|
||||
const { normalized } = validation;
|
||||
|
||||
// Validate category_id exists for this user
|
||||
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
|
||||
);
|
||||
throw ValidationError('category_id is invalid for this user', 'category_id');
|
||||
}
|
||||
|
||||
res.status(201).json(serializeBill(insertBill(db, req.user.id, normalized)));
|
||||
|
|
@ -677,27 +572,20 @@ router.put('/:id', (req: Req, res: Res) => {
|
|||
const existing = db
|
||||
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!existing) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
// Validate and normalize bill data
|
||||
const validation = validateBillData(req.body, existing);
|
||||
if (validation.errors.length > 0) {
|
||||
const firstError = validation.errors[0];
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(firstError.message, 'VALIDATION_ERROR', firstError.field));
|
||||
throw ValidationError(firstError.message, firstError.field);
|
||||
}
|
||||
|
||||
const { normalized } = validation;
|
||||
|
||||
// Validate category_id exists for this user if changed
|
||||
if (!categoryBelongsToUser(db, normalized.category_id, req.user.id)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('category_id is invalid for this user', 'VALIDATION_ERROR', 'category_id'),
|
||||
);
|
||||
throw ValidationError('category_id is invalid for this user', 'category_id');
|
||||
}
|
||||
|
||||
const inactiveReason =
|
||||
|
|
@ -772,13 +660,12 @@ router.put('/:id/archived', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR', 'bill_id'));
|
||||
throw ValidationError('Invalid id', 'bill_id');
|
||||
}
|
||||
const bill = db
|
||||
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(id, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const archived = !!req.body?.archived;
|
||||
db.prepare(
|
||||
|
|
@ -797,8 +684,7 @@ router.delete('/:id', (req: Req, res: Res) => {
|
|||
const bill = db
|
||||
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
db.prepare(
|
||||
"UPDATE bills SET deleted_at = datetime('now'), active = 0, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
|
||||
|
|
@ -818,8 +704,7 @@ router.post('/:id/restore', (req: Req, res: Res) => {
|
|||
const bill = db
|
||||
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Deleted bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Deleted bill not found', 'bill_id');
|
||||
|
||||
db.prepare(
|
||||
"UPDATE bills SET deleted_at = NULL, active = 1, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
|
||||
|
|
@ -839,19 +724,14 @@ router.post('/:id/restore', (req: Req, res: Res) => {
|
|||
// backfill any missing payments.
|
||||
router.post('/:id/sync-simplefin-payments', (req: Req, res: Res) => {
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId))
|
||||
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
|
||||
if (!Number.isInteger(billId)) throw ValidationError('Invalid bill id');
|
||||
const db = getDb();
|
||||
const bill = db
|
||||
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id);
|
||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
try {
|
||||
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json(standardizeError(err.message || 'Sync failed', 'SYNC_ERROR'));
|
||||
}
|
||||
if (!bill) throw NotFoundError('Bill not found');
|
||||
const result = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── GET /api/bills/:id/payments?page=1&limit=20 ───────────────────────────────
|
||||
|
|
@ -860,8 +740,7 @@ router.get('/:id/payments', (req: Req, res: Res) => {
|
|||
const bill = db
|
||||
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
|
||||
const page = Math.max(parseInt(req.query.page || '1', 10), 1);
|
||||
|
|
@ -893,16 +772,13 @@ router.get('/:id/transactions', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
|
||||
throw ValidationError('bill_id must be an integer', 'bill_id');
|
||||
}
|
||||
|
||||
const bill = db
|
||||
.prepare('SELECT id, name FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
|
|
@ -971,40 +847,19 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
|
|||
)
|
||||
.get(billId, req.user.id);
|
||||
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
// Scope to year/month if provided
|
||||
const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null;
|
||||
const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null;
|
||||
if ((year === null) !== (month === null)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'year and month must both be provided or both omitted',
|
||||
'VALIDATION_ERROR',
|
||||
'year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('year and month must both be provided or both omitted', 'year');
|
||||
}
|
||||
if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'year must be a 4-digit integer between 2000 and 2100',
|
||||
'VALIDATION_ERROR',
|
||||
'year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
||||
}
|
||||
if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'),
|
||||
);
|
||||
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
||||
}
|
||||
|
||||
let currentPayment;
|
||||
|
|
@ -1068,11 +923,8 @@ router.post('/:id/toggle-paid', (req: Req, res: Res) => {
|
|||
{ amount, paid_date: paidDate, payment_source: req.body.payment_source ?? 'manual' },
|
||||
{ requireBillId: false },
|
||||
);
|
||||
if (paymentValidation.error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
|
||||
}
|
||||
if (paymentValidation.error)
|
||||
throw ValidationError(paymentValidation.error, paymentValidation.field);
|
||||
const payment = paymentValidation.normalized;
|
||||
|
||||
// Compute balance delta for debt bills before inserting
|
||||
|
|
@ -1112,7 +964,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')
|
||||
.get(req.params.id, req.user.id)
|
||||
)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const ranges = db
|
||||
.prepare(
|
||||
|
|
@ -1139,77 +991,40 @@ router.post('/:id/history-ranges', (req: Req, res: Res) => {
|
|||
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(req.params.id, req.user.id)
|
||||
)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const { start_year, start_month, end_year, end_month, label } = req.body;
|
||||
|
||||
const sy = parseInt(start_year, 10);
|
||||
const sm = parseInt(start_month, 10);
|
||||
if (isNaN(sy) || sy < 2000 || sy > 2100)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'start_year must be between 2000 and 2100',
|
||||
'VALIDATION_ERROR',
|
||||
'start_year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('start_year must be between 2000 and 2100', 'start_year');
|
||||
if (isNaN(sm) || sm < 1 || sm > 12)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
|
||||
);
|
||||
throw ValidationError('start_month must be between 1 and 12', 'start_month');
|
||||
|
||||
let ey = null,
|
||||
em = null;
|
||||
if (end_year != null) {
|
||||
ey = parseInt(end_year, 10);
|
||||
if (isNaN(ey) || ey < 2000 || ey > 2100)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'end_year must be between 2000 and 2100',
|
||||
'VALIDATION_ERROR',
|
||||
'end_year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('end_year must be between 2000 and 2100', 'end_year');
|
||||
}
|
||||
if (end_month != null) {
|
||||
em = parseInt(end_month, 10);
|
||||
if (isNaN(em) || em < 1 || em > 12)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
|
||||
);
|
||||
throw ValidationError('end_month must be between 1 and 12', 'end_month');
|
||||
}
|
||||
if ((ey == null) !== (em == null)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'end_year and end_month must both be provided or both omitted',
|
||||
'VALIDATION_ERROR',
|
||||
'end_year',
|
||||
),
|
||||
);
|
||||
throw ValidationError(
|
||||
'end_year and end_month must both be provided or both omitted',
|
||||
'end_year',
|
||||
);
|
||||
}
|
||||
if (ey != null) {
|
||||
const startVal = sy * 12 + sm;
|
||||
const endVal = ey * 12 + em;
|
||||
if (endVal < startVal)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'end date must be on or after start date',
|
||||
'VALIDATION_ERROR',
|
||||
'end_year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('end date must be on or after start date', 'end_year');
|
||||
}
|
||||
|
||||
const result = db
|
||||
|
|
@ -1235,36 +1050,21 @@ 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')
|
||||
.get(req.params.id, req.user.id)
|
||||
)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const range = db
|
||||
.prepare('SELECT * FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
|
||||
.get(req.params.rangeId, req.params.id);
|
||||
if (!range)
|
||||
return res
|
||||
.status(404)
|
||||
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
|
||||
if (!range) throw NotFoundError('History range not found', 'rangeId');
|
||||
|
||||
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 sm = start_month != null ? parseInt(start_month, 10) : range.start_month;
|
||||
if (isNaN(sy) || sy < 2000 || sy > 2100)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'start_year must be between 2000 and 2100',
|
||||
'VALIDATION_ERROR',
|
||||
'start_year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('start_year must be between 2000 and 2100', 'start_year');
|
||||
if (isNaN(sm) || sm < 1 || sm > 12)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('start_month must be between 1 and 12', 'VALIDATION_ERROR', 'start_month'),
|
||||
);
|
||||
throw ValidationError('start_month must be between 1 and 12', 'start_month');
|
||||
|
||||
let ey = range.end_year;
|
||||
let em = range.end_month;
|
||||
|
|
@ -1272,33 +1072,16 @@ router.put('/:id/history-ranges/:rangeId', (req: Req, res: Res) => {
|
|||
if (end_month !== undefined) em = end_month != null ? parseInt(end_month, 10) : null;
|
||||
|
||||
if (ey != null && (isNaN(ey) || ey < 2000 || ey > 2100))
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('end_year must be between 2000 and 2100', 'VALIDATION_ERROR', 'end_year'),
|
||||
);
|
||||
throw ValidationError('end_year must be between 2000 and 2100', 'end_year');
|
||||
if (em != null && (isNaN(em) || em < 1 || em > 12))
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('end_month must be between 1 and 12', 'VALIDATION_ERROR', 'end_month'),
|
||||
);
|
||||
throw ValidationError('end_month must be between 1 and 12', 'end_month');
|
||||
if ((ey == null) !== (em == null))
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'end_year and end_month must both be provided or both omitted',
|
||||
'VALIDATION_ERROR',
|
||||
'end_year',
|
||||
),
|
||||
);
|
||||
throw ValidationError(
|
||||
'end_year and end_month must both be provided or both omitted',
|
||||
'end_year',
|
||||
);
|
||||
if (ey != null && ey * 12 + em < sy * 12 + sm)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('end date must be on or after start date', 'VALIDATION_ERROR', 'end_year'),
|
||||
);
|
||||
throw ValidationError('end date must be on or after start date', 'end_year');
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
|
|
@ -1331,15 +1114,12 @@ 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')
|
||||
.get(req.params.id, req.user.id)
|
||||
)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const range = db
|
||||
.prepare('SELECT id FROM bill_history_ranges WHERE id = ? AND bill_id = ?')
|
||||
.get(req.params.rangeId, req.params.id);
|
||||
if (!range)
|
||||
return res
|
||||
.status(404)
|
||||
.json(standardizeError('History range not found', 'NOT_FOUND', 'rangeId'));
|
||||
if (!range) throw NotFoundError('History range not found', 'rangeId');
|
||||
|
||||
db.prepare('DELETE FROM bill_history_ranges WHERE id = ? AND bill_id = ?').run(
|
||||
req.params.rangeId,
|
||||
|
|
@ -1356,8 +1136,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
|
|||
const bill = db
|
||||
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const balance = fromCents(Number(bill.current_balance));
|
||||
const apr = Number(bill.interest_rate) || 0;
|
||||
|
|
@ -1368,9 +1147,7 @@ router.get('/:id/amortization', (req: Req, res: Res) => {
|
|||
if (req.query.payment !== undefined) {
|
||||
const qp = parseFloat(req.query.payment);
|
||||
if (!Number.isFinite(qp) || qp <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('payment must be a positive number', 'VALIDATION_ERROR', 'payment'));
|
||||
throw ValidationError('payment must be a positive number', 'payment');
|
||||
}
|
||||
payment = qp;
|
||||
}
|
||||
|
|
@ -1424,7 +1201,7 @@ router.patch('/:id/snowball', (req: Req, res: Res) => {
|
|||
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id)
|
||||
) {
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
}
|
||||
const include =
|
||||
req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined;
|
||||
|
|
@ -1440,8 +1217,7 @@ router.patch('/:id/snowball', (req: Req, res: Res) => {
|
|||
parts.push('snowball_exempt = ?');
|
||||
vals.push(exempt);
|
||||
}
|
||||
if (parts.length === 0)
|
||||
return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR'));
|
||||
if (parts.length === 0) throw ValidationError('Nothing to update');
|
||||
parts.push("updated_at = datetime('now')");
|
||||
db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(
|
||||
...vals,
|
||||
|
|
@ -1460,7 +1236,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
|
|||
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id)
|
||||
) {
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
}
|
||||
|
||||
const raw = req.body.current_balance;
|
||||
|
|
@ -1468,15 +1244,7 @@ router.patch('/:id/balance', (req: Req, res: Res) => {
|
|||
if (raw !== null && raw !== '' && raw !== undefined) {
|
||||
val = parseFloat(raw);
|
||||
if (!Number.isFinite(val) || val < 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'current_balance must be a non-negative number',
|
||||
'VALIDATION_ERROR',
|
||||
'current_balance',
|
||||
),
|
||||
);
|
||||
throw ValidationError('current_balance must be a non-negative number', 'current_balance');
|
||||
}
|
||||
val = roundMoney(val);
|
||||
}
|
||||
|
|
@ -1538,10 +1306,8 @@ function findConflicts(db, userId, billId, normalized) {
|
|||
router.get('/:id/merchant-rules', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1)
|
||||
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'));
|
||||
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
|
||||
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
|
||||
|
||||
const rules = db
|
||||
.prepare(
|
||||
|
|
@ -1591,10 +1357,8 @@ router.get('/:id/merchant-rules', (req: Req, res: Res) => {
|
|||
router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1)
|
||||
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'));
|
||||
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
|
||||
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
|
||||
|
||||
const raw = String(req.query.merchant || '').trim();
|
||||
const normalized = normalizeMerchant(raw);
|
||||
|
|
@ -1612,37 +1376,23 @@ router.get('/:id/merchant-rules/preview', (req: Req, res: Res) => {
|
|||
router.post('/:id/merchant-rules', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1)
|
||||
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'));
|
||||
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
|
||||
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
|
||||
|
||||
const raw = String(req.body?.merchant || '').trim();
|
||||
const normalized = normalizeMerchant(raw);
|
||||
if (!normalized || normalized.length < 2)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'merchant must be at least 2 characters after normalisation',
|
||||
'VALIDATION_ERROR',
|
||||
'merchant',
|
||||
),
|
||||
);
|
||||
throw ValidationError('merchant must be at least 2 characters after normalisation', 'merchant');
|
||||
|
||||
const conflicts = findConflicts(db, req.user.id, billId, normalized);
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO bill_merchant_rules (user_id, bill_id, merchant)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, bill_id, merchant) DO NOTHING
|
||||
`,
|
||||
).run(req.user.id, billId, normalized);
|
||||
} catch (err) {
|
||||
return res.status(500).json(standardizeError('Failed to save rule', 'DB_ERROR'));
|
||||
}
|
||||
).run(req.user.id, billId, normalized);
|
||||
|
||||
// Retroactively apply the new rule to existing unmatched transactions
|
||||
const { added } = syncBillPaymentsFromSimplefin(db, req.user.id, billId);
|
||||
|
|
@ -1665,10 +1415,9 @@ router.post('/:id/merchant-rules', (req: Req, res: Res) => {
|
|||
router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1)
|
||||
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
|
||||
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
|
||||
const bill = requireBill(db, billId, req.user.id);
|
||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
if (!bill) throw NotFoundError('Bill not found');
|
||||
|
||||
const rules = db
|
||||
.prepare('SELECT merchant FROM bill_merchant_rules WHERE user_id = ? AND bill_id = ?')
|
||||
|
|
@ -1750,22 +1499,16 @@ router.get('/:id/merchant-rules/candidates', (req: Req, res: Res) => {
|
|||
router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1)
|
||||
return res.status(400).json(standardizeError('Invalid bill id', 'VALIDATION_ERROR'));
|
||||
if (!Number.isInteger(billId) || billId < 1) throw ValidationError('Invalid bill id');
|
||||
const bill = requireBill(db, billId, req.user.id);
|
||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
if (!bill) throw NotFoundError('Bill not found');
|
||||
|
||||
const ids = req.body?.transaction_ids;
|
||||
if (!Array.isArray(ids) || ids.length === 0)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('transaction_ids must be a non-empty array', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('transaction_ids must be a non-empty array');
|
||||
|
||||
const validIds = ids.filter((id) => Number.isInteger(id) && id > 0);
|
||||
if (validIds.length === 0)
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('No valid transaction ids provided', 'VALIDATION_ERROR'));
|
||||
if (validIds.length === 0) throw ValidationError('No valid transaction ids provided');
|
||||
|
||||
const getBill = db.prepare('SELECT * FROM bills WHERE id = ? AND deleted_at IS NULL');
|
||||
const getTx = db.prepare(
|
||||
|
|
@ -1837,7 +1580,7 @@ router.post('/:id/merchant-rules/import-historical', (req: Req, res: Res) => {
|
|||
})();
|
||||
} catch (err) {
|
||||
log.error('[import-historical] Transaction failed:', err.message);
|
||||
return res.status(500).json(standardizeError('Import failed', 'DB_ERROR'));
|
||||
throw new ApiError('DB_ERROR', 'Import failed', 500);
|
||||
}
|
||||
|
||||
res.json({ imported, late_attributions: lateAttributions });
|
||||
|
|
@ -1850,9 +1593,8 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
|
|||
const billId = parseInt(req.params.id, 10);
|
||||
const ruleId = parseInt(req.params.ruleId, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
|
||||
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
|
||||
if (!requireBill(db, billId, req.user.id))
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
throw ValidationError('Invalid id');
|
||||
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
|
||||
|
||||
const enabled = req.body?.enabled ? 1 : 0;
|
||||
const changes = db
|
||||
|
|
@ -1861,7 +1603,7 @@ router.patch('/:id/merchant-rules/:ruleId/auto-attribute', (req: Req, res: Res)
|
|||
)
|
||||
.run(enabled, ruleId, req.user.id, billId).changes;
|
||||
|
||||
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
|
||||
if (changes === 0) throw NotFoundError('Rule not found');
|
||||
res.json({ id: ruleId, auto_attribute_late: enabled === 1 });
|
||||
});
|
||||
|
||||
|
|
@ -1870,15 +1612,14 @@ router.delete('/:id/merchant-rules/:ruleId', (req: Req, res: Res) => {
|
|||
const billId = parseInt(req.params.id, 10);
|
||||
const ruleId = parseInt(req.params.ruleId, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1 || !Number.isInteger(ruleId) || ruleId < 1)
|
||||
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
|
||||
if (!requireBill(db, billId, req.user.id))
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
throw ValidationError('Invalid id');
|
||||
if (!requireBill(db, billId, req.user.id)) throw NotFoundError('Bill not found');
|
||||
|
||||
const changes = db
|
||||
.prepare('DELETE FROM bill_merchant_rules WHERE id = ? AND user_id = ? AND bill_id = ?')
|
||||
.run(ruleId, req.user.id, billId).changes;
|
||||
|
||||
if (changes === 0) return res.status(404).json(standardizeError('Rule not found', 'NOT_FOUND'));
|
||||
if (changes === 0) throw NotFoundError('Rule not found');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Req, Res, Next } from '../types/http';
|
|||
|
||||
const router = require('express').Router();
|
||||
const { getDb } = require('../db/database.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const { ApiError, ValidationError, NotFoundError } = require('../utils/apiError.cts');
|
||||
const {
|
||||
decorateDataSource,
|
||||
ensureManualDataSource,
|
||||
|
|
@ -26,10 +26,19 @@ function cleanFilter(value) {
|
|||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function safeError(err, fallback) {
|
||||
// SimpleFIN/service errors are deliberately surfaced to the user — but only
|
||||
// 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 status = typeof err?.status === 'number' ? err.status : 500;
|
||||
return { msg, status };
|
||||
return new ApiError(err?.code || code, 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 ──────────────────────────────────────
|
||||
|
|
@ -37,11 +46,10 @@ function safeError(err, fallback) {
|
|||
// Used by the bank tracking account picker.
|
||||
|
||||
router.get('/accounts/all', (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const accounts = db
|
||||
.prepare(
|
||||
`
|
||||
const db = getDb();
|
||||
const accounts = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
fa.id, fa.name, fa.org_name, fa.account_type,
|
||||
fa.balance, fa.available_balance, fa.currency,
|
||||
|
|
@ -51,19 +59,16 @@ router.get('/accounts/all', (req: Req, res: Res) => {
|
|||
WHERE fa.user_id = ?
|
||||
ORDER BY fa.org_name COLLATE NOCASE ASC, fa.name COLLATE NOCASE ASC
|
||||
`,
|
||||
)
|
||||
.all(req.user.id);
|
||||
)
|
||||
.all(req.user.id);
|
||||
|
||||
res.json(
|
||||
accounts.map((a) => ({
|
||||
...a,
|
||||
monitored: a.monitored === 1,
|
||||
balance_dollars: a.balance !== null ? a.balance / 100 : null,
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
|
||||
}
|
||||
res.json(
|
||||
accounts.map((a) => ({
|
||||
...a,
|
||||
monitored: a.monitored === 1,
|
||||
balance_dollars: a.balance !== null ? a.balance / 100 : null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── GET /api/data-sources ────────────────────────────────────────────────────
|
||||
|
|
@ -76,21 +81,9 @@ router.get('/', (req: Req, res: Res) => {
|
|||
const status = cleanFilter(req.query.status);
|
||||
|
||||
if (type && !VALID_TYPES.has(type))
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'type must be manual, file_import, or provider_sync',
|
||||
'VALIDATION_ERROR',
|
||||
'type',
|
||||
),
|
||||
);
|
||||
throw ValidationError('type must be manual, file_import, or provider_sync', 'type');
|
||||
if (status && !VALID_STATUSES.has(status))
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('status must be active, inactive, or error', 'VALIDATION_ERROR', 'status'),
|
||||
);
|
||||
throw ValidationError('status must be active, inactive, or error', 'status');
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
|
|
@ -159,17 +152,11 @@ router.get('/simplefin/status', (req: Req, res: Res) => {
|
|||
// ─── POST /api/data-sources/simplefin/connect ────────────────────────────────
|
||||
|
||||
router.post('/simplefin/connect', async (req: Req, res: Res) => {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res
|
||||
.status(503)
|
||||
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
requireBankSyncEnabled();
|
||||
|
||||
const setupToken = typeof req.body?.setupToken === 'string' ? req.body.setupToken.trim() : '';
|
||||
if (!setupToken) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('setupToken is required', 'VALIDATION_ERROR', 'setupToken'));
|
||||
throw ValidationError('setupToken is required', 'setupToken');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -177,8 +164,7 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
|
|||
const result = await connectSimplefin(db, req.user.id, setupToken);
|
||||
res.status(201).json(result);
|
||||
} catch (err) {
|
||||
const { msg, status } = safeError(err, 'Failed to connect SimpleFIN');
|
||||
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
|
||||
throw toSourceError(err, 'Failed to connect SimpleFIN');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -187,23 +173,19 @@ router.post('/simplefin/connect', async (req: Req, res: Res) => {
|
|||
router.get('/:sourceId/accounts', (req: Req, res: Res) => {
|
||||
const sourceId = parseInt(req.params.sourceId, 10);
|
||||
if (!Number.isInteger(sourceId) || sourceId < 1) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'sourceId'));
|
||||
throw ValidationError('Invalid data source id', 'sourceId');
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const db = getDb();
|
||||
|
||||
const source = db
|
||||
.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?')
|
||||
.get(sourceId, req.user.id);
|
||||
if (!source)
|
||||
return res.status(404).json(standardizeError('Data source not found', 'NOT_FOUND'));
|
||||
const source = db
|
||||
.prepare('SELECT id FROM data_sources WHERE id = ? AND user_id = ?')
|
||||
.get(sourceId, req.user.id);
|
||||
if (!source) throw NotFoundError('Data source not found');
|
||||
|
||||
const accounts = db
|
||||
.prepare(
|
||||
`
|
||||
const accounts = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
fa.id, fa.provider_account_id, fa.name, fa.org_name, fa.account_type,
|
||||
fa.balance, fa.available_balance, fa.currency, fa.monitored,
|
||||
|
|
@ -215,10 +197,10 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
|
|||
GROUP BY fa.id
|
||||
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,
|
||||
t.payee, t.description, t.memo, t.match_status, t.ignored,
|
||||
t.matched_bill_id, b.name AS matched_bill_name
|
||||
|
|
@ -229,16 +211,13 @@ router.get('/:sourceId/accounts', (req: Req, res: Res) => {
|
|||
LIMIT 50
|
||||
`);
|
||||
|
||||
const result = accounts.map((acc) => ({
|
||||
...acc,
|
||||
monitored: acc.monitored === 1,
|
||||
transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [],
|
||||
}));
|
||||
const result = accounts.map((acc) => ({
|
||||
...acc,
|
||||
monitored: acc.monitored === 1,
|
||||
transactions: acc.monitored === 1 ? txStmt.all(acc.id, req.user.id) : [],
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json(standardizeError(err.message || 'Failed to load accounts', 'DB_ERROR'));
|
||||
}
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ─── PUT /api/data-sources/:sourceId/accounts/:accountId ─────────────────────
|
||||
|
|
@ -252,52 +231,39 @@ router.put('/:sourceId/accounts/:accountId', (req: Req, res: Res) => {
|
|||
!Number.isInteger(accountId) ||
|
||||
accountId < 1
|
||||
) {
|
||||
return res.status(400).json(standardizeError('Invalid id', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('Invalid id');
|
||||
}
|
||||
if (typeof req.body?.monitored !== 'boolean') {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('monitored must be a boolean', 'VALIDATION_ERROR', 'monitored'));
|
||||
throw ValidationError('monitored must be a boolean', 'monitored');
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
const db = getDb();
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE financial_accounts
|
||||
SET monitored = ?, updated_at = datetime('now')
|
||||
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)
|
||||
return res.status(404).json(standardizeError('Account not found', 'NOT_FOUND'));
|
||||
if (result.changes === 0) throw NotFoundError('Account not found');
|
||||
|
||||
const account = db
|
||||
.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?')
|
||||
.get(accountId);
|
||||
res.json({ ...account, monitored: account.monitored === 1 });
|
||||
} catch (err) {
|
||||
res.status(500).json(standardizeError(err.message || 'Failed to update account', 'DB_ERROR'));
|
||||
}
|
||||
const account = db
|
||||
.prepare('SELECT id, name, monitored FROM financial_accounts WHERE id = ?')
|
||||
.get(accountId);
|
||||
res.json({ ...account, monitored: account.monitored === 1 });
|
||||
});
|
||||
|
||||
// ─── POST /api/data-sources/:id/sync ─────────────────────────────────────────
|
||||
|
||||
router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res
|
||||
.status(503)
|
||||
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
requireBankSyncEnabled();
|
||||
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
|
||||
throw ValidationError('Invalid data source id', 'id');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -305,8 +271,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
|
|||
const result = await syncDataSource(db, req.user.id, id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const { msg, status } = safeError(err, 'Sync failed');
|
||||
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
|
||||
throw toSourceError(err, 'Sync failed');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -314,11 +279,7 @@ router.post('/:id/sync', syncLimiter, async (req: Req, res: Res) => {
|
|||
// Syncs every SimpleFIN source for the current user. Returns aggregated stats.
|
||||
|
||||
router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res
|
||||
.status(503)
|
||||
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
requireBankSyncEnabled();
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
|
|
@ -329,7 +290,7 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
|
|||
.all(req.user.id);
|
||||
|
||||
if (sources.length === 0) {
|
||||
return res.status(404).json(standardizeError('No SimpleFIN connections found', 'NOT_FOUND'));
|
||||
throw NotFoundError('No SimpleFIN connections found');
|
||||
}
|
||||
|
||||
let accountsUpserted = 0;
|
||||
|
|
@ -364,25 +325,18 @@ router.post('/sync-all', syncLimiter, async (req: Req, res: Res) => {
|
|||
errors,
|
||||
});
|
||||
} catch (err) {
|
||||
const { msg, status } = safeError(err, 'Sync failed');
|
||||
res.status(status).json(standardizeError(msg, 'SIMPLEFIN_ERROR'));
|
||||
throw toSourceError(err, 'Sync failed');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /api/data-sources/:id/backfill ─────────────────────────────────────
|
||||
|
||||
router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res
|
||||
.status(503)
|
||||
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
requireBankSyncEnabled();
|
||||
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
|
||||
throw ValidationError('Invalid data source id', 'id');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -390,33 +344,25 @@ router.post('/:id/backfill', syncLimiter, async (req: Req, res: Res) => {
|
|||
const result = await backfillDataSource(db, req.user.id, id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const { msg, status } = safeError(err, 'Backfill failed');
|
||||
res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR'));
|
||||
throw toSourceError(err, 'Backfill failed');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── DELETE /api/data-sources/:id ────────────────────────────────────────────
|
||||
|
||||
router.delete('/:id', (req: Req, res: Res) => {
|
||||
if (!getBankSyncConfig().enabled) {
|
||||
return res
|
||||
.status(503)
|
||||
.json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED'));
|
||||
}
|
||||
requireBankSyncEnabled();
|
||||
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id < 1) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id'));
|
||||
throw ValidationError('Invalid data source id', 'id');
|
||||
}
|
||||
|
||||
try {
|
||||
disconnectDataSource(getDb(), req.user.id, id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
const { msg, status } = safeError(err, 'Failed to disconnect');
|
||||
res.status(status).json(standardizeError(msg, err?.code || 'DISCONNECT_ERROR'));
|
||||
throw toSourceError(err, 'Failed to disconnect', 'DISCONNECT_ERROR');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -342,11 +342,19 @@ function buildUserDbExportFile(userId) {
|
|||
|
||||
router.get('/user-db', (req: Req, res: Res) => {
|
||||
const file = buildUserDbExportFile(req.user.id);
|
||||
res.download(file, 'bill-tracker-user-export.sqlite', () => {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
} catch {}
|
||||
});
|
||||
// root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
|
||||
res.download(
|
||||
path.basename(file),
|
||||
'bill-tracker-user-export.sqlite',
|
||||
{
|
||||
root: path.dirname(file),
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
} catch {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Full portable JSON export of the user's data (same assembly as SQLite/Excel).
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ function makeErrorId() {
|
|||
}
|
||||
|
||||
function sendImportError(res, err, fallback, defaultCode) {
|
||||
if (err.status) {
|
||||
// Only 4xx service errors carry a user-facing message; a thrown error with
|
||||
// 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({
|
||||
error: fallback,
|
||||
message: err.message,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
// @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';
|
||||
const router = require('express').Router();
|
||||
const { log } = require('../utils/logger.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const { getDb } = require('../db/database.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const {
|
||||
applyBankPaymentAsSourceOfTruth,
|
||||
reactivatePaymentsOverriddenBy,
|
||||
|
|
@ -17,32 +21,23 @@ const { markMatched, markUnmatched } = require('../services/transactionMatchStat
|
|||
const { serializePayment } = require('../services/paymentValidation.cts');
|
||||
const { todayLocal } = require('../utils/dates.mts');
|
||||
|
||||
function sendMatchError(res, err, fallbackMessage = 'Match operation failed') {
|
||||
if (err.status) {
|
||||
return res
|
||||
.status(err.status)
|
||||
.json(standardizeError(err.message, err.code || 'MATCH_ERROR', err.field));
|
||||
// 4xx service errors keep their message/code on the wire; anything else is
|
||||
// rethrown raw so the terminal handler masks + logs it as a 5xx.
|
||||
function toMatchError(err) {
|
||||
if (err.status && err.status < 500) {
|
||||
return new ApiError(err.code || 'MATCH_ERROR', err.message, err.status, { field: err.field });
|
||||
}
|
||||
log.error('[matches] service error:', err.stack || err.message);
|
||||
return res.status(500).json(standardizeError(fallbackMessage, 'MATCH_ERROR'));
|
||||
return err;
|
||||
}
|
||||
|
||||
// GET /api/matches/suggestions
|
||||
router.get('/suggestions', (req: Req, res: Res) => {
|
||||
try {
|
||||
res.json(listMatchSuggestions(req.user.id, req.query));
|
||||
} catch (err) {
|
||||
return sendMatchError(res, err, 'Match suggestions failed');
|
||||
}
|
||||
res.json(listMatchSuggestions(req.user.id, req.query));
|
||||
});
|
||||
|
||||
// POST /api/matches/:id/reject
|
||||
router.post('/:id/reject', (req: Req, res: Res) => {
|
||||
try {
|
||||
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
|
||||
} catch (err) {
|
||||
return sendMatchError(res, err, 'Rejecting match suggestion failed');
|
||||
}
|
||||
res.json(rejectMatchSuggestion(req.user.id, req.params.id));
|
||||
});
|
||||
|
||||
// POST /api/matches/confirm — link a transaction to a bill and record a payment
|
||||
|
|
@ -50,46 +45,36 @@ router.post('/confirm', (req: Req, res: Res) => {
|
|||
const txId = parseInt(req.body?.transaction_id, 10);
|
||||
const billId = parseInt(req.body?.bill_id, 10);
|
||||
if (!Number.isInteger(txId) || !Number.isInteger(billId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('transaction_id and bill_id are required integers', 'VALIDATION_ERROR'),
|
||||
);
|
||||
throw ValidationError('transaction_id and bill_id are required integers');
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const tx = db
|
||||
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
|
||||
.get(txId, req.user.id);
|
||||
if (!tx)
|
||||
return res
|
||||
.status(404)
|
||||
.json(standardizeError('Transaction not found', 'NOT_FOUND', 'transaction_id'));
|
||||
if (!tx) throw NotFoundError('Transaction not found', 'transaction_id');
|
||||
if (tx.match_status === 'matched') {
|
||||
return res
|
||||
.status(409)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Transaction is already matched to a bill',
|
||||
'ALREADY_MATCHED',
|
||||
'transaction_id',
|
||||
),
|
||||
);
|
||||
throw ConflictError(
|
||||
'Transaction is already matched to a bill',
|
||||
'transaction_id',
|
||||
'ALREADY_MATCHED',
|
||||
);
|
||||
}
|
||||
|
||||
const bill = db
|
||||
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const existing = db
|
||||
.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL')
|
||||
.get(txId);
|
||||
if (existing)
|
||||
return res
|
||||
.status(409)
|
||||
.json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH'));
|
||||
throw ConflictError(
|
||||
'A payment is already linked to this transaction',
|
||||
undefined,
|
||||
'DUPLICATE_MATCH',
|
||||
);
|
||||
|
||||
const paidDate =
|
||||
tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal());
|
||||
|
|
@ -134,7 +119,7 @@ router.post('/confirm', (req: Req, res: Res) => {
|
|||
try {
|
||||
db.exec('ROLLBACK');
|
||||
} catch {}
|
||||
return sendMatchError(res, err, 'Failed to confirm match');
|
||||
throw toMatchError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -142,18 +127,16 @@ router.post('/confirm', (req: Req, res: Res) => {
|
|||
router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
|
||||
const txId = parseInt(req.params.transactionId, 10);
|
||||
if (!Number.isInteger(txId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('transactionId must be an integer', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('transactionId must be an integer');
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const tx = db
|
||||
.prepare('SELECT * FROM transactions WHERE id = ? AND user_id = ?')
|
||||
.get(txId, req.user.id);
|
||||
if (!tx) return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND'));
|
||||
if (!tx) throw NotFoundError('Transaction not found');
|
||||
if (tx.match_status !== 'matched') {
|
||||
return res.status(409).json(standardizeError('Transaction is not matched', 'NOT_MATCHED'));
|
||||
throw ConflictError('Transaction is not matched', undefined, 'NOT_MATCHED');
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -183,7 +166,7 @@ router.post('/:transactionId/unmatch', (req: Req, res: Res) => {
|
|||
try {
|
||||
db.exec('ROLLBACK');
|
||||
} catch {}
|
||||
return sendMatchError(res, err, 'Failed to unmatch transaction');
|
||||
throw toMatchError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const { sendTestEmail } = require('../services/notificationService.cts');
|
|||
const { sendTestPush } = require('../services/notificationService.cts')._push || {};
|
||||
const { decryptSecret } = require('../services/encryptionService.cts');
|
||||
const { encryptSecret } = require('../services/encryptionService.cts');
|
||||
const { ApiError, ValidationError } = require('../utils/apiError.cts');
|
||||
|
||||
// ── Admin: SMTP configuration ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -74,13 +75,16 @@ router.put('/admin', requireAuth, requireAdmin, (req: Req, res: Res) => {
|
|||
// POST /api/notifications/test — send a test email
|
||||
router.post('/test', requireAuth, requireAdmin, async (req: Req, res: Res) => {
|
||||
const { to } = req.body;
|
||||
if (!to) return res.status(400).json({ error: 'Recipient address required' });
|
||||
if (!to) throw ValidationError('Recipient address required', 'to');
|
||||
try {
|
||||
await sendTestEmail(to);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
// Deliberate pass-through: the SMTP error text is the admin's own config
|
||||
// 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 ───────────────────────────────────────────
|
||||
|
|
@ -164,7 +168,7 @@ router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) =
|
|||
.get(req.user.id);
|
||||
|
||||
if (!user?.push_channel || !user?.push_url) {
|
||||
return res.status(400).json({ error: 'Push notification channel not configured' });
|
||||
throw ValidationError('Push notification channel not configured', 'push_channel');
|
||||
}
|
||||
|
||||
// Decrypt for sending
|
||||
|
|
@ -185,10 +189,12 @@ router.post('/test-push', requireAuth, requireUser, async (req: Req, res: Res) =
|
|||
try {
|
||||
if (!sendTestPush) throw new Error('Push service not initialised');
|
||||
await sendTestPush(userForPush);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
// Deliberate pass-through: the push-provider error is the user's own
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
// @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';
|
||||
const express = require('express');
|
||||
const { log } = require('../utils/logger.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const router = require('express').Router();
|
||||
const { getDb } = require('../db/database.cts');
|
||||
const { computeBalanceDelta, applyBalanceDelta } = require('../services/billsService.cts');
|
||||
|
|
@ -27,38 +32,22 @@ function isTransactionLinkedPayment(payment) {
|
|||
return payment?.payment_source === TRANSACTION_MATCH_SOURCE || payment?.transaction_id != null;
|
||||
}
|
||||
|
||||
function rejectTransactionLinkedPayment(res) {
|
||||
return res
|
||||
.status(409)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Transaction-linked payments must be changed through transaction match controls',
|
||||
'TRANSACTION_PAYMENT_LOCKED',
|
||||
'transaction_id',
|
||||
),
|
||||
);
|
||||
function rejectTransactionLinkedPayment() {
|
||||
throw ConflictError(
|
||||
'Transaction-linked payments must be changed through transaction match controls',
|
||||
'transaction_id',
|
||||
'TRANSACTION_PAYMENT_LOCKED',
|
||||
);
|
||||
}
|
||||
|
||||
function parseYearMonth(body) {
|
||||
const year = parseInt(body.year, 10);
|
||||
const month = parseInt(body.month, 10);
|
||||
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
|
||||
return {
|
||||
error: standardizeError(
|
||||
'year must be a 4-digit integer between 2000 and 2100',
|
||||
'VALIDATION_ERROR',
|
||||
'year',
|
||||
),
|
||||
};
|
||||
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
||||
}
|
||||
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
||||
return {
|
||||
error: standardizeError(
|
||||
'month must be an integer between 1 and 12',
|
||||
'VALIDATION_ERROR',
|
||||
'month',
|
||||
),
|
||||
};
|
||||
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
||||
}
|
||||
return { year, month };
|
||||
}
|
||||
|
|
@ -73,17 +62,9 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
|
|||
`,
|
||||
)
|
||||
.get(billId, userId);
|
||||
if (!bill)
|
||||
return { error: standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'), status: 404 };
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
if (!bill.autopay_enabled || bill.autodraft_status !== 'assumed_paid') {
|
||||
return {
|
||||
error: standardizeError(
|
||||
'Bill is not eligible for autopay suggestions',
|
||||
'VALIDATION_ERROR',
|
||||
'bill_id',
|
||||
),
|
||||
status: 400,
|
||||
};
|
||||
throw ValidationError('Bill is not eligible for autopay suggestions', 'bill_id');
|
||||
}
|
||||
|
||||
const state = db
|
||||
|
|
@ -96,26 +77,12 @@ function getAutopaySuggestionContext(db, userId, billId, year, month) {
|
|||
)
|
||||
.get(bill.id, year, month);
|
||||
if (state?.is_skipped) {
|
||||
return {
|
||||
error: standardizeError(
|
||||
'Skipped bills cannot be suggested for payment',
|
||||
'VALIDATION_ERROR',
|
||||
'bill_id',
|
||||
),
|
||||
status: 400,
|
||||
};
|
||||
throw ValidationError('Skipped bills cannot be suggested for payment', 'bill_id');
|
||||
}
|
||||
|
||||
const dueDate = resolveDueDate(bill, year, month);
|
||||
if (!dueDate) {
|
||||
return {
|
||||
error: standardizeError(
|
||||
'Bill does not occur in the selected month',
|
||||
'VALIDATION_ERROR',
|
||||
'month',
|
||||
),
|
||||
status: 400,
|
||||
};
|
||||
throw ValidationError('Bill does not occur in the selected month', 'month');
|
||||
}
|
||||
const amount = fromCents(state?.actual_amount ?? bill.expected_amount);
|
||||
return { bill, dueDate, amount };
|
||||
|
|
@ -128,15 +95,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
|
||||
// Validate year/month when provided
|
||||
if ((year || month) && !(year && month)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Both year and month are required when filtering by date',
|
||||
'VALIDATION_ERROR',
|
||||
'year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('Both year and month are required when filtering by date', 'year');
|
||||
}
|
||||
|
||||
let y, m;
|
||||
|
|
@ -144,26 +103,10 @@ router.get('/', (req: Req, res: Res) => {
|
|||
y = parseInt(year, 10);
|
||||
m = parseInt(month, 10);
|
||||
if (!Number.isInteger(y) || y < 2000 || y > 2100) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'year must be a 4-digit integer between 2000 and 2100',
|
||||
'VALIDATION_ERROR',
|
||||
'year',
|
||||
),
|
||||
);
|
||||
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
||||
}
|
||||
if (!Number.isInteger(m) || m < 1 || m > 12) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'month must be an integer between 1 and 12',
|
||||
'VALIDATION_ERROR',
|
||||
'month',
|
||||
),
|
||||
);
|
||||
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,8 +171,7 @@ 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`,
|
||||
)
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!payment)
|
||||
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||
if (!payment) throw NotFoundError('Payment not found', 'id');
|
||||
res.json(serializePayment(payment));
|
||||
});
|
||||
|
||||
|
|
@ -246,51 +188,42 @@ router.post('/:id/undo-auto', (req: Req, res: Res) => {
|
|||
)
|
||||
.get(req.params.id, req.user.id);
|
||||
|
||||
if (!payment)
|
||||
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||
if (!payment) throw NotFoundError('Payment not found', 'id');
|
||||
if (payment.payment_source !== 'provider_sync') {
|
||||
return res
|
||||
.status(409)
|
||||
.json(standardizeError('Only provider_sync payments can be undone here', 'NOT_AUTO_MATCH'));
|
||||
throw ConflictError(
|
||||
'Only provider_sync payments can be undone here',
|
||||
undefined,
|
||||
'NOT_AUTO_MATCH',
|
||||
);
|
||||
}
|
||||
if (!payment.transaction_id) {
|
||||
return res
|
||||
.status(409)
|
||||
.json(standardizeError('Payment has no linked transaction', 'NO_TRANSACTION'));
|
||||
throw ConflictError('Payment has no linked transaction', undefined, 'NO_TRANSACTION');
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
// Restore balance (same logic as DELETE /:id)
|
||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||
const bill = db
|
||||
.prepare('SELECT current_balance FROM bills WHERE id = ?')
|
||||
.get(payment.bill_id);
|
||||
if (bill?.current_balance != null) {
|
||||
const restored = Math.max(
|
||||
0,
|
||||
Number(bill.current_balance) - Number(payment.balance_delta),
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
db.transaction(() => {
|
||||
// Restore balance (same logic as DELETE /:id)
|
||||
if (!payment.accounting_excluded && payment.balance_delta != null) {
|
||||
const bill = db
|
||||
.prepare('SELECT current_balance FROM bills WHERE id = ?')
|
||||
.get(payment.bill_id);
|
||||
if (bill?.current_balance != null) {
|
||||
const restored = Math.max(0, Number(bill.current_balance) - Number(payment.balance_delta));
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE bills
|
||||
SET current_balance = ?,
|
||||
interest_accrued_month = CASE WHEN ? THEN NULL ELSE interest_accrued_month END,
|
||||
updated_at = datetime('now')
|
||||
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);
|
||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
|
||||
markUnmatched(db, req.user.id, payment.transaction_id);
|
||||
})();
|
||||
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'));
|
||||
}
|
||||
}
|
||||
reactivatePaymentsOverriddenBy(db, payment.id);
|
||||
db.prepare("UPDATE payments SET deleted_at = datetime('now') WHERE id = ?").run(payment.id);
|
||||
markUnmatched(db, req.user.id, payment.transaction_id);
|
||||
})();
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /api/payments — create single payment
|
||||
|
|
@ -313,18 +246,13 @@ router.post('/', (req: Req, res: Res) => {
|
|||
paid_date,
|
||||
payment_source: payment_source ?? 'manual',
|
||||
});
|
||||
if (validation.error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
|
||||
}
|
||||
if (validation.error) throw ValidationError(validation.error, validation.field);
|
||||
const payment = validation.normalized;
|
||||
|
||||
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)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
// 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
|
||||
|
|
@ -392,17 +320,12 @@ router.post('/quick', (req: Req, res: Res) => {
|
|||
{ bill_id },
|
||||
{ requireAmount: false, requirePaidDate: false },
|
||||
);
|
||||
if (billValidation.error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
|
||||
}
|
||||
if (billValidation.error) throw ValidationError(billValidation.error, billValidation.field);
|
||||
|
||||
const bill = db
|
||||
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billValidation.normalized.bill_id, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const paymentValidation = validatePaymentInput(
|
||||
{
|
||||
|
|
@ -412,11 +335,8 @@ router.post('/quick', (req: Req, res: Res) => {
|
|||
},
|
||||
{ requireBillId: false },
|
||||
);
|
||||
if (paymentValidation.error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
|
||||
}
|
||||
if (paymentValidation.error)
|
||||
throw ValidationError(paymentValidation.error, paymentValidation.field);
|
||||
const payAmount = paymentValidation.normalized.amount;
|
||||
const payDate = paymentValidation.normalized.paid_date;
|
||||
const paySource = paymentValidation.normalized.payment_source;
|
||||
|
|
@ -468,32 +388,23 @@ router.post('/quick', (req: Req, res: Res) => {
|
|||
router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const ym = parseYearMonth(req.body);
|
||||
if (ym.error) return res.status(400).json(ym.error);
|
||||
|
||||
const billId = parseInt(req.params.billId, 10);
|
||||
if (!Number.isInteger(billId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
|
||||
throw ValidationError('bill_id must be an integer', 'bill_id');
|
||||
}
|
||||
|
||||
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;
|
||||
if (dueDate > todayLocal()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date'));
|
||||
throw ValidationError('Autopay suggestion is not due yet', 'paid_date');
|
||||
}
|
||||
const paymentValidation = validatePaymentInput(
|
||||
{ amount, paid_date: dueDate },
|
||||
{ requireBillId: false },
|
||||
);
|
||||
if (paymentValidation.error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
|
||||
}
|
||||
if (paymentValidation.error)
|
||||
throw ValidationError(paymentValidation.error, paymentValidation.field);
|
||||
const suggestedPayment = paymentValidation.normalized;
|
||||
|
||||
const suggestionRange = getCycleRange(ym.year, ym.month, bill);
|
||||
|
|
@ -560,20 +471,17 @@ router.post('/autopay-suggestions/:billId/confirm', (req: Req, res: Res) => {
|
|||
router.post('/autopay-suggestions/:billId/dismiss', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const ym = parseYearMonth(req.body);
|
||||
if (ym.error) return res.status(400).json(ym.error);
|
||||
|
||||
const billId = parseInt(req.params.billId, 10);
|
||||
if (!Number.isInteger(billId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
|
||||
throw ValidationError('bill_id must be an integer', 'bill_id');
|
||||
}
|
||||
if (
|
||||
!db
|
||||
.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id)
|
||||
) {
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
throw NotFoundError('Bill not found', 'bill_id');
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
|
|
@ -602,23 +510,11 @@ router.post('/bulk', (req: Req, res: Res) => {
|
|||
|
||||
// Validate request body has payments array
|
||||
if (!payments || !Array.isArray(payments))
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'Request body must contain a `payments` array',
|
||||
'VALIDATION_ERROR',
|
||||
'payments',
|
||||
),
|
||||
);
|
||||
throw ValidationError('Request body must contain a `payments` array', 'payments');
|
||||
|
||||
// Validate max items per request (50)
|
||||
if (payments.length > 50)
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('Maximum 50 items allowed per request', 'VALIDATION_ERROR', 'payments'),
|
||||
);
|
||||
throw ValidationError('Maximum 50 items allowed per request', 'payments');
|
||||
|
||||
// Validate each payment item
|
||||
for (let i = 0; i < payments.length; i++) {
|
||||
|
|
@ -628,15 +524,7 @@ router.post('/bulk', (req: Req, res: Res) => {
|
|||
{ fieldPrefix: `payments[${i}].` },
|
||||
);
|
||||
if (validation.error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
`Payment at index ${i}: ${validation.error}`,
|
||||
'VALIDATION_ERROR',
|
||||
validation.field,
|
||||
),
|
||||
);
|
||||
throw ValidationError(`Payment at index ${i}: ${validation.error}`, validation.field);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -716,20 +604,15 @@ 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`,
|
||||
)
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||
if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);
|
||||
if (!existing) throw NotFoundError('Payment not found', 'id');
|
||||
if (isTransactionLinkedPayment(existing)) rejectTransactionLinkedPayment();
|
||||
|
||||
const { amount, paid_date, method, notes, payment_source } = req.body;
|
||||
const validation = validatePaymentInput(
|
||||
{ amount, paid_date, payment_source },
|
||||
{ requireBillId: false, requireAmount: false, requirePaidDate: false },
|
||||
);
|
||||
if (validation.error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
|
||||
}
|
||||
if (validation.error) throw ValidationError(validation.error, validation.field);
|
||||
|
||||
const nextAmount = validation.normalized.amount ?? existing.amount;
|
||||
const nextPaidDate = validation.normalized.paid_date ?? existing.paid_date;
|
||||
|
|
@ -824,9 +707,8 @@ 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`,
|
||||
)
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!payment)
|
||||
return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
|
||||
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
||||
if (!payment) throw NotFoundError('Payment not found', 'id');
|
||||
if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
|
||||
|
||||
// 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
|
||||
|
|
@ -868,9 +750,8 @@ 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',
|
||||
)
|
||||
.get(req.params.id, req.user.id);
|
||||
if (!payment)
|
||||
return res.status(404).json(standardizeError('Deleted payment not found', 'NOT_FOUND', 'id'));
|
||||
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
|
||||
if (!payment) throw NotFoundError('Deleted payment not found', 'id');
|
||||
if (isTransactionLinkedPayment(payment)) rejectTransactionLinkedPayment();
|
||||
|
||||
// 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
|
||||
|
|
@ -923,90 +804,72 @@ router.patch('/:id/attribute-to-month', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const paymentId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(paymentId) || paymentId < 1) {
|
||||
return res.status(400).json(standardizeError('Invalid payment id', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('Invalid payment id');
|
||||
}
|
||||
|
||||
const { paid_date } = req.body;
|
||||
if (!paid_date || !/^\d{4}-\d{2}-\d{2}$/.test(paid_date)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date'));
|
||||
throw ValidationError('paid_date must be YYYY-MM-DD', 'paid_date');
|
||||
}
|
||||
// Validate it is a real calendar date
|
||||
const newDate = new Date(paid_date + 'T00:00:00Z');
|
||||
if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date'),
|
||||
);
|
||||
throw ValidationError('paid_date is not a valid calendar date', 'paid_date');
|
||||
}
|
||||
|
||||
try {
|
||||
const payment = db
|
||||
.prepare(
|
||||
`
|
||||
const payment = 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);
|
||||
)
|
||||
.get(paymentId, req.user.id);
|
||||
|
||||
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND'));
|
||||
if (!payment) throw NotFoundError('Payment not found');
|
||||
|
||||
// Only allow date-only reclassification for provider_sync payments
|
||||
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
|
||||
return res
|
||||
.status(409)
|
||||
.json(
|
||||
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),
|
||||
),
|
||||
// Only allow date-only reclassification for provider_sync payments
|
||||
if (payment.payment_source !== 'provider_sync' && payment.payment_source !== 'auto_match') {
|
||||
throw ConflictError(
|
||||
'Only bank-synced payments can be reclassified to a different month',
|
||||
undefined,
|
||||
'RECLASSIFY_ONLY_SYNC',
|
||||
);
|
||||
} 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;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Req, Res, Next } from '../types/http';
|
|||
('use strict');
|
||||
|
||||
const express = require('express');
|
||||
const { log } = require('../utils/logger.cts');
|
||||
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { passwordLimiter } = require('../middleware/rateLimiter.cts');
|
||||
|
|
@ -18,7 +18,14 @@ const {
|
|||
} = require('../services/authService.cts');
|
||||
const { getImportHistory } = require('../services/spreadsheetImportService.cts');
|
||||
const { logAudit } = require('../services/auditService.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
AuthError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const { encryptSecret, decryptSecret } = require('../services/encryptionService.cts');
|
||||
|
||||
// All profile routes require authentication — enforced in server.js.
|
||||
|
|
@ -30,9 +37,7 @@ function dataImportEnabled() {
|
|||
|
||||
function requireDataImportEnabled(req, res, next) {
|
||||
if (!dataImportEnabled()) {
|
||||
return res
|
||||
.status(403)
|
||||
.json(standardizeError('Data import is disabled by DATA_IMPORT_ENABLED=false', 'FORBIDDEN'));
|
||||
throw ForbiddenError('Data import is disabled by DATA_IMPORT_ENABLED=false');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
|
@ -73,7 +78,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
)
|
||||
.get(req.user.id);
|
||||
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
res.json({
|
||||
...profileResponse(user),
|
||||
|
|
@ -108,20 +113,20 @@ router.patch('/', (req: Req, res: Res) => {
|
|||
|
||||
if (username !== undefined) {
|
||||
if (typeof username !== 'string') {
|
||||
return res.status(400).json({ error: 'username must be a string' });
|
||||
throw ValidationError('username must be a string', 'username');
|
||||
}
|
||||
const trimmedUsername = username.trim();
|
||||
if (trimmedUsername.length < 3) {
|
||||
return res.status(400).json({ error: 'username must be at least 3 characters' });
|
||||
throw ValidationError('username must be at least 3 characters', 'username');
|
||||
}
|
||||
if (trimmedUsername.length > 50) {
|
||||
return res.status(400).json({ error: 'username must be 50 characters or fewer' });
|
||||
throw ValidationError('username must be 50 characters or fewer', 'username');
|
||||
}
|
||||
const taken = db
|
||||
.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?')
|
||||
.get(trimmedUsername, req.user.id);
|
||||
if (taken) {
|
||||
return res.status(409).json({ error: 'Username already taken' });
|
||||
throw ConflictError('Username already taken', 'username');
|
||||
}
|
||||
db.prepare("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
trimmedUsername,
|
||||
|
|
@ -137,11 +142,11 @@ router.patch('/', (req: Req, res: Res) => {
|
|||
|
||||
if (displayNameInput !== undefined) {
|
||||
if (typeof displayNameInput !== 'string') {
|
||||
return res.status(400).json({ error: 'display_name must be a string' });
|
||||
throw ValidationError('display_name must be a string', 'display_name');
|
||||
}
|
||||
const trimmed = displayNameInput.trim();
|
||||
if (trimmed.length > 100) {
|
||||
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' });
|
||||
throw ValidationError('display_name must be 100 characters or fewer', 'display_name');
|
||||
}
|
||||
|
||||
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(
|
||||
|
|
@ -188,7 +193,7 @@ router.get('/settings', (req: Req, res: Res) => {
|
|||
)
|
||||
.get(req.user.id);
|
||||
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
// Decrypt push secrets — return masked indicator for token (never the raw value)
|
||||
const pushUrlDecrypted = user.push_url
|
||||
|
|
@ -246,10 +251,10 @@ router.patch('/settings', (req: Req, res: Res) => {
|
|||
|
||||
if (nextEmail !== undefined && nextEmail !== null) {
|
||||
if (typeof nextEmail !== 'string') {
|
||||
return res.status(400).json({ error: 'notification_email must be a string' });
|
||||
throw ValidationError('notification_email must be a string', 'notification_email');
|
||||
}
|
||||
if (nextEmail.trim().length > 255) {
|
||||
return res.status(400).json({ error: 'notification_email is too long' });
|
||||
throw ValidationError('notification_email is too long', 'notification_email');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -350,67 +355,64 @@ router.post('/change-password', passwordLimiter, async (req: Req, res: Res) => {
|
|||
const { current_password, new_password, confirm_new_password } = req.body;
|
||||
|
||||
if (!current_password) {
|
||||
return res.status(400).json({ error: 'current_password is required' });
|
||||
throw ValidationError('current_password is required', 'current_password');
|
||||
}
|
||||
if (!new_password) {
|
||||
return res.status(400).json({ error: 'new_password is required' });
|
||||
throw ValidationError('new_password is required', 'new_password');
|
||||
}
|
||||
if (!confirm_new_password) {
|
||||
return res.status(400).json({ error: 'confirm_new_password is required' });
|
||||
throw ValidationError('confirm_new_password is required', 'confirm_new_password');
|
||||
}
|
||||
if (new_password !== confirm_new_password) {
|
||||
return res.status(400).json({ error: 'new passwords do not match' });
|
||||
throw ValidationError('new passwords do not match', 'confirm_new_password');
|
||||
}
|
||||
if (new_password.length < 8) {
|
||||
return res.status(400).json({ error: 'new password must be at least 8 characters' });
|
||||
throw ValidationError('new password must be at least 8 characters', 'new_password');
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (!user) throw NotFoundError('User not found');
|
||||
|
||||
try {
|
||||
const valid = await bcrypt.compare(current_password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'current password is incorrect' });
|
||||
}
|
||||
const valid = await bcrypt.compare(current_password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw new ApiError('AUTH_ERROR', 'current password is incorrect', 401, {
|
||||
field: 'current_password',
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await hashPassword(new_password);
|
||||
const hash = await hashPassword(new_password);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
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);
|
||||
).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);
|
||||
// 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));
|
||||
}
|
||||
// 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('[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 ──────────────────────────────────────────────────
|
||||
|
|
@ -441,13 +443,8 @@ router.get('/exports', (req: Req, res: Res) => {
|
|||
// Returns the signed-in user's import history.
|
||||
// Delegates to the same service as GET /api/import/history.
|
||||
router.get('/import-history', requireDataImportEnabled, (req: Req, res: Res) => {
|
||||
try {
|
||||
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' });
|
||||
}
|
||||
const history = getImportHistory(req.user.id);
|
||||
res.json({ history });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// @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';
|
||||
const express = require('express');
|
||||
const { log } = require('../utils/logger.cts');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const { ValidationError, NotFoundError } = require('../utils/apiError.cts');
|
||||
const { calculateSnowball, calculateAvalanche } = require('../services/snowballService.cts');
|
||||
const { calculateMinimumOnly, debtAprSnapshot } = require('../services/aprService.cts');
|
||||
const { serializeBill } = require('../services/billsService.cts');
|
||||
|
|
@ -122,15 +121,7 @@ router.patch('/settings', (req: Req, res: Res) => {
|
|||
if (extra_payment !== undefined && extra_payment !== null && extra_payment !== '') {
|
||||
val = parseFloat(extra_payment);
|
||||
if (!Number.isFinite(val) || val < 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'extra_payment must be a non-negative number',
|
||||
'VALIDATION_ERROR',
|
||||
'extra_payment',
|
||||
),
|
||||
);
|
||||
throw ValidationError('extra_payment must be a non-negative number', 'extra_payment');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -261,9 +252,7 @@ function buildComparison(snowball, minimum_only) {
|
|||
router.patch('/order', (req: Req, res: Res) => {
|
||||
const items = req.body;
|
||||
if (!Array.isArray(items)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('Request body must be an array', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('Request body must be an array');
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return res.json({ success: true, updated: 0 });
|
||||
|
|
@ -276,24 +265,12 @@ router.patch('/order', (req: Req, res: Res) => {
|
|||
const id = parseInt(row?.id, 10);
|
||||
const order = parseInt(row?.snowball_order, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`,
|
||||
'VALIDATION_ERROR',
|
||||
),
|
||||
);
|
||||
throw ValidationError(`Item at index ${i} has an invalid id: ${JSON.stringify(row?.id)}`);
|
||||
}
|
||||
if (!Number.isInteger(order) || order < 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
|
||||
'VALIDATION_ERROR',
|
||||
),
|
||||
);
|
||||
throw ValidationError(
|
||||
`Item at index ${i} has an invalid snowball_order: ${JSON.stringify(row?.snowball_order)}`,
|
||||
);
|
||||
}
|
||||
parsed.push({ id, order });
|
||||
}
|
||||
|
|
@ -373,204 +350,170 @@ function enrichPlanWithProgress(db, plan) {
|
|||
|
||||
// POST /api/snowball/plans — start a new snowball plan
|
||||
router.post('/plans', (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
const { name, method, notes } = req.body;
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
const { name, method, notes } = req.body;
|
||||
|
||||
const planName =
|
||||
typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan';
|
||||
const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball';
|
||||
const planName =
|
||||
typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : 'Snowball Plan';
|
||||
const planMethod = ['snowball', 'avalanche', 'custom'].includes(method) ? method : 'snowball';
|
||||
|
||||
const ramseyMode = isRamseyMode(userId);
|
||||
const debts = getDebtBills(userId, ramseyMode);
|
||||
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
|
||||
if (activeDebts.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
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,
|
||||
const ramseyMode = isRamseyMode(userId);
|
||||
const debts = getDebtBills(userId, ramseyMode);
|
||||
const activeDebts = debts.filter((b) => (b.current_balance ?? 0) > 0);
|
||||
if (activeDebts.length === 0) {
|
||||
throw ValidationError(
|
||||
'No debts with a balance found. Add a balance to at least one bill.',
|
||||
undefined,
|
||||
'NO_DEBTS',
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
// 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 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,
|
||||
});
|
||||
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();
|
||||
|
||||
// Abandon any existing active/paused plan first
|
||||
db.prepare(
|
||||
`
|
||||
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,
|
||||
);
|
||||
|
||||
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')
|
||||
WHERE user_id = ? AND status IN ('active', 'paused')
|
||||
`,
|
||||
).run(userId);
|
||||
).run(userId);
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
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'))
|
||||
`,
|
||||
)
|
||||
.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);
|
||||
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'));
|
||||
}
|
||||
const plan = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json(enrichPlanWithProgress(db, plan));
|
||||
});
|
||||
|
||||
// GET /api/snowball/plans — list all plans for user
|
||||
router.get('/plans', (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const plans = db
|
||||
.prepare(
|
||||
`
|
||||
const db = getDb();
|
||||
const plans = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM snowball_plans WHERE user_id = ? ORDER BY created_at DESC
|
||||
`,
|
||||
)
|
||||
.all(req.user.id);
|
||||
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'));
|
||||
}
|
||||
)
|
||||
.all(req.user.id);
|
||||
res.json({ plans: plans.map((p) => enrichPlanWithProgress(db, p)) });
|
||||
});
|
||||
|
||||
// GET /api/snowball/plans/active — return the active or paused plan (or null)
|
||||
router.get('/plans/active', (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const plan = db
|
||||
.prepare(
|
||||
`
|
||||
const db = getDb();
|
||||
const plan = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM snowball_plans
|
||||
WHERE user_id = ? AND status IN ('active', 'paused')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(req.user.id);
|
||||
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'));
|
||||
}
|
||||
)
|
||||
.get(req.user.id);
|
||||
res.json(plan ? enrichPlanWithProgress(db, plan) : null);
|
||||
});
|
||||
|
||||
// PATCH /api/snowball/plans/:id — update name or notes
|
||||
router.patch('/plans/:id', (req: Req, res: Res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
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'));
|
||||
const db = getDb();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) throw ValidationError('Invalid plan id', 'id');
|
||||
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');
|
||||
|
||||
const { name, notes } = req.body;
|
||||
const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name;
|
||||
const newNotes = notes !== undefined ? notes || null : plan.notes;
|
||||
const { name, notes } = req.body;
|
||||
const newName = typeof name === 'string' && name.trim() ? name.trim().slice(0, 100) : plan.name;
|
||||
const newNotes = notes !== undefined ? notes || null : plan.notes;
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
db.prepare(
|
||||
`
|
||||
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);
|
||||
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'));
|
||||
}
|
||||
const updated = db.prepare('SELECT * FROM snowball_plans WHERE id = ?').get(id);
|
||||
res.json(enrichPlanWithProgress(db, updated));
|
||||
});
|
||||
|
||||
// Shared status-transition handler for pause / resume / complete / abandon.
|
||||
// `setSql` is a fixed constant per route (never user input).
|
||||
function transitionPlan(req, res, { allowedFrom, setSql, action, past }) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
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'));
|
||||
function transitionPlan(req, res, { allowedFrom, setSql, past }) {
|
||||
const db = getDb();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
throw ValidationError('Invalid plan id', 'id');
|
||||
}
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import type { Req, Res, Next } from '../types/http';
|
|||
('use strict');
|
||||
|
||||
const express = require('express');
|
||||
const { log } = require('../utils/logger.cts');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../db/database.cts');
|
||||
const { ValidationError } = require('../utils/apiError.cts');
|
||||
const {
|
||||
getSpendingSummary,
|
||||
getSpendingTransactions,
|
||||
|
|
@ -18,31 +18,27 @@ const {
|
|||
getIncomeTransactions,
|
||||
} = 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) {
|
||||
const now = new Date();
|
||||
const year = parseInt(source.year || now.getFullYear(), 10);
|
||||
const month = parseInt(source.month || now.getMonth() + 1, 10);
|
||||
if (isNaN(year) || year < 2000 || year > 2100) return { error: 'Invalid year' };
|
||||
if (isNaN(month) || month < 1 || month > 12) return { error: 'Invalid month' };
|
||||
if (isNaN(year) || year < 2000 || year > 2100) throw ValidationError('Invalid year', 'year');
|
||||
if (isNaN(month) || month < 1 || month > 12) throw ValidationError('Invalid month', 'month');
|
||||
return { year, month };
|
||||
}
|
||||
|
||||
// GET /api/spending/summary?year=&month=
|
||||
router.get('/summary', (req: Req, res: Res) => {
|
||||
const ym = parseYM(req.query);
|
||||
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' });
|
||||
}
|
||||
res.json(getSpendingSummary(getDb(), req.user.id, ym.year, ym.month));
|
||||
});
|
||||
|
||||
// GET /api/spending/transactions?year=&month=&category_id=&page=&limit=
|
||||
router.get('/transactions', (req: Req, res: Res) => {
|
||||
const ym = parseYM(req.query);
|
||||
if (ym.error) return res.status(400).json({ error: ym.error });
|
||||
|
||||
const { category_id, page, limit } = req.query;
|
||||
const categoryId =
|
||||
|
|
@ -52,56 +48,38 @@ router.get('/transactions', (req: Req, res: Res) => {
|
|||
? parseInt(category_id, 10)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
res.json(
|
||||
getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, {
|
||||
categoryId,
|
||||
uncategorizedOnly: category_id === 'null',
|
||||
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' });
|
||||
}
|
||||
res.json(
|
||||
getSpendingTransactions(getDb(), req.user.id, ym.year, ym.month, {
|
||||
categoryId,
|
||||
uncategorizedOnly: category_id === 'null',
|
||||
page: parseInt(page || '1', 10),
|
||||
limit: Math.min(parseInt(limit || '50', 10), 200),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// PATCH /api/spending/transactions/:id/category
|
||||
router.patch('/transactions/:id/category', (req: Req, res: Res) => {
|
||||
const txId = parseInt(req.params.id, 10);
|
||||
if (isNaN(txId)) return res.status(400).json({ error: 'Invalid transaction ID' });
|
||||
if (isNaN(txId)) throw ValidationError('Invalid transaction ID', 'id');
|
||||
|
||||
const { category_id, save_rule } = req.body || {};
|
||||
const categoryId =
|
||||
category_id === null || category_id === undefined ? null : parseInt(category_id, 10);
|
||||
|
||||
try {
|
||||
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' });
|
||||
}
|
||||
categorizeTransaction(getDb(), req.user.id, txId, categoryId, !!save_rule);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/spending/budgets?year=&month=
|
||||
router.get('/budgets', (req: Req, res: Res) => {
|
||||
const ym = parseYM(req.query);
|
||||
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' });
|
||||
}
|
||||
res.json({ budgets: getSpendingBudgets(getDb(), req.user.id, ym.year, ym.month) });
|
||||
});
|
||||
|
||||
// POST /api/spending/budgets/copy — copy all budgets from previous month into target month
|
||||
router.post('/budgets/copy', (req: Req, res: Res) => {
|
||||
const ym = parseYM(req.body || {});
|
||||
if (ym.error) return res.status(400).json({ error: ym.error });
|
||||
|
||||
// Previous month
|
||||
let prevYear = ym.year,
|
||||
|
|
@ -111,25 +89,24 @@ router.post('/budgets/copy', (req: Req, res: Res) => {
|
|||
prevYear--;
|
||||
}
|
||||
|
||||
try {
|
||||
const db = getDb();
|
||||
const prev = db
|
||||
.prepare(
|
||||
`
|
||||
const db = getDb();
|
||||
const prev = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT category_id, amount FROM spending_budgets
|
||||
WHERE user_id = ? AND year = ? AND month = ?
|
||||
`,
|
||||
)
|
||||
.all(req.user.id, prevYear, prevMonth);
|
||||
)
|
||||
.all(req.user.id, prevYear, prevMonth);
|
||||
|
||||
if (prev.length === 0) {
|
||||
return res.json({
|
||||
copied: 0,
|
||||
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
|
||||
});
|
||||
}
|
||||
if (prev.length === 0) {
|
||||
return res.json({
|
||||
copied: 0,
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(user_id, category_id, year, month) DO UPDATE SET
|
||||
|
|
@ -137,95 +114,66 @@ router.post('/budgets/copy', (req: Req, res: Res) => {
|
|||
updated_at = datetime('now')
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
for (const row of prev)
|
||||
upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
|
||||
})();
|
||||
db.transaction(() => {
|
||||
for (const row of prev) upsert.run(req.user.id, row.category_id, ym.year, ym.month, row.amount);
|
||||
})();
|
||||
|
||||
res.json({
|
||||
copied: prev.length,
|
||||
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' });
|
||||
}
|
||||
res.json({
|
||||
copied: prev.length,
|
||||
budgets: getSpendingBudgets(db, req.user.id, ym.year, ym.month),
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/spending/budgets — { category_id, year, month, amount }
|
||||
router.put('/budgets', (req: Req, res: Res) => {
|
||||
const { category_id, year, month, amount } = req.body || {};
|
||||
if (!category_id) return res.status(400).json({ error: 'category_id required' });
|
||||
if (!category_id) throw ValidationError('category_id required', 'category_id');
|
||||
const ym = parseYM({ year, month });
|
||||
if (ym.error) return res.status(400).json({ error: ym.error });
|
||||
|
||||
try {
|
||||
setSpendingBudget(
|
||||
getDb(),
|
||||
req.user.id,
|
||||
parseInt(category_id, 10),
|
||||
ym.year,
|
||||
ym.month,
|
||||
amount ?? null,
|
||||
);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error('[spending/budgets PUT]', err.message);
|
||||
res.status(500).json({ error: 'Failed to save budget' });
|
||||
}
|
||||
setSpendingBudget(
|
||||
getDb(),
|
||||
req.user.id,
|
||||
parseInt(category_id, 10),
|
||||
ym.year,
|
||||
ym.month,
|
||||
amount ?? null,
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/spending/category-rules
|
||||
router.get('/category-rules', (req: Req, res: Res) => {
|
||||
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' });
|
||||
}
|
||||
res.json({ rules: getSpendingCategoryRules(getDb(), req.user.id) });
|
||||
});
|
||||
|
||||
// POST /api/spending/category-rules — { category_id, merchant }
|
||||
router.post('/category-rules', (req: Req, res: Res) => {
|
||||
const { category_id, merchant } = req.body || {};
|
||||
if (!category_id || !merchant)
|
||||
return res.status(400).json({ error: 'category_id and merchant required' });
|
||||
try {
|
||||
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error('[spending/category-rules POST]', err.message);
|
||||
res.status(err.status || 500).json({ error: err.message || 'Failed to save rule' });
|
||||
}
|
||||
throw ValidationError(
|
||||
'category_id and merchant required',
|
||||
!category_id ? 'category_id' : 'merchant',
|
||||
);
|
||||
addSpendingCategoryRule(getDb(), req.user.id, parseInt(category_id, 10), merchant);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/spending/category-rules/:id
|
||||
router.delete('/category-rules/:id', (req: Req, res: Res) => {
|
||||
try {
|
||||
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' });
|
||||
}
|
||||
deleteSpendingCategoryRule(getDb(), req.user.id, parseInt(req.params.id, 10));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/spending/income?year=&month=&page=
|
||||
router.get('/income', (req: Req, res: Res) => {
|
||||
const ym = parseYM(req.query);
|
||||
if (ym.error) return res.status(400).json({ error: ym.error });
|
||||
try {
|
||||
res.json(
|
||||
getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
|
||||
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' });
|
||||
}
|
||||
res.json(
|
||||
getIncomeTransactions(getDb(), req.user.id, ym.year, ym.month, {
|
||||
page: parseInt(req.query.page || '1', 10),
|
||||
limit: Math.min(parseInt(req.query.limit || '50', 10), 200),
|
||||
includeIgnored: req.query.include_ignored === 'true',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import type { Req, Res, Next } from '../types/http';
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb, ensureUserDefaultCategories } = require('../db/database.cts');
|
||||
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||
const {
|
||||
ApiError,
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
ConflictError,
|
||||
} = require('../utils/apiError.cts');
|
||||
const { fromCents } = require('../utils/money.mts');
|
||||
const {
|
||||
createSubscriptionFromRecommendation,
|
||||
|
|
@ -36,45 +41,26 @@ router.get('/recommendations', (req: Req, res: Res) => {
|
|||
});
|
||||
|
||||
router.get('/transaction-matches', (req: Req, res: Res) => {
|
||||
try {
|
||||
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',
|
||||
),
|
||||
);
|
||||
}
|
||||
res.json({
|
||||
transactions: searchSubscriptionTransactions(getDb(), req.user.id, req.query),
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/recommendations/decline', (req: Req, res: Res) => {
|
||||
const { decline_key } = req.body || {};
|
||||
if (!decline_key || typeof decline_key !== 'string' || decline_key.length > 200) {
|
||||
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'));
|
||||
throw ValidationError('decline_key is required', 'decline_key');
|
||||
}
|
||||
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
|
||||
|
|
@ -88,20 +74,10 @@ router.post('/recommendations/match-bill', (req: Req, res: Res) => {
|
|||
.slice(0, 50);
|
||||
|
||||
if (!Number.isInteger(billId) || billId < 1) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
|
||||
throw ValidationError('bill_id is required', 'bill_id');
|
||||
}
|
||||
if (txIds.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'transaction_ids must be a non-empty array',
|
||||
'VALIDATION_ERROR',
|
||||
'transaction_ids',
|
||||
),
|
||||
);
|
||||
throw ValidationError('transaction_ids must be a non-empty array', 'transaction_ids');
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
|
@ -110,8 +86,7 @@ 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',
|
||||
)
|
||||
.get(billId, req.user.id);
|
||||
if (!bill)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!bill) throw NotFoundError('Bill not found', 'bill_id');
|
||||
const catalogId = parseInt(req.body?.catalog_id, 10);
|
||||
const catalogEntry =
|
||||
Number.isInteger(catalogId) && catalogId > 0
|
||||
|
|
@ -187,15 +162,7 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
|
|||
.get(categoryId, req.user.id)
|
||||
: null;
|
||||
if (!category) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'category_id is invalid for this user',
|
||||
'VALIDATION_ERROR',
|
||||
'category_id',
|
||||
),
|
||||
);
|
||||
throw ValidationError('category_id is invalid for this user', 'category_id');
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
|
@ -212,15 +179,14 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
|
|||
});
|
||||
res.status(201).json(created);
|
||||
} catch (err) {
|
||||
res
|
||||
.status(err.status || 400)
|
||||
.json(
|
||||
standardizeError(
|
||||
err.message || 'Could not create subscription',
|
||||
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
|
||||
err.field || null,
|
||||
),
|
||||
);
|
||||
// Service validation errors are user-facing at 4xx (parity with the old
|
||||
// behavior: no-status service throws surfaced at 400, never as masked 5xx).
|
||||
throw new ApiError(
|
||||
err.status ? 'VALIDATION_ERROR' : 'SUBSCRIPTION_CREATE_ERROR',
|
||||
err.message || 'Could not create subscription',
|
||||
err.status || 400,
|
||||
{ field: err.field || null },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -228,24 +194,23 @@ router.post('/recommendations/create', (req: Req, res: Res) => {
|
|||
|
||||
router.get('/catalog', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
try {
|
||||
const catalogEntries = db
|
||||
.prepare(
|
||||
`
|
||||
const catalogEntries = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, rank, name, category, subcategory, subscription_type,
|
||||
website, starting_monthly_usd, starting_annual_usd
|
||||
FROM subscription_catalog
|
||||
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
|
||||
const matchedBills = db
|
||||
.prepare(
|
||||
`
|
||||
// User's subscription bills that are linked to a catalog entry
|
||||
const matchedBills = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT b.id, b.name, b.expected_amount, b.active, b.catalog_id,
|
||||
b.cycle_type, b.billing_cycle
|
||||
FROM bills b
|
||||
|
|
@ -254,56 +219,49 @@ router.get('/catalog', (req: Req, res: Res) => {
|
|||
AND b.deleted_at IS 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);
|
||||
|
||||
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'));
|
||||
} 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 });
|
||||
});
|
||||
|
||||
// Update which catalog entry a bill is linked to (or unlink with null)
|
||||
|
|
@ -311,7 +269,7 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId) || billId < 1) {
|
||||
return res.status(400).json(standardizeError('Invalid bill ID', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('Invalid bill ID');
|
||||
}
|
||||
|
||||
const bill = db
|
||||
|
|
@ -319,7 +277,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',
|
||||
)
|
||||
.get(billId, req.user.id);
|
||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND'));
|
||||
if (!bill) throw NotFoundError('Bill not found');
|
||||
|
||||
const rawCatalogId = req.body?.catalog_id;
|
||||
|
||||
|
|
@ -338,23 +296,12 @@ router.put('/:id/catalog-link', (req: Req, res: Res) => {
|
|||
|
||||
const catalogId = parseInt(rawCatalogId, 10);
|
||||
if (!Number.isInteger(catalogId) || catalogId < 1) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'catalog_id must be a positive integer or null',
|
||||
'VALIDATION_ERROR',
|
||||
'catalog_id',
|
||||
),
|
||||
);
|
||||
throw ValidationError('catalog_id must be a positive integer or null', 'catalog_id');
|
||||
}
|
||||
const catalogEntry = db
|
||||
.prepare('SELECT id FROM subscription_catalog WHERE id = ?')
|
||||
.get(catalogId);
|
||||
if (!catalogEntry)
|
||||
return res
|
||||
.status(404)
|
||||
.json(standardizeError('Catalog entry not found', 'NOT_FOUND', 'catalog_id'));
|
||||
if (!catalogEntry) throw NotFoundError('Catalog entry not found', 'catalog_id');
|
||||
|
||||
db.prepare(
|
||||
"UPDATE bills SET catalog_id = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?",
|
||||
|
|
@ -374,70 +321,49 @@ router.post('/catalog/:catalogId/descriptors', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const catalogId = parseInt(req.params.catalogId, 10);
|
||||
if (!Number.isInteger(catalogId) || catalogId < 1) {
|
||||
return res.status(400).json(standardizeError('Invalid catalog ID', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('Invalid catalog ID');
|
||||
}
|
||||
|
||||
const catalogEntry = db
|
||||
.prepare('SELECT id FROM subscription_catalog WHERE id = ?')
|
||||
.get(catalogId);
|
||||
if (!catalogEntry)
|
||||
return res.status(404).json(standardizeError('Catalog entry not found', 'NOT_FOUND'));
|
||||
if (!catalogEntry) throw NotFoundError('Catalog entry not found');
|
||||
|
||||
const descriptor = String(req.body?.descriptor ?? '').trim();
|
||||
if (!descriptor) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('descriptor is required', 'VALIDATION_ERROR', 'descriptor'));
|
||||
throw ValidationError('descriptor is required', 'descriptor');
|
||||
}
|
||||
if (descriptor.length > 100) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'descriptor must be 100 characters or less',
|
||||
'VALIDATION_ERROR',
|
||||
'descriptor',
|
||||
),
|
||||
);
|
||||
throw ValidationError('descriptor must be 100 characters or less', 'descriptor');
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for case-insensitive duplicate
|
||||
const exists = db
|
||||
.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) {
|
||||
return res
|
||||
.status(409)
|
||||
.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'));
|
||||
// Check for case-insensitive duplicate
|
||||
const exists = db
|
||||
.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) {
|
||||
throw ConflictError(
|
||||
'Descriptor already exists for this service',
|
||||
'descriptor',
|
||||
'DUPLICATE_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
|
||||
|
|
@ -445,53 +371,42 @@ router.delete('/catalog/descriptors/:id', (req: Req, res: Res) => {
|
|||
const db = getDb();
|
||||
const descriptorId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(descriptorId) || descriptorId < 1) {
|
||||
return res.status(400).json(standardizeError('Invalid descriptor ID', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('Invalid descriptor ID');
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = db
|
||||
.prepare(
|
||||
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?',
|
||||
)
|
||||
.get(descriptorId, req.user.id);
|
||||
const result = db
|
||||
.prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?')
|
||||
.run(descriptorId, req.user.id);
|
||||
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'),
|
||||
);
|
||||
const existing = db
|
||||
.prepare(
|
||||
'SELECT catalog_id, descriptor FROM user_catalog_descriptors WHERE id = ? AND user_id = ?',
|
||||
)
|
||||
.get(descriptorId, req.user.id);
|
||||
const result = db
|
||||
.prepare('DELETE FROM user_catalog_descriptors WHERE id = ? AND user_id = ?')
|
||||
.run(descriptorId, req.user.id);
|
||||
if (result.changes === 0) {
|
||||
throw NotFoundError('Descriptor 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 });
|
||||
});
|
||||
|
||||
router.patch('/:id', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const billId = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(billId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(standardizeError('bill_id must be an integer', 'VALIDATION_ERROR', 'bill_id'));
|
||||
throw ValidationError('bill_id must be an integer', 'bill_id');
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL')
|
||||
.get(billId, req.user.id);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||
if (!existing) throw NotFoundError('Bill not found', 'bill_id');
|
||||
|
||||
const allowedTypes = new Set([
|
||||
'streaming',
|
||||
|
|
@ -530,15 +445,7 @@ router.patch('/:id', (req: Req, res: Res) => {
|
|||
next.reminder_days_before < 0 ||
|
||||
next.reminder_days_before > 30
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json(
|
||||
standardizeError(
|
||||
'reminder_days_before must be between 0 and 30',
|
||||
'VALIDATION_ERROR',
|
||||
'reminder_days_before',
|
||||
),
|
||||
);
|
||||
throw ValidationError('reminder_days_before must be between 0 and 30', 'reminder_days_before');
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const { getCycleRange, resolveDueDate } = require('../services/statusService.cts
|
|||
const { getUserSettings } = require('../services/userSettings.cts');
|
||||
const { accountingActiveSql } = require('../services/paymentAccountingService.cts');
|
||||
const { toCents, fromCents } = require('../utils/money.mts');
|
||||
const { ValidationError } = require('../utils/apiError.cts');
|
||||
|
||||
const DEFAULT_INCOME_LABEL = 'Salary';
|
||||
const DEFAULT_PENDING_DAYS = 3;
|
||||
|
|
@ -127,10 +128,10 @@ function parseYearMonth(source) {
|
|||
const month = parseInt(source.month || now.getMonth() + 1, 10);
|
||||
|
||||
if (Number.isNaN(year) || year < 2000 || year > 2100) {
|
||||
return { error: 'year must be a 4-digit integer between 2000 and 2100' };
|
||||
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
||||
}
|
||||
if (Number.isNaN(month) || month < 1 || month > 12) {
|
||||
return { error: 'month must be an integer between 1 and 12' };
|
||||
throw ValidationError('month must be an integer between 1 and 12', 'month');
|
||||
}
|
||||
|
||||
return { year, month };
|
||||
|
|
@ -447,7 +448,6 @@ function buildSummary(db, userId, year, month) {
|
|||
|
||||
router.get('/', (req: Req, res: Res) => {
|
||||
const parsed = parseYearMonth(req.query);
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const db = getDb();
|
||||
res.json(buildSummary(db, req.user.id, parsed.year, parsed.month));
|
||||
|
|
@ -455,11 +455,10 @@ router.get('/', (req: Req, res: Res) => {
|
|||
|
||||
router.put('/income', (req: Req, res: Res) => {
|
||||
const parsed = parseYearMonth(req.body || {});
|
||||
if (parsed.error) return res.status(400).json({ error: parsed.error });
|
||||
|
||||
const amount = Number(req.body?.amount);
|
||||
if (!Number.isFinite(amount) || amount < 0 || amount > 1000000000) {
|
||||
return res.status(400).json({ error: 'amount must be a number between 0 and 1000000000' });
|
||||
throw ValidationError('amount must be a number between 0 and 1000000000', 'amount');
|
||||
}
|
||||
|
||||
const label =
|
||||
|
|
|
|||
|
|
@ -4,6 +4,16 @@ const router = require('express').Router();
|
|||
const { log } = require('../utils/logger.cts');
|
||||
const { getDb } = require('../db/database.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 {
|
||||
decorateTransaction,
|
||||
ensureManualDataSource,
|
||||
|
|
@ -356,14 +366,15 @@ function rejectDirectMatchState(body = {}) {
|
|||
);
|
||||
}
|
||||
|
||||
function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction operation failed') {
|
||||
if (err.status) {
|
||||
return res
|
||||
.status(err.status)
|
||||
.json(standardizeError(err.message, err.code || 'TRANSACTION_ERROR', err.field));
|
||||
// 4xx service errors keep their message/code on the wire; anything else is
|
||||
// rethrown raw so the terminal handler masks + logs it as a 5xx.
|
||||
function toTransactionServiceError(err) {
|
||||
if (err.status && err.status < 500) {
|
||||
return new ApiError(err.code || 'TRANSACTION_ERROR', err.message, err.status, {
|
||||
field: err.field,
|
||||
});
|
||||
}
|
||||
log.error('[transactions] service error:', err.stack || err.message);
|
||||
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
|
||||
return err;
|
||||
}
|
||||
|
||||
function emptyBankLedger(enabled, hasConnections = false) {
|
||||
|
|
@ -460,7 +471,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
ensureManualDataSource(db, req.user.id);
|
||||
|
||||
const page = parseLimitOffset(req.query);
|
||||
if (page.error) return res.status(400).json(page.error);
|
||||
if (page.error) throwStandardized(page.error);
|
||||
|
||||
const SORT_COLUMNS = {
|
||||
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
|
||||
|
|
@ -534,7 +545,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
for (const field of ['data_source_id', 'account_id', 'matched_bill_id']) {
|
||||
if (req.query[field] !== undefined) {
|
||||
const parsed = parseInteger(req.query[field], field);
|
||||
if (parsed.error) return res.status(400).json(parsed.error);
|
||||
if (parsed.error) throwStandardized(parsed.error);
|
||||
where.push(`t.${field} = ?`);
|
||||
params.push(parsed.value);
|
||||
}
|
||||
|
|
@ -542,13 +553,13 @@ router.get('/', (req: Req, res: Res) => {
|
|||
|
||||
if (req.query.start_date) {
|
||||
const parsed = parseDate(req.query.start_date, 'start_date');
|
||||
if (parsed.error) return res.status(400).json(parsed.error);
|
||||
if (parsed.error) throwStandardized(parsed.error);
|
||||
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?');
|
||||
params.push(parsed.value);
|
||||
}
|
||||
if (req.query.end_date) {
|
||||
const parsed = parseDate(req.query.end_date, 'end_date');
|
||||
if (parsed.error) return res.status(400).json(parsed.error);
|
||||
if (parsed.error) throwStandardized(parsed.error);
|
||||
where.push('COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?');
|
||||
params.push(parsed.value);
|
||||
}
|
||||
|
|
@ -607,7 +618,7 @@ router.get('/', (req: Req, res: Res) => {
|
|||
router.get('/bank-ledger', (req: Req, res: Res) => {
|
||||
const config = getBankSyncConfig();
|
||||
const page = parseLimitOffset(req.query);
|
||||
if (page.error) return res.status(400).json(page.error);
|
||||
if (page.error) throwStandardized(page.error);
|
||||
|
||||
const db = getDb();
|
||||
const sources = db
|
||||
|
|
@ -653,7 +664,7 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
|||
.all(req.user.id);
|
||||
|
||||
const filtered = bankLedgerWhere(req.query, req.user.id);
|
||||
if (filtered.error) return res.status(400).json(filtered.error);
|
||||
if (filtered.error) throwStandardized(filtered.error);
|
||||
|
||||
const SORT_COLUMNS = {
|
||||
date: 'COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)',
|
||||
|
|
@ -758,14 +769,14 @@ router.get('/bank-ledger', (req: Req, res: Res) => {
|
|||
router.post('/manual', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const directMatchState = rejectDirectMatchState(req.body);
|
||||
if (directMatchState) return res.status(400).json(directMatchState);
|
||||
if (directMatchState) throwStandardized(directMatchState);
|
||||
|
||||
const validation = normalizeTransactionFields(db, req.user.id, req.body);
|
||||
if (validation.error) return res.status(validation.status || 400).json(validation.error);
|
||||
if (validation.error) throwStandardized(validation.error, validation.status || 400);
|
||||
const tx = validation.normalized;
|
||||
const source = ensureManualDataSource(db, req.user.id);
|
||||
const state = resolveTransactionState(tx);
|
||||
if (state.error) return res.status(400).json(state.error);
|
||||
if (state.error) throwStandardized(state.error);
|
||||
Object.assign(tx, state);
|
||||
|
||||
const result = db
|
||||
|
|
@ -803,20 +814,19 @@ router.post('/manual', (req: Req, res: Res) => {
|
|||
router.put('/:id', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const directMatchState = rejectDirectMatchState(req.body);
|
||||
if (directMatchState) return res.status(400).json(directMatchState);
|
||||
if (directMatchState) throwStandardized(directMatchState);
|
||||
|
||||
const id = parseInteger(req.params.id, 'id');
|
||||
if (id.error) return res.status(400).json(id.error);
|
||||
if (id.error) throwStandardized(id.error);
|
||||
|
||||
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||
|
||||
const validation = normalizeTransactionFields(db, req.user.id, req.body, { partial: true });
|
||||
if (validation.error) return res.status(validation.status || 400).json(validation.error);
|
||||
if (validation.error) throwStandardized(validation.error, validation.status || 400);
|
||||
const tx = validation.normalized;
|
||||
const state = resolveTransactionState(tx, existing);
|
||||
if (state.error) return res.status(400).json(state.error);
|
||||
if (state.error) throwStandardized(state.error);
|
||||
Object.assign(tx, state);
|
||||
|
||||
const nextPostedDate = hasOwn(tx, 'posted_date') ? tx.posted_date : existing.posted_date;
|
||||
|
|
@ -880,11 +890,10 @@ router.put('/:id', (req: Req, res: Res) => {
|
|||
router.delete('/:id', (req: Req, res: Res) => {
|
||||
const db = getDb();
|
||||
const id = parseInteger(req.params.id, 'id');
|
||||
if (id.error) return res.status(400).json(id.error);
|
||||
if (id.error) throwStandardized(id.error);
|
||||
|
||||
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||
|
||||
db.transaction(() => {
|
||||
unmatchTransaction(req.user.id, id.value);
|
||||
|
|
@ -905,7 +914,7 @@ router.post('/:id/match', (req: Req, res: Res) => {
|
|||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Transaction match failed');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -915,7 +924,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
|
|||
const result = unmatchTransaction(req.user.id, req.params.id);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Transaction unmatch failed');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -926,7 +935,7 @@ router.post('/:id/unmatch', (req: Req, res: Res) => {
|
|||
router.post('/unmatch-bulk', (req: Req, res: Res) => {
|
||||
const matches = req.body?.matches;
|
||||
if (!Array.isArray(matches) || matches.length === 0) {
|
||||
return res.status(400).json(standardizeError('matches array required', 'VALIDATION_ERROR'));
|
||||
throw ValidationError('matches array required');
|
||||
}
|
||||
if (matches.length > 50) {
|
||||
return res
|
||||
|
|
@ -996,13 +1005,19 @@ router.post('/unmatch-bulk', (req: Req, res: Res) => {
|
|||
results.push({ transaction_id: txId, ok: true });
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({ transaction_id: txId, ok: false, error: err.message });
|
||||
// Per-item feedback: service 4xx messages are user-facing; anything
|
||||
// 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);
|
||||
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 });
|
||||
}
|
||||
res.json({ results, unmatched: results.filter((r) => r.ok).length });
|
||||
|
|
@ -1014,7 +1029,7 @@ router.post('/:id/ignore', (req: Req, res: Res) => {
|
|||
const result = ignoreTransaction(req.user.id, req.params.id);
|
||||
res.json(result.transaction);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Transaction ignore failed');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1024,7 +1039,7 @@ router.post('/:id/unignore', (req: Req, res: Res) => {
|
|||
const result = unignoreTransaction(req.user.id, req.params.id);
|
||||
res.json(result.transaction);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Transaction unignore failed');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1033,11 +1048,10 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
|
|||
try {
|
||||
const db = getDb();
|
||||
const id = parseInteger(req.params.id, 'id');
|
||||
if (id.error) return res.status(400).json(id.error);
|
||||
if (id.error) throwStandardized(id.error);
|
||||
|
||||
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||
|
||||
const match = findMerchantMatch(
|
||||
db,
|
||||
|
|
@ -1045,7 +1059,7 @@ router.get('/:id/merchant-match', (req: Req, res: Res) => {
|
|||
);
|
||||
res.json({ match });
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Failed to look up merchant match');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1054,11 +1068,10 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
|
|||
try {
|
||||
const db = getDb();
|
||||
const id = parseInteger(req.params.id, 'id');
|
||||
if (id.error) return res.status(400).json(id.error);
|
||||
if (id.error) throwStandardized(id.error);
|
||||
|
||||
const existing = getTransactionForUser(db, req.user.id, id.value);
|
||||
if (!existing)
|
||||
return res.status(404).json(standardizeError('Transaction not found', 'NOT_FOUND', 'id'));
|
||||
if (!existing) throw NotFoundError('Transaction not found', 'id');
|
||||
|
||||
const match = findMerchantMatch(
|
||||
db,
|
||||
|
|
@ -1075,7 +1088,7 @@ router.post('/:id/apply-merchant-match', (req: Req, res: Res) => {
|
|||
display_name: match.display_name,
|
||||
});
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Failed to apply merchant match');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -1088,7 +1101,7 @@ router.post('/auto-categorize', (req: Req, res: Res) => {
|
|||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return sendTransactionServiceError(res, err, 'Failed to auto-categorize transactions');
|
||||
throw toTransactionServiceError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ router.get('/history', (req: Req, res: Res) => {
|
|||
if (!fs.existsSync(HISTORY_PATH)) throw NotFoundError('Release history not found');
|
||||
|
||||
const history = fs.readFileSync(HISTORY_PATH, 'utf8');
|
||||
let updatedAt = null;
|
||||
let updatedAt;
|
||||
try {
|
||||
updatedAt = fs.statSync(HISTORY_PATH).mtime.toISOString();
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -273,7 +273,8 @@ const { getCsrfToken } = require('./middleware/csrf.cts');
|
|||
app.get('/{*splat}', (req, res) => {
|
||||
// Set CSRF cookie if not present (needed for SPA to read token)
|
||||
getCsrfToken(req, res);
|
||||
res.sendFile(path.join(DIST, 'index.html'));
|
||||
// root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
|
||||
res.sendFile('index.html', { root: DIST });
|
||||
});
|
||||
|
||||
// ── Global error handler ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -89,9 +89,17 @@ async function login(username: any, password: any) {
|
|||
createAuthenticationChallenge,
|
||||
createLoginChallenge,
|
||||
} = require('./webauthnService.cts');
|
||||
const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
|
||||
const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
|
||||
return { requires_webauthn: true, challenge_token: loginToken, webauthn_options: options };
|
||||
try {
|
||||
const { options, challengeId } = await createAuthenticationChallenge(getDb(), user.id);
|
||||
const loginToken = createLoginChallenge(getDb(), user.id, challengeId);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ async function claimSetupToken(setupToken: unknown): Promise<string> {
|
|||
const token = setupToken.trim();
|
||||
|
||||
// Base64-decode to get the claim URL; handle both raw base64 and data-URLs
|
||||
let claimUrl = token;
|
||||
let claimUrl;
|
||||
try {
|
||||
const decoded = Buffer.from(token, 'base64').toString('utf8').trim();
|
||||
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');
|
||||
}
|
||||
|
||||
let accessUrl = '';
|
||||
let accessUrl;
|
||||
try {
|
||||
const res = await fetch(claimUrl, { method: 'POST', signal: AbortSignal.timeout(30000) });
|
||||
if (res.status === 403) {
|
||||
|
|
|
|||
|
|
@ -18,11 +18,24 @@ function getRpId(): string {
|
|||
return process.env.WEBAUTHN_RP_ID || 'localhost';
|
||||
}
|
||||
|
||||
function getOrigin(): string {
|
||||
// WEBAUTHN_ORIGIN is authoritative when set. Otherwise accept the API origin
|
||||
// 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;
|
||||
if (o) return o;
|
||||
const port = process.env.PORT || 3000;
|
||||
return `http://localhost:${port}`;
|
||||
const accepted = [`http://localhost:${port}`];
|
||||
if (requestOrigin) {
|
||||
try {
|
||||
if (new URL(requestOrigin).hostname === getRpId()) accepted.push(requestOrigin);
|
||||
} catch {
|
||||
/* malformed Origin header — ignore */
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
// ── Registration ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -80,6 +93,7 @@ async function verifyRegistration(
|
|||
challengeId: string,
|
||||
response: any,
|
||||
credentialName: any,
|
||||
requestOrigin?: string,
|
||||
) {
|
||||
const row = db
|
||||
.prepare(
|
||||
|
|
@ -92,7 +106,7 @@ async function verifyRegistration(
|
|||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: row.challenge,
|
||||
expectedOrigin: getOrigin(),
|
||||
expectedOrigin: getOrigin(requestOrigin),
|
||||
expectedRPID: getRpId(),
|
||||
});
|
||||
|
||||
|
|
@ -162,7 +176,13 @@ async function createAuthenticationChallenge(db: Db, userId: number) {
|
|||
return { options, challengeId };
|
||||
}
|
||||
|
||||
async function verifyAuthentication(db: Db, userId: number, challengeId: string, response: any) {
|
||||
async function verifyAuthentication(
|
||||
db: Db,
|
||||
userId: number,
|
||||
challengeId: string,
|
||||
response: any,
|
||||
requestOrigin?: string,
|
||||
) {
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT challenge FROM webauthn_challenges WHERE id = ? AND user_id = ? AND challenge_type = 'authentication' AND expires_at > datetime('now')",
|
||||
|
|
@ -181,7 +201,7 @@ async function verifyAuthentication(db: Db, userId: number, challengeId: string,
|
|||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: row.challenge,
|
||||
expectedOrigin: getOrigin(),
|
||||
expectedOrigin: getOrigin(requestOrigin),
|
||||
expectedRPID: getRpId(),
|
||||
credential: {
|
||||
id: cred.credential_id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
'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');
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-reorder-test-${process.pid}.
|
|||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database.cts');
|
||||
const { formatError } = require('../utils/apiError.cts');
|
||||
const { getTracker } = require('../services/trackerService.cts');
|
||||
|
||||
function createUser(db, suffix) {
|
||||
|
|
@ -60,7 +61,13 @@ function callBillsRoute(routePath, method, { userId, params = {}, query = {}, bo
|
|||
try {
|
||||
handler(req, res);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
// Routes follow the throw-pattern: mirror the app's terminal error
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-payments-${process.pid}.sqli
|
|||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database.cts');
|
||||
const { formatError } = require('../utils/apiError.cts');
|
||||
const router = require('../routes/payments.cts');
|
||||
|
||||
function handler(method, routePath) {
|
||||
|
|
@ -36,7 +37,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
|
|||
resolve({ status: this.statusCode, data: d });
|
||||
},
|
||||
};
|
||||
h(req, res);
|
||||
// Routes follow the throw-pattern: mirror the app's terminal error handler
|
||||
// (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) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-snowball-plan-${process.pid}
|
|||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database.cts');
|
||||
const { formatError } = require('../utils/apiError.cts');
|
||||
const router = require('../routes/snowball.cts');
|
||||
|
||||
function handler(method, routePath) {
|
||||
|
|
@ -34,7 +35,13 @@ function call(method, routePath, { userId, params = {}, body = {} } = {}) {
|
|||
resolve({ status: this.statusCode, data: d });
|
||||
},
|
||||
};
|
||||
h(req, res);
|
||||
// Routes follow the throw-pattern: mirror the app's terminal error handler
|
||||
// (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) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-transaction-match-test-${pro
|
|||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database.cts');
|
||||
const { formatError } = require('../utils/apiError.cts');
|
||||
const { ensureManualDataSource } = require('../services/transactionService.cts');
|
||||
const { getTracker } = require('../services/trackerService.cts');
|
||||
const {
|
||||
|
|
@ -131,7 +132,13 @@ function callBillsRoute(routePath, { userId, params = {}, query = {} }) {
|
|||
try {
|
||||
handler(req, res);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
// Routes follow the throw-pattern: mirror the app's terminal error
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -164,7 +171,13 @@ function callPaymentsRoute(routePath, method, { userId, params = {}, query = {},
|
|||
try {
|
||||
handler(req, res);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
// Routes follow the throw-pattern: mirror the app's terminal error
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
'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})`,
|
||||
);
|
||||
});
|
||||
|
|
@ -51,6 +51,17 @@ function validateEnv(): void {
|
|||
'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 };
|
||||
|
|
|
|||
|
|
@ -103,18 +103,15 @@ export default defineConfig({
|
|||
output: {
|
||||
// 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.
|
||||
manualChunks: {
|
||||
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||
'vendor-radix': [
|
||||
'@radix-ui/react-dialog',
|
||||
'@radix-ui/react-select',
|
||||
'@radix-ui/react-dropdown-menu',
|
||||
'@radix-ui/react-tabs',
|
||||
'@radix-ui/react-tooltip',
|
||||
'@radix-ui/react-alert-dialog',
|
||||
],
|
||||
'vendor-motion': ['framer-motion'],
|
||||
'vendor-query': ['@tanstack/react-query', '@tanstack/react-virtual'],
|
||||
// Function form: vite 8 (rolldown core) dropped the object form.
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return undefined;
|
||||
if (/node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/.test(id))
|
||||
return 'vendor-react';
|
||||
if (/node_modules[\\/]@radix-ui[\\/]/.test(id)) return 'vendor-radix';
|
||||
if (/node_modules[\\/]framer-motion[\\/]/.test(id)) return 'vendor-motion';
|
||||
if (/node_modules[\\/]@tanstack[\\/]/.test(id)) return 'vendor-query';
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||