Finanzen: Collections und Setup-Skript mit Sitzungsuebernahme
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9329318df5
commit
3dd2fac50b
4 files changed
+320
-5
No files matched your search
@@ -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 <id> Sitzungs-ID (wird nie ausgegeben)
|
||||||
|
* --account <uid> Konto-UID aus der Sitzung
|
||||||
|
* --valid-until <iso> 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 <id> --account <uid> --valid-until <iso>
|
||||||
|
*/
|
||||||
|
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 <id> --account <uid> --valid-until <iso>')
|
||||||
|
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.')
|
||||||
@@ -153,6 +153,11 @@ export const config = {
|
|||||||
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
|
aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers',
|
||||||
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
|
contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents',
|
||||||
newsPosts: process.env.APPWRITE_COLLECTION_NEWS_POSTS || 'newsPosts',
|
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(/\/$/, ''),
|
publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''),
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
})
|
||||||
+12
-5
@@ -1,14 +1,21 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
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
|
* Attribut-Schlüssel einer Collection, wie sie scripts/setup-abo-collections.mjs bzw.
|
||||||
* anlegt. Tests geben sie dem Appwrite-Nachbau als Schema: Schreibt der Code ein Feld, das das
|
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
|
||||||
* Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in Produktion.
|
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
|
||||||
|
* Produktion.
|
||||||
*/
|
*/
|
||||||
export function schemaKeys(collection) {
|
export function schemaKeys(collection) {
|
||||||
const src = readFileSync(new URL('../../scripts/setup-abo-collections.mjs', import.meta.url), 'utf8')
|
for (const script of SETUP_SCRIPTS) {
|
||||||
|
const src = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), 'utf8')
|
||||||
const start = src.indexOf(`'${collection}',`)
|
const start = src.indexOf(`'${collection}',`)
|
||||||
if (start < 0) throw new Error(`Collection ${collection} fehlt im Setup-Skript`)
|
if (start < 0) continue
|
||||||
const block = src.slice(start, src.indexOf('\n ],', start))
|
const block = src.slice(start, src.indexOf('\n ],', start))
|
||||||
return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
|
return [...block.matchAll(/key: '(\w+)'/g)].map((m) => m[1])
|
||||||
}
|
}
|
||||||
|
throw new Error(`Collection ${collection} fehlt im Setup-Skript`)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user