diff --git a/src/components/finance/AddressWarning.jsx b/src/components/finance/AddressWarning.jsx new file mode 100644 index 0000000..ba64a33 --- /dev/null +++ b/src/components/finance/AddressWarning.jsx @@ -0,0 +1,26 @@ +import { Link } from 'react-router-dom' +import { ADDRESS_INCOMPLETE_TEXT, addressComplete } from '../../lib/financeView' + +/** + * Hinweis „Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.“ mit Weg zur + * Übersicht des Kunden (dort stehen die Stammdaten). Rendert nichts, wenn die Anschrift vollständig ist. Mit + * `onShowOverview` (Reiter am Kunden) wechselt ein Knopf zum Reiter Übersicht, sonst führt ein Link zur Kundenseite. + * `variant`: 'dialog' (im Rechnungsdialog) oder 'page' (über der Rechnungsliste des Kunden). + */ +export default function AddressWarning({ customer, onShowOverview, variant = 'page' }) { + if (!customer || addressComplete(customer)) return null + return ( +
+ {ADDRESS_INCOMPLETE_TEXT} + {onShowOverview ? ( + + ) : customer.$id ? ( + + Zur Übersicht + + ) : null} +
+ ) +} diff --git a/src/components/finance/AssignDialog.jsx b/src/components/finance/AssignDialog.jsx index 85d39e7..3cbf609 100644 --- a/src/components/finance/AssignDialog.jsx +++ b/src/components/finance/AssignDialog.jsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { FaXmark } from 'react-icons/fa6' import { Badge, Button } from '../ui' import { useDialogFocus } from '../../hooks/useDialogFocus' +import { useScrollIntoView } from '../../hooks/useScrollIntoView' import { assignTransaction, getFixedCosts, unassignTransaction } from '../../lib/financeApi' import { assignBody, @@ -40,11 +41,14 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi const dirtyRef = useRef(false) const dialogRef = useRef(null) const confirmRef = useRef(null) + const errorRef = useRef(null) const outgoing = Number.isFinite(tx.amountCents) && tx.amountCents < 0 const unassigned = !tx.category const onOverlayKeyDown = useDialogFocus(dialogRef) + // Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen + useScrollIntoView(errorRef, error) useEffect(() => { aliveRef.current = true @@ -196,7 +200,7 @@ export default function AssignDialog({ transaction, invoices = [], invoicesLoadi {error && ( -
+
{error}
)} diff --git a/src/components/finance/CustomerInvoices.jsx b/src/components/finance/CustomerInvoices.jsx index 2101679..2c93100 100644 --- a/src/components/finance/CustomerInvoices.jsx +++ b/src/components/finance/CustomerInvoices.jsx @@ -1,11 +1,13 @@ import { Button, Card, EmptyState } from '../ui' +import AddressWarning from './AddressWarning' import InvoiceTable from './InvoiceTable' import { customerInvoicesGate } from '../../lib/financeView' /** * Reiter „Rechnungen“ am Kunden. Kann für den Kunden keine Rechnung geschrieben werden (noch Lead, abgesagt, keine * E-Mail), steht der Grund da, mit „Daten erfassen“ (onShowOverview wechselt zum Reiter Übersicht). Sonst die - * Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest). + * Rechnungen dieses Kunden mit „Rechnung schreiben“ (der Kunde steht im Dialog fest). Fehlt dem Kunden die Anschrift + * (Straße, PLZ, Ort), steht darüber eine Warnung mit Knopf zur Übersicht; „Senden“ ist dann über 250 € gesperrt. */ export default function CustomerInvoices({ customer, onShowOverview }) { const gate = customerInvoicesGate(customer) @@ -20,5 +22,10 @@ export default function CustomerInvoices({ customer, onShowOverview }) { ) } - return + return ( + <> + + + + ) } diff --git a/src/components/finance/InvoiceCreateModal.jsx b/src/components/finance/InvoiceCreateModal.jsx index 67662cd..9461021 100644 --- a/src/components/finance/InvoiceCreateModal.jsx +++ b/src/components/finance/InvoiceCreateModal.jsx @@ -1,17 +1,22 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { FaPlus, FaTrashCan, FaXmark } from 'react-icons/fa6' import { AutocompleteInput, Button } from '../ui' +import AddressWarning from './AddressWarning' import { useCustomers } from '../../hooks/useCustomers' import { useDialogFocus } from '../../hooks/useDialogFocus' +import { useScrollIntoView } from '../../hooks/useScrollIntoView' import { createInvoice } from '../../lib/financeApi' import { canInvoice, euro } from '../../lib/financeFormat' import { + ADDRESS_INCOMPLETE_TEXT, INVOICE_LIMITS, customerName, customerPickerOptions, customerSuggestions, finalizedFilter, invoiceFailureMessage, + invoiceSendConfirmText, + invoiceSendGate, invoiceTotalCents, isFinalizedNotSent, lineTotalCents, @@ -26,17 +31,21 @@ const newItem = () => ({ uid: ++itemCounter, description: '', quantity: '1', pri * Dialog „Rechnung schreiben“. Kunde (nur feste Kunden mit E-Mail wählbar; mit `fixedCustomer` steht er fest), * Positionen (Beschreibung, Menge, Einzelpreis in Euro), Zahlungsziel (Standard 14 Tage), Notiz, Summe und der * Hinweis nach § 19 UStG. „Als Entwurf speichern“ und „Senden“ sperren sich gegenseitig, solange die Anfrage läuft; - * Fehler des Servers stehen im Dialog. + * Fehler des Servers stehen im Dialog (und werden in den sichtbaren Bereich geholt). „Senden“ fragt vorher nach + * (die Rechnung wird festgeschrieben und per Mail an den Kunden geschickt). Fehlt dem Kunden die Anschrift (Straße, + * PLZ, Ort), steht eine Warnung mit Weg zur Übersicht da, und „Senden“ ist über 250 € gesperrt (§ 14 Abs. 4 Nr. 1 + * UStG; Entwürfe bleiben möglich). `onShowOverview` (Reiter am Kunden) wechselt zur Übersicht, sonst führt ein Link + * zur Kundenseite. * * onCreated(rechnung, { senden }) nach Erfolg; wurde die Rechnung festgeschrieben, aber nicht versendet (502 mit * invoiceId), kommt onCreated(null, { senden: true, notice, tone: 'warn', finalizedFilter }) und der Dialog schließt * (die Rechnung steht dann „offen“ in der Liste und lässt sich dort erneut senden). Danach ruft der Dialog onClose(). */ -export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null }) { +export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCustomer = null, onShowOverview = null }) { if (!open) return null // Die Kundenliste (Appwrite, bis 5000 Dokumente) wird nur geholt, wenn der Kunde nicht feststeht return fixedCustomer ? ( - + ) : ( ) @@ -44,10 +53,10 @@ export default function InvoiceCreateModal({ open, onClose, onCreated, fixedCust function PickerForm({ onClose, onCreated }) { const { customers, loading, error } = useCustomers() - return + return } -function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) { +function InvoiceForm({ onClose, onCreated, fixedCustomer, source, onShowOverview }) { const [customerText, setCustomerText] = useState('') const [items, setItems] = useState(() => [newItem()]) const [days, setDays] = useState(String(INVOICE_LIMITS.defaultDays)) @@ -57,8 +66,11 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) { const lockRef = useRef(false) const aliveRef = useRef(true) const dialogRef = useRef(null) + const errorRef = useRef(null) const onOverlayKeyDown = useDialogFocus(dialogRef) + // Der Dialog kann höher sein als das Fenster: eine Fehlermeldung oben soll nicht außerhalb des Bildes stehen + useScrollIntoView(errorRef, error) useEffect(() => { aliveRef.current = true @@ -83,6 +95,8 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) { const customer = fixedCustomer || resolved.option?.customer || null const customerBlocked = fixedCustomer ? canInvoice(fixedCustomer) : resolved.option ? { ok: resolved.option.ok, reason: resolved.option.reason } : { ok: true } const total = invoiceTotalCents(items) + // Anschrift des Empfängers (§ 14 Abs. 4 Nr. 1 UStG): ohne sie ist „Senden“ nur bis 250 € möglich + const gate = invoiceSendGate(customerBlocked.ok ? customer : null, total) const updateItem = (uid, field, value) => { setItems((list) => list.map((it) => (it.uid === uid ? { ...it, [field]: value } : it))) @@ -96,6 +110,17 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) { setError(check.error) return } + if (senden) { + if (!invoiceSendGate(customer, check.totalCents).sendAllowed) { + setError(ADDRESS_INCOMPLETE_TEXT) + return + } + // Senden schreibt die Rechnung fest und schickt sie an den Kunden: erst nach Rückfrage + const sure = window.confirm( + invoiceSendConfirmText({ name: customerName(customer), email: customer.email, totalCents: check.totalCents }) + ) + if (!sure) return + } lockRef.current = true setBusy(senden ? 'send' : 'draft') setError('') @@ -135,7 +160,7 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) {

Rechnung schreiben

{error && ( -
+
{error}
)} @@ -176,6 +201,21 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) { {customer && customerBlocked.ok && customer.email && !fixedCustomer && (

Die Rechnung geht an {customer.email}.

)} + {gate.warn && ( + { + if (lockRef.current) return + onClose() + onShowOverview() + } + : undefined + } + /> + )}
@@ -289,7 +329,13 @@ function InvoiceForm({ onClose, onCreated, fixedCustomer, source }) { -
diff --git a/src/components/finance/InvoiceTable.jsx b/src/components/finance/InvoiceTable.jsx index b941b1d..7797be3 100644 --- a/src/components/finance/InvoiceTable.jsx +++ b/src/components/finance/InvoiceTable.jsx @@ -6,6 +6,7 @@ import Pager from './Pager' import { deleteInvoice, getInvoices, resendInvoice, sendInvoice, voidInvoice } from '../../lib/financeApi' import { pageOf } from '../../lib/financeFormat' import { + ADDRESS_INCOMPLETE_TEXT, DEFAULT_INVOICE_FILTER, INVOICES_ANCHOR, INVOICE_FILTERS, @@ -13,6 +14,7 @@ import { finalizedFilter, invoiceConfirmText, invoiceFilterStatuses, + invoiceSendGate, invoiceDoneMessage, invoiceFailureMessage, invoiceRows, @@ -32,12 +34,14 @@ const CALLS = { send: sendInvoice, resend: resendInvoice, void: voidInvoice, del * * customerId filtert auf einen Kunden; `customer` (Appwrite-Dokument) legt zusätzlich den Kunden im Dialog fest * (Kundenansicht). onChanged() meldet jede Änderung (Übersicht neu laden). Schmal (Karte bis 700 px) wird jede - * Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. + * Zeile ein Raster: Nummer und Status / Kunde / Datum, Fällig / Betrag, Offen / Aktionen. „Senden“ eines Entwurfs + * fragt vorher nach. In der Kundenansicht ist „Senden“ gesperrt, solange dem Kunden die Anschrift fehlt und der + * Entwurf über 250 € liegt (§ 14 Abs. 4 Nr. 1 UStG); `onShowOverview` führt dann im Dialog zur Übersicht. * * „Offen“ zeigt offene und überfällige Rechnungen (zwei Abrufe, zusammengeführt, neueste zuerst). Ändert sich * `refreshToken` (die Seite lädt nach Zuordnen, Bankabruf usw. alles neu), lädt auch die Liste neu. */ -export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, refreshToken = 0, onChanged }) { +export default function InvoiceTable({ altersgruppen = null, customerId = null, customer = null, refreshToken = 0, onChanged, onShowOverview = null }) { const filterCustomer = customerId || customer?.$id || null const [status, setStatus] = useState(DEFAULT_INVOICE_FILTER) const [reloadKey, setReloadKey] = useState(0) @@ -76,6 +80,9 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null, }) }, [status, filterCustomer, key, reloadKey, refreshToken]) + // Nur mit bekanntem Kunden (Kundenansicht) lässt sich die Anschrift prüfen + const sendBlocked = (invoice) => Boolean(customer) && !invoiceSendGate(customer, invoice?.totalCents).sendAllowed + const reload = () => setReloadKey((k) => k + 1) const changed = () => { reload() @@ -101,6 +108,7 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null, async function run(row, action) { if (lockRef.current) return const invoice = byId[row.id] + if (action.key === 'send' && sendBlocked(invoice)) return const confirmText = invoiceConfirmText(action.key, invoice) if (confirmText && !window.confirm(confirmText)) return lockRef.current = true @@ -253,7 +261,8 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null, size="sm" variant={action.key === 'delete' || action.key === 'void' ? 'danger' : 'ghost'} onClick={() => run(row, action)} - disabled={busy !== null} + disabled={busy !== null || (action.key === 'send' && sendBlocked(byId[row.id]))} + title={action.key === 'send' && sendBlocked(byId[row.id]) ? ADDRESS_INCOMPLETE_TEXT : undefined} aria-label={`${action.label}: ${row.numberText}, ${row.customerText}`} > {busy === `${row.id}:${action.key}` ? RUNNING_LABEL[action.key] : action.label} @@ -276,6 +285,7 @@ export default function InvoiceTable({ altersgruppen = null, customerId = null, onClose={() => setCreateOpen(false)} onCreated={handleCreated} fixedCustomer={customer} + onShowOverview={onShowOverview} /> ) diff --git a/src/hooks/useScrollIntoView.js b/src/hooks/useScrollIntoView.js new file mode 100644 index 0000000..714b5e8 --- /dev/null +++ b/src/hooks/useScrollIntoView.js @@ -0,0 +1,14 @@ +import { useEffect } from 'react' + +/** + * Holt das Element in `ref` in den sichtbaren Bereich, sobald sich `trigger` ändert und nicht leer ist (zum Beispiel eine + * Fehlermeldung ganz oben in einem langen Dialog, die sonst außerhalb des Fensters stehen würde). `block: 'nearest'` + * bewegt nur so weit wie nötig, ein schon sichtbares Element bleibt ruhig. + */ +export function useScrollIntoView(ref, trigger) { + useEffect(() => { + if (!trigger) return + const el = ref.current + if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' }) + }, [ref, trigger]) +} diff --git a/src/lib/financeView.js b/src/lib/financeView.js index f1f08f3..3a1c811 100644 --- a/src/lib/financeView.js +++ b/src/lib/financeView.js @@ -542,13 +542,31 @@ export function invoiceRows(invoices) { }) } -/** Rückfrage vor dem Löschen eines Entwurfs, dem Stornieren und der Erinnerung; '' für Aktionen ohne Rückfrage. */ +/** + * Rückfrage vor dem Festschreiben und Senden einer Rechnung (Dialog „Senden“ und Entwurf in der Tabelle): + * „Rechnung für über jetzt festschreiben und an senden? Danach lässt sie sich nur noch + * stornieren.“ Fehlen Name oder E-Mail, steht dort „den Kunden“. + */ +export function invoiceSendConfirmText({ name, email, totalCents } = {}) { + const mail = text(email) + const who = text(name) || mail || 'den Kunden' + return `Rechnung für ${who} über ${euro(totalCents)} jetzt festschreiben und an ${mail || 'den Kunden'} senden? Danach lässt sie sich nur noch stornieren.` +} + +/** + * Rückfrage vor dem Senden eines Entwurfs, dem Löschen eines Entwurfs, dem Stornieren und der Erinnerung; '' für + * Aktionen ohne Rückfrage (auch „send“ bei einer Rechnung, die kein Entwurf mehr ist). + */ export function invoiceConfirmText(actionKey, invoice) { const inv = obj(invoice) const who = text(inv.customerName) || text(inv.customerEmail) || 'den Kunden' const total = euro(inv.totalCents) const number = text(inv.number) || 'ohne Nummer' switch (actionKey) { + case 'send': + return inv.status === 'entwurf' + ? invoiceSendConfirmText({ name: inv.customerName, email: inv.customerEmail, totalCents: inv.totalCents }) + : '' case 'delete': return `Den Entwurf für ${who} über ${total} wirklich löschen? Das lässt sich nicht rückgängig machen.` case 'void': @@ -778,6 +796,32 @@ export function customerInvoicesGate(customer) { return { ok: false, title: check.reason, hint: '' } } +// --- Anschrift des Rechnungsempfängers (§ 14 Abs. 4 Nr. 1 UStG) -------------------------------------------- + +/** Kleinbetragsrechnung (§ 33 UStDV): bis 250 € brauchen Rechnungen keine Anschrift des Empfängers. */ +export const SMALL_INVOICE_MAX_CENTS = 25_000 +export const ADDRESS_INCOMPLETE_TEXT = + 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.' + +/** Hat der Kunde (Appwrite-Dokument) Straße, PLZ und Ort? Leere und nur aus Leerzeichen bestehende Felder zählen nicht. */ +export function addressComplete(customer) { + const c = obj(customer) + return Boolean(text(c.street) && text(c.postalCode) && text(c.city)) +} + +/** + * Darf diese Rechnung gesendet werden? { warn, sendAllowed, text }. warn: die Anschrift des Kunden ist unvollständig + * (Hinweis mit `text` zeigen). sendAllowed ist nur dann false, wenn zugleich die Anschrift fehlt und die Summe über + * 250 € liegt (genau 250,00 € ist noch eine Kleinbetragsrechnung); Entwürfe sind nie betroffen (sie haben keinen + * Versand). Ohne Kunden gibt es keine Warnung, die Formularprüfung meldet das fehlende Feld. + */ +export function invoiceSendGate(customer, totalCents) { + if (!customer || typeof customer !== 'object') return { warn: false, sendAllowed: true, text: '' } + const complete = addressComplete(customer) + const small = isNum(totalCents) && totalCents <= SMALL_INVOICE_MAX_CENTS // unbekannte Summe gilt nicht als klein + return { warn: !complete, sendAllowed: complete || small, text: complete ? '' : ADDRESS_INCOMPLETE_TEXT } +} + // --- Buchungen (Block 7) ----------------------------------------------------------------------------------- const RANK = { hoch: 0, mittel: 1, niedrig: 2 } diff --git a/src/styles/global.css b/src/styles/global.css index ec485e0..0978c91 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1375,6 +1375,10 @@ textarea.form-control { .fin-dialog-warn { margin: 6px 0 0; font-size: 13px; color: #fbbf24; overflow-wrap: anywhere; } .fin-dialog-sub { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); overflow-wrap: anywhere; } .fin-fixed-customer { margin: 0; overflow-wrap: anywhere; } +.fin-address-warn { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px var(--sp-3); font-size: 13px; overflow-wrap: anywhere; } +.fin-address-warn-dialog { margin: 6px 0 0; color: #fbbf24; } +.fin-address-warn-page { margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4); border: 1px solid var(--warn); border-radius: var(--r-sm); color: var(--warn); } +.fin-address-warn .fin-link { font-size: 13px; } .fin-items { border: 0; padding: 0; margin: 0 0 var(--sp-4); min-width: 0; } .fin-items legend { padding: 0; } .fin-item { diff --git a/tests/unit/financeView.test.mjs b/tests/unit/financeView.test.mjs index 2b2ef61..ae0443e 100644 --- a/tests/unit/financeView.test.mjs +++ b/tests/unit/financeView.test.mjs @@ -20,7 +20,6 @@ const { forecastRows, forecastNotes, taxView, - TAX_NOTE, aboView, productLabel, aboStatus, @@ -34,6 +33,7 @@ const { invoiceActions, invoiceRows, invoiceConfirmText, + invoiceSendConfirmText, invoiceDoneMessage, isFinalizedNotSent, finalizedFilter, @@ -48,6 +48,11 @@ const { customerSuggestions, resolveCustomerText, customerInvoicesGate, + SMALL_INVOICE_MAX_CENTS, + ADDRESS_INCOMPLETE_TEXT, + addressComplete, + invoiceSendGate, + TAX_NOTE, sortTransactions, strongestSuggestion, suggestionText, @@ -790,10 +795,41 @@ test('invoiceConfirmText: Löschen, Stornieren, Erinnerung; sonst leer', () => { assert.equal(invoiceConfirmText('delete', inv), 'Den Entwurf für Kunde Zwei über 50,00 € wirklich löschen? Das lässt sich nicht rückgängig machen.') assert.equal(invoiceConfirmText('void', inv), 'Die Rechnung WK-0006 an Kunde Zwei über 50,00 € wirklich stornieren? Das lässt sich nicht rückgängig machen.') assert.equal(invoiceConfirmText('resend', inv), 'Die Rechnung WK-0006 jetzt noch einmal an k@example.invalid senden (Erinnerung)?') + // „send“ gibt es nur für Entwürfe; bei jeder anderen Rechnung keine Rückfrage assert.equal(invoiceConfirmText('send', inv), '') assert.equal(invoiceConfirmText('pdf', inv), '') }) +test('invoiceConfirmText: Senden eines Entwurfs fragt vorher, mit Kunde, Betrag und E-Mail', () => { + const draft = INV({ status: 'entwurf', number: '', totalCents: 120050 }) + assert.equal( + invoiceConfirmText('send', draft), + 'Rechnung für Kunde Zwei über 1.200,50 € jetzt festschreiben und an k@example.invalid senden? Danach lässt sie sich nur noch stornieren.' + ) + // ohne Namen steht die E-Mail als Kunde, ohne beides „den Kunden“ + assert.equal( + invoiceConfirmText('send', INV({ status: 'entwurf', customerName: '', customerEmail: 'a@example.invalid' })), + 'Rechnung für a@example.invalid über 50,00 € jetzt festschreiben und an a@example.invalid senden? Danach lässt sie sich nur noch stornieren.' + ) + assert.equal( + invoiceConfirmText('send', INV({ status: 'entwurf', customerName: '', customerEmail: '' })), + 'Rechnung für den Kunden über 50,00 € jetzt festschreiben und an den Kunden senden? Danach lässt sie sich nur noch stornieren.' + ) + for (const status of ['offen', 'ueberfaellig', 'bezahlt']) assert.equal(invoiceConfirmText('send', INV({ status })), '') +}) + +test('invoiceSendConfirmText: Text für den Dialog „Senden“ (Kunde, Summe, E-Mail des gewählten Kunden)', () => { + assert.equal( + invoiceSendConfirmText({ name: 'Müller Sanitär', email: 'm@example.invalid', totalCents: 15050 }), + 'Rechnung für Müller Sanitär über 150,50 € jetzt festschreiben und an m@example.invalid senden? Danach lässt sie sich nur noch stornieren.' + ) + assert.equal( + invoiceSendConfirmText({ name: ' ', email: ' m@example.invalid ', totalCents: 100 }), + 'Rechnung für m@example.invalid über 1,00 € jetzt festschreiben und an m@example.invalid senden? Danach lässt sie sich nur noch stornieren.' + ) + assert.match(invoiceSendConfirmText(), /^Rechnung für den Kunden über /) +}) + test('invoiceDoneMessage', () => { const r = { number: 'WK-0007', customerEmail: 'k@example.invalid' } assert.equal(invoiceDoneMessage('send', r), 'Rechnung WK-0007 wurde an k@example.invalid gesendet.') @@ -1220,3 +1256,37 @@ test('customerInvoicesGate: fester Kunde mit E-Mail darf, alle anderen bekommen assert.equal(none.title, 'Kein Kunde ausgewählt') assert.equal(none.hint, '') }) + +test('addressComplete: Straße, PLZ und Ort müssen alle gefüllt sein', () => { + const full = { street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin' } + assert.equal(addressComplete(full), true) + assert.equal(addressComplete({ ...full, street: '' }), false) + assert.equal(addressComplete({ ...full, postalCode: ' ' }), false) + assert.equal(addressComplete({ ...full, city: undefined }), false) + assert.equal(addressComplete({ ...full, city: null }), false) + assert.equal(addressComplete({}), false) + assert.equal(addressComplete(null), false) + // Land allein macht keine Anschrift + assert.equal(addressComplete({ country: 'Deutschland' }), false) +}) + +test('invoiceSendGate: Senden nur gesperrt bei fehlender Anschrift UND Summe über 250 €', () => { + assert.equal(SMALL_INVOICE_MAX_CENTS, 25000) + assert.equal(ADDRESS_INCOMPLETE_TEXT, 'Adresse des Kunden unvollständig (Straße, PLZ, Ort). Für den Versand bitte zuerst ergänzen.') + const open = { street: '', postalCode: '10115', city: 'Berlin' } + const full = { street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin' } + + // unvollständig + über 250 €: Warnung, Senden gesperrt + assert.deepEqual(invoiceSendGate(open, 25001), { warn: true, sendAllowed: false, text: ADDRESS_INCOMPLETE_TEXT }) + assert.deepEqual(invoiceSendGate(open, 120050), { warn: true, sendAllowed: false, text: ADDRESS_INCOMPLETE_TEXT }) + // genau 250,00 € und darunter: Kleinbetragsrechnung, Warnung ja, Senden erlaubt + assert.deepEqual(invoiceSendGate(open, 25000), { warn: true, sendAllowed: true, text: ADDRESS_INCOMPLETE_TEXT }) + assert.deepEqual(invoiceSendGate(open, 0), { warn: true, sendAllowed: true, text: ADDRESS_INCOMPLETE_TEXT }) + // vollständige Anschrift: nie eine Sperre + assert.deepEqual(invoiceSendGate(full, 99999999), { warn: false, sendAllowed: true, text: '' }) + // unbekannte Summe gilt nicht als klein + assert.equal(invoiceSendGate(open, null).sendAllowed, false) + assert.equal(invoiceSendGate(open, undefined).sendAllowed, false) + // noch kein Kunde gewählt: weder Warnung noch Sperre (die Formularprüfung meldet das) + assert.deepEqual(invoiceSendGate(null, 99999), { warn: false, sendAllowed: true, text: '' }) +})