diff --git a/server/services/auftraege/rechnungsbau.js b/server/services/auftraege/rechnungsbau.js index 080f0c6..190a12f 100644 --- a/server/services/auftraege/rechnungsbau.js +++ b/server/services/auftraege/rechnungsbau.js @@ -85,6 +85,14 @@ export async function buche(was, fn) { } } +/** Schlussrechnung finalisiert: die AB geht von versendet oder bestaetigt auf abgerechnet. Unter der Sperre der AB. */ +export async function setzeAbAbgerechnet(auftragId) { + await withLocks(sperreAuftrag(auftragId), async () => { + const ab = await getAuftrag(auftragId) + if (ab && (ab.status === 'versendet' || ab.status === 'bestaetigt')) await updateAuftrag(ab.id, { status: 'abgerechnet' }) + }) +} + /** Schlussrechnung weg (storniert, geloescht): die AB geht von abgerechnet zurueck. Unter der Sperre der AB. */ export async function setzeAbZurueck(auftragId) { await withLocks(sperreAuftrag(auftragId), async () => { diff --git a/server/services/finance/stripeFinance.js b/server/services/finance/stripeFinance.js index b4787f2..d3f2a6c 100644 --- a/server/services/finance/stripeFinance.js +++ b/server/services/finance/stripeFinance.js @@ -4,8 +4,8 @@ import { getStripe, getOrCreateStripeCustomer, stripeCustomerFields, subscriptio import { addDays, berlinDate, daysBetween } from './dates.js' import { RECHNUNGS_ARTEN, sperreRechnung } from '../auftraege/konstanten.js' import { withLocks } from '../keyedLock.js' -import { baueRechnung, ladeFestenKunden } from '../auftraege/rechnungsbau.js' -import { createProjektRechnung } from '../auftraege/projektRechnungenStore.js' +import { baueRechnung, ladeFestenKunden, buche, gibProjektRechnungFrei, setzeAbAbgerechnet } from '../auftraege/rechnungsbau.js' +import { createProjektRechnung, updateProjektRechnung } from '../auftraege/projektRechnungenStore.js' import { getSettings } from './settings.js' /** @@ -221,12 +221,34 @@ async function readInvoice(invoiceId) { return mapInvoice(await retrieveInvoice(invoiceId), { today: berlinDate() }) } +// Buchungen am Projektdatensatz nach einem Stripe-Erfolg: scheitert Appwrite, wird nur protokolliert (siehe buche) +const PROJEKT_ARTEN = RECHNUNGS_ARTEN.filter((art) => art !== 'frei') + +async function bucheNummer(invoiceId, number, art) { + if (!PROJEKT_ARTEN.includes(art) || !number) return + await buche('Rechnungsnummer buchen', () => updateProjektRechnung(invoiceId, { rechnungsnummer: number })) +} + +/** Finalisiert: Rechnungsnummer in den Datensatz; bei einer Schlussrechnung geht die AB auf abgerechnet. */ +async function bucheFinalisiert(invoiceId, number, art, auftragId) { + if (!PROJEKT_ARTEN.includes(art)) return + await bucheNummer(invoiceId, number, art) + if (art === 'schluss' && auftragId) await buche('AB abrechnen', () => setzeAbAbgerechnet(auftragId)) +} + +/** Storniert oder geloescht: Datensatz inaktiv (Blaetter frei); bei einer Schlussrechnung geht die AB zurueck. */ +async function bucheStorniert(invoiceId, art) { + if (!PROJEKT_ARTEN.includes(art)) return + await buche('Rechnung freigeben', () => gibProjektRechnungFrei(invoiceId)) +} + // Finalisieren ohne Auto-Advance (die Rechnung bleibt in unserer Hand), dann per E-Mail senden. Scheitert nur das // Senden, ist die Rechnung schon festgeschrieben (offen, mit Nummer): der Fehler (status 502) nennt sie, damit der // Aufrufer sie erneut senden kann (sendInvoiceNow), statt eine zweite anzulegen. -async function finalizeAndSend(stripe, invoiceId) { +async function finalizeAndSend(stripe, invoiceId, { art = 'frei', auftragId = '' } = {}) { const finalized = await stripe.invoices.finalizeInvoice(invoiceId, { auto_advance: false }) const number = finalized.number || '' + await bucheFinalisiert(invoiceId, number, art, auftragId) try { await stripe.invoices.sendInvoice(invoiceId) } catch (cause) { @@ -402,7 +424,7 @@ async function createInvoice({ eingabe, daysUntilDue, note, senden, key }) { } try { - const number = senden ? await finalizeAndSend(stripe, invoiceId) : '' + const number = senden ? await finalizeAndSend(stripe, invoiceId, { art: entwurf.art, auftragId: entwurf.auftrag?.id ?? '' }) : '' try { return await readInvoice(invoiceId) } catch (cause) { @@ -448,8 +470,12 @@ export async function sendInvoiceNow(invoiceId) { // (Name, E-Mail, Anschrift stehen danach unveraenderlich auf der Rechnung). Ohne Kennung (z. B. Entwurf aus dem // Stripe-Dashboard) gibt es keine WOMS-Daten zum Abgleichen. if (customer) await syncStripeCustomer(getStripe(), stripeCustomerIdOf(raw), customer) - await finalizeAndSend(getStripe(), invoice.id) - } else await getStripe().invoices.sendInvoice(invoice.id) + await finalizeAndSend(getStripe(), invoice.id, { art: invoice.art, auftragId: invoice.auftragId }) + } else { + // Selbstheilung nach einem ausgefallenen Buchen: eine fehlende Rechnungsnummer im Datensatz nachtragen + await bucheNummer(invoice.id, invoice.number, invoice.art) + await getStripe().invoices.sendInvoice(invoice.id) + } return await readInvoice(invoice.id) } finally { invalidateFinanceCache() @@ -463,6 +489,7 @@ export async function voidInvoice(invoiceId) { if (invoice.status !== 'offen' && invoice.status !== 'ueberfaellig') throw httpError(409, 'Nur offene Rechnungen können storniert werden.') try { await getStripe().invoices.voidInvoice(invoice.id) + await bucheStorniert(invoice.id, invoice.art) return await readInvoice(invoice.id) } finally { invalidateFinanceCache() @@ -476,6 +503,7 @@ export async function deleteDraft(invoiceId) { if (invoice.status !== 'entwurf') throw httpError(409, 'Nur Entwürfe können gelöscht werden.') try { await getStripe().invoices.del(invoice.id) + await bucheStorniert(invoice.id, invoice.art) } finally { invalidateFinanceCache() } diff --git a/test/auftraegeStripe.test.js b/test/auftraegeStripe.test.js index 043ecc6..23d50f8 100644 --- a/test/auftraegeStripe.test.js +++ b/test/auftraegeStripe.test.js @@ -203,3 +203,86 @@ test('Schluss: zwei gleichzeitige Schlussrechnungen zur selben AB -> eine entste assert.equal(ergebnisse[1].reason.message, `Erst den Entwurf über 800,00${NBSP}€ senden oder löschen.`) assert.equal(st.invoices.size, 1) }) + +test('Finalisieren trägt die Rechnungsnummer in den Projektdatensatz ein (senden und sendInvoiceNow); freie Rechnungen haben keinen Datensatz', async () => { + const gesendet = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true }) + assert.match(gesendet.number, /^WK-\d{4}$/) + assert.equal((await getProjektRechnung(gesendet.id)).rechnungsnummer, gesendet.number) + const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, memo: 'später' }) + assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, '') + const nachher = await sendInvoiceNow(entwurf.id) + assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, nachher.number) + const frei = await createProjectInvoice({ customerId: 'k1', items: POSTEN, memo: 'frei', senden: true }) + assert.equal(await getProjektRechnung(frei.id), null) +}) + +test('Schlussrechnung: nach dem Finalisieren steht die AB auf abgerechnet; Anzahlung und Entwurf ändern den Status nicht', async () => { + const ab = await legeAuftragAn() + await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true }) + assert.equal((await getAuftrag(ab.id)).status, 'versendet') + const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) + assert.equal((await getAuftrag(ab.id)).status, 'versendet') + const gesendet = await sendInvoiceNow(entwurf.id) + assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') + assert.equal((await getProjektRechnung(entwurf.id)).rechnungsnummer, gesendet.number) + await wirft(createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'noch eine' }), 400, 'Die Auftragsbestätigung muss versendet oder bestätigt sein.') +}) + +test('Storno der Schlussrechnung: Datensatz inaktiv, AB zurück auf bestaetigt (mit bestaetigtAm) bzw. versendet; Storno einer Anzahlung ändert die AB nie', async () => { + for (const [patch, erwartet] of [[{}, 'versendet'], [{ status: 'bestaetigt', bestaetigtAm: '2026-10-07T09:00:00.000Z' }, 'bestaetigt']]) { + await leereRechnungsDaten(aw, st) + resetInvoiceCreateGuardForTests() + const ab = await legeAuftragAn({}, patch) + const anzahlung = await createProjectInvoice({ customerId: 'k1', art: 'anzahlung', auftragId: ab.id, senden: true }) + await voidInvoice(anzahlung.id) + assert.equal((await getProjektRechnung(anzahlung.id)).aktiv, false) + assert.equal((await getAuftrag(ab.id)).status, patch.status ?? 'versendet') + const schluss = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, senden: true }) + assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') + await voidInvoice(schluss.id) + assert.equal((await getProjektRechnung(schluss.id)).aktiv, false) + assert.equal((await getAuftrag(ab.id)).status, erwartet) + // die stornierten Rechnungen zählen nicht: eine neue Schlussrechnung geht über die volle Summe + assert.equal((await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id, memo: 'neu' })).totalCents, 80000) + } +}) + +test('Entwurf löschen und Storno geben die Arbeitsblätter frei (Datensatz inaktiv)', async () => { + const args = { customerId: 'k1', art: 'aufwand', projektId: 'p1', arbeitsblattIds: ['ws1'], createdBy: 'k' } + const entwurf = await createProjectInvoice(args) + await wirft(createProjectInvoice({ ...args, memo: 'nochmal' }), 409, 'Arbeitsblatt bereits abgerechnet (Entwurf).') + await deleteDraft(entwurf.id) + assert.equal((await getProjektRechnung(entwurf.id)).aktiv, false) + const offen = await createProjectInvoice({ ...args, memo: 'nochmal', senden: true }) + assert.equal(offen.status, 'offen') + await wirft(createProjectInvoice({ ...args, memo: 'drittes Mal' }), 409, `Arbeitsblatt bereits abgerechnet (Rechnung ${offen.number}).`) + await voidInvoice(offen.id) + assert.equal((await getProjektRechnung(offen.id)).aktiv, false) + assert.equal((await createProjectInvoice({ ...args, memo: 'viertes Mal' })).status, 'entwurf') +}) + +test('Scheitert das Buchen nach dem Finalisieren, wird die Rechnung trotzdem gesendet (nur Protokoll); erneutes Senden trägt die Nummer nach', async () => { + const log = mock.method(console, 'error', () => {}) + aw.failNext({ collection: config.collections.projektRechnungen, method: 'PATCH', status: 500 }) + const rechnung = await createProjectInvoice({ customerId: 'k1', art: 'festbetrag', projektId: 'p1', items: POSTEN, senden: true }) + assert.equal(rechnung.status, 'offen') + assert.equal(callsTo('POST', /\/send$/).length, 1) + assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Rechnungsnummer buchen'))) + assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, '') + const erneut = await sendInvoiceNow(rechnung.id) + assert.equal(erneut.status, 'offen') + assert.equal((await getProjektRechnung(rechnung.id)).rechnungsnummer, rechnung.number) +}) + +test('Die AB ändert L2 nur unter der AB-Sperre: eine laufende Statusänderung eines anderen Strangs wird nicht überholt', async () => { + const ab = await legeAuftragAn() + const entwurf = await createProjectInvoice({ customerId: 'k1', art: 'schluss', auftragId: ab.id }) + let freigeben + const gehalten = withLocks(sperreAuftrag(ab.id), () => new Promise((resolve) => { freigeben = resolve })) + const senden = sendInvoiceNow(entwurf.id) + await new Promise((resolve) => setTimeout(resolve, 200)) + assert.equal((await getAuftrag(ab.id)).status, 'versendet', 'L2 wartet auf die Sperre der AB') + freigeben() + await Promise.all([gehalten, senden]) + assert.equal((await getAuftrag(ab.id)).status, 'abgerechnet') +})