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
+51
-13
@@ -18,7 +18,7 @@ import {
|
||||
reconcilePendingSwitch,
|
||||
switchToWartung,
|
||||
} from '../services/planSwitch.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent } from '../services/siteFeatures.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
@@ -704,13 +704,13 @@ export function publicRateLimitState() {
|
||||
|
||||
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
|
||||
const CONTRACTS = ['abo', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion']
|
||||
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
|
||||
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
|
||||
|
||||
/**
|
||||
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher'; packages (nur beim
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion'; packages (nur beim
|
||||
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
|
||||
*/
|
||||
function validDeclaration(body) {
|
||||
@@ -782,6 +782,7 @@ function contractLabel(contract, sub) {
|
||||
function orderLabel(order, sub, { customer = false } = {}) {
|
||||
if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo'
|
||||
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
|
||||
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
|
||||
const abo = productNameOf(sub)
|
||||
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
|
||||
}
|
||||
@@ -1067,6 +1068,25 @@ async function withdrawOrder(sub, d, receivedAt) {
|
||||
}
|
||||
}
|
||||
|
||||
const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' }
|
||||
|
||||
/**
|
||||
* Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig): gehoert zu keinem Abo und hat kein Vertrags-
|
||||
* ereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; das Team
|
||||
* bekommt in T09 die bezahlten Funktionen mit Fristende und erstattet/schaltet ab von Hand.
|
||||
*/
|
||||
async function withdrawFeature(d, receivedAt) {
|
||||
const customer =
|
||||
(await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null))
|
||||
const orders = customer ? await paidFeatureOrders(customer.$id) : []
|
||||
const ends = orders.filter((o) => o.orderedAt).map((o) => withdrawalDeadline(o.orderedAt).endsAt)
|
||||
const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null
|
||||
const lines = orders
|
||||
.map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`)
|
||||
.join('\n')
|
||||
return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline }
|
||||
}
|
||||
|
||||
/** Zeile je widerrufenem Ereignis fuer T09: ID, Inhalt (Speicher: Menge vorher -> nachher), Bestelldatum. */
|
||||
function describeWithdrawn(events) {
|
||||
return events
|
||||
@@ -1085,11 +1105,19 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
|
||||
let sub = null
|
||||
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false }
|
||||
// Funktion: kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||||
const feature = d.order === 'funktion'
|
||||
let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false }
|
||||
try {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
result = await withdrawOrder(sub, d, receivedAt)
|
||||
sub = result.sub || sub
|
||||
if (feature) {
|
||||
fn = await withdrawFeature(d, receivedAt)
|
||||
result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) }
|
||||
} else {
|
||||
sub = await findActiveSubscriptionByEmail(d.email)
|
||||
if (sub) {
|
||||
result = await withdrawOrder(sub, d, receivedAt)
|
||||
sub = result.sub || sub
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[abo] Widerruf nicht verarbeitbar:', err.message)
|
||||
@@ -1103,7 +1131,7 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
values: {
|
||||
name: d.name,
|
||||
bestellung: orderLabel(d.order, sub, { customer: true }),
|
||||
produkt: productLabelOf(sub),
|
||||
produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
|
||||
eingang_zeitpunkt: fmtDateTime(receivedAt),
|
||||
email: d.email,
|
||||
kundennummer: d.customerNumber,
|
||||
@@ -1113,7 +1141,8 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
pruefen: !inTime,
|
||||
funktion: feature,
|
||||
pruefen: !inTime && !feature,
|
||||
},
|
||||
}).catch((e) => console.error('[abo] Widerrufsbestaetigung fehlgeschlagen:', e.message))
|
||||
|
||||
@@ -1125,11 +1154,19 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
name: d.name,
|
||||
kundennummer: d.customerNumber,
|
||||
bestellung: orderLabel(d.order, sub),
|
||||
produkt: productLabelOf(sub),
|
||||
produkt: feature ? 'News-Pinnwand' : productLabelOf(sub),
|
||||
stripe_abo_id: sub?.stripeSubscriptionId || '',
|
||||
abo_beginn: fmtDate(sub?.startedAt),
|
||||
fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden',
|
||||
rechtzeitig: !sub
|
||||
rechtzeitig: feature
|
||||
? !fn.customer
|
||||
? 'ungeprüft (kein Kunde zu dieser E-Mail-Adresse gefunden)'
|
||||
: !fn.orders.length
|
||||
? 'ungeprüft (keine bezahlte Funktion gefunden)'
|
||||
: inTime
|
||||
? 'RECHTZEITIG'
|
||||
: 'NACH Fristende, vor einer Ablehnung bitte prüfen'
|
||||
: !sub
|
||||
? 'ungeprüft (kein Abo gefunden)'
|
||||
: inTime
|
||||
? 'RECHTZEITIG'
|
||||
@@ -1142,18 +1179,19 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
: result.deadline
|
||||
? 'ungeprüft (keine offene Frist: Bestellung gescheitert)'
|
||||
: 'ungeprüft (keine Bestellung dieser Art gefunden)',
|
||||
ereignisse: describeWithdrawn(result.withdrawn),
|
||||
ereignisse: feature ? fn.lines : describeWithdrawn(result.withdrawn),
|
||||
pruefhinweis,
|
||||
},
|
||||
blocks: {
|
||||
kundennummer: Boolean(d.customerNumber),
|
||||
abo_gefunden: Boolean(sub),
|
||||
auf_kuendigung_gesetzt: result.canceled,
|
||||
kein_abo_gefunden: !sub,
|
||||
kein_abo_gefunden: !sub && !feature,
|
||||
kuendigung_fehlgeschlagen: result.cancelFailed,
|
||||
abschluss: inTime && d.order === 'abschluss',
|
||||
wechsel: inTime && d.order === 'wechsel',
|
||||
zusatzspeicher: inTime && d.order === 'zusatzspeicher',
|
||||
funktion: feature,
|
||||
keine_frist: Boolean(sub) && !inTime && !pruefhinweis && !result.alreadyWithdrawn,
|
||||
nachweis_fehlt: Boolean(sub) && !result.event,
|
||||
wechsel_offen: result.openSwitchWithdrawn,
|
||||
|
||||
@@ -3,8 +3,15 @@ import { config } from '../config.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { withLocks } from '../services/keyedLock.js'
|
||||
import { createFeatureCheckout } from '../services/stripeBilling.js'
|
||||
import { assertOwnProject, getFeatureDoc, listCustomerFeatures, markOrdered, runActivation } from '../services/siteFeatures.js'
|
||||
import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js'
|
||||
import {
|
||||
DISABLED_MESSAGE,
|
||||
assertOwnProject,
|
||||
backfillInvoiceLinks,
|
||||
getFeatureDoc, listCustomerFeatures,
|
||||
markOrdered,
|
||||
runActivation,
|
||||
} from '../services/siteFeatures.js'
|
||||
|
||||
const router = Router()
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
@@ -20,6 +27,7 @@ router.get('/', requireSession, async (req, res) => {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
// Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern
|
||||
await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message))
|
||||
await backfillInvoiceLinks(customerId)
|
||||
res.json({
|
||||
projects: await listCustomerFeatures(customerId),
|
||||
setupEstimate: config.funktionen.setupEstimate,
|
||||
@@ -30,6 +38,27 @@ router.get('/', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal
|
||||
* (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft
|
||||
* oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409.
|
||||
*/
|
||||
async function openOrderSession(doc) {
|
||||
const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
|
||||
if (!doc.stripeSessionId) return null
|
||||
let session
|
||||
try {
|
||||
session = await retrieveCheckoutSession(doc.stripeSessionId)
|
||||
} catch (err) {
|
||||
console.warn('[funktionen] Sitzung nicht abrufbar:', err.message)
|
||||
if (Date.now() - new Date(doc.orderedAt || 0).getTime() < DAY_MS) throw conflict()
|
||||
return null
|
||||
}
|
||||
if (session.status === 'open' && session.url) return { id: session.id, url: session.url }
|
||||
if (session.status === 'expired') return null
|
||||
throw conflict()
|
||||
}
|
||||
|
||||
router.post('/news/checkout', requireSession, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
@@ -47,12 +76,17 @@ router.post('/news/checkout', requireSession, async (req, res) => {
|
||||
// Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine
|
||||
// zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
|
||||
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
|
||||
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv oder eine noch gueltige offene Bestellung -> 409
|
||||
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung
|
||||
// richtet sich nach ihrer gespeicherten Stripe-Sitzung
|
||||
const existing = await getFeatureDoc(customerId, projectId, 'news')
|
||||
const freshOrder = existing?.state === 'ordered' && Date.now() - new Date(existing.orderedAt || 0).getTime() < DAY_MS
|
||||
if (existing?.state === 'setup' || existing?.state === 'active' || freshOrder) {
|
||||
if (existing?.state === 'setup' || existing?.state === 'active') {
|
||||
throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
|
||||
}
|
||||
if (existing?.state === 'disabled') throw Object.assign(new Error(DISABLED_MESSAGE), { status: 409 })
|
||||
if (existing?.state === 'ordered') {
|
||||
const open = await openOrderSession(existing)
|
||||
if (open) return open
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, customerId)
|
||||
const now = new Date().toISOString()
|
||||
const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
|
||||
|
||||
Reference in new issue
Block a user