Zahlungseingang: markPaid meldet den Fall und merkt ihn im Dokument

markPaid liefert jetzt { doc, created, fall }. Trifft eine Zahlung ein Dokument, das nicht
mehr auf die Bezahlung wartet (active, disabled, setup mit Ticket), wird sie einmalig
verbucht (purchaseStatus paid, unlockedByPurchase, Rechnung, Notiz) und der noch zu
meldende Fall im eigenen Feld zahlungMeldung vermerkt (bereits_aktiv | abgeschaltet |
abo_ende), in einem einzigen Schreibzugriff. Ein Folgeereignis bucht nichts doppelt und
liefert den Fall erneut, bis zahlungMeldung auf gemeldet steht. ordered und fortsetzbares
setup laufen wie bisher, off bleibt unveraendert (Uebernahme ueber adoptPaidSession).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 13:13:42 +00:00
1 parent 992d7358fa
commit 468df49812
2 files changed
+202 -4

No files matched your search

+54 -4
View File
@@ -269,19 +269,68 @@ async function findBySession(sessionId) {
return doc || null
}
/** Faelle einer Zahlung, die kein bestellbares Dokument trifft (portalFeatures.zahlungMeldung); 'gemeldet' setzt erst die Meldung (B10b). */
const ZAHLUNG_FAELLE = ['bereits_aktiv', 'abgeschaltet', 'abo_ende']
const NOTE_MAX = 300
/** Fall einer Zahlung auf ein Dokument im Zustand active, disabled oder setup mit Ticket. */
function zahlungsFall(doc) {
if (doc.state !== 'disabled') return 'bereits_aktiv'
return doc.disabledReason === 'abo_ende_vor_einrichtung' ? 'abo_ende' : 'abgeschaltet'
}
/** Der noch zu meldende Fall am Dokument (zahlungMeldung), sonst null; '', 'gemeldet' und alles andere ist kein offener Fall. */
function offenerFall(doc) {
return ZAHLUNG_FAELLE.includes(doc.zahlungMeldung) ? doc.zahlungMeldung : null
}
/** Neuester Eintrag zuerst, hoechstens 300 Zeichen (wie die Notiz im Ticketsystem). */
function notizVorn(previous, entry) {
const neu = entry.slice(0, NOTE_MAX)
const aelter = String(previous || '')
return aelter ? `${neu} | ${aelter}`.slice(0, NOTE_MAX) : neu
}
/**
* Eine Zahlung trifft ein Dokument, das nicht (mehr) auf die Bezahlung wartet: active, disabled oder setup mit Ticket. Sie wird
* einmalig verbucht (purchaseStatus 'paid', Rechnung, Notiz) und der Fall im eigenen Feld zahlungMeldung vermerkt - in einem einzigen
* Schreibzugriff, damit nie eine verbuchte Zahlung ohne Marker bleibt. purchaseStatus bekommt nie einen Meldezustand (die
* Widerrufspruefung liest ihn). Ein Folgeereignis bucht nichts mehr, liefert den Fall aber erneut, solange er nicht gemeldet ist.
* Aufruf nur unter der Sperre der Sitzung.
*/
async function bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl }) {
if (doc.purchaseStatus === 'paid') return { doc, created: false, fall: offenerFall(doc) }
const fall = zahlungsFall(doc)
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
purchaseStatus: 'paid',
unlockedByPurchase: true,
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
adminNote: notizVorn(doc.adminNote, `${new Date().toISOString()} System: Zahlung eingegangen (Sitzung ${sessionId}), nichts eingerichtet`),
zahlungMeldung: fall,
})
return { doc: updated, created: false, fall }
}
/**
* Idempotent und je Session serialisiert: nur der Uebergang aus 'ordered' legt das Ticket an. Erst
* wird auf 'setup' gestellt, dann das Ticket erstellt; scheitert das, bleibt 'setup' ohne ticketId
* (das Portal zeigt "Ticket fehlt") und der Fehler wird weitergereicht. Stripe wiederholt den Webhook:
* 'setup' ohne ticketId gilt dann als fortsetzbar (Ticket anlegen, created:true -> K15/T16 gehen raus).
*
* Liefert { doc, created, fall }. fall ist null, ausser die Zahlung traf ein Dokument, das nicht mehr bestellt ist ('active',
* 'disabled', 'setup' mit Ticket): dann wird sie einmalig verbucht und 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' geliefert,
* auch bei jedem Folgeereignis, bis zahlungMeldung 'gemeldet' ist (siehe bucheSpaeteZahlung). Ein Dokument im Zustand 'off'
* bleibt unveraendert (die Uebernahme macht adoptPaidSession).
*/
export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
if (!sessionId) return { doc: null, created: false }
if (!sessionId) return { doc: null, created: false, fall: null }
return withLocks([`feature-paid:${sessionId}`], async () => {
const doc = await findBySession(sessionId)
if (!doc) return { doc: null, created: false }
if (!doc) return { doc: null, created: false, fall: null }
if (doc.state === 'off') return { doc, created: false, fall: null }
const resumable = doc.state === 'setup' && !doc.ticketId
if (doc.state !== 'ordered' && !resumable) return { doc, created: false }
if (doc.state !== 'ordered' && !resumable) return bucheSpaeteZahlung(doc, { sessionId, invoiceId, invoiceUrl })
const setup = resumable
? doc
@@ -291,6 +340,7 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
unlockedByPurchase: true,
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
zahlungMeldung: '',
})
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
const customer = await customerOf(doc.customerId).catch(() => null)
@@ -304,7 +354,7 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
...texte,
})
const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId })
return { doc: updated, created: true }
return { doc: updated, created: true, fall: null }
})
}