import { createHash } from 'node:crypto' 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 } // Appwrite-Dokument-IDs: hoechstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, // nicht mit einem Sonderzeichen beginnend (Appwrite 1.8, Validator UID) const DOCUMENT_ID_MAX = 36 const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ export function isValidDocumentId(id) { return typeof id === 'string' && DOCUMENT_ID.test(id) } /** * Feste Ereignis-ID (F05): "-" + sha256 des Schluessels als Hex, insgesamt genau 36 Zeichen * (bei abschluss 26 Hex-Zeichen). Gleicher Schluessel -> gleiche ID, ein Stripe-Webhook, der zweimal * ankommt, legt kein zweites Dokument an. key ist die Stripe-Abo-ID (abschluss: eines je Abo) oder * Abo-ID plus Rechnungs-/Zeitstempel, wenn es mehrere Ereignisse eines Typs je Abo gibt. Die Abo-ID * selbst darf nicht in die ID: "abschluss-sub_..." (sub_ + 24 Zeichen) waeren 38 Zeichen. */ export function eventIdFor(type, key) { if (!/^[a-z]+$/.test(String(type))) throw new Error(`eventIdFor: ungueltiger Typ "${type}"`) if (!key) throw new Error('eventIdFor: Schluessel fehlt') const hashLength = DOCUMENT_ID_MAX - type.length - 1 return `${type}-${createHash('sha256').update(String(key)).digest('hex').slice(0, hashLength)}` } /** * 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 (aus eventIdFor): 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). Eine id, die Appwrite nicht annimmt, wird vorher mit * Status 400 abgelehnt statt erst als unklarer Appwrite-Fehler aufzufallen. */ export async function recordContractEvent({ customerId, subscriptionId, type, productKey, details = {}, consent = null, status = 'erledigt', dueAt = null, id = null, }) { if (id !== null && !isValidDocumentId(id)) { const err = new Error( `Ungueltige Ereignis-ID "${id}": Appwrite erlaubt hoechstens ${DOCUMENT_ID_MAX} Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, nicht mit Sonderzeichen beginnend (eventIdFor benutzen)` ) err.status = 400 throw err } 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, mit geparstem details. */ export async function listOpenStorageTasks() { const docs = await listDocuments(COLLECTION(), [ Query.equal('type', 'speicher'), Query.equal('status', 'offen'), Query.orderAsc('dueAt'), Query.limit(1000), ]) return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) })) } /** 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) }