diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js new file mode 100644 index 0000000..6f0ae0c --- /dev/null +++ b/server/services/auftraege/rechnungsbau.js @@ -0,0 +1,98 @@ +/** + * Baut aus der Eingabe einer Projektrechnung den RechnungsEntwurf (Positionen, Zusatzfelder, Metadaten, Projektdatensatz). + * Lesezugriffe: Kunde, Projekt, AB, Arbeitsblaetter und fruehere Rechnungen; geschrieben wird hier nichts (ausser der + * Selbstheilung veralteter Projektdatensaetze, siehe unten). Importiert nichts aus stripeFinance.js (Importzyklus). + */ +import { config } from '../../config.js' +import { getDocument } from '../appwriteAdmin.js' +import { AuftragError } from './fehler.js' +import { RECHNUNGS_ARTEN, LIMITS } from './konstanten.js' +import { festePositionen, pruefePositionen, kuerzen, auftragDatum, SCHLUSS_GRENZEN } from './positionen.js' +import { formatDe } from '../finance/dates.js' + +// Appwrite-Dokument-ID +const KENNUNG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + +/** Kunde aus Appwrite; nur feste Kunden (nicht lead/lost) bekommen Rechnungen. */ +export async function ladeFestenKunden(customerId) { + if (typeof customerId !== 'string' || !customerId || !KENNUNG.test(customerId)) throw new AuftragError(400, 'Kunde nicht gefunden.') + let kunde + try { + kunde = await getDocument(config.collections.customers, customerId) + } catch (err) { + if (err.status === 404) throw new AuftragError(400, 'Kunde nicht gefunden.') + throw err + } + const stufe = String(kunde.customerStatus || '').trim().toLowerCase() + if (stufe === 'lead' || stufe === 'lost') throw new AuftragError(400, 'Rechnungen nur für feste Kunden.') + return kunde +} + +/** Projekt des Kunden; fremde und unbekannte Projekte sind nicht zu unterscheiden (404). */ +async function ladeProjekt(kundenId, projektId) { + if (typeof projektId !== 'string' || !projektId) throw new AuftragError(400, 'Für diese Rechnungsart ist ein Projekt nötig.') + if (!KENNUNG.test(projektId)) throw new AuftragError(404, 'Projekt nicht gefunden.') + let projekt + try { + projekt = await getDocument(config.collections.websiteProjects, projektId) + } catch (err) { + if (err.status === 404) throw new AuftragError(404, 'Projekt nicht gefunden.') + throw err + } + if (projekt.customerId !== kundenId) throw new AuftragError(404, 'Projekt nicht gefunden.') + return { id: projekt.$id, name: projekt.projectName || projekt.subdomain || 'Projekt', ticketId: projekt.ticketId || '' } +} + +function pruefeLeistungszeitraum(wert) { + if (wert === undefined || wert === null) return '' + if (typeof wert !== 'string' || wert.trim().length > LIMITS.maxLeistungszeitraum) { + throw new AuftragError(400, `Der Leistungszeitraum muss ein Text mit höchstens ${LIMITS.maxLeistungszeitraum} Zeichen sein.`) + } + return wert.trim() +} + +/** Gemeinsamer Abschluss aller Arten: Grenzen, Zusatzfelder, Metadaten, Projektdatensatz. */ +function schliesseAb({ kunde, art, projekt, auftrag, positionen, leistungszeitraum, arbeitsblattIds = [], createdBy }) { + const summeCents = pruefePositionen(positionen, { max: art === 'schluss' ? SCHLUSS_GRENZEN.zeilen : LIMITS.maxPositionen }) + const projektId = projekt?.id ?? auftrag?.projektId ?? '' + const projektName = kuerzen(projekt?.name ?? auftrag?.titel ?? '', 140) + const customFields = [] + if (art !== 'frei' && projektName) customFields.push({ name: 'Projekt', value: projektName }) + if (auftrag) customFields.push({ name: 'Auftrag', value: `${auftrag.nummer} vom ${formatDe(auftragDatum(auftrag))}` }) + if (leistungszeitraum) customFields.push({ name: 'Leistungszeitraum', value: leistungszeitraum }) + const metadata = { webklarCustomerId: kunde.$id, quelle: 'woms', erstelltVon: String(createdBy || '').slice(0, 500), art } + if (projektId) metadata.projektId = projektId + if (projektName) metadata.projektName = projektName + if (auftrag) { + metadata.auftragId = auftrag.id + if (auftrag.nummer) metadata.auftragNummer = auftrag.nummer + } + const projektRechnung = art === 'frei' ? null : { + kundenId: kunde.$id, + projektId, + auftragId: auftrag?.id ?? '', + art, + betragCents: summeCents, + arbeitsblattIds: art === 'aufwand' ? [...arbeitsblattIds].sort() : [], + rechnungsnummer: '', + aktiv: true, + erstelltVon: String(createdBy || '').slice(0, 255), + erstelltAm: new Date().toISOString(), + } + return { kunde, art, projekt: projekt ?? null, auftrag: auftrag ?? null, positionen, summeCents, leistungszeitraum, customFields, metadata, projektRechnung } +} + +/** Baut den RechnungsEntwurf einer Rechnung der Art frei, festbetrag, aufwand, anzahlung oder schluss. */ +export async function baueRechnung(eingabe) { + const art = eingabe.art ?? 'frei' + if (typeof art !== 'string' || !RECHNUNGS_ARTEN.includes(art)) throw new AuftragError(400, 'Unbekannte Rechnungsart.') + const kunde = await ladeFestenKunden(eingabe.customerId) + const leistungszeitraum = pruefeLeistungszeitraum(eingabe.leistungszeitraum) + const basis = { kunde, art, leistungszeitraum, createdBy: eingabe.createdBy } + if (art === 'frei') return schliesseAb({ ...basis, positionen: festePositionen(eingabe.items) }) + if (art === 'festbetrag') { + const projekt = await ladeProjekt(kunde.$id, eingabe.projektId) + return schliesseAb({ ...basis, projekt, positionen: festePositionen(eingabe.items) }) + } + throw new AuftragError(501, 'Rechnungsart noch nicht umgesetzt.') +} diff --git a/test/auftraegeRechnungenHilfe.js b/test/auftraegeRechnungenHilfe.js new file mode 100644 index 0000000..b818327 --- /dev/null +++ b/test/auftraegeRechnungenHilfe.js @@ -0,0 +1,100 @@ +// Gemeinsame Testdaten für die Rechnungstests des Strangs L2. Erst nach useTestEnv() importieren (zieht config.js). +import { config } from '../server/config.js' +import { deleteDocument } from '../server/services/appwriteAdmin.js' +import { createAuftrag, updateAuftrag } from '../server/services/auftraege/auftragStore.js' +import { createProjektRechnung } from '../server/services/auftraege/projektRechnungenStore.js' + +/** n Arbeitstage (Montag bis Freitag) ab start (YYYY-MM-DD, einschließlich). */ +export function werktage(start, n) { + const tage = [] + for (let ms = Date.parse(`${start}T00:00:00Z`); tage.length < n; ms += 86_400_000) { + const tag = new Date(ms) + if (tag.getUTCDay() % 6 !== 0) tage.push(tag.toISOString().slice(0, 10)) + } + return tage +} + +const deutsch = (tag) => tag.split('-').reverse().join('.') + +/** + * Kunden k1 (fest, mit Anschrift: Frau Wolf), k2 (fest), lead1 (Interessent); Projekte p1 (k1, Ticket t1), p2 (k2, Ticket t2), + * p3 (k1, Ticket t4); Tickets t1 bis t4 (t2 gehört k2); Arbeitsblätter ws1 bis wsT3 (Oktober) und wl01 bis wl20 (Großprojekt). + */ +export function seedRechnungsDaten(aw) { + const kunde = (id, over = {}) => + aw.seed('customers', { $id: id, email: `${id}@example.invalid`, name: `Kunde ${id}`, customerStatus: 'active', status: 'active', street: 'Hauptstr. 1', postalCode: '10115', city: 'Berlin', ...over }) + kunde('k1', { name: 'Anelia Wolf', email: 'wolf@example.invalid' }) + kunde('k2') + kunde('lead1', { customerStatus: 'lead' }) + const projekt = (id, customerId, projectName, ticketId) => aw.seed('websiteProjects', { $id: id, customerId, projectName, ticketId }) + projekt('p1', 'k1', 'Website anelia-wolf.com', 't1') + projekt('p2', 'k2', 'Fremdes Projekt', 't2') + projekt('p3', 'k1', 'Großprojekt', 't4') + for (const [id, customerId, woid] of [['t1', 'k1', 'WO-100'], ['t2', 'k2', 'WO-200'], ['t3', 'k1', 'WO-101'], ['t4', 'k1', 'WO-102']]) { + aw.seed('workorders', { $id: id, customerId, woid, topic: `Ticket ${woid}`, status: 'occupied' }) + } + const blatt = (id, workorderId, startDate, totalTime, details, over = {}) => + aw.seed('worksheets', { $id: id, workorderId, woid: 'WO', startDate, totalTime, details, serviceType: 'remote', countsToPlan: false, isComment: false, ...over }) + blatt('ws1', 't1', '06.10.2026', 90, 'Startseite') + blatt('ws2', 't1', '06.10.2026', 45, 'Kontaktformular') + blatt('ws3', 't1', '07.10.2026', 60, 'Impressum') + blatt('wsAbo', 't1', '07.10.2026', 60, 'Wartung', { countsToPlan: true }) + blatt('wsKomm', 't1', '07.10.2026', 30, 'Notiz', { isComment: true }) + blatt('wsNull', 't1', '07.10.2026', 0, 'Leer') + blatt('wsFremd', 't2', '07.10.2026', 60, 'Fremd') + blatt('wsT3', 't3', '08.10.2026', 30, 'Zusatzticket') + // Großprojekt: 20 Arbeitstage (vier Wochen, KW 45 bis 48) zu je 60 Minuten + werktage('2026-11-02', 20).forEach((tag, i) => blatt(`wl${String(i + 1).padStart(2, '0')}`, 't4', deutsch(tag), 60, `Etappe ${i + 1}`)) +} + +/** + * Eine versendete Festpreis-AB des Kunden k1 zu p1 (80.000 Cent: Website 600 €, Texte 2 × 100 €; Anzahlung 50 %; AB-2026-0002, + * gesendet am 06.10.2026) über die L0-Speicher anlegen. over gilt für createAuftrag, patch für das anschließende updateAuftrag + * (Status, Nummer, bestaetigtAm, …). + */ +export async function legeAuftragAn(over = {}, patch = {}) { + const roh = await createAuftrag({ + kundenId: 'k1', projektId: 'p1', titel: 'Website anelia-wolf.com', zeitquelle: [], art: 'festpreis', + positionen: [{ beschreibung: 'Website', menge: 1, einzelpreisCents: 60000 }, { beschreibung: 'Texte', menge: 2, einzelpreisCents: 10000 }], + stundensatzCents: null, schaetzungMinuten: null, summeCents: 80000, + zahlungsplan: { art: 'anzahlung', prozent: 50, text: '' }, zahlungszielTage: 14, + auftragVom: '2026-10-05', auftragWeg: 'telefon', startAm: '', fertigBis: '', erstelltVon: 'kenso@webklar.com', + ...over, + }) + return updateAuftrag(roh.id, { status: 'versendet', nummer: 'AB-2026-0002', jahr: 2026, laufnummer: 2, gesendetAm: '2026-10-06T08:15:00.000Z', ...patch }) +} + +const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000) + +/** + * Eine Projektrechnung wie nach createProjectInvoice: Stripe-Rechnung im Nachbau (status draft, open, paid oder void; ohneStripe: die + * Rechnung gibt es bei Stripe nicht, z. B. im Dashboard gelöscht) plus aktiver Projektdatensatz. docNummer ist die Nummer im Datensatz + * (Vorgabe: die der Rechnung, bei einem Entwurf leer). + */ +export async function legeRechnungAn(st, id, { + status = 'draft', nummer = 'WEBKLAR-0007', total = 40000, art = 'aufwand', auftragId = '', projektId = 'p1', + arbeitsblattIds = [], finalisiert = '2026-10-08T10:00:00.000Z', ohneStripe = false, docNummer, +} = {}) { + const entwurf = status === 'draft' + if (!ohneStripe) { + st.invoices.set(id, { + id, object: 'invoice', number: entwurf ? null : nummer, status, customer: 'cus_x', created: unix(finalisiert), total, amount_due: total, + amount_paid: status === 'paid' ? total : 0, amount_remaining: status === 'paid' ? 0 : total, + status_transitions: { finalized_at: entwurf ? null : unix(finalisiert) }, metadata: { art }, lines: { object: 'list', data: [], has_more: false }, + }) + } + await createProjektRechnung(id, { + kundenId: 'k1', projektId, auftragId, art, betragCents: total, arbeitsblattIds, + rechnungsnummer: docNummer ?? (entwurf ? '' : nummer), aktiv: true, erstelltVon: 'k', erstelltAm: new Date().toISOString(), + }) +} + +/** Aufträge und Projektdatensätze löschen, Stripe-Nachbau (Rechnungen, Kunden, Aufrufe) leeren. */ +export async function leereRechnungsDaten(aw, st) { + for (const sammlung of [config.collections.auftraege, config.collections.projektRechnungen]) { + for (const id of aw.docs(sammlung).keys()) await deleteDocument(sammlung, id) + } + st.invoices.clear() + st.customers.clear() + st.calls.length = 0 +} diff --git a/test/auftraegeRechnungsbau.test.js b/test/auftraegeRechnungsbau.test.js new file mode 100644 index 0000000..009c17b --- /dev/null +++ b/test/auftraegeRechnungsbau.test.js @@ -0,0 +1,114 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { AUFTRAEGE_DATETIMES } from '../server/services/auftraege/schema.js' + +// Nachbau von Appwrite mit dem Schema und den Datumsfeldern der neuen Collections und dem Standard-Limit 25 (wie in den Tests von L0) +const aw = await startAppwriteMock({ + schema: { auftraege: schemaKeys('auftraege'), projektRechnungen: schemaKeys('projektRechnungen') }, + datetimes: AUFTRAEGE_DATETIMES, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { seedRechnungsDaten, legeAuftragAn, legeRechnungAn, leereRechnungsDaten } = await import('./auftraegeRechnungenHilfe.js') +const { baueRechnung } = await import('../server/services/auftraege/rechnungsbau.js') +const { getProjektRechnung, updateProjektRechnung } = await import('../server/services/auftraege/projektRechnungenStore.js') +const { getAuftrag } = await import('../server/services/auftraege/auftragStore.js') + +after(() => Promise.all([aw.close(), st.close()])) +seedRechnungsDaten(aw) +config.auftraege.mode = 'on' +beforeEach(() => leereRechnungsDaten(aw, st)) + +const NBSP = String.fromCharCode(160) // geschütztes Leerzeichen U+00A0, wie euroDe es vor € setzt +const SCHALTER_AUS = 'Auftragsbestätigungen sind für diesen Kunden noch nicht freigeschaltet.' +const POSTEN = [{ description: 'Domain', quantity: 1, unitAmountCents: 1200 }] +const wirft = (promise, status, message) => + assert.rejects(promise, (err) => { + assert.equal(err.status, status, `Status ${err.status}: ${err.message}`) + assert.equal(err.message, message) + return true + }) + +test('baueRechnung frei: freie Positionen, nur Kopf-Metadaten, keine Zusatzfelder, kein Projektdatensatz', async () => { + const e = await baueRechnung({ customerId: 'k1', items: POSTEN, createdBy: 'kenso@webklar.com' }) + assert.equal(e.art, 'frei') + assert.equal(e.kunde.$id, 'k1') + assert.equal(e.projekt, null) + assert.equal(e.auftrag, null) + assert.deepEqual(e.positionen, [{ schluessel: 'pos-1', beschreibung: 'Domain', menge: 1, einzelpreisCents: 1200, betragCents: 1200 }]) + assert.equal(e.summeCents, 1200) + assert.equal(e.leistungszeitraum, '') + assert.deepEqual(e.customFields, []) + assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'frei' }) + assert.equal(e.projektRechnung, null) + assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN })).metadata.erstelltVon, '') +}) + +test('baueRechnung frei: Projekt, Auftrag, Arbeitsblätter und Anzahlung der Eingabe bleiben ohne Wirkung', async () => { + const e = await baueRechnung({ customerId: 'k1', art: 'frei', items: POSTEN, projektId: 'p1', auftragId: 'a1', arbeitsblattIds: ['ws1'], anzahlung: { prozent: 10 } }) + assert.equal(e.projekt, null) + assert.equal(e.auftrag, null) + assert.equal(e.projektRechnung, null) + assert.deepEqual(e.customFields, []) + assert.deepEqual(Object.keys(e.metadata).sort(), ['art', 'erstelltVon', 'quelle', 'webklarCustomerId']) +}) + +test('baueRechnung: Leistungszeitraum ist optional, wird getrimmt und hat höchstens 140 Zeichen', async () => { + const e = await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: ' Oktober 2026 ' }) + assert.equal(e.leistungszeitraum, 'Oktober 2026') + assert.deepEqual(e.customFields, [{ name: 'Leistungszeitraum', value: 'Oktober 2026' }]) + assert.equal((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: 'z'.repeat(140) })).leistungszeitraum.length, 140) + for (const leer of [undefined, null, '', ' ']) assert.deepEqual((await baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum: leer })).customFields, []) + const MELDUNG = 'Der Leistungszeitraum muss ein Text mit höchstens 140 Zeichen sein.' + for (const leistungszeitraum of ['z'.repeat(141), 5, {}]) await wirft(baueRechnung({ customerId: 'k1', items: POSTEN, leistungszeitraum }), 400, MELDUNG) +}) + +test('baueRechnung: unbekannte Art, unbekannter oder nicht fester Kunde, ungültige Positionen -> 400 ohne Stripe-Aufruf', async () => { + for (const art of ['quatsch', 'FREI', 5, {}]) await wirft(baueRechnung({ customerId: 'k1', art, items: POSTEN }), 400, 'Unbekannte Rechnungsart.') + for (const customerId of [undefined, '', 'gibtEsNicht', '../x', 5]) await wirft(baueRechnung({ customerId, items: POSTEN }), 400, 'Kunde nicht gefunden.') + await wirft(baueRechnung({ customerId: 'lead1', items: POSTEN }), 400, 'Rechnungen nur für feste Kunden.') + await wirft(baueRechnung({ customerId: 'k1' }), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.') + assert.equal(st.calls.length, 0) +}) + +test('baueRechnung festbetrag: Projekt, Zusatzfeld Projekt, Metadaten und Projektdatensatz', async () => { + const items = [{ description: 'Erstellung der Website anelia-wolf.com, Pauschalpreis laut Absprache', quantity: 1, unitAmountCents: 50000 }] + const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items, createdBy: 'kenso@webklar.com' }) + assert.deepEqual(e.projekt, { id: 'p1', name: 'Website anelia-wolf.com', ticketId: 't1' }) + assert.equal(e.auftrag, null) + assert.equal(e.summeCents, 50000) + assert.deepEqual(e.customFields, [{ name: 'Projekt', value: 'Website anelia-wolf.com' }]) + assert.deepEqual(e.metadata, { webklarCustomerId: 'k1', quelle: 'woms', erstelltVon: 'kenso@webklar.com', art: 'festbetrag', projektId: 'p1', projektName: 'Website anelia-wolf.com' }) + const { erstelltAm, ...rest } = e.projektRechnung + assert.match(erstelltAm, /^\d{4}-\d{2}-\d{2}T/) + assert.deepEqual(rest, { kundenId: 'k1', projektId: 'p1', auftragId: '', art: 'festbetrag', betragCents: 50000, arbeitsblattIds: [], rechnungsnummer: '', aktiv: true, erstelltVon: 'kenso@webklar.com' }) + // eine genannte auftragId bleibt bei dieser Art ohne Wirkung + assert.equal((await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', auftragId: 'a1', items })).auftrag, null) +}) + +test('baueRechnung festbetrag: lange Projektnamen werden auf 140 Zeichen gekürzt, leere ersetzt', async () => { + aw.seed('websiteProjects', { $id: 'plang', customerId: 'k1', projectName: 'P'.repeat(200), ticketId: '' }) + aw.seed('websiteProjects', { $id: 'psub', customerId: 'k1', projectName: '', subdomain: 'wolf-test', ticketId: '' }) + aw.seed('websiteProjects', { $id: 'pleer', customerId: 'k1', ticketId: '' }) + const e = await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId: 'plang', items: POSTEN }) + assert.equal(e.customFields[0].value.length, 140) + assert.ok(e.customFields[0].value.endsWith('P…')) + assert.equal(e.metadata.projektName, e.customFields[0].value) + const name = async (projektId) => (await baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items: POSTEN })).customFields[0].value + assert.equal(await name('psub'), 'wolf-test') + assert.equal(await name('pleer'), 'Projekt') +}) + +test('baueRechnung festbetrag: Projekt ist Pflicht, fremde und unbekannte Projekte sind 404 (kein Unterschied), ungültige Positionen 400', async () => { + const mit = (projektId, items = POSTEN) => baueRechnung({ customerId: 'k1', art: 'festbetrag', projektId, items }) + for (const projektId of [undefined, '', null, 5]) await wirft(mit(projektId), 400, 'Für diese Rechnungsart ist ein Projekt nötig.') + for (const projektId of ['gibtEsNicht', 'p2', '../x']) await wirft(mit(projektId), 404, 'Projekt nicht gefunden.') + await wirft(mit('p1', []), 400, 'Eine Rechnung braucht 1 bis 20 Positionen.') + assert.equal(st.calls.length, 0) +})