diff --git a/scripts/setup-finanzen.mjs b/scripts/setup-finanzen.mjs new file mode 100644 index 0000000..b52e027 --- /dev/null +++ b/scripts/setup-finanzen.mjs @@ -0,0 +1,273 @@ +#!/usr/bin/env node +/** + * Finanzen: legt die Collections bankConnections (Bankzugang Finom ueber Enable Banking), bankTransactions + * (Buchungen), financeSettings (Einstellungen) und financeFixedCosts (Fixkosten) in woms-database an. + * Idempotent: existierende Collection/Attribute/Indexe werden uebersprungen. + * + * Optional uebernimmt das Skript eine bereits bestehende Enable-Banking-Sitzung (einmalig beim Livegang): + * --session Sitzungs-ID (wird nie ausgegeben) + * --account Konto-UID aus der Sitzung + * --valid-until Ende der Freigabe (ISO-Zeitpunkt) + * Dann wird bankConnections/finom mit status "aktiv" angelegt bzw. aktualisiert und financeSettings/main + * mit den Standardwerten angelegt, falls es fehlt. Ohne diese Argumente werden nur die Collections angelegt. + * + * Usage (im Container): + * docker exec -w /app webklar-kundenbereich-server node scripts/setup-finanzen.mjs \ + * --session --account --valid-until + */ +import { parseArgs } from 'node:util' + +// Argumente zuerst pruefen und Fehler ohne den Wert melden: parseArgs nennt bei Tippfehlern sonst den +// falschen Wert (etwa eine Sitzungs-ID ohne Option) im Klartext. +let args +try { + args = parseArgs({ + options: { + session: { type: 'string' }, + account: { type: 'string' }, + 'valid-until': { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }).values +} catch { + console.error('Ungueltige Argumente. Erlaubt: --session --account --valid-until ') + process.exit(1) +} + +const takeover = Object.values(args).some((v) => v !== undefined) +let takeoverData = null +if (takeover) { + const session = String(args.session ?? '').trim() + const account = String(args.account ?? '').trim() + const validUntil = new Date(String(args['valid-until'] ?? '').trim()) + if (!session || !account || !args['valid-until']) { + console.error('Zur Uebernahme sind --session, --account und --valid-until zusammen noetig') + process.exit(1) + } + if (session.length > 64 || account.length > 64) { + console.error('--session und --account duerfen hoechstens 64 Zeichen lang sein') + process.exit(1) + } + if (Number.isNaN(validUntil.getTime())) { + console.error('--valid-until ist kein gueltiger ISO-Zeitpunkt') + process.exit(1) + } + takeoverData = { session, account, validUntil: validUntil.toISOString() } +} + +const endpoint = (process.env.APPWRITE_ENDPOINT || 'https://ticket.webklar.com/v1').replace(/\/$/, '') +const projectId = process.env.APPWRITE_PROJECT_ID || '6a1058610003c5a13a05' +const apiKey = process.env.APPWRITE_API_KEY +const databaseId = process.env.APPWRITE_DATABASE_ID || 'woms-database' + +if (!apiKey) { + console.error('APPWRITE_API_KEY erforderlich') + process.exit(1) +} + +async function aw(path, { method = 'GET', body } = {}) { + const response = await fetch(`${endpoint}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + 'X-Appwrite-Key': apiKey, + }, + body: body ? JSON.stringify(body) : undefined, + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const err = new Error(data.message || `Appwrite ${response.status}`) + err.status = response.status + throw err + } + return data +} + +async function ensure(label, fn) { + try { + await fn() + console.log(`+ ${label}`) + } catch (e) { + if (e.status === 409 || /already exists/i.test(e.message)) { + console.log(`= ${label} (existiert)`) + } else { + throw new Error(`${label}: ${e.message}`) + } + } +} + +async function waitForAttributes(collectionId, keys, maxSec = 60) { + // Ohne Limit liefert Appwrite nur 25 Attribute -> spaetere Attribute waeren nie "verfuegbar" + const limit = encodeURIComponent(JSON.stringify({ method: 'limit', values: [100] })) + for (let i = 0; i < maxSec; i += 1) { + const { attributes } = await aw(`/databases/${databaseId}/collections/${collectionId}/attributes?queries[]=${limit}`) + const pending = keys.filter((k) => attributes.find((a) => a.key === k)?.status !== 'available') + if (!pending.length) return + await new Promise((r) => setTimeout(r, 1000)) + } + throw new Error(`Attribute in ${collectionId} wurden nicht verfuegbar`) +} + +const col = (id) => `/databases/${databaseId}/collections/${id}` + +async function ensureCollection(id, name, attributes, indexes) { + // Keine Dokument-Permissions: Zugriff ausschliesslich ueber den Server-API-Key + await ensure(`Collection ${id}`, () => + aw(`/databases/${databaseId}/collections`, { + method: 'POST', + body: { collectionId: id, name, permissions: [], documentSecurity: false }, + }) + ) + for (const a of attributes) { + const { type, key, ...rest } = a + await ensure(`${id}.${key}`, () => + aw(`${col(id)}/attributes/${type}`, { method: 'POST', body: { key, required: false, ...rest } }) + ) + } + await waitForAttributes(id, attributes.map((a) => a.key)) + for (const idx of indexes) { + await ensure(`${id} index ${idx.key}`, () => + aw(`${col(id)}/indexes`, { method: 'POST', body: idx }) + ) + } +} + +await ensureCollection( + 'bankConnections', + 'Bankzugang (Enable Banking)', + [ + // Ein Dokument "finom": Zugang, Abrufstatus, letzter Kontostand + { type: 'string', key: 'status', size: 16 }, + { type: 'string', key: 'sessionId', size: 64 }, + { type: 'string', key: 'accountUid', size: 64 }, + { type: 'string', key: 'accountIdHash', size: 128 }, + { type: 'string', key: 'ibanMasked', size: 40 }, + { type: 'string', key: 'iban', size: 40 }, + { type: 'string', key: 'validUntil', size: 32 }, + { type: 'string', key: 'lastSyncAt', size: 32 }, + { type: 'boolean', key: 'lastSyncOk' }, + { type: 'string', key: 'lastError', size: 500 }, + { type: 'string', key: 'lastManualSyncAt', size: 32 }, + { type: 'integer', key: 'balanceBookedCents' }, + { type: 'integer', key: 'balanceAvailableCents' }, + { type: 'string', key: 'balanceDate', size: 10 }, + { type: 'string', key: 'latestBookingDate', size: 10 }, + // Freigabe in Arbeit (state der Rueckkehradresse) und bereits gemeldete Warnstufen (z. B. "30,14") + { type: 'string', key: 'authState', size: 64 }, + { type: 'string', key: 'authStartedAt', size: 32 }, + { type: 'string', key: 'warnedThresholds', size: 32 }, + ], + [] +) + +// Buchungen: Dokument-ID = Hash der entry_reference (siehe bankStore). Betrag in Cent, Eingang +, Ausgang -. +await ensureCollection( + 'bankTransactions', + 'Bankbuchungen', + [ + { type: 'string', key: 'accountUid', size: 64 }, + { type: 'string', key: 'status', size: 8 }, + { type: 'string', key: 'bookingDate', size: 10 }, + { type: 'string', key: 'valueDate', size: 10 }, + { type: 'integer', key: 'amountCents' }, + { type: 'string', key: 'currency', size: 3 }, + { type: 'string', key: 'counterpartyName', size: 255 }, + { type: 'string', key: 'counterpartyIban', size: 40 }, + { type: 'string', key: 'remittance', size: 1000 }, + { type: 'string', key: 'mcc', size: 8 }, + { type: 'string', key: 'entryReference', size: 128 }, + // Zuordnung: leer | rechnung | rechnung-extern | stripe-auszahlung | fixkosten | privat | sonstiges + { type: 'string', key: 'category', size: 32 }, + { type: 'string', key: 'matchedInvoiceId', size: 64 }, + { type: 'string', key: 'matchedInvoiceNumber', size: 64 }, + { type: 'string', key: 'matchedPayoutId', size: 64 }, + { type: 'string', key: 'fixedCostId', size: 64 }, + { type: 'string', key: 'note', size: 500 }, + { type: 'string', key: 'syncedAt', size: 32 }, + ], + [{ key: 'idx_booking', type: 'key', attributes: ['bookingDate'] }] +) + +// Einstellungen: ein Dokument "main" +await ensureCollection( + 'financeSettings', + 'Finanz-Einstellungen', + [ + { type: 'integer', key: 'minBufferCents' }, + { type: 'integer', key: 'taxReservePercent' }, + { type: 'integer', key: 'previousYearRevenueCents' }, + { type: 'integer', key: 'estPrepaymentCents' }, + { type: 'string', key: 'bankDetailsText', size: 500 }, + ], + [] +) + +// Fixkosten: Betrag positiv, Intervall monat | quartal | jahr +await ensureCollection( + 'financeFixedCosts', + 'Fixkosten', + [ + { type: 'string', key: 'name', size: 120 }, + { type: 'integer', key: 'amountCents' }, + { type: 'string', key: 'interval', size: 16 }, + { type: 'string', key: 'nextDate', size: 10 }, + { type: 'string', key: 'counterpartyMatch', size: 120 }, + { type: 'boolean', key: 'active' }, + ], + [] +) + +/** + * Uebernimmt die bestehende Enable-Banking-Sitzung: bankConnections/finom anlegen bzw. aktualisieren und + * financeSettings/main mit den Standardwerten anlegen, falls es fehlt. Die Sitzungs-ID wird weder + * ausgegeben noch in Fehlermeldungen wiederholt. + */ +async function takeOverSession({ session, account, validUntil }) { + const docs = (id) => `${col(id)}/documents` + const exists = async (collectionId, documentId) => { + try { + await aw(`${docs(collectionId)}/${documentId}`) + return true + } catch (e) { + if (e.status === 404) return false + throw e + } + } + + const connection = { + status: 'aktiv', + sessionId: session, + accountUid: account, + validUntil, + // neue Freigabe: Warnstufen und alte Fehler gelten nicht mehr + authState: '', + warnedThresholds: '', + lastError: '', + } + try { + if (await exists('bankConnections', 'finom')) { + await aw(`${docs('bankConnections')}/finom`, { method: 'PATCH', body: { data: connection } }) + } else { + await aw(docs('bankConnections'), { method: 'POST', body: { documentId: 'finom', data: connection } }) + } + } catch (e) { + throw new Error(`bankConnections/finom: ${e.message.split(session).join('***')}`) + } + console.log(`+ Sitzung uebernommen, gueltig bis ${validUntil}`) + + if (await exists('financeSettings', 'main')) { + console.log('= financeSettings/main (existiert)') + } else { + await aw(docs('financeSettings'), { + method: 'POST', + body: { documentId: 'main', data: { taxReservePercent: 30, minBufferCents: 0, estPrepaymentCents: 0 } }, + }) + console.log('+ financeSettings/main') + } +} + +if (takeoverData) await takeOverSession(takeoverData) + +console.log('Fertig.') diff --git a/server/config.js b/server/config.js index 82766f1..0eb0a2e 100644 --- a/server/config.js +++ b/server/config.js @@ -153,6 +153,11 @@ export const config = { aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers', contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents', newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts', + // Finanzen (Bankkonto, Buchungen, Einstellungen, Fixkosten) + bankConnections: process.env.APPWRITE_COLLECTION_BANK_CONNECTIONS || 'bankConnections', + bankTransactions: process.env.APPWRITE_COLLECTION_BANK_TRANSACTIONS || 'bankTransactions', + financeSettings: process.env.APPWRITE_COLLECTION_FINANCE_SETTINGS || 'financeSettings', + financeFixedCosts: process.env.APPWRITE_COLLECTION_FINANCE_FIXED_COSTS || 'financeFixedCosts', }, publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), diff --git a/test/financeSetup.test.js b/test/financeSetup.test.js new file mode 100644 index 0000000..4828102 --- /dev/null +++ b/test/financeSetup.test.js @@ -0,0 +1,30 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { schemaKeys } from './support/schema.js' + +test('bankTransactions: Attribute der Spec sind im Setup-Skript angelegt', () => { + const keys = schemaKeys('bankTransactions') + for (const key of ['amountCents', 'category', 'matchedInvoiceId', 'entryReference', 'bookingDate', 'syncedAt']) { + assert.ok(keys.includes(key), `${key} fehlt`) + } +}) + +test('bankConnections: Status und Warnstufen', () => { + const keys = schemaKeys('bankConnections') + for (const key of ['status', 'sessionId', 'accountUid', 'validUntil', 'warnedThresholds', 'balanceBookedCents']) { + assert.ok(keys.includes(key), `${key} fehlt`) + } +}) + +test('financeSettings und financeFixedCosts sind angelegt', () => { + assert.ok(schemaKeys('financeSettings').includes('taxReservePercent')) + assert.ok(schemaKeys('financeFixedCosts').includes('counterpartyMatch')) +}) + +test('subscriptions wird weiter aus dem Abo-Setup gelesen', () => { + assert.ok(schemaKeys('subscriptions').includes('stripeSubscriptionId')) +}) + +test('unbekannte Collection scheitert mit klarer Meldung', () => { + assert.throws(() => schemaKeys('gibtEsNicht'), /Collection gibtEsNicht fehlt im Setup-Skript/) +}) diff --git a/test/support/schema.js b/test/support/schema.js index 7c133e0..873ee50 100644 --- a/test/support/schema.js +++ b/test/support/schema.js @@ -1,14 +1,21 @@ import { readFileSync } from 'node:fs' +// Setup-Skripte, die Collections in Appwrite anlegen (Blockformat: 'id', ... \n ],) +const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs'] + /** - * Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs in Appwrite - * anlegt. Tests geben sie dem Appwrite-Nachbau als Schema: Schreibt der Code ein Feld, das das - * Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in Produktion. + * Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs bzw. + * scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema: + * Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in + * Produktion. */ export function schemaKeys(collection) { - const src = readFileSync(new URL('../../scripts/setup-abo-collections.mjs', import.meta.url), 'utf8') - const start = src.indexOf(`'${collection}',`) - if (start < 0) throw new Error(`Collection ${collection} fehlt im Setup-Skript`) - const block = src.slice(start, src.indexOf('\n ],', start)) - return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1]) + for (const script of SETUP_SCRIPTS) { + const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8') + const start = src.indexOf(`'${collection}',`) + if (start < 0) continue + const block = src.slice(start, src.indexOf('\n ],', start)) + return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1]) + } + throw new Error(`Collection ${collection} fehlt im Setup-Skript`) }