Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architektur/finanz-domaene.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Die Tabellen `finance_meta`, `accounts`, `account_snapshots`, `pockets`, `pocket

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).
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). Dieselbe Suite hält Cents, `salaryDay`/`dueDay` und die Selektor-Auswahl `selectLatest*Snapshot` der anonymen Fixture zwischen Parser und Reader fest.

## Integer-Cents und Snapshots

Expand Down
2 changes: 1 addition & 1 deletion docs/architektur/tests-und-qualitaet.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +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 test:postgres` | Migrationen 001/002, Finance-Constraints, Owner-Isolation, Reader, Selektorgrenze und ACC-29-Parität (Cents, Fälligkeiten, Snapshot-Auswahl) 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 |
Expand Down
5 changes: 3 additions & 2 deletions docs/entscheidungen/0013-postgresql-als-finanzquelle.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Diese ADR legt das verbindliche Zielbild fest, beschreibt aber nicht den bereits
Die Umsetzung erfolgt in klaren Schritten:

1. ACC-71 erstellt Schema und Repository-Lesepfad, ohne die fachliche Grenze zu ändern.
2. ACC-29 weist mit derselben anonymen Fixture identische Cents, Fälligkeiten und Snapshot-Auswahl für Parser- und PostgreSQL-Pfad nach.
2. ACC-29 weist mit derselben anonymen Fixture identische Cents, Fälligkeiten und Snapshot-Auswahl für Parser- und PostgreSQL-Pfad nach. Dieser Nachweis ist umgesetzt.
3. ACC-66 importiert den privaten Bestand vollständig, prüft die Parität und schaltet die einzige produktive Quelle auf PostgreSQL um.
4. ACC-72 baut anschließend den eng begrenzten In-App-Editor.

Expand Down Expand Up @@ -77,4 +77,5 @@ Schema, Migration, Repository und Schreibgrenzen müssen sorgfältig umgesetzt u

- 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
- Umgesetzter ACC-29-Nachweis: dieselbe Suite vergleicht Cents, `salaryDay`/`dueDay` und `selectLatest*Snapshot` von Parser- und PostgreSQL-Pfad an der anonymen Fixture
- Weitere Nachweise und Cutover: ACC-66 und ACC-72 in Linear
4 changes: 2 additions & 2 deletions docs/produkt/entwicklungsstand.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +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.
- Ownergebundenes PostgreSQL-v1-Schema mit zusammengesetzten Fremdschlüsseln, gemeinsamem Lazy-Pool und internem `READ ONLY`-/`REPEATABLE READ`-Reader zurück zum unveränderten `FinanceDataV1`. Parser- und PostgreSQL-Lesepfad liefern für die anonyme Fixture dieselben Cents, Fälligkeitstage und Snapshot-Auswahl. 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, 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.
- GitHub-CI für Lint, Unit-Tests, echte PostgreSQL-Integrationstests, Build und Smoke; aktuell 237 normale Vitest-Tests, 13 dedizierte PostgreSQL-Fälle und elf Node-Tests sowie PWA-, Offline-, Golden- und Axe-Prüfungen.

## Historische Meilensteine

Expand Down
1 change: 0 additions & 1 deletion docs/produkt/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ Die Roadmap ist eine Absichtserklärung, kein Funktionsversprechen. „Now“ be

- Architektur für den Quellenwechsel verbindlich festlegen und die sheetgebundenen ADRs ersetzen.
- Das heutige `FinanceDataV1` mit internem `owner_id` in PostgreSQL abbilden und ownergebunden wieder als denselben Vertrag lesen.
- Sheet-Parser und PostgreSQL-Lesepfad mit derselben anonymen Fixture auf identische Cents, Fälligkeiten und Snapshot-Auswahl prüfen.
- Den bestehenden privaten Datenstand einmalig importieren und danach eindeutig auf PostgreSQL als einzige produktive Quelle umschalten.
- Einen eng begrenzten In-App-Editor für Stände, Beträge, Fälligkeiten und Aktivstatus bauen.

Expand Down
138 changes: 136 additions & 2 deletions tests/postgres/financeRepository.postgres.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
} from '../../api/_lib/financeRepository';
import { financeDataV1Schema } from '../../src/finance/runtime';
import { parseSheetsBatchResponse } from '../../src/finance/parser';
import { selectLatestAccountSnapshot } from '../../src/finance/selectors';
import {
selectLatestAccountSnapshot,
selectLatestDebtSnapshot,
selectLatestPocketSnapshot,
} from '../../src/finance/selectors';
import { anonymousSheetsResponse } from '../../src/mocks/anonymousWorkbook';
import type { FinanceDataV1 } from '../../src/finance/types';

Expand Down Expand Up @@ -270,6 +274,67 @@ describe.sequential('Finance PostgreSQL migration and repository', () => {
expect(serialized).not.toContain('Geheimer Kontoname');
expect(serialized).not.toContain(String(fixture.monthlyIncomeCents));
});

it('delivers identical cents from the parser and the postgres reader for the anonymous fixture', async () => {
const ownerId = await createOwner('parity-cents-owner');
await insertFinanceData(ownerId, fixture);

const fromParser = fixture;
const fromPostgres = requireStoredFinanceData(await repository.readForGoogleSub('parity-cents-owner'));

const parserCents = moneyCentsByStableKey(fromParser);
const postgresCents = moneyCentsByStableKey(fromPostgres);

expect(parserCents).toHaveLength(ANONYMOUS_FIXTURE_MONEY_FIELD_COUNT);
expect(parserCents).toContainEqual(['meta.monthlyIncomeCents', 259_132]);
expect(postgresCents).toEqual(parserCents);
});

it('selects the same current snapshots from parser and postgres data for the anonymous fixture', async () => {
const ownerId = await createOwner('parity-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)
`;
await sql`
INSERT INTO pocket_snapshots (owner_id, pocket_id, as_of, balance_cents)
VALUES
(${ownerId}, 'home-reserve', '2025-01-01', 1),
(${ownerId}, 'home-reserve', '2027-01-01', 999999)
`;
await sql`
INSERT INTO debt_snapshots (
owner_id, debt_id, as_of, payoff_balance_cents,
remaining_payment_count, remaining_scheduled_total_cents
) VALUES
(${ownerId}, 'primary-loan', '2025-01-01', 1, 1, 1),
(${ownerId}, 'primary-loan', '2027-01-01', 999999, 1, 1)
`;

const fromPostgres = requireStoredFinanceData(await repository.readForGoogleSub('parity-snapshot-owner'));
expect(fromPostgres.accountSnapshots.length).toBeGreaterThan(fixture.accountSnapshots.length);
expect(fromPostgres.pocketSnapshots.length).toBeGreaterThan(fixture.pocketSnapshots.length);
expect(fromPostgres.debtSnapshots.length).toBeGreaterThan(fixture.debtSnapshots.length);

expect(selectedSnapshotsById(fromPostgres)).toEqual(selectedSnapshotsById(fixture));
});

it('delivers identical salary and due days from the parser and the postgres reader', async () => {
const ownerId = await createOwner('parity-due-day-owner');
await insertFinanceData(ownerId, fixture);

const fromPostgres = requireStoredFinanceData(await repository.readForGoogleSub('parity-due-day-owner'));

const parserDays = dueDaysByStableKey(fixture);
const postgresDays = dueDaysByStableKey(fromPostgres);

expect(parserDays['meta.salaryDay']).toBe(25);
expect(Object.values(parserDays).some((day) => day === null)).toBe(true);
expect(postgresDays).toEqual(parserDays);
});
});

async function createOwner(googleSub: string): Promise<string> {
Expand Down Expand Up @@ -382,9 +447,78 @@ async function insertFinanceData(ownerId: string, data: FinanceDataV1): Promise<
}
}

const ANONYMOUS_FIXTURE_MONEY_FIELD_COUNT = 53;

function requireStoredFinanceData(data: FinanceDataV1 | null): FinanceDataV1 {
if (!data) throw new Error('Expected stored FinanceDataV1 from the postgres reader.');
return data;
}

function moneyCentsByStableKey(data: FinanceDataV1): Array<[string, number]> {
const entries: Array<[string, number]> = [
['meta.monthlyIncomeCents', data.monthlyIncomeCents],
...data.accountSnapshots.map((row) => [
`accountSnapshots.${row.accountId}.${row.asOf}`,
row.balanceCents,
] as [string, number]),
...data.pocketSnapshots.map((row) => [
`pocketSnapshots.${row.pocketId}.${row.asOf}`,
row.balanceCents,
] as [string, number]),
...data.budgetItems.map((row) => [
`budgetItems.${row.id}.monthlyAmountCents`,
row.monthlyAmountCents,
] as [string, number]),
...data.debts.map((row) => [
`debts.${row.id}.monthlyPaymentCents`,
row.monthlyPaymentCents,
] as [string, number]),
...data.debtSnapshots.flatMap((row) => [
[`debtSnapshots.${row.debtId}.${row.asOf}.payoffBalanceCents`, row.payoffBalanceCents],
[`debtSnapshots.${row.debtId}.${row.asOf}.remainingScheduledTotalCents`, row.remainingScheduledTotalCents],
] as Array<[string, number]>),
...data.debtMilestones.map((row) => [
`debtMilestones.${row.debtId}.${row.date}`,
row.balanceCents,
] as [string, number]),
...data.reliefMilestones.map((row) => [
`reliefMilestones.${row.date}.${row.event}.${row.eventDetail ?? ''}`,
row.monthlyReliefCents,
] as [string, number]),
];
return entries.sort(([leftKey, leftValue], [rightKey, rightValue]) =>
compareText(leftKey, rightKey) || leftValue - rightValue);
}

function dueDaysByStableKey(data: FinanceDataV1): Record<string, number | null> {
const entries: Array<[string, number | null]> = [
['meta.salaryDay', data.salaryDay],
...data.budgetItems.map((row) => [`budgetItems.${row.id}.dueDay`, row.dueDay] as [string, number | null]),
...data.debts.map((row) => [`debts.${row.id}.dueDay`, row.dueDay] as [string, number | null]),
];
return Object.fromEntries(entries.sort(([left], [right]) => compareText(left, right)));
}

function selectedSnapshotsById(data: FinanceDataV1) {
return {
accounts: Object.fromEntries(
data.accounts.map((account) => [account.id, selectLatestAccountSnapshot(data, account.id) ?? null]),
),
pockets: Object.fromEntries(
data.pockets.map((pocket) => [pocket.id, selectLatestPocketSnapshot(data, pocket.id) ?? null]),
),
debts: Object.fromEntries(
data.debts.map((debt) => [debt.id, selectLatestDebtSnapshot(data, debt.id) ?? null]),
),
};
}

function compareText(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}

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);
Expand Down
Loading