Finanzen: Uebersicht und Rechnungsrouten fuer WOMS
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7dbf2ce75f
commit
3c5e06723f
7 files changed
+1194
-34
No files matched your search
+104
-29
@@ -3,8 +3,9 @@ import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { EnableBankingError } from '../../services/finance/enableBanking.js'
|
||||
import { completeBankAuth, startBankAuth, syncBank } from '../../services/finance/bankSync.js'
|
||||
import { getConnection, listTransactions, saveConnection } from '../../services/finance/bankStore.js'
|
||||
import { getConnection, listTransactions, publicConnection, saveConnection } from '../../services/finance/bankStore.js'
|
||||
import { AssignError, assignTransaction, unassignTransaction, withSuggestions } from '../../services/finance/assign.js'
|
||||
import { buildOverview } from '../../services/finance/overview.js'
|
||||
import {
|
||||
SettingsError,
|
||||
createFixedCost,
|
||||
@@ -14,11 +15,20 @@ import {
|
||||
patchSettings,
|
||||
updateFixedCost,
|
||||
} from '../../services/finance/settings.js'
|
||||
import {
|
||||
InvoiceError,
|
||||
STRIPE_UNAVAILABLE_MESSAGE,
|
||||
createProjectInvoice,
|
||||
deleteDraft,
|
||||
listInvoices,
|
||||
sendInvoiceNow,
|
||||
voidInvoice,
|
||||
} from '../../services/finance/stripeFinance.js'
|
||||
|
||||
/**
|
||||
* Finanzen im WOMS (nur Admins): Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung, Einstellungen und
|
||||
* Fixkosten. Die Handler sind klein und rufen die Dienste in server/services/finance auf; weitere Finanz-Routen kommen
|
||||
* in dieselbe Datei.
|
||||
* Finanzen im WOMS (nur Admins): Übersicht, Bankstatus, Abruf, Neuverbindung, Buchungen und deren Zuordnung,
|
||||
* Rechnungen (lesen, anlegen, senden, stornieren, Entwurf löschen), Einstellungen und Fixkosten. Die Handler sind klein
|
||||
* und rufen die Dienste in server/services/finance auf.
|
||||
*/
|
||||
const router = Router()
|
||||
|
||||
@@ -32,29 +42,21 @@ const MANUAL_SYNC_LOCK = ['finanzen-bank-manual-sync']
|
||||
// (z. B. 401) und alles, was Sitzungen verraten koennte, bleibt auf dem Server
|
||||
const BANK_UNAVAILABLE_MESSAGE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.'
|
||||
|
||||
/** Was die Seite vom Bankzugang sieht: nie Sitzung, IBAN, state oder Konto-Kennungen. */
|
||||
function publicConnection(connection) {
|
||||
const pick = (key) => connection[key] ?? null
|
||||
return {
|
||||
status: pick('status'),
|
||||
ibanMasked: pick('ibanMasked'),
|
||||
validUntil: pick('validUntil'),
|
||||
lastSyncAt: pick('lastSyncAt'),
|
||||
lastSyncOk: pick('lastSyncOk'),
|
||||
lastError: pick('lastError'),
|
||||
balanceBookedCents: pick('balanceBookedCents'),
|
||||
balanceAvailableCents: pick('balanceAvailableCents'),
|
||||
balanceDate: pick('balanceDate'),
|
||||
latestBookingDate: pick('latestBookingDate'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Dokument ohne Appwrite-Metafelder ($createdAt, $permissions, ...); nur $id bleibt. */
|
||||
const publicDoc = (doc) => Object.fromEntries(Object.entries(doc).filter(([key]) => key === '$id' || !key.startsWith('$')))
|
||||
|
||||
/** Fehlerart fuers Protokoll: nie die Meldung (sie koennte Kennungen der Bank oder von Appwrite enthalten). */
|
||||
const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.status ?? '', err?.code ?? '')
|
||||
|
||||
router.get('/uebersicht', async (_req, res) => {
|
||||
try {
|
||||
return res.json(await buildOverview())
|
||||
} catch (err) {
|
||||
logFailure('Übersicht laden', err)
|
||||
return res.status(500).json({ error: 'Die Übersicht konnte nicht geladen werden.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/bank', async (_req, res) => {
|
||||
try {
|
||||
const connection = await getConnection()
|
||||
@@ -133,18 +135,24 @@ router.get('/buchungen', async (_req, res) => {
|
||||
})
|
||||
|
||||
const STRIPE_REJECTED_MESSAGE = 'Stripe hat die Änderung abgelehnt.'
|
||||
const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
|
||||
|
||||
// Fehler des Stripe-Pakets (StripeInvalidRequestError, StripeConnectionError, ...) tragen type "Stripe..."
|
||||
const isStripeError = (err) => typeof err?.type === 'string' && err.type.startsWith('Stripe')
|
||||
|
||||
/**
|
||||
* Antwort auf einen Fehler beim Zuordnen: AssignError (eigene, anzeigbare Meldung) mit seinem Status; Stripe lehnt
|
||||
* ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500. Meldungen von Stripe, Appwrite
|
||||
* und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite.
|
||||
* Antwort auf einen Fehler beim Zuordnen und bei den Rechnungen: AssignError und InvoiceError (eigene, anzeigbare
|
||||
* Meldung) mit ihrem Status, bei einer festgeschriebenen, aber nicht gesendeten Rechnung (502) samt invoiceId und
|
||||
* invoiceNumber; Stripe lehnt ab (4xx, ausser 429) -> 409; Stripe sonst nicht erreichbar -> 502; alles andere 500.
|
||||
* Meldungen von Stripe, Appwrite und Netzfehlern (Adressen, Schluessel) gehen nie an die Seite, auch wenn ihre
|
||||
* Fehler einen status tragen: nur die beiden eigenen Fehlerarten werden weitergegeben.
|
||||
*/
|
||||
function sendAssignError(res, what, err) {
|
||||
if (err instanceof AssignError) return res.status(err.status).json({ error: err.message })
|
||||
function sendFinanceError(res, what, err) {
|
||||
if (err instanceof AssignError || err instanceof InvoiceError) {
|
||||
const body = { error: err.message }
|
||||
if (err.invoiceId) body.invoiceId = err.invoiceId
|
||||
if (err.invoiceNumber !== undefined) body.invoiceNumber = err.invoiceNumber
|
||||
return res.status(err.status).json(body)
|
||||
}
|
||||
logFailure(what, err)
|
||||
if (isStripeError(err)) {
|
||||
const status = err.statusCode
|
||||
@@ -160,7 +168,7 @@ router.post('/buchungen/:id/zuordnen', async (req, res) => {
|
||||
const buchung = await assignTransaction(req.params.id, { invoiceId, payoutId, fixedCostId, category, note })
|
||||
return res.json({ buchung: publicDoc(buchung) })
|
||||
} catch (err) {
|
||||
return sendAssignError(res, 'Buchung zuordnen', err)
|
||||
return sendFinanceError(res, 'Buchung zuordnen', err)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -168,7 +176,74 @@ router.delete('/buchungen/:id/zuordnung', async (req, res) => {
|
||||
try {
|
||||
return res.json({ buchung: publicDoc(await unassignTransaction(req.params.id)) })
|
||||
} catch (err) {
|
||||
return sendAssignError(res, 'Zuordnung lösen', err)
|
||||
return sendFinanceError(res, 'Zuordnung lösen', err)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Rechnungen (Stripe) ----------------------------------------------------------------------------------------
|
||||
|
||||
const INVOICE_FILTERS = new Set(['alle', 'offen', 'ueberfaellig', 'bezahlt', 'entwurf'])
|
||||
const DEFAULT_DAYS_UNTIL_DUE = 14
|
||||
const createdOf = (invoice) => invoice.createdDate ?? ''
|
||||
|
||||
router.get('/rechnungen', async (req, res) => {
|
||||
const status = req.query.status || 'alle'
|
||||
const customer = req.query.kunde || ''
|
||||
if (!INVOICE_FILTERS.has(status)) {
|
||||
return res.status(400).json({ error: 'Unbekannter Filter (erlaubt: alle, offen, ueberfaellig, bezahlt, entwurf).' })
|
||||
}
|
||||
if (typeof customer !== 'string') return res.status(400).json({ error: 'Ungültiger Kunde.' })
|
||||
try {
|
||||
const invoices = (await listInvoices())
|
||||
.filter((invoice) => (status === 'alle' || invoice.status === status) && (!customer || invoice.webklarCustomerId === customer))
|
||||
// neueste zuerst (der Sortieralgorithmus ist stabil: am selben Tag bleibt Stripes Reihenfolge)
|
||||
.sort((a, b) => (createdOf(a) < createdOf(b) ? 1 : createdOf(a) > createdOf(b) ? -1 : 0))
|
||||
return res.json({ rechnungen: invoices })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnungen laden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rechnungen', async (req, res) => {
|
||||
const { customerId, items, daysUntilDue, memo, senden } = req.body || {}
|
||||
try {
|
||||
const rechnung = await createProjectInvoice({
|
||||
customerId,
|
||||
items,
|
||||
daysUntilDue: daysUntilDue ?? DEFAULT_DAYS_UNTIL_DUE,
|
||||
memo,
|
||||
// Nur ein echtes Ja sendet sofort: alles andere bleibt ein Entwurf
|
||||
senden: senden === true || senden === 'true',
|
||||
createdBy: req.appwriteUser?.email,
|
||||
})
|
||||
return res.json({ rechnung })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnung anlegen', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rechnungen/:id/senden', async (req, res) => {
|
||||
try {
|
||||
return res.json({ rechnung: await sendInvoiceNow(req.params.id) })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnung senden', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/rechnungen/:id/stornieren', async (req, res) => {
|
||||
try {
|
||||
return res.json({ rechnung: await voidInvoice(req.params.id) })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Rechnung stornieren', err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/rechnungen/:id', async (req, res) => {
|
||||
try {
|
||||
await deleteDraft(req.params.id)
|
||||
return res.json({ ok: true })
|
||||
} catch (err) {
|
||||
return sendFinanceError(res, 'Entwurf löschen', err)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -59,6 +59,23 @@ async function upsert(collectionId, id, patch, createData) {
|
||||
return { how: 'aktualisiert', doc: await updateDocument(collectionId, id, patch) }
|
||||
}
|
||||
|
||||
/** Was die Seite vom Bankzugang sieht (GET /bank und die Statuszeile der Übersicht): nie Sitzung, IBAN, state oder Konto-Kennungen. */
|
||||
export function publicConnection(connection) {
|
||||
const pick = (key) => connection[key] ?? null
|
||||
return {
|
||||
status: pick('status'),
|
||||
ibanMasked: pick('ibanMasked'),
|
||||
validUntil: pick('validUntil'),
|
||||
lastSyncAt: pick('lastSyncAt'),
|
||||
lastSyncOk: pick('lastSyncOk'),
|
||||
lastError: pick('lastError'),
|
||||
balanceBookedCents: pick('balanceBookedCents'),
|
||||
balanceAvailableCents: pick('balanceAvailableCents'),
|
||||
balanceDate: pick('balanceDate'),
|
||||
latestBookingDate: pick('latestBookingDate'),
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Bankzugang oder null, wenn es noch keinen gibt. */
|
||||
export async function getConnection() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument, listDocuments, Query } from '../appwriteAdmin.js'
|
||||
import { withSuggestions } from './assign.js'
|
||||
import { getConnection, listTransactions, publicConnection } from './bankStore.js'
|
||||
import { berlinDate, daysBetween } from './dates.js'
|
||||
import { buildForecast } from './forecast.js'
|
||||
import { detectRecurring } from './recurring.js'
|
||||
import { getSettings, listFixedCosts } from './settings.js'
|
||||
import { listInvoices, listPayouts, STRIPE_UNAVAILABLE_MESSAGE } from './stripeFinance.js'
|
||||
import { computeTax } from './tax.js'
|
||||
import { buildTodos } from './todos.js'
|
||||
|
||||
/**
|
||||
* Alles für die Seite /finance in einer Antwort (GET /uebersicht): Statuszeile, Liquiditätskopf, Aufgaben, Vorschau,
|
||||
* Steuer, Abos und Altersgruppen der Rechnungen. Die Rechnen-Module (forecast, tax, todos, recurring) sind rein; hier
|
||||
* werden nur die Daten geladen (Bank und Einstellungen aus Appwrite, Rechnungen und Auszahlungen von Stripe) und
|
||||
* zusammengesetzt. Scheitert Stripe, rechnet die Übersicht mit leeren Rechnungs- und Auszahlungslisten weiter und
|
||||
* meldet das in stand.stripe; Appwrite-Fehler gehen als Fehler an den Aufrufer.
|
||||
*/
|
||||
|
||||
const SUBSCRIPTION_LIMIT = 500
|
||||
// Abos in der Liste: laufende (auch in der Testphase) und solche mit Zahlungsproblem; beendete fehlen
|
||||
const LISTED_STATUSES = new Set(['active', 'trialing', 'past_due', 'unpaid'])
|
||||
const NAME_BATCH = 10 // Kundennamen: höchstens so viele Anfragen an Appwrite gleichzeitig
|
||||
const AGE_GROUPS = ['nicht-faellig', '1-30', '31-60', 'ueber-60']
|
||||
|
||||
const isCents = (n) => Number.isSafeInteger(n)
|
||||
const trimmed = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
/** Fehlerart fürs Protokoll: nie die Meldung (sie könnte Adressen oder Schlüssel enthalten). */
|
||||
const logFailure = (what, err) => console.error(`[finanzen] ${what} fehlgeschlagen:`, err?.name || 'Error', err?.statusCode ?? err?.status ?? '', err?.code ?? '')
|
||||
|
||||
/** Zeitstempel (ISO-Text) als Berliner Kalendertag, null bei fehlendem oder unlesbarem Wert. */
|
||||
function berlinDayOf(iso) {
|
||||
if (!iso) return null
|
||||
const date = new Date(iso)
|
||||
return Number.isNaN(date.getTime()) ? null : berlinDate(date)
|
||||
}
|
||||
|
||||
// --- Stripe -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Rechnungen und Auszahlungen; was nicht kommt, ist eine leere Liste und macht ok false. */
|
||||
async function loadStripe(now) {
|
||||
const [invoices, payouts] = await Promise.allSettled([listInvoices(), listPayouts()])
|
||||
if (invoices.status === 'rejected') logFailure('Rechnungen laden', invoices.reason)
|
||||
if (payouts.status === 'rejected') logFailure('Auszahlungen laden', payouts.reason)
|
||||
const ok = invoices.status === 'fulfilled' && payouts.status === 'fulfilled'
|
||||
return {
|
||||
ok,
|
||||
invoices: invoices.status === 'fulfilled' ? invoices.value : [],
|
||||
payouts: payouts.status === 'fulfilled' ? payouts.value : [],
|
||||
// abgerufenUm: wann der Stand geholt wurde; bei einem Fehler gibt es keinen
|
||||
status: ok ? { ok: true, abgerufenUm: now.toISOString() } : { ok: false, fehler: STRIPE_UNAVAILABLE_MESSAGE, abgerufenUm: null },
|
||||
}
|
||||
}
|
||||
|
||||
// --- Abos ---------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Kundennamen (Firmenname vor Name) zu den Kunden-IDs; ein fehlender oder nicht lesbarer Kunde bekommt den Namen "". */
|
||||
async function customerNames(ids) {
|
||||
const names = new Map()
|
||||
for (let i = 0; i < ids.length; i += NAME_BATCH) {
|
||||
await Promise.all(
|
||||
ids.slice(i, i + NAME_BATCH).map(async (id) => {
|
||||
try {
|
||||
const customer = await getDocument(config.collections.customers, id)
|
||||
names.set(id, trimmed(customer.companyName) || trimmed(customer.name))
|
||||
} catch (err) {
|
||||
if (err?.status !== 404) logFailure('Kundenname laden', err)
|
||||
names.set(id, '')
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag der nächsten Abbuchung (Berliner Tag) oder null: Periodenende eines laufenden Abos, solange keine Kündigung
|
||||
* davor greift (wie in forecast.js: ein gesetztes cancelAt gilt allein; cancelAtPeriodEnd ohne cancelAt heißt, dass
|
||||
* zum Periodenende nichts mehr abgebucht wird). Bei Zahlungsproblemen steht kein Termin fest.
|
||||
*/
|
||||
function nextCharge(subscription) {
|
||||
if (subscription.status !== 'active' && subscription.status !== 'trialing') return null
|
||||
const periodEnd = berlinDayOf(subscription.currentPeriodEnd)
|
||||
if (!periodEnd) return null
|
||||
const cancelAt = berlinDayOf(subscription.cancelAt)
|
||||
if (!cancelAt && subscription.cancelAtPeriodEnd) return null
|
||||
if (cancelAt && periodEnd >= cancelAt) return null
|
||||
return periodEnd
|
||||
}
|
||||
|
||||
const RISK_ORDER = { past_due: 0, unpaid: 0 }
|
||||
|
||||
function buildSubscriptions(subscriptions) {
|
||||
const amount = (subscription) => (isCents(subscription.priceAmount) ? subscription.priceAmount : 0)
|
||||
const sumOf = (statuses) => subscriptions.filter((s) => statuses.includes(s.status)).reduce((sum, s) => sum + amount(s), 0)
|
||||
const payingCustomers = new Set(subscriptions.filter((s) => s.status === 'active' && s.customerId).map((s) => s.customerId))
|
||||
const liste = subscriptions
|
||||
.map((s) => ({
|
||||
customerId: s.customerId ?? '',
|
||||
kunde: s.customerName,
|
||||
produkt: s.productKey ?? s.planKey ?? '',
|
||||
status: s.status,
|
||||
betragCents: amount(s),
|
||||
naechsteAbbuchung: nextCharge(s),
|
||||
}))
|
||||
// Zahlungsprobleme zuerst, dann nach Kunde (Reihenfolge der Eingabe bei Gleichstand)
|
||||
.sort((a, b) => (RISK_ORDER[a.status] ?? 1) - (RISK_ORDER[b.status] ?? 1) || a.kunde.localeCompare(b.kunde, 'de', { sensitivity: 'base' }))
|
||||
return {
|
||||
monatlichCents: sumOf(['active', 'past_due']),
|
||||
zahlendeKunden: payingCustomers.size,
|
||||
gefaehrdetCents: sumOf(['past_due']),
|
||||
liste,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rechnungen ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Tage im Verzug zum Stichtag (0 = nicht fällig); ohne lesbares Fälligkeitsdatum gilt der Wert aus dem Mapping. */
|
||||
function daysLate(invoice, today) {
|
||||
try {
|
||||
return Math.max(0, daysBetween(invoice.dueDate, today))
|
||||
} catch {
|
||||
return invoice.status === 'ueberfaellig' && isCents(invoice.overdueDays) ? invoice.overdueDays : 0
|
||||
}
|
||||
}
|
||||
|
||||
const ageGroupOf = (late) => (late <= 0 ? 'nicht-faellig' : late <= 30 ? '1-30' : late <= 60 ? '31-60' : 'ueber-60')
|
||||
|
||||
function buildInvoiceSummary(invoices, today) {
|
||||
const groups = Object.fromEntries(AGE_GROUPS.map((key) => [key, { key, anzahl: 0, summeCents: 0 }]))
|
||||
const drafts = { anzahl: 0, summeCents: 0 }
|
||||
for (const invoice of invoices) {
|
||||
if (invoice.status === 'entwurf') {
|
||||
drafts.anzahl += 1
|
||||
if (isCents(invoice.totalCents)) drafts.summeCents += invoice.totalCents
|
||||
} else if ((invoice.status === 'offen' || invoice.status === 'ueberfaellig') && isCents(invoice.remainingCents)) {
|
||||
const group = groups[ageGroupOf(daysLate(invoice, today))]
|
||||
group.anzahl += 1
|
||||
group.summeCents += invoice.remainingCents
|
||||
}
|
||||
}
|
||||
return { altersgruppen: AGE_GROUPS.map((key) => groups[key]), entwuerfe: drafts }
|
||||
}
|
||||
|
||||
// --- Übersicht ----------------------------------------------------------------------------------------------
|
||||
|
||||
const isOpenIncoming = (tx) => tx.status === 'BOOK' && isCents(tx.amountCents) && tx.amountCents > 0 && !tx.category
|
||||
|
||||
/**
|
||||
* @param {object} [args]
|
||||
* @param {Date} [args.now] Zeitpunkt; "heute" ist der Berliner Tag dazu
|
||||
* @returns {Promise<{ stand, kopf, aufgaben, vorschau, steuer, abos, rechnungen }>}
|
||||
*/
|
||||
export async function buildOverview({ now = new Date() } = {}) {
|
||||
const today = berlinDate(now)
|
||||
const [connection, transactions, settings, fixedCosts, subscriptions, stripe] = await Promise.all([
|
||||
getConnection(),
|
||||
listTransactions(),
|
||||
getSettings(),
|
||||
listFixedCosts(),
|
||||
listDocuments(config.collections.subscriptions, [Query.limit(SUBSCRIPTION_LIMIT)]),
|
||||
loadStripe(now),
|
||||
])
|
||||
|
||||
// Kundennamen nur für die Abos der Liste
|
||||
const listed = subscriptions.filter((s) => LISTED_STATUSES.has(s.status))
|
||||
const names = await customerNames([...new Set(listed.map((s) => s.customerId).filter((id) => typeof id === 'string' && id))])
|
||||
const named = listed.map((s) => ({ ...s, customerName: names.get(s.customerId) ?? '' }))
|
||||
|
||||
// Kontostand (gebucht) laut letztem Abruf; ohne Zugang oder ohne Abruf unbekannt (null)
|
||||
const balance = connection && isCents(connection.balanceBookedCents) ? connection.balanceBookedCents : null
|
||||
const minBuffer = isCents(settings.minBufferCents) ? settings.minBufferCents : 0
|
||||
|
||||
const forecast = buildForecast({
|
||||
today,
|
||||
balanceCents: balance ?? 0,
|
||||
payouts: stripe.payouts,
|
||||
invoices: stripe.invoices,
|
||||
subscriptions,
|
||||
fixedCosts,
|
||||
settings,
|
||||
})
|
||||
const tax = computeTax({ today, transactions, invoices: stripe.invoices, settings })
|
||||
|
||||
// Zuordnungsvorschläge wie bei GET /buchungen, nur für gebuchte, offene Eingänge; ohne vollständige Stripe-Daten keine
|
||||
const openIncoming = transactions.filter(isOpenIncoming)
|
||||
const todoTransactions = stripe.ok && openIncoming.length ? (await withSuggestions(openIncoming)).buchungen : openIncoming
|
||||
const aufgaben = buildTodos({
|
||||
today,
|
||||
now,
|
||||
connection,
|
||||
invoices: stripe.invoices,
|
||||
transactions: todoTransactions,
|
||||
subscriptions: named,
|
||||
recurring: detectRecurring(transactions, { fixedCosts }),
|
||||
tax,
|
||||
})
|
||||
|
||||
return {
|
||||
stand: { bank: connection ? publicConnection(connection) : null, stripe: stripe.status },
|
||||
kopf: {
|
||||
kontostandCents: balance,
|
||||
verfuegbarCents: balance === null ? null : balance - tax.ruecklageCents - forecast.fixkosten30Cents - minBuffer,
|
||||
aufschluesselung: {
|
||||
kontostandCents: balance,
|
||||
steuerRuecklageCents: tax.ruecklageCents,
|
||||
fixkosten30Cents: forecast.fixkosten30Cents,
|
||||
mindestpufferCents: minBuffer,
|
||||
},
|
||||
// ohne Kontostand wäre der tiefste Stand nur die Summe der Posten, nicht der Stand des Kontos
|
||||
tiefsterSicher: balance === null ? null : forecast.minSicher,
|
||||
tiefsterErwartet: balance === null ? null : forecast.minErwartet,
|
||||
},
|
||||
aufgaben,
|
||||
vorschau: forecast,
|
||||
steuer: tax,
|
||||
abos: buildSubscriptions(named),
|
||||
rechnungen: buildInvoiceSummary(stripe.invoices, today),
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ const CACHE_MS = 60_000
|
||||
const LOOKBACK_DAYS = 400
|
||||
const MAX_INVOICES = 1000
|
||||
|
||||
/** Meldung an die Seite, wenn Stripe nicht antwortet (Übersicht, Rechnungsliste, Schreibzugriffe). */
|
||||
export const STRIPE_UNAVAILABLE_MESSAGE = 'Stripe ist gerade nicht erreichbar.'
|
||||
|
||||
// Stripe-Status -> Status nach aussen; "open" wird je nach Faelligkeit zu "offen" oder "ueberfaellig"
|
||||
const STATUS_DE = { draft: 'entwurf', open: 'offen', paid: 'bezahlt', void: 'storniert', uncollectible: 'uneinbringlich' }
|
||||
|
||||
@@ -118,12 +121,20 @@ const PAYMENT_REFERENCE_NOTE = 'Bitte geben Sie bei Überweisung die Rechnungsnu
|
||||
const ABO_MESSAGE = 'Abo-Rechnungen verwaltet Stripe.'
|
||||
const NO_EMAIL_MESSAGE = 'Für den Versand fehlt die E-Mail-Adresse des Kunden.'
|
||||
|
||||
function httpError(status, message) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
/**
|
||||
* Fehler dieses Moduls mit HTTP-Status und einer Meldung, die die Seite anzeigen darf (nur diese gibt die Route
|
||||
* weiter; Stripe- und Appwrite-Fehler tragen auch status/statusCode, ihre Meldungen bleiben auf dem Server).
|
||||
*/
|
||||
export class InvoiceError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message)
|
||||
this.name = 'InvoiceError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
const httpError = (status, message) => new InvoiceError(status, message)
|
||||
|
||||
/** Rechnungsfuss: Kleinunternehmer-Hinweis, Bankdaten (falls gesetzt), Hinweis auf den Verwendungszweck. */
|
||||
export function invoiceFooter(bankDetailsText = '') {
|
||||
const bank = typeof bankDetailsText === 'string' ? bankDetailsText.trim() : ''
|
||||
|
||||
@@ -273,7 +273,7 @@ function smallBusinessTodos({ today, tax }) {
|
||||
'kleinunternehmer',
|
||||
HOCH,
|
||||
'Kleinunternehmer-Grenze überschritten',
|
||||
`Die Einnahmen ${year} betragen ${euroDe(revenue)} und liegen über der Grenze von ${euroDe(hardLimit)}. Die Kleinunternehmerregelung (§ 19 UStG) ist davon sofort betroffen. Bitte umgehend mit der Steuerberatung klären.`,
|
||||
`Die Einnahmen ${year} betragen ${euroDe(revenue)} und liegen über der Grenze von ${euroDe(hardLimit)}. Ab dem Umsatz, mit dem die Grenze überschritten wurde, gilt die Kleinunternehmerregelung (§ 19 UStG) nicht mehr – auch im laufenden Jahr. Bitte umgehend mit der Steuerberatung klären.`,
|
||||
today,
|
||||
{ typ: 'einstellungen' }
|
||||
),
|
||||
|
||||
@@ -0,0 +1,829 @@
|
||||
import { test, after, beforeEach, mock } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import Stripe from 'stripe'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeAssign.test.js)
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
bankConnections: schemaKeys('bankConnections'),
|
||||
bankTransactions: schemaKeys('bankTransactions'),
|
||||
financeSettings: schemaKeys('financeSettings'),
|
||||
financeFixedCosts: schemaKeys('financeFixedCosts'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { seedFixtures, customers } = await import('./support/fixtures.js')
|
||||
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
|
||||
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
|
||||
const { invalidateFinanceCache } = await import('../server/services/finance/stripeFinance.js')
|
||||
const { getStripe } = await import('../server/services/stripeBilling.js')
|
||||
const { addDays, berlinDate } = await import('../server/services/finance/dates.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { buildOverview } = await import('../server/services/finance/overview.js')
|
||||
|
||||
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
|
||||
after(async () => {
|
||||
mock.restoreAll()
|
||||
await Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
seedFixtures(aw)
|
||||
|
||||
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
|
||||
const staff = { headers: { Authorization: 'Bearer jwt-staff' } }
|
||||
const kunde = { headers: { Authorization: 'Bearer jwt-kunde' } }
|
||||
const base = '/api/admin/finanzen'
|
||||
const COLLECTIONS = () => [
|
||||
config.collections.bankConnections,
|
||||
config.collections.bankTransactions,
|
||||
config.collections.financeSettings,
|
||||
config.collections.financeFixedCosts,
|
||||
config.collections.subscriptions,
|
||||
]
|
||||
const callsTo = (method, path) => st.calls.filter((c) => c.method === method && (path instanceof RegExp ? path.test(c.path) : c.path === path))
|
||||
|
||||
const TODAY = berlinDate()
|
||||
const YEAR = TODAY.slice(0, 4)
|
||||
const at = (days) => addDays(TODAY, days)
|
||||
const unix = (ymd) => Math.floor(new Date(`${ymd}T12:00:00+02:00`).getTime() / 1000)
|
||||
|
||||
async function reset() {
|
||||
mock.restoreAll()
|
||||
invalidateFinanceCache()
|
||||
st.calls.length = 0
|
||||
st.invoices.clear()
|
||||
st.customers.clear()
|
||||
st.payouts.clear()
|
||||
st.customers.set('cus_c1', { id: 'cus_c1', object: 'customer', name: 'Kunde Eins', email: 'k@example.invalid', metadata: { webklarCustomerId: 'c1' } })
|
||||
st.customers.set('cus_c2', { id: 'cus_c2', object: 'customer', name: 'Kunde Zwei', email: 'k2@example.invalid', metadata: { webklarCustomerId: 'c2' } })
|
||||
for (const collection of COLLECTIONS()) {
|
||||
for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id)
|
||||
}
|
||||
// Kunde c2 hat einen Firmennamen: er geht dem Namen vor
|
||||
aw.seed('customers', { ...customers.c2, companyName: 'Zwei GmbH' })
|
||||
}
|
||||
beforeEach(reset)
|
||||
|
||||
// ---------- Testdaten ----------
|
||||
|
||||
const FULL_BANK_KEYS = [
|
||||
'status',
|
||||
'ibanMasked',
|
||||
'validUntil',
|
||||
'lastSyncAt',
|
||||
'lastSyncOk',
|
||||
'lastError',
|
||||
'balanceBookedCents',
|
||||
'balanceAvailableCents',
|
||||
'balanceDate',
|
||||
'latestBookingDate',
|
||||
]
|
||||
|
||||
const seedConnection = (over = {}) =>
|
||||
aw.seed(config.collections.bankConnections, {
|
||||
$id: 'finom',
|
||||
status: 'aktiv',
|
||||
sessionId: 'sess-geheim-4711',
|
||||
accountUid: 'konto-4711',
|
||||
iban: 'DE00000000000000009313',
|
||||
ibanMasked: 'DE00 •••• 9313',
|
||||
validUntil: '2099-03-29T00:00:00.000Z',
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
lastSyncOk: true,
|
||||
lastError: '',
|
||||
balanceBookedCents: 10893,
|
||||
// der Kontostand der Übersicht ist der gebuchte, nicht der verfügbare
|
||||
balanceAvailableCents: 9999,
|
||||
balanceDate: TODAY,
|
||||
latestBookingDate: `${YEAR}-01-09`,
|
||||
...over,
|
||||
})
|
||||
|
||||
const tx = (id, over = {}) => ({
|
||||
$id: id,
|
||||
accountUid: 'konto-4711',
|
||||
status: 'BOOK',
|
||||
bookingDate: `${YEAR}-01-09`,
|
||||
valueDate: `${YEAR}-01-09`,
|
||||
amountCents: -449,
|
||||
currency: 'EUR',
|
||||
counterpartyName: 'PORKBUN.COM',
|
||||
counterpartyIban: '',
|
||||
remittance: '',
|
||||
mcc: '',
|
||||
entryReference: id,
|
||||
category: '',
|
||||
matchedInvoiceId: '',
|
||||
matchedInvoiceNumber: '',
|
||||
matchedPayoutId: '',
|
||||
fixedCostId: '',
|
||||
note: '',
|
||||
syncedAt: new Date().toISOString(),
|
||||
...over,
|
||||
})
|
||||
|
||||
// Die neun Buchungen des echten Abrufs (Betraege wie in financeBankSync.test.js), alle im laufenden Jahr: Einnahmen
|
||||
// 16.879, Ausgaben 5.986 -> Gewinn 10.893, Ruecklage 30 % = 3.268
|
||||
const seedNine = () => {
|
||||
const rows = [
|
||||
tx('b1', { bookingDate: `${YEAR}-01-05`, amountCents: 16879, counterpartyName: 'KUNDE EINS GMBH', remittance: 'Rechnung WK-0005 vom 28.07.2026' }),
|
||||
tx('b2', { bookingDate: `${YEAR}-01-06`, amountCents: -4358, counterpartyName: 'OPENROUTER, INC', mcc: '5734' }),
|
||||
tx('b3', { bookingDate: `${YEAR}-01-07`, amountCents: -131, counterpartyName: 'PNL Fintech B.V.' }),
|
||||
tx('b4', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }),
|
||||
tx('b5', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }),
|
||||
tx('b6', { amountCents: -50, counterpartyName: 'WEBKLAR WARTUNG' }),
|
||||
tx('b7', { amountCents: -449 }),
|
||||
tx('b8', { amountCents: -449 }),
|
||||
tx('b9', { amountCents: -449 }),
|
||||
]
|
||||
for (const row of rows) aw.seed(config.collections.bankTransactions, row)
|
||||
}
|
||||
|
||||
const seedSettings = (over = {}) =>
|
||||
aw.seed(config.collections.financeSettings, {
|
||||
$id: 'main',
|
||||
minBufferCents: 2000,
|
||||
taxReservePercent: 30,
|
||||
previousYearRevenueCents: null,
|
||||
estPrepaymentCents: 0,
|
||||
bankDetailsText: '',
|
||||
...over,
|
||||
})
|
||||
|
||||
// Fixkosten der naechsten 30 Tage: 449 + 1.000 = 1.449; die inaktive Position zaehlt nicht
|
||||
const seedCosts = () => {
|
||||
const costs = config.collections.financeFixedCosts
|
||||
aw.seed(costs, { $id: 'fk1', name: 'Porkbun', amountCents: 449, interval: 'monat', nextDate: at(10), counterpartyMatch: 'porkbun', active: true })
|
||||
aw.seed(costs, { $id: 'fk2', name: 'Hetzner', amountCents: 1000, interval: 'quartal', nextDate: at(5), counterpartyMatch: 'hetzner', active: true })
|
||||
aw.seed(costs, { $id: 'fk3', name: 'Alt', amountCents: 2500, interval: 'monat', nextDate: at(3), counterpartyMatch: '', active: false })
|
||||
}
|
||||
|
||||
const seedSub = (id, over = {}) =>
|
||||
aw.seed(config.collections.subscriptions, {
|
||||
$id: id,
|
||||
customerId: 'c1',
|
||||
status: 'active',
|
||||
productKey: 'hosting',
|
||||
planKey: 'hosting_monat',
|
||||
priceAmount: 2000,
|
||||
currentPeriodEnd: new Date(`${at(20)}T12:00:00+02:00`).toISOString(),
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
const rawInvoice = (over = {}) => ({
|
||||
id: 'in_open',
|
||||
object: 'invoice',
|
||||
number: 'WK-0005',
|
||||
status: 'open',
|
||||
customer: 'cus_c1',
|
||||
customer_name: 'Kunde Eins',
|
||||
customer_email: 'k@example.invalid',
|
||||
created: unix(at(-5)),
|
||||
due_date: unix(at(2)),
|
||||
total: 16879,
|
||||
amount_due: 16879,
|
||||
amount_paid: 0,
|
||||
amount_remaining: 16879,
|
||||
status_transitions: { paid_at: null },
|
||||
metadata: { webklarCustomerId: 'c1' },
|
||||
lines: { object: 'list', data: [], has_more: false },
|
||||
...over,
|
||||
})
|
||||
const putInvoice = (over) => {
|
||||
const invoice = rawInvoice(over)
|
||||
st.invoices.set(invoice.id, invoice)
|
||||
return invoice
|
||||
}
|
||||
|
||||
// Eine offene Projekt-, eine ueberfaellige und eine bezahlte Rechnung, dazu ein junger Entwurf
|
||||
function seedStripe() {
|
||||
putInvoice()
|
||||
putInvoice({
|
||||
id: 'in_over',
|
||||
number: 'WK-0006',
|
||||
customer: 'cus_c2',
|
||||
customer_name: 'Kunde Zwei',
|
||||
created: unix(at(-30)),
|
||||
due_date: unix(at(-10)),
|
||||
total: 5000,
|
||||
amount_due: 5000,
|
||||
amount_remaining: 5000,
|
||||
metadata: { webklarCustomerId: 'c2' },
|
||||
})
|
||||
putInvoice({
|
||||
id: 'in_paid',
|
||||
number: 'WK-0004',
|
||||
status: 'paid',
|
||||
created: unix(at(-20)),
|
||||
due_date: unix(at(-6)),
|
||||
total: 8000,
|
||||
amount_due: 0,
|
||||
amount_paid: 8000,
|
||||
amount_remaining: 0,
|
||||
status_transitions: { paid_at: unix(TODAY) },
|
||||
})
|
||||
putInvoice({ id: 'in_draft', number: null, status: 'draft', created: unix(TODAY), due_date: null, total: 3000, amount_due: 3000, amount_remaining: 3000 })
|
||||
}
|
||||
|
||||
function seedAll() {
|
||||
seedConnection()
|
||||
seedNine()
|
||||
seedSettings()
|
||||
seedCosts()
|
||||
seedSub('sub_a', { customerId: 'c1', status: 'active', priceAmount: 2000, productKey: 'hosting' })
|
||||
seedSub('sub_b', { customerId: 'c2', status: 'past_due', priceAmount: 5000, productKey: 'wartung', planKey: 'wartung_monat' })
|
||||
seedStripe()
|
||||
}
|
||||
|
||||
const uebersicht = () => get(`${base}/uebersicht`, admin)
|
||||
const ART = (body) => body.aufgaben.map((t) => t.art)
|
||||
|
||||
const request = async (method, path, { body, headers } = {}) => {
|
||||
const res = await fetch(app.url + path, {
|
||||
method,
|
||||
headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...(headers || {}) },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await res.text()
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null }
|
||||
}
|
||||
const del = (path) => request('DELETE', path, admin)
|
||||
|
||||
const apiError = (message, extra = {}) => new Stripe.errors.StripeAPIError({ message, type: 'api_error', statusCode: 500, ...extra })
|
||||
/** Stripe liefert weder Rechnungen noch Auszahlungen (wie bei einem Ausfall). */
|
||||
function stripeDown() {
|
||||
const error = apiError('Interner Stripe-Fehler sk_live_geheim')
|
||||
mock.method(getStripe().invoices, 'list', () => ({ autoPagingToArray: () => Promise.reject(error) }))
|
||||
mock.method(getStripe().payouts, 'list', () => Promise.reject(error))
|
||||
}
|
||||
|
||||
// ---------- Zugriff ----------
|
||||
|
||||
test('alle neuen Routen: ohne JWT 401, Mitarbeiter ohne Admin-Label und Kunden 403', async () => {
|
||||
putInvoice({ id: 'in_draft', status: 'draft', number: null })
|
||||
const calls = [
|
||||
['GET', `${base}/uebersicht`],
|
||||
['GET', `${base}/rechnungen`],
|
||||
['POST', `${base}/rechnungen`, { customerId: 'c1', items: [{ description: 'x', quantity: 1, unitAmountCents: 100 }] }],
|
||||
['POST', `${base}/rechnungen/in_draft/senden`, {}],
|
||||
['POST', `${base}/rechnungen/in_draft/stornieren`, {}],
|
||||
['DELETE', `${base}/rechnungen/in_draft`],
|
||||
]
|
||||
for (const [method, path, body] of calls) {
|
||||
assert.equal((await request(method, path, { body })).status, 401, `${method} ${path} ohne JWT`)
|
||||
assert.equal((await request(method, path, { body, ...staff })).status, 403, `${method} ${path} Mitarbeiter`)
|
||||
assert.equal((await request(method, path, { body, ...kunde })).status, 403, `${method} ${path} Kunde`)
|
||||
}
|
||||
assert.equal(st.invoices.size, 1)
|
||||
assert.equal(st.invoices.get('in_draft').status, 'draft')
|
||||
assert.equal(callsTo('POST', '/v1/invoices').length, 0)
|
||||
})
|
||||
|
||||
// ---------- GET /uebersicht ----------
|
||||
|
||||
test('GET /uebersicht: Kopf, Abos, Altersgruppen und Aufgaben aus den Testdaten', async () => {
|
||||
seedAll()
|
||||
const body = await uebersicht()
|
||||
assert.deepEqual(Object.keys(body), ['stand', 'kopf', 'aufgaben', 'vorschau', 'steuer', 'abos', 'rechnungen'])
|
||||
|
||||
// Kopf: 10.893 - Ruecklage 3.268 - Fixkosten 1.449 - Puffer 2.000 = 4.176
|
||||
assert.equal(body.kopf.kontostandCents, 10893)
|
||||
assert.equal(body.kopf.verfuegbarCents, 4176)
|
||||
assert.deepEqual(body.kopf.aufschluesselung, { kontostandCents: 10893, steuerRuecklageCents: 3268, fixkosten30Cents: 1449, mindestpufferCents: 2000 })
|
||||
// sicher: 10.893 - 1.000 (in 5 Tagen) - 449 (in 10 Tagen); erwartet: die Rechnung in 2 Tagen hebt den Stand, der Tiefpunkt ist heute
|
||||
assert.deepEqual(body.kopf.tiefsterSicher, { amountCents: 9444, date: at(10) })
|
||||
assert.deepEqual(body.kopf.tiefsterErwartet, { amountCents: 10893, date: TODAY })
|
||||
assert.deepEqual(body.kopf.tiefsterSicher, body.vorschau.minSicher)
|
||||
assert.deepEqual(body.kopf.tiefsterErwartet, body.vorschau.minErwartet)
|
||||
|
||||
// Vorschau: Fixkosten (ohne die inaktive Position), offene und ueberfaellige Rechnung, Abo-Verlaengerung
|
||||
assert.deepEqual(
|
||||
body.vorschau.posten.map((p) => [p.label, p.amountCents, p.sicherheit]),
|
||||
[
|
||||
['Rechnung WK-0005 · Kunde Eins', 16879, 'erwartet'],
|
||||
['Hetzner', -1000, 'sicher'],
|
||||
['Porkbun', -449, 'sicher'],
|
||||
['Rechnung WK-0006 · Kunde Zwei (überfällig, angenommen)', 5000, 'erwartet'],
|
||||
['Abo-Verlängerung', 2000, 'erwartet'],
|
||||
]
|
||||
)
|
||||
assert.equal(body.vorschau.fixkosten30Cents, 1449)
|
||||
assert.equal(typeof body.vorschau.annahme, 'string')
|
||||
|
||||
// Steuer
|
||||
assert.equal(body.steuer.jahr, Number(YEAR))
|
||||
assert.equal(body.steuer.ruecklageCents, 3268)
|
||||
assert.equal(body.steuer.gewinnCents, 10893)
|
||||
assert.equal(body.steuer.kleinunternehmer.jahrCents, 8000)
|
||||
|
||||
// Abos: aktiv 2.000 + past_due 5.000 = 7.000; gefaehrdet 5.000; ein zahlender Kunde (nur active zaehlt)
|
||||
assert.equal(body.abos.monatlichCents, 7000)
|
||||
assert.equal(body.abos.gefaehrdetCents, 5000)
|
||||
assert.equal(body.abos.zahlendeKunden, 1)
|
||||
assert.deepEqual(body.abos.liste, [
|
||||
{ customerId: 'c2', kunde: 'Zwei GmbH', produkt: 'wartung', status: 'past_due', betragCents: 5000, naechsteAbbuchung: null },
|
||||
{ customerId: 'c1', kunde: 'Kunde Eins', produkt: 'hosting', status: 'active', betragCents: 2000, naechsteAbbuchung: at(20) },
|
||||
])
|
||||
|
||||
// Altersgruppen: die offene (in 2 Tagen faellig) ist nicht faellig, die ueberfaellige 10 Tage im Verzug
|
||||
assert.deepEqual(body.rechnungen.altersgruppen, [
|
||||
{ key: 'nicht-faellig', anzahl: 1, summeCents: 16879 },
|
||||
{ key: '1-30', anzahl: 1, summeCents: 5000 },
|
||||
{ key: '31-60', anzahl: 0, summeCents: 0 },
|
||||
{ key: 'ueber-60', anzahl: 0, summeCents: 0 },
|
||||
])
|
||||
assert.deepEqual(body.rechnungen.entwuerfe, { anzahl: 1, summeCents: 3000 })
|
||||
|
||||
// Aufgaben: ueberfaellige Rechnung, fehlgeschlagene Abo-Zahlung (mit Kundenname) und die Zuordnung mit Vorschlag
|
||||
const arts = ART(body)
|
||||
assert.ok(arts.includes('ueberfaellig'))
|
||||
assert.ok(arts.includes('abo-zahlung'))
|
||||
const abo = body.aufgaben.find((t) => t.art === 'abo-zahlung')
|
||||
assert.ok(abo.text.startsWith('Zwei GmbH: '), abo.text)
|
||||
assert.deepEqual(abo.aktion, { typ: 'kunde-oeffnen', customerId: 'c2' })
|
||||
const zuordnung = body.aufgaben.filter((t) => t.art === 'zuordnung')
|
||||
assert.equal(zuordnung.length, 1)
|
||||
assert.ok(zuordnung[0].text.endsWith('Vorschlag: Rechnung WK-0005'), zuordnung[0].text)
|
||||
assert.equal(zuordnung[0].aktion.transactionId, 'b1')
|
||||
assert.ok(!arts.includes('bank-zugang') && !arts.includes('bank-abruf'))
|
||||
})
|
||||
|
||||
test('GET /uebersicht: Statuszeile mit Bank (nur die oeffentlichen Felder) und Stripe', async () => {
|
||||
seedAll()
|
||||
const before = Date.now()
|
||||
const body = await uebersicht()
|
||||
assert.deepEqual(Object.keys(body.stand), ['bank', 'stripe'])
|
||||
assert.deepEqual(Object.keys(body.stand.bank), FULL_BANK_KEYS)
|
||||
assert.equal(body.stand.bank.status, 'aktiv')
|
||||
assert.equal(body.stand.bank.balanceBookedCents, 10893)
|
||||
assert.equal(body.stand.bank.balanceAvailableCents, 9999)
|
||||
assert.equal(body.stand.bank.ibanMasked, 'DE00 •••• 9313')
|
||||
const raw = JSON.stringify(body)
|
||||
for (const secret of ['sess-geheim-4711', 'konto-4711', 'DE00000000000000009313']) assert.ok(!raw.includes(secret), `${secret} darf nicht an die Seite`)
|
||||
|
||||
assert.equal(body.stand.stripe.ok, true)
|
||||
assert.equal(body.stand.stripe.fehler, undefined)
|
||||
const fetchedAt = Date.parse(body.stand.stripe.abgerufenUm)
|
||||
assert.ok(fetchedAt >= before - 1000 && fetchedAt <= Date.now() + 1000)
|
||||
})
|
||||
|
||||
test('GET /uebersicht: Stripe wird je Art nur einmal gefragt (Vorschlaege teilen den Zwischenspeicher)', async () => {
|
||||
seedAll()
|
||||
await uebersicht()
|
||||
assert.equal(callsTo('GET', '/v1/invoices').length, 1)
|
||||
assert.equal(callsTo('GET', '/v1/payouts').length, 1)
|
||||
})
|
||||
|
||||
test('GET /uebersicht: Stripe-Auszahlungen unterwegs zaehlen zur sicheren Linie', async () => {
|
||||
seedConnection()
|
||||
st.payouts.set('po_1', { id: 'po_1', object: 'payout', amount: 45000, arrival_date: unix(at(3)), status: 'in_transit', currency: 'eur' })
|
||||
const body = await uebersicht()
|
||||
assert.deepEqual(body.vorschau.posten, [{ date: at(3), label: 'Stripe-Auszahlung', amountCents: 45000, sicherheit: 'sicher' }])
|
||||
assert.equal(body.vorschau.endeSicherCents, 55893)
|
||||
})
|
||||
|
||||
test('GET /uebersicht: leere Daten -> 200, flache Vorschau, nur Bank-Aufgaben', async () => {
|
||||
seedConnection({ validUntil: `${YEAR}-12-31T00:00:00.000Z` })
|
||||
const res = await rawGet(`${base}/uebersicht`, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const body = res.body
|
||||
assert.deepEqual(body.vorschau.posten, [])
|
||||
assert.deepEqual(body.vorschau.minSicher, { amountCents: 10893, date: TODAY })
|
||||
assert.deepEqual(body.vorschau.minErwartet, { amountCents: 10893, date: TODAY })
|
||||
assert.equal(body.vorschau.endeSicherCents, 10893)
|
||||
assert.equal(body.vorschau.endeErwartetCents, 10893)
|
||||
// keine Einstellungen gespeichert: Standardwerte (Puffer 0), keine Ruecklage ohne Gewinn
|
||||
assert.equal(body.kopf.kontostandCents, 10893)
|
||||
assert.equal(body.kopf.verfuegbarCents, 10893)
|
||||
assert.deepEqual(body.abos, { monatlichCents: 0, zahlendeKunden: 0, gefaehrdetCents: 0, liste: [] })
|
||||
assert.deepEqual(body.rechnungen, {
|
||||
altersgruppen: [
|
||||
{ key: 'nicht-faellig', anzahl: 0, summeCents: 0 },
|
||||
{ key: '1-30', anzahl: 0, summeCents: 0 },
|
||||
{ key: '31-60', anzahl: 0, summeCents: 0 },
|
||||
{ key: 'ueber-60', anzahl: 0, summeCents: 0 },
|
||||
],
|
||||
entwuerfe: { anzahl: 0, summeCents: 0 },
|
||||
})
|
||||
for (const art of ART(body)) assert.ok(art.startsWith('bank-'), `unerwartete Aufgabe ${art}`)
|
||||
assert.equal(body.stand.stripe.ok, true)
|
||||
})
|
||||
|
||||
test('GET /uebersicht: ohne Bankzugang -> Aufgabe "Bankkonto verbinden", Kontostand unbekannt (null)', async () => {
|
||||
const res = await rawGet(`${base}/uebersicht`, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.stand.bank, null)
|
||||
assert.equal(res.body.kopf.kontostandCents, null)
|
||||
assert.equal(res.body.kopf.verfuegbarCents, null)
|
||||
assert.equal(res.body.kopf.tiefsterSicher, null)
|
||||
assert.equal(res.body.kopf.tiefsterErwartet, null)
|
||||
assert.deepEqual(res.body.kopf.aufschluesselung, { kontostandCents: null, steuerRuecklageCents: 0, fixkosten30Cents: 0, mindestpufferCents: 0 })
|
||||
assert.deepEqual(ART(res.body), ['bank-zugang'])
|
||||
})
|
||||
|
||||
test('GET /uebersicht: Stripe 500 -> 200, Bankteil, Kopf und Aufgaben aus Bank und Abos, Stripe-Block leer', async () => {
|
||||
seedAll()
|
||||
stripeDown()
|
||||
const res = await rawGet(`${base}/uebersicht`, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const body = res.body
|
||||
assert.deepEqual(body.stand.stripe, { ok: false, fehler: 'Stripe ist gerade nicht erreichbar.', abgerufenUm: null })
|
||||
assert.ok(!JSON.stringify(body).includes('sk_live_geheim'))
|
||||
assert.equal(body.stand.bank.balanceBookedCents, 10893)
|
||||
|
||||
// Kopf ohne Stripe-Posten: Fixkosten und Abo-Verlaengerung (Appwrite) bleiben, Rechnungen fehlen
|
||||
assert.equal(body.kopf.kontostandCents, 10893)
|
||||
assert.equal(body.kopf.verfuegbarCents, 4176)
|
||||
assert.deepEqual(body.vorschau.posten.map((p) => p.label), ['Hetzner', 'Porkbun', 'Abo-Verlängerung'])
|
||||
assert.equal(body.steuer.kleinunternehmer.jahrCents, 0)
|
||||
assert.deepEqual(body.rechnungen.altersgruppen.map((g) => g.anzahl), [0, 0, 0, 0])
|
||||
assert.deepEqual(body.rechnungen.entwuerfe, { anzahl: 0, summeCents: 0 })
|
||||
|
||||
// Aufgaben aus Bank- und Abodaten; die Zuordnung hat ohne Stripe keinen Vorschlag
|
||||
const arts = ART(body)
|
||||
assert.ok(arts.includes('abo-zahlung'))
|
||||
assert.ok(!arts.includes('ueberfaellig'))
|
||||
const zuordnung = body.aufgaben.find((t) => t.art === 'zuordnung')
|
||||
assert.ok(zuordnung)
|
||||
assert.ok(!zuordnung.text.includes('Vorschlag'))
|
||||
assert.equal(body.abos.monatlichCents, 7000)
|
||||
})
|
||||
|
||||
test('GET /uebersicht: nur die Auszahlungen scheitern -> Rechnungen bleiben, Stripe-Block meldet den Fehler', async () => {
|
||||
seedAll()
|
||||
st.failNext({ method: 'GET', path: '/v1/payouts', status: 400, message: 'Geheimer Stripe-Text' })
|
||||
const body = await uebersicht()
|
||||
assert.equal(body.stand.stripe.ok, false)
|
||||
assert.equal(body.stand.stripe.fehler, 'Stripe ist gerade nicht erreichbar.')
|
||||
assert.ok(!JSON.stringify(body).includes('Geheimer'))
|
||||
assert.ok(ART(body).includes('ueberfaellig'))
|
||||
assert.equal(body.rechnungen.altersgruppen[1].anzahl, 1)
|
||||
// ohne vollstaendige Stripe-Daten gibt es keine Zuordnungsvorschlaege
|
||||
assert.ok(!body.aufgaben.find((t) => t.art === 'zuordnung').text.includes('Vorschlag'))
|
||||
})
|
||||
|
||||
test('GET /uebersicht: Altersgruppen an den Grenzen 30/31 und 60/61 Tage Verzug, Summen nach offenem Betrag', async () => {
|
||||
seedConnection()
|
||||
const late = (id, days, remaining) =>
|
||||
putInvoice({
|
||||
id,
|
||||
number: id.toUpperCase(),
|
||||
created: unix(at(-days - 14)),
|
||||
due_date: unix(at(-days)),
|
||||
total: remaining + 100,
|
||||
amount_due: remaining + 100,
|
||||
amount_remaining: remaining,
|
||||
metadata: {},
|
||||
})
|
||||
late('in_a', 1, 100)
|
||||
late('in_b', 30, 200)
|
||||
late('in_c', 31, 400)
|
||||
late('in_d', 60, 800)
|
||||
late('in_e', 61, 1600)
|
||||
putInvoice({ id: 'in_f', number: 'IN_F', due_date: unix(TODAY), total: 50, amount_due: 50, amount_remaining: 50, metadata: {} })
|
||||
// bezahlt, storniert und Entwurf zaehlen nicht zu den Altersgruppen
|
||||
putInvoice({ id: 'in_g', status: 'paid', amount_remaining: 0, amount_paid: 999, due_date: unix(at(-90)), metadata: {} })
|
||||
putInvoice({ id: 'in_h', status: 'void', due_date: unix(at(-90)), metadata: {} })
|
||||
const body = await uebersicht()
|
||||
assert.deepEqual(body.rechnungen.altersgruppen, [
|
||||
{ key: 'nicht-faellig', anzahl: 1, summeCents: 50 },
|
||||
{ key: '1-30', anzahl: 2, summeCents: 300 },
|
||||
{ key: '31-60', anzahl: 2, summeCents: 1200 },
|
||||
{ key: 'ueber-60', anzahl: 1, summeCents: 1600 },
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /uebersicht: Abo eines unbekannten Kunden und Fixkosten-Verweis auf eine geloeschte Position bringen nichts zu Fall', async () => {
|
||||
seedConnection()
|
||||
seedSub('sub_g', { customerId: 'geist', status: 'past_due', priceAmount: 700 })
|
||||
aw.seed(config.collections.bankTransactions, tx('b1', { category: 'fixkosten', fixedCostId: 'geloescht', amountCents: -449 }))
|
||||
const res = await rawGet(`${base}/uebersicht`, admin)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(res.body.abos.liste, [{ customerId: 'geist', kunde: '', produkt: 'hosting', status: 'past_due', betragCents: 700, naechsteAbbuchung: null }])
|
||||
const abo = res.body.aufgaben.find((t) => t.art === 'abo-zahlung')
|
||||
assert.ok(abo)
|
||||
assert.ok(!abo.text.startsWith(': '))
|
||||
})
|
||||
|
||||
test('GET /uebersicht: Abo-Liste - Status, Produkt, kuendigende Abos ohne naechste Abbuchung, beendete Abos fehlen', async () => {
|
||||
seedConnection()
|
||||
const end = new Date(`${at(10)}T12:00:00+02:00`).toISOString()
|
||||
seedSub('s1', { customerId: 'c1', status: 'active', productKey: 'hosting' })
|
||||
seedSub('s2', { customerId: 'c2', status: 'active', productKey: undefined, planKey: 'wartung_monat', priceAmount: 1000, currentPeriodEnd: end, cancelAtPeriodEnd: true, cancelAt: null })
|
||||
seedSub('s3', { customerId: 'c3', status: 'active', priceAmount: 300, currentPeriodEnd: end, cancelAtPeriodEnd: true, cancelAt: new Date(`${at(10)}T12:00:00+02:00`).toISOString() })
|
||||
seedSub('s4', { customerId: 'c4', status: 'canceled', priceAmount: 9999 })
|
||||
seedSub('s5', { customerId: 'c1', status: 'unpaid', priceAmount: 400 })
|
||||
const body = await uebersicht()
|
||||
const byId = Object.fromEntries(body.abos.liste.map((a) => [`${a.customerId}:${a.status}`, a]))
|
||||
assert.equal(body.abos.liste.length, 4)
|
||||
assert.equal(byId['c1:active'].naechsteAbbuchung, at(20))
|
||||
// Stripe bucht zum Periodenende nichts mehr ab: keine naechste Abbuchung; das Abo zaehlt bis dahin zur Summe
|
||||
assert.equal(byId['c2:active'].naechsteAbbuchung, null)
|
||||
assert.equal(byId['c2:active'].produkt, 'wartung_monat')
|
||||
assert.equal(byId['c3:active'].naechsteAbbuchung, null)
|
||||
assert.equal(byId['c1:unpaid'].betragCents, 400)
|
||||
assert.ok(!('c4:canceled' in byId))
|
||||
// active 2000 + 1000 + 300; unpaid und canceled zaehlen nicht
|
||||
assert.equal(body.abos.monatlichCents, 3300)
|
||||
assert.equal(body.abos.gefaehrdetCents, 0)
|
||||
assert.equal(body.abos.zahlendeKunden, 3)
|
||||
assert.ok(ART(body).includes('abo-zahlung'))
|
||||
})
|
||||
|
||||
test('GET /uebersicht: ueber 100.000 Euro Einnahmen -> Kleinunternehmer-Aufgabe mit dem engeren Text', async () => {
|
||||
seedConnection()
|
||||
putInvoice({ id: 'in_big', status: 'paid', total: 10100000, amount_due: 0, amount_paid: 10100000, amount_remaining: 0, status_transitions: { paid_at: unix(TODAY) }, metadata: {} })
|
||||
const body = await uebersicht()
|
||||
const klein = body.aufgaben.find((t) => t.art === 'kleinunternehmer')
|
||||
assert.ok(klein)
|
||||
assert.equal(klein.dringlichkeit, 'hoch')
|
||||
assert.ok(klein.text.includes('nicht mehr – auch im laufenden Jahr'), klein.text)
|
||||
})
|
||||
|
||||
test('buildOverview: ein Zeitpunkt (now) bestimmt "heute"', async () => {
|
||||
seedConnection()
|
||||
const overview = await buildOverview({ now: new Date('2031-05-17T08:00:00Z') })
|
||||
assert.equal(overview.vorschau.minSicher.date, '2031-05-17')
|
||||
assert.equal(overview.steuer.jahr, 2031)
|
||||
})
|
||||
|
||||
test('GET /uebersicht: Appwrite scheitert -> 500 ohne Appwrite-Meldung', async () => {
|
||||
seedConnection()
|
||||
aw.failNext({ collection: config.collections.bankTransactions, method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' })
|
||||
const res = await rawGet(`${base}/uebersicht`, admin)
|
||||
assert.equal(res.status, 500)
|
||||
assert.equal(res.body.error, 'Die Übersicht konnte nicht geladen werden.')
|
||||
assert.ok(!JSON.stringify(res.body).includes('10.0.0.9'))
|
||||
})
|
||||
|
||||
// ---------- GET /rechnungen ----------
|
||||
|
||||
test('GET /rechnungen: Standard ist alle, neueste zuerst; Statusfilter', async () => {
|
||||
seedStripe()
|
||||
const all = await get(`${base}/rechnungen`, admin)
|
||||
assert.deepEqual(Object.keys(all), ['rechnungen'])
|
||||
assert.deepEqual(all.rechnungen.map((r) => r.id), ['in_draft', 'in_open', 'in_paid', 'in_over'])
|
||||
assert.deepEqual((await get(`${base}/rechnungen?status=alle`, admin)).rechnungen.map((r) => r.id), ['in_draft', 'in_open', 'in_paid', 'in_over'])
|
||||
const byStatus = async (status) => (await get(`${base}/rechnungen?status=${status}`, admin)).rechnungen.map((r) => r.id)
|
||||
assert.deepEqual(await byStatus('ueberfaellig'), ['in_over'])
|
||||
assert.deepEqual(await byStatus('offen'), ['in_open'])
|
||||
assert.deepEqual(await byStatus('bezahlt'), ['in_paid'])
|
||||
assert.deepEqual(await byStatus('entwurf'), ['in_draft'])
|
||||
const [over] = (await get(`${base}/rechnungen?status=ueberfaellig`, admin)).rechnungen
|
||||
assert.equal(over.status, 'ueberfaellig')
|
||||
assert.equal(over.overdueDays, 10)
|
||||
assert.equal(over.number, 'WK-0006')
|
||||
assert.equal(over.remainingCents, 5000)
|
||||
})
|
||||
|
||||
test('GET /rechnungen: kunde filtert auf webklarCustomerId, auch zusammen mit dem Status', async () => {
|
||||
seedStripe()
|
||||
const ids = async (query) => (await get(`${base}/rechnungen?${query}`, admin)).rechnungen.map((r) => r.id)
|
||||
assert.deepEqual(await ids('kunde=c1'), ['in_draft', 'in_open', 'in_paid'])
|
||||
assert.deepEqual(await ids('kunde=c2'), ['in_over'])
|
||||
assert.deepEqual(await ids('kunde=c1&status=bezahlt'), ['in_paid'])
|
||||
assert.deepEqual(await ids('kunde=c9'), [])
|
||||
})
|
||||
|
||||
test('GET /rechnungen: unbekannter Status oder mehrfache Parameter -> 400', async () => {
|
||||
seedStripe()
|
||||
for (const query of ['status=irgendwas', 'status=offen&status=bezahlt', 'kunde=a&kunde=b']) {
|
||||
const res = await rawGet(`${base}/rechnungen?${query}`, admin)
|
||||
assert.equal(res.status, 400, query)
|
||||
assert.equal(typeof res.body.error, 'string')
|
||||
}
|
||||
assert.equal(callsTo('GET', '/v1/invoices').length, 0)
|
||||
})
|
||||
|
||||
test('GET /rechnungen: Stripe nicht erreichbar -> 502 mit deutscher Meldung', async () => {
|
||||
stripeDown()
|
||||
const res = await rawGet(`${base}/rechnungen`, admin)
|
||||
assert.equal(res.status, 502)
|
||||
assert.equal(res.body.error, 'Stripe ist gerade nicht erreichbar.')
|
||||
assert.ok(!JSON.stringify(res.body).includes('sk_live_geheim'))
|
||||
})
|
||||
|
||||
// ---------- POST /rechnungen ----------
|
||||
|
||||
const ITEMS = [
|
||||
{ description: 'Website-Anpassung', quantity: 2, unitAmountCents: 5000 },
|
||||
{ description: 'Domain', quantity: 1, unitAmountCents: 1200 },
|
||||
]
|
||||
const postInvoice = (body) => post(`${base}/rechnungen`, body, admin)
|
||||
|
||||
test('POST /rechnungen: Entwurf mit Standard-Zahlungsziel 14 Tage, createdBy aus dem Admin-Konto', async () => {
|
||||
const res = await postInvoice({ customerId: 'c1', items: ITEMS, memo: 'Danke' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.deepEqual(Object.keys(res.body), ['rechnung'])
|
||||
assert.equal(res.body.rechnung.status, 'entwurf')
|
||||
assert.equal(res.body.rechnung.totalCents, 11200)
|
||||
assert.equal(res.body.rechnung.webklarCustomerId, 'c1')
|
||||
const [create] = callsTo('POST', '/v1/invoices')
|
||||
assert.equal(create.body.days_until_due, '14')
|
||||
assert.equal(create.body['metadata[erstelltVon]'], 'kenso@webklar.com')
|
||||
assert.equal(create.body.description, 'Danke')
|
||||
assert.equal(callsTo('POST', /\/finalize$/).length, 0)
|
||||
assert.equal(callsTo('POST', /\/send$/).length, 0)
|
||||
})
|
||||
|
||||
test('POST /rechnungen: daysUntilDue wird uebernommen, null zaehlt als fehlend', async () => {
|
||||
assert.equal((await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: 30 })).status, 200)
|
||||
assert.equal(callsTo('POST', '/v1/invoices')[0].body.days_until_due, '30')
|
||||
assert.equal((await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: null })).status, 200)
|
||||
assert.equal(callsTo('POST', '/v1/invoices')[1].body.days_until_due, '14')
|
||||
const bad = await postInvoice({ customerId: 'c1', items: ITEMS, daysUntilDue: 61 })
|
||||
assert.equal(bad.status, 400)
|
||||
assert.equal(bad.body.error, 'Das Zahlungsziel muss eine ganze Zahl von 0 bis 60 Tagen sein.')
|
||||
})
|
||||
|
||||
for (const senden of [true, 'true']) {
|
||||
test(`POST /rechnungen: senden ${JSON.stringify(senden)} -> festgeschrieben und gesendet (offen)`, async () => {
|
||||
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.rechnung.status, 'offen')
|
||||
assert.match(res.body.rechnung.number, /^WK-\d{4}$/)
|
||||
assert.equal(callsTo('POST', /\/finalize$/).length, 1)
|
||||
assert.equal(callsTo('POST', /\/send$/).length, 1)
|
||||
})
|
||||
}
|
||||
|
||||
for (const senden of [false, 'false', 'ja', 1, undefined, null]) {
|
||||
test(`POST /rechnungen: senden ${JSON.stringify(senden)} bleibt ein Entwurf`, async () => {
|
||||
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(res.body.rechnung.status, 'entwurf')
|
||||
assert.equal(callsTo('POST', /\/send$/).length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('POST /rechnungen: ungueltige Eingaben -> 400 mit Meldung, nichts bei Stripe', async () => {
|
||||
aw.seed('customers', { $id: 'lead1', email: 'lead@example.invalid', name: 'Interessent', customerStatus: 'lead', status: 'active' })
|
||||
const cases = [
|
||||
[{ customerId: 'c1', items: [] }, 'Eine Rechnung braucht 1 bis 20 Positionen.'],
|
||||
[{ customerId: 'c1' }, 'Eine Rechnung braucht 1 bis 20 Positionen.'],
|
||||
[{ customerId: 'c1', items: [{ description: 'x', quantity: 0, unitAmountCents: 100 }] }, 'Position 1: Die Menge muss eine ganze Zahl von 1 bis 999 sein.'],
|
||||
[{ customerId: 'lead1', items: ITEMS }, 'Rechnungen nur für feste Kunden.'],
|
||||
[{ customerId: 'gibtsnicht', items: ITEMS }, 'Kunde nicht gefunden.'],
|
||||
[{ items: ITEMS }, 'Kunde nicht gefunden.'],
|
||||
]
|
||||
for (const [body, message] of cases) {
|
||||
const res = await postInvoice(body)
|
||||
assert.equal(res.status, 400, JSON.stringify(body))
|
||||
assert.equal(res.body.error, message)
|
||||
}
|
||||
assert.equal(callsTo('POST', '/v1/invoices').length, 0)
|
||||
assert.equal(st.invoices.size, 0)
|
||||
})
|
||||
|
||||
test('POST /rechnungen: ohne Body -> 400, kein Absturz', async () => {
|
||||
const res = await request('POST', `${base}/rechnungen`, { ...admin })
|
||||
assert.equal(res.status, 400)
|
||||
})
|
||||
|
||||
test('POST /rechnungen: Senden scheitert nach dem Festschreiben -> 502 mit invoiceId und Rechnungsnummer', async () => {
|
||||
st.failNext({ method: 'POST', path: /\/send$/, status: 400, message: 'Geheimer Sendetext sk_live_abc' })
|
||||
const res = await postInvoice({ customerId: 'c1', items: ITEMS, senden: true })
|
||||
assert.equal(res.status, 502)
|
||||
assert.match(res.body.invoiceId, /^in_/)
|
||||
assert.match(res.body.invoiceNumber, /^WK-\d{4}$/)
|
||||
assert.equal(res.body.error, `Rechnung ${res.body.invoiceNumber} wurde festgeschrieben, aber nicht versendet. Bitte erneut senden.`)
|
||||
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['error', 'invoiceId', 'invoiceNumber'])
|
||||
// die Rechnung ist offen und laesst sich erneut senden
|
||||
assert.equal(st.invoices.get(res.body.invoiceId).status, 'open')
|
||||
const again = await post(`${base}/rechnungen/${res.body.invoiceId}/senden`, {}, admin)
|
||||
assert.equal(again.status, 200, JSON.stringify(again.body))
|
||||
assert.equal(again.body.rechnung.status, 'offen')
|
||||
})
|
||||
|
||||
test('POST /rechnungen: Stripe lehnt ab (4xx) -> 409, ueberspringt Stripe-Text; Stripe nicht erreichbar -> 502', async () => {
|
||||
st.failNext({ method: 'POST', path: '/v1/invoices', status: 400, message: 'Geheimer Stripe-Text' })
|
||||
const rejected = await postInvoice({ customerId: 'c1', items: ITEMS })
|
||||
assert.equal(rejected.status, 409)
|
||||
assert.equal(rejected.body.error, 'Stripe hat die Änderung abgelehnt.')
|
||||
assert.ok(!JSON.stringify(rejected.body).includes('Geheimer'))
|
||||
|
||||
mock.method(getStripe().invoices, 'create', async () => {
|
||||
throw new Stripe.errors.StripeConnectionError({ message: 'connect ECONNREFUSED 10.0.0.1:443', type: 'api_connection_error' })
|
||||
})
|
||||
const down = await postInvoice({ customerId: 'c1', items: ITEMS })
|
||||
assert.equal(down.status, 502)
|
||||
assert.equal(down.body.error, 'Stripe ist gerade nicht erreichbar.')
|
||||
assert.ok(!JSON.stringify(down.body).includes('10.0.0.1'))
|
||||
})
|
||||
|
||||
test('POST /rechnungen: Appwrite scheitert (Kunde laden) -> 500 ohne Appwrite-Meldung', async () => {
|
||||
aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Appwrite kaputt 10.0.0.9' })
|
||||
const res = await postInvoice({ customerId: 'c1', items: ITEMS })
|
||||
assert.equal(res.status, 500)
|
||||
assert.equal(res.body.error, 'Interner Fehler.')
|
||||
assert.ok(!JSON.stringify(res.body).includes('10.0.0.9'))
|
||||
})
|
||||
|
||||
// ---------- senden, stornieren, loeschen ----------
|
||||
|
||||
const senden = (id) => post(`${base}/rechnungen/${id}/senden`, {}, admin)
|
||||
const stornieren = (id) => post(`${base}/rechnungen/${id}/stornieren`, {}, admin)
|
||||
|
||||
test('POST /rechnungen/:id/senden: Entwurf -> offen; offene Rechnung wird erneut gesendet', async () => {
|
||||
const created = await postInvoice({ customerId: 'c1', items: ITEMS })
|
||||
const id = created.body.rechnung.id
|
||||
const sent = await senden(id)
|
||||
assert.equal(sent.status, 200, JSON.stringify(sent.body))
|
||||
assert.deepEqual(Object.keys(sent.body), ['rechnung'])
|
||||
assert.equal(sent.body.rechnung.status, 'offen')
|
||||
assert.equal(callsTo('POST', /\/finalize$/).length, 1)
|
||||
assert.equal(callsTo('POST', /\/send$/).length, 1)
|
||||
|
||||
const again = await senden(id)
|
||||
assert.equal(again.status, 200)
|
||||
assert.equal(again.body.rechnung.status, 'offen')
|
||||
assert.equal(callsTo('POST', /\/finalize$/).length, 1)
|
||||
assert.equal(callsTo('POST', /\/send$/).length, 2)
|
||||
})
|
||||
|
||||
test('POST /rechnungen/:id/senden: bezahlt -> 409, Abo-Rechnung -> 409, unbekannt oder ungueltige ID -> 404', async () => {
|
||||
putInvoice({ id: 'in_paid', status: 'paid', amount_paid: 100, amount_remaining: 0 })
|
||||
putInvoice({ id: 'in_abo', status: 'draft', number: null, subscription: 'sub_a' })
|
||||
const paid = await senden('in_paid')
|
||||
assert.equal(paid.status, 409)
|
||||
assert.equal(paid.body.error, 'Diese Rechnung kann nicht mehr gesendet werden.')
|
||||
const abo = await senden('in_abo')
|
||||
assert.equal(abo.status, 409)
|
||||
assert.equal(abo.body.error, 'Abo-Rechnungen verwaltet Stripe.')
|
||||
assert.equal((await senden('in_gibtsnicht')).status, 404)
|
||||
assert.equal((await senden('keine-id')).status, 404)
|
||||
assert.equal(callsTo('POST', /\/(finalize|send)$/).length, 0)
|
||||
})
|
||||
|
||||
test('POST /rechnungen/:id/senden: Kunde ohne E-Mail-Adresse -> 400', async () => {
|
||||
putInvoice({ id: 'in_nomail', status: 'draft', number: null, customer_email: null, customer: 'cus_ohne' })
|
||||
st.customers.set('cus_ohne', { id: 'cus_ohne', object: 'customer', name: 'Ohne', email: null, metadata: {} })
|
||||
const res = await senden('in_nomail')
|
||||
assert.equal(res.status, 400)
|
||||
assert.equal(res.body.error, 'Für den Versand fehlt die E-Mail-Adresse des Kunden.')
|
||||
})
|
||||
|
||||
test('POST /rechnungen/:id/stornieren: offene Rechnung -> storniert; Entwurf und bezahlte -> 409', async () => {
|
||||
putInvoice({ id: 'in_open' })
|
||||
putInvoice({ id: 'in_draft', status: 'draft', number: null })
|
||||
putInvoice({ id: 'in_paid', status: 'paid', amount_paid: 100, amount_remaining: 0 })
|
||||
const ok = await stornieren('in_open')
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||||
assert.deepEqual(Object.keys(ok.body), ['rechnung'])
|
||||
assert.equal(ok.body.rechnung.status, 'storniert')
|
||||
assert.equal(st.invoices.get('in_open').status, 'void')
|
||||
for (const id of ['in_draft', 'in_paid']) {
|
||||
const res = await stornieren(id)
|
||||
assert.equal(res.status, 409, id)
|
||||
assert.equal(res.body.error, 'Nur offene Rechnungen können storniert werden.')
|
||||
}
|
||||
assert.equal((await stornieren('in_gibtsnicht')).status, 404)
|
||||
})
|
||||
|
||||
test('DELETE /rechnungen/:id: Entwurf wird geloescht, offene Rechnung -> 409', async () => {
|
||||
const created = await postInvoice({ customerId: 'c1', items: ITEMS })
|
||||
const draftId = created.body.rechnung.id
|
||||
const ok = await del(`${base}/rechnungen/${draftId}`)
|
||||
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||||
assert.deepEqual(ok.body, { ok: true })
|
||||
assert.ok(!st.invoices.has(draftId))
|
||||
|
||||
putInvoice({ id: 'in_open' })
|
||||
const open = await del(`${base}/rechnungen/in_open`)
|
||||
assert.equal(open.status, 409)
|
||||
assert.equal(open.body.error, 'Nur Entwürfe können gelöscht werden.')
|
||||
assert.ok(st.invoices.has('in_open'))
|
||||
|
||||
putInvoice({ id: 'in_abo', status: 'draft', number: null, subscription: 'sub_a' })
|
||||
assert.equal((await del(`${base}/rechnungen/in_abo`)).status, 409)
|
||||
assert.equal((await del(`${base}/rechnungen/in_gibtsnicht`)).status, 404)
|
||||
})
|
||||
|
||||
test('DELETE /rechnungen/:id: Stripe lehnt ab -> 409 ohne Stripe-Text', async () => {
|
||||
putInvoice({ id: 'in_draft', status: 'draft', number: null })
|
||||
st.failNext({ method: 'DELETE', path: '/v1/invoices/in_draft', status: 400, message: 'Geheimer Stripe-Text' })
|
||||
const res = await del(`${base}/rechnungen/in_draft`)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'Stripe hat die Änderung abgelehnt.')
|
||||
assert.ok(!JSON.stringify(res.body).includes('Geheimer'))
|
||||
})
|
||||
|
||||
test('Rechnungsaenderungen leeren den Zwischenspeicher: Liste und Uebersicht zeigen den neuen Stand', async () => {
|
||||
seedConnection()
|
||||
putInvoice({ id: 'in_open' })
|
||||
assert.equal((await uebersicht()).rechnungen.altersgruppen[0].anzahl, 1)
|
||||
assert.equal((await stornieren('in_open')).status, 200)
|
||||
assert.equal((await uebersicht()).rechnungen.altersgruppen[0].anzahl, 0)
|
||||
assert.deepEqual((await get(`${base}/rechnungen?status=offen`, admin)).rechnungen, [])
|
||||
})
|
||||
@@ -350,6 +350,12 @@ test('kleinunternehmer: über 100.000 Euro nennt die harte Grenze', () => {
|
||||
assert.equal(t.dringlichkeit, 'hoch')
|
||||
assert.ok(t.text.includes(euroDe(10000000)))
|
||||
assert.ok(t.text.includes(euroDe(10500000)))
|
||||
assert.equal(
|
||||
t.text,
|
||||
`Die Einnahmen 2026 betragen ${euroDe(10500000)} und liegen über der Grenze von ${euroDe(10000000)}. ` +
|
||||
'Ab dem Umsatz, mit dem die Grenze überschritten wurde, gilt die Kleinunternehmerregelung (§ 19 UStG) nicht mehr – auch im laufenden Jahr. ' +
|
||||
'Bitte umgehend mit der Steuerberatung klären.'
|
||||
)
|
||||
})
|
||||
|
||||
test('kleinunternehmer: ohne tax oder mit unlesbarem Anteil keine Aufgabe', () => {
|
||||
|
||||
Reference in new issue
Block a user