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) => {
|
||||
|
||||
@@ -431,34 +431,58 @@ export async function ensureTestCoupon(productKey) {
|
||||
return created.id
|
||||
}
|
||||
|
||||
// Preis wie in den Bedingungen: "15,00 €" (normales Leerzeichen, kein geschuetztes wie bei toLocaleString)
|
||||
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
|
||||
|
||||
/**
|
||||
* Stripe Checkout fuer das Abo. Der Kunde hat seine Bestellung vorher im Portal
|
||||
* abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
|
||||
* Hinweis ueber dem Bezahlknopf im Checkout (custom_text): Vertragsschluss, Leistungsbeginn, Preis
|
||||
* und Mindestlaufzeit des bestellten Produkts; bei einem Angebot der vereinbarte Preis.
|
||||
*/
|
||||
function checkoutMessage(product, lateStart, offer) {
|
||||
const intro =
|
||||
`Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.` +
|
||||
(lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : '')
|
||||
const price = offer
|
||||
? `Preis wie vereinbart: ${offer.text}.`
|
||||
: `${lateStart ? 'Danach ' : ''}${fmtEuro(product.amountCents)} monatlich,`
|
||||
return `${intro} ${price} Mindestlaufzeit ${product.minTermMonths} Monate, danach monatlich kündbar. Gemäß § 19 UStG ohne Umsatzsteuer.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stripe Checkout fuer ein Abo (productKey 'wartung' | 'hosting'). Der Kunde hat seine Bestellung
|
||||
* vorher im Portal abgegeben ("Zahlungspflichtig bestellen"); hier hinterlegt er die Zahlungsart.
|
||||
* Position, Beschreibung, Hinweistext und Testrabatt richten sich nach dem Produkt.
|
||||
* startChoice: 'sofort' (Leistung und erste Abbuchung sofort) oder 'nach_frist' (Probezeit ohne Leistung
|
||||
* und ohne Abbuchung bis consent.serviceStartAt, dem Tag nach dem Ende der Widerrufsfrist, deadlines.js).
|
||||
* offer: individuelles Angebot { id, stripeCouponId, text } -> Coupon im Checkout; bei Testkonten
|
||||
* bleibt der Testrabatt im Checkout und das Angebot wird nach dem Abschluss angehaengt (offerDeferred).
|
||||
*/
|
||||
export async function createAboCheckout(customer, consent, offer = null) {
|
||||
export async function createAboCheckout(customer, consent, productKey, offer = null) {
|
||||
const product = getProduct(productKey) // unbekanntes Produkt -> 400
|
||||
// Hoechstens ein laufendes Abo je Kunde (Spec 7); der Wechsel zwischen den Abos laeuft ueber planSwitch
|
||||
const current = await getCurrentSubscription(customer.$id)
|
||||
if (current && ACTIVE_STATUSES.has(current.status)) {
|
||||
const err = new Error('Sie haben das Wartungs-Abo bereits abgeschlossen.')
|
||||
const err = new Error('Sie haben bereits ein laufendes Abo abgeschlossen.')
|
||||
err.status = 409
|
||||
throw err
|
||||
}
|
||||
const stripe = getStripe()
|
||||
const [priceId, stripeCustomerId] = await Promise.all([
|
||||
ensurePrice('wartung'),
|
||||
ensurePrice(productKey),
|
||||
getOrCreateStripeCustomer(customer),
|
||||
])
|
||||
const lateStart = consent.startChoice === 'nach_frist'
|
||||
const serviceStartAt = lateStart ? consent.serviceStartAt || lateStartAt() : null
|
||||
const isTestAccount = config.abo.testEmails.includes(String(customer.email || '').toLowerCase())
|
||||
const testCoupon = isTestAccount ? await ensureTestCoupon('wartung') : null
|
||||
// Stripe-Metadaten: max. 500 Zeichen je Wert
|
||||
const testCoupon = isTestAccount ? await ensureTestCoupon(productKey) : null
|
||||
const offerMeta = offer
|
||||
? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) }
|
||||
: {}
|
||||
// Stripe-Metadaten am Abo (max. 500 Zeichen je Wert): Zuordnung, Produkt und Nachweis der Zustimmung
|
||||
const meta = {
|
||||
webklarCustomerId: customer.$id,
|
||||
email: customer.email || '',
|
||||
productKey,
|
||||
termsVersion: config.abo.termsVersion,
|
||||
consentTextVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt: consent.termsAcceptedAt,
|
||||
@@ -466,13 +490,10 @@ export async function createAboCheckout(customer, consent, offer = null) {
|
||||
earlyStartConsentAt: consent.earlyStartConsentAt || '',
|
||||
consentIp: String(consent.consentIp || '').slice(0, 64),
|
||||
consentUserAgent: String(consent.consentUserAgent || '').slice(0, 300),
|
||||
...(offer
|
||||
? { offerId: offer.id, offerText: offer.text.slice(0, 300), ...(testCoupon ? { offerDeferred: '1' } : {}) }
|
||||
: {}),
|
||||
...offerMeta,
|
||||
}
|
||||
// Checkout erlaubt nur einen Rabatt: Testkonto -> Testrabatt, sonst der Angebots-Coupon
|
||||
const coupon = testCoupon || offer?.stripeCouponId || null
|
||||
const offerPrice = offer ? ` Preis wie vereinbart: ${offer.text}.` : ''
|
||||
const params = {
|
||||
mode: 'subscription',
|
||||
customer: stripeCustomerId,
|
||||
@@ -484,20 +505,15 @@ export async function createAboCheckout(customer, consent, offer = null) {
|
||||
customer_update: { address: 'auto', name: 'auto' },
|
||||
subscription_data: {
|
||||
metadata: meta,
|
||||
description: config.stripe.productName,
|
||||
description: product.name,
|
||||
...(serviceStartAt ? { trial_end: Math.floor(serviceStartAt.getTime() / 1000) } : {}),
|
||||
},
|
||||
metadata: meta,
|
||||
// An der Checkout-Sitzung nur, was der Webhook braucht (Zuordnung, Produkt, Angebot). Keine
|
||||
// Zustimmungsdaten: abgeschlossene Sitzungen lassen sich spaeter nicht mehr bereinigen
|
||||
// (Loeschfristen, Datenschutzerklaerung Abschnitt 7) - die stehen nur am Abo.
|
||||
metadata: { webklarCustomerId: customer.$id, productKey, ...offerMeta },
|
||||
...(coupon ? { discounts: [{ coupon }] } : {}),
|
||||
custom_text: {
|
||||
submit: {
|
||||
message: offer
|
||||
? `Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit ${lateStart ? 'dem Abschluss' : 'der ersten Zahlung'} kommt der Vertrag zustande.${lateStart ? ' Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab.' : ''}${offerPrice} Mindestlaufzeit 12 Monate, danach monatlich kündbar.`
|
||||
: lateStart
|
||||
? 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit dem Abschluss kommt der Vertrag zustande. Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist – bis dahin erbringen wir keine Leistungen und buchen nichts ab. Danach 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.'
|
||||
: 'Ihre Bestellung haben Sie im Kundenportal abgegeben. Hier hinterlegen Sie die Zahlungsart; mit der ersten Zahlung kommt der Vertrag zustande. 50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar.',
|
||||
},
|
||||
},
|
||||
custom_text: { submit: { message: checkoutMessage(product, lateStart, offer) } },
|
||||
success_url: `${config.publicBaseUrl}/dashboard.html?abo=success`,
|
||||
cancel_url: `${config.publicBaseUrl}/dashboard.html?abo=cancel`,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
aboOffers: schemaKeys('aboOffers'),
|
||||
hourCredits: schemaKeys('hourCredits'),
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, sessionCookie, get, rawGet, post, postWebhook } = await import('./support/testApp.js')
|
||||
const { hostingDoc, wartungDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
|
||||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||||
const { recordContractEvent, listContractEvents } = await import('../server/services/contractEvents.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
|
||||
|
||||
const app = await startTestApp({ '/api/abo': aboRoutes })
|
||||
after(() => Promise.all([app.close(), aw.close(), st.close()]))
|
||||
seedFixtures(aw)
|
||||
|
||||
const DAY_MS = 86400000
|
||||
const daysAgo = (days) => new Date(Date.now() - days * DAY_MS).toISOString()
|
||||
const orderBody = (productKey, extra = {}) => ({
|
||||
productKey,
|
||||
acceptTerms: true,
|
||||
startChoice: 'sofort',
|
||||
termsVersion: config.abo.termsVersion,
|
||||
offerId: '',
|
||||
...extra,
|
||||
})
|
||||
async function clearEvents(customerId) {
|
||||
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
|
||||
}
|
||||
|
||||
test('GET /api/abo: Katalog, Hosting-Abo mit Speicher, kein Stundenkonto', async () => {
|
||||
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
|
||||
const r = await get('/api/abo', { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) })
|
||||
assert.deepEqual(r.products.map((p) => [p.key, p.amountCents]), [['hosting', 1500], ['wartung', 5000]])
|
||||
assert.equal(r.subscription.productKey, 'hosting'); assert.equal(r.subscription.storage.units, 2)
|
||||
assert.equal(r.subscription.canSwitch, true); assert.equal(r.ledger, null)
|
||||
assert.equal(r.subscription.termsUrl, '/abo-bedingungen.html')
|
||||
// F13: PDF der zugestimmten Fassung; Katalogdaten fuer Speicher-Widget und Bestellseiten
|
||||
assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Abo-Vertragsunterlagen.pdf')
|
||||
assert.equal(r.subscription.termsVersion, config.abo.termsVersion)
|
||||
assert.deepEqual(r.mailStorage, { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 })
|
||||
// Angebote je Produkt (Hosting bis Aufgabe 11 immer null); das alte Feld offer entfaellt (F15)
|
||||
assert.deepEqual(r.offers, { hosting: null, wartung: null })
|
||||
assert.equal('offer' in r, false)
|
||||
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: false, pendingReduction: null })
|
||||
assert.equal(r.subscription.canChangeStorage, true)
|
||||
assert.equal(r.subscription.switchPending, null)
|
||||
assert.deepEqual(r.subscription.withdrawals, [])
|
||||
assert.equal(r.subscription.withdrawalOpen, false)
|
||||
assert.equal(r.subscription.withdrawalUntil, null)
|
||||
assert.equal(r.available, true)
|
||||
assert.equal(r.termsVersion, config.abo.termsVersion)
|
||||
assert.ok(r.lateStartAt)
|
||||
})
|
||||
|
||||
test('GET /api/abo: Wartung v3 → Link auf Fassung 3, Stundenkonto da', async () => {
|
||||
aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', customerId: 'c1', termsVersion: '2026-09-25-v3' })
|
||||
const r = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.equal(r.subscription.termsUrl, '/vertragsunterlagen/v3/abo-bedingungen.html'); assert.ok(r.ledger)
|
||||
assert.equal(r.subscription.termsPdfUrl, '/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
|
||||
assert.equal(r.subscription.productKey, 'wartung')
|
||||
// Wechseln kann nur ein Hosting-Abo; Zusatzspeicher gibt es in beiden Abos
|
||||
assert.equal(r.subscription.canSwitch, false)
|
||||
assert.equal(r.subscription.canChangeStorage, true)
|
||||
})
|
||||
|
||||
test('GET /api/abo: Altbestand ohne productKey gilt als Wartungs-Abo', async () => {
|
||||
aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_h', productKey: undefined, termsVersion: '2026-09-25-v3' })
|
||||
const r = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.equal(r.subscription.productKey, 'wartung')
|
||||
assert.ok(r.ledger)
|
||||
assert.equal(r.subscription.canSwitch, false)
|
||||
})
|
||||
|
||||
test('Ohne Session 401, Admin-Session ist keine Kunden-Session', async () => {
|
||||
assert.equal((await rawGet('/api/abo')).status, 401)
|
||||
assert.equal((await rawGet('/api/abo', { cookie: adminCookie() })).status, 401)
|
||||
await assert.rejects(get('/api/abo'), (e) => e.status === 401)
|
||||
})
|
||||
|
||||
test('Checkout Hosting: Preis, Beschreibung, Hinweistext', async () => {
|
||||
const res = await post('/api/abo/checkout', orderBody('hosting'), { cookie: customerCookie('c3') })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.match(res.body.url, /^https:\/\//)
|
||||
const call = st.calls.find((c) => c.path === '/v1/checkout/sessions')
|
||||
assert.equal(call.body['subscription_data[description]'], 'WEBklar Hosting-Abo')
|
||||
assert.match(call.body['custom_text[submit][message]'], /15,00 € monatlich/)
|
||||
assert.equal(call.body['subscription_data[metadata][productKey]'], 'hosting')
|
||||
assert.match(call.body['custom_text[submit][message]'], /Mindestlaufzeit 12 Monate, danach monatlich kündbar/)
|
||||
assert.match(call.body['custom_text[submit][message]'], /§ 19 UStG/)
|
||||
const hostingPrice = [...st.prices.values()].find((p) => p.lookup_key === 'webklar_hosting_monatlich_1500')
|
||||
assert.equal(call.body['line_items[0][price]'], hostingPrice.id)
|
||||
assert.equal(call.body['discounts[0][coupon]'], undefined)
|
||||
// Nachweis der Zustimmung nur am Abo (F41c): an der Checkout-Sitzung bleiben Zuordnung und Produkt
|
||||
assert.equal(call.body['subscription_data[metadata][termsAcceptedAt]'] !== undefined, true)
|
||||
assert.ok(call.body['subscription_data[metadata][consentIp]'])
|
||||
assert.equal(call.body['subscription_data[metadata][termsVersion]'], config.abo.termsVersion)
|
||||
for (const key of ['termsVersion', 'consentTextVersion', 'termsAcceptedAt', 'startChoice', 'earlyStartConsentAt', 'consentIp', 'consentUserAgent', 'email']) {
|
||||
assert.equal(call.body[`metadata[${key}]`], undefined, `metadata[${key}] darf nicht an der Sitzung stehen`)
|
||||
}
|
||||
assert.equal(call.body['metadata[webklarCustomerId]'], 'c3')
|
||||
assert.equal(call.body['metadata[productKey]'], 'hosting')
|
||||
})
|
||||
|
||||
test('Checkout Wartung: 50,00 €, Beschreibung, Produkt in den Metadaten', async () => {
|
||||
const before = st.calls.length
|
||||
const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c3') })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const call = st.calls.slice(before).find((c) => c.path === '/v1/checkout/sessions')
|
||||
assert.equal(call.body['subscription_data[description]'], 'WEBklar Wartungs-Abo')
|
||||
assert.match(call.body['custom_text[submit][message]'], /50,00 € monatlich, Mindestlaufzeit 12 Monate, danach monatlich kündbar/)
|
||||
assert.equal(call.body['subscription_data[metadata][productKey]'], 'wartung')
|
||||
const wartungPrice = [...st.prices.values()].find((p) => p.lookup_key === 'webklar_wartung_monatlich_5000')
|
||||
assert.equal(call.body['line_items[0][price]'], wartungPrice.id)
|
||||
})
|
||||
|
||||
test('Checkout: unbekanntes Produkt → 400', async () => {
|
||||
assert.equal((await post('/api/abo/checkout', orderBody('gold'), { cookie: customerCookie('c3') })).status, 400)
|
||||
assert.equal((await post('/api/abo/checkout', { ...orderBody('hosting'), productKey: undefined }, { cookie: customerCookie('c3') })).status, 400)
|
||||
})
|
||||
|
||||
test('Checkout Hosting mit Angebot → 409; das Angebot gilt fuer die Wartung', async () => {
|
||||
aw.seed('aboOffers', {
|
||||
$id: 'o1', customerId: 'c3', status: 'offen', scope: 'neu', months: 2, monthlyPriceCents: 2500, regularPriceCents: 5000,
|
||||
validUntil: '2099-01-01T00:00:00.000Z', stripeCouponId: 'WEBKLAR-ANGEBOT-O1', note: '',
|
||||
})
|
||||
try {
|
||||
const r = await get('/api/abo', { cookie: customerCookie('c3') })
|
||||
assert.equal(r.offers.hosting, null)
|
||||
assert.equal(r.offers.wartung.id, 'o1')
|
||||
const hosting = await post('/api/abo/checkout', orderBody('hosting', { offerId: 'o1' }), { cookie: customerCookie('c3') })
|
||||
assert.equal(hosting.status, 409)
|
||||
assert.match(hosting.body.error, /anderes Abo/)
|
||||
const before = st.calls.length
|
||||
const wartung = await post('/api/abo/checkout', orderBody('wartung', { offerId: 'o1' }), { cookie: customerCookie('c3') })
|
||||
assert.equal(wartung.status, 200, JSON.stringify(wartung.body))
|
||||
const call = st.calls.slice(before).find((c) => c.path === '/v1/checkout/sessions')
|
||||
assert.equal(call.body['discounts[0][coupon]'], 'WEBKLAR-ANGEBOT-O1')
|
||||
assert.equal(call.body['subscription_data[metadata][offerId]'], 'o1')
|
||||
// Der Webhook liest das Angebot von der Sitzung (activateOfferFromCheckout)
|
||||
assert.equal(call.body['metadata[offerId]'], 'o1')
|
||||
} finally {
|
||||
await deleteDocument(config.collections.aboOffers, 'o1')
|
||||
}
|
||||
})
|
||||
|
||||
test('Checkout mit laufendem Abo → 409', async () => {
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
const res = await post('/api/abo/checkout', orderBody('wartung'), { cookie: customerCookie('c1') })
|
||||
assert.equal(res.status, 409)
|
||||
assert.match(res.body.error, /bereits/)
|
||||
})
|
||||
|
||||
test('Gekündigtes Abo: canSwitch und canChangeStorage false', async () => {
|
||||
aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true, cancelAt: '2027-10-05T10:00:00.000Z' })
|
||||
const r = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.equal(r.subscription.canSwitch, false); assert.equal(r.subscription.canChangeStorage, false)
|
||||
assert.equal(r.subscription.cancelAt, '2027-10-05T10:00:00.000Z')
|
||||
})
|
||||
|
||||
test('Widerrufsfristen aus den Ereignissen', async () => {
|
||||
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
|
||||
const consentAt = daysAgo(2)
|
||||
await recordContractEvent({
|
||||
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
|
||||
details: { fromUnits: 0, toUnits: 2 }, consent: { ...consent, consentAt }, status: 'offen', dueAt: consentAt,
|
||||
})
|
||||
try {
|
||||
const r = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.deepEqual(r.subscription.withdrawals.map((w) => w.type), ['speicher'])
|
||||
assert.equal(r.subscription.withdrawalOpen, true)
|
||||
assert.equal(r.subscription.withdrawalUntil, withdrawalDeadline(consentAt).endsAt.toISOString())
|
||||
assert.equal(r.subscription.withdrawals[0].until, r.subscription.withdrawalUntil)
|
||||
assert.ok(r.subscription.withdrawals[0].eventId)
|
||||
// Offenes Plus: konsoleH noch nicht umgestellt
|
||||
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null })
|
||||
} finally {
|
||||
await clearEvents('c1')
|
||||
}
|
||||
})
|
||||
|
||||
test('Geplante Verringerung: ab Periodenende, ohne Widerrufsfrist', async () => {
|
||||
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 2 })
|
||||
await recordContractEvent({
|
||||
customerId: 'c1', subscriptionId: 'sub_h', type: 'speicher', productKey: 'hosting',
|
||||
details: { fromUnits: 2, toUnits: 1 }, consent: null, status: 'offen', dueAt: hostingDoc.currentPeriodEnd,
|
||||
})
|
||||
try {
|
||||
const r = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.deepEqual(r.subscription.storage, { units: 2, pendingSetup: false, pendingReduction: { units: 1, effectiveAt: hostingDoc.currentPeriodEnd } })
|
||||
assert.deepEqual(r.subscription.withdrawals, [])
|
||||
assert.equal(r.subscription.withdrawalOpen, false)
|
||||
assert.equal(r.subscription.canChangeStorage, true)
|
||||
} finally {
|
||||
await clearEvents('c1')
|
||||
}
|
||||
})
|
||||
|
||||
// F12: solange ein Wechsel auf die Zahlung wartet, gibt es weder Wechsel noch Speicheraenderung,
|
||||
// und das Portal kann den Kunden zur Zahlungsseite zurueckfuehren
|
||||
test('Wechsel wartet auf die Zahlung: canSwitch und canChangeStorage false, switchPending mit Zahlungslink', async () => {
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
const ev = await recordContractEvent({
|
||||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||||
details: { from: 'hosting', paymentUrl: 'https://pay.example/inv' }, consent, status: 'offen',
|
||||
})
|
||||
try {
|
||||
const r = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.equal(r.subscription.canSwitch, false)
|
||||
assert.equal(r.subscription.canChangeStorage, false)
|
||||
assert.deepEqual(r.subscription.switchPending, { since: consent.consentAt, paymentUrl: 'https://pay.example/inv' })
|
||||
assert.equal(ev.status, 'offen')
|
||||
} finally {
|
||||
await clearEvents('c1')
|
||||
}
|
||||
})
|
||||
|
||||
// F14: Abos von vor Plan 2 haben kein Ereignis abschluss -> Frist ab startedAt wie bisher
|
||||
test('Altbestand ohne Ereignis abschluss: Widerrufsfrist ab startedAt', async () => {
|
||||
const startedAt = daysAgo(2)
|
||||
aw.seed('subscriptions', { ...hostingDoc, startedAt, termStartAt: startedAt })
|
||||
const r = await get('/api/abo', { cookie: customerCookie() })
|
||||
const until = withdrawalDeadline(startedAt).endsAt.toISOString()
|
||||
assert.deepEqual(r.subscription.withdrawals, [{ type: 'abschluss', eventId: null, until }])
|
||||
assert.equal(r.subscription.withdrawalOpen, true)
|
||||
assert.equal(r.subscription.withdrawalUntil, until)
|
||||
|
||||
// Mit Ereignis zaehlt allein das Ereignis (hier: Vertragsschluss vor 40 Tagen, Frist vorbei)
|
||||
await recordContractEvent({
|
||||
id: 'abschluss-sub_h', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'hosting',
|
||||
details: { contractAt: daysAgo(40) }, consent: { ...consent, consentAt: daysAgo(40) },
|
||||
})
|
||||
try {
|
||||
const again = await get('/api/abo', { cookie: customerCookie() })
|
||||
assert.deepEqual(again.subscription.withdrawals, [])
|
||||
assert.equal(again.subscription.withdrawalOpen, false)
|
||||
} finally {
|
||||
await clearEvents('c1')
|
||||
}
|
||||
})
|
||||
|
||||
test('minTermEnd rechnet ab termStartAt, sonst ab trialEnd bzw. startedAt', async () => {
|
||||
aw.seed('subscriptions', { ...hostingDoc, termStartAt: '2026-10-05T10:00:00.000Z' })
|
||||
assert.equal((await get('/api/abo', { cookie: customerCookie() })).subscription.minTermEnd, '2027-10-05T10:00:00.000Z')
|
||||
aw.seed('subscriptions', { ...hostingDoc, termStartAt: null, trialEnd: '2026-11-01T05:00:00.000Z', status: 'trialing' })
|
||||
assert.equal((await get('/api/abo', { cookie: customerCookie() })).subscription.minTermEnd, '2027-11-01T05:00:00.000Z')
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
})
|
||||
|
||||
// F04/F05/F25: der Abschluss wird als Ereignis nachgewiesen, die Frist zaehlt ab dem Vertragsschluss bei
|
||||
// Stripe (startedAt), eine Webhook-Wiederholung legt kein zweites Ereignis an. Zuletzt, weil danach c3 ein Abo hat.
|
||||
test('Webhook checkout.session.completed: Ereignis abschluss ab Vertragsschluss, kein Duplikat', async () => {
|
||||
const stripeSub = stripeSubscription({ id: 'sub_new', customerId: 'c3', productKey: 'hosting', metadata: { termsAcceptedAt: '2026-09-20T10:00:00.000Z' } })
|
||||
st.subscriptions.set('sub_new', stripeSub)
|
||||
const event = {
|
||||
id: 'evt_1',
|
||||
object: 'event',
|
||||
type: 'checkout.session.completed',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
data: {
|
||||
object: {
|
||||
id: 'cs_done', object: 'checkout.session', mode: 'subscription', subscription: 'sub_new', customer: 'cus_c3',
|
||||
customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' },
|
||||
metadata: { webklarCustomerId: 'c3', productKey: 'hosting' },
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
const first = await postWebhook(event)
|
||||
assert.equal(first.status, 200, JSON.stringify(first.body))
|
||||
const events = await listContractEvents('c3')
|
||||
assert.equal(events.length, 1)
|
||||
const [e] = events
|
||||
assert.equal(e.$id, 'abschluss-sub_new')
|
||||
assert.equal(e.type, 'abschluss')
|
||||
assert.equal(e.productKey, 'hosting')
|
||||
assert.equal(e.status, 'erledigt')
|
||||
assert.equal(e.details.contractAt, new Date(stripeSub.start_date * 1000).toISOString())
|
||||
assert.equal(e.termsVersion, config.abo.termsVersion)
|
||||
assert.equal(e.consentAt, '2026-09-20T10:00:00.000Z')
|
||||
assert.equal(e.consentIp, '127.0.0.1')
|
||||
assert.equal(e.consentUserAgent, 'test')
|
||||
assert.equal(aw.docs('subscriptions').get('sub_new').productKey, 'hosting')
|
||||
|
||||
const again = await postWebhook(event)
|
||||
assert.equal(again.status, 200)
|
||||
assert.equal((await listContractEvents('c3')).length, 1)
|
||||
|
||||
// Falsche Signatur: 400, nichts verarbeitet
|
||||
assert.equal((await postWebhook(event, { secret: 'whsec_falsch' })).status, 400)
|
||||
} finally {
|
||||
await clearEvents('c3')
|
||||
await deleteDocument(config.collections.subscriptions, 'sub_new').catch(() => {})
|
||||
}
|
||||
})
|
||||
@@ -12,6 +12,8 @@ export async function startFakeStripe() {
|
||||
const products = new Map()
|
||||
const coupons = new Map()
|
||||
const billingPortalConfigurations = new Map()
|
||||
const customers = new Map()
|
||||
const checkoutSessions = new Map()
|
||||
const calls = []
|
||||
let seq = 0
|
||||
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
|
||||
@@ -115,6 +117,44 @@ export async function startFakeStripe() {
|
||||
coupons.set(id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
// Checkout (Neuabschluss): Kunde suchen/anlegen, Checkout-Sitzung anlegen (F10)
|
||||
if (method === 'GET' && path === '/v1/customers/search') {
|
||||
// Suchsyntax wie in getOrCreateStripeCustomer: metadata['webklarCustomerId']:'c1'
|
||||
const m2 = (query.get('query') || '').match(/metadata\['(\w+)'\]:'([^']*)'/)
|
||||
const found = [...customers.values()].filter((c) => !m2 || c.metadata?.[m2[1]] === m2[2])
|
||||
return [200, { object: 'search_result', data: found, has_more: false, url: '/v1/customers/search' }]
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/customers') {
|
||||
const data = unflatten(body)
|
||||
const obj = {
|
||||
id: nextId('cus'),
|
||||
object: 'customer',
|
||||
email: data.email ?? null,
|
||||
name: data.name ?? null,
|
||||
address: data.address ?? null,
|
||||
invoice_settings: data.invoice_settings || {},
|
||||
metadata: data.metadata || {},
|
||||
}
|
||||
customers.set(obj.id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/checkout/sessions') {
|
||||
const data = unflatten(body)
|
||||
const id = nextId('cs')
|
||||
const obj = {
|
||||
id,
|
||||
object: 'checkout.session',
|
||||
url: `https://checkout.stripe.test/c/pay/${id}`,
|
||||
mode: data.mode || 'payment',
|
||||
customer: data.customer ?? null,
|
||||
client_reference_id: data.client_reference_id ?? null,
|
||||
subscription: null,
|
||||
metadata: data.metadata || {},
|
||||
subscription_data: data.subscription_data || {},
|
||||
}
|
||||
checkoutSessions.set(id, obj)
|
||||
return [200, obj]
|
||||
}
|
||||
if (method === 'GET' && path === '/v1/billing_portal/configurations') {
|
||||
const active = query.get('active')
|
||||
const found = [...billingPortalConfigurations.values()].filter(
|
||||
@@ -172,6 +212,8 @@ export async function startFakeStripe() {
|
||||
products,
|
||||
coupons,
|
||||
billingPortalConfigurations,
|
||||
customers,
|
||||
checkoutSessions,
|
||||
close: () => new Promise((resolve) => server.close(resolve)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// testApp.js zuerst: es prueft beim Laden, dass useTestEnv() gelaufen ist, bevor config.js die Umgebung liest
|
||||
import { sessionCookie } from './testApp.js'
|
||||
import { config } from '../../server/config.js'
|
||||
|
||||
/**
|
||||
* Feste Testdaten für die Routen-Tests (Aufgaben 7–13): Kunden c1–c3, Abo-Dokumente, Zustimmung,
|
||||
* Session-Cookies. seedFixtures(aw) legt Kunden und Abos in den Appwrite-Nachbau; c3 hat kein Abo
|
||||
* (Neuabschluss-Tests). Erst nach useTestEnv() dynamisch importieren (siehe testApp.js).
|
||||
*/
|
||||
|
||||
const DAY_MS = 86400000
|
||||
const daysFromNow = (days) => new Date(Date.now() + days * DAY_MS).toISOString()
|
||||
|
||||
export const customers = {
|
||||
c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },
|
||||
c2: { $id: 'c2', appwriteUserId: 'u2', email: 'k2@example.invalid', name: 'Kunde Zwei', status: 'active', portalAccessEnabled: true },
|
||||
c3: { $id: 'c3', appwriteUserId: 'u3', email: 'k3@example.invalid', name: 'Kunde Drei', status: 'active', portalAccessEnabled: true },
|
||||
}
|
||||
|
||||
// Abo seit 40 Tagen (Widerrufsfrist des Abschlusses vorbei), naechste Abbuchung in 20 Tagen
|
||||
const startedAt = daysFromNow(-40)
|
||||
|
||||
/** Hosting-Abo sub_h von c1: aktiv, Fassung 4, ohne Zusatzspeicher. */
|
||||
export const hostingDoc = {
|
||||
$id: 'sub_h',
|
||||
customerId: 'c1',
|
||||
email: 'k@example.invalid',
|
||||
stripeCustomerId: 'cus_c1',
|
||||
stripeSubscriptionId: 'sub_h',
|
||||
status: 'active',
|
||||
productKey: 'hosting',
|
||||
planKey: config.products.hosting.lookupKey,
|
||||
priceAmount: config.products.hosting.amountCents,
|
||||
startedAt,
|
||||
termStartAt: startedAt,
|
||||
currentPeriodEnd: daysFromNow(20),
|
||||
trialEnd: null,
|
||||
cancelAtPeriodEnd: false,
|
||||
cancelAt: null,
|
||||
canceledAt: null,
|
||||
endedAt: null,
|
||||
termsVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt: startedAt,
|
||||
earlyStartConsentAt: startedAt,
|
||||
startChoice: 'sofort',
|
||||
consentIp: '127.0.0.1',
|
||||
consentUserAgent: 'test',
|
||||
consentTextVersion: config.abo.termsVersion,
|
||||
storageUnits: 0,
|
||||
storagePaidUnits: 0,
|
||||
storagePaidPeriodEnd: null,
|
||||
}
|
||||
|
||||
/** Wartungs-Abo sub_w von c1 (im Test meist als sub_h eingespielt, damit c1 nur ein Abo hat). */
|
||||
export const wartungDoc = {
|
||||
...hostingDoc,
|
||||
$id: 'sub_w',
|
||||
stripeSubscriptionId: 'sub_w',
|
||||
productKey: 'wartung',
|
||||
planKey: config.products.wartung.lookupKey,
|
||||
priceAmount: config.products.wartung.amountCents,
|
||||
}
|
||||
|
||||
/** Zweites Hosting-Abo sub_h2 von c2. */
|
||||
export const hosting2Doc = {
|
||||
...hostingDoc,
|
||||
$id: 'sub_h2',
|
||||
customerId: 'c2',
|
||||
email: 'k2@example.invalid',
|
||||
stripeCustomerId: 'cus_c2',
|
||||
stripeSubscriptionId: 'sub_h2',
|
||||
}
|
||||
|
||||
const consentAt = new Date().toISOString()
|
||||
|
||||
/** Zustimmung im Portal (Bestellung, Wechsel, Speicher-Plus); earlyStartConsentAt fuer den Wechsel (F16). */
|
||||
export const consent = {
|
||||
termsVersion: config.abo.termsVersion,
|
||||
consentAt,
|
||||
consentIp: '127.0.0.1',
|
||||
consentUserAgent: 'test',
|
||||
earlyStartConsentAt: consentAt,
|
||||
}
|
||||
|
||||
/** Kunden-Session; Standard c1. */
|
||||
export const customerCookie = (customerId = 'c1') =>
|
||||
sessionCookie({ customerId, appwriteUserId: customers[customerId]?.appwriteUserId || `u_${customerId}` })
|
||||
|
||||
/** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */
|
||||
export const adminCookie = () =>
|
||||
sessionCookie({ role: 'admin', appwriteUserId: 'u_admin', name: 'Kenso', email: 'kenso@webklar.com' })
|
||||
|
||||
/** Kunden c1–c3 und die Abos sub_h (c1) und sub_h2 (c2) in den Appwrite-Nachbau legen. */
|
||||
export function seedFixtures(aw) {
|
||||
for (const customer of Object.values(customers)) aw.seed('customers', customer)
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
aw.seed('subscriptions', hosting2Doc)
|
||||
}
|
||||
|
||||
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
|
||||
|
||||
/**
|
||||
* Stripe-Abo in der Form der SDK-API-Version für den Stripe-Nachbau: Abo-Posten je Produkt,
|
||||
* dazu ein Zusatzspeicher-Posten, wenn storageUnits > 0. metadata ergaenzt/ueberschreibt die
|
||||
* Zustimmungsdaten, rest alles Weitere (status, cancel_at, ...).
|
||||
*/
|
||||
export function stripeSubscription({ id = 'sub_h', customerId = 'c1', productKey = 'hosting', storageUnits = 0, metadata = {}, ...rest } = {}) {
|
||||
const product = config.products[productKey]
|
||||
const items = [
|
||||
{
|
||||
id: `si_${id}_abo`,
|
||||
object: 'subscription_item',
|
||||
quantity: 1,
|
||||
price: { id: `price_${productKey}`, object: 'price', lookup_key: product.lookupKey, unit_amount: product.amountCents },
|
||||
},
|
||||
]
|
||||
if (storageUnits > 0) {
|
||||
items.push({
|
||||
id: `si_${id}_speicher`,
|
||||
object: 'subscription_item',
|
||||
quantity: storageUnits,
|
||||
price: { id: 'price_mailStorage', object: 'price', lookup_key: config.mailStorage.lookupKey, unit_amount: config.mailStorage.amountCents },
|
||||
})
|
||||
}
|
||||
return {
|
||||
id,
|
||||
object: 'subscription',
|
||||
status: 'active',
|
||||
customer: `cus_${customerId}`,
|
||||
start_date: unix(hostingDoc.startedAt),
|
||||
current_period_end: unix(hostingDoc.currentPeriodEnd),
|
||||
trial_end: null,
|
||||
cancel_at_period_end: false,
|
||||
cancel_at: null,
|
||||
canceled_at: null,
|
||||
ended_at: null,
|
||||
metadata: {
|
||||
webklarCustomerId: customerId,
|
||||
email: customers[customerId]?.email || '',
|
||||
productKey,
|
||||
termsVersion: config.abo.termsVersion,
|
||||
consentTextVersion: config.abo.termsVersion,
|
||||
termsAcceptedAt: consentAt,
|
||||
startChoice: 'sofort',
|
||||
earlyStartConsentAt: consentAt,
|
||||
consentIp: '127.0.0.1',
|
||||
consentUserAgent: 'test',
|
||||
...metadata,
|
||||
},
|
||||
items: { object: 'list', data: items },
|
||||
...rest,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import http from 'node:http'
|
||||
import express from 'express'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import signature from 'cookie-signature'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
/**
|
||||
* Express-App für Routen-Tests: wie server/index.js mit cookieParser(SESSION_SECRET) und
|
||||
* express.json samt rawBody (Stripe-Webhook), aber nur mit den Routern, die der Test übergibt:
|
||||
* const app = await startTestApp({ '/api/abo': aboRoutes, '/api/portal-admin': portalAdminRoutes })
|
||||
*
|
||||
* Reihenfolge im Test: Nachbauten starten, useTestEnv(), dann dieses Modul und die Server-Module
|
||||
* dynamisch importieren (assertTestEnv() weigert sich sonst, weil config.js die Umgebung beim
|
||||
* ersten Import liest). Die Anfrage-Helfer sprechen die zuletzt gestartete App an.
|
||||
*/
|
||||
|
||||
function assertTestEnv() {
|
||||
for (const name of ['APPWRITE_ENDPOINT', 'STRIPE_API_BASE']) {
|
||||
let host = null
|
||||
try {
|
||||
host = new URL(process.env[name] || '').hostname
|
||||
} catch {
|
||||
host = null
|
||||
}
|
||||
if (host !== '127.0.0.1') {
|
||||
throw new Error(`testApp: ${name} zeigt nicht auf 127.0.0.1 – useTestEnv() vor dem Import aufrufen`)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTestEnv()
|
||||
|
||||
const cookieName = () => process.env.SESSION_COOKIE_NAME || 'webklar_portal_session'
|
||||
const sessionSecret = () => process.env.SESSION_SECRET || ''
|
||||
|
||||
/**
|
||||
* Signierter Session-Cookie wie setPortalSession (cookie-parser: Praefix "s:" + cookie-signature).
|
||||
* Kunde: { customerId, appwriteUserId }. Admin: { role: 'admin', appwriteUserId, name, email }.
|
||||
*/
|
||||
export function sessionCookie(session) {
|
||||
const value = `s:${signature.sign(JSON.stringify(session), sessionSecret())}`
|
||||
return `${cookieName()}=${encodeURIComponent(value)}`
|
||||
}
|
||||
|
||||
let baseUrl = null
|
||||
|
||||
/** App mit den Routern (Pfad -> Router) auf 127.0.0.1 starten; liefert { url, close }. */
|
||||
export async function startTestApp(routers = {}) {
|
||||
const app = express()
|
||||
app.set('trust proxy', 1)
|
||||
app.use(cookieParser(sessionSecret()))
|
||||
app.use(
|
||||
express.json({
|
||||
limit: '2mb',
|
||||
verify: (req, _res, buf) => {
|
||||
req.rawBody = buf
|
||||
},
|
||||
})
|
||||
)
|
||||
for (const [mountPath, router] of Object.entries(routers)) app.use(mountPath, router)
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (!res.headersSent) res.status(err.status || 500).json({ error: err.message || 'Interner Serverfehler' })
|
||||
})
|
||||
const server = http.createServer(app)
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||
return { url: baseUrl, close: () => new Promise((resolve) => server.close(resolve)) }
|
||||
}
|
||||
|
||||
async function request(method, path, { body, cookie, headers = {} } = {}) {
|
||||
if (!baseUrl) throw new Error('testApp: startTestApp() zuerst aufrufen')
|
||||
const res = await fetch(baseUrl + path, {
|
||||
method,
|
||||
headers: {
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body),
|
||||
redirect: 'manual',
|
||||
})
|
||||
const text = await res.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
parsed = text
|
||||
}
|
||||
return { status: res.status, body: parsed, headers: res.headers }
|
||||
}
|
||||
|
||||
/** GET -> { status, body } */
|
||||
export const rawGet = (path, opts = {}) => request('GET', path, opts)
|
||||
|
||||
/** GET -> JSON-Body; ab Status 400 fliegt ein Fehler mit err.status und err.body. */
|
||||
export async function get(path, opts = {}) {
|
||||
const { status, body } = await rawGet(path, opts)
|
||||
if (status >= 400) {
|
||||
const err = new Error(`GET ${path} → ${status}: ${body?.error || JSON.stringify(body)}`)
|
||||
err.status = status
|
||||
err.body = body
|
||||
throw err
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** POST mit JSON-Body -> { status, body } */
|
||||
export const post = (path, body, opts = {}) => request('POST', path, { ...opts, body })
|
||||
|
||||
// Signiert wie Stripe (whsec_test aus useTestEnv); der Schluessel wird nie fuer Netzaufrufe benutzt
|
||||
const webhookSigner = new Stripe('sk_test_fake')
|
||||
|
||||
/** Stripe-Ereignis signiert an den Webhook schicken (F25) -> { status, body }. */
|
||||
export function postWebhook(event, { path = '/api/abo/webhook', secret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_test' } = {}) {
|
||||
const payload = JSON.stringify(event)
|
||||
const stripeSignature = webhookSigner.webhooks.generateTestHeaderString({ payload, secret })
|
||||
return request('POST', path, { body: payload, headers: { 'stripe-signature': stripeSignature } })
|
||||
}
|
||||
Reference in new issue
Block a user