Files
Webklar-Kundenbereich/test/featurePaid.test.js
T
ClaudeandClaude Sonnet 5.5 f4754f573a Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)
onFeaturePaid wertet den Fall aus markPaid aus. Trifft die Zahlung eine Funktion, die
schon freigeschaltet, in Einrichtung oder abgeschaltet ist, geht nur eine Team-Meldung
raus (T16 Block bereits_aktiv, bei Abo-Ende vor der Einrichtung T19), kein Ticket und
keine K15. Beide Meldungen laufen genau einmal je Dokument: unter feature-meldung:<id>
frisch lesen, Marker zahlungMeldung gemeldet erst nach gesendeter Mail. Scheitert der
Versand, wirft onFeaturePaid (Webhook 500), Stripe wiederholt und der naechste Versuch
sendet genau einmal.

meldeErstattung ist die einzige Stelle fuer T19 (Zahlung nach dem Abo-Ende und spaeter
die Meldung des Abo-Abgleichs), wirft nie. Bei Funktionen mit Wartungs-Abo-Pflicht
zeigt T16 nach der Verbuchung den Block kein_abo, wenn das Abo nicht mehr laeuft; ein
Lesefehler laesst den Block aus und blockiert nichts. customerOf und projectName sind
exportiert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:25:26 +00:00

680 lines
33 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 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'), '')
})