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

@@ -8,7 +8,7 @@
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{email}}, {{kunde_name}}, {{appwrite_id}}, {{stripe_abo_id}}, {{angebot_text}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, angebot_fehler
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): angebot, angebot_fehler, nachweis_fehlt
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -44,6 +44,10 @@ a{color:#111111;}
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Das Angebot konnte nicht automatisch angewendet werden. Bitte in Stripe prüfen und den Rabatt von Hand am Abo hinterlegen.</div></td></tr></table></td></tr>
<!-- ENDE angebot_fehler -->
<!-- WENN nachweis_fehlt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Nachweis nicht gespeichert</div><div class="mut" style="color:#4a4843;">Das Vertragsereignis „Abschluss“ (Nachweis der Zustimmung, Beginn der Widerrufsfrist) konnte nicht in Appwrite gespeichert werden. Bitte prüfen, ob die Collection contractEvents mit allen Attributen angelegt ist (scripts/setup-abo-collections.mjs), und den Nachweis zu diesem Abo nachtragen. Vertragsbestätigung und Angebot sind davon nicht betroffen.</div></td></tr></table></td></tr>
<!-- ENDE nachweis_fehlt -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
+19 -6
View File
@@ -5,7 +5,7 @@ import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
import { listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js'
import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js'
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
import { getLedger } from '../services/hourLedger.js'
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
@@ -324,11 +324,12 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
* Nachweis des Neuabschlusses als Vertragsereignis. Der Vertrag kommt mit dem Abschluss bei Stripe
* zustande, die Widerrufsfrist zaehlt ab dann (details.contractAt = startedAt, F04); die Zustimmung
* (Fassung, Zeitpunkt, IP, Browser) kommt aus den Abo-Metadaten, die der Spiegel traegt. Feste
* Dokument-ID je Abo: eine Webhook-Wiederholung legt kein zweites Ereignis an (F05).
* Dokument-ID je Abo (eventIdFor, 36 Zeichen): eine Webhook-Wiederholung legt kein zweites
* Ereignis an (F05).
*/
async function recordAbschluss(sub, session) {
return recordContractEvent({
id: `abschluss-${sub.$id}`,
id: eventIdFor('abschluss', sub.$id),
customerId: sub.customerId,
subscriptionId: sub.$id,
type: 'abschluss',
@@ -357,8 +358,20 @@ async function handleStripeEvent(event) {
case 'checkout.session.completed': {
if (obj.mode !== 'subscription' || !obj.subscription) return
const sub = await syncSubscription(obj.subscription)
// Ein Fehler hier (etwa Collection fehlt) gibt 500 -> Stripe wiederholt, das Ereignis wird nachgeholt
if (sub) await recordAbschluss(sub, obj)
// Nachweis: voruebergehend nicht speicherbar (Appwrite nicht erreichbar, 5xx) -> 500, Stripe
// wiederholt und der Nachweis kommt nach. Dauerhaft nicht speicherbar (4xx, etwa Collection
// oder Attribut fehlt) -> Angebot, T02 und K01 laufen trotzdem, T02 sagt es dem Team; eine
// Wiederholung wuerde nichts aendern, der Kunde soll seine Bestaetigung nicht warten lassen.
let proofProblem = false
if (sub) {
try {
await recordAbschluss(sub, obj)
} catch (err) {
if (!err.status || err.status >= 500) throw err
console.error(`[abo] Nachweis des Abschlusses ${obj.subscription} nicht gespeichert (${err.status}):`, err.message)
proofProblem = true
}
}
const offerProblem = await activateOfferFromCheckout(obj).then(
() => false,
(e) => {
@@ -375,7 +388,7 @@ async function handleStripeEvent(event) {
stripe_abo_id: obj.subscription,
angebot_text: obj.metadata?.offerText || '',
},
blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem },
blocks: { angebot: Boolean(obj.metadata?.offerText), angebot_fehler: offerProblem, nachweis_fehlt: proofProblem },
})
await sendContractConfirmation(sub, obj.customer_details?.name).catch((e) =>
console.error('[abo] Vertragsbestaetigung fehlgeschlagen:', e.message)
+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,