Abo-API: Nachweis-ID nach Appwrite-Regeln, Webhook bei dauerhaften Fehlern, Tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-28 10:19:05 +00:00
1 parent 7df0b72019
commit 887e1e54a0
10 files changed
+409 -107

No files matched your search

+35 -3
View File
@@ -1,3 +1,4 @@
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'
@@ -36,12 +37,36 @@ function isWithdrawable(event) {
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: 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).
* 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,
@@ -54,6 +79,13 @@ export async function recordContractEvent({
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,