Finanzen: Formatierung, Betragsparser und Portal-API-Client

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:12:18 +00:00
1 parent 8ecf07fecd
commit c910bedcec
3 files changed
+502

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
import { account } from './appwrite'
import { createJwtCache, fetchWithRetry } from './serverpulsAuth'
/**
* Client fuer die Finanzen-API des Kundenportals (/api/admin/finanzen).
* Authentifiziert per Appwrite-JWT (Bearer); alle Betraege in Cent.
*/
const PROJECT_ADMIN_URL =
import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com'
const BASE = `${PROJECT_ADMIN_URL}/api/admin/finanzen`
// JWT 10 Min. gecacht (Appwrite begrenzt das Erzeugen pro Nutzer)
const jwtCache = createJwtCache({ createJWT: () => account.createJWT() })
// Netzwerkfehler (fetch wirft) bekommen eine deutsche Meldung mit status 0
async function portalFetch(url, init) {
try {
return await fetch(url, init)
} catch {
const err = new Error('Das Kundenportal ist gerade nicht erreichbar.')
err.status = 0
throw err
}
}
async function request(path, { method = 'GET', body } = {}) {
const res = await fetchWithRetry(jwtCache, (jwt) =>
portalFetch(`${BASE}${path}`, {
method,
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
)
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data.error || `API-Fehler ${res.status}`)
err.status = res.status
// Nach dem Festschreiben (502) liegt die Rechnung schon als "offen" vor
if (data.invoiceId) err.invoiceId = data.invoiceId
if (data.invoiceNumber) err.invoiceNumber = data.invoiceNumber
throw err
}
return data
}
const enc = encodeURIComponent
// --- Uebersicht ---
export const getOverview = () => request('/uebersicht')
// --- Rechnungen ---
export function getInvoices({ status, kunde } = {}) {
const params = new URLSearchParams()
if (status) params.set('status', status)
if (kunde) params.set('kunde', kunde)
const qs = params.toString()
return request(`/rechnungen${qs ? `?${qs}` : ''}`)
}
// Jeder Aufruf bekommt einen neuen Schluessel, damit ein Doppelklick keine zweite Rechnung erzeugt.
export const createInvoice = (body) =>
request('/rechnungen', { method: 'POST', body: { ...body, idempotencyKey: crypto.randomUUID() } })
export const sendInvoice = (id) => request(`/rechnungen/${enc(id)}/senden`, { method: 'POST' })
// "Erinnerung senden": das Portal versendet eine offene/ueberfaellige Rechnung erneut
export const resendInvoice = sendInvoice
export const voidInvoice = (id) => request(`/rechnungen/${enc(id)}/stornieren`, { method: 'POST' })
export const deleteInvoice = (id) => request(`/rechnungen/${enc(id)}`, { method: 'DELETE' })
// --- Buchungen ---
export const getTransactions = () => request('/buchungen')
// target: genau eins von { invoiceId } | { payoutId } | { fixedCostId } | { category }, optional note
export const assignTransaction = (id, target) =>
request(`/buchungen/${enc(id)}/zuordnen`, { method: 'POST', body: target })
export const unassignTransaction = (id) => request(`/buchungen/${enc(id)}/zuordnung`, { method: 'DELETE' })
// --- Bank ---
export const getBank = () => request('/bank')
export const refreshBank = () => request('/bank/aktualisieren', { method: 'POST' })
export const startBankAuth = () => request('/bank/verbinden', { method: 'POST' })
export const completeBankAuth = ({ code, state, error } = {}) =>
request('/bank/rueckkehr', { method: 'POST', body: { code, state, error } })
// --- Einstellungen ---
export const getSettings = () => request('/einstellungen')
export const patchSettings = (patch) => request('/einstellungen', { method: 'PATCH', body: patch })
// --- Fixkosten ---
export const getFixedCosts = () => request('/fixkosten')
export const createFixedCost = (body) => request('/fixkosten', { method: 'POST', body })
export const updateFixedCost = (id, patch) => request(`/fixkosten/${enc(id)}`, { method: 'PATCH', body: patch })
export const deleteFixedCost = (id) => request(`/fixkosten/${enc(id)}`, { method: 'DELETE' })
+164
View File
@@ -0,0 +1,164 @@
// Reine Format- und Parser-Helfer fuer die Finanzen-Seiten (kein React, kein Appwrite, unter Node testbar).
// Alle Betraege sind Cent (Integer). Minus ist immer U+2212, nicht der Bindestrich.
const MINUS = '−'
const PLACEHOLDER = '–'
function isNum(v) {
return typeof v === 'number' && Number.isFinite(v)
}
// 1234567 -> '1.234.567'
function groupThousands(intString) {
return intString.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
}
// Cent -> { neg, euros, rest } mit auf ganze Cent gerundetem Betrag; neg nur, wenn der Betrag nicht 0 ist.
function splitCents(cents) {
const abs = Math.round(Math.abs(cents))
return { neg: cents < 0 && abs !== 0, euros: Math.floor(abs / 100), rest: abs % 100 }
}
/** 123456 -> '1.234,56 €'; null/ungueltig -> '–'; negativ mit U+2212. */
export function euro(cents) {
if (!isNum(cents)) return PLACEHOLDER
const { neg, euros, rest } = splitCents(cents)
return `${neg ? MINUS : ''}${groupThousands(String(euros))},${String(rest).padStart(2, '0')} €`
}
/** 10893 -> '109 €' (auf ganze Euro gerundet, ab 50 Cent auf). */
export function euroWhole(cents) {
if (!isNum(cents)) return PLACEHOLDER
const abs = Math.round(Math.abs(cents) / 100)
const neg = cents < 0 && abs !== 0
return `${neg ? MINUS : ''}${groupThousands(String(abs))} €`
}
/** 16879 -> '+168,79 €', -449 -> '−4,49 €', 0 -> '0,00 €'. */
export function signedEuro(cents) {
if (!isNum(cents)) return PLACEHOLDER
const { neg } = splitCents(cents)
const text = euro(Math.abs(cents))
if (neg) return `${MINUS}${text}`
return cents > 0 && text !== '0,00 €' ? `+${text}` : text
}
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/
const berlinDate = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
/** '2027-03-29' -> '29.03.2027'; ISO-Zeitpunkte in Berliner Zeit; ungueltig -> '–'. */
export function dateDe(value) {
if (typeof value !== 'string' || !value.trim()) return PLACEHOLDER
const s = value.trim()
const m = DATE_ONLY.exec(s)
if (m) {
const [, y, mo, d] = m
const probe = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)))
const valid =
probe.getUTCFullYear() === Number(y) &&
probe.getUTCMonth() === Number(mo) - 1 &&
probe.getUTCDate() === Number(d)
return valid ? `${d}.${mo}.${y}` : PLACEHOLDER
}
const t = Date.parse(s)
return Number.isNaN(t) ? PLACEHOLDER : berlinDate.format(new Date(t))
}
/** ISO-Zeitpunkt -> 'gerade eben' | 'vor 12 Min.' | 'vor 3 Std.' | 'vor 1 Tag' | 'vor 2 Tagen'. */
export function sinceLabel(iso, now = Date.now()) {
if (typeof iso !== 'string' && !(iso instanceof Date)) return PLACEHOLDER
const then = iso instanceof Date ? iso.getTime() : Date.parse(iso)
const nowMs = now instanceof Date ? now.getTime() : Number(now)
if (Number.isNaN(then) || Number.isNaN(nowMs)) return PLACEHOLDER
const min = Math.floor(Math.max(0, nowMs - then) / 60000)
if (min < 1) return 'gerade eben'
if (min < 60) return `vor ${min} Min.`
const hours = Math.floor(min / 60)
if (hours < 24) return `vor ${hours} Std.`
const days = Math.floor(hours / 24)
return days === 1 ? 'vor 1 Tag' : `vor ${days} Tagen`
}
// Ganze Euro mit Tausenderpunkten (1.234.567) oder ohne Punkte, optional ,1-2 Nachkommastellen.
// Eine fuehrende 0 ist nur ohne Tausenderpunkte erlaubt, '12.50' (englisch) ist absichtlich ungueltig.
const AMOUNT = /^([1-9][0-9]{0,2}(?:\.[0-9]{3})+|[0-9]+)(?:,([0-9]{1,2}))?$/
/**
* '1.234,56' -> 123456 (Cent). Erlaubt Tausenderpunkte, Dezimalkomma und ein
* einzelnes Euro-Zeichen davor oder dahinter. Keine negativen Werte, kein Dezimalpunkt.
* Wirft Error('Ungültiger Betrag').
*/
export function parseEuro(text) {
const invalid = () => new Error('Ungültiger Betrag')
if (typeof text !== 'string') throw invalid()
let s = text.trim()
if (s.startsWith('€')) s = s.slice(1).trim()
else if (s.endsWith('€')) s = s.slice(0, -1).trim()
const m = AMOUNT.exec(s)
if (!m) throw invalid()
const euros = Number(m[1].replace(/\./g, ''))
const cents = Number((m[2] || '').padEnd(2, '0'))
const total = euros * 100 + cents
if (!Number.isSafeInteger(total)) throw invalid()
return total
}
export const INVOICE_STATUS = {
entwurf: { label: 'Entwurf', tone: 'muted' },
offen: { label: 'Offen', tone: 'info' },
ueberfaellig: { label: 'Überfällig', tone: 'warn' },
bezahlt: { label: 'Bezahlt', tone: 'ok' },
storniert: { label: 'Storniert', tone: 'muted' },
uneinbringlich: { label: 'Uneinbringlich', tone: 'danger' },
}
export const CATEGORY_LABEL = {
rechnung: 'Rechnung',
'rechnung-extern': 'Einnahme ohne Stripe-Rechnung',
'stripe-auszahlung': 'Stripe-Auszahlung',
fixkosten: 'Fixkosten',
privat: 'Privat/Umbuchung',
sonstiges: 'Sonstiges',
'': 'Offen',
}
export const URGENCY = {
hoch: { label: 'Dringend', tone: 'danger' },
mittel: { label: 'Bald', tone: 'warn' },
info: { label: 'Hinweis', tone: 'muted' },
}
export const AGE_GROUP_LABEL = {
'nicht-faellig': 'Nicht fällig',
'1-30': '1–30 Tage',
'31-60': '31–60 Tage',
'ueber-60': 'Über 60 Tage',
}
/** Seite einer Liste (1-basiert); die Seitenzahl wird auf 1..pages begrenzt. */
export function pageOf(list, page, size = 50) {
const items = Array.isArray(list) ? list : []
const perPage = Number.isInteger(size) && size > 0 ? size : 50
const pages = Math.max(1, Math.ceil(items.length / perPage))
const wanted = Math.floor(Number(page))
const current = Number.isNaN(wanted) ? 1 : Math.min(Math.max(wanted, 1), pages)
return { items: items.slice((current - 1) * perPage, current * perPage), page: current, pages }
}
/**
* Darf fuer diesen Kunden eine Rechnung erstellt werden?
* Leads und Abgesagte sind keine festen Kunden; Bestandsdaten ohne Status zaehlen als Kunde (wie customerStage).
*/
export function canInvoice(customer) {
if (!customer) return { ok: false, reason: 'Kein Kunde ausgewählt' }
const status = String(customer.customerStatus || '').trim().toLowerCase()
if (status === 'lead' || status === 'lost') return { ok: false, reason: 'Noch kein fester Kunde' }
const email = typeof customer.email === 'string' ? customer.email.trim() : ''
if (!email) return { ok: false, reason: 'E-Mail fehlt' }
return { ok: true }
}