Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)
onFeaturePaid wertet den Fall aus markPaid aus. Trifft die Zahlung eine Funktion, die schon freigeschaltet, in Einrichtung oder abgeschaltet ist, geht nur eine Team-Meldung raus (T16 Block bereits_aktiv, bei Abo-Ende vor der Einrichtung T19), kein Ticket und keine K15. Beide Meldungen laufen genau einmal je Dokument: unter feature-meldung:<id> frisch lesen, Marker zahlungMeldung gemeldet erst nach gesendeter Mail. Scheitert der Versand, wirft onFeaturePaid (Webhook 500), Stripe wiederholt und der naechste Versuch sendet genau einmal. meldeErstattung ist die einzige Stelle fuer T19 (Zahlung nach dem Abo-Ende und spaeter die Meldung des Abo-Abgleichs), wirft nie. Bei Funktionen mit Wartungs-Abo-Pflicht zeigt T16 nach der Verbuchung den Block kein_abo, wenn das Abo nicht mehr laeuft; ein Lesefehler laesst den Block aus und blockiert nichts. customerOf und projectName sind exportiert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
468df49812
commit
f4754f573a
3 files changed
+817
-11
No files matched your search
+135
-10
@@ -6,6 +6,7 @@ import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
|
||||
import { anleitungsLink, featureConfig, featureKeys, ticketTexte } from './featureRegistry.js'
|
||||
import { zustimmungFuerSitzung } from './featureConsents.js'
|
||||
import { laeuft } from './aboStatus.js'
|
||||
import { createFeatureTicket } from './ticketAdmin.js'
|
||||
|
||||
export const FEATURE_KEYS = featureKeys()
|
||||
@@ -587,7 +588,8 @@ export function isFeatureCheckoutEvent(event) {
|
||||
return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type)
|
||||
}
|
||||
|
||||
async function projectName(projectId) {
|
||||
/** Projektname zur ID; ein unbekanntes Projekt ergibt ''. */
|
||||
export async function projectName(projectId) {
|
||||
try {
|
||||
return (await getDocument(config.collections.websiteProjects, projectId)).projectName || ''
|
||||
} catch (err) {
|
||||
@@ -596,7 +598,8 @@ async function projectName(projectId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function customerOf(customerId) {
|
||||
/** Kundendokument zur ID; ein unbekannter Kunde ergibt null. */
|
||||
export async function customerOf(customerId) {
|
||||
try {
|
||||
return await getDocument(config.collections.customers, customerId)
|
||||
} catch (err) {
|
||||
@@ -624,8 +627,8 @@ async function ticketWoidOf(doc) {
|
||||
|
||||
/**
|
||||
* T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus.
|
||||
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". kein_abo, bereits_aktiv und gesundheit sind in der Vorlage
|
||||
* deklariert und bleiben hier aus (B10a und D4 fuellen sie).
|
||||
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der
|
||||
* Block gesundheit bleibt hier aus (D4 fuellt ihn).
|
||||
*/
|
||||
function sendT16({ funktion, ...werte }, bloecke) {
|
||||
return sendAdminNotice({
|
||||
@@ -647,7 +650,8 @@ function sendT16({ funktion, ...werte }, bloecke) {
|
||||
})
|
||||
}
|
||||
|
||||
async function teamNotice(doc, customer, { failed = false } = {}) {
|
||||
/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */
|
||||
async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) {
|
||||
const mails = featureConfig(doc.featureKey)?.mails
|
||||
// Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde
|
||||
const anleitung = Boolean(mails?.anleitung) && !failed
|
||||
@@ -661,7 +665,7 @@ async function teamNotice(doc, customer, { failed = false } = {}) {
|
||||
sitekey: failed ? '' : doc.siteKey || '',
|
||||
anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
|
||||
},
|
||||
{ zahlung_fehlgeschlagen: failed, anleitung }
|
||||
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -707,15 +711,127 @@ async function adoptPaidSession(session) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldung zu einer Zahlung genau einmal (T16 bereits_aktiv, T19): unter feature-meldung:<docId> das Dokument frisch lesen;
|
||||
* zahlungMeldung === 'gemeldet' => { sent: false, schonGemeldet: true } ohne Versand. Sonst senden(frisch) (liefert { sent }) und
|
||||
* erst bei sent === true den Marker 'gemeldet' schreiben (auch wenn dort vorher ein offener Fall stand). Wirft nie: jeder Fehler
|
||||
* (Lesen, Aufbau, Versand) ergibt { sent: false } samt Log, der Aufrufer wiederholt. Scheitert nur das Schreiben des Markers, nachdem
|
||||
* die Mail raus ist, bleibt es bei { sent: true } (Log): die Spur ist da, eine Wiederholung durch den Aufrufer waere ein Doppel.
|
||||
* Nie innerhalb einer feature-paid:*-Sperre aufrufen (withLocks ist nicht wiederbetretbar).
|
||||
*/
|
||||
async function meldeEinmal(doc, was, senden) {
|
||||
try {
|
||||
return await withLocks([`feature-meldung:${doc.$id}`], async () => {
|
||||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (frisch.zahlungMeldung === 'gemeldet') return { sent: false, schonGemeldet: true }
|
||||
const { sent } = await senden(frisch)
|
||||
if (!sent) return { sent: false }
|
||||
try {
|
||||
await updateDocument(config.collections.portalFeatures, frisch.$id, { zahlungMeldung: 'gemeldet' })
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] ${was} zur Sitzung ${frisch.stripeSessionId} gesendet, aber der Marker gemeldet blieb ungeschrieben:`, err.message)
|
||||
}
|
||||
return { sent: true }
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] ${was} zur Sitzung ${doc.stripeSessionId} nicht gesendet:`, err.message)
|
||||
return { sent: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Kunde, Projekt und Funktionsname fuer die Team-Meldungen; was sich nicht lesen laesst, faellt auf IDs zurueck (die Meldung geht trotzdem raus). */
|
||||
async function meldeKopf(doc, customer) {
|
||||
const kunde = customer ?? (await customerOf(doc.customerId).catch(() => null))
|
||||
return {
|
||||
kunde_name: kunde?.name || kunde?.email || doc.customerId,
|
||||
email: kunde?.email || '',
|
||||
projekt: (await projectName(doc.projectId).catch(() => '')) || doc.projectId,
|
||||
funktion: featureConfig(doc.featureKey)?.mails.funktion,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlung auf eine Funktion, die schon freigeschaltet, in Einrichtung oder abgeschaltet war (markPaid-Fall 'bereits_aktiv' |
|
||||
* 'abgeschaltet'): T16 nur mit Block bereits_aktiv (zustand "freigeschaltet", "in Einrichtung" bzw. "abgeschaltet", woid leer), keine
|
||||
* K15, kein Ticket. Genau einmal je Dokument (siehe meldeEinmal).
|
||||
* @param {object} doc portalFeatures-Dokument
|
||||
* @param {'bereits_aktiv'|'abgeschaltet'} fall
|
||||
* @param {{ customer?: object|null }} [options] bereits gelesener Kunde, sonst wird er gelesen
|
||||
* @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>}
|
||||
*/
|
||||
export function meldeZahlungFall(doc, fall, { customer = null } = {}) {
|
||||
return meldeEinmal(doc, 'T16 bereits_aktiv', async (frisch) => {
|
||||
const zustand = fall === 'abgeschaltet' ? 'abgeschaltet' : frisch.state === 'setup' ? 'in Einrichtung' : 'freigeschaltet'
|
||||
return sendT16({ ...(await meldeKopf(frisch, customer)), sitzung: frisch.stripeSessionId || '', zustand }, { bereits_aktiv: true })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* T19 "Erstattung faellig" an das Team: die einzige Stelle, die T19 sendet (Zahlung nach dem Abo-Ende hier, die Meldung des
|
||||
* Abo-Abgleichs fuer bezahlte, abgeschaltete Einrichtungen in Block E). Blocks: zahlung_nach_ende, wenn nachAboEnde gesetzt ist oder
|
||||
* am (frisch gelesenen) Dokument der offene Fall 'abo_ende' steht - so auch, wenn der Abgleich vor dem Webhook-Versand zuerst meldet;
|
||||
* ticket_offen, wenn das Dokument ein Ticket hat (kein Ticket und keine spaete Zahlung: keiner von beiden); rechnung mit Rechnungslink.
|
||||
* Genau einmal je Dokument (siehe meldeEinmal), wirft nie.
|
||||
* @param {object} doc portalFeatures-Dokument
|
||||
* @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean }} [options]
|
||||
* @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>}
|
||||
*/
|
||||
export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) {
|
||||
return meldeEinmal(doc, 'T19 Erstattung faellig', async (frisch) => {
|
||||
const kopf = await meldeKopf(frisch, customer)
|
||||
const woid = frisch.ticketId ? await ticketWoidOf(frisch).catch(() => frisch.ticketId) : ''
|
||||
return sendAdminNotice({
|
||||
template: 'T19',
|
||||
values: {
|
||||
...kopf,
|
||||
funktion: kopf.funktion || 'Funktion',
|
||||
betrag: featureConfig(frisch.featureKey)?.priceLabel || '',
|
||||
sitzung: sitzung || '',
|
||||
woid,
|
||||
rechnung_url: frisch.invoiceUrl || '',
|
||||
},
|
||||
blocks: {
|
||||
zahlung_nach_ende: nachAboEnde || frisch.zahlungMeldung === 'abo_ende',
|
||||
ticket_offen: Boolean(frisch.ticketId),
|
||||
rechnung: Boolean(frisch.invoiceUrl),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zahlung traf ein Dokument, das nicht mehr bestellt war (markPaid-Fall): gebucht ist sie schon, hier geht die Meldung ans Team -
|
||||
* 'abo_ende' als T19 (Erstattung), 'bereits_aktiv' und 'abgeschaltet' als T16. Kein ordentliches Ticket, keine K15.
|
||||
* Wirft, wenn die Meldung weder raus ist noch schon draussen war: der Webhook antwortet 500, Stripe wiederholt, markPaid liefert den
|
||||
* noch offenen Fall erneut und der naechste Versuch sendet genau einmal.
|
||||
*/
|
||||
async function meldeFallZurZahlung(doc, fall, session) {
|
||||
console.error(
|
||||
`[funktionen] BEZAHLTE Sitzung ${session.id} traf die Funktion ${doc.featureKey} im Zustand ${doc.state} (Fall ${fall}, Kunde ${doc.customerId}, Projekt ${doc.projectId}) - nichts eingerichtet, das Team wird benachrichtigt`
|
||||
)
|
||||
const customer = await customerOf(doc.customerId).catch((err) => {
|
||||
console.error('[funktionen] Kunde fuer die Zahlungsmeldung nicht lesbar:', err.message)
|
||||
return null
|
||||
})
|
||||
const ergebnis =
|
||||
fall === 'abo_ende'
|
||||
? await meldeErstattung(doc, { customer, nachAboEnde: true })
|
||||
: await meldeZahlungFall(doc, fall, { customer })
|
||||
if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`)
|
||||
}
|
||||
|
||||
async function onFeaturePaid(session) {
|
||||
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
|
||||
const paid = { sessionId: session.id, invoiceId, invoiceUrl }
|
||||
let { doc, created } = await markPaid(paid)
|
||||
let { doc, created, fall } = await markPaid(paid)
|
||||
// Kein Dokument zur Sitzung, oder es fiel auf 'off' (Ablauf/SEPA-Fehler) und die Zahlung kam doch noch an
|
||||
if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created } = await markPaid(paid))
|
||||
if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created, fall } = await markPaid(paid))
|
||||
// Das Dokument wartet nicht mehr auf die Zahlung (aktiv, abgeschaltet, in Einrichtung): nur Meldung, kein Ticket, keine K15
|
||||
if (fall) return meldeFallZurZahlung(doc, fall, session)
|
||||
if (!created) return
|
||||
// Ab hier ist die Bestellung gebucht; ein Wiederholungsversuch von Stripe sieht created:false und
|
||||
// wuerde keine Mail mehr schicken. Darum scheitert jede Mail einzeln und der Webhook antwortet trotzdem 200.
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
let customer = null
|
||||
try {
|
||||
customer = await customerOf(doc.customerId)
|
||||
@@ -724,7 +840,6 @@ async function onFeaturePaid(session) {
|
||||
}
|
||||
if (customer?.email) {
|
||||
try {
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
@@ -734,8 +849,18 @@ async function onFeaturePaid(session) {
|
||||
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
// Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo).
|
||||
// Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500.
|
||||
let keinAbo = false
|
||||
if (feature?.requiresWartungsAbo) {
|
||||
try {
|
||||
keinAbo = !(await laeuft(doc.customerId))
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Wartungs-Abo von Kunde ${doc.customerId} fuer T16 nicht lesbar, Hinweis kein_abo entfaellt:`, err.message)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await teamNotice(doc, customer)
|
||||
await teamNotice(doc, customer, { keinAbo })
|
||||
} catch (err) {
|
||||
console.error('[funktionen] T16 fehlgeschlagen:', err.message)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user