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:
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;"> </td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
+19
-6
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user