Files
Webklar-Kundenbereich/test/featurePaid.test.js
T
ClaudeandClaude Sonnet 5.5 b974b3a3ec TP2 Abschlussprüfung: Zahlung/Team (Wiederzustellung, K16-Warnung, Sperrschlüssel)
- adoptPaidSession übernimmt ein erneut zugestelltes checkout.session.completed nicht mehr, wenn das Dokument nach 30 Tagen zurückgesetzt wurde
  (off, purchaseStatus zurueckgesetzt, gleiche Sitzung): kein Setup, kein Ticket, keine K15/T16
- meldeFreigeschaltet liefert true/false; die Staff-Aktivierung in Textform vermerkt eine nicht gesendete K16 mit Unterlagen in adminNote und
  antwortet mit warnung: true
- disable() nutzt die Sperre feature-activate:<id> statt feature-disable:<id> (Freischaltung und Abschalten überholen sich nicht)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:18:34 +00:00

711 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'), '')
})