#!/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.')