From 7799d7c4de30ad24766fac8e8eaeef14bb9714e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 08:59:30 +0000 Subject: [PATCH] Vertragsereignisse: Nachweise, Widerrufsfristen, Speicher-Aufgaben Co-Authored-By: Claude Opus 5.5 --- scripts/setup-abo-collections.mjs | 36 ++++++++ server/config.js | 1 + server/services/contractEvents.js | 141 ++++++++++++++++++++++++++++++ test/contractEvents.test.js | 140 +++++++++++++++++++++++++++++ 4 files changed, 318 insertions(+) create mode 100644 server/services/contractEvents.js create mode 100644 test/contractEvents.test.js diff --git a/scripts/setup-abo-collections.mjs b/scripts/setup-abo-collections.mjs index 1e0ff0e..1dcdef0 100644 --- a/scripts/setup-abo-collections.mjs +++ b/scripts/setup-abo-collections.mjs @@ -119,6 +119,11 @@ await ensureCollection( { type: 'string', key: 'offerText', size: 300 }, // Plan 1: Produkt des Abos (wartung | hosting) { type: 'string', key: 'productKey', size: 16 }, + // Plan 2 (Hosting-Abo + Zusatzspeicher): Wechseltag (Beginn der neuen Mindestlaufzeit) und bezahlter Speicherstand + { type: 'datetime', key: 'termStartAt' }, + { type: 'integer', key: 'storageUnits' }, + { type: 'integer', key: 'storagePaidUnits' }, + { type: 'datetime', key: 'storagePaidPeriodEnd' }, ], [ { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, @@ -186,6 +191,8 @@ await ensureCollection( 'Abo-Angebote', [ { type: 'string', key: 'customerId', size: 64 }, + // Plan 2: Angebot gilt fuer dieses Produkt (wartung | hosting) + { type: 'string', key: 'productKey', size: 16 }, { type: 'string', key: 'status', size: 20 }, { type: 'string', key: 'scope', size: 16 }, { type: 'integer', key: 'monthlyPriceCents' }, @@ -208,6 +215,35 @@ await ensureCollection( ] ) +// Vertragsereignisse: ein Dokument je Bestellung/Aenderung (Neuabschluss, Wechsel, Zusatzspeicher, +// Kuendigung, Widerruf) mit den Nachweisen der Zustimmung, der Widerrufsfrist und - bei Zusatzspeicher - +// dem Stand der Aufgabe fuers Team. +await ensureCollection( + 'contractEvents', + 'Vertragsereignisse', + [ + { type: 'string', key: 'customerId', size: 64 }, + { type: 'string', key: 'subscriptionId', size: 64 }, + { type: 'string', key: 'type', size: 16 }, + { type: 'string', key: 'productKey', size: 16 }, + { type: 'string', key: 'details', size: 2000 }, + { type: 'string', key: 'termsVersion', size: 32 }, + { type: 'datetime', key: 'consentAt' }, + { type: 'string', key: 'consentIp', size: 64 }, + { type: 'string', key: 'consentUserAgent', size: 300 }, + { type: 'datetime', key: 'earlyStartConsentAt' }, + { type: 'string', key: 'status', size: 16 }, + { type: 'datetime', key: 'dueAt' }, + { type: 'datetime', key: 'doneAt' }, + { type: 'string', key: 'doneBy', size: 255 }, + { type: 'datetime', key: 'confirmationSentAt' }, + ], + [ + { key: 'idx_customer', type: 'key', attributes: ['customerId'] }, + { key: 'idx_type_status', type: 'key', attributes: ['type', 'status'] }, + ] +) + // Worksheet-Flag: nur markierte Arbeitszeit wird vom Abo-Guthaben abgezogen await ensure('worksheets.countsToPlan', () => aw(`${col('worksheets')}/attributes/boolean`, { diff --git a/server/config.js b/server/config.js index 5538691..98cfc2b 100644 --- a/server/config.js +++ b/server/config.js @@ -94,6 +94,7 @@ export const config = { hourCredits: process.env.APPWRITE_COLLECTION_HOUR_CREDITS || 'hourCredits', takeoverCredits: process.env.APPWRITE_COLLECTION_TAKEOVER_CREDITS || 'takeoverCredits', aboOffers: process.env.APPWRITE_COLLECTION_ABO_OFFERS || 'aboOffers', + contractEvents: process.env.APPWRITE_COLLECTION_CONTRACT_EVENTS || 'contractEvents', }, publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), diff --git a/server/services/contractEvents.js b/server/services/contractEvents.js new file mode 100644 index 0000000..c493a69 --- /dev/null +++ b/server/services/contractEvents.js @@ -0,0 +1,141 @@ +import { config } from '../config.js' +import { createDocument, getDocument, ID, listDocuments, Query, updateDocument } from './appwriteAdmin.js' +import { withdrawalDeadline } from './deadlines.js' + +/** + * Vertragsereignisse: ein Dokument je Bestellung/Aenderung (Neuabschluss, Wechsel, Zusatzspeicher, + * Kuendigung, Widerruf). Jedes Ereignis traegt seinen eigenen Nachweis (Bedingungen-Version, + * Zeitpunkt der Zustimmung, IP, User-Agent), seine eigene Widerrufsfrist (ยง 355 BGB) und - bei + * Zusatzspeicher - den Stand der Aufgabe fuers Team (Postfachgroesse bei Konsolehost anpassen). + * + * Eine Speicher-Verringerung (Minus) hat kein Pflicht-Haekchen und damit keine Zustimmungsdaten + * (Rechtsrunde 28.09.2026): recordContractEvent wird dafuer mit consent: null aufgerufen und + * speichert dann keine consentIp/consentUserAgent. + */ + +const COLLECTION = () => config.collections.contractEvents + +function parseJson(value, fallback) { + try { + return JSON.parse(value || '') ?? fallback + } catch { + return fallback + } +} + +// Ereignistypen, zu denen ueberhaupt eine Widerrufsfrist gehoert (Kuendigung/Widerruf nicht) +const WITHDRAWABLE_TYPES = new Set(['abschluss', 'wechsel', 'speicher']) +// Ereignis ohne (weitere) Wirkung: widerrufen (Kunde) oder gescheitert (Wechsel ohne Zahlung) - +// in beiden Faellen ist keine Frist mehr offen +const CLOSED_STATUSES = new Set(['widerrufen', 'gescheitert']) + +function isWithdrawable(event) { + if (!WITHDRAWABLE_TYPES.has(event.type) || CLOSED_STATUSES.has(event.status)) return false + // Zusatzspeicher: nur eine Erhoehung (Plus) hat ein Widerrufsrecht, eine Verringerung (Minus) nicht + if (event.type === 'speicher') return event.details?.toUnits > event.details?.fromUnits + return true +} + +/** + * Ereignis anlegen (Bestellung/Aenderung). consent hat die Form { termsVersion, consentAt, + * consentIp, consentUserAgent, earlyStartConsentAt? } oder ist null (Speicher-Minus, siehe oben). + * details wird als JSON gespeichert. Mit id: erneutes Speichern derselben id ist kein Fehler + * (Appwrite meldet 409), sondern liefert das schon vorhandene Dokument zurueck (z. B. wenn ein + * Stripe-Webhook zweimal ankommt). + */ +export async function recordContractEvent({ + customerId, + subscriptionId, + type, + productKey, + details = {}, + consent = null, + status = 'erledigt', + dueAt = null, + id = null, +}) { + const documentId = id || ID.unique() + const data = { + customerId, + subscriptionId, + type, + productKey, + details: JSON.stringify(details), + status, + dueAt, + termsVersion: consent?.termsVersion || null, + consentAt: consent?.consentAt || null, + consentIp: consent?.consentIp || null, + consentUserAgent: consent?.consentUserAgent || null, + earlyStartConsentAt: consent?.earlyStartConsentAt || null, + } + try { + return await createDocument(COLLECTION(), data, documentId) + } catch (err) { + // Schon gespeichert (gleiche id, z. B. Webhook-Wiederholung): das vorhandene Dokument gilt als Nachweis + if (err.status === 409) return getDocument(COLLECTION(), documentId) + throw err + } +} + +/** Ereignisse eines Kunden, neueste zuerst, mit geparstem details. */ +export async function listContractEvents(customerId) { + const docs = await listDocuments(COLLECTION(), [ + Query.equal('customerId', customerId), + Query.orderDesc('$createdAt'), + Query.limit(1000), + ]) + return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) })) +} + +/** + * Offene Widerrufsfristen je Bestellung (Neuabschluss, Wechsel, Speicher-Plus). events kommen aus + * listContractEvents (details schon geparst). Die Frist beginnt mit dem Vertragsschluss bei Stripe + * (details.contractAt); solange dieses Feld fehlt (Altbestand vor Aufgabe 7), zaehlt ersatzweise + * der Zeitpunkt der Zustimmung im Portal (consentAt). + */ +export function openWithdrawals(events, now = new Date()) { + return events + .filter(isWithdrawable) + .map((event) => ({ + type: event.type, + eventId: event.$id, + until: withdrawalDeadline(event.details?.contractAt ?? event.consentAt).endsAt, + })) + .filter((entry) => now <= entry.until) +} + +/** Offene Zusatzspeicher-Aufgaben fuers Team, faelligste zuerst. */ +export async function listOpenStorageTasks() { + return listDocuments(COLLECTION(), [ + Query.equal('type', 'speicher'), + Query.equal('status', 'offen'), + Query.orderAsc('dueAt'), + Query.limit(1000), + ]) +} + +/** Zusatzspeicher-Aufgabe erledigt (Team hat die Postfachgroesse angepasst). */ +export async function completeContractEvent(id, staffEmail) { + return updateDocument(COLLECTION(), id, { + status: 'erledigt', + doneAt: new Date().toISOString(), + doneBy: staffEmail, + }) +} + +/** + * Ereignis aendern (z. B. status, doneAt/doneBy oder weitere Felder). patch.details wird mit den + * schon gespeicherten details zusammengefuehrt statt sie zu ersetzen, damit z. B. eine spaeter + * bekannte paymentUrl oder ein Ablehnungsgrund ergaenzt werden kann, ohne fromUnits/toUnits (oder + * andere schon gespeicherte Angaben) zu verlieren. + */ +export async function updateContractEvent(id, patch = {}) { + const { details, ...rest } = patch + const data = { ...rest } + if (details !== undefined) { + const existing = await getDocument(COLLECTION(), id) + data.details = JSON.stringify({ ...parseJson(existing.details, {}), ...details }) + } + return updateDocument(COLLECTION(), id, data) +} diff --git a/test/contractEvents.test.js b/test/contractEvents.test.js new file mode 100644 index 0000000..22de1d8 --- /dev/null +++ b/test/contractEvents.test.js @@ -0,0 +1,140 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +const aw = await startAppwriteMock({ + schema: { contractEvents: schemaKeys('contractEvents'), subscriptions: schemaKeys('subscriptions') }, +}) +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { + recordContractEvent, + listContractEvents, + openWithdrawals, + listOpenStorageTasks, + completeContractEvent, + updateContractEvent, +} = await import('../server/services/contractEvents.js') + +test('Ereignis speichern und lesen (details als JSON)', async () => { + await recordContractEvent({ + customerId: 'c1', + subscriptionId: 'sub_1', + type: 'speicher', + productKey: 'hosting', + details: { fromUnits: 0, toUnits: 2 }, + consent: { + termsVersion: '2026-10-05-v4', + consentAt: '2026-10-05T10:00:00.000Z', + consentIp: '1.2.3.4', + consentUserAgent: 'UA', + }, + status: 'offen', + dueAt: '2026-10-05T10:00:00.000Z', + }) + const [e] = await listContractEvents('c1') + assert.deepEqual(e.details, { fromUnits: 0, toUnits: 2 }) + assert.equal((await listOpenStorageTasks()).length, 1) +}) + +test('Widerrufsfristen je Bestellung', () => { + const ev = (type, consentAt, details = {}, status = 'erledigt') => ({ $id: type, type, consentAt, details, status }) + const now = new Date('2026-10-10T12:00:00Z') + const open = openWithdrawals( + [ + ev('abschluss', '2026-09-01T10:00:00Z'), + ev('wechsel', '2026-10-05T10:00:00Z'), + ev('speicher', '2026-10-06T10:00:00Z', { fromUnits: 2, toUnits: 1 }), + ev('speicher', '2026-10-07T10:00:00Z', { fromUnits: 0, toUnits: 1 }), + ], + now + ) + assert.deepEqual(open.map((o) => o.type), ['wechsel', 'speicher']) + assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'widerrufen')], now).length, 0) + // F02: ein Wechsel ohne Zahlung (gescheitert) hat wie ein widerrufener kein Widerrufsrecht mehr + assert.equal(openWithdrawals([ev('wechsel', '2026-10-05T10:00:00Z', {}, 'gescheitert')], now).length, 0) +}) + +// F04: die Frist zaehlt ab dem Vertragsschluss bei Stripe (details.contractAt), nicht ab dem fruehereren +// Portal-Klick (consentAt). Waere hier consentAt massgeblich, waere die Frist laengst abgelaufen. +test('Widerrufsfrist zaehlt ab dem Vertragsschluss (details.contractAt), nicht ab dem Portal-Klick', () => { + const now = new Date('2026-10-10T12:00:00Z') + const ev = { + $id: 'e1', + type: 'abschluss', + status: 'erledigt', + consentAt: '2026-08-01T10:00:00Z', + details: { contractAt: '2026-10-08T10:00:00Z' }, + } + assert.deepEqual(openWithdrawals([ev], now).map((o) => o.eventId), ['e1']) +}) + +test('Aufgabe erledigen', async () => { + const [t] = await listOpenStorageTasks() + const done = await completeContractEvent(t.$id, 'kenso@webklar.com') + assert.equal(done.status, 'erledigt') + assert.equal(done.doneBy, 'kenso@webklar.com') +}) + +// F01: Status setzen und details zusammenfuehren (nicht ersetzen) in einem Aufruf +test('Ereignis aktualisieren: Status setzen, details zusammenfuehren', async () => { + const created = await recordContractEvent({ + customerId: 'c2', + subscriptionId: 'sub_2', + type: 'wechsel', + productKey: 'hosting', + details: { von: 'wartung', zu: 'hosting' }, + status: 'offen', + }) + const updated = await updateContractEvent(created.$id, { + status: 'gescheitert', + details: { grund: 'Zahlung fehlgeschlagen' }, + }) + assert.equal(updated.status, 'gescheitert') + assert.deepEqual(JSON.parse(updated.details), { von: 'wartung', zu: 'hosting', grund: 'Zahlung fehlgeschlagen' }) +}) + +// F05: dieselbe id zweimal speichern (z. B. Webhook-Wiederholung) ist kein Fehler, sondern liefert +// das schon vorhandene Dokument zurueck, ohne ein zweites anzulegen +test('Ereignis mit vorhandener id (409): liefert das schon gespeicherte Dokument', async () => { + const first = await recordContractEvent({ + customerId: 'c3', + subscriptionId: 'sub_3', + type: 'abschluss', + productKey: 'hosting', + details: { contractAt: '2026-10-01T09:00:00.000Z' }, + id: 'abschluss-sub_3', + }) + const again = await recordContractEvent({ + customerId: 'c3', + subscriptionId: 'sub_3', + type: 'abschluss', + productKey: 'hosting', + details: { contractAt: '2026-10-01T09:00:00.000Z' }, + id: 'abschluss-sub_3', + }) + assert.equal(again.$id, 'abschluss-sub_3') + assert.equal(again.$id, first.$id) + assert.equal((await listContractEvents('c3')).length, 1) +}) + +// Rechtsrunde 28.09.2026: eine Speicher-Verringerung hat kein Pflicht-Haekchen und damit keine +// Zustimmungsdaten - recordContractEvent muss mit consent: null funktionieren und darf dann keine +// consentIp/consentUserAgent speichern +test('Speicher-Verringerung ohne Zustimmung (consent: null) speichert keine Zustimmungsdaten', async () => { + const doc = await recordContractEvent({ + customerId: 'c4', + subscriptionId: 'sub_4', + type: 'speicher', + productKey: 'hosting', + details: { fromUnits: 2, toUnits: 1 }, + consent: null, + }) + assert.equal(doc.consentIp ?? null, null) + assert.equal(doc.consentUserAgent ?? null, null) + assert.equal(doc.termsVersion ?? null, null) +})