From 468df49812248a2586e5e8119b77b1398b55476d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:13:42 +0000 Subject: [PATCH] Zahlungseingang: markPaid meldet den Fall und merkt ihn im Dokument markPaid liefert jetzt { doc, created, fall }. Trifft eine Zahlung ein Dokument, das nicht mehr auf die Bezahlung wartet (active, disabled, setup mit Ticket), wird sie einmalig verbucht (purchaseStatus paid, unlockedByPurchase, Rechnung, Notiz) und der noch zu meldende Fall im eigenen Feld zahlungMeldung vermerkt (bereits_aktiv | abgeschaltet | abo_ende), in einem einzigen Schreibzugriff. Ein Folgeereignis bucht nichts doppelt und liefert den Fall erneut, bis zahlungMeldung auf gemeldet steht. ordered und fortsetzbares setup laufen wie bisher, off bleibt unveraendert (Uebernahme ueber adoptPaidSession). Co-Authored-By: Claude Sonnet 5.5 --- server/services/siteFeatures.js | 58 ++++++++++++- test/siteFeatures.test.js | 148 ++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 4 deletions(-) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index be2ab98..d73000a 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -269,19 +269,68 @@ async function findBySession(sessionId) { return doc || null } +/** Faelle einer Zahlung, die kein bestellbares Dokument trifft (portalFeatures.zahlungMeldung); 'gemeldet' setzt erst die Meldung (B10b). */ +const ZAHLUNG_FAELLE = ['bereits_aktiv', 'abgeschaltet', 'abo_ende'] +const NOTE_MAX = 300 + +/** Fall einer Zahlung auf ein Dokument im Zustand active, disabled oder setup mit Ticket. */ +function zahlungsFall(doc) { + if (doc.state !== 'disabled') return 'bereits_aktiv' + return doc.disabledReason === 'abo_ende_vor_einrichtung' ? 'abo_ende' : 'abgeschaltet' +} + +/** Der noch zu meldende Fall am Dokument (zahlungMeldung), sonst null; '', 'gemeldet' und alles andere ist kein offener Fall. */ +function offenerFall(doc) { + return ZAHLUNG_FAELLE.includes(doc.zahlungMeldung) ? doc.zahlungMeldung : null +} + +/** Neuester Eintrag zuerst, hoechstens 300 Zeichen (wie die Notiz im Ticketsystem). */ +function notizVorn(previous, entry) { + const neu = entry.slice(0, NOTE_MAX) + const aelter = String(previous || '') + return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu +} + +/** + * Eine Zahlung trifft ein Dokument, das nicht (mehr) auf die Bezahlung wartet: active, disabled oder setup mit Ticket. Sie wird + * einmalig verbucht (purchaseStatus 'paid', Rechnung, Notiz) und der Fall im eigenen Feld zahlungMeldung vermerkt - in einem einzigen + * Schreibzugriff, damit nie eine verbuchte Zahlung ohne Marker bleibt. purchaseStatus bekommt nie einen Meldezustand (die + * Widerrufspruefung liest ihn). Ein Folgeereignis bucht nichts mehr, liefert den Fall aber erneut, solange er nicht gemeldet ist. + * Aufruf nur unter der Sperre der Sitzung. + */ +async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) { + if (doc.purchaseStatus === 'paid') return { doc, created: false, fall: offenerFall(doc) } + const fall = zahlungsFall(doc) + const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { + purchaseStatus: 'paid', + unlockedByPurchase: true, + stripeInvoiceId: invoiceId || '', + invoiceUrl: invoiceUrl || '', + adminNote: notizVorn(doc.adminNote, `${new Date().toISOString()} System: Zahlung eingegangen (Sitzung ${sessionId}), nichts eingerichtet`), + zahlungMeldung: fall, + }) + return { doc: updated, created: false, fall } +} + /** * Idempotent und je Session serialisiert: nur der Uebergang aus 'ordered' legt das Ticket an. Erst * wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId * (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. Stripe wiederholt den Webhook: * 'setup' ohne ticketId gilt dann als fortsetzbar (Ticket anlegen, created:true -> K15/T16 gehen raus). + * + * Liefert { doc, created, fall }. fall ist null, ausser die Zahlung traf ein Dokument, das nicht mehr bestellt ist ('active', + * 'disabled', 'setup' mit Ticket): dann wird sie einmalig verbucht und 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' geliefert, + * auch bei jedem Folgeereignis, bis zahlungMeldung 'gemeldet' ist (siehe bucheSpaeteZahlung). Ein Dokument im Zustand 'off' + * bleibt unveraendert (die Uebernahme macht adoptPaidSession). */ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { - if (!sessionId) return { doc: null, created: false } + if (!sessionId) return { doc: null, created: false, fall: null } return withLocks([`feature-paid:${sessionId}`], async () => { const doc = await findBySession(sessionId) - if (!doc) return { doc: null, created: false } + if (!doc) return { doc: null, created: false, fall: null } + if (doc.state === 'off') return { doc, created: false, fall: null } const resumable = doc.state === 'setup' && !doc.ticketId - if (doc.state !== 'ordered' && !resumable) return { doc, created: false } + if (doc.state !== 'ordered' && !resumable) return bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) const setup = resumable ? doc @@ -291,6 +340,7 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { unlockedByPurchase: true, stripeInvoiceId: invoiceId || '', invoiceUrl: invoiceUrl || '', + zahlungMeldung: '', }) const project = await getDocument(config.collections.websiteProjects, doc.projectId) const customer = await customerOf(doc.customerId).catch(() => null) @@ -304,7 +354,7 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { ...texte, }) const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId }) - return { doc: updated, created: true } + return { doc: updated, created: true, fall: null } }) } diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js index 5f891fe..9d8f17b 100644 --- a/test/siteFeatures.test.js +++ b/test/siteFeatures.test.js @@ -437,3 +437,151 @@ test('disableCustomerFeatures reicht den Grund an alle Dokumente durch, ein scho await sf.disableCustomerFeatures('c_a11b', 'termine', {}) assert.equal(lesen('f_a11_k2').disabledReason, 'team') }) + +// ------------------------------- markPaid meldet den Fall und merkt ihn im Dokument (B10a) --- +const HINWEIS = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z System: Zahlung eingegangen \(Sitzung cs_[\w]+\), nichts eingerichtet$/ +const bezahlDoc = (id, over = {}) => + termineDoc(id, { projectId: 'p1', stripeSessionId: `cs_${id}`, purchaseStatus: 'ordered', unlockedByPurchase: false, ...over }) +const bezahle = (id) => sf.markPaid({ sessionId: `cs_${id}`, invoiceId: `in_${id}`, invoiceUrl: `https://x/inv/${id}` }) +const FAELLE = [ + ['active', { state: 'active', enabled: true }, 'bereits_aktiv'], + ['setup_mit_ticket', { state: 'setup', enabled: false, ticketId: 't_vorhanden' }, 'bereits_aktiv'], + ['disabled_team', { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' }, 'abgeschaltet'], + ['disabled_abo_ende', { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' }, 'abgeschaltet'], + ['disabled_geloescht', { state: 'disabled', enabled: false, disabledReason: 'kunde_geloescht', disabledAt: '2026-10-01T10:00:00.000Z' }, 'abgeschaltet'], + ['disabled_altbestand', { state: 'disabled', enabled: false, disabledAt: '2026-10-01T10:00:00.000Z' }, 'abgeschaltet'], + ['disabled_vor_einrichtung', { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: '2026-10-01T10:00:00.000Z' }, 'abo_ende'], +] + +for (const [name, zustand, fall] of FAELLE) { + test(`markPaid ${name}: Zahlung wird einmalig verbucht, der Fall ${fall} im Dokument vermerkt, nichts eingerichtet`, async () => { + const id = `f_b10a_${name}` + aw.seed('portalFeatures', bezahlDoc(id, zustand)) + const ticketsVorher = aw.docs('workorders').size + const davor = lesen(id) + + const { doc, created, fall: gemeldet } = await bezahle(id) + assert.equal(created, false) + assert.equal(gemeldet, fall) + assert.equal(doc.$id, id) + const nachher = lesen(id) + assert.equal(nachher.purchaseStatus, 'paid') + assert.equal(nachher.unlockedByPurchase, true) + assert.equal(nachher.stripeInvoiceId, `in_${id}`) + assert.equal(nachher.invoiceUrl, `https://x/inv/${id}`) + assert.equal(nachher.zahlungMeldung, fall) + assert.match(nachher.adminNote, HINWEIS) + assert.ok(nachher.adminNote.includes(`(Sitzung cs_${id})`)) + assert.ok(nachher.adminNote.length <= 300) + assert.deepEqual([nachher.state, nachher.enabled, nachher.ticketId, nachher.disabledReason, nachher.disabledAt], [davor.state, davor.enabled, davor.ticketId, davor.disabledReason, davor.disabledAt], 'Zustand, Ticket und Abschaltgrund bleiben') + assert.equal(aw.docs('workorders').size, ticketsVorher, 'kein Ticket') + assert.deepEqual(doc, nachher, 'liefert das gespeicherte Dokument') + }) +} + +test('markPaid: ein Folgeereignis bucht nichts doppelt, liefert den Fall aber erneut, bis die Meldung als gemeldet vermerkt ist', async () => { + const id = 'f_b10a_wiederholung' + aw.seed('portalFeatures', bezahlDoc(id, { adminNote: 'Notiz vom Team' })) + const erst = await bezahle(id) + assert.equal(erst.fall, 'bereits_aktiv') + const gebucht = lesen(id) + assert.equal(gebucht.adminNote.endsWith(' | Notiz vom Team'), true, 'aeltere Notiz bleibt dahinter erhalten') + assert.match(gebucht.adminNote.split(' | ')[0], HINWEIS) + + // Stripe wiederholt, SEPA schickt completed und async_payment_succeeded: nichts wird geschrieben, der Fall bleibt offen + for (let i = 0; i < 2; i += 1) { + const { ergebnis, schreibend } = await schreibzugriffe(() => bezahle(id)) + assert.equal(schreibend, 0, 'kein Schreibzugriff beim Folgeereignis') + assert.deepEqual([ergebnis.created, ergebnis.fall], [false, 'bereits_aktiv']) + assert.deepEqual(lesen(id), gebucht, 'adminNote, purchaseStatus und Rechnung unveraendert') + } + + // die Meldung ist raus (B10b): der Marker steht auf gemeldet, danach ist der Fall nicht mehr offen + aw.seed('portalFeatures', { ...lesen(id), zahlungMeldung: 'gemeldet' }) + const { ergebnis: danach, schreibend } = await schreibzugriffe(() => bezahle(id)) + assert.equal(schreibend, 0) + assert.deepEqual([danach.created, danach.fall], [false, null]) + assert.equal(lesen(id).purchaseStatus, 'paid', 'purchaseStatus wird nie mit Meldezustaenden ueberladen') + assert.equal(lesen(id).zahlungMeldung, 'gemeldet') + + // ein unbekannter Wert im Marker gilt nicht als offener Fall + aw.seed('portalFeatures', { ...lesen(id), zahlungMeldung: 'unsinn' }) + assert.equal((await bezahle(id)).fall, null) +}) + +test('markPaid: die Notiz haelt 300 Zeichen mit dem neuesten Eintrag vorn', async () => { + const id = 'f_b10a_notiz' + aw.seed('portalFeatures', bezahlDoc(id, { adminNote: 'x'.repeat(300) })) + await bezahle(id) + const note = lesen(id).adminNote + assert.equal(note.length, 300) + assert.match(note, /^\d{4}-\d{2}-\d{2}T[\d:.]+Z System: Zahlung eingegangen \(Sitzung cs_f_b10a_notiz\), nichts eingerichtet \| x+$/) +}) + +test('markPaid: eine normal bezahlte Bestellung (ordered, setup, active) hat zahlungMeldung leer, ihr Folgeereignis liefert keinen Fall', async () => { + aw.seed('websiteProjects', { $id: 'p_b10a_normal', customerId: 'c1', projectName: 'Normal bezahlt' }) + await sf.markOrdered({ customerId: 'c1', projectId: 'p_b10a_normal', key: 'termine', sessionId: 'cs_b10a_normal', consent: {} }) + const erst = await sf.markPaid({ sessionId: 'cs_b10a_normal', invoiceId: 'in_n', invoiceUrl: 'https://x/n' }) + assert.deepEqual([erst.created, erst.fall], [true, null]) + assert.equal(erst.doc.state, 'setup') + assert.equal(erst.doc.purchaseStatus, 'paid') + assert.equal(erst.doc.zahlungMeldung, '') + const notiz = erst.doc.adminNote + + // Ticket fertig, Funktion aktiv: ein Folgeereignis ist kein Zahlungsfall + const aktiv = await sf.activate(erst.doc.$id, {}) + assert.equal(aktiv.state, 'active') + const { ergebnis, schreibend } = await schreibzugriffe(() => sf.markPaid({ sessionId: 'cs_b10a_normal', invoiceId: 'in_n', invoiceUrl: 'https://x/n' })) + assert.equal(schreibend, 0) + assert.deepEqual([ergebnis.created, ergebnis.fall], [false, null]) + assert.equal(lesen(erst.doc.$id).zahlungMeldung, '') + assert.equal(lesen(erst.doc.$id).adminNote, notiz) + assert.equal(lesen(erst.doc.$id).purchaseStatus, 'paid') + + // auch nach dem Abschalten bleibt es ohne Fall + await sf.disable(erst.doc.$id, { reason: 'abo_ende' }) + const spaeter = await sf.markPaid({ sessionId: 'cs_b10a_normal' }) + assert.deepEqual([spaeter.created, spaeter.fall], [false, null]) +}) + +test('markPaid: ordered und fortsetzbares setup (ohne Ticket) laufen wie bisher mit created true und ohne Fall', async () => { + const ordered = 'f_b10a_ordered' + aw.seed('portalFeatures', bezahlDoc(ordered, { state: 'ordered', enabled: false })) + const a = await bezahle(ordered) + assert.deepEqual([a.created, a.fall], [true, null]) + assert.equal(a.doc.state, 'setup') + assert.ok(a.doc.ticketId) + + const fortsetzbar = 'f_b10a_fortsetzbar' + aw.seed('portalFeatures', bezahlDoc(fortsetzbar, { state: 'setup', enabled: false, purchaseStatus: 'paid', unlockedByPurchase: true })) + const b = await bezahle(fortsetzbar) + assert.deepEqual([b.created, b.fall], [true, null]) + assert.ok(b.doc.ticketId) + assert.ok(!lesen(fortsetzbar).adminNote, 'nichts verbucht, die Zahlung stand schon') +}) + +test('markPaid: ein Dokument im Zustand off bleibt unveraendert, ohne Fall (die Uebernahme ist Sache von adoptPaidSession)', async () => { + const id = 'f_b10a_off' + aw.seed('portalFeatures', bezahlDoc(id, { state: 'off', enabled: false, purchaseStatus: 'failed' })) + const davor = lesen(id) + const { ergebnis, schreibend } = await schreibzugriffe(() => bezahle(id)) + assert.equal(schreibend, 0) + assert.deepEqual([ergebnis.created, ergebnis.fall], [false, null]) + assert.equal(ergebnis.doc.state, 'off') + assert.deepEqual(lesen(id), davor) +}) + +test('markPaid: ohne Sitzung oder ohne Dokument gibt es kein Dokument, kein created und keinen Fall', async () => { + assert.deepEqual(await sf.markPaid({}), { doc: null, created: false, fall: null }) + assert.deepEqual(await sf.markPaid({ sessionId: 'cs_gibts_nicht' }), { doc: null, created: false, fall: null }) +}) + +test('markPaid: gleichzeitige Aufrufe buchen die Zahlung einmal und liefern alle denselben Fall', async () => { + const id = 'f_b10a_parallel' + aw.seed('portalFeatures', bezahlDoc(id, { state: 'disabled', enabled: false, disabledReason: 'abo_ende_vor_einrichtung', disabledAt: '2026-10-01T10:00:00.000Z' })) + const { ergebnis, schreibend } = await schreibzugriffe(() => Promise.all([1, 2, 3, 4].map(() => bezahle(id)))) + assert.equal(schreibend, 1, 'genau ein Schreibzugriff') + assert.deepEqual(ergebnis.map((r) => [r.created, r.fall]), Array(4).fill([false, 'abo_ende'])) + assert.equal(lesen(id).adminNote.split('System: Zahlung eingegangen').length - 1, 1, 'ein Eintrag in der Notiz') + assert.equal(lesen(id).zahlungMeldung, 'abo_ende') +})