Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten

Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-06 06:12:23 +00:00
commit e87140e5ba
245 files changed
+38799 -1050

No files matched your search

+10 -2
View File
@@ -11,8 +11,10 @@ import http from 'node:http'
*
* Bulk-Create (POST .../documents mit { documents: [{ $id, ...felder }] }, 1 bis 100 Dokumente) ist alles oder
* nichts: vorhandene oder doppelte $id => 409, ungueltige $id, unbekanntes Attribut oder ungueltiges Datum => 400,
* dann ist nichts geschrieben. Abfragen: equal, notEqual (mit Liste: keiner von; ein Dokument ohne das Feld passt),
* greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die
* dann ist nichts geschrieben. Abfragen: equal (mit Liste: einer von), notEqual (wie Appwrite 1.8.1, gemessen 2026-10-05:
* genau EIN Wert, sonst HTTP 400 "Invalid query: NotEqual queries require exactly one value." general_query_invalid; mehrere
* notEqual in einer Anfrage werden geschnitten; ein Dokument, dessen Feld null ist oder fehlt, passt NICHT zu notEqual, SQL-
* Semantik), greaterThan(Equal), lessThan(Equal), between (inklusiv), orderAsc/orderDesc, limit, cursorAfter, select (nur die
* genannten Felder plus alle $-Felder). Die Antwort traegt total = Treffer der Filter, unabhaengig von limit und
* cursorAfter. listCalls(collection?) / resetListCalls() zeichnen die Listenabfragen auf. Zwei Werte, die beide
* wie "YYYY-MM-DDT..." aussehen, vergleicht er als Zeitpunkte (Schreibweise Z, +00:00, +02:00 ist egal), alle
@@ -133,6 +135,8 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
case 'equal':
return query.values.some((candidate) => same(value, candidate))
case 'notEqual':
// SQL: NULL <> 'x' ist nicht wahr, ein Dokument ohne Wert faellt heraus (Appwrite 1.8.1)
if (value === undefined || value === null) return false
return !query.values.some((candidate) => same(value, candidate))
case 'greaterThan':
return greater(value, first)
@@ -158,6 +162,10 @@ export async function startAppwriteMock({ schema = {}, datetimes = {}, defaultLi
const queries = rawQueries.map((q) => JSON.parse(q))
const unsupported = queries.find((q) => !supportedQueries.includes(q.method))
if (unsupported) return [400, { message: `Query ${unsupported.method} wird nicht nachgebaut`, code: 400 }]
// Appwrite 1.8.1: notEqual nimmt genau einen Wert (der Nachbau lehnt die Liste wie die echte Datenbank ab)
if (queries.some((q) => q.method === 'notEqual' && (!Array.isArray(q.values) || q.values.length !== 1))) {
return [400, { message: 'Invalid query: NotEqual queries require exactly one value.', code: 400, type: 'general_query_invalid' }]
}
// cursorAfter: das Dokument mit dieser ID muss es geben (es muss aber nicht zu den Filtern passen)
const cursorId = queries.find((q) => q.method === 'cursorAfter')?.values[0]
if (cursorId !== undefined && !docs(collection).has(cursorId)) {
+22 -3
View File
@@ -3,7 +3,8 @@
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
//
// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// installFakeDom stellt `document`, `window` (location mit href, pathname, search, hash und reload() - jeder Aufruf steht in fx.reloads -, history.replaceState und
// addEventListener/removeEventListener/dispatchEvent fuer Ereignisse des Fensters wie 'pageshow'), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
//
@@ -471,7 +472,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
/**
* Fake-DOM und Fake-fetch auf globalThis stellen.
* => { document, window, fetchLog, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
* => { document, window, fetchLog, reloads, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
* finde(baum, vergleich) / alle(baum, vergleich): erstes / alle Elemente im Baum (baum selbst eingeschlossen, Dokumentreihenfolge), fuer die
* vergleich(el) wahr ist (Funktion) oder die zum Selektor passen (String).
* text(baum): Text des Baums (textContent). klick(el), setze(el, wert), ruhe(): siehe Kopf der Datei.
@@ -479,11 +480,28 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch']
export function installFakeDom({ antworten = [] } = {}) {
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
const document = neuesDokument()
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' }
const reloads = []
const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '', reload: () => { reloads.push(location.href) } }
const fensterEreignisse = new Map()
const window = {
document,
location,
history: { replaceState: (_zustand, _titel, url) => { location.hash = String(url ?? '').includes('#') ? `#${String(url).split('#')[1]}` : '' } },
addEventListener(typ, fn) {
if (!fensterEreignisse.has(typ)) fensterEreignisse.set(typ, [])
fensterEreignisse.get(typ).push(fn)
},
removeEventListener(typ, fn) {
const liste = fensterEreignisse.get(typ)
if (liste?.includes(fn)) liste.splice(liste.indexOf(fn), 1)
},
/** Ereignis an die Handler des Fensters (z. B. { type: 'pageshow', persisted: true }); Rueckgaben werden nicht abgewartet. */
dispatchEvent(ev) {
for (const fn of [...(fensterEreignisse.get(ev.type) ?? [])]) fn(ev)
return true
},
/** Zahl der Handler eines Typs (fuer Tests, die Aufraeumen pruefen). */
anzahlHandler: (typ) => (fensterEreignisse.get(typ) ?? []).length,
}
const fetchLog = []
const warteschlange = [...antworten]
@@ -503,6 +521,7 @@ export function installFakeDom({ antworten = [] } = {}) {
document,
window,
fetchLog,
reloads,
antworten: warteschlange,
finde: (baum, vergleich) => knoten(baum).find(pruefung(vergleich)) ?? null,
alle: (baum, vergleich) => knoten(baum).filter(pruefung(vergleich)),
+183 -3
View File
@@ -32,6 +32,16 @@ import http from 'node:http'
* Kunden: GET /v1/customers/search (Metadaten), POST /v1/customers (anlegen) und POST /v1/customers/:id (aendern:
* email, name, address, metadata; unbekannte ID -> 404). Wie Stripe fuehrt ein Update die Anschrift feldweise zusammen
* (nur gesendete Felder aendern sich) und ein leerer Text loescht das Feld (gespeichert als null).
*
* Connect (Webshop D1): die Kopfzeile Stripe-Account steht als account in calls (nur bei Aufrufen mit Konto). POST /v1/accounts
* (v1 mit controller; Konto startet unfertig: charges_enabled false, requirements.currently_due ['external_account'];
* gleicher Idempotency-Key liefert dasselbe Konto), GET /v1/accounts/:id (unbekannt -> 404), POST /v1/account_links
* (Konto muss existieren). st.setAccount(id, patch) legt ein Konto an oder aendert es (requirements wird feldweise gemischt).
* Aufrufe mit Stripe-Account arbeiten auf der Ablage dieses Kontos (st.accountSessions(id), st.refunds(id)); ohne Kopfzeile
* bleibt alles wie bisher in st.checkoutSessions. Dort: POST /v1/checkout/sessions, GET .../:id, POST .../:id/expire (nur open),
* POST /v1/refunds (amount fehlt = Rest der Zahlung; Zahlungen legt st.setPaymentIntent(acct, id, { amount }) an; Summe der
* Erstattungen darf die Zahlung nicht uebersteigen) und GET /v1/refunds/:id. Idempotency-Key wiederholt die erste Antwort
* (je Konto und Schluessel) fuer Sitzungen und Erstattungen.
*/
// Parameter, die Stripe bei ausstehenden Aktualisierungen annimmt (docs.stripe.com/billing/subscriptions/
@@ -54,6 +64,16 @@ export async function startFakeStripe() {
// Rechnungsposten ohne Rechnung (pending invoice items), je ID
const invoiceItems = new Map()
const checkoutSessions = new Map()
// Connect: Konten, deren Sitzungen, Zahlungen und Erstattungen (je Konto-ID), Antworten je Idempotency-Key
const accounts = new Map()
const accountSessionMaps = new Map()
const paymentIntentMaps = new Map()
const refundMaps = new Map()
const idempotentReplies = new Map()
const bucket = (maps, accountId) => {
if (!maps.has(accountId)) maps.set(accountId, new Map())
return maps.get(accountId)
}
// Abo-Aenderungen, die Stripe bis zur Zahlung zurueckhaelt (pending_update), je Abo-ID
const pendingUpdates = new Map()
const calls = []
@@ -166,8 +186,117 @@ export async function startFakeStripe() {
return merged
}
const route = (method, path, query, body) => {
const route = (method, path, query, body, account = null) => {
let m
// ---- Connect ----
if (method === 'POST' && path === '/v1/accounts') {
const data = unflatten(body)
const id = `acct_test${(seq += 1)}`
const obj = {
id,
object: 'account',
type: 'standard',
country: data.country || 'DE',
email: data.email ?? null,
business_profile: data.business_profile || {},
controller: { type: 'application', is_controller: false, ...(data.controller || {}) },
capabilities: Object.fromEntries(Object.keys(data.capabilities || {}).map((k) => [k, 'inactive'])),
metadata: data.metadata || {},
charges_enabled: false,
payouts_enabled: false,
details_submitted: false,
requirements: { currently_due: ['external_account'], eventually_due: ['external_account'], past_due: [], errors: [], disabled_reason: null },
}
accounts.set(id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/accounts\/([^/]+)$/))) {
return accounts.has(m[1]) ? [200, accounts.get(m[1])] : noSuch('account', m[1])
}
if (method === 'POST' && path === '/v1/account_links') {
const data = unflatten(body)
if (!accounts.has(data.account)) return noSuch('account', data.account)
if (!data.type) return invalid('type', 'Missing required param: type.')
return [200, {
object: 'account_link',
created: nowUnix(),
expires_at: nowUnix() + 300,
url: `https://connect.stripe.test/setup/${data.account}/${(seq += 1)}`,
}]
}
if (account) {
const sessions = bucket(accountSessionMaps, account)
if (method === 'POST' && path === '/v1/checkout/sessions') {
const data = unflatten(body)
const id = nextId('cs')
const obj = {
id,
object: 'checkout.session',
url: `https://checkout.stripe.test/c/pay/${id}`,
mode: data.mode || 'payment',
client_reference_id: data.client_reference_id ?? null,
success_url: data.success_url ?? null,
cancel_url: data.cancel_url ?? null,
expires_at: data.expires_at ? Number(data.expires_at) : nowUnix() + 86400,
metadata: data.metadata || {},
status: 'open',
payment_status: 'unpaid',
payment_intent: null,
amount_total: null,
currency: 'eur',
customer_details: null,
}
sessions.set(id, obj)
return [200, obj]
}
if (method === 'GET' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)$/))) {
return sessions.has(m[1]) ? [200, sessions.get(m[1])] : noSuch('checkout.session', m[1])
}
if (method === 'POST' && (m = path.match(/^\/v1\/checkout\/sessions\/([^/]+)\/expire$/))) {
const found = sessions.get(m[1])
if (!found) return noSuch('checkout.session', m[1])
if (found.status !== 'open') return invalid(undefined, 'Only Checkout Sessions with a status in open can be expired.')
const expired = { ...found, status: 'expired' }
sessions.set(found.id, expired)
return [200, expired]
}
if (method === 'POST' && path === '/v1/refunds') {
const data = unflatten(body)
const intents = bucket(paymentIntentMaps, account)
const refunds = bucket(refundMaps, account)
const intent = intents.get(data.payment_intent)
if (!data.payment_intent) return invalid('payment_intent', 'Either payment_intent or charge must be provided.')
if (!intent && !data.amount) return noSuch('payment_intent', data.payment_intent)
const already = [...refunds.values()].filter((r) => r.payment_intent === data.payment_intent).reduce((sum, r) => sum + r.amount, 0)
const rest = intent ? intent.amount - already : null
const amount = data.amount ? Number(data.amount) : rest
if (!Number.isInteger(amount) || amount <= 0) return invalid('amount', 'Invalid positive integer')
if (rest !== null && amount > rest) return invalid('amount', `Refund amount (${amount}) is greater than unrefunded amount on payment (${rest}).`)
const obj = {
id: nextId('re'),
object: 'refund',
amount,
currency: 'eur',
payment_intent: data.payment_intent,
reason: data.reason ?? null,
metadata: data.metadata || {},
status: 'succeeded',
created: nowUnix(),
}
refunds.set(obj.id, obj)
return [200, obj]
}
if (method === 'GET' && path === '/v1/refunds') {
const pi = query.get('payment_intent')
const items = [...bucket(refundMaps, account).values()].filter((r) => !pi || r.payment_intent === pi)
return [200, { object: 'list', data: items, has_more: false, url: '/v1/refunds' }]
}
if (method === 'GET' && (m = path.match(/^\/v1\/refunds\/([^/]+)$/))) {
const found = bucket(refundMaps, account).get(m[1])
return found ? [200, found] : noSuch('refund', m[1])
}
}
// ---- Plattform ----
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
}
@@ -626,7 +755,9 @@ export async function startFakeStripe() {
const body = await readBody(req)
// idempotencyKey (Kopfzeile Idempotency-Key) steht nur an Aufrufen, die einen mitschicken
const idempotencyKey = req.headers['idempotency-key']
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}) })
// Connect: Kopfzeile Stripe-Account (Aufruf im Namen eines Haendlerkontos)
const account = req.headers['stripe-account']
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body), ...(idempotencyKey ? { idempotencyKey } : {}), ...(account ? { account } : {}) })
const delay = plannedDelay(req.method, url.pathname)
if (delay) await new Promise((resolve) => setTimeout(resolve, delay))
const failure = plannedFailure(req.method, url.pathname)
@@ -640,7 +771,10 @@ export async function startFakeStripe() {
res.writeHead(200, { 'Content-Type': 'application/pdf' })
return res.end(`%PDF-1.4\n% Stripe-Nachbau ${pdfMatch[1]}\n%%EOF\n`)
}
const [status, payload] = failure || route(req.method, url.pathname, url.searchParams, body)
// Wiederholung mit gleichem Idempotency-Key (nur Connect-Aufrufe und Kontoanlage): erste Antwort, kein neuer Zustand
const replyKey = !failure && req.method === 'POST' && idempotencyKey && (account || url.pathname === '/v1/accounts') ? `${account || ''}|${idempotencyKey}` : null
const [status, payload] = failure || (replyKey && idempotentReplies.get(replyKey)) || route(req.method, url.pathname, url.searchParams, body, account || null)
if (replyKey && status === 200 && !idempotentReplies.has(replyKey)) idempotentReplies.set(replyKey, [status, payload])
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
})
@@ -659,6 +793,52 @@ export async function startFakeStripe() {
payouts,
invoiceItems,
checkoutSessions,
accounts,
/** Sitzungen eines Haendlerkontos (Map id -> Sitzung); Tests setzen status/payment_status/payment_intent direkt darin. */
accountSessions: (accountId) => bucket(accountSessionMaps, accountId),
/** Erstattungen eines Haendlerkontos (Map id -> Erstattung). */
refunds: (accountId) => bucket(refundMaps, accountId),
/** Konto anlegen oder aendern (z. B. { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }). */
setAccount(id, patch = {}) {
const base = accounts.get(id) || {
id, object: 'account', type: 'standard', country: 'DE', email: null, business_profile: {}, metadata: {},
controller: { type: 'application', is_controller: false }, capabilities: {},
charges_enabled: false, payouts_enabled: false, details_submitted: false,
requirements: { currently_due: ['external_account'], eventually_due: [], past_due: [], errors: [], disabled_reason: null },
}
const next = { ...base, ...patch, requirements: { ...base.requirements, ...(patch.requirements || {}) } }
accounts.set(id, next)
return next
},
/**
* Sitzung eines Haendlerkontos als abgeschlossen markieren (status complete) und die Felder setzen, die Stripe nach der Kasse
* liefert: completeSession('acct_x', 'cs_x', { payment_status: 'paid', amount_total, customer_details, shipping, payment_intent,
* payment_method_types }). shipping steht wie bei Stripe unter collected_information.shipping_details und shipping_details.
* Gibt die neue Sitzung zurueck (so schickt der Webhook sie).
*/
completeSession(accountId, id, { payment_status = 'paid', amount_total = null, customer_details = null, shipping = null, payment_intent = null, payment_method_types } = {}) {
const sessions = bucket(accountSessionMaps, accountId)
const found = sessions.get(id)
if (!found) throw new Error(`Stripe-Nachbau: keine Sitzung ${id} bei ${accountId}`)
const next = {
...found,
status: 'complete',
payment_status,
amount_total,
customer_details,
payment_intent,
...(payment_method_types ? { payment_method_types } : {}),
...(shipping ? { collected_information: { shipping_details: shipping }, shipping_details: shipping } : {}),
}
sessions.set(id, next)
return next
},
/** Zahlung eines Haendlerkontos (Betrag in Cent) fuer Erstattungen: setPaymentIntent('acct_x', 'pi_x', { amount: 2500 }). */
setPaymentIntent(accountId, id, { amount, currency = 'eur' }) {
const obj = { id, object: 'payment_intent', amount, currency }
bucket(paymentIntentMaps, accountId).set(id, obj)
return obj
},
/**
* Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. latest_invoice). Mit
* pending_update bleibt das Abo wie bei Stripe unveraendert (und traegt pending_update), bis
+3
View File
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { TERMINE_COLLECTIONS } from '../../server/services/termine/schema.js'
import { SHOP_COLLECTIONS } from '../../server/services/shop/schema.js'
import { ZEITEN_COLLECTIONS, ZEITEN_FEATURE_ATTRIBUTES } from '../../server/services/zeiten/schema.js'
import { FEATURE_CONSENT_ATTRIBUTES, PORTAL_FEATURE_ATTRIBUTES } from '../../scripts/feature-setup.mjs'
@@ -11,6 +12,7 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
* scripts/setup-finanzen.mjs in Appwrite anlegen. Tests geben sie dem Appwrite-Nachbau als Schema:
* Schreibt der Code ein Feld, das das Setup-Skript nicht anlegt, scheitert der Test wie Appwrite in
* Produktion. Die Termine-Collections kommen aus server/services/termine/schema.js (scripts/termine-setup.mjs),
* die Shop-Collections aus server/services/shop/schema.js (scripts/shop-setup.mjs),
* die Zeiten-Collections aus server/services/zeiten/schema.js (scripts/zeiten-setup.mjs);
* portalFeatures bekommt zusaetzlich die Attribute aus scripts/feature-setup.mjs (PORTAL_FEATURE_ATTRIBUTES) und die
* Zeiten-Felder aus server/services/zeiten/schema.js (ZEITEN_FEATURE_ATTRIBUTES, angelegt von scripts/zeiten-setup.mjs);
@@ -18,6 +20,7 @@ const SETUP_SCRIPTS = ['setup-abo-collections.mjs', 'setup-finanzen.mjs']
*/
export function schemaKeys(collection) {
if (Object.hasOwn(TERMINE_COLLECTIONS, collection)) return TERMINE_COLLECTIONS[collection].attributes.map((a) => a.key)
if (Object.hasOwn(SHOP_COLLECTIONS, collection)) return SHOP_COLLECTIONS[collection].attributes.map((a) => a.key)
if (Object.hasOwn(ZEITEN_COLLECTIONS, collection)) return ZEITEN_COLLECTIONS[collection].attributes.map((a) => a.key)
if (collection === 'featureConsents') return FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key)
for (const script of SETUP_SCRIPTS) {