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') +})