Files

810 lines
44 KiB
JavaScript
Raw Permalink 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, 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'
// "Fuer 30 EUR behalten" (Aufgabe 20, Spec 3.3): nach dem Ende des Wartungs-Abos behaelt der Kunde Oeffnungszeiten live fuer einmalig
// 30 EUR, ohne neues Einbau-Ticket. POST /api/funktionen/zeiten/behalten (starteBehalten), Webhook-Zweig zweck 'behalten'
// (bucheBehalten in zeiten/behalten.js, vor markPaid/adoptPaidSession) und createFeatureCheckout mit zweck.
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', 'einschaltMailAt'],
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, kundenansichtCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { withLocks } = await import('../server/services/keyedLock.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 sf = await import('../server/services/siteFeatures.js')
const { zustimmungFuerSitzung } = await import('../server/services/featureConsents.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { speichereZeiten, speichereTeam, loescheZeiten } = await import('../server/services/zeiten/speicher.js')
const { bucheBehalten } = await import('../server/services/zeiten/behalten.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 MODUS = { mode: config.zeiten.mode, pilotEmails: [...config.zeiten.pilotEmails] }
const c1 = customerCookie('c1')
const SITE_KEY = 'abcdefghijklmnop'
const ABO_ENDE = '2026-10-01T06:00:00.000Z'
const ZUVOR = '2026-09-20T10:00:00.000Z'
const NICHT_MOEGLICH = 'Behalten ist gerade nicht möglich. Das geht nur nach dem Ende deines Wartungs-Abos, solange deine Öffnungszeiten noch nicht gelöscht sind.'
const SUBJECT = {
K15: 'Deine Bestellung: Öffnungszeiten live',
T16: 'Neue Funktion bestellt: Öffnungszeiten live',
T19: '[Abo] Erstattung fällig: Öffnungszeiten live-Einrichtung',
}
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 behalten = (body, cookie = c1) => post('/api/funktionen/zeiten/behalten', body, { cookie })
const checkout = (body, cookie = c1) => post('/api/funktionen/zeiten/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 nachweise = () => [...aw.docs('featureConsents').values()]
/** Appwrite liefert Datumsfelder als "...+00:00": verglichen wird der Zeitpunkt. */
const zeit = (wert) => (wert ? Date.parse(wert) : null)
const mailsMit = (subject) => sink.sent.filter((m) => m.subject === subject)
const spuren = () => ({ sitzungen: sessionAufrufe().length, nachweise: nachweise().length, mails: sink.sent.length, tickets: tickets().length, neubauten: rebuilds.length })
const keineSpuren = () => assert.deepEqual(spuren(), { sitzungen: 0, nachweise: 0, mails: 0, tickets: 0, neubauten: 0 })
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
const webhook = (type, session) => postWebhook(event(type, session))
/** Ist die Sperre gerade frei? Eine gehaltene Sperre laesst die Probe warten: nach 60 ms gilt sie als gehalten. */
const sperreFrei = (key) => Promise.race([withLocks([key], async () => true), new Promise((resolve) => setTimeout(() => resolve(false), 60))])
/** Dokument der Zeiten wie nach dem Einschalten im Wartungs-Abo, dessen Abo inzwischen endete (aboEndeAt gesetzt). */
const seedeZeiten = (over = {}) =>
aw.seed('portalFeatures', {
$id: 'f_p1_zeiten',
customerId: 'c1',
projectId: 'p1',
featureKey: 'zeiten',
state: 'active',
enabled: true,
unlockedBy: 'abo',
unlockedByPurchase: false,
purchaseStatus: '',
siteKey: SITE_KEY,
ticketId: 't_alt',
activatedAt: ZUVOR,
einbau: 'angestossen',
aboEndeAt: ABO_ENDE,
...over,
})
/** Abgeschaltet nach dem Abo-Ende (Frist verstrichen), Eingaben noch da. */
const seedeAbgeschaltet = (over = {}) =>
seedeZeiten({ state: 'disabled', enabled: false, disabledAt: '2026-10-02T06:00:00.000Z', disabledReason: 'abo_ende', aboEndeMailAt: '2026-10-02T06:05:00.000Z', ...over })
function tage(woche = {}) {
const t = {}
for (let d = 1; d <= 7; d += 1) {
const w = woche[d]
t[d] = w ? { modus: 'zeiten', fenster: [w] } : { modus: 'zu', fenster: [] }
}
return t
}
const EIN_PLAN = () => ({
land: 'RP',
plaene: [{ id: 'p_aaaaaaa1', name: 'Standard', haupt: true, tage: tage({ 1: ['09:00', '18:00'] }) }],
feiertage: {},
ausnahmen: [],
hinweise: [],
kontakt: { an: false, telefon: '', adresse: '', whatsapp: null },
})
const mitZeiten = () => speichereZeiten({ feature: zeitenDoc(), eingabe: EIN_PLAN(), version: 0, now: new Date('2026-10-05T08:00:00.000Z') })
/** Behalten-Weg bis zur Stripe-Sitzung: die Route antwortet 200 und das Dokument traegt die Sitzung. */
async function starteBehalten(projectId = 'p1') {
const res = await behalten(koerper(projectId))
assert.equal(res.status, 200, JSON.stringify(res.body))
const sessionId = zeitenDoc(projectId).behaltenSessionId
assert.match(sessionId, /^cs_test_/)
return sessionId
}
/** Bezahlte Sitzung im Ereignis, mit den Metadaten, die createFeatureCheckout gesetzt hat; die Rechnung liegt im Stripe-Nachbau. */
function bezahltAus(sessionId) {
const invoice = `in_${sessionId}`
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
return { ...st.checkoutSessions.get(sessionId), payment_status: 'paid', status: 'complete', invoice }
}
/** Bezahlte Sitzung, die nicht aus diesem Portal-Lauf stammt (z. B. der zweite Tab): Metadaten wie beim Behalten. */
function fremdBezahlt(sessionId, { projectId = 'p1', customerId = 'c1', featureKey = 'zeiten' } = {}) {
const 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',
status: 'complete',
invoice,
metadata: { customerId, projectId, featureKey, zweck: 'behalten', consentAt: '2026-10-05T08:00:00.000Z' },
}
}
let sink
let rebuilds
let sperrenBeimVersand
const originalRebuild = zeitenDeps.rebuild
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('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 = ['*']
for (const name of ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'freischaltbar']) delete Z[name]
sink = mailSink()
// Jede Mail merkt, ob die Sperren des Projekts beim Versand frei waren (K15 und T19 gehen erst nach der Sperre raus)
sperrenBeimVersand = []
const roh = sink.sendMail.bind(sink)
sink.sendMail = async (mail) => {
sperrenBeimVersand.push({ subject: mail.subject, admin: await sperreFrei('feature-admin:p1'), order: await sperreFrei('feature-order:c1:p1') })
return roh(mail)
}
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,
adminFrei: await sperreFrei(`feature-admin:${p.$id}`),
})
return { queued: true }
}
mock.method(console, 'error', () => {})
mock.method(console, 'warn', () => {})
mock.method(console, 'log', () => {})
})
afterEach(() => {
mock.restoreAll()
zeitenDeps.rebuild = originalRebuild
})
after(() => {
config.zeiten.mode = MODUS.mode
config.zeiten.pilotEmails = MODUS.pilotEmails
})
// ------------------------------------------------------------------------------------------------- Behalten starten
test('Behalten nur nach Abo-Ende, sonst 409', async () => {
const leeren = async () => {
for (const col of ['portalFeatures', 'oeffnungszeiten', 'featureConsents']) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
}
// [Name, Dokument oder null, Eingaben gespeichert?]
const faelle = [
['kein Dokument', null, false],
['aus', () => seedeZeiten({ state: 'off', enabled: false, aboEndeAt: null, activatedAt: null, unlockedBy: '' }), false],
['bestellt', () => seedeZeiten({ state: 'ordered', enabled: false, aboEndeAt: null, activatedAt: null, unlockedBy: '' }), false],
['aktiv im Abo, ohne Abo-Ende', () => seedeZeiten({ aboEndeAt: null }), true],
['in Einrichtung im Abo, ohne Abo-Ende', () => seedeZeiten({ state: 'setup', enabled: false, activatedAt: null, aboEndeAt: null }), true],
['vom Team abgeschaltet', () => seedeAbgeschaltet({ disabledReason: 'team' }), true],
['abgeschaltet vor der Einrichtung', () => seedeAbgeschaltet({ disabledReason: 'abo_ende_vor_einrichtung' }), true],
['abgeschaltet nach Abo-Ende, Eingaben gelöscht', () => seedeAbgeschaltet(), false],
['abgeschaltet nach Abo-Ende, nur Team-Einstellungen (keine Eingaben des Kunden)', () => seedeAbgeschaltet(), 'team'],
]
for (const [name, vorbereiten, daten] of faelle) {
await leeren()
vorbereiten?.()
if (daten === true) await mitZeiten()
if (daten === 'team') await speichereTeam({ feature: zeitenDoc(), eingabe: { navSelektor: '' }, teamVersion: 0, now: new Date('2026-10-05T08:00:00.000Z') })
const vorher = zeitenDoc() ? { ...zeitenDoc() } : null
const res = await behalten(koerper())
assert.equal(res.status, 409, `${name}: ${JSON.stringify(res.body)}`)
assert.equal(res.body.error, NICHT_MOEGLICH, name)
keineSpuren()
if (vorher) assert.deepEqual({ ...zeitenDoc() }, vorher, `${name}: Dokument unverändert`)
else assert.equal(zeitenDoc(), undefined, `${name}: kein Dokument angelegt`)
}
// dagegen möglich: in Einrichtung oder aktiv mit Abo-Ende, und abgeschaltet nach Abo-Ende mit noch vorhandenen Eingaben
for (const [name, vorbereiten] of [
['aktiv mit Abo-Ende', () => seedeZeiten()],
['in Einrichtung mit Abo-Ende', () => seedeZeiten({ state: 'setup', enabled: false, activatedAt: null })],
['abgeschaltet nach Abo-Ende, Eingaben da', () => seedeAbgeschaltet()],
]) {
await leeren()
vorbereiten()
await mitZeiten()
const res = await behalten(koerper())
assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`)
assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_test_/, name)
}
})
test('Läuft das Wartungs-Abo wieder, ist Behalten gesperrt (409, die Funktion ist enthalten); Lesefehler beim Abo ⇒ 503; beides ohne Spur (R25)', async () => {
const wartungLaeuft = (over = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, customerId: 'c1', status: 'active', ...over }))
const LAEUFT = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
// aktiv mit Abo-Ende, die Zahlung hat schon begonnen (offene Sitzung): kehrt das Abo zurück, gibt es auch die alte Seite nicht mehr heraus
seedeZeiten()
const sessionId = await starteBehalten()
const davor = spuren()
wartungLaeuft()
const res = await behalten(koerper())
assert.equal(res.status, 409, JSON.stringify(res.body))
assert.equal(res.body.error, LAEUFT)
assert.equal(res.body.url, undefined)
assert.deepEqual(spuren(), davor, 'keine neue Sitzung, kein Nachweis')
assert.equal(zeitenDoc().behaltenSessionId, sessionId)
// eine vorgemerkte Kündigung zählt als laufend (bis zum Periodenende)
wartungLaeuft({ cancelAtPeriodEnd: true })
assert.equal((await behalten(koerper())).status, 409)
// abgeschaltet nach dem Abo-Ende, Eingaben noch da: ebenso
for (const id of [...aw.docs('portalFeatures').keys()]) await deleteDocument('portalFeatures', id)
seedeAbgeschaltet()
await mitZeiten()
const aus = await behalten(koerper())
assert.equal(aus.status, 409)
assert.equal(aus.body.error, LAEUFT)
// Abo-Lage nicht lesbar: fail-closed
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Appwrite down' })
const kaputt = await behalten(koerper())
assert.equal(kaputt.status, 503)
assert.match(kaputt.body.error, /nicht prüfbar/)
assert.equal(JSON.stringify(kaputt.body).includes('Appwrite'), false)
// ohne laufendes Wartungs-Abo (Hosting, beendet) geht es weiter wie gehabt
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, customerId: 'c1', status: 'canceled', endedAt: ABO_ENDE }))
const frei = await behalten(koerper())
assert.equal(frei.status, 200, JSON.stringify(frei.body))
assert.equal(sessionAufrufe().length, 2, 'die erste Sitzung und diese; die gesperrten Versuche legten keine an')
})
test('Vorprüfungen wie bei der Bestellung: Kundenansicht 403, gleiche Zustimmung (beide Haken), Projekt, Schalter, Unterlagen; alles ohne Spur', async () => {
seedeZeiten()
// Kundenansicht (Mitarbeiter bestellen nicht fuer den Kunden)
const ansicht = await behalten(koerper(), kundenansichtCookie())
assert.equal(ansicht.status, 403)
assert.equal(ansicht.body.error, 'In der Kundenansicht kann nicht bestellt werden.')
// Zustimmung: ohne, nur der Haken des Gratisweges, falsche Fassung
const ohne = await behalten({ projectId: 'p1' })
assert.deepEqual([ohne.status, ohne.body.code], [400, 'zustimmung_fehlt'])
const nurEiner = await behalten({ projectId: 'p1', zustimmung: { version: Z.consent.version, haken: ['leistung'] } })
assert.deepEqual([nurEiner.status, nurEiner.body.code], [400, 'zustimmung_fehlt'])
const alt = await behalten(koerper('p1', { zustimmung: { version: '2020-01-01', haken: bezahlteHaken() } }))
assert.deepEqual([alt.status, alt.body.code], [409, 'fassung_veraltet'])
assert.equal(alt.body.consent.version, Z.consent.version)
// Projekt: keines, fremdes
assert.equal((await behalten({ zustimmung: koerper().zustimmung })).status, 400)
assert.equal((await behalten(koerper('p3'))).status, 403)
// Schalter und Unterlagen
config.zeiten.mode = 'off'
const aus = await behalten(koerper())
assert.deepEqual([aus.status, aus.body.code], [409, 'nicht_verfuegbar'])
config.zeiten.mode = 'pilot'
Z.unterlagenBereit = async () => false
const unterlagen = await behalten(koerper())
assert.deepEqual([unterlagen.status, unterlagen.body.code], [503, 'unterlagen_fehlen'])
delete Z.unterlagenBereit
keineSpuren()
assert.equal(zeitenDoc().behaltenSessionId ?? '', '')
})
test('während der Zahlung bleibt der Zustand (Leisten bleiben live); Sitzung, Nachweis und Metadaten', async () => {
seedeZeiten({ aboEndeMailAt: '2026-10-01T06:05:00.000Z' })
const vorher = { ...zeitenDoc() }
const res = await behalten(koerper())
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.deepEqual(Object.keys(res.body), ['url'])
const sessionId = zeitenDoc().behaltenSessionId
assert.equal(res.body.url, `https://checkout.stripe.test/c/pay/${sessionId}`)
// einzige Aenderung am Dokument: behaltenSessionId (Zustand, Freischaltung, Frist, Rechnung bleiben)
const nachher = { ...zeitenDoc() }
const { behaltenSessionId, $updatedAt, ...rest } = nachher
const { behaltenSessionId: _vorher, $updatedAt: _u, ...restVorher } = vorher
assert.equal(behaltenSessionId, sessionId)
assert.deepEqual(rest, restVorher)
assert.deepEqual([nachher.state, nachher.enabled, nachher.unlockedBy, nachher.unlockedByPurchase, zeit(nachher.aboEndeAt)], ['active', true, 'abo', false, zeit(ABO_ENDE)])
const live = await sf.findActiveBySiteKey(SITE_KEY, 'zeiten', { states: ['setup', 'active'] })
assert.equal(live?.$id, 'f_p1_zeiten', 'die Leisten antworten weiter')
// Stripe: einmalig 30 EUR, Metadaten und Rechnungs-Metadaten mit zweck, Rueckkehr zum Editor
assert.equal(sessionAufrufe().length, 1)
const aufruf = sessionAufrufe()[0].body
assert.equal(preisDerSitzung(sessionAufrufe()[0]).unit_amount, 3000)
assert.equal(aufruf.mode, 'payment')
assert.equal(aufruf['metadata[zweck]'], 'behalten')
assert.equal(aufruf['metadata[featureKey]'], 'zeiten')
assert.equal(aufruf['metadata[projectId]'], 'p1')
assert.equal(aufruf['metadata[customerId]'], 'c1')
assert.match(aufruf['metadata[consentAt]'], /^\d{4}-\d\d-\d\dT[\d:.]+Z$/)
assert.equal(aufruf['invoice_creation[invoice_data][metadata][zweck]'], 'behalten')
assert.equal(aufruf['invoice_creation[invoice_data][metadata][featureKey]'], 'zeiten')
assert.equal(aufruf.success_url, `${config.publicBaseUrl}/dashboard.html#zeiten?behalten=1&projekt=p1`)
assert.equal(aufruf.cancel_url, `${config.publicBaseUrl}/dashboard.html#zeiten?projekt=p1`)
// Nachweis der Zustimmung (die der Bestellung: beide Haken) traegt die Sitzung und den Zeitpunkt des Klicks
assert.equal(nachweise().length, 1)
const nachweis = await zustimmungFuerSitzung(sessionId)
assert.deepEqual([nachweis.customerId, nachweis.projectId, nachweis.featureKey], ['c1', 'p1', 'zeiten'])
assert.equal(zeit(nachweis.acceptedAt), zeit(st.checkoutSessions.get(sessionId).metadata.consentAt))
assert.deepEqual(JSON.parse(nachweis.texte).filter((t) => t.haken).map((t) => t.id), bezahlteHaken())
// sonst nichts: keine Mail, kein Ticket, kein Neubau
assert.deepEqual({ ...spuren(), sitzungen: 0, nachweise: 0 }, { sitzungen: 0, nachweise: 0, mails: 0, tickets: 0, neubauten: 0 })
})
test('während der Zahlung bleibt auch ein abgeschaltetes Dokument abgeschaltet', async () => {
seedeAbgeschaltet()
await mitZeiten()
const sessionId = await starteBehalten()
const doc = zeitenDoc()
assert.deepEqual(
[doc.state, doc.enabled, doc.disabledReason, zeit(doc.disabledAt), doc.unlockedBy, zeit(doc.aboEndeAt), doc.behaltenSessionId],
['disabled', false, 'abo_ende', zeit('2026-10-02T06:00:00.000Z'), 'abo', zeit(ABO_ENDE), sessionId]
)
})
test('zweiter Klick: dieselbe offene Zahlungsseite; abgelaufene Sitzung ⇒ neue; bezahlt oder in Prüfung ⇒ 409', async () => {
seedeZeiten()
const erste = await starteBehalten()
const zweiter = await behalten(koerper())
assert.equal(zweiter.status, 200)
assert.equal(zweiter.body.url, `https://checkout.stripe.test/c/pay/${erste}`)
assert.equal(sessionAufrufe().length, 1, 'keine zweite Stripe-Sitzung')
assert.equal(nachweise().length, 1, 'kein zweiter Nachweis')
assert.equal(zeitenDoc().behaltenSessionId, erste)
// abgelaufen: eine neue Sitzung ersetzt die alte, mit eigenem Nachweis
st.checkoutSessions.get(erste).status = 'expired'
const dritter = await behalten(koerper())
assert.equal(dritter.status, 200)
const neue = zeitenDoc().behaltenSessionId
assert.notEqual(neue, erste)
assert.equal(dritter.body.url, `https://checkout.stripe.test/c/pay/${neue}`)
assert.equal(sessionAufrufe().length, 2)
assert.equal((await zustimmungFuerSitzung(neue)).stripeSessionId, neue)
// abgeschlossen (Zahlung eingegangen, Webhook noch nicht verbucht): kein neuer Versuch
st.checkoutSessions.get(neue).status = 'complete'
const vierter = await behalten(koerper())
assert.equal(vierter.status, 409)
assert.match(vierter.body.error, /Zahlung/)
assert.equal(zeitenDoc().behaltenSessionId, neue)
assert.equal(sessionAufrufe().length, 2)
})
test('Nachweis scheitert ⇒ 500 ohne Rohtext, keine URL, behaltenSessionId bleibt leer', async () => {
seedeZeiten()
aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500, message: 'Interner Appwrite-Text mit Kunde c1' })
const res = await behalten(koerper())
assert.equal(res.status, 500)
assert.equal(JSON.stringify(res.body).includes('Appwrite'), false)
assert.equal(res.body.url, undefined)
assert.equal(zeitenDoc().behaltenSessionId ?? '', '')
assert.equal(nachweise().length, 0)
})
test('Abruf der offenen Sitzung scheitert ⇒ 503, es entsteht keine zweite Zahlung; unbekannte Sitzung (404) ⇒ neue Sitzung', async () => {
seedeZeiten()
const erste = await starteBehalten()
// 400 statt 5xx: das SDK wiederholt 5xx von selbst, hier zaehlt, dass der Fehler nie zu einer zweiten Sitzung fuehrt
st.failNext({ method: 'GET', path: `/v1/checkout/sessions/${erste}`, status: 400, message: 'Stripe lehnt ab' })
const res = await behalten(koerper())
assert.equal(res.status, 503)
assert.equal(res.body.error, 'Die Zahlungsseite ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.')
assert.equal(sessionAufrufe().length, 1)
assert.equal(zeitenDoc().behaltenSessionId, erste)
// Stripe kennt die Sitzung nicht mehr: wie abgelaufen
st.checkoutSessions.delete(erste)
const neu = await behalten(koerper())
assert.equal(neu.status, 200, JSON.stringify(neu.body))
assert.equal(sessionAufrufe().length, 2)
assert.notEqual(zeitenDoc().behaltenSessionId, erste)
})
test('Sperre feature-order: zwei gleichzeitige Klicks legen genau eine Sitzung an', async () => {
seedeZeiten()
const [a, b] = await Promise.all([behalten(koerper()), behalten(koerper())])
assert.deepEqual([a.status, b.status], [200, 200])
assert.equal(a.body.url, b.body.url)
assert.equal(sessionAufrufe().length, 1)
assert.equal(nachweise().length, 1)
})
// -------------------------------------------------------------------------------------------------- Zahlung (Webhook)
test('Webhook behalten: kauf, Frist weg, kein neues Ticket, K15 behalten', async () => {
seedeZeiten({ aboEndeMailAt: '2026-10-01T06:05:00.000Z' })
const sessionId = await starteBehalten()
const res = await webhook('checkout.session.completed', bezahltAus(sessionId))
assert.equal(res.status, 200, JSON.stringify(res.body))
const doc = zeitenDoc()
assert.deepEqual(
[doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.aboEndeAt ?? null, doc.behaltenSessionId, doc.stripeSessionId],
['active', true, 'kauf', true, 'paid', null, '', sessionId]
)
assert.equal(sf.unlockedByVon(doc), 'kauf')
assert.equal(doc.stripeInvoiceId, `in_${sessionId}`)
assert.equal(doc.invoiceUrl, `https://invoice.stripe.test/in_${sessionId}`)
assert.equal(doc.ticketId, 't_alt', 'das bisherige Ticket bleibt')
assert.equal(doc.activatedAt, '2026-09-20T10:00:00.000+00:00')
assert.equal(doc.einbau, 'angestossen')
assert.match(doc.adminNote, /System: .*behalten.*Sitzung /)
assert.equal(doc.zahlungMeldung ?? '', '')
// kein neues Einbau-Ticket, kein Neubau, kein T16, kein T19
assert.equal(tickets().length, 0)
assert.deepEqual(rebuilds, [])
assert.equal(mailsMit(SUBJECT.T16).length, 0)
assert.equal(mailsMit(SUBJECT.T19).length, 0)
// K15 mit dem Behalten-Wortlaut, nach der Sperre
assert.equal(mailsMit(SUBJECT.K15).length, 1)
const k15 = mailsMit(SUBJECT.K15)[0]
assert.deepEqual(k15.to.map((a) => a.address), ['k@example.invalid'])
assert.match(k15.text, /Du behältst Öffnungszeiten live für Dein Projekt, auch ohne Wartungs-Abo\./)
assert.match(k15.text, /Deine Einstellungen und Daten bleiben unverändert, und es wird nichts neu eingebaut\./)
assert.doesNotMatch(k15.text, /Wir richten/)
assert.doesNotMatch(k15.text, /eingebaut ist, schalten wir/)
assert.ok(k15.text.includes(`https://invoice.stripe.test/in_${sessionId}`), 'Rechnungsknopf')
assert.deepEqual(sperrenBeimVersand, [{ subject: SUBJECT.K15, admin: true, order: true }])
// Ansicht: freigeschaltet durch Kauf, keine Frist mehr
const sicht = await sf.featureView(doc, 'zeiten')
assert.deepEqual([sicht.state, sicht.unlockedBy, sicht.aboEndeAt], ['active', 'kauf', null])
// Widerrufsfrist: der Anker ist der Klick dieser Sitzung (Nachweis mit der Sitzungs-ID)
const bezahlt = await sf.paidFeatureOrders('c1')
assert.deepEqual(bezahlt.map((b) => [b.featureKey, b.state, zeit(b.vertragAt)]), [['zeiten', 'active', zeit(st.checkoutSessions.get(sessionId).metadata.consentAt)]])
})
test('in Einrichtung mit Abo-Ende: bleibt in Einrichtung (setup), kauf, ohne Neubau und ohne Ticket', async () => {
seedeZeiten({ state: 'setup', enabled: false, activatedAt: null, einbau: 'manuell' })
const sessionId = await starteBehalten()
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.aboEndeAt ?? null, doc.behaltenSessionId, doc.einbau, doc.ticketId], ['setup', false, 'kauf', null, '', 'manuell', 't_alt'])
assert.deepEqual(rebuilds, [])
assert.equal(tickets().length, 0)
assert.equal(mailsMit(SUBJECT.K15).length, 1)
})
test('Stripe wiederholt den Webhook (auch als async_payment_succeeded): keine zweite Buchung, keine zweite K15, kein T19', async () => {
seedeZeiten()
const sessionId = await starteBehalten()
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
const gebucht = { ...zeitenDoc() }
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahltAus(sessionId))).status, 200)
assert.deepEqual({ ...zeitenDoc() }, gebucht)
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.equal(mailsMit(SUBJECT.T19).length, 0)
assert.equal(mailsMit(SUBJECT.T16).length, 0)
assert.equal(tickets().length, 0)
})
test('SEPA: completed ohne Geld bucht nichts, async_payment_succeeded bucht', async () => {
seedeZeiten()
const sessionId = await starteBehalten()
const ausstehend = { ...bezahltAus(sessionId), payment_status: 'unpaid' }
assert.equal((await webhook('checkout.session.completed', ausstehend)).status, 200)
assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId, mailsMit(SUBJECT.K15).length], ['abo', sessionId, 0])
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahltAus(sessionId))).status, 200)
assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId, mailsMit(SUBJECT.K15).length], ['kauf', '', 1])
})
test('Rechnung (noch) nicht lesbar: gebucht, K15 ohne Rechnungslink', async () => {
seedeZeiten()
const sessionId = await starteBehalten()
const sitzung = { ...bezahltAus(sessionId), invoice: 'in_gibt_es_nicht' }
assert.equal((await webhook('checkout.session.completed', sitzung)).status, 200)
const doc = zeitenDoc()
assert.deepEqual([doc.unlockedBy, doc.stripeSessionId, doc.invoiceUrl], ['kauf', sessionId, ''])
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.doesNotMatch(mailsMit(SUBJECT.K15)[0].text, /Rechnung ansehen/)
})
test('K15 scheitert: gebucht bleibt gebucht, Webhook 200 (kein T19)', async () => {
seedeZeiten()
const sessionId = await starteBehalten()
sink.sendMail = async () => {
throw new Error('SMTP down')
}
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId], ['kauf', ''])
})
test('Behalten aus disabled: zurück auf active und Neubau (nach der Sperre), kein neues Ticket', async () => {
seedeAbgeschaltet()
await mitZeiten()
const sessionId = await starteBehalten()
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
const doc = zeitenDoc()
assert.deepEqual(
[doc.state, doc.enabled, doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId],
['active', true, null, null, null, null, '']
)
assert.deepEqual([doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.stripeSessionId], ['kauf', true, 'paid', sessionId])
assert.equal(doc.activatedAt, '2026-09-20T10:00:00.000+00:00', 'der frühere Zeitpunkt der Freischaltung bleibt')
assert.equal(doc.ticketId, 't_alt')
assert.equal(tickets().length, 0)
// Neubau: einmal, als der Zustand schon active/kauf war, ausserhalb der Sperre; das Ergebnis steht in einbau
assert.deepEqual(rebuilds, [{ projektId: 'p1', tickets: 0, state: 'active', unlockedBy: 'kauf', adminFrei: true }])
assert.equal(doc.einbau, 'angestossen')
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.match(mailsMit(SUBJECT.K15)[0].text, /Du behältst/)
// die Leisten antworten wieder, die Eingaben sind unberuehrt
assert.equal((await sf.findActiveBySiteKey(SITE_KEY, 'zeiten'))?.$id, 'f_p1_zeiten')
const view = await sf.featureView(doc, 'zeiten')
assert.deepEqual([view.state, view.disabledReason, view.loeschungAm, view.unlockedBy], ['active', null, null, 'kauf'])
})
test('Behalten aus disabled ohne frühere Freischaltung: zurück auf setup (kein active) und Neubau', async () => {
seedeAbgeschaltet({ activatedAt: null })
await mitZeiten()
const sessionId = await starteBehalten()
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.disabledReason ?? null, doc.einbau], ['setup', false, 'kauf', null, 'angestossen'])
assert.equal(rebuilds.length, 1)
assert.equal(tickets().length, 0)
})
test('Behalten aus disabled, Neubau nicht möglich oder wirft: einbau manuell, gebucht bleibt gebucht', async () => {
for (const [name, vorbereiten] of [
['Runner wirft', () => { zeitenDeps.rebuild = async () => { throw new Error('Runner nicht erreichbar') } }],
['kein Repo', () => aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Ohne Repo' })],
]) {
for (const col of ['portalFeatures', 'oeffnungszeiten', 'featureConsents']) for (const id of [...aw.docs(col).keys()]) await deleteDocument(col, id)
sink.sent.length = 0
vorbereiten()
seedeAbgeschaltet()
await mitZeiten()
const sessionId = await starteBehalten()
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200, name)
assert.deepEqual([zeitenDoc().state, zeitenDoc().unlockedBy, zeitenDoc().einbau], ['active', 'kauf', 'manuell'], name)
assert.equal(mailsMit(SUBJECT.K15).length, 1, name)
}
})
// -------------------------------------------------------------------------------------------- unpassende Zahlung (T19)
test('Sitzung passt nicht zu behaltenSessionId ⇒ T19 Erstattung fällig, nichts gebucht, nichts eingerichtet', async () => {
seedeZeiten({ behaltenSessionId: 'cs_andere' })
const vorher = { ...zeitenDoc() }
const res = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))
assert.equal(res.status, 200, JSON.stringify(res.body))
// Zustand, Freischaltung, Frist und offene Zahlung bleiben; nur Vermerk und Marker kommen dazu
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.unlockedBy, doc.unlockedByPurchase, zeit(doc.aboEndeAt), doc.behaltenSessionId, doc.purchaseStatus], ['active', 'abo', false, zeit(ABO_ENDE), 'cs_andere', ''])
assert.equal(doc.stripeSessionId ?? '', vorher.stripeSessionId ?? '')
assert.equal(doc.zahlungMeldung, 'gemeldet')
assert.match(doc.adminNote, /cs_fremd/)
// T19: eine Mail ans Team mit der Sitzung, nach dem Abo-Ende, ohne "Ticket offen", ohne Rechnung des alten Dokuments; keine K15, T16
assert.equal(mailsMit(SUBJECT.T19).length, 1)
const t19 = mailsMit(SUBJECT.T19)[0]
assert.match(t19.text, /cs_fremd/)
assert.match(t19.text, /Zahlung nach dem Abo-Ende/)
assert.doesNotMatch(t19.text, /Ticket noch offen/)
assert.ok(t19.text.includes('https://invoice.stripe.test/in_cs_fremd'), 'Rechnung dieser Sitzung')
assert.match(t19.text, /30 €/)
assert.deepEqual(sperrenBeimVersand, [{ subject: SUBJECT.T19, admin: true, order: true }], 'T19 erst nach der Sperre')
assert.equal(mailsMit(SUBJECT.K15).length, 0)
assert.equal(mailsMit(SUBJECT.T16).length, 0)
assert.equal(tickets().length, 0)
assert.deepEqual(rebuilds, [])
// Stripe wiederholt: genau ein T19
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))).status, 200)
assert.equal((await webhook('checkout.session.async_payment_succeeded', fremdBezahlt('cs_fremd'))).status, 200)
assert.equal(mailsMit(SUBJECT.T19).length, 1)
})
test('schon behalten (zweiter Tab zahlt ebenfalls) ⇒ die zweite Zahlung ist ein T19, das Dokument bleibt wie gebucht', async () => {
seedeZeiten()
const erste = await starteBehalten()
assert.equal((await webhook('checkout.session.completed', bezahltAus(erste))).status, 200)
const gebucht = { ...zeitenDoc() }
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_zweite'))).status, 200)
assert.equal(mailsMit(SUBJECT.T19).length, 1)
assert.match(mailsMit(SUBJECT.T19)[0].text, /cs_zweite/)
assert.ok(mailsMit(SUBJECT.T19)[0].text.includes('in_cs_zweite'), 'Rechnung der zweiten, nicht die der ersten Zahlung')
assert.ok(!mailsMit(SUBJECT.T19)[0].text.includes(`in_${erste}`))
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.unlockedBy, doc.stripeSessionId, doc.invoiceUrl], ['active', 'kauf', erste, gebucht.invoiceUrl])
assert.equal(mailsMit(SUBJECT.K15).length, 1, 'keine zweite K15')
})
test('Daten gelöscht, bevor die Zahlung ankommt ⇒ T19, das Dokument bleibt abgeschaltet, kein Neubau, keine K15', async () => {
seedeAbgeschaltet()
await mitZeiten()
const sessionId = await starteBehalten()
await loescheZeiten('f_p1_zeiten')
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.enabled, doc.disabledReason, doc.unlockedBy, doc.unlockedByPurchase, doc.behaltenSessionId], ['disabled', false, 'abo_ende', 'abo', false, sessionId])
assert.equal(mailsMit(SUBJECT.T19).length, 1)
assert.match(mailsMit(SUBJECT.T19)[0].text, new RegExp(sessionId))
assert.equal(mailsMit(SUBJECT.K15).length, 0)
assert.deepEqual(rebuilds, [])
assert.equal(tickets().length, 0)
})
test('vom Team abgeschaltet, während die Zahlung lief ⇒ T19 (nicht stillschweigend wieder eingeschaltet)', async () => {
seedeZeiten()
const sessionId = await starteBehalten()
aw.seed('portalFeatures', { ...zeitenDoc(), state: 'disabled', enabled: false, disabledAt: '2026-10-03T06:00:00.000Z', disabledReason: 'team' })
assert.equal((await webhook('checkout.session.completed', bezahltAus(sessionId))).status, 200)
assert.deepEqual([zeitenDoc().state, zeitenDoc().unlockedBy, zeitenDoc().disabledReason], ['disabled', 'abo', 'team'])
assert.equal(mailsMit(SUBJECT.T19).length, 1)
assert.equal(mailsMit(SUBJECT.K15).length, 0)
assert.deepEqual(rebuilds, [])
})
test('ein früheres „gemeldet“ verschluckt das T19 einer neuen unpassenden Zahlung nicht', async () => {
seedeZeiten({ behaltenSessionId: 'cs_andere', zahlungMeldung: 'gemeldet' })
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_neu'))).status, 200)
assert.equal(mailsMit(SUBJECT.T19).length, 1)
assert.match(mailsMit(SUBJECT.T19)[0].text, /cs_neu/)
assert.equal(zeitenDoc().zahlungMeldung, 'gemeldet', 'danach wieder gemeldet')
// eine weitere, andere Zahlung wird wieder gemeldet; die Wiederholung derselben nicht
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_noch_eine'))).status, 200)
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_neu'))).status, 200)
assert.equal(mailsMit(SUBJECT.T19).length, 2)
})
test('T19 nicht gesendet ⇒ Webhook 500 und Wiederholung; danach genau ein T19', async () => {
seedeZeiten({ behaltenSessionId: 'cs_andere' })
const roh = sink.sendMail
sink.sendMail = async () => {
sink.sendMail = roh
throw new Error('SMTP down')
}
const erster = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))
assert.equal(erster.status, 500)
assert.equal(mailsMit(SUBJECT.T19).length, 0)
assert.notEqual(zeitenDoc().zahlungMeldung, 'gemeldet')
const zweiter = await webhook('checkout.session.completed', fremdBezahlt('cs_fremd'))
assert.equal(zweiter.status, 200)
assert.equal(mailsMit(SUBJECT.T19).length, 1)
assert.equal(zeitenDoc().zahlungMeldung, 'gemeldet')
})
test('kein Dokument zur Zahlung (oder andere Funktion) ⇒ T16 „ungeklärt“ ans Team, nichts angelegt', async () => {
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_ohne_dok'))).status, 200)
assert.equal(mailsMit(SUBJECT.T16).length, 1)
assert.match(mailsMit(SUBJECT.T16)[0].text, /cs_ohne_dok/)
assert.equal(mailsMit(SUBJECT.T19).length, 0)
assert.equal(aw.docs('portalFeatures').size, 0)
// Metadaten eines anderen Kunden (Dokument gehört nicht dazu) ⇒ dasselbe
seedeZeiten({ behaltenSessionId: 'cs_x' })
assert.equal((await webhook('checkout.session.completed', fremdBezahlt('cs_x', { customerId: 'c3' }))).status, 200)
assert.equal(mailsMit(SUBJECT.T16).length, 2)
assert.deepEqual([zeitenDoc().unlockedBy, zeitenDoc().behaltenSessionId], ['abo', 'cs_x'])
})
test('bucheBehalten liefert den Fall: behalten bzw. unpassend', async () => {
seedeZeiten()
const sessionId = await starteBehalten()
assert.deepEqual(await bucheBehalten(bezahltAus(sessionId)), { fall: 'behalten' })
assert.deepEqual(await bucheBehalten(bezahltAus(sessionId)), { fall: 'behalten' }, 'wiederholt: weiterhin behalten, ohne zweite Buchung')
assert.deepEqual(await bucheBehalten(fremdBezahlt('cs_zweite')), { fall: 'unpassend' })
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.equal(mailsMit(SUBJECT.T19).length, 1)
})
// -------------------------------------------------------------------------------------- Zahlung abgebrochen, abgelaufen
test('Sitzung abgelaufen oder SEPA fehlgeschlagen ⇒ behaltenSessionId wird frei, Zustand bleibt, man kann neu starten', async () => {
for (const type of ['checkout.session.expired', 'checkout.session.async_payment_failed']) {
for (const id of [...aw.docs('portalFeatures').keys()]) await deleteDocument('portalFeatures', id)
seedeZeiten()
const sessionId = await starteBehalten()
// eine andere Sitzung desselben Projekts raeumt nichts ab
assert.equal((await webhook(type, fremdBezahlt('cs_andere'))).status, 200, type)
assert.equal(zeitenDoc().behaltenSessionId, sessionId, type)
// die eigene schon
const eigene = { ...st.checkoutSessions.get(sessionId), status: type === 'checkout.session.expired' ? 'expired' : 'complete', payment_status: 'unpaid' }
assert.equal((await webhook(type, eigene)).status, 200, type)
assert.deepEqual([zeitenDoc().behaltenSessionId, zeitenDoc().state, zeitenDoc().unlockedBy, zeit(zeitenDoc().aboEndeAt)], ['', 'active', 'abo', zeit(ABO_ENDE)], type)
assert.equal(mailsMit(SUBJECT.T16).length, 0, type)
const neu = await behalten(koerper())
assert.equal(neu.status, 200, `${type}: ${JSON.stringify(neu.body)}`)
assert.notEqual(zeitenDoc().behaltenSessionId, sessionId, type)
}
})
// ------------------------------------------------------------------------------------------------ Kauf unverändert
test('normaler Kauf-Webhook unverändert: keine Metadaten zweck, bestellt=1, setup/kauf, Neubau, Ticket, K15 „neu“', async () => {
const res = await checkout(koerper())
assert.equal(res.status, 200, JSON.stringify(res.body))
const aufruf = sessionAufrufe().at(-1).body
assert.equal(Object.keys(aufruf).some((k) => k.includes('zweck')), false, 'weder in metadata noch in invoice_data')
assert.equal(aufruf.success_url, `${config.publicBaseUrl}/dashboard.html#zeiten?bestellt=1&projekt=p1`)
assert.equal(aufruf.cancel_url, `${config.publicBaseUrl}/dashboard.html#zeiten?projekt=p1`)
const sessionId = [...st.checkoutSessions.values()].at(-1).id
assert.equal(zeitenDoc().state, 'ordered')
assert.equal(zeitenDoc().behaltenSessionId ?? '', '')
const invoice = `in_${sessionId}`
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
const paid = { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', invoice, metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'zeiten' } }
assert.equal((await webhook('checkout.session.completed', paid)).status, 200)
const doc = zeitenDoc()
assert.deepEqual([doc.state, doc.unlockedBy, doc.unlockedByPurchase, doc.purchaseStatus, doc.stripeSessionId, doc.einbau], ['setup', 'kauf', true, 'paid', sessionId, 'angestossen'])
assert.equal(rebuilds.length, 1)
assert.equal(tickets().length, 1)
assert.equal(mailsMit(SUBJECT.K15).length, 1)
assert.match(mailsMit(SUBJECT.K15)[0].text, /Wir richten Öffnungszeiten live jetzt für Dein Projekt ein\./)
assert.doesNotMatch(mailsMit(SUBJECT.K15)[0].text, /Du behältst/)
assert.equal(mailsMit(SUBJECT.T16).length, 1)
assert.equal(mailsMit(SUBJECT.T19).length, 0)
})
test('createFeatureCheckout: zweck nur behalten (sonst 400 vor jedem Stripe-Aufruf); News und Termine ohne zweck', async () => {
const { createFeatureCheckout } = await import('../server/services/stripeBilling.js')
const kunde = { $id: 'c1', email: 'k@example.invalid', name: 'Kunde Eins' }
await assert.rejects(createFeatureCheckout(kunde, { projectId: 'p1', key: 'zeiten', zweck: 'was_anderes' }), (err) => err.status === 400)
assert.equal(sessionAufrufe().length, 0)
await createFeatureCheckout(kunde, { projectId: 'p1', key: 'news', consentAt: '2026-10-05T08:00:00.000Z' })
const news = sessionAufrufe().at(-1).body
assert.equal(Object.keys(news).some((k) => k.includes('zweck')), false)
assert.equal(news.success_url, `${config.publicBaseUrl}/dashboard.html#${config.features.news.returnHash}?bestellt=1`)
})
test('Behalten (30 €) ist für Abo-Modell-Dokumente gesperrt (409 ohne Spur), für Gratis-Zeiten im selben Zustand möglich', async () => {
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
seedeAbgeschaltet({ einrichtungAbrechnung: 'abo' }); await mitZeiten()
const res = await behalten(koerper()); assert.deepEqual([res.status, res.body.error], [409, NICHT_MOEGLICH]); keineSpuren()
await updateDocument('portalFeatures', 'f_p1_zeiten', { einrichtungAbrechnung: '' }); assert.equal((await behalten(koerper())).status, 200)
})