Abo-API: Bestellung je Produkt, Katalog, Speicherstand, Wechsel-Möglichkeit
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
05363d06ad
commit
c4607e9c43
6 files changed
+784
-40
No files matched your search
+125
-18
@@ -5,10 +5,12 @@ import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
import { getCustomerByEmail, getDocument, updateDocument } from '../services/appwriteAdmin.js'
|
||||
import { listContractEvents, openWithdrawals, recordContractEvent } from '../services/contractEvents.js'
|
||||
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
|
||||
import { getLedger } from '../services/hourLedger.js'
|
||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
|
||||
import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
ACTIVE_STATUSES,
|
||||
cancelAtPeriodEnd,
|
||||
@@ -45,17 +47,44 @@ function addMonthsIso(iso, months) {
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
/** Mindestlaufzeit ab dem Wechseltag (termStartAt), sonst ab dem Leistungsbeginn (Probezeit-Ende bzw. Abo-Start). */
|
||||
function minTermEndOf(sub) {
|
||||
const start = sub?.trialEnd || sub?.startedAt
|
||||
const start = sub?.termStartAt || sub?.trialEnd || sub?.startedAt
|
||||
const months = minTermMonthsFor(sub?.termsVersion)
|
||||
return start && months ? addMonthsIso(start, months) : null
|
||||
}
|
||||
|
||||
function publicSubscription(sub) {
|
||||
// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6)
|
||||
const productKeyOf = (sub) => sub?.productKey || 'wartung'
|
||||
|
||||
/**
|
||||
* Abo fuers Portal. events: Vertragsereignisse des Kunden (listContractEvents); es zaehlen die
|
||||
* dieses Abos (subscriptionId = Stripe-Abo-ID = Dokument-ID). Daraus kommen der Speicherstand
|
||||
* (offene Aufgaben), ein Wechsel, der auf die Zahlung wartet (F12), und die Widerrufsfristen je
|
||||
* Bestellung. Abos von vor Plan 2 haben kein Ereignis abschluss: fuer sie zaehlt die Frist wie
|
||||
* bisher ab startedAt (F14).
|
||||
*/
|
||||
function publicSubscription(sub, events = []) {
|
||||
if (!sub) return null
|
||||
const active = ACTIVE_STATUSES.has(sub.status)
|
||||
// Fristende 24 Uhr, bei Sa/So/Feiertag am nächsten Werktag (deadlines.js)
|
||||
const withdrawalUntil = sub.startedAt ? withdrawalDeadline(sub.startedAt).endsAt : null
|
||||
const productKey = productKeyOf(sub)
|
||||
const own = events.filter((e) => e.subscriptionId === sub.$id)
|
||||
const openOf = (type) => own.filter((e) => e.type === type && e.status === 'offen')
|
||||
const pendingPlus = openOf('speicher').find((e) => e.details?.toUnits > e.details?.fromUnits)
|
||||
const pendingMinus = openOf('speicher').find((e) => e.details?.toUnits < e.details?.fromUnits)
|
||||
const pendingSwitch = openOf('wechsel')[0] || null
|
||||
// Plus/Minus und Wechsel nur bei Status active, ohne Kuendigung und ohne offene Wechselzahlung
|
||||
const cancelled = Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
|
||||
const canChangeStorage = sub.status === 'active' && !cancelled && !pendingSwitch
|
||||
// Widerrufsfristen je Bestellung (Abschluss, Wechsel, Speicher-Plus), Fristende 24 Uhr, bei
|
||||
// Sa/So/Feiertag am naechsten Werktag (deadlines.js)
|
||||
const withdrawals = active ? openWithdrawals(own) : []
|
||||
if (active && sub.startedAt && !own.some((e) => e.type === 'abschluss')) {
|
||||
const until = withdrawalDeadline(sub.startedAt).endsAt
|
||||
if (until > new Date()) withdrawals.push({ type: 'abschluss', eventId: null, until })
|
||||
}
|
||||
const withdrawalUntil = withdrawals.reduce((latest, w) => (!latest || w.until > latest ? w.until : latest), null)
|
||||
const terms = termsDocFor(sub.termsVersion)
|
||||
return {
|
||||
status: sub.status,
|
||||
active,
|
||||
@@ -70,11 +99,31 @@ function publicSubscription(sub) {
|
||||
startChoice: sub.startChoice || null,
|
||||
// Beginn nach Ablauf der Widerrufsfrist: Leistung startet mit Ende der Probezeit
|
||||
serviceStartsAt: sub.status === 'trialing' ? sub.trialEnd : null,
|
||||
// Widerrufsbutton im Portal nur waehrend der Frist hervorheben
|
||||
withdrawalOpen: Boolean(active && withdrawalUntil && withdrawalUntil > new Date()),
|
||||
withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null,
|
||||
// Individuelles Angebot, mit dem das Abo laeuft (z. B. "Erste 2 Monate je 25,00 € ...")
|
||||
offerText: sub.offerText || null,
|
||||
productKey,
|
||||
termsVersion: sub.termsVersion || null,
|
||||
termStartAt: sub.termStartAt || null,
|
||||
// Unterlagen in der zugestimmten Fassung (Spec 4.2): Fassung 3 fuer Bestandskunden, sonst Fassung 4
|
||||
termsUrl: terms.url,
|
||||
termsPdfUrl: `/${terms.pdfFile}`,
|
||||
storage: {
|
||||
units: Number(sub.storageUnits) || 0,
|
||||
// Plus bestellt, konsoleH noch nicht umgestellt ("wird eingerichtet")
|
||||
pendingSetup: Boolean(pendingPlus),
|
||||
// Minus bestellt, wirkt zum Periodenende ("ab TT.MM. X GB")
|
||||
pendingReduction: pendingMinus ? { units: pendingMinus.details.toUnits, effectiveAt: pendingMinus.dueAt || null } : null,
|
||||
},
|
||||
canChangeStorage,
|
||||
canSwitch: canChangeStorage && productKey === 'hosting',
|
||||
// Wechsel bestellt, Zahlung offen (Karte mit 3-D Secure): Link zur Stripe-Zahlungsseite
|
||||
switchPending: pendingSwitch
|
||||
? { since: pendingSwitch.consentAt || pendingSwitch.$createdAt, paymentUrl: pendingSwitch.details?.paymentUrl || null }
|
||||
: null,
|
||||
withdrawals: withdrawals.map((w) => ({ type: w.type, eventId: w.eventId, until: w.until.toISOString() })),
|
||||
// Widerrufsbutton im Portal waehrend einer laufenden Frist hervorheben
|
||||
withdrawalOpen: withdrawals.length > 0,
|
||||
withdrawalUntil: withdrawalUntil ? withdrawalUntil.toISOString() : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,28 +139,43 @@ function isPilotAllowed(customer, offer = null) {
|
||||
// Kunde (eingeloggt)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Abo-Status + Stundenkonto fuer die Startseite. */
|
||||
/** Katalog, Abo-Status mit Speicherstand und Wechsel-Moeglichkeit, Angebote je Produkt, Stundenkonto (nur Wartung). */
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const [sub, customer] = await Promise.all([
|
||||
const [sub, customer, events] = await Promise.all([
|
||||
getCurrentSubscription(customerId),
|
||||
getDocument(config.collections.customers, customerId).catch(() => null),
|
||||
// Ohne lesbare Ereignisse (z. B. Collection noch nicht angelegt) bleibt die Seite nutzbar: die
|
||||
// Widerrufsfrist des Abschlusses zaehlt dann ab startedAt (F14), offene Aufgaben fehlen
|
||||
listContractEvents(customerId).catch((e) => {
|
||||
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
|
||||
return []
|
||||
}),
|
||||
])
|
||||
const active = Boolean(sub && ACTIVE_STATUSES.has(sub.status))
|
||||
const [ledger, offer] = await Promise.all([
|
||||
sub ? getLedger(customerId, { active }) : null,
|
||||
// Arbeitszeit gibt es nur im Wartungs-Abo
|
||||
sub && productKeyOf(sub) === 'wartung' ? getLedger(customerId, { active }) : null,
|
||||
active ? null : getOpenOffer(customerId).catch(() => null),
|
||||
])
|
||||
return res.json({
|
||||
available: isStripeConfigured() && isPilotAllowed(customer, offer),
|
||||
testAccount: config.abo.testEmails.includes(String(customer?.email || '').toLowerCase()),
|
||||
termsVersion: config.abo.termsVersion,
|
||||
subscription: publicSubscription(sub),
|
||||
offer: offer ? offerView(offer) : null,
|
||||
ledger,
|
||||
// Leistungsbeginn bei "Nach Ablauf der Widerrufsfrist" für die Bestellseite
|
||||
lateStartAt: lateStartAt().toISOString(),
|
||||
products: [productView('hosting'), productView('wartung')],
|
||||
mailStorage: {
|
||||
includedGb: config.mailStorage.includedGb,
|
||||
gbPerUnit: config.mailStorage.gbPerUnit,
|
||||
unitPriceCents: config.mailStorage.amountCents,
|
||||
maxUnits: config.mailStorage.maxUnits,
|
||||
},
|
||||
// Angebote gibt es bis Aufgabe 11 nur fuer das Wartungs-Abo
|
||||
offers: { hosting: null, wartung: offer ? offerView(offer) : null },
|
||||
ledger,
|
||||
subscription: publicSubscription(sub, events),
|
||||
})
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: err.message || 'Abo konnte nicht geladen werden' })
|
||||
@@ -124,7 +188,10 @@ router.post('/checkout', requireSession, async (req, res) => {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
|
||||
}
|
||||
const { acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {}
|
||||
const { productKey, acceptTerms, startChoice, termsVersion, offerId, lateStartAt: shownLateStart } = req.body || {}
|
||||
if (!PRODUCT_KEYS.includes(productKey)) {
|
||||
return res.status(400).json({ error: 'Bitte wählen Sie ein Abo (Hosting-Abo oder Wartungs-Abo).' })
|
||||
}
|
||||
if (acceptTerms !== true) {
|
||||
return res.status(400).json({ error: 'Bitte stimmen Sie den Vertragsbedingungen zu, um zu bestellen.' })
|
||||
}
|
||||
@@ -135,10 +202,16 @@ router.post('/checkout', requireSession, async (req, res) => {
|
||||
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||||
}
|
||||
const customer = await getDocument(config.collections.customers, getSessionCustomerId(req))
|
||||
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat
|
||||
const offer = await getOpenOffer(customer.$id)
|
||||
if ((offer?.$id || '') !== String(offerId || '')) {
|
||||
return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' })
|
||||
// Bestellt wird genau der Preis, den die Bestelluebersicht gezeigt hat. Angebote gibt es bis
|
||||
// Aufgabe 11 nur fuer das Wartungs-Abo; ein Angebot bei einer Hosting-Bestellung passt nie.
|
||||
let offer = null
|
||||
if (productKey === 'wartung') {
|
||||
offer = await getOpenOffer(customer.$id)
|
||||
if ((offer?.$id || '') !== String(offerId || '')) {
|
||||
return res.status(409).json({ error: 'Ihr Angebot hat sich geändert. Bitte laden Sie die Seite neu.' })
|
||||
}
|
||||
} else if (offerId) {
|
||||
return res.status(409).json({ error: 'Ihr Angebot gilt für ein anderes Abo. Bitte laden Sie die Seite neu.' })
|
||||
}
|
||||
if (!isStripeConfigured() || !isPilotAllowed(customer, offer)) {
|
||||
return res.status(403).json({ error: 'Die Online-Bestellung ist noch nicht freigeschaltet.' })
|
||||
@@ -160,6 +233,7 @@ router.post('/checkout', requireSession, async (req, res) => {
|
||||
consentIp: req.ip,
|
||||
consentUserAgent: req.get('user-agent') || '',
|
||||
},
|
||||
productKey,
|
||||
offer ? { id: offer.$id, stripeCouponId: offer.stripeCouponId, text: offerText(offer) } : null
|
||||
)
|
||||
return res.json({ url })
|
||||
@@ -246,12 +320,45 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Nachweis des Neuabschlusses als Vertragsereignis. Der Vertrag kommt mit dem Abschluss bei Stripe
|
||||
* zustande, die Widerrufsfrist zaehlt ab dann (details.contractAt = startedAt, F04); die Zustimmung
|
||||
* (Fassung, Zeitpunkt, IP, Browser) kommt aus den Abo-Metadaten, die der Spiegel traegt. Feste
|
||||
* Dokument-ID je Abo: eine Webhook-Wiederholung legt kein zweites Ereignis an (F05).
|
||||
*/
|
||||
async function recordAbschluss(sub, session) {
|
||||
return recordContractEvent({
|
||||
id: `abschluss-${sub.$id}`,
|
||||
customerId: sub.customerId,
|
||||
subscriptionId: sub.$id,
|
||||
type: 'abschluss',
|
||||
productKey: productKeyOf(sub),
|
||||
details: {
|
||||
contractAt: sub.startedAt,
|
||||
startChoice: sub.startChoice || null,
|
||||
serviceStartAt: sub.trialEnd || sub.startedAt,
|
||||
amountCents: sub.priceAmount ?? null,
|
||||
checkoutSessionId: session.id || null,
|
||||
...(sub.offerId ? { offerId: sub.offerId, offerText: sub.offerText || '' } : {}),
|
||||
},
|
||||
consent: {
|
||||
termsVersion: sub.termsVersion || null,
|
||||
consentAt: sub.termsAcceptedAt || null,
|
||||
consentIp: sub.consentIp || null,
|
||||
consentUserAgent: sub.consentUserAgent || null,
|
||||
earlyStartConsentAt: sub.earlyStartConsentAt || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleStripeEvent(event) {
|
||||
const obj = event.data.object
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
if (obj.mode !== 'subscription' || !obj.subscription) return
|
||||
const sub = await syncSubscription(obj.subscription)
|
||||
// Ein Fehler hier (etwa Collection fehlt) gibt 500 -> Stripe wiederholt, das Ereignis wird nachgeholt
|
||||
if (sub) await recordAbschluss(sub, obj)
|
||||
const offerProblem = await activateOfferFromCheckout(obj).then(
|
||||
() => false,
|
||||
(e) => {
|
||||
|
||||
Reference in new issue
Block a user