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 { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' // Zahlungseingang in jedem Zustand (Task B10b, Review Focus #1): nie Geld ohne Spur, nie doppelt. // Immer gilt nach einem Fall: Tickets + Team-Mails >= 1 und Tickets <= 1 (spur()). Bestellt wird ueber die echte Route // POST /api/funktionen/termine/checkout, bezahlt ueber den signierten Webhook; websiteProjects, workorders und customers // legt das Setup-Skript nicht an: ohne Schema im Nachbau. const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents'), termineConfig: schemaKeys('termineConfig'), }, }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, post, postWebhook } = await import('./support/testApp.js') const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') const { default: aboRoutes } = await import('../server/routes/abo.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const sf = await import('../server/services/siteFeatures.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes }) // Mail-Senke, deren Versand sich gezielt scheitern lassen laesst: versandFehler(mail) => true wirft wie ein SMTP-Fehler const sink = mailSink() const mails = { sent: sink.sent, versandFehler: null, async sendMail(mail) { if (mails.versandFehler?.(mail)) throw new Error('SMTP nicht erreichbar') return sink.sendMail(mail) }, } setMailTransportForTests(mails) const T = config.features.termine const STANDARD_TERMINE_MODUS = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails } after(() => { setMailTransportForTests(null) config.termine.mode = STANDARD_TERMINE_MODUS.mode config.termine.pilotEmails = STANDARD_TERMINE_MODUS.pilotEmails delete T.bestellpruefung return Promise.all([app.close(), aw.close(), st.close()]) }) // seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) beforeEach(async () => { for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'workorders']) { for (const id of aw.docs(col).keys()) await deleteDocument(col, id) } seedFixtures(aw) aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon' }) aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Studio' }) wartung() st.checkoutSessions.clear() st.invoices.clear() mails.sent.length = 0 mails.versandFehler = null config.termine.mode = 'pilot' config.termine.pilotEmails = ['*'] // Die Bestellpruefung (Fragebogen-Gate, termine/fragebogen.js) ist hier nicht Gegenstand: jedes Projekt ist bestellbar T.bestellpruefung = async () => [] }) const c1 = customerCookie('c1') const SUBJECT = { K15: 'Deine Bestellung: Terminplaner', T16: 'Neue Funktion bestellt: Terminplaner', T16_NEWS: 'Neue Funktion bestellt: News-Pinnwand', T19: '[Abo] Erstattung fällig: Terminplaner-Einrichtung', } const KEIN_ABO = /Kein laufendes Wartungs-Abo/ const BEREITS_DA = /Bezahlt, aber schon vorhanden/ const tickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('Terminplaner einbauen')) const mailsMit = (subject) => mails.sent.filter((m) => m.subject === subject) const teamMails = () => mails.sent.filter((m) => m.to.some((t) => config.chatNotify.emails.includes(t.address))) const doc = (projectId = 'p1', key = 'termine') => sf.getFeatureDoc('c1', projectId, key) const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } }) const webhook = (type, session) => postWebhook(event(type, session)) const STUMM = (t) => t.mock.method(console, 'error', () => {}) const geloggt = (stumm) => stumm.mock.calls.map((c) => c.arguments.join(' ')).join('\n') /** Hilfspruefung nach jedem Fall: Geld nie ohne Spur (Ticket oder Team-Mail) und nie mehr als ein Ticket. */ function spur() { assert.ok(tickets().length + teamMails().length >= 1, 'Geld ohne Spur: weder Ticket noch Team-Mail') assert.ok(tickets().length <= 1, `hoechstens ein Ticket, gefunden ${tickets().length}`) } /** Echter Bestellweg: Checkout-Route legt die Stripe-Sitzung an und das Dokument auf 'ordered'. */ async function bestelle(projectId = 'p1') { const haken = T.consent.items.filter((item) => item.haken).map((item) => item.id) const res = await post('/api/funktionen/termine/checkout', { projectId, zustimmung: { version: T.consent.version, haken } }, { cookie: c1 }) assert.equal(res.status, 200, JSON.stringify(res.body)) const sessionId = [...st.checkoutSessions.values()].at(-1).id assert.equal((await doc(projectId)).state, 'ordered') assert.equal((await doc(projectId)).stripeSessionId, sessionId) return sessionId } /** Bezahlte Sitzung im Ereignis; die Rechnung liegt im Stripe-Nachbau. */ function bezahlt(sessionId, { projectId = 'p1', featureKey = 'termine', invoice = `in_${sessionId}`, ...extra } = {}) { st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` }) return { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', invoice, metadata: { customerId: 'c1', projectId, featureKey }, ...extra, } } const rechnungUrl = (sessionId) => `https://invoice.stripe.test/in_${sessionId}` /** Die Systemzeile der verspaeteten Zahlung steht vorn in adminNote. */ const SYSTEMZEILE = (sessionId) => new RegExp(`^\\d{4}-\\d\\d-\\d\\dT[\\d:.]+Z System: Zahlung eingegangen \\(Sitzung ${sessionId}\\), nichts eingerichtet`) // ------------------------------------------------------------------------------------ (1) ordered + Abo laeuft --- test('(1) ordered + Abo laeuft: setup, ein Ticket, K15 und T16 je einmal, T16 ohne kein_abo', async () => { const sessionId = await bestelle() const res = await webhook('checkout.session.completed', bezahlt(sessionId)) assert.equal(res.status, 200) const d = await doc() assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['setup', 'paid', true, '']) assert.equal(d.invoiceUrl, rechnungUrl(sessionId)) assert.equal(tickets().length, 1) assert.match(tickets()[0].topic, /^Terminplaner einbauen – Salon/) assert.equal(aw.docs('workorders').get(d.ticketId).topic, tickets()[0].topic) const [k15] = mailsMit(SUBJECT.K15) assert.equal(mailsMit(SUBJECT.K15).length, 1) assert.equal(k15.to[0].address, 'k@example.invalid') const t16 = mailsMit(SUBJECT.T16) assert.equal(t16.length, 1) assert.doesNotMatch(t16[0].text, KEIN_ABO) assert.doesNotMatch(t16[0].text, BEREITS_DA) assert.match(t16[0].text, new RegExp(String(aw.docs('workorders').get(d.ticketId).woid))) spur() // Stripe-Wiederholung: nichts doppelt assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal(tickets().length, 1) assert.equal(mailsMit(SUBJECT.K15).length + mailsMit(SUBJECT.T16).length, 2) }) test('(1) Abo vorgemerkt gekuendigt, laeuft aber noch: kein kein_abo-Block', async () => { const sessionId = await bestelle() wartung({ cancelAtPeriodEnd: true }) assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO) spur() }) // ------------------------------------------------------------------------------------ (2) ordered + Abo beendet --- test('(2) ordered + Abo beendet (canceled): setup, Ticket, K15, T16 mit kein_abo', async () => { const sessionId = await bestelle() wartung({ status: 'canceled' }) assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal((await doc()).state, 'setup') assert.equal(tickets().length, 1) assert.equal(mailsMit(SUBJECT.K15).length, 1) const t16 = mailsMit(SUBJECT.T16) assert.equal(t16.length, 1) assert.match(t16[0].text, KEIN_ABO) assert.match(t16[0].text, new RegExp(String(aw.docs('workorders').get((await doc()).ticketId).woid))) spur() // Wiederholung: stumm assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal(mailsMit(SUBJECT.T16).length, 1) }) test('(2) News braucht kein Abo: nie kein_abo', async () => { await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_news_a', consent: {} }) wartung({ status: 'canceled' }) assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_a', { featureKey: 'news' }))).status, 200) assert.equal(mailsMit(SUBJECT.T16_NEWS).length, 1) assert.doesNotMatch(mailsMit(SUBJECT.T16_NEWS)[0].text, KEIN_ABO) }) // ------------------------------------------------------------------------------------ (3) laeuft scheitert --- test('(3) Abo nicht lesbar: Webhook 200, Ticket, K15, T16 ohne kein_abo, Fehler im Log', async (t) => { const sessionId = await bestelle() const stumm = STUMM(t) aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) const res = await webhook('checkout.session.completed', bezahlt(sessionId)) assert.equal(res.status, 200) assert.equal((await doc()).state, 'setup') assert.equal(tickets().length, 1) assert.equal(mailsMit(SUBJECT.K15).length, 1) assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO) assert.match(geloggt(stumm), /Wartungs-Abo/) spur() }) // ------------------------------------------------------------------------------------ (4) Staff hat aktiviert --- test('(4) Staff aktiviert die ordered-Bestellung, dann Zahlung: active bleibt, kein Ticket, keine K15, T16 bereits_aktiv genau einmal', async (t) => { const sessionId = await bestelle() await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' }) const stumm = STUMM(t) const res = await webhook('checkout.session.completed', bezahlt(sessionId)) assert.equal(res.status, 200) const d = await doc() assert.deepEqual([d.state, d.enabled, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['active', true, 'paid', true, 'gemeldet']) assert.equal(d.invoiceUrl, rechnungUrl(sessionId)) assert.equal(d.stripeInvoiceId, `in_${sessionId}`) assert.match(d.adminNote, SYSTEMZEILE(sessionId)) assert.match(d.adminNote, /Freigeschaltet von Andrej/, 'die aeltere Notiz bleibt dahinter') assert.equal(tickets().length, 0) assert.equal(mailsMit(SUBJECT.K15).length, 0) const t16 = mailsMit(SUBJECT.T16) assert.equal(t16.length, 1) assert.match(t16[0].text, BEREITS_DA) assert.match(t16[0].text, /schon freigeschaltet/) assert.match(t16[0].text, new RegExp(sessionId)) assert.doesNotMatch(t16[0].text, KEIN_ABO) assert.match(geloggt(stumm), new RegExp(sessionId), 'console.error nennt die Sitzungs-ID') assert.equal(mails.sent.length, 1, 'sonst keine Mail') spur() // zweite Zustellung desselben Ereignisses und async_payment_succeeded nach completed: stumm assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200) assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.equal(mails.sent.length, 1) assert.equal(tickets().length, 0) assert.equal((await doc()).adminNote, d.adminNote, 'nichts doppelt verbucht') }) test('(4) zwei parallele Zustellungen: genau ein T16', async (t) => { const sessionId = await bestelle() await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' }) STUMM(t) const ergebnis = await Promise.all([ webhook('checkout.session.completed', bezahlt(sessionId)), webhook('checkout.session.completed', bezahlt(sessionId)), ]) assert.deepEqual(ergebnis.map((r) => r.status), [200, 200]) assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.equal(mails.sent.length, 1) assert.equal((await doc()).zahlungMeldung, 'gemeldet') assert.equal(tickets().length, 0) spur() }) test('(4) normal bezahlte Bestellung (inzwischen aktiv): ein Folgeereignis derselben Sitzung ist kein Fall und bleibt stumm', async () => { const sessionId = await bestelle() await webhook('checkout.session.completed', bezahlt(sessionId)) const vorher = mails.sent.length // Folgeereignis zu einer normal bezahlten Bestellung (Zustand inzwischen active): kein Fall await sf.activate((await doc()).$id, { by: 'Andrej' }) assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200) assert.equal(mails.sent.length, vorher) assert.equal(tickets().length, 1) }) // ------------------------------------------------------------------------------------ (5) Team hat abgeschaltet --- test('(5) Team schaltet die ordered-Bestellung ab, dann Zahlung: disabled bleibt, T16 abgeschaltet einmal, Wiederholung stumm', async (t) => { const sessionId = await bestelle() const abgeschaltet = await sf.disable((await doc()).$id, { by: 'Andrej' }) STUMM(t) const res = await webhook('checkout.session.completed', bezahlt(sessionId)) assert.equal(res.status, 200) const d = await doc() assert.deepEqual([d.state, d.enabled, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', false, 'paid', true, 'gemeldet']) assert.equal(d.disabledAt, abgeschaltet.disabledAt, 'disabledAt (Beginn der Loeschfrist) unveraendert') assert.equal(d.disabledReason, 'team') assert.match(d.adminNote, SYSTEMZEILE(sessionId)) assert.equal(tickets().length, 0) assert.equal(mailsMit(SUBJECT.K15).length, 0) assert.equal(mailsMit(SUBJECT.T19).length, 0, 'kein T19: der Grund ist team, nicht das Abo') const t16 = mailsMit(SUBJECT.T16) assert.equal(t16.length, 1) assert.match(t16[0].text, BEREITS_DA) assert.match(t16[0].text, /schon abgeschaltet/) spur() assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200) assert.equal(mails.sent.length, 1) }) // ------------------------------------------------------------------------------------ (6) Abo-Ende vor der Einrichtung --- test('(6) disabled abo_ende_vor_einrichtung + Zahlung: T19 einmal mit zahlung_nach_ende, kein T16, kein Ticket, keine K15, Wiederholung stumm', async (t) => { const sessionId = await bestelle() await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung', adminNote: 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' }) STUMM(t) const res = await webhook('checkout.session.completed', bezahlt(sessionId)) assert.equal(res.status, 200) const d = await doc() assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', 'paid', true, 'gemeldet']) assert.equal(tickets().length, 0) assert.equal(mailsMit(SUBJECT.K15).length, 0) assert.equal(mailsMit(SUBJECT.T16).length, 0) const t19 = mailsMit(SUBJECT.T19) assert.equal(t19.length, 1) assert.equal(mails.sent.length, 1) assert.match(t19[0].text, /Zahlung nach dem Abo-Ende/) assert.doesNotMatch(t19[0].text, /Ticket noch offen/) assert.match(t19[0].text, /149 €/) assert.match(t19[0].text, new RegExp(sessionId)) assert.match(t19[0].text, /Kunde Eins/) assert.match(t19[0].text, /Salon/) assert.ok(t19[0].html.includes(rechnungUrl(sessionId)), 'Knopf zur Rechnung') assert.ok(t19[0].to.every((x) => config.chatNotify.emails.includes(x.address)), 'T19 geht an das Team') spur() // Wiederholung, async_payment_succeeded nach completed und paralleles Paar: stumm assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200) const parallel = await Promise.all([ webhook('checkout.session.completed', bezahlt(sessionId)), webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId)), ]) assert.deepEqual(parallel.map((r) => r.status), [200, 200]) assert.equal(mails.sent.length, 1) }) test('(6) parallel von Anfang an: zwei Zustellungen, ein T19', async (t) => { const sessionId = await bestelle() await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' }) STUMM(t) const ergebnis = await Promise.all([ webhook('checkout.session.completed', bezahlt(sessionId)), webhook('checkout.session.completed', bezahlt(sessionId)), webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId)), ]) assert.deepEqual(ergebnis.map((r) => r.status), [200, 200, 200]) assert.equal(mailsMit(SUBJECT.T19).length, 1) assert.equal(mails.sent.length, 1) assert.equal(tickets().length, 0) spur() }) // ------------------------------------------------------------------------------------ (7) Sitzung abgelaufen --- test('(7) Stripe-Sitzung laeuft ab, die Zahlung kommt doch: Uebernahme, setup, ein Ticket, K15 und T16 je einmal', async () => { const sessionId = await bestelle() const expired = { id: sessionId, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'termine' } } assert.equal((await webhook('checkout.session.expired', expired)).status, 200) assert.equal((await doc()).state, 'off') assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) const d = await doc() assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.stripeSessionId], ['setup', 'paid', true, sessionId]) assert.equal(tickets().length, 1) assert.equal(mailsMit(SUBJECT.K15).length, 1) assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO) spur() assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal(tickets().length, 1) assert.equal(mails.sent.length, 2) }) test('(7) A1: erneut zugestelltes checkout.session.completed nach der 30-Tage-Loeschung (off, zurueckgesetzt, gleiche Sitzung) wird nicht uebernommen', async (t) => { const sessionId = await bestelle() assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) // der 30-Tage-Lauf hat das Dokument zurueckgesetzt (siehe setzeFunktionZurueck); die Sitzungs-ID bleibt als Marke stehen const d0 = await doc() aw.seed('portalFeatures', { ...d0, state: 'off', enabled: false, unlockedByPurchase: false, purchaseStatus: 'zurueckgesetzt', zahlungMeldung: '', ticketId: '', adminNote: 'Daten gelöscht (Löschfrist abgelaufen)', }) const tickets0 = tickets().length const mails0 = mails.sent.length const stumm = STUMM(t) const warn = t.mock.method(console, 'warn', () => {}) // "Resend event" im Stripe-Dashboard: dasselbe Ereignis noch einmal assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) const d = await doc() assert.deepEqual([d.state, d.purchaseStatus, d.stripeSessionId, d.ticketId], ['off', 'zurueckgesetzt', sessionId, ''], 'nicht uebernommen') assert.equal(tickets().length, tickets0, 'kein neues Ticket') assert.equal(mails.sent.length, mails0, 'weder K15 noch T16') const log = geloggt(stumm) + warn.mock.calls.map((c) => c.arguments.join(' ')).join('\n') assert.match(log, new RegExp(`Sitzung ${sessionId} .*zurueckgesetzt`)) assert.doesNotMatch(log, /k@example\.invalid|Salon/, 'keine personenbezogenen Daten im Log') }) // ------------------------------------------------------------------------------------ (8) Neubestellung nach Ablauf --- test('(8) Neubestellung nach Ablauf: die alte Sitzung zahlt (Uebernahme, ein Ticket), die neue danach ungeklaert, kein zweites Ticket', async (t) => { const alt = await bestelle() const expired = { id: alt, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'termine' } } st.checkoutSessions.get(alt).status = 'expired' await webhook('checkout.session.expired', expired) const neu = await bestelle() assert.notEqual(neu, alt) assert.equal((await webhook('checkout.session.completed', bezahlt(alt))).status, 200) const d = await doc() assert.deepEqual([d.state, d.stripeSessionId], ['setup', alt], 'Uebernahme in die laufende Bestellung') assert.equal(tickets().length, 1) assert.equal(mailsMit(SUBJECT.K15).length, 1) assert.equal(mailsMit(SUBJECT.T16).length, 1) spur() const stumm = STUMM(t) assert.equal((await webhook('checkout.session.completed', bezahlt(neu))).status, 200) assert.equal(tickets().length, 1, 'kein zweites Ticket') const t16 = mailsMit(SUBJECT.T16) assert.equal(t16.length, 2) assert.match(t16[1].text, /Bezahlt, aber nicht zuzuordnen/) assert.match(t16[1].text, new RegExp(neu)) assert.match(geloggt(stumm), new RegExp(neu)) assert.equal((await doc()).stripeSessionId, alt) spur() }) // ------------------------------------------------------------------------------------ (9) SEPA --- test('(9) SEPA: completed ohne paid tut nichts, async_payment_succeeded wie (1)', async () => { const sessionId = await bestelle() const unbezahlt = bezahlt(sessionId, { payment_status: 'unpaid' }) assert.equal((await webhook('checkout.session.completed', unbezahlt)).status, 200) assert.equal((await doc()).state, 'ordered') assert.equal(tickets().length, 0) assert.equal(mails.sent.length, 0) assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200) assert.equal((await doc()).state, 'setup') assert.equal(tickets().length, 1) assert.equal(mailsMit(SUBJECT.K15).length, 1) assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO) spur() }) test('(9) SEPA fehlgeschlagen: T16 zahlung_fehlgeschlagen mit Terminplaner, Bestellung zurueck auf off', async () => { const sessionId = await bestelle('p2') const res = await webhook('checkout.session.async_payment_failed', bezahlt(sessionId, { projectId: 'p2', payment_status: 'unpaid' })) assert.equal(res.status, 200) assert.equal((await doc('p2')).state, 'off') const t16 = mailsMit(SUBJECT.T16) assert.equal(t16.length, 1) assert.match(t16[0].text, /Zahlung fehlgeschlagen/) assert.match(t16[0].text, /Terminplaner/) assert.equal(tickets().length, 0) }) // ------------------------------------------------------------------------------------ (10) Versandfehler --- test('(10) T16 bereits_aktiv scheitert beim Versand: Webhook 500, Zahlung gebucht, Marker offen; Wiederholung sendet genau eine Mail', async (t) => { const sessionId = await bestelle() await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' }) STUMM(t) mails.versandFehler = (m) => m.subject === SUBJECT.T16 const res = await webhook('checkout.session.completed', bezahlt(sessionId)) assert.equal(res.status, 500) const d = await doc() assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['active', 'paid', true, 'bereits_aktiv']) assert.match(d.adminNote, SYSTEMZEILE(sessionId)) assert.equal(mails.sent.length, 0) mails.versandFehler = null assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.match(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA) assert.equal((await doc()).zahlungMeldung, 'gemeldet') assert.equal((await doc()).adminNote, d.adminNote, 'die Wiederholung bucht nichts doppelt') // weitere Wiederholungen stumm assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200) assert.equal(mails.sent.length, 1) spur() }) test('(10) T19 scheitert beim Versand: Webhook 500, Marker offen; Wiederholung sendet genau ein T19', async (t) => { const sessionId = await bestelle() await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' }) STUMM(t) mails.versandFehler = (m) => m.subject === SUBJECT.T19 const res = await webhook('checkout.session.completed', bezahlt(sessionId)) assert.equal(res.status, 500) const d = await doc() assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', 'paid', true, 'abo_ende']) assert.match(d.adminNote, SYSTEMZEILE(sessionId)) assert.equal(mails.sent.length, 0) mails.versandFehler = null assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal(mailsMit(SUBJECT.T19).length, 1) assert.equal((await doc()).zahlungMeldung, 'gemeldet') assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200) assert.equal(mails.sent.length, 1) spur() }) test('(10) K15 und T16 beim normalen created scheitern weiterhin gefangen: Webhook 200, Ticket da', async (t) => { const sessionId = await bestelle() STUMM(t) mails.versandFehler = () => true const res = await webhook('checkout.session.completed', bezahlt(sessionId)) assert.equal(res.status, 200) assert.equal((await doc()).state, 'setup') assert.equal(tickets().length, 1) assert.equal(mails.sent.length, 0) // der Wiederholungsversuch sieht created:false und schickt nichts mehr (wie bisher) mails.versandFehler = null assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) assert.equal(mails.sent.length, 0) assert.equal(tickets().length, 1) }) // ------------------------------------------------------------------------------------ (11) News --- test('(11) News + Staff-Aktivierung + Zahlung: T16 bereits_aktiv (die bisher stille Luecke)', async (t) => { await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_news_b', consent: {} }) await sf.adminActivate('c1', 'p1', 'news', { by: 'Andrej' }) const stumm = STUMM(t) assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_b', { featureKey: 'news' }))).status, 200) const d = await doc('p1', 'news') assert.deepEqual([d.state, d.purchaseStatus, d.zahlungMeldung], ['active', 'paid', 'gemeldet']) assert.equal(aw.docs('workorders').size, 0, 'kein Ticket') assert.equal(mails.sent.length, 1) const t16 = mailsMit(SUBJECT.T16_NEWS) assert.equal(t16.length, 1) assert.match(t16[0].text, BEREITS_DA) assert.match(t16[0].text, /schon freigeschaltet/) assert.match(geloggt(stumm), /cs_news_b/) assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_b', { featureKey: 'news' }))).status, 200) assert.equal(mails.sent.length, 1) }) test('(11) Zahlung auf ein Dokument in Einrichtung (setup mit Ticket, Zahlung noch nicht gebucht): T16 bereits_aktiv mit "in Einrichtung"', async (t) => { aw.seed('portalFeatures', { $id: 'f_p1_termine', customerId: 'c1', projectId: 'p1', featureKey: 'termine', state: 'setup', enabled: false, siteKey: 'abcdefghijklmnop', stripeSessionId: 'cs_alt_setup', ticketId: 'wo_alt', unlockedByPurchase: false, purchaseStatus: 'ordered', }) STUMM(t) assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt_setup'))).status, 200) const t16 = mailsMit(SUBJECT.T16) assert.equal(t16.length, 1) assert.match(t16[0].text, /schon in Einrichtung/) assert.equal((await doc()).state, 'setup') assert.equal(mailsMit(SUBJECT.K15).length, 0) }) // ------------------------------------------------------------------------------------ (12) meldeErstattung direkt --- test('(12) meldeErstattung: bezahltes setup-Dokument => ein T19 mit ticket_offen, Marker gemeldet, zweiter Aufruf schonGemeldet', async () => { const sessionId = await bestelle() await webhook('checkout.session.completed', bezahlt(sessionId)) const vorher = mails.sent.length const d = await doc() const woid = aw.docs('workorders').get(d.ticketId).woid const erst = await sf.meldeErstattung(d) assert.deepEqual(erst, { sent: true }) const t19 = mailsMit(SUBJECT.T19) assert.equal(t19.length, 1) assert.equal(mails.sent.length, vorher + 1) assert.match(t19[0].text, /Ticket noch offen/) assert.match(t19[0].text, new RegExp(String(woid))) assert.doesNotMatch(t19[0].text, /Zahlung nach dem Abo-Ende/, 'die Zahlung kam vor dem Abo-Ende') assert.match(t19[0].text, /149 €/) assert.match(t19[0].text, new RegExp(sessionId)) assert.ok(t19[0].html.includes(rechnungUrl(sessionId))) const gemeldet = await doc() assert.equal(gemeldet.zahlungMeldung, 'gemeldet') assert.equal(gemeldet.purchaseStatus, 'paid') assert.deepEqual(await sf.meldeErstattung(d), { sent: false, schonGemeldet: true }) assert.deepEqual(await sf.meldeErstattung(await doc()), { sent: false, schonGemeldet: true }) assert.equal(mailsMit(SUBJECT.T19).length, 1) }) test('(12) meldeErstattung parallel: nie zwei Mails', async () => { const sessionId = await bestelle() await webhook('checkout.session.completed', bezahlt(sessionId)) const d = await doc() const ergebnis = await Promise.all([sf.meldeErstattung(d), sf.meldeErstattung(d), sf.meldeErstattung(d)]) assert.equal(ergebnis.filter((r) => r.sent).length, 1) assert.equal(ergebnis.filter((r) => r.schonGemeldet).length, 2) assert.equal(mailsMit(SUBJECT.T19).length, 1) }) test('(12) offener Fall abo_ende + Aufruf des Abgleichs + Webhook-Wiederholung: insgesamt genau ein T19 (zahlung_nach_ende)', async (t) => { const sessionId = await bestelle() await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' }) // Die erste Zustellung bucht den Fall nur (hier direkt ueber markPaid), die Meldung fehlt noch const gebucht = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) }) assert.equal(gebucht.fall, 'abo_ende') assert.equal(mails.sent.length, 0) // Der Abgleich (nachAboEnde: false) meldet zuerst: der offene Fall abo_ende macht daraus die Zahlung nach dem Abo-Ende assert.deepEqual(await sf.meldeErstattung(gebucht.doc, { nachAboEnde: false }), { sent: true }) const t19 = mailsMit(SUBJECT.T19) assert.equal(t19.length, 1) assert.match(t19[0].text, /Zahlung nach dem Abo-Ende/) assert.doesNotMatch(t19[0].text, /Ticket noch offen/, 'kein Ticket') assert.equal((await doc()).zahlungMeldung, 'gemeldet') // die Wiederholung des Webhooks danach bleibt stumm, auch parallel zu einem weiteren Aufruf STUMM(t) const parallel = await Promise.all([ webhook('checkout.session.completed', bezahlt(sessionId)), sf.meldeErstattung(await doc()), ]) assert.equal(parallel[0].status, 200) assert.deepEqual(parallel[1], { sent: false, schonGemeldet: true }) assert.equal(mailsMit(SUBJECT.T19).length, 1) assert.equal(mails.sent.length, 1) assert.equal(tickets().length, 0) spur() }) test('(12) Abgleich und Webhook gleichzeitig auf den offenen Fall: ein T19', async (t) => { const sessionId = await bestelle() await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' }) const gebucht = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) }) STUMM(t) const [abgleich, hook] = await Promise.all([sf.meldeErstattung(gebucht.doc), webhook('checkout.session.completed', bezahlt(sessionId))]) assert.equal(hook.status, 200) assert.ok(abgleich.sent || abgleich.schonGemeldet) assert.equal(mailsMit(SUBJECT.T19).length, 1) assert.match(mailsMit(SUBJECT.T19)[0].text, /Zahlung nach dem Abo-Ende/) assert.equal(mails.sent.length, 1) }) test('(12) meldeErstattung: Mail scheitert => { sent: false }, kein Marker, kein Wurf; spaeter sendet der naechste Aufruf', async (t) => { const sessionId = await bestelle() await webhook('checkout.session.completed', bezahlt(sessionId)) const d = await doc() STUMM(t) mails.versandFehler = () => true const ergebnis = await sf.meldeErstattung(d) assert.deepEqual(ergebnis, { sent: false }) assert.equal((await doc()).zahlungMeldung, '', 'kein Marker') assert.equal(mailsMit(SUBJECT.T19).length, 0) mails.versandFehler = null assert.deepEqual(await sf.meldeErstattung(d), { sent: true }) assert.equal((await doc()).zahlungMeldung, 'gemeldet') assert.equal(mailsMit(SUBJECT.T19).length, 1) }) test('(12) meldeErstattung: nachAboEnde setzt den Block auch ohne offenen Fall, Sitzung und Kunde sind uebersteuerbar', async () => { const sessionId = await bestelle() await webhook('checkout.session.completed', bezahlt(sessionId)) const d = await doc() const ergebnis = await sf.meldeErstattung(d, { nachAboEnde: true, sitzung: 'cs_andere', customer: { name: 'Frau Muster', email: 'muster@example.invalid' } }) assert.deepEqual(ergebnis, { sent: true }) const [t19] = mailsMit(SUBJECT.T19) assert.match(t19.text, /Zahlung nach dem Abo-Ende/) assert.match(t19.text, /cs_andere/) assert.match(t19.text, /Frau Muster/) assert.match(t19.text, /muster@example\.invalid/) }) test('(12) meldeErstattung wirft nie: unbekanntes Dokument => { sent: false }', async (t) => { STUMM(t) assert.deepEqual(await sf.meldeErstattung({ $id: 'gibt_es_nicht', customerId: 'c1', projectId: 'p1', featureKey: 'termine' }), { sent: false }) }) test('meldeZahlungFall direkt: einmal je Dokument, Zustand im Text, kein Wurf bei Versandfehler', async (t) => { const sessionId = await bestelle() await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' }) const { doc: gebucht, fall } = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) }) assert.equal(fall, 'bereits_aktiv') STUMM(t) mails.versandFehler = () => true assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: false }) assert.equal((await doc()).zahlungMeldung, 'bereits_aktiv') mails.versandFehler = null assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: true }) assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: false, schonGemeldet: true }) assert.equal(mailsMit(SUBJECT.T16).length, 1) assert.match(mailsMit(SUBJECT.T16)[0].text, /schon freigeschaltet/) }) test('customerOf und projectName sind exportiert', async () => { assert.equal((await sf.customerOf('c1')).name, 'Kunde Eins') assert.equal(await sf.customerOf('gibt_es_nicht'), null) assert.equal(await sf.projectName('p1'), 'Salon') assert.equal(await sf.projectName('gibt_es_nicht'), '') })