Funktionen/News: Schlussdurchgang (Webhook-Retry, Checkout-Sitzung, Rechnungslink, Widerruf Funktion, UI- und Rechtstext-Korrekturen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 17:38:15 +00:00
1 parent 0dc57fe5e8
commit 4b7f73abba
22 files changed
+400 -57

No files matched your search

+102 -7
View File
@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs'
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
@@ -98,16 +99,26 @@ test('checkout_legt_session_an', async () => {
assert.equal(doc.consentText, config.funktionen.consentText)
assert.ok(doc.consentAt)
assert.ok(doc.consentUserAgent)
// zweite Bestellung waehrend die erste offen ist: 409, keine neue Sitzung
assert.equal((await checkout('p1')).status, 409)
// zweite Bestellung waehrend die erste offen ist: dieselbe Zahlungsseite, keine neue Sitzung
const again = await checkout('p1')
assert.equal(again.status, 200)
assert.equal(again.body.url, res.body.url)
assert.equal(sessions().length, 1)
})
test('abgelaufene_bestellung_darf_neu_gestartet_werden', async () => {
const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: new Date(Date.now() - 25 * 3600 * 1000).toISOString() })
assert.equal((await checkout('p1')).status, 200)
test('abgelaufene_sitzung_darf_neu_gestartet_werden', async () => {
sessions().at(-1).status = 'expired'
const res = await checkout('p1')
assert.equal(res.status, 200)
assert.equal(sessions().length, 2)
assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).stripeSessionId, sessions().at(-1).id)
})
test('abgeschlossene_sitzung_409', async () => {
sessions().at(-1).status = 'complete'
const before = sessions().length
assert.equal((await checkout('p1')).status, 409)
assert.equal(sessions().length, before)
})
test('checkout_bei_setup_409', async () => {
@@ -192,7 +203,8 @@ test('parallele_checkouts_eine_sitzung', async () => {
aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Gärtnerei' })
const before = sessions().length
const results = await Promise.all([checkout('p8'), checkout('p8')])
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409])
assert.deepEqual(results.map((r) => r.status), [200, 200])
assert.equal(results[0].body.url, results[1].body.url)
assert.equal(sessions().length, before + 1)
})
@@ -300,3 +312,86 @@ test('ticket_fehlt_markiert', async () => {
assert.equal(missing.ticketMissing, true)
assert.equal(missing.ticketWoid, null)
})
// ------------------------------------------------ Schlussdurchgang ---
test('webhook_retry_setzt_ticketlose_einrichtung_fort', async () => {
aw.seed('websiteProjects', { $id: 'pr1', customerId: 'c1', projectName: 'Retry' })
await checkout('pr1')
const id = sessions().at(-1).id
const wosBefore = workorders().length
const mailsBefore = mails.sent.length
aw.failNext({ collection: 'workorders', method: 'POST', status: 400 })
const first = await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))
assert.equal(first.status, 500)
const stuck = await sf.getFeatureDoc('c1', 'pr1', 'news')
assert.equal(stuck.state, 'setup')
assert.ok(!stuck.ticketId)
assert.equal(mails.sent.length, mailsBefore)
assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))).status, 200)
const fixed = await sf.getFeatureDoc('c1', 'pr1', 'news')
assert.ok(fixed.ticketId)
assert.equal(workorders().length, wosBefore + 1)
assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand'])
// weiterer Retry: nichts mehr
await postWebhook(event('checkout.session.completed', paidSession(id, 'pr1')))
assert.equal(workorders().length, wosBefore + 1)
assert.equal(mails.sent.length - mailsBefore, 2)
})
test('rechnungslink_wird_nachgetragen', async () => {
aw.seed('websiteProjects', { $id: 'pi1', customerId: 'c1', projectName: 'Rechnung' })
await checkout('pi1')
const session = sessions().at(-1)
await postWebhook(event('checkout.session.completed', paidSession(session.id, 'pi1')))
const listed = async () => (await get('/api/funktionen', { cookie })).projects.find((p) => p.projectId === 'pi1').features.news
assert.equal((await listed()).invoiceUrl, null)
st.invoices.set('in_late', { id: 'in_late', object: 'invoice', hosted_invoice_url: 'https://invoice.stripe.test/late' })
session.invoice = 'in_late'
assert.equal((await listed()).invoiceUrl, 'https://invoice.stripe.test/late')
const doc = await sf.getFeatureDoc('c1', 'pi1', 'news')
assert.equal(doc.stripeInvoiceId, 'in_late')
// Stripe-Fehler lassen die Liste nicht scheitern
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), invoiceUrl: '' })
st.failNext({ method: 'GET', path: /checkout\/sessions/, status: 400 })
assert.equal((await get('/api/funktionen', { cookie })).projects.length > 0, true)
})
test('bezahlt_ohne_passende_bestellung_meldet_dem_team', async () => {
aw.seed('websiteProjects', { $id: 'pu1', customerId: 'c1', projectName: 'Unklar' })
const mailsBefore = mails.sent.length
const res = await postWebhook(event('checkout.session.completed', paidSession('cs_verwaist', 'pu1')))
assert.equal(res.status, 200)
const notice = mails.sent.slice(mailsBefore)
assert.equal(notice.length, 1)
assert.equal(notice[0].subject, 'Neue Funktion bestellt: News-Pinnwand')
assert.match(notice[0].text, /cs_verwaist/)
assert.match(notice[0].text, /nicht zuzuordnen/)
assert.doesNotMatch(notice[0].text, /Zahlung fehlgeschlagen/)
assert.equal(workorders().filter((t) => t.topic.includes('Unklar')).length, 0)
})
test('abgeschaltete_funktion_nicht_buchbar', async () => {
aw.seed('websiteProjects', { $id: 'pd1', customerId: 'c1', projectName: 'Abgeschaltet' })
const doc = await sf.adminActivate('c1', 'pd1', 'news', { by: 'test' })
await sf.disable(doc.$id, { by: 'test' })
const before = sessions().length
const res = await checkout('pd1')
assert.equal(res.status, 409)
assert.equal(res.body.error, 'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.')
assert.equal(sessions().length, before)
})
test('statische_quellen: news_label, minmax_grid, ticket_anleitung', () => {
const read = (p) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8')
assert.match(read('public/app.js'), /FEATURE_LABELS = \{[^}]*news: 'News-Pinnwand'/)
assert.doesNotMatch(read('public/app.js'), /FEATURE_LABELS\[f\.featureKey\] \|\| f\.featureKey/)
const embed = read('public/embed/news.js')
assert.ok(embed.includes('grid-template-columns:minmax(0,1.25fr) minmax(0,1fr)'))
assert.ok(embed.includes('.wkn-combo-in{grid-template-columns:minmax(0,1fr)}'))
assert.ok(!read('public/dashboard.html').includes('id="view-news" aria-live'))
})
test('ticket_details_nennen_die_anleitung', () => {
const doc = [...aw.docs('workorders').values()].find((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
assert.ok(doc.details.includes('Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)'))
})
+8
View File
@@ -473,12 +473,20 @@ test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)',
for (const s of ['News-Beiträge und Fotos', 'Metadaten', 'GPS', 'Falkenstein', 'Art. 6 Abs. 1 lit. b DSGVO']) assert.ok(news.includes(s), s)
assert.ok(html.slice(Math.max(0, a - 120), a).includes('ENTWURF, Freigabe Nutzer ausstehend'))
assert.ok(html.includes('<h2>7. Abos und Zahlung über Stripe</h2>'), 'keine Umnummerierung')
assert.ok(news.includes('nach etwa 24 Stunden') && !news.includes('spätestens nach 24 Stunden'))
assert.ok(news.includes('nach den Fristen in Abschnitt 7') && !news.includes('Abschnitt 6 und 7'))
assert.ok(!news.includes('Endet die Zusammenarbeit, schalten wir'))
const lb = read('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html')
assert.ok(lb.includes('ENTWURF, Freigabe Nutzer ausstehend'))
const text = strip(lb)
assert.ok(text.includes('Auf Ihren Wunsch schalten wir die Pinnwand ab') && !text.includes('Endet die Zusammenarbeit mit uns, schalten wir'))
assert.ok(text.includes('abgeschlossen') && !text.includes('dauert erfahrungsgemäß'))
for (const s of ['30,00 €', '§ 356 Abs. 4 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s)
const muster = read('docs/funktionen/website-datenschutz-absatz.md')
// 14 Tage Protokolle nur, solange Abschnitt 3 der WEBklar-Erklaerung dieselbe Frist nennt
assert.ok(!muster.includes('nach 14 Tagen gelöscht') || strip(html).includes('Die Protokolle löschen wir nach 14 Tagen'))
assert.ok(muster.includes('[Prüfen: AVV mit WEBklar erforderlich]'))
assert.ok(!/[–—]/.test(muster.slice(muster.indexOf('## Variante A'))), 'Kundentext ohne Gedankenstriche')
})
+2 -2
View File
@@ -260,11 +260,11 @@ test('K03, K04, T08, T09: Vertrag bzw. Bestellung, Pakete und die Blöcke der Au
for (const p of ['vertrag', 'pakete', 'pakete_neu']) assert.ok(loadTemplate('K03').placeholders.includes(p), `K03 ${p}`)
for (const b of ['zusatzspeicher', 'pruefen', 'abo']) assert.ok(loadTemplate('K03').blocks.includes(b), `K03 ${b}`)
assert.ok(loadTemplate('K04').placeholders.includes('bestellung'))
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'pruefen'])
assert.deepEqual(loadTemplate('K04').blocks, ['kundennummer', 'abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'pruefen'])
for (const p of ['vertrag', 'pakete', 'fehler']) assert.ok(loadTemplate('T08').placeholders.includes(p), `T08 ${p}`)
for (const b of ['zusatzspeicher', 'kein_speicher', 'wechsel_ausstehend', 'speicher_fehlgeschlagen', 'nachweis_fehlt']) assert.ok(loadTemplate('T08').blocks.includes(b), `T08 ${b}`)
for (const p of ['bestellung', 'ereignisse']) assert.ok(loadTemplate('T09').placeholders.includes(p), `T09 ${p}`)
for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`)
for (const b of ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'keine_frist', 'nachweis_fehlt']) assert.ok(loadTemplate('T09').blocks.includes(b), `T09 ${b}`)
const k03 = { ...sampleValues(loadTemplate('K03')), vertrag: 'E-Mail-Zusatzspeicher zum WEBklar Hosting-Abo', pakete: 2, pakete_neu: 1, vertragsende: '31.10.2026' }
const speicher = renderMail('K03', k03, { zusatzspeicher: true })
+26
View File
@@ -452,6 +452,31 @@ test('Widerruf Zusatzspeicher: alle Plus-Bestellungen in der Frist werden widerr
assert.match(t09().text, /Menge .*zurück/)
})
test('Widerruf Funktion: nichts automatisch, K04 „wir prüfen“, T09 nennt Funktion, Projekt und Fristende', async () => {
await resetSubH({ units: 0 })
const orderedAt = daysAgo(3)
aw.seed('websiteProjects', { $id: 'pf1', customerId: 'c1', projectName: 'Bäckerei Funktion' })
aw.seed('portalFeatures', { $id: 'f_pf1_news', customerId: 'c1', projectId: 'pf1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt })
const before = st.calls.length
const res = await withdraw({ order: 'funktion' })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
assert.deepEqual(await listContractEvents('c1'), [], 'kein Vertragsereignis, kein Abo betroffen')
assert.equal(aw.docs('subscriptions').get('sub_h').withdrawalRequestedAt, undefined)
assert.equal(aw.docs('portalFeatures').get('f_pf1_news').state, 'setup')
assert.match(k04().text, /Funktion \(z\. B\. News-Pinnwand\)/)
assert.match(k04().text, /Wir prüfen Ihren Widerruf/)
assert.doesNotMatch(k04().text, /Es erfolgen keine weiteren Abbuchungen/)
const team = t09().text
assert.match(team, /Funktion \(z\. B\. News-Pinnwand\)/)
assert.match(team, /NICHTS automatisch geändert/)
assert.match(team, /Bäckerei Funktion: in Einrichtung/)
assert.ok(team.includes(fmtDate(withdrawalDeadline(orderedAt).endsAt)))
assert.match(team, /RECHTZEITIG/)
assert.doesNotMatch(team, /KEIN aktives Abo/)
aw.docs('portalFeatures').delete('f_pf1_news')
})
test('Widerruf Abschluss in der Frist: auf Kündigung gesetzt; Altbestand ohne Ereignis zählt ab startedAt (F14)', async () => {
const startedAt = daysAgo(2)
await resetSubH({ units: 0, patch: { startedAt, termStartAt: startedAt } })
@@ -656,6 +681,7 @@ test('widerrufen.html: Radioknöpfe order', () => {
assert.match(src, /name="order" value="abschluss"[^>]*> Abschluss meines Abos/)
assert.match(src, /name="order" value="wechsel"[^>]*> Wechsel ins Wartungs-Abo/)
assert.match(src, /name="order" value="zusatzspeicher"[^>]*> E-Mail-Zusatzspeicher/)
assert.match(src, /name="order" value="funktion"[^>]*> Funktion \(z\. B\. News-Pinnwand\)/)
assert.doesNotMatch(src, /value="WEBklar Wartungs-Abo" disabled/)
assert.match(src, /fields: \['order', 'name', 'customerNumber', 'email'\]/)
})
+12
View File
@@ -314,10 +314,22 @@ export async function startFakeStripe() {
subscription: null,
metadata: data.metadata || {},
subscription_data: data.subscription_data || {},
status: 'open',
payment_status: 'unpaid',
invoice: null,
}
checkoutSessions.set(id, obj)
return [200, obj]
}
// Sitzung abrufen (Funktions-Checkout); expand=invoice ersetzt die Rechnungs-ID durch das Objekt.
// Tests setzen status/payment_status/invoice direkt im Objekt aus st.checkoutSessions.
if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) {
const found = checkoutSessions.get(m[1])
if (!found) return noSuch('checkout.session', m[1])
const expandInvoice = [...query.entries()].some(([k, v]) => k.startsWith('expand') && v === 'invoice')
const inv = typeof found.invoice === 'string' ? invoices.get(found.invoice) : null
return [200, expandInvoice && inv ? { ...found, invoice: inv } : found]
}
if (method === 'GET' && path === '/v1/billing_portal/configurations') {
const active = query.get('active')
const found = [...billingPortalConfigurations.values()].filter(
+21
View File
@@ -34,6 +34,7 @@ const SCENARIOS = {
off: [proj('p1', 'Bäckerei Berg', 'off')],
setup: [proj('p1', 'Bäckerei Berg', 'setup')],
active: [proj('p1', 'Bäckerei Berg', 'active')],
disabled: [proj('p1', 'Bäckerei Berg', 'disabled')],
multi: [proj('p1', 'Bäckerei Berg', 'active'), proj('p2', 'Café Nord', 'off')],
}
const CONSENT = 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt.'
@@ -173,6 +174,17 @@ try {
const back = await page.textContent('#view-news')
check(`${label} bestellt_hinweis`, back.includes('Danke! Sobald die Zahlung da ist, legen wir los.') && back.includes('1234') && back.includes('Rechnung ansehen') && back.includes('Frage dazu? Chat öffnen'))
check(`${label} bestellt_router`, await page.evaluate(() => document.getElementById('view-news').classList.contains('active')))
check(`${label} bestellt_flag_aus_hash`, await page.evaluate(() => !window.location.hash.includes('bestellt')), await page.evaluate(() => window.location.hash))
// Flag ohne bestellte Funktion (Zustand off): kein Dankeshinweis
await open('#news?bestellt=1', 'off')
await page.waitForSelector('#news-order')
check(`${label} bestellt_flag_ignoriert_bei_off`, !(await page.textContent('#view-news')).includes('Danke!'))
// Abgeschaltet: Pille und Knopf der Kachel, Status statt Buchung
await open('#funktionen', 'disabled')
await page.waitForSelector('[data-fn-tile="news"] .fn-pill')
const dis = await page.evaluate(() => ({ pill: document.querySelector('[data-fn-tile="news"] .fn-pill').textContent.trim(), btn: document.querySelector('[data-fn-tile="news"] .fn-tile-cta').textContent.trim() }))
check(`${label} kachel_abgeschaltet`, dis.pill === 'Abgeschaltet' && dis.btn === 'Status ansehen', JSON.stringify(dis))
check(`${label} news_ohne_aria_live`, await page.evaluate(() => !document.getElementById('view-news').hasAttribute('aria-live')))
// Editor
await open('#news', 'active')
@@ -188,6 +200,15 @@ try {
check(`${label} editor_foto_sichtbar`, await page.isVisible('#nw-photo-field'))
const accepts = await page.$$eval('#nw-photo-field input[type=file]', (els) => els.map((e) => `${e.accept}|${e.getAttribute('capture') || ''}`))
check(`${label} editor_foto_accept`, accepts.length >= 1 && accepts.every((a) => a.startsWith('image/jpeg,image/png,image/webp|')) && accepts.filter((a) => a.endsWith('|environment')).length === 1, accepts.join(' ; '))
// Zurueck zum Tab und wieder in News (gleiches Projekt, gleicher Zustand): Entwurf bleibt stehen
await page.fill('#nw-title', 'Entwurf bleibt')
await page.evaluate(() => document.querySelector('.kb-nav-item[data-view="funktionen"]').click())
await page.waitForSelector('[data-fn-tile="news"] .fn-tile-cta')
await page.click('[data-fn-tile="news"] .fn-tile-cta')
await page.waitForSelector('#nw-title')
await page.waitForTimeout(200)
check(`${label} editor_entwurf_bleibt`, (await page.inputValue('#nw-title')) === 'Entwurf bleibt')
await page.fill('#nw-title', 'Neue Winterjacken')
const lists = await page.textContent('#view-news')
check(`${label} editor_listen`, lists.includes('Hängt gerade') && lists.includes('Abgelaufen') && lists.includes('Freitag geschlossen') && lists.includes('Auf meiner Website ansehen'))
await page.click('[data-del]')