Files
Webklar-Kundenbereich/server/services/contractEvents.js
T

143 lines
5.4 KiB
JavaScript

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, 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)
}