diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index d73000a..49108e3 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -6,6 +6,7 @@ import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' import { anleitungsLink, featureConfig, featureKeys, ticketTexte } from './featureRegistry.js' import { zustimmungFuerSitzung } from './featureConsents.js' +import { laeuft } from './aboStatus.js' import { createFeatureTicket } from './ticketAdmin.js' export const FEATURE_KEYS = featureKeys() @@ -587,7 +588,8 @@ export function isFeatureCheckoutEvent(event) { return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type) } -async function projectName(projectId) { +/** Projektname zur ID; ein unbekanntes Projekt ergibt ''. */ +export async function projectName(projectId) { try { return (await getDocument(config.collections.websiteProjects, projectId)).projectName || '' } catch (err) { @@ -596,7 +598,8 @@ async function projectName(projectId) { } } -async function customerOf(customerId) { +/** Kundendokument zur ID; ein unbekannter Kunde ergibt null. */ +export async function customerOf(customerId) { try { return await getDocument(config.collections.customers, customerId) } catch (err) { @@ -624,8 +627,8 @@ async function ticketWoidOf(doc) { /** * T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus. - * Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". kein_abo, bereits_aktiv und gesundheit sind in der Vorlage - * deklariert und bleiben hier aus (B10a und D4 fuellen sie). + * Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der + * Block gesundheit bleibt hier aus (D4 fuellt ihn). */ function sendT16({ funktion, ...werte }, bloecke) { return sendAdminNotice({ @@ -647,7 +650,8 @@ function sendT16({ funktion, ...werte }, bloecke) { }) } -async function teamNotice(doc, customer, { failed = false } = {}) { +/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */ +async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) { const mails = featureConfig(doc.featureKey)?.mails // Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde const anleitung = Boolean(mails?.anleitung) && !failed @@ -661,7 +665,7 @@ async function teamNotice(doc, customer, { failed = false } = {}) { sitekey: failed ? '' : doc.siteKey || '', anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '', }, - { zahlung_fehlgeschlagen: failed, anleitung } + { zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo } ) } @@ -707,15 +711,127 @@ async function adoptPaidSession(session) { return true } +/** + * Meldung zu einer Zahlung genau einmal (T16 bereits_aktiv, T19): unter feature-meldung: das Dokument frisch lesen; + * zahlungMeldung === 'gemeldet' => { sent: false, schonGemeldet: true } ohne Versand. Sonst senden(frisch) (liefert { sent }) und + * erst bei sent === true den Marker 'gemeldet' schreiben (auch wenn dort vorher ein offener Fall stand). Wirft nie: jeder Fehler + * (Lesen, Aufbau, Versand) ergibt { sent: false } samt Log, der Aufrufer wiederholt. Scheitert nur das Schreiben des Markers, nachdem + * die Mail raus ist, bleibt es bei { sent: true } (Log): die Spur ist da, eine Wiederholung durch den Aufrufer waere ein Doppel. + * Nie innerhalb einer feature-paid:*-Sperre aufrufen (withLocks ist nicht wiederbetretbar). + */ +async function meldeEinmal(doc, was, senden) { + try { + return await withLocks([`feature-meldung:${doc.$id}`], async () => { + const frisch = await getDocument(config.collections.portalFeatures, doc.$id) + if (frisch.zahlungMeldung === 'gemeldet') return { sent: false, schonGemeldet: true } + const { sent } = await senden(frisch) + if (!sent) return { sent: false } + try { + await updateDocument(config.collections.portalFeatures, frisch.$id, { zahlungMeldung: 'gemeldet' }) + } catch (err) { + console.error(`[funktionen] ${was} zur Sitzung ${frisch.stripeSessionId} gesendet, aber der Marker gemeldet blieb ungeschrieben:`, err.message) + } + return { sent: true } + }) + } catch (err) { + console.error(`[funktionen] ${was} zur Sitzung ${doc.stripeSessionId} nicht gesendet:`, err.message) + return { sent: false } + } +} + +/** Kunde, Projekt und Funktionsname fuer die Team-Meldungen; was sich nicht lesen laesst, faellt auf IDs zurueck (die Meldung geht trotzdem raus). */ +async function meldeKopf(doc, customer) { + const kunde = customer ?? (await customerOf(doc.customerId).catch(() => null)) + return { + kunde_name: kunde?.name || kunde?.email || doc.customerId, + email: kunde?.email || '', + projekt: (await projectName(doc.projectId).catch(() => '')) || doc.projectId, + funktion: featureConfig(doc.featureKey)?.mails.funktion, + } +} + +/** + * Zahlung auf eine Funktion, die schon freigeschaltet, in Einrichtung oder abgeschaltet war (markPaid-Fall 'bereits_aktiv' | + * 'abgeschaltet'): T16 nur mit Block bereits_aktiv (zustand "freigeschaltet", "in Einrichtung" bzw. "abgeschaltet", woid leer), keine + * K15, kein Ticket. Genau einmal je Dokument (siehe meldeEinmal). + * @param {object} doc portalFeatures-Dokument + * @param {'bereits_aktiv'|'abgeschaltet'} fall + * @param {{ customer?: object|null }} [options] bereits gelesener Kunde, sonst wird er gelesen + * @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>} + */ +export function meldeZahlungFall(doc, fall, { customer = null } = {}) { + return meldeEinmal(doc, 'T16 bereits_aktiv', async (frisch) => { + const zustand = fall === 'abgeschaltet' ? 'abgeschaltet' : frisch.state === 'setup' ? 'in Einrichtung' : 'freigeschaltet' + return sendT16({ ...(await meldeKopf(frisch, customer)), sitzung: frisch.stripeSessionId || '', zustand }, { bereits_aktiv: true }) + }) +} + +/** + * T19 "Erstattung faellig" an das Team: die einzige Stelle, die T19 sendet (Zahlung nach dem Abo-Ende hier, die Meldung des + * Abo-Abgleichs fuer bezahlte, abgeschaltete Einrichtungen in Block E). Blocks: zahlung_nach_ende, wenn nachAboEnde gesetzt ist oder + * am (frisch gelesenen) Dokument der offene Fall 'abo_ende' steht - so auch, wenn der Abgleich vor dem Webhook-Versand zuerst meldet; + * ticket_offen, wenn das Dokument ein Ticket hat (kein Ticket und keine spaete Zahlung: keiner von beiden); rechnung mit Rechnungslink. + * Genau einmal je Dokument (siehe meldeEinmal), wirft nie. + * @param {object} doc portalFeatures-Dokument + * @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean }} [options] + * @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>} + */ +export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) { + return meldeEinmal(doc, 'T19 Erstattung faellig', async (frisch) => { + const kopf = await meldeKopf(frisch, customer) + const woid = frisch.ticketId ? await ticketWoidOf(frisch).catch(() => frisch.ticketId) : '' + return sendAdminNotice({ + template: 'T19', + values: { + ...kopf, + funktion: kopf.funktion || 'Funktion', + betrag: featureConfig(frisch.featureKey)?.priceLabel || '', + sitzung: sitzung || '', + woid, + rechnung_url: frisch.invoiceUrl || '', + }, + blocks: { + zahlung_nach_ende: nachAboEnde || frisch.zahlungMeldung === 'abo_ende', + ticket_offen: Boolean(frisch.ticketId), + rechnung: Boolean(frisch.invoiceUrl), + }, + }) + }) +} + +/** + * Eine Zahlung traf ein Dokument, das nicht mehr bestellt war (markPaid-Fall): gebucht ist sie schon, hier geht die Meldung ans Team - + * 'abo_ende' als T19 (Erstattung), 'bereits_aktiv' und 'abgeschaltet' als T16. Kein ordentliches Ticket, keine K15. + * Wirft, wenn die Meldung weder raus ist noch schon draussen war: der Webhook antwortet 500, Stripe wiederholt, markPaid liefert den + * noch offenen Fall erneut und der naechste Versuch sendet genau einmal. + */ +async function meldeFallZurZahlung(doc, fall, session) { + console.error( + `[funktionen] BEZAHLTE Sitzung ${session.id} traf die Funktion ${doc.featureKey} im Zustand ${doc.state} (Fall ${fall}, Kunde ${doc.customerId}, Projekt ${doc.projectId}) - nichts eingerichtet, das Team wird benachrichtigt` + ) + const customer = await customerOf(doc.customerId).catch((err) => { + console.error('[funktionen] Kunde fuer die Zahlungsmeldung nicht lesbar:', err.message) + return null + }) + const ergebnis = + fall === 'abo_ende' + ? await meldeErstattung(doc, { customer, nachAboEnde: true }) + : await meldeZahlungFall(doc, fall, { customer }) + if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`) +} + async function onFeaturePaid(session) { const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) const paid = { sessionId: session.id, invoiceId, invoiceUrl } - let { doc, created } = await markPaid(paid) + let { doc, created, fall } = await markPaid(paid) // Kein Dokument zur Sitzung, oder es fiel auf 'off' (Ablauf/SEPA-Fehler) und die Zahlung kam doch noch an - if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created } = await markPaid(paid)) + if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created, fall } = await markPaid(paid)) + // Das Dokument wartet nicht mehr auf die Zahlung (aktiv, abgeschaltet, in Einrichtung): nur Meldung, kein Ticket, keine K15 + if (fall) return meldeFallZurZahlung(doc, fall, session) if (!created) return // Ab hier ist die Bestellung gebucht; ein Wiederholungsversuch von Stripe sieht created:false und // wuerde keine Mail mehr schicken. Darum scheitert jede Mail einzeln und der Webhook antwortet trotzdem 200. + const feature = featureConfig(doc.featureKey) let customer = null try { customer = await customerOf(doc.customerId) @@ -724,7 +840,6 @@ async function onFeaturePaid(session) { } if (customer?.email) { try { - const feature = featureConfig(doc.featureKey) if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) await sendCustomerMail( customer.email, @@ -734,8 +849,18 @@ async function onFeaturePaid(session) { console.error('[funktionen] K15 fehlgeschlagen:', err.message) } } + // Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo). + // Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500. + let keinAbo = false + if (feature?.requiresWartungsAbo) { + try { + keinAbo = !(await laeuft(doc.customerId)) + } catch (err) { + console.error(`[funktionen] Wartungs-Abo von Kunde ${doc.customerId} fuer T16 nicht lesbar, Hinweis kein_abo entfaellt:`, err.message) + } + } try { - await teamNotice(doc, customer) + await teamNotice(doc, customer, { keinAbo }) } catch (err) { console.error('[funktionen] T16 fehlgeschlagen:', err.message) } diff --git a/test/featureMails.test.js b/test/featureMails.test.js index 93e93e5..1e5f199 100644 --- a/test/featureMails.test.js +++ b/test/featureMails.test.js @@ -353,6 +353,8 @@ test('Termine-Mails: K16 mit den Werten des Terminplaners', () => { // -------------------------------------------------------------------- T16 je Funktion (Team-Hinweis) --- test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () => { + // c1 hat ein laufendes Wartungs-Abo: ohne es meldet T16 den Block kein_abo (Task B10b, test/featurePaid.test.js) + aw.seed('subscriptions', { $id: 'sub_w_mails', stripeSubscriptionId: 'sub_w_mails', customerId: 'c1', productKey: 'wartung', status: 'active' }) const { mail, projectId, woid, doc } = await t16Bezahlt('termine', 'Salon Haarlinie') assert.equal(mail.subject, 'Neue Funktion bestellt: Terminplaner') for (const teil of [mail.subject, mail.text, mail.html]) assert.doesNotMatch(teil, /News|Beitr/) @@ -363,7 +365,7 @@ test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () => assert.equal(link, reg.anleitungsLink('c1', projectId)) assert.ok(mail.text.includes(`Im Ticketsystem öffnen: ${link}`), mail.text) assert.ok(mail.html.includes(`href="${link.replaceAll('&', '&')}"`), mail.html) - // die Warnblöcke bleiben aus, solange B10a/D4 sie nicht füllen + // die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit füllt D4) for (const warnung of ['Zahlung fehlgeschlagen', 'Bezahlt, aber nicht zuzuordnen', 'Kein laufendes Wartungs-Abo', 'Gesundheitsbereich prüfen', 'Bezahlt, aber schon vorhanden']) { assert.ok(!mail.text.includes(warnung), `kein Block "${warnung}"`) } diff --git a/test/featurePaid.test.js b/test/featurePaid.test.js new file mode 100644 index 0000000..1e79d32 --- /dev/null +++ b/test/featurePaid.test.js @@ -0,0 +1,679 @@ +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 Fragebogen-Pruefung gehoert zu Block C: hier ist jedes Projekt 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) +}) + +// ------------------------------------------------------------------------------------ (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'), '') +})