Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.
Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.
Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e87140e5ba
245 files changed
+38799
-1050
No files matched your search
+33
-10
@@ -18,7 +18,7 @@ import {
|
||||
reconcilePendingSwitch,
|
||||
switchToWartung,
|
||||
} from '../services/planSwitch.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, paidFeatureOrders } from '../services/siteFeatures.js'
|
||||
import { featureConfig } from '../services/featureRegistry.js'
|
||||
import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js'
|
||||
import {
|
||||
@@ -68,6 +68,8 @@ function minTermEndOf(sub) {
|
||||
// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6)
|
||||
const productKeyOf = (sub) => sub?.productKey || 'wartung'
|
||||
const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung'
|
||||
// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner
|
||||
const ABO_FUNKTIONEN = ['analyse', 'termine']
|
||||
// {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo"
|
||||
const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo')
|
||||
// {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne
|
||||
@@ -408,6 +410,24 @@ router.post('/switch', requireSession, async (req, res) => {
|
||||
// Stripe-Webhook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hat der Kunde dieses Abos die Website-Analyse oder den Terminplaner (Funktionsdokument in portalFeatures mit featureKey analyse
|
||||
* oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht)? Nur dann gilt der Hinweis in K02 und
|
||||
* K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). Der Kunde ist der des Abos (sub.customerId,
|
||||
* wie in customerNameOf); gelesen wird wie sonst auch ueber listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als ja (der Kunde
|
||||
* wird eher informiert als nicht), eine Logzeile, die Mail geht in jedem Fall raus.
|
||||
*/
|
||||
async function hatAboFunktionen(sub) {
|
||||
try {
|
||||
if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID')
|
||||
const docs = await listCustomerFeatureDocs(sub.customerId)
|
||||
return docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && (doc.state || 'off') !== 'off')
|
||||
} catch (err) {
|
||||
console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */
|
||||
async function customerNameOf(sub, fallback = '') {
|
||||
const customer = sub?.customerId
|
||||
@@ -475,8 +495,9 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
|
||||
vertragsende: fmtDate(endsAt),
|
||||
vertragsende_paragraph: endParagraphOf(sub),
|
||||
},
|
||||
// Hinweis auf nicht genutzte Arbeitszeit und auf das Ende von Website-Analyse und Terminplaner nur beim Wartungs-Abo
|
||||
blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) },
|
||||
// Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner nur beim
|
||||
// Wartungs-Abo eines Kunden, der eine von beiden hat (hatAboFunktionen)
|
||||
blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) && (await hatAboFunktionen(sub)) },
|
||||
})
|
||||
await updateDocument(config.collections.subscriptions, sub.$id, {
|
||||
cancelRequestedAt: receivedAt.toISOString(),
|
||||
@@ -706,15 +727,15 @@ export function publicRateLimitState() {
|
||||
|
||||
// Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert
|
||||
const CONTRACTS = ['abo', 'zusatzspeicher']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'zeiten']
|
||||
const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine', 'shop', 'zeiten']
|
||||
// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist)
|
||||
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', zeiten: 'zeiten' }
|
||||
const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine', shop: 'shop', zeiten: 'zeiten' }
|
||||
// Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals)
|
||||
const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' }
|
||||
|
||||
/**
|
||||
* Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl.
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'zeiten'; packages (nur beim
|
||||
* contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine' | 'shop' | 'zeiten'; packages (nur beim
|
||||
* Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026).
|
||||
*/
|
||||
function validDeclaration(body) {
|
||||
@@ -788,6 +809,7 @@ function orderLabel(order, sub, { customer = false } = {}) {
|
||||
if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher'
|
||||
if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)'
|
||||
if (order === 'termine') return 'Terminplaner (Einrichtung)'
|
||||
if (order === 'shop') return 'Webshop (Einrichtung)'
|
||||
if (order === 'zeiten') return 'Öffnungszeiten live'
|
||||
const abo = productNameOf(sub)
|
||||
return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}`
|
||||
@@ -948,8 +970,9 @@ router.post('/public/cancel', async (req, res) => {
|
||||
abo: !storage,
|
||||
// Hinweis auf nicht genutzte Arbeitszeit nur im Wartungs-Abo (Block innerhalb von abo)
|
||||
wartung: !storage && isWartung(sub),
|
||||
// Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung)
|
||||
funktionen: !storage && isWartung(sub),
|
||||
// Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung),
|
||||
// nur fuer einen Kunden, der eine von beiden hat (hatAboFunktionen)
|
||||
funktionen: !storage && isWartung(sub) && (await hatAboFunktionen(sub)),
|
||||
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
|
||||
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
|
||||
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
|
||||
@@ -1097,7 +1120,7 @@ async function withdrawOrder(sub, d, receivedAt) {
|
||||
const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' }
|
||||
|
||||
/**
|
||||
* Widerruf einer Funktion (News-Pinnwand 30 € oder Terminplaner-Einrichtung 149 €, je einmalig): gehoert zu keinem Abo
|
||||
* Widerruf einer Funktion (News-Pinnwand 30 €, Terminplaner-Einrichtung 149 € oder Webshop-Einrichtung 299 €, je einmalig): gehoert zu keinem Abo
|
||||
* und hat kein Vertragsereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse;
|
||||
* das Team bekommt in T09 die bezahlten Bestellungen der gewaehlten Funktion mit Fristende und erstattet/schaltet ab von
|
||||
* Hand. Jede Funktion hat ihre eigene Widerrufsfrist: gefiltert wird auf featureKey, der Anker ist deren vertragAt
|
||||
@@ -1142,7 +1165,7 @@ router.post('/public/withdraw', async (req, res) => {
|
||||
|
||||
let sub = null
|
||||
let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false }
|
||||
// Funktion (News oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||||
// Funktion (News, Terminplaner oder Webshop): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature)
|
||||
const featureKey = FEATURE_OF_ORDER[d.order] ?? null
|
||||
const feature = featureKey !== null
|
||||
const funktion = feature ? featureConfig(featureKey) : null
|
||||
|
||||
Reference in new issue
Block a user