gleicheZeitenAboAb(now) koppelt die im Wartungs-Abo enthaltenen Zeiten (unlockedBy abo) an das Abo (Spec 5, Z11): Abo-Ende erkennen (aboEndeAt, K26), nach 30 Berliner Kalendertagen abschalten (abo_ende, K27, Ersatz-Ticket), bei Rückkehr des Abos wieder einschalten (Neubau nach dem Abschalten, K24 wieder), 30 Tage nach dem Abschalten Daten löschen und state off. Lesefehler beim Abo: Kunde überspringen, nie abschalten oder löschen. R25: activate() leert aboEndeAt und behaltenSessionId; starteBehalten lehnt bei laufendem Wartungs-Abo mit 409 ab (Lesefehler 503). R17: k24Mail mit einbau null und wieder schaltet beide Einbau-Blöcke ab, K24 bekommt dafür den Block weiter um den Kasten "Wie geht es weiter?". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
803 lines
43 KiB
JavaScript
803 lines
43 KiB
JavaScript
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', 'k24At'],
|
||
featureConsents: ['acceptedAt'],
|
||
},
|
||
defaultLimit: 25,
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
const { config } = await import('../server/config.js')
|
||
const { startTestApp, post, postWebhook } = await import('./support/testApp.js')
|
||
const { customerCookie, 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`)
|
||
})
|