diff --git a/server/config.js b/server/config.js
index 1601fdb..4ef99a6 100644
--- a/server/config.js
+++ b/server/config.js
@@ -144,6 +144,13 @@ export const config = {
products,
mailStorage,
newsSetup,
+ // Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung,
+ // der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB)
+ funktionen: {
+ setupEstimate: 'meist innerhalb von 1–2 Wochen',
+ consentText:
+ 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
+ },
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
// Die Werte kommen aus dem Produktkatalog (products.wartung).
diff --git a/server/index.js b/server/index.js
index 107c3b5..8c75f8f 100644
--- a/server/index.js
+++ b/server/index.js
@@ -12,9 +12,11 @@ import customerDataRoutes from './routes/customerData.js'
import portalAdminRoutes from './routes/portalAdmin.js'
import chatRoutes from './routes/chat.js'
import aboRoutes from './routes/abo.js'
+import funktionenRoutes from './routes/funktionen.js'
import takeoverCreditRoutes from './routes/takeoverCredits.js'
import { purgeExpiredUploads } from './services/takeoverCredits.js'
import { purgePendingChatUploads } from './services/chatAttachments.js'
+import { runActivation } from './services/siteFeatures.js'
import giteaWebhookRoutes from './routes/webhook/gitea.js'
import pushTimeRoutes from './routes/pushTime.js'
import adminGiteaRoutes from './routes/admin/gitea.js'
@@ -60,6 +62,7 @@ app.use('/api', customerDataRoutes)
app.use('/api/chat', chatRoutes)
app.use('/api/abo/takeover-credits', takeoverCreditRoutes)
app.use('/api/abo', aboRoutes)
+app.use('/api/funktionen', funktionenRoutes)
app.use('/api/portal-admin', portalAdminRoutes)
app.use('/api/admin/gitea', adminGiteaRoutes)
app.use('/api/admin/website-projects', adminWebsiteProjectsRoutes)
@@ -130,6 +133,13 @@ const server = app.listen(config.port, '0.0.0.0', () => {
.catch((err) => console.warn('[chat] Aufräumen fehlgeschlagen:', err.message))
setTimeout(purgeChat, 90 * 1000).unref()
setInterval(purgeChat, 60 * 60 * 1000).unref()
+
+ // Funktionen (News-Pinnwand): geschlossene Einrichtungs-Tickets freischalten und den Kunden benachrichtigen (alle 5 Minuten)
+ const activateFeatures = () =>
+ runActivation()
+ .then((done) => done.length && console.log(`[funktionen] ${done.length} Funktion(en) freigeschaltet`))
+ .catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message))
+ setInterval(activateFeatures, 5 * 60 * 1000).unref()
})
server.on('error', (err) => {
diff --git a/server/mail-templates/K15 Bestellung Funktion.html b/server/mail-templates/K15 Bestellung Funktion.html
new file mode 100644
index 0000000..53d7b01
--- /dev/null
+++ b/server/mail-templates/K15 Bestellung Funktion.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+Deine Bestellung: News-Pinnwand
+
+
+
+
+Deine Bestellung der News-Pinnwand ist bei uns eingegangen. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Bestätigung |
+
|
+
+
+ |
+| Deine Bestellung: News-Pinnwand |
+Hallo {{kunde_name}}, vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein. |
+| Funktion | News-Pinnwand – Einrichtung | | Projekt | {{projekt}} | | Einmalig bezahlt | {{preis}} (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo) | | Einrichtung | {{zeit}} |
|
+Wie geht es weiter? Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben. |
|
+
+ |
+
+ |
+Viele Grüße Dein WEBklar-Team |
+
+ |
+| Kundenportal |
+WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler Telefon 0176 23726355 · support@webklar.com Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein |
+
+ |
+
+
+
diff --git a/server/mail-templates/K16 Funktion bereit.html b/server/mail-templates/K16 Funktion bereit.html
new file mode 100644
index 0000000..f9c2fde
--- /dev/null
+++ b/server/mail-templates/K16 Funktion bereit.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+Deine News-Pinnwand ist bereit
+
+
+
+
+Deine News-Pinnwand ist freigeschaltet. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Bereit |
+
|
+
+
+ |
+| Deine News-Pinnwand ist bereit |
+Hallo {{kunde_name}}, die News-Pinnwand ist auf Deiner Website eingebaut und freigeschaltet. Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite. |
+| Funktion | News-Pinnwand | | Projekt | {{projekt}} |
|
+ |
+Viele Grüße Dein WEBklar-Team |
+
+ |
+| Kundenportal |
+WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler Telefon 0176 23726355 · support@webklar.com Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein |
+
+ |
+
+
+
diff --git a/server/mail-templates/T16 Funktion bestellt.html b/server/mail-templates/T16 Funktion bestellt.html
new file mode 100644
index 0000000..4a52403
--- /dev/null
+++ b/server/mail-templates/T16 Funktion bestellt.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+Neue Funktion bestellt: News-Pinnwand
+
+
+
+
+{{kunde_name}}: News-Pinnwand, Projekt {{projekt}}. ͏ ͏ ͏
+
+
+
+
+ | WEBklar |
|
+Intern · Kundenportal |
+
|
+
+
+ |
+| Neue Funktion bestellt: News-Pinnwand |
+| Kunde | {{kunde_name}} | | E-Mail | {{email}} | | Projekt | {{projekt}} | | Ticket | {{woid}} | | Site-Key | {{sitekey}} |
|
+
+Zahlung fehlgeschlagen Die Zahlung ist nicht eingegangen (zum Beispiel SEPA-Lastschrift abgelehnt). Es wurde nichts eingerichtet und kein Ticket angelegt; die Bestellung steht im Portal wieder auf „nicht bestellt“. |
|
+
+| |
+
+ |
+| Automatische Benachrichtigung aus dem WEBklar Kundenportal · Admin-Bereich |
+
+ |
+
+
+
diff --git a/server/routes/abo.js b/server/routes/abo.js
index edfb105..eed9c3c 100644
--- a/server/routes/abo.js
+++ b/server/routes/abo.js
@@ -18,6 +18,7 @@ import {
reconcilePendingSwitch,
switchToWartung,
} from '../services/planSwitch.js'
+import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent } from '../services/siteFeatures.js'
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
import {
ACTIVE_STATUSES,
@@ -518,6 +519,11 @@ async function recordAbschluss(sub, session) {
async function handleStripeEvent(event) {
const obj = event.data.object
+ // Einmalkauf einer Funktion (News-Pinnwand): eigener Weg, der Abo-Zweig unten bleibt unberuehrt
+ if (isFeatureCheckoutEvent(event)) {
+ await handleFeatureCheckoutEvent(event)
+ return
+ }
switch (event.type) {
case 'checkout.session.completed': {
if (obj.mode !== 'subscription' || !obj.subscription) return
diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js
new file mode 100644
index 0000000..aedc630
--- /dev/null
+++ b/server/routes/funktionen.js
@@ -0,0 +1,63 @@
+import { Router } from 'express'
+import { config } from '../config.js'
+import { getDocument } from '../services/appwriteAdmin.js'
+import { getSessionCustomerId, requireSession } from '../middleware/session.js'
+import { createFeatureCheckout } from '../services/stripeBilling.js'
+import { assertOwnProject, getFeatureDoc, listCustomerFeatures, markOrdered, runActivation } from '../services/siteFeatures.js'
+
+const router = Router()
+const DAY_MS = 24 * 60 * 60 * 1000
+
+function fail(res, err) {
+ const status = err.status >= 400 && err.status < 600 ? err.status : 500
+ return res.status(status).json({ error: err.message || 'Interner Serverfehler' })
+}
+
+// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
+router.get('/', requireSession, async (req, res) => {
+ try {
+ const customerId = getSessionCustomerId(req)
+ await runActivation({ customerId })
+ res.json({ projects: await listCustomerFeatures(customerId), setupEstimate: config.funktionen.setupEstimate })
+ } catch (err) {
+ fail(res, err)
+ }
+})
+
+router.post('/news/checkout', requireSession, async (req, res) => {
+ try {
+ if (req.session.viewAs) {
+ return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nicht bestellt werden.' })
+ }
+ const { projectId, consent } = req.body || {}
+ if (consent !== true) {
+ return res.status(400).json({ error: 'Bitte stimme der Erklärung zu, um zu bestellen.' })
+ }
+ if (typeof projectId !== 'string' || !projectId) {
+ return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
+ }
+ const customerId = getSessionCustomerId(req)
+ await assertOwnProject(customerId, projectId)
+ // Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv oder eine noch gueltige offene Bestellung -> 409
+ 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) {
+ return res.status(409).json({ error: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.' })
+ }
+ const customer = await getDocument(config.collections.customers, customerId)
+ const now = new Date().toISOString()
+ const session = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
+ await markOrdered({
+ customerId,
+ projectId,
+ key: 'news',
+ sessionId: session.id,
+ consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') },
+ })
+ res.json({ url: session.url })
+ } catch (err) {
+ fail(res, err)
+ }
+})
+
+export default router
diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js
index 3b55a1c..430043b 100644
--- a/server/services/siteFeatures.js
+++ b/server/services/siteFeatures.js
@@ -2,6 +2,8 @@ import { createHash, randomBytes } from 'node:crypto'
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 { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = ['news']
@@ -200,13 +202,20 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) {
})
}
-/** Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. */
+/**
+ * Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck.
+ * Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte
+ * Bestellung ('setup') oder eine aktive Funktion anfasst.
+ */
export async function markCheckoutFailed(sessionId) {
- const doc = await findBySession(sessionId)
- if (!doc || doc.state !== 'ordered') return doc
- return updateDocument(config.collections.portalFeatures, doc.$id, {
- ...stateFields('off'),
- purchaseStatus: 'failed',
+ if (!sessionId) return null
+ return withLocks([`feature-paid:${sessionId}`], async () => {
+ const doc = await findBySession(sessionId)
+ if (!doc || doc.state !== 'ordered') return doc
+ return updateDocument(config.collections.portalFeatures, doc.$id, {
+ ...stateFields('off'),
+ purchaseStatus: 'failed',
+ })
})
}
@@ -274,3 +283,134 @@ export async function findActiveBySiteKey(siteKey) {
])
return doc || null
}
+
+// ---------------------------------------------------------------------------
+// Stripe-Webhook, Mails und Freischalt-Job
+// ---------------------------------------------------------------------------
+
+const eur = (cents) => `${(cents / 100).toFixed(2).replace('.', ',')} €`
+const portalUrl = () => `${config.publicBaseUrl}/dashboard.html#news`
+
+/** Ereignisse, die handleFeatureCheckoutEvent versteht (Funktions-Checkout: mode 'payment' mit featureKey). */
+export const FEATURE_EVENT_TYPES = [
+ 'checkout.session.completed',
+ 'checkout.session.async_payment_succeeded',
+ 'checkout.session.async_payment_failed',
+ 'checkout.session.expired',
+]
+
+export function isFeatureCheckoutEvent(event) {
+ const obj = event?.data?.object
+ return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type)
+}
+
+async function projectName(projectId) {
+ try {
+ return (await getDocument(config.collections.websiteProjects, projectId)).projectName || ''
+ } catch (err) {
+ if (err.status === 404) return ''
+ throw err
+ }
+}
+
+async function customerOf(customerId) {
+ try {
+ return await getDocument(config.collections.customers, customerId)
+ } catch (err) {
+ if (err.status === 404) return null
+ throw err
+ }
+}
+
+/** Link zur Stripe-Rechnung; fehlt sie noch oder ist sie nicht lesbar, geht die Mail ohne Link raus. */
+async function invoiceOf(invoiceRef) {
+ const id = typeof invoiceRef === 'string' ? invoiceRef : invoiceRef?.id
+ if (!id) return { invoiceId: '', invoiceUrl: '' }
+ try {
+ const invoice = await getStripe().invoices.retrieve(id)
+ return { invoiceId: id, invoiceUrl: invoice.hosted_invoice_url || '' }
+ } catch (err) {
+ console.warn(`[funktionen] Rechnung ${id} nicht lesbar:`, err.message)
+ return { invoiceId: id, invoiceUrl: '' }
+ }
+}
+
+async function ticketWoidOf(doc) {
+ return doc.ticketId ? (await ticketWoid(doc.ticketId)) || '' : ''
+}
+
+async function teamNotice(doc, customer, { failed = false } = {}) {
+ await sendAdminNotice({
+ template: 'T16',
+ values: {
+ kunde_name: customer?.name || customer?.email || doc.customerId,
+ email: customer?.email || '',
+ projekt: await projectName(doc.projectId),
+ woid: failed ? '' : await ticketWoidOf(doc),
+ sitekey: failed ? '' : doc.siteKey || '',
+ },
+ blocks: { zahlung_fehlgeschlagen: failed },
+ })
+}
+
+async function onFeaturePaid(session) {
+ const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
+ const { doc, created } = await markPaid({ sessionId: session.id, invoiceId, invoiceUrl })
+ if (!created) return
+ const customer = await customerOf(doc.customerId)
+ if (customer?.email) {
+ await sendCustomerMail(customer.email, {
+ template: 'K15',
+ values: {
+ kunde_name: customer.name || '',
+ projekt: await projectName(doc.projectId),
+ preis: eur(config.newsSetup.amountCents),
+ zeit: config.funktionen.setupEstimate,
+ rechnung_url: invoiceUrl,
+ },
+ blocks: { rechnung: Boolean(invoiceUrl) },
+ }).catch((err) => console.error('[funktionen] K15 fehlgeschlagen:', err.message))
+ }
+ await teamNotice(doc, customer)
+}
+
+/** Stripe-Ereignis eines Funktions-Checkouts (isFeatureCheckoutEvent); idempotent, Fehler -> Stripe wiederholt. */
+export async function handleFeatureCheckoutEvent(event) {
+ const session = event.data.object
+ switch (event.type) {
+ case 'checkout.session.completed':
+ // SEPA: die Sitzung ist abgeschlossen, das Geld noch nicht da -> erst async_payment_succeeded
+ if (session.payment_status !== 'paid') return
+ await onFeaturePaid(session)
+ return
+ case 'checkout.session.async_payment_succeeded':
+ await onFeaturePaid(session)
+ return
+ case 'checkout.session.async_payment_failed': {
+ const doc = await markCheckoutFailed(session.id)
+ if (doc && doc.state === 'off') await teamNotice(doc, await customerOf(doc.customerId), { failed: true })
+ return
+ }
+ case 'checkout.session.expired':
+ await markCheckoutFailed(session.id)
+ return
+ }
+}
+
+/** Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden. */
+export async function runActivation({ now = new Date(), customerId = null } = {}) {
+ const activated = await activateClosedTickets({ now, customerId })
+ for (const doc of activated) {
+ try {
+ const customer = await customerOf(doc.customerId)
+ if (!customer?.email) continue
+ await sendCustomerMail(customer.email, {
+ template: 'K16',
+ values: { kunde_name: customer.name || '', projekt: await projectName(doc.projectId), portal_url: portalUrl() },
+ })
+ } catch (err) {
+ console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
+ }
+ }
+ return activated
+}
diff --git a/server/services/stripeBilling.js b/server/services/stripeBilling.js
index cf7c071..4da6906 100644
--- a/server/services/stripeBilling.js
+++ b/server/services/stripeBilling.js
@@ -591,6 +591,48 @@ export async function createAboCheckout(customer, consent, productKey, offer = n
return session.url
}
+/**
+ * Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung
+ * verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die
+ * Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht.
+ * @returns {Promise<{ id: string, url: string }>}
+ */
+export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
+ const stripe = getStripe()
+ const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)])
+ const params = {
+ mode: 'payment',
+ customer: stripeCustomerId,
+ client_reference_id: customer.$id,
+ line_items: [{ price: priceId, quantity: 1 }],
+ payment_method_types: ['card', 'sepa_debit'],
+ locale: 'de',
+ billing_address_collection: 'required',
+ customer_update: { address: 'auto', name: 'auto' },
+ invoice_creation: {
+ enabled: true,
+ invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter },
+ },
+ metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
+ expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
+ success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`,
+ cancel_url: `${config.publicBaseUrl}/dashboard.html#news`,
+ }
+ let session
+ try {
+ session = await stripe.checkout.sessions.create(params)
+ } catch (err) {
+ // SEPA-Lastschrift noch nicht im Stripe-Konto aktiviert -> nur Karte anbieten (wie beim Abo-Checkout)
+ if (/sepa_debit|payment_method_types/i.test(err.message || '')) {
+ console.warn('[stripe] SEPA-Lastschrift nicht verfuegbar, Checkout nur mit Karte:', err.message)
+ session = await stripe.checkout.sessions.create({ ...params, payment_method_types: ['card'] })
+ } else {
+ throw err
+ }
+ }
+ return { id: session.id, url: session.url }
+}
+
export async function createBillingPortalUrl(customerId) {
const sub = await getCurrentSubscription(customerId)
if (!sub?.stripeCustomerId) {
diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js
new file mode 100644
index 0000000..205842c
--- /dev/null
+++ b/test/funktionenRoutes.test.js
@@ -0,0 +1,180 @@
+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'
+
+// websiteProjects, workorders und customers legt das Setup-Skript nicht an: ohne Schema im Nachbau
+const aw = await startAppwriteMock({
+ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions') },
+})
+const st = await startFakeStripe()
+useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
+const { config } = await import('../server/config.js')
+const { startTestApp, get, post, postWebhook } = await import('./support/testApp.js')
+const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
+const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
+const { default: aboRoutes } = await import('../server/routes/abo.js')
+const sf = await import('../server/services/siteFeatures.js')
+const { setMailTransportForTests } = await import('../server/services/mailer.js')
+const { mailSink } = await import('./support/mailSink.js')
+
+const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes })
+const mails = mailSink()
+setMailTransportForTests(mails)
+after(() => {
+ setMailTransportForTests(null)
+ return Promise.all([app.close(), aw.close(), st.close()])
+})
+seedFixtures(aw)
+aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' })
+aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' })
+aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Kiosk' })
+aw.seed('websiteProjects', { $id: 'p4', customerId: 'c1', projectName: 'Bistro' })
+aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd' })
+
+const cookie = customerCookie('c1')
+const checkout = (projectId, consent = true) => post('/api/funktionen/news/checkout', { projectId, consent }, { cookie })
+const sessions = () => [...st.checkoutSessions.values()]
+const workorders = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('News-Pinnwand einbauen'))
+const subjects = () => mails.sent.map((m) => m.subject)
+const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
+const paidSession = (id, projectId, extra = {}) => ({
+ id,
+ object: 'checkout.session',
+ mode: 'payment',
+ payment_status: 'paid',
+ metadata: { customerId: 'c1', projectId, featureKey: 'news' },
+ ...extra,
+})
+
+test('liste_zustaende', async () => {
+ const body = await get('/api/funktionen', { cookie })
+ assert.equal(body.setupEstimate, 'meist innerhalb von 1–2 Wochen')
+ assert.equal(body.projects.length, 4)
+ for (const p of body.projects) assert.equal(p.features.news.state, 'off')
+})
+
+test('checkout_ohne_zustimmung_400', async () => {
+ assert.equal((await checkout('p1', false)).status, 400)
+ assert.equal((await post('/api/funktionen/news/checkout', { projectId: 'p1' }, { cookie })).status, 400)
+ assert.equal(sessions().length, 0)
+})
+
+test('checkout_fremdes_projekt_403', async () => {
+ assert.equal((await checkout('p5')).status, 403)
+ assert.equal(sessions().length, 0)
+})
+
+test('checkout_legt_session_an', async () => {
+ const res = await checkout('p1')
+ assert.equal(res.status, 200)
+ assert.match(res.body.url, /^https:\/\/checkout\.stripe\.test\//)
+ const [session] = sessions()
+ assert.equal(session.mode, 'payment')
+ assert.equal(session.metadata.featureKey, 'news')
+ assert.equal(session.metadata.projectId, 'p1')
+ assert.equal(session.metadata.customerId, 'c1')
+ const call = st.calls.find((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions')
+ assert.equal(call.body['invoice_creation[enabled]'], 'true')
+ assert.equal(call.body.success_url, `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`)
+ assert.equal(call.body.cancel_url, `${config.publicBaseUrl}/dashboard.html#news`)
+ assert.ok(Number(call.body.expires_at) > Date.now() / 1000)
+ const price = [...st.prices.values()].find((p) => p.id === call.body['line_items[0][price]'])
+ assert.equal(price.unit_amount, 3000)
+ const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
+ assert.equal(doc.state, 'ordered')
+ assert.equal(doc.stripeSessionId, session.id)
+ assert.equal(doc.consentText, config.funktionen.consentText)
+ assert.ok(doc.consentAt)
+ assert.ok(doc.consentUserAgent)
+ // zweite Bestellung waehrend die erste offen ist: 409, keine neue Sitzung
+ assert.equal((await checkout('p1')).status, 409)
+ assert.equal(sessions().length, 1)
+})
+
+test('abgelaufene_bestellung_darf_neu_gestartet_werden', async () => {
+ const doc = await sf.getFeatureDoc('c1', 'p1', 'news')
+ aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get(doc.$id), orderedAt: new Date(Date.now() - 25 * 3600 * 1000).toISOString() })
+ assert.equal((await checkout('p1')).status, 200)
+ assert.equal(sessions().length, 2)
+})
+
+test('checkout_bei_setup_409', async () => {
+ const sid = 'cs_setup'
+ await sf.markOrdered({ customerId: 'c1', projectId: 'p2', key: 'news', sessionId: sid, consent: {} })
+ await sf.markPaid({ sessionId: sid })
+ const before = sessions().length
+ assert.equal((await checkout('p2')).status, 409)
+ assert.equal(sessions().length, before)
+})
+
+test('sepa_wartet_auf_zahlung', async () => {
+ const res = await checkout('p3')
+ assert.equal(res.status, 200)
+ const id = sessions().at(-1).id
+ const mailsBefore = mails.sent.length
+ const wosBefore = workorders().length
+ const unpaid = paidSession(id, 'p3', { payment_status: 'unpaid' })
+ assert.equal((await postWebhook(event('checkout.session.completed', unpaid))).status, 200)
+ assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'ordered')
+ assert.equal(workorders().length, wosBefore)
+ assert.equal(mails.sent.length, mailsBefore)
+ assert.equal((await postWebhook(event('checkout.session.async_payment_succeeded', paidSession(id, 'p3')))).status, 200)
+ assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'setup')
+ assert.equal(workorders().length, wosBefore + 1)
+ assert.deepEqual(subjects().slice(mailsBefore).sort(), ['Deine Bestellung: News-Pinnwand', 'Neue Funktion bestellt: News-Pinnwand'])
+ const k15 = mails.sent.at(-2).subject.startsWith('Deine') ? mails.sent.at(-2) : mails.sent.at(-1)
+ assert.equal(k15.to[0].address, 'k@example.invalid')
+ assert.match(k15.text, /30,00 €/)
+})
+
+test('webhook_idempotent', async () => {
+ await checkout('p4')
+ const id = sessions().at(-1).id
+ const mailsBefore = mails.sent.length
+ const wosBefore = workorders().length
+ for (let i = 0; i < 2; i += 1) {
+ assert.equal((await postWebhook(event('checkout.session.completed', paidSession(id, 'p4')))).status, 200)
+ }
+ assert.equal(workorders().length, wosBefore + 1)
+ assert.equal(mails.sent.length - mailsBefore, 2)
+ assert.equal(subjects().slice(mailsBefore).filter((s) => s.startsWith('Deine Bestellung')).length, 1)
+})
+
+test('abgelaufen_zurueck_auf_off', async () => {
+ aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Werkstatt' })
+ await checkout('p6')
+ const id = sessions().at(-1).id
+ const expired = { id, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p6', featureKey: 'news' } }
+ assert.equal((await postWebhook(event('checkout.session.expired', expired))).status, 200)
+ assert.equal((await sf.getFeatureDoc('c1', 'p6', 'news')).state, 'off')
+ assert.equal((await checkout('p6')).status, 200)
+})
+
+test('zahlung_fehlgeschlagen_mailt_team', async () => {
+ aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Garage' })
+ await checkout('p7')
+ const id = sessions().at(-1).id
+ const mailsBefore = mails.sent.length
+ await postWebhook(event('checkout.session.async_payment_failed', paidSession(id, 'p7', { payment_status: 'unpaid' })))
+ assert.equal((await sf.getFeatureDoc('c1', 'p7', 'news')).state, 'off')
+ assert.equal(mails.sent.length - mailsBefore, 1)
+ assert.match(mails.sent.at(-1).text, /Zahlung fehlgeschlagen/)
+})
+
+test('aktivierung_mailt', async () => {
+ const doc = await sf.getFeatureDoc('c1', 'p3', 'news')
+ assert.equal(doc.state, 'setup')
+ aw.seed('workorders', { ...aw.docs('workorders').get(doc.ticketId), status: 'Closed' })
+ const mailsBefore = mails.sent.length
+ const done = await sf.runActivation()
+ assert.equal(done.length, 1)
+ assert.equal((await sf.getFeatureDoc('c1', 'p3', 'news')).state, 'active')
+ assert.equal(mails.sent.length - mailsBefore, 1)
+ assert.equal(mails.sent.at(-1).subject, 'Deine News-Pinnwand ist bereit')
+ assert.equal(mails.sent.at(-1).to[0].address, 'k@example.invalid')
+ assert.deepEqual(await sf.runActivation(), [])
+ assert.equal(mails.sent.length - mailsBefore, 1)
+})
diff --git a/test/mailTemplates.test.js b/test/mailTemplates.test.js
index 7a6cb33..7e5f589 100644
--- a/test/mailTemplates.test.js
+++ b/test/mailTemplates.test.js
@@ -6,17 +6,20 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
-// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail
+// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand)
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K13',
'K14',
+ 'K15',
+ 'K16',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
'T13',
'T14',
'T15',
+ 'T16',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -33,7 +36,7 @@ for (const id of EXPECTED) {
const title = source.match(/([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und sind gleich')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben), [Datenschutz] (Loeschfristen) oder [Zeit] (Push-Zeitmail)
- assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit]')
+ assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz|Zeit)\]/.test(tpl.subject) || id === 'T01' || id === 'T16', 'Team-Hinweise ab T02 mit [Abo], [Speicher], [Datenschutz] oder [Zeit] (T16 Funktion bestellt ohne Kennung)')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
diff --git a/test/siteFeatures.test.js b/test/siteFeatures.test.js
index c58b261..b4f43e7 100644
--- a/test/siteFeatures.test.js
+++ b/test/siteFeatures.test.js
@@ -131,3 +131,34 @@ test('parallele_webhooks_ein_ticket', async () => {
assert.equal(tickets().length, before + 1)
assert.ok((await sf.getFeatureDoc('c1', 'p4', 'news')).ticketId)
})
+
+test('abgelaufen_fasst_setup_und_active_nicht_an', async () => {
+ // cs_1 ist bezahlt ('setup' bzw. nach dem Schliessen des Tickets 'active'): ein spaetes expired aendert nichts
+ const before = await sf.getFeatureDoc('c1', 'p1', 'news')
+ assert.notEqual(before.state, 'ordered')
+ const after = await sf.markCheckoutFailed('cs_1')
+ assert.equal(after.state, before.state)
+ assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).state, before.state)
+ assert.equal((await sf.getFeatureDoc('c1', 'p1', 'news')).purchaseStatus, 'paid')
+})
+
+test('bezahlt_und_abgelaufen_gleichzeitig_bleiben_stimmig', async () => {
+ const pid = 'p_race'
+ aw.seed('websiteProjects', { $id: pid, customerId: 'c1', projectName: 'Rennen' })
+ await sf.markOrdered({ customerId: 'c1', projectId: pid, key: 'news', sessionId: 'cs_race', consent })
+ const ticketsBefore = tickets().length
+ await Promise.all([
+ sf.markPaid({ sessionId: 'cs_race', invoiceId: 'in_r', invoiceUrl: '' }),
+ sf.markCheckoutFailed('cs_race'),
+ ])
+ const doc = await sf.getFeatureDoc('c1', pid, 'news')
+ // entweder bezahlt (mit Ticket) oder zurueckgesetzt (ohne): nie 'setup' ohne Ticket oder 'off' mit Ticket
+ if (doc.state === 'setup') {
+ assert.ok(doc.ticketId)
+ assert.equal(tickets().length, ticketsBefore + 1)
+ } else {
+ assert.equal(doc.state, 'off')
+ assert.equal(doc.ticketId || '', '')
+ assert.equal(tickets().length, ticketsBefore)
+ }
+})