diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8356de1 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Public application origin and exact OAuth callback +APP_ORIGIN=http://localhost:3000 +GOOGLE_OAUTH_REDIRECT_URI=http://localhost:3000/api/auth/google/callback + +# Google Cloud identifiers and server-side OAuth secret +GOOGLE_CLIENT_ID=replace-with-google-oauth-client-id +GOOGLE_CLIENT_SECRET=replace-with-google-oauth-client-secret +GOOGLE_API_KEY=replace-with-picker-api-key +GOOGLE_CLOUD_PROJECT_NUMBER=123456789012 +ALLOWED_GOOGLE_EMAIL=owner@example.test + +# Pooled PostgreSQL runtime connection (never use production for tests) +DATABASE_URL=postgresql://runtime-user:replace-with-password@localhost:5432/accura + +# Generate independent values as documented in docs/anleitungen/produktions-setup.md +TOKEN_ENCRYPTION_KEY=replace-with-base64-encoded-32-byte-key +SESSION_SECRET=replace-with-at-least-32-random-bytes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc5cf3e..7c75d3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,42 @@ jobs: - name: Run unit tests run: npm test + postgres-tests: + name: PostgreSQL Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + postgres: + image: postgres:17 + env: + POSTGRES_DB: accura_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d accura_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + POSTGRES_TEST_URL: postgresql://postgres:postgres@127.0.0.1:5432/accura_test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run PostgreSQL integration tests + run: npm run test:postgres + build: name: Production Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index ab290c3..0f4449f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,3 @@ pnpm-debug.log* .DS_Store Thumbs.db .vercel -.env* diff --git a/api/_lib/database.ts b/api/_lib/database.ts new file mode 100644 index 0000000..195575e --- /dev/null +++ b/api/_lib/database.ts @@ -0,0 +1,18 @@ +import postgres from 'postgres'; + +const databases = new Map(); + +/** Returns one lazy PostgreSQL pool per connection URL in the current function instance. */ +export function getDatabase(databaseUrl: string): postgres.Sql { + const existing = databases.get(databaseUrl); + if (existing) return existing; + + const sql = postgres(databaseUrl, { + max: 1, + idle_timeout: 20, + connect_timeout: 10, + prepare: false, + }); + databases.set(databaseUrl, sql); + return sql; +} diff --git a/api/_lib/financeRepository.ts b/api/_lib/financeRepository.ts new file mode 100644 index 0000000..8edc579 --- /dev/null +++ b/api/_lib/financeRepository.ts @@ -0,0 +1,329 @@ +import type postgres from 'postgres'; +import { financeDataV1Schema } from '../../src/finance/runtime.js'; +import type { FinanceDataV1 } from '../../src/finance/types.js'; +import { getDatabase } from './database.js'; + +export interface FinanceRepository { + readForGoogleSub(googleSub: string): Promise; +} + +export type FinanceDataIntegrityReason = 'invalid_integer' | 'invalid_shape' | 'missing_current_snapshot'; + +/** Sanitized internal error: it deliberately carries no row, entity ID, or financial value. */ +export class FinanceDataIntegrityError extends Error { + readonly code = 'finance_data_integrity_error'; + + constructor(readonly reason: FinanceDataIntegrityReason) { + super('Stored finance data failed integrity validation.'); + this.name = 'FinanceDataIntegrityError'; + } +} + +type OwnerRow = { id: string }; +type MetaRow = { + schema_version: number; + as_of: string; + currency: string; + monthly_income_cents: string; + salary_day: number | null; +}; +type AccountRow = { + id: string; + name: string; + kind: string; + display_order: string; + active: boolean; +}; +type AccountSnapshotRow = { + account_id: string; + as_of: string; + balance_cents: string; +}; +type PocketRow = { + id: string; + account_id: string; + name: string; + display_order: string; + active: boolean; +}; +type PocketSnapshotRow = { + pocket_id: string; + as_of: string; + balance_cents: string; +}; +type BudgetItemRow = { + id: string; + label: string; + monthly_amount_cents: string; + necessity_id: string; + kind: string; + display_order: string; + active: boolean; + note: string | null; + due_day: number | null; +}; +type DebtRow = { + id: string; + name: string; + kind: string; + monthly_payment_cents: string; + display_order: string; + active: boolean; + note: string | null; + due_day: number | null; +}; +type DebtSnapshotRow = { + debt_id: string; + as_of: string; + payoff_balance_cents: string; + remaining_payment_count: string; + remaining_scheduled_total_cents: string; +}; +type DebtMilestoneRow = { + debt_id: string; + date: string; + balance_cents: string; +}; +type ReliefMilestoneRow = { + date: string; + monthly_relief_cents: string; + event: string; + event_detail: string | null; +}; + +const integerPattern = /^-?(?:0|[1-9]\d*)$/; + +function safeInteger(value: unknown): number { + if (typeof value !== 'string' || !integerPattern.test(value)) { + throw new FinanceDataIntegrityError('invalid_integer'); + } + const mapped = Number(value); + if (!Number.isSafeInteger(mapped)) throw new FinanceDataIntegrityError('invalid_integer'); + return mapped; +} + +function hasCurrentSnapshot( + id: string, + asOf: string, + snapshots: T[], + getId: (snapshot: T) => string, + getDate: (snapshot: T) => string, +): boolean { + return snapshots.some((snapshot) => getId(snapshot) === id && getDate(snapshot) <= asOf); +} + +function assertCurrentSnapshots(data: FinanceDataV1): void { + const accountSnapshotsValid = data.accounts + .filter(({ active }) => active) + .every(({ id }) => hasCurrentSnapshot(id, data.asOf, data.accountSnapshots, (entry) => entry.accountId, (entry) => entry.asOf)); + const pocketSnapshotsValid = data.pockets + .filter(({ active }) => active) + .every(({ id }) => hasCurrentSnapshot(id, data.asOf, data.pocketSnapshots, (entry) => entry.pocketId, (entry) => entry.asOf)); + const debtSnapshotsValid = data.debts + .filter(({ active }) => active) + .every(({ id }) => hasCurrentSnapshot(id, data.asOf, data.debtSnapshots, (entry) => entry.debtId, (entry) => entry.asOf)); + + if (!accountSnapshotsValid || !pocketSnapshotsValid || !debtSnapshotsValid) { + throw new FinanceDataIntegrityError('missing_current_snapshot'); + } +} + +export class PostgresFinanceRepository implements FinanceRepository { + constructor(private readonly sql: postgres.Sql) {} + + async readForGoogleSub(googleSub: string): Promise { + return this.sql.begin('read only isolation level repeatable read', async (transaction) => { + const owners = await transaction` + SELECT id + FROM owners + WHERE google_sub = ${googleSub} + LIMIT 1 + `; + const ownerId = owners[0]?.id; + if (!ownerId) return null; + + const metaRows = await transaction` + SELECT schema_version, + TO_CHAR(as_of, 'YYYY-MM-DD') AS as_of, + currency, + monthly_income_cents, + salary_day + FROM finance_meta + WHERE owner_id = ${ownerId} + LIMIT 1 + `; + const meta = metaRows[0]; + if (!meta) return null; + + const accountRows = await transaction` + SELECT id, name, kind, display_order, active + FROM accounts + WHERE owner_id = ${ownerId} + ORDER BY display_order, id + `; + const accountSnapshotRows = await transaction` + SELECT snapshots.account_id, + TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, + snapshots.balance_cents + FROM account_snapshots AS snapshots + JOIN accounts AS accounts + ON accounts.owner_id = snapshots.owner_id AND accounts.id = snapshots.account_id + WHERE snapshots.owner_id = ${ownerId} + ORDER BY accounts.display_order, accounts.id, snapshots.as_of + `; + const pocketRows = await transaction` + SELECT id, account_id, name, display_order, active + FROM pockets + WHERE owner_id = ${ownerId} + ORDER BY display_order, id + `; + const pocketSnapshotRows = await transaction` + SELECT snapshots.pocket_id, + TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, + snapshots.balance_cents + FROM pocket_snapshots AS snapshots + JOIN pockets AS pockets + ON pockets.owner_id = snapshots.owner_id AND pockets.id = snapshots.pocket_id + WHERE snapshots.owner_id = ${ownerId} + ORDER BY pockets.display_order, pockets.id, snapshots.as_of + `; + const budgetItemRows = await transaction` + SELECT id, label, monthly_amount_cents, necessity_id, kind, display_order, + active, note, due_day + FROM budget_items + WHERE owner_id = ${ownerId} + ORDER BY display_order, id + `; + const debtRows = await transaction` + SELECT id, name, kind, monthly_payment_cents, display_order, active, note, due_day + FROM debts + WHERE owner_id = ${ownerId} + ORDER BY display_order, id + `; + const debtSnapshotRows = await transaction` + SELECT snapshots.debt_id, + TO_CHAR(snapshots.as_of, 'YYYY-MM-DD') AS as_of, + snapshots.payoff_balance_cents, + snapshots.remaining_payment_count, + snapshots.remaining_scheduled_total_cents + FROM debt_snapshots AS snapshots + JOIN debts AS debts + ON debts.owner_id = snapshots.owner_id AND debts.id = snapshots.debt_id + WHERE snapshots.owner_id = ${ownerId} + ORDER BY debts.display_order, debts.id, snapshots.as_of + `; + const debtMilestoneRows = await transaction` + SELECT milestones.debt_id, + CASE milestones.date_precision + WHEN 'month' THEN TO_CHAR(milestones.milestone_date, 'YYYY-MM') + ELSE TO_CHAR(milestones.milestone_date, 'YYYY-MM-DD') + END AS date, + milestones.balance_cents + FROM debt_milestones AS milestones + JOIN debts AS debts + ON debts.owner_id = milestones.owner_id AND debts.id = milestones.debt_id + WHERE milestones.owner_id = ${ownerId} + ORDER BY milestones.milestone_date, debts.display_order, debts.id, milestones.date_precision + `; + const reliefMilestoneRows = await transaction` + SELECT CASE date_precision + WHEN 'month' THEN TO_CHAR(milestone_date, 'YYYY-MM') + ELSE TO_CHAR(milestone_date, 'YYYY-MM-DD') + END AS date, + monthly_relief_cents, + event, + event_detail + FROM relief_milestones + WHERE owner_id = ${ownerId} + ORDER BY milestone_date, event, event_detail NULLS FIRST, id + `; + + const candidate = { + schemaVersion: meta.schema_version, + asOf: meta.as_of, + currency: meta.currency, + monthlyIncomeCents: safeInteger(meta.monthly_income_cents), + salaryDay: meta.salary_day, + accounts: accountRows.map((row) => ({ + id: row.id, + name: row.name, + kind: row.kind, + displayOrder: safeInteger(row.display_order), + active: row.active, + })), + accountSnapshots: accountSnapshotRows.map((row) => ({ + accountId: row.account_id, + asOf: row.as_of, + balanceCents: safeInteger(row.balance_cents), + })), + pockets: pocketRows.map((row) => ({ + id: row.id, + accountId: row.account_id, + name: row.name, + displayOrder: safeInteger(row.display_order), + active: row.active, + })), + pocketSnapshots: pocketSnapshotRows.map((row) => ({ + pocketId: row.pocket_id, + asOf: row.as_of, + balanceCents: safeInteger(row.balance_cents), + })), + budgetItems: budgetItemRows.map((row) => ({ + id: row.id, + label: row.label, + monthlyAmountCents: safeInteger(row.monthly_amount_cents), + necessityId: row.necessity_id, + kind: row.kind, + displayOrder: safeInteger(row.display_order), + active: row.active, + note: row.note, + dueDay: row.due_day, + })), + debts: debtRows.map((row) => ({ + id: row.id, + name: row.name, + kind: row.kind, + monthlyPaymentCents: safeInteger(row.monthly_payment_cents), + displayOrder: safeInteger(row.display_order), + active: row.active, + note: row.note, + dueDay: row.due_day, + })), + debtSnapshots: debtSnapshotRows.map((row) => ({ + debtId: row.debt_id, + asOf: row.as_of, + payoffBalanceCents: safeInteger(row.payoff_balance_cents), + remainingPaymentCount: safeInteger(row.remaining_payment_count), + remainingScheduledTotalCents: safeInteger(row.remaining_scheduled_total_cents), + })), + debtMilestones: debtMilestoneRows.map((row) => ({ + debtId: row.debt_id, + date: row.date, + balanceCents: safeInteger(row.balance_cents), + })), + reliefMilestones: reliefMilestoneRows.map((row) => ({ + date: row.date, + monthlyReliefCents: safeInteger(row.monthly_relief_cents), + event: row.event, + eventDetail: row.event_detail, + })), + }; + + const result = financeDataV1Schema.safeParse(candidate); + if (!result.success) throw new FinanceDataIntegrityError('invalid_shape'); + assertCurrentSnapshots(result.data); + return result.data; + }); + } +} + +const repositories = new Map(); + +export function getFinanceRepository(databaseUrl: string): FinanceRepository { + const existing = repositories.get(databaseUrl); + if (existing) return existing; + + const repository = new PostgresFinanceRepository(getDatabase(databaseUrl)); + repositories.set(databaseUrl, repository); + return repository; +} diff --git a/api/_lib/repository.ts b/api/_lib/repository.ts index 8c57ce1..7d1f999 100644 --- a/api/_lib/repository.ts +++ b/api/_lib/repository.ts @@ -1,4 +1,5 @@ -import postgres from 'postgres'; +import type postgres from 'postgres'; +import { getDatabase } from './database.js'; export type GoogleConnection = { googleSub: string; @@ -95,12 +96,13 @@ export class PostgresConnectionRepository implements ConnectionRepository { } } -let repository: ConnectionRepository | undefined; +const repositories = new Map(); export function getConnectionRepository(databaseUrl: string): ConnectionRepository { - if (!repository) { - const sql = postgres(databaseUrl, { max: 1, idle_timeout: 20, connect_timeout: 10, prepare: false }); - repository = new PostgresConnectionRepository(sql); - } + const existing = repositories.get(databaseUrl); + if (existing) return existing; + + const repository = new PostgresConnectionRepository(getDatabase(databaseUrl)); + repositories.set(databaseUrl, repository); return repository; } diff --git a/docs/README.md b/docs/README.md index 8791dd0..a2e4535 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,7 +45,7 @@ Diese Seite ist der zentrale Index und damit die Single Source of Truth (SSOT) f | HTTP-Endpunkte | [API-Referenz](referenz/api.md) | | Tabellenvertrag | [Finance Data Schema v1](referenz/finance-data-schema-v1.md) | | Umgebungsvariablen | [Konfiguration](referenz/konfiguration.md) | -| Tabelle `google_connections` | [Datenbank](referenz/datenbank.md) | +| PostgreSQL-Schema, Owner-Isolation und Tabelle `google_connections` | [Datenbank](referenz/datenbank.md) | | Verzeichnis- und Modulzuständigkeiten | [Quellcode-Karte](referenz/quellcode-karte.md) | | Begriffe | [Glossar](referenz/glossar.md) | | Begründete Architekturentscheidungen | [ADR-Index](entscheidungen/README.md) | diff --git a/docs/anleitungen/produktions-setup.md b/docs/anleitungen/produktions-setup.md index ec1a764..5ee0570 100644 --- a/docs/anleitungen/produktions-setup.md +++ b/docs/anleitungen/produktions-setup.md @@ -21,15 +21,60 @@ Repository-Code kann externe Konten, APIs, Redirects, Datenbank und Secrets nich `GOOGLE_CLOUD_PROJECT_NUMBER` ist die numerische Nummer, nicht die textuelle Projekt-ID. OAuth Client ID und Picker-Key sind browserlesbare Identifikatoren, aber ihre Einschränkungen bleiben sicherheitsrelevant. Das Client-Secret bleibt serverseitig. -## 2. PostgreSQL +## 2. PostgreSQL und Neon-Betrieb -Eine gepoolte PostgreSQL-Verbindung bereitstellen; für Vercel eignet sich ein kompatibler Marketplace-Anbieter wie Neon. Die gepoolte URL als `DATABASE_URL` verwenden. Migration zuerst bewusst in Development und erst nach Prüfung in Production anwenden: +Neon ist der aktuelle Betreiber; das Schema und der Anwendungscode setzen nur PostgreSQL voraus. Development und Production verwenden getrennte Neon-Datenbanken oder Branches. Preview-Deployments erhalten keine Produktionskopie und arbeiten ausschließlich mit synthetischen Daten. + +### Verbindungsarten und Migration + +Zwei getrennte Verbindungen verwenden: + +- Vercel Functions erhalten die gepoolte Neon-URL als `DATABASE_URL`. +- Migrationen, Rollenverwaltung und Restore-Prüfungen verwenden einen direkten Neon-Endpoint mit administrativen Credentials. Diese URL ist kein Runtime-Secret der App. + +Vor jedem Lauf Zielhost, Datenbank, Benutzer und Environment sichtbar prüfen. Migrationen zuerst in Development anwenden, dort die Integrationstests und einen vollständigen Reader-Durchlauf ausführen und erst danach Production getrennt beauftragen: + +```bash +psql "$DATABASE_DIRECT_URL" -f migrations/001_google_connections.sql +psql "$DATABASE_DIRECT_URL" -f migrations/002_finance_data_v1.sql +``` + +`DATABASE_DIRECT_URL` ist hier nur ein Name für die administrative Shell-Variable und keine von der Anwendung gelesene Konfiguration. Das tatsächliche Anwenden auf eine externe Development- oder Production-Datenbank ist ein eigener ausdrücklicher Betriebsauftrag. + +Für eine lokale dedizierte Testdatenbank oder den CI-Service gilt: ```bash -psql "$DATABASE_URL" -f migrations/001_google_connections.sql +POSTGRES_TEST_URL=postgresql://... npm run test:postgres ``` -Vor Ausführung Zielhost und Datenbanknamen prüfen. Die Migration ist idempotent angelegt, aber Datenbankänderungen bleiben Betreiberverantwortung. Schema und Rotation stehen unter [Datenbank](../referenz/datenbank.md). +Die Suite bricht ohne URL ab, legt unter der Ziel-Datenbank ein isoliertes synthetisches Testschema an, führt 001 und 002 aus und entfernt dieses Schema anschließend wieder. Niemals eine Produktions-URL als `POSTGRES_TEST_URL` verwenden. + +### Runtime-Rolle + +Direkte Owner-/Migrations-Credentials dürfen nicht als `DATABASE_URL` verwendet werden. Die Runtime-Rolle benötigt im ACC-71-Stand: + +- die bestehenden notwendigen `SELECT`-, `INSERT`-, `UPDATE`- und `DELETE`-Rechte auf `google_connections`; +- `SELECT` auf `owners` und allen Finance-Tabellen; +- keine DDL-, Rollenverwaltungs- oder Schema-Owner-Rechte; +- keine pauschalen Finance-Schreibrechte. Der spätere Editor erweitert Rechte nur auf die ausdrücklich benötigten Tabellen und Operationen. + +Die konkrete `GRANT`-Konfiguration wird pro Datenbank mit dem administrativen direkten Endpoint angewandt und anschließend durch Anmeldung, Connection-Update und einen Finance-Read mit synthetischem Owner geprüft. + +### Region + +Vor Production-Migration und Cutover werden die reale Neon-Region und die tatsächlich ausgeführte Vercel-Functions-Region im jeweiligen Dashboard geprüft. Ziel ist eine sinnvolle gemeinsame EU-Region. Ohne diesen Befund wird keine Region blind in `vercel.json` eingetragen. Gemessene Latenz und der gewählte Stand gehören ins private Betriebsprotokoll, nicht als vermutete Werte ins Repository. + +### Backup und Restore vor ACC-66 + +Vor dem späteren Import/Cutover sind folgende Punkte verpflichtend: + +1. Ein für die privaten Daten ausreichendes Neon-Restore-Fenster und ein geeigneter Tarif sind aktiv. +2. Ein Restore wird mit ausschließlich synthetischen Development-Daten praktisch durchgeführt. +3. Auf dem wiederhergestellten Stand werden Migrationstabellen beziehungsweise Schema-Constraints geprüft. +4. `npm run test:postgres` läuft gegen eine dafür vorgesehene Testdatenbank; anschließend wird ein vollständiger `FinanceDataV1` über das Repository gelesen. +5. Dauer, Verantwortlicher, Ziel, Ergebnis und Rückkehrschritte werden im privaten Betriebsprotokoll festgehalten. + +Das Repository automatisiert weder Neon-Restore noch externe Migrationen. Schema und Constraint-Vertrag stehen vollständig unter [Datenbank](../referenz/datenbank.md). ## 3. Secrets erzeugen @@ -95,11 +140,11 @@ Google-OAuth-Apps im External-Testmodus können Grants mit nicht ausschließlich - `SESSION_SECRET`: bestehende Sitzungen und OAuth-Transaktionen werden ungültig; kontrolliert wechseln und neu anmelden. - `TOKEN_ENCRYPTION_KEY`: vorhandene Refresh-Token sind ohne alten Schlüssel nicht entschlüsselbar. Aktuell gibt es keinen Keyring; Verbindung vor/nach koordiniertem Wechsel löschen und neu autorisieren oder eine explizite Migration bauen. - Google Client Secret/API-Key: Google- und Vercel-Konfiguration gemeinsam aktualisieren; Referrer/Redirects erneut testen. -- `DATABASE_URL`: Migration und Erreichbarkeit im Ziel prüfen, bevor der Appwert umgestellt wird. +- `DATABASE_URL`: gepoolte Runtime-URL; Erreichbarkeit, eingeschränkte Rolle und Ziel-Environment prüfen, bevor der Appwert umgestellt wird. ## Nachweis und Fehlerdiagnose - Setupfehler: [Fehlerdiagnose](fehlerdiagnose.md) - Sicherheitsfluss: [Backend und Sicherheit](../architektur/backend-und-sicherheit.md) - Releaseprüfungen: [Testen und Release](testen-und-release.md) -- Implementierung: [api/_lib/config.ts](../../api/_lib/config.ts), [api/auth/google](../../api/auth/google), [migrations/001_google_connections.sql](../../migrations/001_google_connections.sql) +- Implementierung: [api/_lib/config.ts](../../api/_lib/config.ts), [api/_lib/database.ts](../../api/_lib/database.ts), [api/auth/google](../../api/auth/google), [Migrationen](../../migrations) diff --git a/docs/anleitungen/testen-und-release.md b/docs/anleitungen/testen-und-release.md index b09fbca..3f4cd44 100644 --- a/docs/anleitungen/testen-und-release.md +++ b/docs/anleitungen/testen-und-release.md @@ -13,13 +13,15 @@ Vom Repository-Root aus: ```bash npm run docs:check npm test +npm run test:postgres npm run lint +npm run licenses:check npm run build npm run test:visual npm run smoke ``` -`test:visual` und `smoke` starten beziehungsweise orchestrieren ihre benötigten lokalen Browserabläufe gemäß den Skripten. Sie benötigen installierte Playwright-Chromium-Binaries. Externe Links können zusätzlich diagnostiziert werden: +`test:postgres` benötigt eine dedizierte `POSTGRES_TEST_URL`, darf nie gegen Production laufen und wird ohne URL absichtlich nicht übersprungen. Die CI stellt dafür einen temporären PostgreSQL-Service bereit. `test:visual` und `smoke` starten beziehungsweise orchestrieren ihre benötigten lokalen Browserabläufe gemäß den Skripten. Sie benötigen installierte Playwright-Chromium-Binaries. Externe Links können zusätzlich diagnostiziert werden: ```bash npm run docs:check:external @@ -63,7 +65,7 @@ Chromes nativer Installationsdialog, Android-Launcher, Task-Switcher und OS-Spla Arbeitsbranches entstehen von `develop` und werden per Pull Request dorthin integriert. Vercel stellt sie und den dauerhaften `develop`-Stand mit anonymer, bereits angemeldeter Mock-Sitzung bereit. Dieser Pfad darf keine realen Google-/Datenbankabläufe vortäuschen. -Ein Produktionsrelease von `develop` nach `master` erfolgt nur als eigener bewusster Schritt, wenn automatische Prüfungen grün, Änderungen und Migrationen verstanden, Secrets/Finanzwerte ausgeschlossen und relevante reale Szenarien abgenommen sind. GitHub-CI prüft Pushes auf `develop` und `master` sowie Pull Requests mit Lint, Unit, Build und Smoke. Reale Google-/Datenbankabläufe bleiben außerhalb CI. +Ein Produktionsrelease von `develop` nach `master` erfolgt nur als eigener bewusster Schritt, wenn automatische Prüfungen grün, Änderungen und Migrationen verstanden, Secrets/Finanzwerte ausgeschlossen und relevante reale Szenarien abgenommen sind. GitHub-CI prüft Pushes auf `develop` und `master` sowie Pull Requests mit Lint, Unit, PostgreSQL, Build und Smoke. Externe Neon-Betriebsabläufe bleiben außerhalb CI. ## Nachweis diff --git a/docs/architektur/backend-und-sicherheit.md b/docs/architektur/backend-und-sicherheit.md index b051980..8c55613 100644 --- a/docs/architektur/backend-und-sicherheit.md +++ b/docs/architektur/backend-und-sicherheit.md @@ -8,9 +8,19 @@ ## Mentales Modell -Die Vercel Functions sind Backend-for-Frontend und Sicherheitsgrenze. Sie verifizieren die einzige erlaubte Identität, verwalten Google-Token und Datenbankverbindung, lesen Sheets, validieren das Finance-Schema und liefern eine kleine same-origin JSON-API. Der Browser spricht Google nur beim bewusst geöffneten Picker direkt an. +Die Vercel Functions sind Backend-for-Frontend und Sicherheitsgrenze. Sie verifizieren die einzige erlaubte Identität, verwalten Google-Token und Datenbankverbindung, lesen Sheets, validieren das Finance-Schema und liefern eine kleine same-origin JSON-API. Der Browser spricht Google nur beim bewusst geöffneten Picker direkt an. Ein zentraler Lazy-Pool mit höchstens einer Verbindung pro `DATABASE_URL` wird vom Google-Connection- und vom neuen Finance-Repository geteilt. -Dies beschreibt den aktuell implementierten Übergangsstand. Im verbindlichen Zielbild aus [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) und [ADR 0014](../entscheidungen/0014-google-oauth-nur-als-identitaet.md) liest der Server `FinanceDataV1` ownergebunden aus PostgreSQL. Google bleibt nur Identitätsanbieter; Picker, `drive.file`, Sheets-Laufzeitzugriff und persistierte Refresh-Tokens entfallen beim Cutover. Bis dahin bleibt der alte Pfad betriebsfähig, wird aber nicht weiter ausgebaut. +Dies beschreibt den aktuell implementierten Übergangsstand. Der ownergebundene PostgreSQL-Reader aus [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) ist inzwischen implementiert und mit echter PostgreSQL-Instanz getestet, aber absichtlich an keinen HTTP-Endpunkt angeschlossen. `/api/finance` verwendet weiterhin ausschließlich den Sheets-Service. Erst ACC-66 importiert den produktiven Stand und führt den eindeutigen Cutover aus; bis dahin gibt es weder Dual-Read noch PostgreSQL-zu-Sheets-Fallback. Im Zielbild aus [ADR 0014](../entscheidungen/0014-google-oauth-nur-als-identitaet.md) bleibt Google nur Identitätsanbieter, und Picker, `drive.file`, Sheets-Laufzeitzugriff sowie persistierte Refresh-Tokens entfallen. + +## Implementierter PostgreSQL-Reader + +`FinanceRepository.readForGoogleSub(googleSub)` ist eine reine interne Servergrenze. `googleSub` darf nur aus der verifizierten Sitzung stammen; der Browser sendet keine Owner-UUID. Das Repository löst die Subjekt-ID einmalig zu `owners.id` auf und filtert jede weitere Abfrage mit dieser internen ID. + +Der vollständige Read läuft `READ ONLY` und `REPEATABLE READ`. Meta, Stammdaten, sämtliche historische und zukünftige Snapshots sowie Meilensteine stammen deshalb aus einem konsistenten Datenbankstand. `DATE` wird direkt in SQL zu ISO-Text formatiert; `BIGINT` wird nur aus gültiger Integerdarstellung in einen sicheren JavaScript-Integer überführt. Anschließend validiert das gemeinsame Zod-Schema den vollständigen `FinanceDataV1`-Vertrag. Aktive Accounts, Pockets und Debts brauchen zusätzlich einen Snapshot mit Datum am oder vor `asOf`. + +Fehlender Owner oder fehlendes `finance_meta` liefert `null`. Ein ungültiger gespeicherter Stand erzeugt einen eigenen internen Integritätsfehler ohne Zeilen, IDs oder Finanzwerte. Da ACC-71 keinen produktiven Endpunkt umstellt, existiert noch keine neue öffentliche HTTP-Fehlerabbildung. + +Implementierung und Test: [api/_lib/financeRepository.ts](../../api/_lib/financeRepository.ts), [PostgreSQL-Integrationstest](../../tests/postgres/financeRepository.postgres.test.ts). ## OAuth-Sequenz @@ -76,7 +86,9 @@ Widerrufene oder abgelaufene Google-Grants werden als `reconnect_required` abgeb Für den aktuellen Übergangsstand siehe die ersetzte [ADR 0003](../entscheidungen/0003-serverseitiger-google-zugriff-und-drive-file.md). Das Zielbild steht in [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) und [ADR 0014](../entscheidungen/0014-google-oauth-nur-als-identitaet.md); [ADR 0004](../entscheidungen/0004-single-user-sicherheitsmodell.md) bleibt gültig. - Konfiguration: [api/_lib/config.ts](../../api/_lib/config.ts) +- Gemeinsamer Datenbankzugang: [api/_lib/database.ts](../../api/_lib/database.ts) - HTTP-Grenze: [api/_lib/http.ts](../../api/_lib/http.ts) - Google-Client: [api/_lib/google.ts](../../api/_lib/google.ts) - Finance-Service: [api/_lib/financeService.ts](../../api/_lib/financeService.ts) +- Inaktiver PostgreSQL-Reader: [api/_lib/financeRepository.ts](../../api/_lib/financeRepository.ts) - Server-Tests: [src/server](../../src/server) diff --git a/docs/architektur/finanz-domaene.md b/docs/architektur/finanz-domaene.md index 57d4523..cfc3305 100644 --- a/docs/architektur/finanz-domaene.md +++ b/docs/architektur/finanz-domaene.md @@ -10,7 +10,7 @@ Die Tabelle enthält Quellen und zeitbezogene Snapshots, keine UI-Gesamtsummen. Der Parser validiert Beziehungen und normalisiert Geld in Integer-Cents. Reine Selektoren wählen den fachlich gültigen Stand und berechnen Summen. Das View-Model ergänzt lokalisierte Texte und Screen-Strukturen. React rendert diese Ausgabe. -Im beschlossenen Zielbild ist `FinanceDataV1` die quellenunabhängige Domänengrenze: Der einmalige Sheet-Import erzeugt denselben Vertrag wie das PostgreSQL-Repository. Die folgenden Sheets-Schritte beschreiben den aktuell implementierten Übergangsstand. Quelle, Owner-Zuordnung und SQL-Grenze legt [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) fest. +`FinanceDataV1` ist die quellenunabhängige Domänengrenze: Der einmalige Sheet-Import wird später denselben Vertrag erzeugen wie das bereits implementierte PostgreSQL-Repository. Die produktive API verwendet im aktuellen Übergangsstand weiterhin Sheets; der PostgreSQL-Reader ist nur intern und in Integrationstests erreichbar. Quelle, Owner-Zuordnung und SQL-Grenze legt [ADR 0013](../entscheidungen/0013-postgresql-als-finanzquelle.md) fest. ## Aktuelle Sheets- und Finance-Datenpipeline @@ -27,6 +27,24 @@ flowchart LR Implementierung und Tests: [api/_lib/google.ts](../../api/_lib/google.ts), [src/finance/parser.ts](../../src/finance/parser.ts), [src/finance/selectors.ts](../../src/finance/selectors.ts), [src/finance/viewModel.ts](../../src/finance/viewModel.ts), [src/finance/parser.test.ts](../../src/finance/parser.test.ts). +## PostgreSQL-Abbildung im Übergangsstand + +```mermaid +flowchart LR + S["verifizierte Session: Google sub"] --> O["owners.id intern auflösen"] + O --> T["READ ONLY / REPEATABLE READ"] + T --> R["Meta + alle Quellenzeilen und Snapshots"] + R --> V["Safe-Integer- und FinanceDataV1-Validierung"] + V --> C["FinanceDataV1"] + C --> SEL["dieselben reinen Selektoren"] +``` + +Die Tabellen `finance_meta`, `accounts`, `account_snapshots`, `pockets`, `pocket_snapshots`, `budget_items`, `debts`, `debt_snapshots`, `debt_milestones` und `relief_milestones` entsprechen den zehn v1-Quellbereichen. `owner_id` ist reine Persistenzinformation und wird nicht Teil des Domänenobjekts. Geld bleibt `BIGINT` in Cents; Meilensteine speichern Monats-/Tagespräzision separat und werden wieder als `YYYY-MM` beziehungsweise `YYYY-MM-DD` ausgegeben. + +Das Repository liest bewusst jeden gespeicherten Snapshot einschließlich alter und zukünftiger Werte. Es wählt keinen „aktuellen“ Stand und berechnet keine Summe. Erst die unveränderten Selektoren verwenden `snapshot.asOf <= data.asOf`. Damit bleibt die fachliche Auswahlgrenze identisch zum Sheets-Pfad. Die Datenbank erzwingt strukturelle Owner-/Fremdschlüsselintegrität; das Repository ergänzt Laufzeitvertrag und die Parserregel, dass aktive Accounts, Pockets und Debts einen passenden Snapshot benötigen. + +Vollständiger Tabellenvertrag: [Datenbankreferenz](../referenz/datenbank.md). Implementierung und echter Datenbanktest: [financeRepository.ts](../../api/_lib/financeRepository.ts), [financeRepository.postgres.test.ts](../../tests/postgres/financeRepository.postgres.test.ts). + ## Integer-Cents und Snapshots Ein Euro-Quellwert `x` wird als `sign(x) × round((abs(x) + Number.EPSILON) × 100)` normalisiert und muss ein sicherer JavaScript-Integer sein. Danach rechnen Selektoren ausschließlich in Cents. Ratenanzahlen bleiben separate Ganzzahlen. diff --git a/docs/architektur/tests-und-qualitaet.md b/docs/architektur/tests-und-qualitaet.md index 18ec6ea..bb1a07c 100644 --- a/docs/architektur/tests-und-qualitaet.md +++ b/docs/architektur/tests-und-qualitaet.md @@ -15,6 +15,7 @@ Keine einzelne Prüfung beweist das Produkt. Reine Unit-Tests decken fachliche R | Befehl | Inhalt | Netzwerk/Secrets | | --- | --- | --- | | `npm test` | Node-Tests für ESM und den Lizenzgenerator sowie Vitest-Dateien unter `src/` und `build/` | keine externen Dienste | +| `npm run test:postgres` | Migrationen 001/002, Finance-Constraints, Owner-Isolation, Reader und Selektorgrenze gegen echtes PostgreSQL | dedizierte `POSTGRES_TEST_URL`, nur synthetische Daten | | `npm run lint` | ESLint über das Repository | nein | | `npm run licenses:check` | installierten Produktionsgraph fail-closed gegen Policy und eingecheckte Drittanbieterhinweise prüfen | nein | | `npm run build` | TypeScript-Projektbuild und Vite/PWA-Produktionsbuild | nein | @@ -34,15 +35,16 @@ Unter `tests/visual/__screenshots__/chromium` liegen 38 Referenzbilder für 412, ## GitHub-CI -`.github/workflows/ci.yml` läuft für Pushes nach `master` und Pull Requests. Die Jobs prüfen Lint einschließlich `licenses:check`, Unit-Tests, Build und Smoke-Tests. Der lokale Dokumentationscheck wird bewusst nicht in diese Datei oder `npm test` aufgenommen. Externe Links sind aufgrund temporärer Netzfehler nie Release-Gate. +`.github/workflows/ci.yml` läuft für Pushes nach `master`/`develop` und Pull Requests. Die Jobs prüfen Lint einschließlich `licenses:check`, Unit-Tests, Build und Smoke-Tests. Ein separater Job startet PostgreSQL 17 als temporären Service und führt die dedizierte Suite mit synthetischen Daten aus. Der lokale Dokumentationscheck wird bewusst nicht in diese Datei oder `npm test` aufgenommen. Externe Links sind aufgrund temporärer Netzfehler nie Release-Gate. ## Fehlerfälle und Grenzen -Golden Screens hängen an Browser-/Fontdeterminismus; Updates dürfen nur nach bewusster visueller Prüfung committed werden. Axe findet nicht jede Barriere. Mock-Smokes beweisen keine echte Google- oder Vercel-Konfiguration. Reale OAuth-, Picker-, Sheets-, PostgreSQL- und Disconnect-Abläufe bleiben Betreiber-Abnahme. Desktop-Chromium beweist außerdem nicht die tatsächlich von Android gerenderten Installations-, Launcher-, Task-Switcher- und Splash-Flächen; ACC-7 deckt stattdessen deren Web-Verträge automatisiert ab. +Golden Screens hängen an Browser-/Fontdeterminismus; Updates dürfen nur nach bewusster visueller Prüfung committed werden. Axe findet nicht jede Barriere. Mock-Smokes beweisen keine echte Google- oder Vercel-Konfiguration. Die PostgreSQL-Suite beweist Standard-SQL, Constraints und Reader gegen eine echte temporäre Instanz, aber weder Neon-Region/Rollen/Restore noch produktive Daten. Reale OAuth-, Picker-, Sheets- und Disconnect-Abläufe bleiben Betreiber-Abnahme. Desktop-Chromium beweist außerdem nicht die tatsächlich von Android gerenderten Installations-, Launcher-, Task-Switcher- und Splash-Flächen; ACC-7 deckt stattdessen deren Web-Verträge automatisiert ab. ## Implementierung und Tests - CI: [.github/workflows/ci.yml](../../.github/workflows/ci.yml) - Unit-Konfiguration: [vite.config.ts](../../vite.config.ts) +- PostgreSQL-Konfiguration und Suite: [vitest.postgres.config.ts](../../vitest.postgres.config.ts), [tests/postgres](../../tests/postgres) - Smoke-Orchestrierung: [scripts/run-smoke-tests.mjs](../../scripts/run-smoke-tests.mjs) - Visual/Axe: [tests/visual/finance-ui.spec.ts](../../tests/visual/finance-ui.spec.ts) diff --git a/docs/entscheidungen/0013-postgresql-als-finanzquelle.md b/docs/entscheidungen/0013-postgresql-als-finanzquelle.md index 559ba8b..5c7ccae 100644 --- a/docs/entscheidungen/0013-postgresql-als-finanzquelle.md +++ b/docs/entscheidungen/0013-postgresql-als-finanzquelle.md @@ -61,6 +61,8 @@ Die interne Owner-ID entkoppelt Finanzdaten von einem externen Identitätsanbiet Google Sheets als dauerhafte Quelle beizubehalten würde Editor und Laufzeit weiter an Picker, Token und externe Verfügbarkeit binden. Ein dauerhafter Dual-Source-Betrieb erzeugt Konflikt- und Prioritätsregeln ohne Produktnutzen. JSON-Dokumente in einer einzelnen Spalte würden relationale Integrität und gezielte sichere Bearbeitung erschweren. Finanzlogik oder vorberechnete UI-Kennzahlen in SQL würden eine zweite Berechnungsquelle neben den getesteten Selektoren schaffen. Google `sub` direkt auf jede Finanzzeile zu schreiben wäre kurzfristig kleiner, koppelte die Daten aber unnötig an Google und erschwerte ACC-64. +Convex wurde ebenfalls geprüft. Sein dokumenten- und function-orientiertes Backend-Modell würde Auth-, Datenzugriffs- und Deploymentgrenzen stärker verändern als der benötigte Quellenwechsel. Vor allem kann der gewählte relationale Vertrag mit zusammengesetzten Owner-Fremdschlüsseln und von der Datenbank erzwungener referenzieller Integrität dort nicht unverändert abgebildet werden. Für ACC-71 überwiegen Portabilität des PostgreSQL-Schemas, bestehende Vercel-Integration und technisch erzwungene Owner-Beziehungen; deshalb wurde Convex verworfen. + ## Konsequenzen ### Positiv @@ -73,5 +75,6 @@ Schema, Migration, Repository und Schreibgrenzen müssen sorgfältig umgesetzt u ## Implementierung und Tests -- Aktueller, noch zu ersetzender Vertrag: [FinanceDataV1](../../src/finance/types.ts), [Sheets-Parser](../../src/finance/parser.ts), [PostgreSQL-Verbindungsrepository](../../api/_lib/repository.ts) -- Geplanter Nachweis: ACC-71, ACC-29, ACC-66 und ACC-72 in Linear +- Domänenvertrag und bestehender Produktionspfad: [FinanceDataV1](../../src/finance/types.ts), [Sheets-Parser](../../src/finance/parser.ts), [Finance-Service](../../api/_lib/financeService.ts) +- Umgesetzter ACC-71-Stand: [Migration 002](../../migrations/002_finance_data_v1.sql), [PostgreSQL-Reader](../../api/_lib/financeRepository.ts), [Integrationstest](../../tests/postgres/financeRepository.postgres.test.ts) +- Weitere Nachweise und Cutover: ACC-29, ACC-66 und ACC-72 in Linear diff --git a/docs/produkt/entwicklungsstand.md b/docs/produkt/entwicklungsstand.md index 7a1d0e6..a9a1972 100644 --- a/docs/produkt/entwicklungsstand.md +++ b/docs/produkt/entwicklungsstand.md @@ -13,13 +13,14 @@ - Kanonische URLs für alle vier Hauptansichten mit Deep Links, Browser-/PWA-History, sicherer OAuth-Rückkehr und gezielter PWA-Kaltstart-Wiederherstellung. - Google OAuth mit State, Nonce und PKCE; Picker mit `drive.file`; serverseitige Drive-/Sheets-Zugriffe; verschlüsselte Refresh-Tokens in PostgreSQL. - Finance Data Schema v1 mit zehn Maschinen-Tabs, Laufzeitvalidierung, Integer-Cents, Fremdschlüsseln, Snapshot-Auswahl, `salary_day` und `due_day`. +- Ownergebundenes PostgreSQL-v1-Schema mit zusammengesetzten Fremdschlüsseln, gemeinsamem Lazy-Pool und internem `READ ONLY`-/`REPEATABLE READ`-Reader zurück zum unveränderten `FinanceDataV1`. Der produktive `/api/finance`-Pfad bleibt bis ACC-66 auf Sheets. - Last-known-good-Cache in IndexedDB, Offline-App-Shell, getesteter leerer Offline-Start und Netzrückkehr, manuelle und ereignisgesteuerte Datenaktualisierung sowie Race-Schutz. - Kontrollierter PWA-Versionswechsel mit verständlichem „Jetzt neu laden“/„Später“-Hinweis, stabilem Installationsmanifest und automatisierten Android-orientierten Icon-/Systemfarben-Verträgen. - Appearance mit Systemmodus, Hell/Dunkel, Browser-Akzent, neun Presets, lokaler Bildanalyse im Worker und lokaler WebP-Vorschau. - Lokaler Privacy-Modus einschließlich Tabsynchronisierung und Maskierung von sichtbaren sowie zugänglichen Geldtexten. - Optionaler App-Vorschau-Schutz und lokaler sechsstelliger PIN-Lock mit Android-orientiertem, thematisiertem Lockscreen, Expressive-PIN-Formen, Fehlversuchs-Wartezeit und fail-closed Recovery. - Wiederverwendbare MD3-Komponenten, Responsive/Reflow, Reduced Motion, Forced Colors, Fokusmanagement und lokale Google-Sans-Flex-Schrift. -- GitHub-CI für Lint, Unit-Tests, Build und Smoke; aktuell 179 Vitest-Tests plus ein Node-ESM-Test sowie PWA-, Offline-, Golden- und Axe-Prüfungen. +- GitHub-CI für Lint, Unit-Tests, echte PostgreSQL-Integrationstests, Build und Smoke; aktuell 237 normale Vitest-Tests, zehn dedizierte PostgreSQL-Fälle und elf Node-Tests sowie PWA-, Offline-, Golden- und Axe-Prüfungen. ## Historische Meilensteine @@ -29,7 +30,7 @@ Die genaue Commit-Historie bleibt in Git; diese Seite ist kein tägliches Journa ## Bekannte Abdeckungslücken -Reale Produktionsabläufe mit persönlichen externen Diensten können im Repository nicht automatisiert bewiesen werden und benötigen eine Eigentümer-Abnahme. Androids tatsächlich gerenderter Installationsdialog, Launcher, Splash und App-Switcher liegen ebenfalls außerhalb der gewählten Desktop-Chromium-Automation; Manifest, Installierbarkeit, Icon-Pixelverträge, Worker-Update und der Web-Lockscreen sind automatisiert abgedeckt. +Reale Produktionsabläufe mit persönlichen externen Diensten können im Repository nicht automatisiert bewiesen werden und benötigen eine Eigentümer-Abnahme. Für den PostgreSQL-Cutover sind insbesondere reale Neon-/Vercel-Region, eingeschränkte Runtime-Rolle, Restore-Fenster und ein praktischer synthetischer Restore vor ACC-66 noch als Betriebsaufgaben offen. Androids tatsächlich gerenderter Installationsdialog, Launcher, Splash und App-Switcher liegen ebenfalls außerhalb der gewählten Desktop-Chromium-Automation; Manifest, Installierbarkeit, Icon-Pixelverträge, Worker-Update und der Web-Lockscreen sind automatisiert abgedeckt. ## Nachweis diff --git a/docs/referenz/datenbank.md b/docs/referenz/datenbank.md index 3f7a1d5..7938177 100644 --- a/docs/referenz/datenbank.md +++ b/docs/referenz/datenbank.md @@ -1,12 +1,57 @@ # Datenbankreferenz > **Zielgruppe:** Betreiber und Backend-Entwickler. -> **Zweck und Lernziel:** PostgreSQL-Schema, gespeicherte Daten und Lebenszyklus der Google-Verbindung verstehen. +> **Zweck und Lernziel:** PostgreSQL-Schema, Owner-Isolation, Constraints und Betriebsgrenzen verstehen. > **Voraussetzungen:** PostgreSQL-Grundkenntnisse und [Backend und Sicherheit](../architektur/backend-und-sicherheit.md) -> **Kanonisch für:** Tabelle `google_connections` und deren Datenvertrag. -> **Verwandte Dokumente:** [Produktions-Setup](../anleitungen/produktions-setup.md), [Konfiguration](konfiguration.md) +> **Kanonisch für:** Migrationen 001/002, `google_connections` und das ownergebundene Finance-v1-Schema. +> **Verwandte Dokumente:** [Produktions-Setup](../anleitungen/produktions-setup.md), [Finance Data Schema v1](finance-data-schema-v1.md) -`accura` besitzt genau eine Migration und speichert keine Finanzzeilen in PostgreSQL. Die Tabelle hält die serverseitige Google-Verbindung und optionale Referenz auf das gewählte Sheet. +`accura` besitzt zwei transaktionale Migrationen. [001_google_connections.sql](../../migrations/001_google_connections.sql) speichert die Google-Verbindung. [002_finance_data_v1.sql](../../migrations/002_finance_data_v1.sql) bildet sämtliche Quellenfelder aus `FinanceDataV1` relational ab. Das Finance-Repository ist implementiert und getestet, `/api/finance` liest bis zum späteren Cutover aber weiterhin Google Sheets. + +## Owner-Modell + +`owners` trennt externe Identität und interne Datenzuordnung: + +| Spalte | Typ | Null? | Vertrag | +| --- | --- | --- | --- | +| `id` | `UUID` | nein | Primärschlüssel, Default `gen_random_uuid()` | +| `google_sub` | `TEXT` | nein | eindeutig, nach Trimmung nicht leer | +| `created_at` | `TIMESTAMPTZ` | nein | Default `NOW()` | + +Es besteht absichtlich kein Foreign Key zu `google_connections`. Disconnect darf Finanzdaten nicht löschen. In ACC-71 erzeugt OAuth keinen Owner; erst der kontrollierte Import aus ACC-66 legt den produktiven Datensatz an. Der Reader nimmt ausschließlich Google `sub` aus der verifizierten Sitzung entgegen, löst intern `owners.id` auf und verwendet danach nur diese UUID. + +Jede Finance-Tabelle besitzt ein nicht-nullbares `owner_id`. Fachliche Primär- und Fremdschlüssel enthalten den Owner, beispielsweise `(owner_id, id)` und `(owner_id, account_id)`. Gleiche fachliche IDs bei zwei Ownern sind damit erlaubt, eine Referenz über Ownergrenzen wird von PostgreSQL abgewiesen. Foreign Keys verwenden das Standardverhalten `NO ACTION`; es gibt keine stillen Lösch-Cascades. + +## Gemeinsame Finance-Constraints + +- Geld und Anzahlen werden als `BIGINT` gespeichert. Geld muss zwischen `-9007199254740991` und `9007199254740991` liegen; Anzahlen zusätzlich bei null oder höher. +- `display_order` ist ein sicherer Integer, darf negativ sein und muss nicht eindeutig sein. +- Fachliche IDs entsprechen lowercase-kebab-case. Namen, Labels und Ereignistexte sind nach Trimmung nicht leer; optionale Notizen sind `NULL` oder nach Trimmung nicht leer. +- `salary_day` und `due_day` sind `NULL` oder liegen zwischen 1 und 31. +- Enums sind `TEXT` plus `CHECK`, keine PostgreSQL-Enums. Negative Finanzbeträge bleiben erlaubt. +- Kalendertage sind `DATE`; nur echte Erzeugungs-/Änderungszeitpunkte sind `TIMESTAMPTZ`. +- Es werden weder aktuelle Stände noch Summen, `safeToSpend` oder andere Ableitungen gespeichert. + +## Finance-Tabellen + +| Tabelle | Spalten neben `owner_id` | Schlüssel und Beziehungen | +| --- | --- | --- | +| `finance_meta` | `schema_version`, `as_of`, `currency`, `monthly_income_cents`, `salary_day` | PK `owner_id`; FK zu `owners`; exakt Schema 1 und Währung `EUR` | +| `accounts` | `id`, `name`, `kind`, `display_order`, `active` | PK `(owner_id, id)`; `kind`: `bank`, `wallet`, `cash` | +| `account_snapshots` | `account_id`, `as_of`, `balance_cents` | PK `(owner_id, account_id, as_of)`; zusammengesetzter FK zu `accounts` | +| `pockets` | `id`, `account_id`, `name`, `display_order`, `active` | PK `(owner_id, id)`; zusammengesetzter FK `(owner_id, account_id)` zu `accounts` | +| `pocket_snapshots` | `pocket_id`, `as_of`, `balance_cents` | PK `(owner_id, pocket_id, as_of)`; zusammengesetzter FK zu `pockets` | +| `budget_items` | `id`, `label`, `monthly_amount_cents`, `necessity_id`, `kind`, `display_order`, `active`, `note`, `due_day` | PK `(owner_id, id)`; fünf Necessity-Werte; `kind`: `expense`, `reserve` | +| `debts` | `id`, `name`, `kind`, `monthly_payment_cents`, `display_order`, `active`, `note`, `due_day` | PK `(owner_id, id)`; `kind`: `loan`, `installment` | +| `debt_snapshots` | `debt_id`, `as_of`, `payoff_balance_cents`, `remaining_payment_count`, `remaining_scheduled_total_cents` | PK `(owner_id, debt_id, as_of)`; zusammengesetzter FK zu `debts` | +| `debt_milestones` | `debt_id`, `milestone_date`, `date_precision`, `balance_cents` | PK `(owner_id, debt_id, milestone_date, date_precision)`; zusammengesetzter FK zu `debts` | +| `relief_milestones` | interne `id`, `milestone_date`, `date_precision`, `monthly_relief_cents`, `event`, `event_detail` | PK `(owner_id, id)`; FK zu `owners`; fachlich gleiche Ereignisse bleiben erlaubt | + +Die fünf gültigen `necessity_id`-Werte sind `essential`, `necessary`, `worthwhile`, `optional` und `unnecessary`. Reihenfolge-Indizes auf Accounts, Pockets, Budgetpositionen und Schulden unterstützen den deterministischen Reader, begründen aber keine fachliche Eindeutigkeit. + +## Meilensteinpräzision + +Debt- und Relief-Meilensteine speichern neben `milestone_date` eine `date_precision` mit `month` oder `day`. Bei `month` erzwingt ein Check den Monatsersten. Der Reader rekonstruiert daraus ohne Zeitzonenkonvertierung exakt `YYYY-MM` beziehungsweise `YYYY-MM-DD`. Die interne UUID eines Relief-Meilensteins verlässt die Persistenz nicht und erlaubt doppelte fachliche Ereignisse. ## Tabelle `google_connections` @@ -16,32 +61,26 @@ | `verified_email` | `TEXT` | nein | beim ID-Token verifizierte, normalisierte E-Mail | | `encrypted_refresh_token` | `TEXT` | nein | versioniertes AES-256-GCM-Chiffrat, kein Klartext | | `granted_scopes` | `TEXT[]` | nein | beim OAuth-Tausch gemeldete Scopes | -| `spreadsheet_id` | `TEXT` | ja | ausgewählte und geprüfte Drive-Datei | -| `spreadsheet_name` | `TEXT` | ja | zum Auswahlzeitpunkt gelesener Dateiname | -| `created_at` | `TIMESTAMPTZ` | nein | Erzeugung, Default `NOW()` | -| `updated_at` | `TIMESTAMPTZ` | nein | letzte allgemeine Änderung | -| `token_updated_at` | `TIMESTAMPTZ` | nein | letzte Authorization-Aktualisierung | +| `spreadsheet_id`, `spreadsheet_name` | `TEXT` | ja | gemeinsam gesetzte oder gemeinsam leere Auswahl | +| `created_at`, `updated_at`, `token_updated_at` | `TIMESTAMPTZ` | nein | Lebenszykluszeitpunkte | | `spreadsheet_updated_at` | `TIMESTAMPTZ` | ja | letzte Auswahländerung | -Ein Check Constraint erzwingt, dass `spreadsheet_id` und `spreadsheet_name` entweder beide `NULL` oder beide gesetzt sind. Zusätzlich verhindert ein eindeutiger Index auf `LOWER(verified_email)` mehrere Verbindungen derselben Adresse. `google_sub` bleibt der technische Schlüssel. +Ein Check hält Sheet-ID und -Name vollständig; ein eindeutiger Index auf `LOWER(verified_email)` verhindert mehrere Verbindungen derselben Adresse. OAuth schreibt Authorization-Daten, die Picker-Prüfung aktualisiert die Auswahl, Disconnect löscht ausschließlich diese Zeile. -## Schreibpfade +## Reader und Integritätsgrenze -- OAuth-Callback führt `INSERT … ON CONFLICT (google_sub) DO UPDATE` für E-Mail, Token und Scopes aus; eine bestehende Tabellenauswahl bleibt erhalten. -- Erfolgreiche Picker-/Schemaprüfung aktualisiert ID, Name und Zeitstempel. -- Disconnect löscht die Zeile. Logout ändert die Datenbank nicht. -- Finance-Reads sind nur `SELECT`; Tabellenwerte selbst kommen direkt aus Google Sheets. +Der Reader läuft in einer `READ ONLY, REPEATABLE READ`-Transaktion und liest sämtliche Snapshots. `BIGINT`-Strings werden explizit geparst und erneut als sichere JavaScript-Integer geprüft; `DATE` wird in SQL als Text formatiert. Das rekonstruierte Objekt muss das Laufzeitschema erfüllen. Zusätzlich braucht jede aktive Account-, Pocket- und Debt-Zeile mindestens einen Snapshot am oder vor `finance_meta.as_of`. -## Migration und Backup +Fehlender Owner oder fehlendes `finance_meta` ergibt `null`, keinen erfundenen Leerstand. Interne Integritätsfehler enthalten weder Datenbankzeilen noch IDs oder Finanzwerte. Snapshot-Auswahl und Berechnungen bleiben in den TypeScript-Selektoren. -Die Migration [001_google_connections.sql](../../migrations/001_google_connections.sql) läuft in einer Transaktion und nutzt `IF NOT EXISTS`. Sie muss je Environment bewusst angewandt werden. Backups enthalten sensible verschlüsselte Token und sind wie Secrets zu behandeln. Wiederherstellung benötigt denselben `TOKEN_ENCRYPTION_KEY`; ohne ihn ist erneutes OAuth erforderlich. +## Migration, Rollen und Backup -## Grenzen +Migrationen werden über einen direkten administrativen PostgreSQL-Endpunkt bewusst zuerst in Development, später in Production ausgeführt. Die Vercel Runtime verwendet dagegen die gepoolte `DATABASE_URL` und einen eingeschränkten Runtime-Benutzer. Neon ist der aktuelle Betreiber, aber keine Neon-Funktion ist Teil des Schemas; ein anderer PostgreSQL-Anbieter kann denselben Vertrag ausführen. -Das Schema ist auf Single-User-Betrieb ausgerichtet, auch wenn die Tabelle technisch mehrere Subs aufnehmen könnte. Anwendung und Allowlist erlauben genau eine E-Mail. Es gibt keine Finance-Historie, Audit-Events oder Mandanten-ID in PostgreSQL. +Backups enthalten verschlüsselte Google-Tokens und künftig hochsensible Finanzzeilen. Vor ACC-66 müssen Restore-Fenster, Rollen, Region und ein praktischer Restore-Test mit synthetischen Daten geklärt sein. Details stehen im [Produktions-Setup](../anleitungen/produktions-setup.md#2-postgresql-und-neon-betrieb). ## Implementierung und Tests -- Migration: [migrations/001_google_connections.sql](../../migrations/001_google_connections.sql) -- Repository: [api/_lib/repository.ts](../../api/_lib/repository.ts) -- Server-Service-Tests: [src/server/financeService.test.ts](../../src/server/financeService.test.ts) +- Pool und Repositories: [database.ts](../../api/_lib/database.ts), [repository.ts](../../api/_lib/repository.ts), [financeRepository.ts](../../api/_lib/financeRepository.ts) +- Migrationen: [001](../../migrations/001_google_connections.sql), [002](../../migrations/002_finance_data_v1.sql) +- Echte PostgreSQL-Suite: [financeRepository.postgres.test.ts](../../tests/postgres/financeRepository.postgres.test.ts) diff --git a/docs/referenz/konfiguration.md b/docs/referenz/konfiguration.md index 1a69bab..e58b7f2 100644 --- a/docs/referenz/konfiguration.md +++ b/docs/referenz/konfiguration.md @@ -35,6 +35,8 @@ Alle Variablen sind serverseitig erforderlich. `VERCEL_ENV=production` oder `NOD | `VITE_VERCEL_GIT_REPO_SLUG` | öffentlicher, von Vercel bereitgestellter Repository-Name für den versionsgebundenen Source-Link | | `VITE_VERCEL_GIT_COMMIT_SHA` | öffentlicher, vollständiger Vercel-Deployment-Commit für den versionsgebundenen Source-Link | +`POSTGRES_TEST_URL` ist eine ausschließlich für `npm run test:postgres` gelesene Testprozess-Variable. Sie muss auf eine dedizierte temporäre oder lokale PostgreSQL-Datenbank mit Schema-Erzeugungsrecht zeigen, ist kein Vercel-Runtime-Wert und darf niemals eine Production-URL enthalten. Ohne sie bricht die dedizierte Suite absichtlich ab; `npm test` benötigt sie nicht. + Jede `VITE_`-Variable wird grundsätzlich als browseröffentlich behandelt. Niemals Secret, Token, Datenbank-URL oder persönliche Finanzdaten mit diesem Präfix setzen. Vercels automatische Systemvariablen müssen für das Projekt aktiviert bleiben, damit Preview-Builds eindeutig erkannt werden. Explizite `ACCURA_SOURCE_*`-Overrides haben Vorrang vor den Vercel-Git-Werten. Ohne beide Quellen verwendet ein lokaler Build `git rev-parse HEAD`. Ein Produktionsbuild ohne gültigen vollständigen SHA bricht ab; ausschließlich der Dev-Server darf auf `master` zurückfallen. Repository-URL, vollständiger SHA, Kurz-SHA und daraus abgeleitete Rechtslinks werden als öffentliche Konstanten in das Browser-Bundle eingebettet und enthalten keine Geheimnisse. diff --git a/docs/referenz/quellcode-karte.md b/docs/referenz/quellcode-karte.md index 47311e2..0dfb978 100644 --- a/docs/referenz/quellcode-karte.md +++ b/docs/referenz/quellcode-karte.md @@ -23,9 +23,10 @@ | `src/mocks/` | ausschließlich anonyme Entwicklungsdaten und Mock-API | | `build/` | geprüfte Buildzeit-Auflösung für Source-Link und Preview-Modus | | `api/` | Vercel Function Entry Points | -| `api/_lib/` | Konfiguration, HTTP, Security, Google, Repository, Finance-Service | -| `migrations/` | PostgreSQL-Migrationen | +| `api/_lib/` | Konfiguration, HTTP, Security, Google, gemeinsamer PostgreSQL-Pool, Connection-/Finance-Repositories und Sheets-Finance-Service | +| `migrations/` | transaktionale PostgreSQL-Migrationen für Google-Verbindung und ownergebundenes Finance-v1-Schema | | `scripts/` | Node-ESM-, Browser-, Offline- und Service-Worker-Smokes sowie Docs-Check | +| `tests/postgres/` | echte, synthetische PostgreSQL-Migrations-, Constraint- und Finance-Reader-Tests | | `tests/visual/` | Playwright Golden-/Axe-Spezifikation und Referenzbilder | | `public/` | Icons und statische PWA-Assets | | `vercel.json` | SPA-Deep-Link-Rewrite unter explizitem Ausschluss von `/api` | @@ -36,6 +37,8 @@ Für einen Geldwert beginnt die Spur in einem Header aus [src/finance/schema.ts](../../src/finance/schema.ts), läuft über [src/finance/parser.ts](../../src/finance/parser.ts) in einen Cent-Typ aus [src/finance/types.ts](../../src/finance/types.ts), wird in [src/finance/selectors.ts](../../src/finance/selectors.ts) gewählt/aggregiert, in [src/finance/viewModel.ts](../../src/finance/viewModel.ts) präsentationsfertig und über [src/data/FinanceDataProvider.tsx](../../src/data/FinanceDataProvider.tsx) an einen Screen gereicht. [src/components/MoneyValue.tsx](../../src/components/MoneyValue.tsx) formatiert und maskiert den Wert. +Der noch nicht produktiv angeschlossene PostgreSQL-Pfad beginnt bei der verifizierten Google-Subjekt-ID, löst den internen Owner in [financeRepository.ts](../../api/_lib/financeRepository.ts) auf, liest die Tabellen aus [Migration 002](../../migrations/002_finance_data_v1.sql) und endet ebenfalls am unveränderten `FinanceDataV1`. Beide Repositories teilen [database.ts](../../api/_lib/database.ts); `/api/finance` verwendet bis ACC-66 weiterhin ausschließlich den Sheets-Service. + ## Änderungshinweise - Tabellenvertrag: zuerst Schema-Referenz, Typen, Parser/Laufzeitschema und Tests gemeinsam prüfen. diff --git a/migrations/002_finance_data_v1.sql b/migrations/002_finance_data_v1.sql new file mode 100644 index 0000000..b6b88b9 --- /dev/null +++ b/migrations/002_finance_data_v1.sql @@ -0,0 +1,215 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS owners ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + google_sub TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT owners_google_sub_not_blank CHECK (BTRIM(google_sub) <> '') +); + +CREATE TABLE IF NOT EXISTS finance_meta ( + owner_id UUID PRIMARY KEY, + schema_version SMALLINT NOT NULL, + as_of DATE NOT NULL, + currency TEXT NOT NULL, + monthly_income_cents BIGINT NOT NULL, + salary_day SMALLINT, + CONSTRAINT finance_meta_owner_fk FOREIGN KEY (owner_id) REFERENCES owners (id), + CONSTRAINT finance_meta_schema_version_v1 CHECK (schema_version = 1), + CONSTRAINT finance_meta_currency_eur CHECK (currency = 'EUR'), + CONSTRAINT finance_meta_monthly_income_safe CHECK ( + monthly_income_cents BETWEEN -9007199254740991 AND 9007199254740991 + ), + CONSTRAINT finance_meta_salary_day_valid CHECK (salary_day IS NULL OR salary_day BETWEEN 1 AND 31) +); + +CREATE TABLE IF NOT EXISTS accounts ( + owner_id UUID NOT NULL, + id TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL, + display_order BIGINT NOT NULL, + active BOOLEAN NOT NULL, + PRIMARY KEY (owner_id, id), + CONSTRAINT accounts_owner_fk FOREIGN KEY (owner_id) REFERENCES owners (id), + CONSTRAINT accounts_id_kebab_case CHECK (id ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + CONSTRAINT accounts_name_not_blank CHECK (BTRIM(name) <> ''), + CONSTRAINT accounts_kind_valid CHECK (kind IN ('bank', 'wallet', 'cash')), + CONSTRAINT accounts_display_order_safe CHECK ( + display_order BETWEEN -9007199254740991 AND 9007199254740991 + ) +); + +CREATE TABLE IF NOT EXISTS account_snapshots ( + owner_id UUID NOT NULL, + account_id TEXT NOT NULL, + as_of DATE NOT NULL, + balance_cents BIGINT NOT NULL, + PRIMARY KEY (owner_id, account_id, as_of), + CONSTRAINT account_snapshots_account_fk + FOREIGN KEY (owner_id, account_id) REFERENCES accounts (owner_id, id), + CONSTRAINT account_snapshots_balance_safe CHECK ( + balance_cents BETWEEN -9007199254740991 AND 9007199254740991 + ) +); + +CREATE TABLE IF NOT EXISTS pockets ( + owner_id UUID NOT NULL, + id TEXT NOT NULL, + account_id TEXT NOT NULL, + name TEXT NOT NULL, + display_order BIGINT NOT NULL, + active BOOLEAN NOT NULL, + PRIMARY KEY (owner_id, id), + CONSTRAINT pockets_owner_fk FOREIGN KEY (owner_id) REFERENCES owners (id), + CONSTRAINT pockets_account_fk + FOREIGN KEY (owner_id, account_id) REFERENCES accounts (owner_id, id), + CONSTRAINT pockets_id_kebab_case CHECK (id ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + CONSTRAINT pockets_account_id_kebab_case CHECK (account_id ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + CONSTRAINT pockets_name_not_blank CHECK (BTRIM(name) <> ''), + CONSTRAINT pockets_display_order_safe CHECK ( + display_order BETWEEN -9007199254740991 AND 9007199254740991 + ) +); + +CREATE TABLE IF NOT EXISTS pocket_snapshots ( + owner_id UUID NOT NULL, + pocket_id TEXT NOT NULL, + as_of DATE NOT NULL, + balance_cents BIGINT NOT NULL, + PRIMARY KEY (owner_id, pocket_id, as_of), + CONSTRAINT pocket_snapshots_pocket_fk + FOREIGN KEY (owner_id, pocket_id) REFERENCES pockets (owner_id, id), + CONSTRAINT pocket_snapshots_balance_safe CHECK ( + balance_cents BETWEEN -9007199254740991 AND 9007199254740991 + ) +); + +CREATE TABLE IF NOT EXISTS budget_items ( + owner_id UUID NOT NULL, + id TEXT NOT NULL, + label TEXT NOT NULL, + monthly_amount_cents BIGINT NOT NULL, + necessity_id TEXT NOT NULL, + kind TEXT NOT NULL, + display_order BIGINT NOT NULL, + active BOOLEAN NOT NULL, + note TEXT, + due_day SMALLINT, + PRIMARY KEY (owner_id, id), + CONSTRAINT budget_items_owner_fk FOREIGN KEY (owner_id) REFERENCES owners (id), + CONSTRAINT budget_items_id_kebab_case CHECK (id ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + CONSTRAINT budget_items_label_not_blank CHECK (BTRIM(label) <> ''), + CONSTRAINT budget_items_amount_safe CHECK ( + monthly_amount_cents BETWEEN -9007199254740991 AND 9007199254740991 + ), + CONSTRAINT budget_items_necessity_valid CHECK ( + necessity_id IN ('essential', 'necessary', 'worthwhile', 'optional', 'unnecessary') + ), + CONSTRAINT budget_items_kind_valid CHECK (kind IN ('expense', 'reserve')), + CONSTRAINT budget_items_display_order_safe CHECK ( + display_order BETWEEN -9007199254740991 AND 9007199254740991 + ), + CONSTRAINT budget_items_note_not_blank CHECK (note IS NULL OR BTRIM(note) <> ''), + CONSTRAINT budget_items_due_day_valid CHECK (due_day IS NULL OR due_day BETWEEN 1 AND 31) +); + +CREATE TABLE IF NOT EXISTS debts ( + owner_id UUID NOT NULL, + id TEXT NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL, + monthly_payment_cents BIGINT NOT NULL, + display_order BIGINT NOT NULL, + active BOOLEAN NOT NULL, + note TEXT, + due_day SMALLINT, + PRIMARY KEY (owner_id, id), + CONSTRAINT debts_owner_fk FOREIGN KEY (owner_id) REFERENCES owners (id), + CONSTRAINT debts_id_kebab_case CHECK (id ~ '^[a-z0-9]+(-[a-z0-9]+)*$'), + CONSTRAINT debts_name_not_blank CHECK (BTRIM(name) <> ''), + CONSTRAINT debts_kind_valid CHECK (kind IN ('loan', 'installment')), + CONSTRAINT debts_monthly_payment_safe CHECK ( + monthly_payment_cents BETWEEN -9007199254740991 AND 9007199254740991 + ), + CONSTRAINT debts_display_order_safe CHECK ( + display_order BETWEEN -9007199254740991 AND 9007199254740991 + ), + CONSTRAINT debts_note_not_blank CHECK (note IS NULL OR BTRIM(note) <> ''), + CONSTRAINT debts_due_day_valid CHECK (due_day IS NULL OR due_day BETWEEN 1 AND 31) +); + +CREATE TABLE IF NOT EXISTS debt_snapshots ( + owner_id UUID NOT NULL, + debt_id TEXT NOT NULL, + as_of DATE NOT NULL, + payoff_balance_cents BIGINT NOT NULL, + remaining_payment_count BIGINT NOT NULL, + remaining_scheduled_total_cents BIGINT NOT NULL, + PRIMARY KEY (owner_id, debt_id, as_of), + CONSTRAINT debt_snapshots_debt_fk + FOREIGN KEY (owner_id, debt_id) REFERENCES debts (owner_id, id), + CONSTRAINT debt_snapshots_payoff_balance_safe CHECK ( + payoff_balance_cents BETWEEN -9007199254740991 AND 9007199254740991 + ), + CONSTRAINT debt_snapshots_payment_count_safe CHECK ( + remaining_payment_count BETWEEN 0 AND 9007199254740991 + ), + CONSTRAINT debt_snapshots_scheduled_total_safe CHECK ( + remaining_scheduled_total_cents BETWEEN -9007199254740991 AND 9007199254740991 + ) +); + +CREATE TABLE IF NOT EXISTS debt_milestones ( + owner_id UUID NOT NULL, + debt_id TEXT NOT NULL, + milestone_date DATE NOT NULL, + date_precision TEXT NOT NULL, + balance_cents BIGINT NOT NULL, + PRIMARY KEY (owner_id, debt_id, milestone_date, date_precision), + CONSTRAINT debt_milestones_debt_fk + FOREIGN KEY (owner_id, debt_id) REFERENCES debts (owner_id, id), + CONSTRAINT debt_milestones_precision_valid CHECK (date_precision IN ('month', 'day')), + CONSTRAINT debt_milestones_month_first CHECK ( + date_precision <> 'month' OR EXTRACT(DAY FROM milestone_date) = 1 + ), + CONSTRAINT debt_milestones_balance_safe CHECK ( + balance_cents BETWEEN -9007199254740991 AND 9007199254740991 + ) +); + +CREATE TABLE IF NOT EXISTS relief_milestones ( + owner_id UUID NOT NULL, + id UUID NOT NULL DEFAULT gen_random_uuid(), + milestone_date DATE NOT NULL, + date_precision TEXT NOT NULL, + monthly_relief_cents BIGINT NOT NULL, + event TEXT NOT NULL, + event_detail TEXT, + PRIMARY KEY (owner_id, id), + CONSTRAINT relief_milestones_owner_fk FOREIGN KEY (owner_id) REFERENCES owners (id), + CONSTRAINT relief_milestones_precision_valid CHECK (date_precision IN ('month', 'day')), + CONSTRAINT relief_milestones_month_first CHECK ( + date_precision <> 'month' OR EXTRACT(DAY FROM milestone_date) = 1 + ), + CONSTRAINT relief_milestones_amount_safe CHECK ( + monthly_relief_cents BETWEEN -9007199254740991 AND 9007199254740991 + ), + CONSTRAINT relief_milestones_event_not_blank CHECK (BTRIM(event) <> ''), + CONSTRAINT relief_milestones_detail_not_blank CHECK ( + event_detail IS NULL OR BTRIM(event_detail) <> '' + ) +); + +CREATE INDEX IF NOT EXISTS accounts_owner_order_idx + ON accounts (owner_id, display_order, id); +CREATE INDEX IF NOT EXISTS pockets_owner_order_idx + ON pockets (owner_id, display_order, id); +CREATE INDEX IF NOT EXISTS budget_items_owner_order_idx + ON budget_items (owner_id, display_order, id); +CREATE INDEX IF NOT EXISTS debts_owner_order_idx + ON debts (owner_id, display_order, id); +CREATE INDEX IF NOT EXISTS relief_milestones_owner_order_idx + ON relief_milestones (owner_id, milestone_date, event, event_detail, id); + +COMMIT; diff --git a/package.json b/package.json index d96f8f4..a977e57 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "smoke:browser": "node scripts/browser-smoke.mjs", "smoke:offline": "node scripts/offline-smoke.mjs", "test": "npm run test:server-esm && vitest run", + "test:postgres": "vitest run --config vitest.postgres.config.ts", "test:server-esm": "node --test scripts/*.test.mjs", "test:visual": "playwright test tests/visual", "test:visual:update": "playwright test tests/visual --update-snapshots", diff --git a/tests/postgres/financeRepository.postgres.test.ts b/tests/postgres/financeRepository.postgres.test.ts new file mode 100644 index 0000000..2d042ff --- /dev/null +++ b/tests/postgres/financeRepository.postgres.test.ts @@ -0,0 +1,420 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import postgres from 'postgres'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + FinanceDataIntegrityError, + PostgresFinanceRepository, +} from '../../api/_lib/financeRepository'; +import { financeDataV1Schema } from '../../src/finance/runtime'; +import { parseSheetsBatchResponse } from '../../src/finance/parser'; +import { selectLatestAccountSnapshot } from '../../src/finance/selectors'; +import { anonymousSheetsResponse } from '../../src/mocks/anonymousWorkbook'; +import type { FinanceDataV1 } from '../../src/finance/types'; + +const databaseUrl = process.env.POSTGRES_TEST_URL; +if (!databaseUrl) { + throw new Error('POSTGRES_TEST_URL is required for the PostgreSQL integration suite.'); +} + +const parsedFixture = parseSheetsBatchResponse(anonymousSheetsResponse); +if (!parsedFixture.success) throw new Error('Anonymous finance fixture must be valid.'); +const fixture = parsedFixture.data; + +const schema = `accura_test_${randomUUID().replaceAll('-', '')}`; +const adminSql = postgres(databaseUrl, { max: 1, prepare: false, onnotice: () => undefined }); +const sql = postgres(databaseUrl, { + max: 1, + prepare: false, + connection: { search_path: schema }, +}); +const repository = new PostgresFinanceRepository(sql); +let schemaCreated = false; + +const financeTables = [ + 'finance_meta', + 'accounts', + 'account_snapshots', + 'pockets', + 'pocket_snapshots', + 'budget_items', + 'debts', + 'debt_snapshots', + 'debt_milestones', + 'relief_milestones', +] as const; + +beforeAll(async () => { + await adminSql`CREATE SCHEMA ${adminSql(schema)}`; + schemaCreated = true; + const migration001 = await readFile(new URL('../../migrations/001_google_connections.sql', import.meta.url), 'utf8'); + const migration002 = await readFile(new URL('../../migrations/002_finance_data_v1.sql', import.meta.url), 'utf8'); + await sql.unsafe(migration001); + await sql.unsafe(migration002); +}); + +beforeEach(async () => { + await sql.unsafe(`TRUNCATE TABLE ${[ + ...financeTables, + 'owners', + 'google_connections', + ].join(', ')}`); +}); + +afterAll(async () => { + await sql.end({ timeout: 5 }); + if (schemaCreated) await adminSql`DROP SCHEMA ${adminSql(schema)} CASCADE`; + await adminSql.end({ timeout: 5 }); +}); + +describe.sequential('Finance PostgreSQL migration and repository', () => { + it('applies migrations 001 and 002 with non-null owner isolation on every finance table', async () => { + const tables = await sql<{ table_name: string }[]>` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = ${schema} + ORDER BY table_name + `; + expect(tables.map(({ table_name }) => table_name)).toEqual(expect.arrayContaining([ + 'google_connections', + 'owners', + ...financeTables, + ])); + + const ownerColumns = await sql<{ table_name: string; is_nullable: string }[]>` + SELECT table_name, is_nullable + FROM information_schema.columns + WHERE table_schema = ${schema} + AND column_name = 'owner_id' + AND table_name IN ${sql(financeTables)} + ORDER BY table_name + `; + expect(ownerColumns).toHaveLength(financeTables.length); + expect(ownerColumns.every(({ is_nullable }) => is_nullable === 'NO')).toBe(true); + }); + + it('reconstructs the complete normalized anonymous fixture as runtime-valid FinanceDataV1', async () => { + const ownerId = await createOwner('fixture-owner'); + await insertFinanceData(ownerId, fixture); + + const result = await repository.readForGoogleSub('fixture-owner'); + + expect(financeDataV1Schema.safeParse(result).success).toBe(true); + expect(result).toEqual(inRepositoryOrder(fixture)); + expect(result?.monthlyIncomeCents).toBe(259_132); + expect(result?.budgetItems.some(({ note, dueDay }) => note === null && dueDay === null)).toBe(true); + expect(result?.accounts.some(({ active }) => !active)).toBe(true); + expect(result?.accountSnapshots).toContainEqual({ + accountId: 'daily-account', + asOf: '2026-07-31', + balanceCents: 110_000, + }); + }); + + it('preserves negative amounts and distinguishes month from day milestone precision', async () => { + const ownerId = await createOwner('precision-owner'); + await insertFinanceData(ownerId, fixture); + await sql` + INSERT INTO account_snapshots (owner_id, account_id, as_of, balance_cents) + VALUES (${ownerId}, 'old-account', '2026-08-01', -12345) + `; + await sql` + INSERT INTO debt_milestones (owner_id, debt_id, milestone_date, date_precision, balance_cents) + VALUES (${ownerId}, 'primary-loan', '2026-08-01', 'day', -99) + `; + await sql` + INSERT INTO relief_milestones ( + owner_id, milestone_date, date_precision, monthly_relief_cents, event, event_detail + ) VALUES (${ownerId}, '2026-09-01', 'day', -17, 'Tagesereignis', NULL) + `; + + const result = await repository.readForGoogleSub('precision-owner'); + + expect(result?.accountSnapshots).toContainEqual({ + accountId: 'old-account', + asOf: '2026-08-01', + balanceCents: -12_345, + }); + expect(result?.debtMilestones).toEqual(expect.arrayContaining([ + { debtId: 'primary-loan', date: '2026-08', balanceCents: 1_234_567 }, + { debtId: 'primary-loan', date: '2026-08-01', balanceCents: -99 }, + ])); + expect(result?.reliefMilestones).toEqual(expect.arrayContaining([ + { date: '2026-09', monthlyReliefCents: 12_000, event: 'Finanzierung A', eventDetail: 'Letzte Rate' }, + { date: '2026-09-01', monthlyReliefCents: -17, event: 'Tagesereignis', eventDetail: null }, + ])); + }); + + it('returns null when the owner or finance_meta row is absent', async () => { + await createOwner('owner-without-meta'); + + await expect(repository.readForGoogleSub('unknown-owner')).resolves.toBeNull(); + await expect(repository.readForGoogleSub('owner-without-meta')).resolves.toBeNull(); + }); + + it('allows equal domain IDs for two owners and never returns the other owner rows', async () => { + const ownerA = await createOwner('owner-a'); + const ownerB = await createOwner('owner-b'); + await insertFinanceData(ownerA, fixture); + await insertFinanceData(ownerB, { + ...fixture, + monthlyIncomeCents: -777, + accounts: fixture.accounts.map((account) => account.id === 'daily-account' + ? { ...account, name: 'Owner B Konto' } + : account), + }); + + const resultA = await repository.readForGoogleSub('owner-a'); + const resultB = await repository.readForGoogleSub('owner-b'); + + expect(resultA?.monthlyIncomeCents).toBe(fixture.monthlyIncomeCents); + expect(resultA?.accounts.find(({ id }) => id === 'daily-account')?.name).toBe('Alltagskonto'); + expect(resultB?.monthlyIncomeCents).toBe(-777); + expect(resultB?.accounts.find(({ id }) => id === 'daily-account')?.name).toBe('Owner B Konto'); + }); + + it('rejects cross-owner references in PostgreSQL', async () => { + const ownerA = await createOwner('reference-owner-a'); + const ownerB = await createOwner('reference-owner-b'); + await sql` + INSERT INTO accounts (owner_id, id, name, kind, display_order, active) + VALUES (${ownerA}, 'shared-account', 'A', 'bank', 1, TRUE) + `; + + await expect(sql` + INSERT INTO pockets (owner_id, id, account_id, name, display_order, active) + VALUES (${ownerB}, 'foreign-pocket', 'shared-account', 'Fremd', 1, TRUE) + `).rejects.toMatchObject({ code: '23503' }); + }); + + it('rejects duplicate domain keys', async () => { + const ownerId = await createOwner('duplicate-owner'); + await sql` + INSERT INTO accounts (owner_id, id, name, kind, display_order, active) + VALUES (${ownerId}, 'duplicate-account', 'Erstes Konto', 'bank', 1, TRUE) + `; + + await expect(sql` + INSERT INTO accounts (owner_id, id, name, kind, display_order, active) + VALUES (${ownerId}, 'duplicate-account', 'Zweites Konto', 'cash', 2, TRUE) + `).rejects.toMatchObject({ code: '23505' }); + }); + + it('rejects invalid due days, enums, blank optional text, and unsafe integers', async () => { + const ownerId = await createOwner('constraint-owner'); + + await expect(sql` + INSERT INTO budget_items ( + owner_id, id, label, monthly_amount_cents, necessity_id, kind, + display_order, active, note, due_day + ) VALUES (${ownerId}, 'bad-day', 'Tag', 1, 'essential', 'expense', 1, TRUE, NULL, 32) + `).rejects.toMatchObject({ code: '23514' }); + await expect(sql` + INSERT INTO accounts (owner_id, id, name, kind, display_order, active) + VALUES (${ownerId}, 'bad-kind', 'Konto', 'crypto', 1, TRUE) + `).rejects.toMatchObject({ code: '23514' }); + await expect(sql` + INSERT INTO debts ( + owner_id, id, name, kind, monthly_payment_cents, display_order, active, note, due_day + ) VALUES (${ownerId}, 'blank-note', 'Schuld', 'loan', 1, 1, TRUE, ' ', NULL) + `).rejects.toMatchObject({ code: '23514' }); + await expect(sql` + INSERT INTO finance_meta ( + owner_id, schema_version, as_of, currency, monthly_income_cents, salary_day + ) VALUES (${ownerId}, 1, '2026-08-08', 'EUR', 9007199254740992, NULL) + `).rejects.toMatchObject({ code: '23514' }); + }); + + it('returns all historical and future snapshots while selectors keep choosing the domain-current row', async () => { + const ownerId = await createOwner('snapshot-owner'); + await insertFinanceData(ownerId, fixture); + await sql` + INSERT INTO account_snapshots (owner_id, account_id, as_of, balance_cents) + VALUES + (${ownerId}, 'daily-account', '2025-01-01', 1), + (${ownerId}, 'daily-account', '2027-01-01', 999999) + `; + + const result = await repository.readForGoogleSub('snapshot-owner'); + expect(result?.accountSnapshots.filter(({ accountId }) => accountId === 'daily-account')).toHaveLength(4); + expect(selectLatestAccountSnapshot(result!, 'daily-account')).toEqual({ + accountId: 'daily-account', + asOf: '2026-08-08', + balanceCents: 120_025, + }); + }); + + it('reports active entities without a current snapshot as a sanitized integrity error', async () => { + const ownerId = await createOwner('broken-owner'); + await insertMeta(ownerId, fixture); + await sql` + INSERT INTO accounts (owner_id, id, name, kind, display_order, active) + VALUES (${ownerId}, 'sensitive-account-id', 'Geheimer Kontoname', 'bank', 1, TRUE) + `; + + let caught: unknown; + try { + await repository.readForGoogleSub('broken-owner'); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(FinanceDataIntegrityError); + expect(caught).toMatchObject({ + code: 'finance_data_integrity_error', + reason: 'missing_current_snapshot', + message: 'Stored finance data failed integrity validation.', + }); + const serialized = JSON.stringify(caught); + expect(serialized).not.toContain('sensitive-account-id'); + expect(serialized).not.toContain('Geheimer Kontoname'); + expect(serialized).not.toContain(String(fixture.monthlyIncomeCents)); + }); +}); + +async function createOwner(googleSub: string): Promise { + const rows = await sql<{ id: string }[]>` + INSERT INTO owners (google_sub) + VALUES (${googleSub}) + RETURNING id + `; + return rows[0]!.id; +} + +async function insertMeta(ownerId: string, data: FinanceDataV1): Promise { + await sql` + INSERT INTO finance_meta ( + owner_id, schema_version, as_of, currency, monthly_income_cents, salary_day + ) VALUES ( + ${ownerId}, ${data.schemaVersion}, ${data.asOf}, ${data.currency}, + ${data.monthlyIncomeCents}, ${data.salaryDay} + ) + `; +} + +async function insertFinanceData(ownerId: string, data: FinanceDataV1): Promise { + await insertMeta(ownerId, data); + for (const account of data.accounts) { + await sql` + INSERT INTO accounts (owner_id, id, name, kind, display_order, active) + VALUES ( + ${ownerId}, ${account.id}, ${account.name}, ${account.kind}, + ${account.displayOrder}, ${account.active} + ) + `; + } + for (const snapshot of data.accountSnapshots) { + await sql` + INSERT INTO account_snapshots (owner_id, account_id, as_of, balance_cents) + VALUES (${ownerId}, ${snapshot.accountId}, ${snapshot.asOf}, ${snapshot.balanceCents}) + `; + } + for (const pocket of data.pockets) { + await sql` + INSERT INTO pockets (owner_id, id, account_id, name, display_order, active) + VALUES ( + ${ownerId}, ${pocket.id}, ${pocket.accountId}, ${pocket.name}, + ${pocket.displayOrder}, ${pocket.active} + ) + `; + } + for (const snapshot of data.pocketSnapshots) { + await sql` + INSERT INTO pocket_snapshots (owner_id, pocket_id, as_of, balance_cents) + VALUES (${ownerId}, ${snapshot.pocketId}, ${snapshot.asOf}, ${snapshot.balanceCents}) + `; + } + for (const item of data.budgetItems) { + await sql` + INSERT INTO budget_items ( + owner_id, id, label, monthly_amount_cents, necessity_id, kind, + display_order, active, note, due_day + ) VALUES ( + ${ownerId}, ${item.id}, ${item.label}, ${item.monthlyAmountCents}, + ${item.necessityId}, ${item.kind}, ${item.displayOrder}, ${item.active}, + ${item.note}, ${item.dueDay} + ) + `; + } + for (const debt of data.debts) { + await sql` + INSERT INTO debts ( + owner_id, id, name, kind, monthly_payment_cents, display_order, + active, note, due_day + ) VALUES ( + ${ownerId}, ${debt.id}, ${debt.name}, ${debt.kind}, + ${debt.monthlyPaymentCents}, ${debt.displayOrder}, ${debt.active}, + ${debt.note}, ${debt.dueDay} + ) + `; + } + for (const snapshot of data.debtSnapshots) { + await sql` + INSERT INTO debt_snapshots ( + owner_id, debt_id, as_of, payoff_balance_cents, + remaining_payment_count, remaining_scheduled_total_cents + ) VALUES ( + ${ownerId}, ${snapshot.debtId}, ${snapshot.asOf}, ${snapshot.payoffBalanceCents}, + ${snapshot.remainingPaymentCount}, ${snapshot.remainingScheduledTotalCents} + ) + `; + } + for (const milestone of data.debtMilestones) { + const precision = milestone.date.length === 7 ? 'month' : 'day'; + const date = precision === 'month' ? `${milestone.date}-01` : milestone.date; + await sql` + INSERT INTO debt_milestones ( + owner_id, debt_id, milestone_date, date_precision, balance_cents + ) VALUES (${ownerId}, ${milestone.debtId}, ${date}, ${precision}, ${milestone.balanceCents}) + `; + } + for (const milestone of data.reliefMilestones) { + const precision = milestone.date.length === 7 ? 'month' : 'day'; + const date = precision === 'month' ? `${milestone.date}-01` : milestone.date; + await sql` + INSERT INTO relief_milestones ( + owner_id, milestone_date, date_precision, monthly_relief_cents, event, event_detail + ) VALUES ( + ${ownerId}, ${date}, ${precision}, ${milestone.monthlyReliefCents}, + ${milestone.event}, ${milestone.eventDetail} + ) + `; + } +} + +function inRepositoryOrder(data: FinanceDataV1): FinanceDataV1 { + const result = structuredClone(data); + const compareText = (left: string, right: string) => left < right ? -1 : left > right ? 1 : 0; + const compareEntities = (left: { displayOrder: number; id: string }, right: { displayOrder: number; id: string }) => + left.displayOrder - right.displayOrder || compareText(left.id, right.id); + result.accounts.sort(compareEntities); + result.pockets.sort(compareEntities); + result.budgetItems.sort(compareEntities); + result.debts.sort(compareEntities); + + const accountOrder = new Map(result.accounts.map((account, index) => [account.id, index])); + const pocketOrder = new Map(result.pockets.map((pocket, index) => [pocket.id, index])); + const debtOrder = new Map(result.debts.map((debt, index) => [debt.id, index])); + result.accountSnapshots.sort((left, right) => + accountOrder.get(left.accountId)! - accountOrder.get(right.accountId)! + || compareText(left.accountId, right.accountId) + || compareText(left.asOf, right.asOf)); + result.pocketSnapshots.sort((left, right) => + pocketOrder.get(left.pocketId)! - pocketOrder.get(right.pocketId)! + || compareText(left.pocketId, right.pocketId) + || compareText(left.asOf, right.asOf)); + result.debtSnapshots.sort((left, right) => + debtOrder.get(left.debtId)! - debtOrder.get(right.debtId)! + || compareText(left.debtId, right.debtId) + || compareText(left.asOf, right.asOf)); + result.debtMilestones.sort((left, right) => + compareText(`${left.date}-01`.slice(0, 10), `${right.date}-01`.slice(0, 10)) + || debtOrder.get(left.debtId)! - debtOrder.get(right.debtId)! + || compareText(left.debtId, right.debtId) + || compareText(left.date.length === 7 ? 'month' : 'day', right.date.length === 7 ? 'month' : 'day')); + result.reliefMilestones.sort((left, right) => + compareText(`${left.date}-01`.slice(0, 10), `${right.date}-01`.slice(0, 10)) + || compareText(left.event, right.event) + || compareText(left.eventDetail ?? '', right.eventDetail ?? '')); + return result; +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 375d2f8..1dd79a1 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -10,5 +10,5 @@ "noEmit": true, "allowImportingTsExtensions": true }, - "include": ["vite.config.ts", "eslint.config.js", "build/**/*.ts"] + "include": ["vite.config.ts", "vitest.postgres.config.ts", "eslint.config.js", "build/**/*.ts"] } diff --git a/vitest.postgres.config.ts b/vitest.postgres.config.ts new file mode 100644 index 0000000..2ad3dbe --- /dev/null +++ b/vitest.postgres.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/postgres/**/*.test.ts'], + fileParallelism: false, + }, +});