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
7 changes: 2 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,13 @@
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 identity (openid email profile). No Picker, Drive or Sheets runtime access.
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
# Generate an independent value as documented in docs/anleitungen/produktions-setup.md
SESSION_SECRET=replace-with-at-least-32-random-bytes
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Accura schafft finanzielle Klarheit für Menschen, die unter engem Budget, Schulden oder mentaler Überforderung leiden. Statt Vermögen zu optimieren, zeigt die App, was wirklich verfügbar ist, was als Nächstes fällig wird und wie sich finanzielle Belastungen entwickeln. Der [Produktüberblick](docs/produkt/ueberblick.md) beschreibt dieses Versprechen und seine bewussten Grenzen.

Heute ist `accura` eine private, deutschsprachige Finanzübersicht als installierbare Web-App (PWA). Genau eine freigegebene Person verbindet eine selbst kontrollierte Google-Tabelle. Die App liest und validiert die Daten, verändert die Tabelle aber nicht. Ein zuletzt erfolgreich geladener Datenstand bleibt lokal offline verfügbar.
Heute ist `accura` eine private, deutschsprachige Finanzübersicht als installierbare Web-App (PWA). Genau eine freigegebene Person meldet sich mit Google an. Der Finanzstand liegt in PostgreSQL. Ein zuletzt erfolgreich geladener Datenstand bleibt lokal offline verfügbar.

Die vier Ansichten zeigen verfügbare Mittel, anstehende Zahlungen bis zum nächsten Gehalt, Monatsbudget und Schuldenverlauf. Ein lokaler Privacy-Modus maskiert sichtbare Geldbeträge; er ist ausdrücklich keine Verschlüsselung.

Expand All @@ -26,7 +26,7 @@ npm install
npm run dev:mock
```

Der Mock-Modus verwendet ausschließlich anonyme Repository-Daten. Für Google OAuth, Picker, Sheets, PostgreSQL und Vercel Functions gilt die [Produktions-Setup-Anleitung](docs/anleitungen/produktions-setup.md).
Der Mock-Modus verwendet ausschließlich anonyme Repository-Daten. Für Google OAuth, PostgreSQL und Vercel Functions gilt die [Produktions-Setup-Anleitung](docs/anleitungen/produktions-setup.md).

Der Integrationsstand auf `develop` ist unter [accura-preview.kiumu.app](https://accura-preview.kiumu.app/) mit derselben anonymen, bereits angemeldeten Mock-Sitzung verfügbar. Pull Requests zielen standardmäßig auf `develop`; `master` bleibt der bewusst freizugebende Produktionsstand.

Expand Down
8 changes: 0 additions & 8 deletions api/_lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,9 @@ export type ServerConfig = {
appOrigin: string;
googleClientId: string;
googleClientSecret: string;
googleApiKey: string;
googleCloudProjectNumber: string;
googleOAuthRedirectUri: string;
allowedGoogleEmail: string;
databaseUrl: string;
tokenEncryptionKey: string;
sessionSecret: string;
production: boolean;
};
Expand All @@ -38,20 +35,15 @@ export function getServerConfig(env: NodeJS.ProcessEnv = process.env): ServerCon
}
const allowedGoogleEmail = required('ALLOWED_GOOGLE_EMAIL', env).toLowerCase();
if (!z.string().email().safeParse(allowedGoogleEmail).success) throw new Error('ALLOWED_GOOGLE_EMAIL must be an email address.');
const projectNumber = required('GOOGLE_CLOUD_PROJECT_NUMBER', env);
if (!/^\d+$/.test(projectNumber)) throw new Error('GOOGLE_CLOUD_PROJECT_NUMBER must be numeric.');
const sessionSecret = required('SESSION_SECRET', env);
if (Buffer.byteLength(sessionSecret, 'utf8') < 32) throw new Error('SESSION_SECRET must contain at least 32 bytes.');
return {
appOrigin,
googleClientId: required('GOOGLE_CLIENT_ID', env),
googleClientSecret: required('GOOGLE_CLIENT_SECRET', env),
googleApiKey: required('GOOGLE_API_KEY', env),
googleCloudProjectNumber: projectNumber,
googleOAuthRedirectUri: redirectUri,
allowedGoogleEmail,
databaseUrl: required('DATABASE_URL', env),
tokenEncryptionKey: required('TOKEN_ENCRYPTION_KEY', env),
sessionSecret,
production: env.VERCEL_ENV === 'production' || env.NODE_ENV === 'production',
};
Expand Down
8 changes: 8 additions & 0 deletions api/_lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@ export function getDatabase(databaseUrl: string): postgres.Sql {
databases.set(databaseUrl, sql);
return sql;
}

/** Closes and forgets a pool used by a one-off process such as the operator import. */
export async function closeDatabase(databaseUrl: string): Promise<void> {
const sql = databases.get(databaseUrl);
if (!sql) return;
databases.delete(databaseUrl);
await sql.end({ timeout: 5 });
}
7 changes: 0 additions & 7 deletions api/_lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,6 @@ export class AppError extends Error {
}
}

export class ReconnectRequiredError extends AppError {
constructor() {
super('reconnect_required', 401, 'Die Google-Verbindung muss erneut autorisiert werden.');
this.name = 'ReconnectRequiredError';
}
}

export const publicError = (error: unknown) => {
if (error instanceof AppError) {
return { status: error.status, body: { error: { code: error.code, message: error.message, details: error.details } } };
Expand Down
86 changes: 86 additions & 0 deletions api/_lib/financeImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { parseSheetsBatchResponse } from '../../src/finance/parser.ts';
import { financeDataV1Schema } from '../../src/finance/runtime.ts';
import { z } from 'zod';
import type { FinanceDataV1, FinanceValidationResult } from '../../src/finance/types.ts';

const sheetsBatchResponseSchema = z.object({
spreadsheetId: z.string().min(1).optional(),
valueRanges: z.array(z.object({
range: z.string().min(1).optional(),
values: z.array(z.array(z.unknown())).optional(),
}).passthrough()),
}).passthrough();

/** Parses operator JSON without allowing V8 to echo source fragments in an error message. */
export function parseFinanceImportJson(contents: string): unknown {
try {
return JSON.parse(contents) as unknown;
} catch {
throw new Error('Die Importdatei ist kein gültiges JSON.');
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** Accepts a Sheets batchGet payload or an already normalized FinanceDataV1 object. */
export function parseFinanceImportSource(raw: unknown): FinanceValidationResult {
if (!isRecord(raw)) {
return {
success: false,
issues: [{
tab: '_Meta',
row: 1,
column: '(file)',
expected: 'JSON-Objekt',
message: 'Die Importdatei muss ein JSON-Objekt sein.',
}],
};
}
if ('valueRanges' in raw || 'spreadsheetId' in raw) {
const sheetsResponse = sheetsBatchResponseSchema.safeParse(raw);
if (!sheetsResponse.success) {
return {
success: false,
issues: [{
tab: '_Meta',
row: 1,
column: '(file)',
expected: 'vollständige Sheets-batchGet-Antwort',
message: 'Die Importdatei enthält keine gültige Sheets-batchGet-Antwort.',
}],
};
}
return parseSheetsBatchResponse(sheetsResponse.data);
}
const parsed = financeDataV1Schema.safeParse(raw);
if (parsed.success) return { success: true, data: parsed.data };
Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Import validation misses database constraints

When the documented normalized FinanceDataV1 input contains duplicate composite-key records, whitespace-only constrained text, or a regex-shaped invalid calendar date, this validation accepts it and PostgreSQL rejects the replacement, causing the operator import to fail instead of returning a validation error.

return {
success: false,
issues: [{
tab: '_Meta',
row: 1,
column: '(file)',
expected: 'FinanceDataV1 oder Sheets-batchGet',
message: 'Die Importdatei entspricht weder Finance Data Schema v1 noch einer Sheets-batchGet-Antwort.',
}],
};
}

export function financeImportFingerprint(data: FinanceDataV1) {
return {
asOf: data.asOf,
currency: data.currency,
salaryDay: data.salaryDay,
accounts: data.accounts.length,
accountSnapshots: data.accountSnapshots.length,
pockets: data.pockets.length,
pocketSnapshots: data.pocketSnapshots.length,
budgetItems: data.budgetItems.length,
debts: data.debts.length,
debtSnapshots: data.debtSnapshots.length,
debtMilestones: data.debtMilestones.length,
reliefMilestones: data.reliefMilestones.length,
};
}
Loading
Loading