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:
1 parent
0dc57fe5e8
commit
4b7f73abba
22 files changed
+400
-57
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user