175 lines
7.1 KiB
JavaScript
175 lines
7.1 KiB
JavaScript
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): "<typ>-" + 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)
|
|
}
|