Finanzen: Formatierung, Betragsparser und Portal-API-Client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8ecf07fecd
commit
c910bedcec
3 files changed
+502
No files matched your search
@@ -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' })
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
|
const {
|
||||||
|
euro,
|
||||||
|
euroWhole,
|
||||||
|
signedEuro,
|
||||||
|
dateDe,
|
||||||
|
sinceLabel,
|
||||||
|
parseEuro,
|
||||||
|
INVOICE_STATUS,
|
||||||
|
CATEGORY_LABEL,
|
||||||
|
URGENCY,
|
||||||
|
AGE_GROUP_LABEL,
|
||||||
|
pageOf,
|
||||||
|
canInvoice,
|
||||||
|
} = await loadModule('src/lib/financeFormat.js')
|
||||||
|
|
||||||
|
const MINUS = '−'
|
||||||
|
|
||||||
|
test('euro: de-DE mit Tausenderpunkt und Komma, Leerzeichen vor dem Euro-Zeichen', () => {
|
||||||
|
assert.equal(euro(123456), '1.234,56 €')
|
||||||
|
assert.equal(euro(10893), '108,93 €')
|
||||||
|
assert.equal(euro(0), '0,00 €')
|
||||||
|
assert.equal(euro(5), '0,05 €')
|
||||||
|
assert.equal(euro(100), '1,00 €')
|
||||||
|
assert.equal(euro(100000000), '1.000.000,00 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('euro: Platzhalter für fehlende Werte', () => {
|
||||||
|
assert.equal(euro(null), '–')
|
||||||
|
assert.equal(euro(undefined), '–')
|
||||||
|
assert.equal(euro(NaN), '–')
|
||||||
|
assert.equal(euro('abc'), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('euro: negative Beträge mit Minuszeichen U+2212', () => {
|
||||||
|
assert.equal(euro(-449), `${MINUS}4,49 €`)
|
||||||
|
assert.equal(euro(-123456), `${MINUS}1.234,56 €`)
|
||||||
|
assert.equal(euro(-5), `${MINUS}0,05 €`)
|
||||||
|
assert.equal(euro(-0), '0,00 €')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('euroWhole: auf ganze Euro gerundet', () => {
|
||||||
|
assert.equal(euroWhole(10893), '109 €')
|
||||||
|
assert.equal(euroWhole(10849), '108 €')
|
||||||
|
assert.equal(euroWhole(10850), '109 €')
|
||||||
|
assert.equal(euroWhole(0), '0 €')
|
||||||
|
assert.equal(euroWhole(123456), '1.235 €')
|
||||||
|
assert.equal(euroWhole(null), '–')
|
||||||
|
assert.equal(euroWhole(undefined), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('euroWhole: negative Werte, keine „−0 €“', () => {
|
||||||
|
assert.equal(euroWhole(-10893), `${MINUS}109 €`)
|
||||||
|
assert.equal(euroWhole(-49), '0 €')
|
||||||
|
assert.equal(euroWhole(-50), `${MINUS}1 €`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('signedEuro: immer mit Vorzeichen, Minus ist U+2212', () => {
|
||||||
|
assert.equal(signedEuro(16879), '+168,79 €')
|
||||||
|
assert.equal(signedEuro(-449), `${MINUS}4,49 €`)
|
||||||
|
assert.equal(signedEuro(-449).charAt(0), '−')
|
||||||
|
assert.equal(signedEuro(123456), '+1.234,56 €')
|
||||||
|
assert.equal(signedEuro(-123456), `${MINUS}1.234,56 €`)
|
||||||
|
assert.equal(signedEuro(0), '0,00 €')
|
||||||
|
assert.equal(signedEuro(null), '–')
|
||||||
|
assert.equal(signedEuro(undefined), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dateDe: ISO-Datum zu TT.MM.JJJJ', () => {
|
||||||
|
assert.equal(dateDe('2027-03-29'), '29.03.2027')
|
||||||
|
assert.equal(dateDe('2026-01-05'), '05.01.2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dateDe: ISO-Zeitpunkt in Berliner Zeit, Platzhalter für Ungültiges', () => {
|
||||||
|
assert.equal(dateDe('2099-03-29T00:00:00.000Z'), '29.03.2099')
|
||||||
|
// 23:30 UTC im Sommer = 01:30 am Folgetag in Berlin
|
||||||
|
assert.equal(dateDe('2026-07-01T23:30:00.000Z'), '02.07.2026')
|
||||||
|
assert.equal(dateDe(''), '–')
|
||||||
|
assert.equal(dateDe(null), '–')
|
||||||
|
assert.equal(dateDe(undefined), '–')
|
||||||
|
assert.equal(dateDe('kein Datum'), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sinceLabel: gerade eben / Minuten / Stunden / Tage', () => {
|
||||||
|
const now = Date.parse('2026-10-01T12:00:00.000Z')
|
||||||
|
const ago = (ms) => new Date(now - ms).toISOString()
|
||||||
|
assert.equal(sinceLabel(ago(0), now), 'gerade eben')
|
||||||
|
assert.equal(sinceLabel(ago(59 * 1000), now), 'gerade eben')
|
||||||
|
assert.equal(sinceLabel(ago(60 * 1000), now), 'vor 1 Min.')
|
||||||
|
assert.equal(sinceLabel(ago(12 * 60000), now), 'vor 12 Min.')
|
||||||
|
assert.equal(sinceLabel(ago(59 * 60000), now), 'vor 59 Min.')
|
||||||
|
assert.equal(sinceLabel(ago(60 * 60000), now), 'vor 1 Std.')
|
||||||
|
assert.equal(sinceLabel(ago(3 * 3600000), now), 'vor 3 Std.')
|
||||||
|
assert.equal(sinceLabel(ago(23 * 3600000), now), 'vor 23 Std.')
|
||||||
|
assert.equal(sinceLabel(ago(24 * 3600000), now), 'vor 1 Tag')
|
||||||
|
assert.equal(sinceLabel(ago(2 * 86400000), now), 'vor 2 Tagen')
|
||||||
|
assert.equal(sinceLabel(ago(40 * 86400000), now), 'vor 40 Tagen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sinceLabel: Zukunft, Date als now, fehlende Werte', () => {
|
||||||
|
const now = Date.parse('2026-10-01T12:00:00.000Z')
|
||||||
|
assert.equal(sinceLabel(new Date(now + 5 * 60000).toISOString(), now), 'gerade eben')
|
||||||
|
assert.equal(sinceLabel('2026-10-01T11:48:00.000Z', new Date(now)), 'vor 12 Min.')
|
||||||
|
assert.equal(sinceLabel(null, now), '–')
|
||||||
|
assert.equal(sinceLabel('Quatsch', now), '–')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseEuro: Tausenderpunkte und Dezimalkomma', () => {
|
||||||
|
assert.equal(parseEuro('1.234,56'), 123456)
|
||||||
|
assert.equal(parseEuro('12,50'), 1250)
|
||||||
|
assert.equal(parseEuro('12,5'), 1250)
|
||||||
|
assert.equal(parseEuro('1234'), 123400)
|
||||||
|
assert.equal(parseEuro('1234,5'), 123450)
|
||||||
|
assert.equal(parseEuro('0,05'), 5)
|
||||||
|
assert.equal(parseEuro('0'), 0)
|
||||||
|
assert.equal(parseEuro('0,00'), 0)
|
||||||
|
assert.equal(parseEuro('1.234'), 123400)
|
||||||
|
assert.equal(parseEuro('1.234.567,89'), 123456789)
|
||||||
|
assert.equal(parseEuro('19,99'), 1999)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseEuro: Leerraum und Euro-Zeichen werden toleriert', () => {
|
||||||
|
assert.equal(parseEuro(' 12,50 '), 1250)
|
||||||
|
assert.equal(parseEuro('12,50 €'), 1250)
|
||||||
|
assert.equal(parseEuro('12,50€'), 1250)
|
||||||
|
assert.equal(parseEuro('€ 12,50'), 1250)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseEuro: ergibt exakte Cent (keine Gleitkomma-Fehler)', () => {
|
||||||
|
assert.equal(parseEuro('19,99'), 1999)
|
||||||
|
assert.equal(parseEuro('0,29'), 29)
|
||||||
|
assert.equal(parseEuro('1,15'), 115)
|
||||||
|
assert.equal(parseEuro('4,35'), 435)
|
||||||
|
assert.ok(Number.isInteger(parseEuro('1.234,56')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseEuro: ungültige Eingaben werfen „Ungültiger Betrag“', () => {
|
||||||
|
const bad = [
|
||||||
|
'', ' ', 'abc', '12abc', '1,2,3', '12,345', '1,234', '12.50', '1.23', '1.2345',
|
||||||
|
'1..234', '1.234,5,6', ',5', '.5', '12,', '1.', '-5', '-0', '+5', '−5', '1e3',
|
||||||
|
'12 34', '1 234,56', '0.500', '€', '12,50 € 3', '١٢', '12',
|
||||||
|
]
|
||||||
|
for (const text of bad) {
|
||||||
|
assert.throws(() => parseEuro(text), { message: 'Ungültiger Betrag' }, `sollte werfen: ${JSON.stringify(text)}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseEuro: Nicht-Strings und zu große Werte werfen', () => {
|
||||||
|
for (const v of [null, undefined, 12.5, {}, [], true]) {
|
||||||
|
assert.throws(() => parseEuro(v), { message: 'Ungültiger Betrag' })
|
||||||
|
}
|
||||||
|
assert.throws(() => parseEuro('99999999999999999999'), { message: 'Ungültiger Betrag' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Status- und Beschriftungstabellen (verbatim)', () => {
|
||||||
|
assert.deepEqual(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' },
|
||||||
|
})
|
||||||
|
assert.deepEqual(CATEGORY_LABEL, {
|
||||||
|
rechnung: 'Rechnung',
|
||||||
|
'rechnung-extern': 'Einnahme ohne Stripe-Rechnung',
|
||||||
|
'stripe-auszahlung': 'Stripe-Auszahlung',
|
||||||
|
fixkosten: 'Fixkosten',
|
||||||
|
privat: 'Privat/Umbuchung',
|
||||||
|
sonstiges: 'Sonstiges',
|
||||||
|
'': 'Offen',
|
||||||
|
})
|
||||||
|
assert.deepEqual(URGENCY, {
|
||||||
|
hoch: { label: 'Dringend', tone: 'danger' },
|
||||||
|
mittel: { label: 'Bald', tone: 'warn' },
|
||||||
|
info: { label: 'Hinweis', tone: 'muted' },
|
||||||
|
})
|
||||||
|
assert.deepEqual(AGE_GROUP_LABEL, {
|
||||||
|
'nicht-faellig': 'Nicht fällig',
|
||||||
|
'1-30': '1–30 Tage',
|
||||||
|
'31-60': '31–60 Tage',
|
||||||
|
'ueber-60': 'Über 60 Tage',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pageOf: 1-basiert, 50 pro Seite, Standardgröße', () => {
|
||||||
|
const list = Array.from({ length: 120 }, (_, i) => i + 1)
|
||||||
|
const p1 = pageOf(list, 1)
|
||||||
|
assert.equal(p1.items.length, 50)
|
||||||
|
assert.equal(p1.items[0], 1)
|
||||||
|
assert.equal(p1.page, 1)
|
||||||
|
assert.equal(p1.pages, 3)
|
||||||
|
const p3 = pageOf(list, 3)
|
||||||
|
assert.deepEqual(p3.items, list.slice(100))
|
||||||
|
assert.equal(p3.items.length, 20)
|
||||||
|
assert.equal(p3.page, 3)
|
||||||
|
assert.equal(p3.pages, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pageOf: Seitenzahl wird begrenzt, eigene Größe, leere Liste', () => {
|
||||||
|
const list = [1, 2, 3, 4, 5]
|
||||||
|
assert.deepEqual(pageOf(list, 99, 2), { items: [5], page: 3, pages: 3 })
|
||||||
|
assert.deepEqual(pageOf(list, 0, 2), { items: [1, 2], page: 1, pages: 3 })
|
||||||
|
assert.deepEqual(pageOf(list, -4, 2), { items: [1, 2], page: 1, pages: 3 })
|
||||||
|
assert.deepEqual(pageOf(list, 'x', 2), { items: [1, 2], page: 1, pages: 3 })
|
||||||
|
assert.deepEqual(pageOf(list, 2.7, 2), { items: [3, 4], page: 2, pages: 3 })
|
||||||
|
assert.deepEqual(pageOf([], 1), { items: [], page: 1, pages: 1 })
|
||||||
|
assert.deepEqual(pageOf(null, 3), { items: [], page: 1, pages: 1 })
|
||||||
|
assert.deepEqual(pageOf(undefined), { items: [], page: 1, pages: 1 })
|
||||||
|
assert.equal(pageOf(Array.from({ length: 50 }, (_, i) => i), 1).pages, 1)
|
||||||
|
assert.equal(pageOf(Array.from({ length: 51 }, (_, i) => i), 1).pages, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canInvoice: fester Kunde mit E-Mail ist ok', () => {
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'customer', email: 'k@example.invalid' }), { ok: true })
|
||||||
|
// Bestandsdaten ohne Status gelten als fester Kunde (wie customerStage)
|
||||||
|
assert.deepEqual(canInvoice({ email: 'k@example.invalid' }), { ok: true })
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: '', email: 'k@example.invalid' }), { ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canInvoice: Lead und Abgesagt sind noch keine festen Kunden', () => {
|
||||||
|
const reason = 'Noch kein fester Kunde'
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'lead', email: 'k@example.invalid' }), { ok: false, reason })
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'lost', email: 'k@example.invalid' }), { ok: false, reason })
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'Lead', email: 'k@example.invalid' }), { ok: false, reason })
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: ' LOST ', email: 'k@example.invalid' }), { ok: false, reason })
|
||||||
|
// Status hat Vorrang vor fehlender E-Mail
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'lead', email: '' }), { ok: false, reason })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canInvoice: ohne E-Mail nicht möglich', () => {
|
||||||
|
const reason = 'E-Mail fehlt'
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'customer' }), { ok: false, reason })
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'customer', email: '' }), { ok: false, reason })
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'customer', email: ' ' }), { ok: false, reason })
|
||||||
|
assert.deepEqual(canInvoice({ customerStatus: 'customer', email: null }), { ok: false, reason })
|
||||||
|
assert.deepEqual(canInvoice({}), { ok: false, reason })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('canInvoice: kein Kunde', () => {
|
||||||
|
assert.equal(canInvoice(null).ok, false)
|
||||||
|
assert.equal(canInvoice(undefined).ok, false)
|
||||||
|
assert.equal(typeof canInvoice(null).reason, 'string')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user