Funktionen/News: Schlussdurchgang (Webhook-Retry, Checkout-Sitzung, Rechnungslink, Widerruf Funktion, UI- und Rechtstext-Korrekturen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-09-30 17:38:15 +00:00
1 parent 0dc57fe5e8
commit 4b7f73abba
22 files changed
+400 -57

No files matched your search

+96 -12
View File
@@ -3,12 +3,15 @@ import { config } from '../config.js'
import { createDocument, getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe } from './stripeBilling.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = ['news']
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
export const DISABLED_MESSAGE =
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.'
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'
function httpError(status, message) {
@@ -143,12 +146,64 @@ export async function listCustomerFeatures(customerId) {
return result
}
/**
* Rechnungslink nachtragen: Stripe legt die Rechnung eines Zahlungs-Checkouts erst kurz nach der Zahlung an,
* der Webhook sieht sie oft noch nicht. Best effort - Fehler werden geloggt und nie weitergereicht.
*/
export async function backfillInvoiceLinks(customerId) {
try {
const docs = await listDocuments(config.collections.portalFeatures, [
Query.equal('customerId', customerId),
Query.limit(1000),
])
for (const doc of docs) {
if ((doc.state !== 'setup' && doc.state !== 'active') || doc.invoiceUrl || !doc.stripeSessionId) continue
try {
const session = await retrieveCheckoutSession(doc.stripeSessionId, { expandInvoice: true })
const invoice = session.invoice && typeof session.invoice === 'object' ? session.invoice : null
if (!invoice?.hosted_invoice_url) continue
await updateDocument(config.collections.portalFeatures, doc.$id, {
stripeInvoiceId: invoice.id || doc.stripeInvoiceId || '',
invoiceUrl: invoice.hosted_invoice_url,
})
} catch (err) {
console.warn(`[funktionen] Rechnungslink fuer Sitzung ${doc.stripeSessionId} nicht nachgetragen:`, err.message)
}
}
} catch (err) {
console.warn('[funktionen] Rechnungslinks nicht nachgetragen:', err.message)
}
}
/**
* Bezahlte Funktionen eines Kunden mit Projektname und Ticketnummer (Widerruf der Funktion, Team-Hinweis T09).
* Reine Lesefunktion; 'ordered' (noch nicht bezahlt) und 'off' zaehlen nicht.
*/
export async function paidFeatureOrders(customerId) {
const docs = await listDocuments(config.collections.portalFeatures, [
Query.equal('customerId', customerId),
Query.limit(1000),
])
const paid = docs.filter((d) => ['setup', 'active', 'disabled'].includes(d.state) && d.unlockedByPurchase)
const result = []
for (const doc of paid) {
result.push({
projekt: await projectName(doc.projectId).catch(() => ''),
state: doc.state,
orderedAt: doc.orderedAt || '',
ticketWoid: doc.ticketId ? await ticketWoid(doc.ticketId).catch(() => null) : null,
})
}
return result
}
export async function markOrdered({ customerId, projectId, key, sessionId, consent = {} }) {
assertKey(key)
const doc = await ensureFeatureDoc(customerId, projectId, key)
if (doc.state === 'setup' || doc.state === 'active') {
throw httpError(409, 'Diese Funktion ist bereits bestellt oder freigeschaltet')
}
if (doc.state === 'disabled') throw httpError(409, DISABLED_MESSAGE)
return updateDocument(config.collections.portalFeatures, doc.$id, {
featureKey: key,
...stateFields('ordered'),
@@ -174,22 +229,26 @@ async function findBySession(sessionId) {
/**
* 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.
* (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).
*/
export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
if (!sessionId) return { doc: null, created: false }
return withLocks([`feature-paid:${sessionId}`], async () => {
const doc = await findBySession(sessionId)
if (!doc) return { doc: null, created: false }
if (doc.state !== 'ordered') return { doc, created: false }
const resumable = doc.state === 'setup' && !doc.ticketId
if (doc.state !== 'ordered' && !resumable) return { doc, created: false }
const setup = await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('setup'),
purchaseStatus: 'paid',
unlockedByPurchase: true,
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
})
const setup = resumable
? doc
: await updateDocument(config.collections.portalFeatures, doc.$id, {
...stateFields('setup'),
purchaseStatus: 'paid',
unlockedByPurchase: true,
stripeInvoiceId: invoiceId || '',
invoiceUrl: invoiceUrl || '',
})
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
const { ticketId } = await createFeatureTicket({
customerId: doc.customerId,
@@ -354,8 +413,27 @@ async function teamNotice(doc, customer, { failed = false } = {}) {
projekt: await projectName(doc.projectId),
woid: failed ? '' : await ticketWoidOf(doc),
sitekey: failed ? '' : doc.siteKey || '',
sitzung: '',
},
blocks: { zahlung_fehlgeschlagen: failed },
blocks: { zahlung_fehlgeschlagen: failed, ungeklaert: false },
})
}
/** Bezahlte Sitzung ohne passende Bestellung: das Team muss von Hand klaeren (T16, Block ungeklaert). */
async function unmatchedNotice(session) {
const { customerId, projectId } = session.metadata || {}
const customer = customerId ? await customerOf(customerId).catch(() => null) : null
await sendAdminNotice({
template: 'T16',
values: {
kunde_name: customer?.name || customer?.email || customerId || 'unbekannt',
email: customer?.email || '',
projekt: projectId ? (await projectName(projectId).catch(() => '')) || projectId : '',
woid: '',
sitekey: '',
sitzung: `${session.id} (Kunde ${customerId || '-'}, Projekt ${projectId || '-'})`,
},
blocks: { zahlung_fehlgeschlagen: false, ungeklaert: true },
})
}
@@ -371,6 +449,11 @@ async function adoptPaidSession(session) {
console.error(
`[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen`
)
try {
await unmatchedNotice(session)
} catch (err) {
console.error('[funktionen] Hinweis zur ungeklaerten Zahlung fehlgeschlagen:', err.message)
}
return false
}
await updateDocument(config.collections.portalFeatures, doc.$id, {
@@ -384,7 +467,8 @@ async function onFeaturePaid(session) {
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
const paid = { sessionId: session.id, invoiceId, invoiceUrl }
let { doc, created } = await markPaid(paid)
if (!doc && (await adoptPaidSession(session))) ({ doc, created } = 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 (!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.