Files
Webklar-Kundenbereich/scripts/setup-finanzen.mjs
T
ClaudeandClaude Sonnet 5.5 5ed4fbd188 Finanzen: Setup-Skript mit --iban und strenger Pruefung von --valid-until, Enable-Banking-Variablen in .env.example
M5: --iban speichert iban und ibanMasked (erste und letzte vier Zeichen); --valid-until nur als Datum
oder voller ISO-Zeitpunkt mit Zeitzone, sonst Abbruch.
M16: ENABLE_BANKING_APP_ID und ENABLE_BANKING_KEY_PATH auskommentiert dokumentiert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:42:06 +00:00

314 lines
12 KiB
JavaScript

#!/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 <id> Sitzungs-ID (wird nie ausgegeben)
* --account <uid> Konto-UID aus der Sitzung
* --valid-until <iso> Ende der Freigabe: Datum (JJJJ-MM-TT) oder voller ISO-Zeitpunkt mit Zeitzone
* (z. B. 2027-03-29T00:00:00Z); alles andere bricht ab
* --iban <iban> (optional) IBAN des Kontos: wird gespeichert, die Seite zeigt sie als DE00...1234
* 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 <id> --account <uid> --valid-until <iso> [--iban <iban>]
*/
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' },
iban: { type: 'string' },
},
strict: true,
allowPositionals: false,
}).values
} catch {
console.error('Ungueltige Argumente. Erlaubt: --session <id> --account <uid> --valid-until <iso> [--iban <iban>]')
process.exit(1)
}
/**
* Strenger ISO-Zeitpunkt: JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm:ss[.f]Z bzw. mit Versatz +hh:mm. Gibt es den
* Tag oder die Uhrzeit nicht (30.02., 25 Uhr), ist es kein Zeitpunkt. Sonst null.
*/
function parseIsoInstant(value) {
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2}))?$/.exec(value)
if (!m) return null
const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])]
const [hour, minute, second] = [Number(m[4] ?? 0), Number(m[5] ?? 0), Number(m[6] ?? 0)]
const calendar = new Date(Date.UTC(year, month - 1, day))
if (calendar.getUTCFullYear() !== year || calendar.getUTCMonth() !== month - 1 || calendar.getUTCDate() !== day) return null
if (hour > 23 || minute > 59 || second > 59) return null
if (m[7] && m[7] !== 'Z' && (Number(m[7].slice(1, 3)) > 23 || Number(m[7].slice(4)) > 59)) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
/** IBAN ohne Leerzeichen in Grossbuchstaben, oder null, wenn sie nicht wie eine IBAN aussieht (2 Buchstaben, 2 Ziffern, 11 bis 30 Zeichen). */
function normalizeIban(value) {
const compact = String(value).replace(/\s+/g, '').toUpperCase()
return /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact) ? compact : null
}
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 = parseIsoInstant(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 (!validUntil) {
console.error('--valid-until ist kein gueltiger ISO-Zeitpunkt (erlaubt: JJJJ-MM-TT oder JJJJ-MM-TTThh:mm:ssZ)')
process.exit(1)
}
let iban = ''
if (args.iban !== undefined) {
iban = normalizeIban(args.iban)
if (!iban) {
console.error('--iban ist keine gueltige IBAN')
process.exit(1)
}
}
takeoverData = { session, account, validUntil: validUntil.toISOString(), iban }
}
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'] },
// Abruf: vorgemerkte Buchungen (PDNG) suchen
{ key: 'idx_status', type: 'key', attributes: ['status'] },
]
)
// 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, iban }) {
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,
// IBAN nur, wenn angegeben (sonst bleibt eine gespeicherte stehen); die Seite zeigt nur die ersten und letzten vier Zeichen
...(iban ? { iban, ibanMasked: `${iban.slice(0, 4)}…${iban.slice(-4)}` } : {}),
// 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.')