diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 71757e4..7bf3c18 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -22,7 +22,7 @@ import { zeitenFuerKunde } from '../services/zeiten/schalter.js' import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../services/zeiten/einschalten.js' import { aboMemo } from '../services/aboStatus.js' import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js' -import { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js' +import { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js' import { ELIGIBILITY_MESSAGES, @@ -238,7 +238,7 @@ async function pruefeUnterlagen(haken) { // Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news. // Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung, // kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter, -// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo. +// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo, 7b inklusive (Abo laeuft: einschalten statt bezahlen). router.post('/:key/checkout', requireSession, async (req, res) => { // Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis, // Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z) @@ -282,6 +282,8 @@ router.post('/:key/checkout', requireSession, async (req, res) => { } // 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv await pruefeAbo(feature, customerId) + // 7b Funktionen mit aboInklusive (Oeffnungszeiten live): im laufenden Wartungs-Abo gibt es nichts zu bezahlen (409 inklusive) + await pruefeInklusive(feature, customerId) // Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen // erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument diff --git a/server/services/featureOrder.js b/server/services/featureOrder.js index fb0c2f6..4d30d69 100644 --- a/server/services/featureOrder.js +++ b/server/services/featureOrder.js @@ -144,6 +144,31 @@ export async function pruefeAbo(feature, customerId) { throw bestellFehler(409, aboMeldung(feature, grund), grund, { grund }) } +const INKLUSIVE_TEXT = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.' + +/** + * Pruefstufe 7b: Funktionen mit aboInklusive (Oeffnungszeiten live) kosten im laufenden Wartungs-Abo nichts. Laeuft das Abo und ist + * es nicht gekuendigt (bestellbar ok) oder nur gerade nicht aktiv (nicht_aktiv: Probezeit, offene Zahlung), gibt es keinen Grund zu + * bezahlen: 409 inklusive mit dem Hinweis aufs Einschalten; body.grund ist null (Abo laeuft) oder 'nicht_aktiv' (wie zeiten.grund in + * GET /api/funktionen). kein_abo und gekuendigt lassen den 30-Euro-Weg offen. Wie pruefeAbo: gleicher Memo je Anfrage, ein Lesefehler + * wirft (500, die Route zeigt nur ihren allgemeinen Text), im Zweifel nicht bestellbar; ein Ergebnis ohne gueltigen Grund ist ein Fehler. + * Funktionen ohne aboInklusive (News, Terminplaner) lassen es unberuehrt und lesen das Abo hier nicht. + */ +export async function pruefeInklusive(feature, customerId) { + if (feature.aboInklusive !== true) return + let ergebnis + try { + ergebnis = await orderDeps.aboMemo().bestellbar(customerId) + } catch (err) { + throw Object.assign(new Error(`Abo-Prüfung fehlgeschlagen: ${err?.message}`), { status: 500, cause: err }) + } + if (ergebnis?.ok === true) throw bestellFehler(409, INKLUSIVE_TEXT, 'inklusive', { grund: null }) + const grund = ergebnis?.grund + if (grund === 'nicht_aktiv') throw bestellFehler(409, INKLUSIVE_TEXT, 'inklusive', { grund }) + if (grund === 'kein_abo' || grund === 'gekuendigt') return + throw new Error('Abo-Prüfung ohne gültiges Ergebnis') +} + const GRATIS_ABO_TEXT = { kein_abo_gekuendigt: (name, preis) => `Ohne laufendes Wartungs-Abo kannst Du ${name} für einmalig ${preis} bestellen.`, nicht_aktiv: () => 'Einschaltbar, sobald Dein Abo aktiv ist.', diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 9a08688..9686520 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -330,6 +330,26 @@ function notizVorn(previous, entry) { return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu } +/** + * Funktionen mit aboInklusive (Oeffnungszeiten live) halten am Dokument fest, wodurch sie freigeschaltet wurden (unlockedBy) und was der + * Neubau fuer den Einbau ergab (einbau). Fuer die uebrigen Funktionen (News, Terminplaner) schreibt der Kaufweg beides nicht: das Schema + * dieser Felder gibt es nur, wo zeiten-setup.mjs gelaufen ist. + */ +const mitAbo = (key) => featureConfig(key)?.aboInklusive === true + +/** + * Neubau fuer den Einbau des Zeiten-Skripts (Spec 4.6) beim Kauf, VOR dem Einbau-Ticket: das Ticket nennt das Ergebnis (einbau). + * Steht einbau schon am Dokument (Wiederholung des Webhooks nach einem gescheiterten Ticket), wird nicht noch einmal gebaut. + * stosseNeubauAn nimmt die eigene Sperre 'zeiten-neubau' (andere Schluessel als feature-paid, also kein Wiederbetreten) und wirft nie; + * zeiten/* nur per dynamischem Import (siehe Kopf). Liefert das Dokument mit dem gespeicherten einbau. + */ +async function baueZeitenEin(doc, project) { + if (doc.einbau) return doc + const { stosseNeubauAn } = await import('./zeiten/neubau.js') + const einbau = await stosseNeubauAn(project) + return updateDocument(config.collections.portalFeatures, doc.$id, { einbau }) +} + /** * 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 @@ -343,6 +363,9 @@ async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) { const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { purchaseStatus: 'paid', unlockedByPurchase: true, + // Eine Zahlung, die nichts einrichtet, aendert nicht, wodurch das Dokument freigeschaltet wurde: ein abo oder team bleibt (der + // Abo-Abgleich und die Loeschfrist haengen an unlockedBy abo); nur ohne Angabe (Altbestand) wird es zu kauf, passend zu unlockedByPurchase + ...(mitAbo(doc.featureKey) && !doc.unlockedBy ? { unlockedBy: 'kauf' } : {}), stripeInvoiceId: invoiceId || '', invoiceUrl: invoiceUrl || '', adminNote: notizVorn(doc.adminNote, `${new Date().toISOString()} System: Zahlung eingegangen (Sitzung ${sessionId}), nichts eingerichtet`), @@ -356,6 +379,9 @@ async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) { * 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). + * Funktionen mit aboInklusive (Oeffnungszeiten live): 'setup' traegt unlockedBy 'kauf' (und einbau leer), danach stoesst baueZeitenEin den + * Neubau an und speichert einbau, erst dann entsteht das Ticket mit dem Ergebnis. Fortsetzbar ist dort nur setup mit unlockedBy kauf; ein + * per Abo oder Team eingeschaltetes Dokument ohne Ticket heilt holeZeitenNach, die Zahlung ist fuer es ein Fall (bereits_aktiv). * * 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, @@ -368,20 +394,27 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { const doc = await findBySession(sessionId) 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 + const mitAboKauf = mitAbo(doc.featureKey) + // Funktionen mit aboInklusive: nur ein Dokument, das der Kauf auf setup gestellt hat (unlockedBy kauf), ist fortsetzbar. Eines, das per Abo + // oder vom Team auf setup steht, bekommt sein Ticket vom Nachholen (holeZeitenNach); die Zahlung ist dort ein Fall fuers Team + const resumable = doc.state === 'setup' && !doc.ticketId && (!mitAboKauf || unlockedByVon(doc) === 'kauf') if (doc.state !== 'ordered' && !resumable) return bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) - const setup = resumable + let setup = resumable ? doc : await updateDocument(config.collections.portalFeatures, doc.$id, { ...stateFields('setup'), purchaseStatus: 'paid', unlockedByPurchase: true, + // einbau beginnt leer: Reste eines frueheren Laufs gelten nicht, der Neubau (baueZeitenEin) setzt es neu + ...(mitAboKauf ? { unlockedBy: 'kauf', einbau: '' } : {}), stripeInvoiceId: invoiceId || '', invoiceUrl: invoiceUrl || '', zahlungMeldung: '', }) const project = await getDocument(config.collections.websiteProjects, doc.projectId) + // Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6) + if (mitAboKauf) setup = await baueZeitenEin(setup, project) const customer = await customerOf(doc.customerId).catch(() => null) const feature = featureConfig(doc.featureKey) const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {} diff --git a/test/zeitenCheckout.test.js b/test/zeitenCheckout.test.js new file mode 100644 index 0000000..c995623 --- /dev/null +++ b/test/zeitenCheckout.test.js @@ -0,0 +1,454 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { schemaKeys } from './support/schema.js' +import { useTestEnv } from './support/testEnv.js' +import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js' + +// Bezahlweg von Oeffnungszeiten live (Aufgabe 17, Spec 3.3 und 4.6): Checkout-Sperre "inklusive" (pruefeInklusive in featureOrder.js), +// Zahlung (markPaid in siteFeatures.js): setup, unlockedBy kauf, Neubau VOR dem Ticket, einbau am Dokument, Ticket-Text nennt das Ergebnis. +// News und Terminplaner bleiben unveraendert (kein inklusive-Check, kein Neubau, kein unlockedBy). +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + featureConsents: schemaKeys('featureConsents'), + termineConfig: schemaKeys('termineConfig'), + oeffnungszeiten: schemaKeys('oeffnungszeiten'), + zeitenErinnerungen: schemaKeys('zeitenErinnerungen'), + }, + // Datumsfelder wie in Appwrite: Unlesbares oder '' waere dort ein 400 (leeren geht nur mit null) + datetimes: { + ...ZEITEN_DATETIMES, + portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'k24At'], + featureConsents: ['acceptedAt'], + }, + defaultLimit: 25, +}) +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, hostingDoc, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const { default: aboRoutes } = await import('../server/routes/abo.js') +const { orderDeps, pruefeInklusive } = await import('../server/services/featureOrder.js') +const { aboMemo } = await import('../server/services/aboStatus.js') +const sf = await import('../server/services/siteFeatures.js') +const { zeitenDeps } = await import('../server/services/zeiten/neubau.js') +const { resetZeitenCacheForTests } = await import('../server/services/zeiten/cache.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) +after(() => setMailTransportForTests(null)) + +const Z = config.features.zeiten +const T = config.features.termine +const MODUS = { zeiten: { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] }, termine: { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] } } +const c1 = customerCookie('c1') +const c3 = customerCookie('c3') +const INKLUSIVE_TEXT = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.' +const SUBJECT = { + K15: 'Deine Bestellung: Öffnungszeiten live', + T16: 'Neue Funktion bestellt: Öffnungszeiten live', +} +const BEREITS_DA = /Bezahlt, aber schon vorhanden/ + +const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) +const projekt = (id, over = {}) => + aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: `Projekt ${id}`, subdomain: id, repoFullName: `JUSN/${id}`, previewMode: 'auto', previewDetected: 'static', ...over }) + +const bezahlteHaken = () => Z.consent.items.filter((item) => item.haken).map((item) => item.id) +const koerper = (projectId = 'p1', extra = {}) => ({ projectId, zustimmung: { version: Z.consent.version, haken: bezahlteHaken() }, ...extra }) +const checkout = (body, cookie = c1, key = 'zeiten') => post(`/api/funktionen/${key}/checkout`, body, { cookie }) + +const sessionAufrufe = () => st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') +const preisDerSitzung = (aufruf) => st.prices.get(aufruf.body['line_items[0][price]']) +const zeitenDoc = (projectId = 'p1') => aw.docs('portalFeatures').get(`f_${projectId}_zeiten`) +const tickets = () => [...aw.docs('workorders').values()] +const mailsMit = (subject) => sink.sent.filter((m) => m.subject === subject) +const keineSpuren = () => + assert.deepEqual( + { features: aw.docs('portalFeatures').size, consents: aw.docs('featureConsents').size, tickets: aw.docs('workorders').size, sitzungen: sessionAufrufe().length, mails: sink.sent.length }, + { features: 0, consents: 0, tickets: 0, sitzungen: 0, mails: 0 } + ) +const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } }) +const webhook = (type, session) => postWebhook(event(type, session)) +/** Bezahlte Sitzung im Ereignis; die Rechnung liegt im Stripe-Nachbau. */ +function bezahlt(sessionId, { projectId = 'p1', featureKey = 'zeiten', invoice = `in_${sessionId}` } = {}) { + 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 } } +} + +/** Echter Bestellweg: Checkout-Route legt die Stripe-Sitzung an und das Dokument auf 'ordered' (kein Wartungs-Abo). */ +async function bestelle(projectId = 'p1') { + const res = await checkout(koerper(projectId)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const sessionId = [...st.checkoutSessions.values()].at(-1).id + assert.equal(zeitenDoc(projectId).state, 'ordered') + return sessionId +} + +let sink +let rebuilds +const originalRebuild = zeitenDeps.rebuild +const originalAboMemo = orderDeps.aboMemo +beforeEach(async () => { + for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'oeffnungszeiten', 'workorders', 'websiteProjects']) { + for (const id of aw.docs(col).keys()) await deleteDocument(col, id) + } + resetZeitenCacheForTests() + st.checkoutSessions.clear() + st.invoices.clear() + st.calls.length = 0 + seedFixtures(aw) + projekt('p1') + projekt('p2') + projekt('p3', { customerId: 'c3' }) + aw.seed('customers', { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true }) + config.zeiten.mode = 'pilot' + config.zeiten.pilotEmails = ['*'] + config.termine.mode = 'pilot' + config.termine.pilotEmails = ['*'] + T.bestellpruefung = async () => [] + sink = mailSink() + setMailTransportForTests(sink) + rebuilds = [] + zeitenDeps.rebuild = async (p) => { + rebuilds.push({ projektId: p.$id, tickets: aw.docs('workorders').size, state: zeitenDoc(p.$id)?.state, unlockedBy: zeitenDoc(p.$id)?.unlockedBy, einbau: zeitenDoc(p.$id)?.einbau }) + return { queued: true } + } + mock.method(console, 'error', () => {}) + mock.method(console, 'warn', () => {}) + mock.method(console, 'log', () => {}) +}) +afterEach(() => { + mock.restoreAll() + zeitenDeps.rebuild = originalRebuild + orderDeps.aboMemo = originalAboMemo +}) +after(() => { + config.zeiten.mode = MODUS.zeiten.mode + config.zeiten.pilotEmails = MODUS.zeiten.pilotEmails + config.termine.mode = MODUS.termine.mode + config.termine.pilotEmails = MODUS.termine.pilotEmails + delete T.bestellpruefung +}) + +// ------------------------------------------------------------------------------------- Checkout-Sperre inklusive + +test('Checkout zeiten mit laufendem Abo ⇒ 409 inklusive, keine Stripe-Sitzung, keine Spur', async () => { + wartung() + const res = await checkout(koerper()) + assert.equal(res.status, 409, JSON.stringify(res.body)) + assert.equal(res.body.error, INKLUSIVE_TEXT) + assert.equal(res.body.code, 'inklusive') + assert.equal(res.body.grund, null) + keineSpuren() +}) + +test('Checkout zeiten bei Abo nicht aktiv ⇒ 409 inklusive mit grund nicht_aktiv', async () => { + for (const status of ['trialing', 'past_due']) { + wartung({ status }) + const res = await checkout(koerper()) + assert.equal(res.status, 409, status) + assert.equal(res.body.error, INKLUSIVE_TEXT, status) + assert.equal(res.body.code, 'inklusive', status) + assert.equal(res.body.grund, 'nicht_aktiv', status) + keineSpuren() + } +}) + +test('Checkout zeiten ohne Abo, mit Hosting-Abo oder gekündigt ⇒ Stripe-Sitzung über 3000 Cent', async () => { + const faelle = [ + ['Hosting-Abo', () => aw.seed('subscriptions', hostingDoc), c1, 'p1'], + ['gekündigt', () => wartung({ cancelAtPeriodEnd: true }), c1, 'p2'], + ['gar kein Abo', () => {}, c3, 'p3'], + ] + for (const [name, vorbereiten, cookie, projectId] of faelle) { + for (const id of aw.docs('subscriptions').keys()) await deleteDocument('subscriptions', id) + vorbereiten() + const vorher = sessionAufrufe().length + const res = await checkout(koerper(projectId), cookie) + assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`) + assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_test_/, name) + assert.equal(sessionAufrufe().length, vorher + 1, name) + assert.equal(preisDerSitzung(sessionAufrufe().at(-1)).unit_amount, 3000, name) + assert.equal(sessionAufrufe().at(-1).body['metadata[featureKey]'], 'zeiten', name) + assert.equal(zeitenDoc(projectId).state, 'ordered', name) + } +}) + +test('Checkout zeiten: Abo nicht lesbar ⇒ 500 ohne Rohtext, keine Stripe-Sitzung, keine Spur (fail-closed)', async () => { + wartung() + aw.failNext({ collection: 'subscriptions', status: 500, message: 'Interner Appwrite-Text mit Kunde c1' }) + const res = await checkout(koerper()) + assert.equal(res.status, 500) + assert.equal(JSON.stringify(res.body).includes('Appwrite'), false) + keineSpuren() +}) + +test('Reihenfolge: die Sperre inklusive kommt nach Zustimmung, Projekt, Schalter und Unterlagen', async () => { + wartung() + const lesungen = [] + orderDeps.aboMemo = () => { + const memo = aboMemo() + return { ...memo, bestellbar: async (id) => (lesungen.push(id), memo.bestellbar(id)) } + } + // 3 Zustimmung + const ohne = await checkout({ projectId: 'p1' }) + assert.equal(ohne.status, 400) + assert.equal(ohne.body.code, 'zustimmung_fehlt') + // 4 Projekt (fremd) + assert.equal((await checkout(koerper('p3'))).status, 403) + // 5 Schalter + config.zeiten.mode = 'off' + const aus = await checkout(koerper()) + assert.equal(aus.status, 409) + assert.equal(aus.body.code, 'nicht_verfuegbar') + config.zeiten.mode = 'pilot' + // 5b Unterlagen + Z.unterlagenBereit = async () => false + const unterlagen = await checkout(koerper()) + assert.equal(unterlagen.status, 503) + assert.equal(unterlagen.body.code, 'unterlagen_fehlen') + delete Z.unterlagenBereit + assert.deepEqual(lesungen, [], 'bis hierhin wurde das Abo nicht gelesen') + // 7b Abo: jetzt kommt inklusive, mit genau einer Abo-Lesung + const inklusive = await checkout(koerper()) + assert.equal(inklusive.status, 409) + assert.equal(inklusive.body.code, 'inklusive') + assert.deepEqual(lesungen, ['c1']) + keineSpuren() +}) + +test('News und Termine unverändert: kein inklusive-Check (Abo wird für News nicht, für Termine einmal gelesen)', async () => { + wartung() + const lesungen = [] + orderDeps.aboMemo = () => { + const memo = aboMemo() + return { ...memo, bestellbar: async (id) => (lesungen.push(id), memo.bestellbar(id)) } + } + const news = await checkout({ projectId: 'p1', consent: true }, c1, 'news') + assert.equal(news.status, 200, JSON.stringify(news.body)) + assert.deepEqual(lesungen, [], 'News liest das Abo nicht') + + const haken = T.consent.items.filter((item) => item.haken).map((item) => item.id) + const termine = await checkout({ projectId: 'p2', zustimmung: { version: T.consent.version, haken } }, c1, 'termine') + assert.equal(termine.status, 200, JSON.stringify(termine.body)) + assert.deepEqual(lesungen, ['c1'], 'Termine liest das Abo einmal (Wartungs-Abo-Pflicht), nicht ein zweites Mal für inklusive') + assert.equal(aw.docs('portalFeatures').get('f_p1_news')?.state, 'ordered') + assert.equal(aw.docs('portalFeatures').get('f_p2_termine')?.state, 'ordered') +}) + +test('pruefeInklusive: nur bei aboInklusive; ok und nicht_aktiv sperren, kein_abo und gekuendigt nicht; Lesefehler ⇒ 500', async () => { + wartung() + await pruefeInklusive({ ...Z, aboInklusive: false }, 'c1') // Funktion ohne aboInklusive: nichts + await pruefeInklusive(config.features.news, 'c1') + await assert.rejects(pruefeInklusive(Z, 'c1'), (err) => err.status === 409 && err.code === 'inklusive' && err.message === INKLUSIVE_TEXT && err.body.grund === null) + wartung({ status: 'past_due' }) + await assert.rejects(pruefeInklusive(Z, 'c1'), (err) => err.status === 409 && err.code === 'inklusive' && err.body.grund === 'nicht_aktiv') + wartung({ cancelAtPeriodEnd: true }) + await pruefeInklusive(Z, 'c1') + await pruefeInklusive(Z, 'c3') + aw.failNext({ collection: 'subscriptions', status: 500, message: 'Geheim' }) + await assert.rejects(pruefeInklusive(Z, 'c1'), (err) => err.status === 500 && err.code === undefined) // wie pruefeAbo: die Route zeigt nur ihren allgemeinen Text +}) + +// -------------------------------------------------------------------------------------------------------- Zahlung + +test('Zahlung ⇒ setup, unlockedBy kauf, Neubau vor dem Ticket, einbau am Dokument, Ticket nennt das Ergebnis, K15 und T16', async () => { + const sessionId = await bestelle() + const res = await webhook('checkout.session.completed', bezahlt(sessionId)) + assert.equal(res.status, 200, JSON.stringify(res.body)) + + const doc = zeitenDoc() + assert.deepEqual( + [doc.state, doc.enabled, doc.purchaseStatus, doc.unlockedBy, doc.unlockedByPurchase, doc.einbau, doc.zahlungMeldung], + ['setup', false, 'paid', 'kauf', true, 'angestossen', ''] + ) + assert.equal(sf.unlockedByVon(doc), 'kauf') + assert.equal(doc.invoiceUrl, `https://invoice.stripe.test/in_${sessionId}`) + + // Reihenfolge: der Neubau lief, als der Zustand schon setup/kauf war, aber noch ohne Ticket und ohne einbau + assert.deepEqual(rebuilds, [{ projektId: 'p1', tickets: 0, state: 'setup', unlockedBy: 'kauf', einbau: '' }]) + + // Ticket: einmal, Text nennt das Ergebnis des Neubaus (automatisch eingesetzt) und den siteKey + assert.equal(tickets().length, 1) + const [ticket] = tickets() + assert.equal(doc.ticketId, ticket.$id) + assert.equal(ticket.topic, 'Öffnungszeiten live einbauen – Projekt p1') + assert.match(ticket.details, /Einbau: automatisch eingesetzt \(nach dem nächsten Veröffentlichen prüfen\)/) + assert.doesNotMatch(ticket.details, /bitte von Hand einsetzen/) + assert.ok(ticket.details.includes(`data-site="${doc.siteKey}"`)) + + // K15 an den Kunden, T16 ans Team, je einmal; keine K24 (die gehört zum Gratisweg) + assert.equal(mailsMit(SUBJECT.K15).length, 1) + assert.deepEqual(mailsMit(SUBJECT.K15)[0].to.map((a) => a.address), ['k@example.invalid']) + assert.equal(mailsMit(SUBJECT.T16).length, 1) + assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA) + assert.equal(mailsMit('Öffnungszeiten live ist eingeschaltet').length, 0) + assert.equal(doc.k24At ?? null, null) +}) + +test('Zahlung, Neubau nicht möglich (kein Repo): einbau manuell, Ticket sagt „bitte von Hand einsetzen“', async () => { + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' }) + const sessionId = await bestelle() + assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) + assert.deepEqual(rebuilds, []) + assert.equal(zeitenDoc().einbau, 'manuell') + assert.equal(zeitenDoc().unlockedBy, 'kauf') + assert.equal(tickets().length, 1) + assert.match(tickets()[0].details, /Einbau: bitte von Hand einsetzen/) + assert.doesNotMatch(tickets()[0].details, /automatisch eingesetzt/) +}) + +test('Zahlung, Neubau wirft: einbau manuell, Zustand und Ticket bleiben (bezahlt ist bezahlt)', async () => { + zeitenDeps.rebuild = async () => { + throw new Error('Runner nicht erreichbar') + } + const sessionId = await bestelle() + assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) + assert.deepEqual([zeitenDoc().state, zeitenDoc().einbau, zeitenDoc().unlockedBy], ['setup', 'manuell', 'kauf']) + assert.equal(tickets().length, 1) + assert.match(tickets()[0].details, /Einbau: bitte von Hand einsetzen/) + assert.equal(mailsMit(SUBJECT.K15).length, 1) +}) + +test('Stripe wiederholt den Webhook: kein zweiter Neubau, kein zweites Ticket, keine zweite Mail', async () => { + const sessionId = await bestelle() + assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) + 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(rebuilds.length, 1) + assert.equal(tickets().length, 1) + assert.equal(mailsMit(SUBJECT.K15).length, 1) + assert.equal(mailsMit(SUBJECT.T16).length, 1) +}) + +test('Ticket scheitert ⇒ Webhook 500, Zustand setup/kauf/einbau gespeichert; die Wiederholung legt das Ticket an, ohne zweiten Neubau', async () => { + const sessionId = await bestelle() + aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticketsystem down' }) + const erster = await webhook('checkout.session.completed', bezahlt(sessionId)) + assert.equal(erster.status, 500) + assert.deepEqual([zeitenDoc().state, zeitenDoc().unlockedBy, zeitenDoc().einbau], ['setup', 'kauf', 'angestossen']) + assert.equal(zeitenDoc().ticketId ?? '', '') + assert.equal(tickets().length, 0) + assert.equal(mailsMit(SUBJECT.K15).length, 0, 'K15 erst nach dem Ticket') + + const zweiter = await webhook('checkout.session.completed', bezahlt(sessionId)) + assert.equal(zweiter.status, 200) + assert.equal(rebuilds.length, 1, 'einbau war schon gespeichert: kein zweiter Neubau') + assert.equal(tickets().length, 1) + assert.match(tickets()[0].details, /Einbau: automatisch eingesetzt/) + assert.equal(zeitenDoc().ticketId, tickets()[0].$id) + assert.equal(mailsMit(SUBJECT.K15).length, 1) + assert.equal(mailsMit(SUBJECT.T16).length, 1) +}) + +test('Neubau-Absturz vor dem Speichern von einbau: die Wiederholung baut neu und legt dann das Ticket an', async () => { + const sessionId = await bestelle() + // der Zustand ist gespeichert (setup, kauf, einbau leer), aber weder Neubau noch Ticket sind gelaufen + aw.seed('portalFeatures', { ...zeitenDoc(), state: 'setup', enabled: false, purchaseStatus: 'paid', unlockedBy: 'kauf', unlockedByPurchase: true, einbau: '' }) + assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200) + assert.equal(rebuilds.length, 1) + assert.equal(tickets().length, 1) + assert.equal(zeitenDoc().einbau, 'angestossen') +}) + +test('Neubauten sind serialisiert: zwei gleichzeitige Zahlungen starten nie zwei Neubauten zugleich', async () => { + let gleichzeitig = 0 + let hoechstens = 0 + zeitenDeps.rebuild = async (p) => { + gleichzeitig += 1 + hoechstens = Math.max(hoechstens, gleichzeitig) + await new Promise((resolve) => setTimeout(resolve, 25)) + gleichzeitig -= 1 + rebuilds.push({ projektId: p.$id }) + return { queued: true } + } + const s1 = await bestelle('p1') + const s2 = await bestelle('p2') + const [a, b] = await Promise.all([webhook('checkout.session.completed', bezahlt(s1, { projectId: 'p1' })), webhook('checkout.session.completed', bezahlt(s2, { projectId: 'p2' }))]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(rebuilds.length, 2) + assert.equal(hoechstens, 1) + assert.deepEqual([zeitenDoc('p1').einbau, zeitenDoc('p2').einbau], ['angestossen', 'angestossen']) +}) + +// -------------------------------------------------------------------------------------- späte Zahlung, Altbestand + +const seedeZeiten = (over) => + aw.seed('portalFeatures', { + $id: 'f_p1_zeiten', + customerId: 'c1', + projectId: 'p1', + featureKey: 'zeiten', + state: 'active', + enabled: true, + unlockedByPurchase: false, + purchaseStatus: '', + siteKey: 'abcdefghijklmnop', + stripeSessionId: 'cs_alt', + ...over, + }) + +test('späte Zahlung auf ein per Abo eingeschaltetes Dokument: gebucht und gemeldet, unlockedBy bleibt abo, nichts eingerichtet', async () => { + seedeZeiten({ state: 'active', unlockedBy: 'abo', ticketId: 't_alt' }) + assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt'))).status, 200) + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.purchaseStatus, doc.unlockedBy, doc.zahlungMeldung], ['active', 'paid', 'abo', 'gemeldet']) + assert.equal(rebuilds.length, 0) + assert.equal(tickets().length, 0) + assert.equal(mailsMit(SUBJECT.K15).length, 0) + assert.equal(mailsMit(SUBJECT.T16).length, 1) + assert.match(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA) +}) + +test('Zahlung auf ein Abo-Dokument in setup ohne Ticket: kein zweites Ticket neben dem Nachholen (holeZeitenNach), unlockedBy bleibt abo', async () => { + seedeZeiten({ state: 'setup', enabled: false, unlockedBy: 'abo' }) + assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt'))).status, 200) + const doc = zeitenDoc() + assert.deepEqual([doc.state, doc.unlockedBy, doc.purchaseStatus], ['setup', 'abo', 'paid']) + assert.equal(doc.ticketId ?? '', '') + assert.equal(tickets().length, 0) + assert.equal(rebuilds.length, 0) + assert.equal(mailsMit(SUBJECT.K15).length, 0) + assert.match(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA) +}) + +test('späte Zahlung ohne unlockedBy am Dokument (Altbestand): unlockedBy wird kauf, passend zu unlockedByPurchase true', async () => { + seedeZeiten({ state: 'active', ticketId: 't_alt' }) + assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt'))).status, 200) + const doc = zeitenDoc() + assert.deepEqual([doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus], ['kauf', true, 'paid']) + assert.equal(rebuilds.length, 0) +}) + +// ------------------------------------------------------------------------------------ News und Termine unverändert + +test('News und Termine bezahlt: wie bisher, kein unlockedBy, kein einbau, kein Neubau', async () => { + // News (alter Weg ohne Abo) und Terminplaner (mit Wartungs-Abo, TERMINE_ENABLED=pilot) + wartung() + const haken = T.consent.items.filter((item) => item.haken).map((item) => item.id) + assert.equal((await checkout({ projectId: 'p1', consent: true }, c1, 'news')).status, 200) + assert.equal((await checkout({ projectId: 'p2', zustimmung: { version: T.consent.version, haken } }, c1, 'termine')).status, 200) + const sNews = aw.docs('portalFeatures').get('f_p1_news').stripeSessionId + const sTermine = aw.docs('portalFeatures').get('f_p2_termine').stripeSessionId + assert.equal((await webhook('checkout.session.completed', bezahlt(sNews, { projectId: 'p1', featureKey: 'news' }))).status, 200) + assert.equal((await webhook('checkout.session.completed', bezahlt(sTermine, { projectId: 'p2', featureKey: 'termine' }))).status, 200) + + for (const id of ['f_p1_news', 'f_p2_termine']) { + const doc = aw.docs('portalFeatures').get(id) + assert.deepEqual([doc.state, doc.purchaseStatus, doc.unlockedByPurchase], ['setup', 'paid', true], id) + assert.ok(doc.ticketId, id) + assert.equal(doc.unlockedBy ?? null, null, `${id}: unlockedBy nicht geschrieben`) + assert.equal(doc.einbau ?? null, null, `${id}: einbau nicht geschrieben`) + } + assert.equal(rebuilds.length, 0) + assert.equal(tickets().length, 2) +})