From 8f75276c54f50527132acceebfcf31ad1f8b468a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:48:34 +0000 Subject: [PATCH] Funktionen: Preis entfernt, Abrechnung und Abo-Fehlertext als reine Logik Co-Authored-By: Claude Sonnet 5.5 --- src/lib/financeRequest.js | 2 + src/lib/funktionenView.js | 51 +++++++++++++++++++++-- tests/unit/financeRequest.test.mjs | 7 ++++ tests/unit/funktionenView.test.mjs | 65 ++++++++++++++++++++++++++++-- 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/lib/financeRequest.js b/src/lib/financeRequest.js index c8e6baf..bbe1d2b 100644 --- a/src/lib/financeRequest.js +++ b/src/lib/financeRequest.js @@ -65,6 +65,8 @@ export function createFinanceRequest({ baseUrl, createJWT, fetchImpl = (...args) if (info.invoiceNumber) err.invoiceNumber = info.invoiceNumber // Team-Einstellungen der Oeffnungszeiten (PATCH): code (409 ZEITEN_VERALTET) und fehler je Feld (400) if (typeof info.code === 'string') err.code = info.code + // Funktion einschalten ohne passendes Abo (409 abo_fehlt): der Grund (kein_abo, falsches_abo, gekuendigt, nicht_aktiv …) bestimmt den Text + if (typeof info.grund === 'string') err.grund = info.grund if (info.fehler && typeof info.fehler === 'object') err.fehler = info.fehler throw err } diff --git a/src/lib/funktionenView.js b/src/lib/funktionenView.js index 903762f..9f8c185 100644 --- a/src/lib/funktionenView.js +++ b/src/lib/funktionenView.js @@ -19,7 +19,7 @@ export const FUNKTIONEN = [ label: 'Terminplaner', bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', - disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Bestellung (149 €) möglich.', + disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Einrichtung möglich.', abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.', ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?', }, @@ -291,13 +291,58 @@ export function textformBereit({ confirmed, date, channel, today }, feld) { return canSubmitTextform({ confirmed, date, channel, today, minDate, channels: FUNKTION_CHANNELS }) } -/** Rumpf fuer POST /funktionen/:customerId/:key; Datum und Weg der Textform nur beim Aktivieren und nur, wenn beide da sind. */ -export function featureBody({ projectId, action, note, textformDate, textformChannel }) { +/** Wahl beim Aktivieren: wohin die Einrichtungszeit geht (Portal: POST /funktionen/:customerId/:key, Feld abrechnung). */ +export const ABRECHNUNG_OPTIONEN = [ + { value: 'abo', label: 'Aus dem Abo' }, + { value: 'projekt', label: 'Im Projekt enthalten' }, +] + +/** Erklaerung unter der Wahl (ohne Preis). */ +export const ABRECHNUNG_HINWEIS = + 'Aus dem Abo: Die Einrichtungszeit geht aufs Stundenkonto des Kunden. Im Projekt enthalten: Sie zählt nicht aufs Stundenkonto und läuft über die Projektkosten.' + +/** + * true, wenn diese Aktion die Wahl der Abrechnung braucht: "Aktivieren" einer Funktion aus FUNKTIONEN, ausser nach einer Team-Sperre + * (state "disabled" mit disabledReason "team"): dort wird nur wieder eingeschaltet, die Abrechnung steht schon. + */ +export const fragtAbrechnung = (key, action, feature) => + action === 'activate' && FUNKTIONEN.some((f) => f.key === key) && !(feature?.state === 'disabled' && feature?.disabledReason === 'team') + +/** Ohne Frage immer bereit, mit Frage nur mit "abo" oder "projekt". */ +export const abrechnungBereit = ({ key, action, feature, abrechnung }) => + !fragtAbrechnung(key, action, feature) || ABRECHNUNG_OPTIONEN.some((o) => o.value === abrechnung) + +/** Text zu 409 abo_fehlt (Portal: grund = kein_abo | falsches_abo | gekuendigt | nicht_aktiv); unbekannter oder fehlender Grund -> allgemeiner Satz. */ +export function aboFehltText(grund, key) { + const name = FUNKTIONEN.find((f) => f.key === key)?.label || key + switch (grund) { + case 'kein_abo': + return `${name}: Der Kunde hat kein laufendes Abo. Die Funktion läuft nur mit passendem Abo.` + case 'falsches_abo': + return `${name}: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.` + case 'gekuendigt': + return `${name}: Das Abo des Kunden ist gekündigt. Eingeschaltet wird nicht, solange die Kündigung vorgemerkt ist.` + case 'nicht_aktiv': + return `${name}: Das Abo des Kunden läuft nicht (Status nicht aktiv). Bitte zuerst das Abo klären.` + default: + return `${name}: Das passende Abo fehlt.` + } +} + +/** Fehlertext zu einer Aktion: bei abo_fehlt der Text zum Grund, sonst die Meldung des Fehlers, ohne beides "Speichern fehlgeschlagen.". */ +export const featureFehlerText = (err, key) => (err?.code === 'abo_fehlt' ? aboFehltText(err.grund, key) : err?.message || 'Speichern fehlgeschlagen.') + +/** + * Rumpf fuer POST /funktionen/:customerId/:key; Datum und Weg der Textform nur beim Aktivieren und nur, wenn beide da sind, + * abrechnung nur beim Aktivieren und nur mit einem Wert aus ABRECHNUNG_OPTIONEN. + */ +export function featureBody({ projectId, action, note, textformDate, textformChannel, abrechnung }) { const body = { projectId, action, note } if (action === 'activate' && textformDate && textformChannel) { body.textformDate = textformDate body.textformChannel = textformChannel } + if (action === 'activate' && ABRECHNUNG_OPTIONEN.some((o) => o.value === abrechnung)) body.abrechnung = abrechnung return body } diff --git a/tests/unit/financeRequest.test.mjs b/tests/unit/financeRequest.test.mjs index 5bda240..10ebfaa 100644 --- a/tests/unit/financeRequest.test.mjs +++ b/tests/unit/financeRequest.test.mjs @@ -144,3 +144,10 @@ test('Fehlercode und Feldmeldungen des Portals bleiben am Fehler (409 ZEITEN_VER assert.equal(ohne.code, undefined) assert.equal(ohne.fehler, undefined) }) + +test('Der Grund einer Abo-Sperre bleibt am Fehler (409 abo_fehlt mit grund)', async () => { + const { api } = client({ responses: [reply(409, { error: 'x', code: 'abo_fehlt', grund: 'kein_abo' }), reply(409, { error: 'y', code: 'abo_fehlt', grund: 5 })] }) + const a = await fails(api('/funktionen/c1/news', { method: 'POST', body: {} })) + assert.deepEqual([a.status, a.code, a.grund], [409, 'abo_fehlt', 'kein_abo']) + assert.equal((await fails(api('/funktionen/c1/news', { method: 'POST', body: {} }))).grund, undefined) +}) diff --git a/tests/unit/funktionenView.test.mjs b/tests/unit/funktionenView.test.mjs index da135f2..d648ad1 100644 --- a/tests/unit/funktionenView.test.mjs +++ b/tests/unit/funktionenView.test.mjs @@ -28,6 +28,12 @@ const { textformAnsicht, textformBereit, featureBody, + ABRECHNUNG_OPTIONEN, + ABRECHNUNG_HINWEIS, + fragtAbrechnung, + abrechnungBereit, + aboFehltText, + featureFehlerText, speicherStatus, K16_WARNUNG, K16_WARNUNG_OHNE_UNTERLAGEN, @@ -59,7 +65,7 @@ test('FUNKTIONEN: News, Terminplaner, Webshop und Öffnungszeiten live (in diese label: 'Terminplaner', bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', - disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Bestellung (149 €) möglich.', + disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich. Damit beginnt die Löschung: Nach 30 Tagen werden alle Termine und Einstellungen gelöscht. Wieder einschalten ist nach diesen 30 Tagen nur mit einer neuen Einrichtung möglich.', abnahme: 'Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.', ticket: 'Fehlendes Einrichtungs-Ticket neu anlegen?', }, @@ -106,12 +112,13 @@ test('FUNKTIONEN: Schlüssel sind eindeutig und jede Funktion hat Aktivieren und for (const text of Object.values(zeiten)) assert.doesNotMatch(text, /24 Uhr/, 'nie "24 Uhr"') }) -test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Bestellung (149 €)', () => { +test('W1: der Abschalten-Dialog des Terminplaners nennt Buchungsstopp, Löschung nach 30 Tagen und die neue Einrichtung, aber keinen Preis', () => { const text = FUNKTIONEN.find((f) => f.key === 'termine').bestaetigen.disable assert.match(text, /^Terminplaner abschalten\?/) assert.match(text, /keine Buchungen mehr möglich/, 'Buchungen stehen') assert.match(text, /Nach 30 Tagen werden alle Termine und Einstellungen gelöscht/, 'Löschung nach 30 Tagen') - assert.match(text, /nur mit einer neuen Bestellung \(149 €\)/, 'danach nur mit neuer Bestellung, mit Preis') + assert.match(text, /nur mit einer neuen Einrichtung möglich/, 'danach nur mit neuer Einrichtung') + assert.doesNotMatch(text, /€|Euro|Bestellung/, 'kein Preis') // News bleibt unverändert: dort wird nichts gelöscht assert.equal(FUNKTIONEN.find((f) => f.key === 'news').bestaetigen.disable, 'News abschalten? Die Website zeigt dann keine Beiträge mehr.') }) @@ -837,3 +844,55 @@ test('Buchungskalender: textformNoetig, Label, Vorlage und Links aus dem Feld un assert.equal(schlecht.vorlage, '') assert.equal(textformBereit({ confirmed: true, date: '2026-10-07', channel: 'email', today: '2026-10-07' }, ukFeld()), true) }) + +// Kein Preis, Abrechnung beim Aktivieren (aus dem Abo / im Projekt enthalten) und Fehlertext bei fehlendem Abo +test('Kein Text der Funktionen nennt einen Preis', () => { + for (const f of FUNKTIONEN) for (const t of Object.values(f.bestaetigen)) assert.doesNotMatch(t, /€|\bEuro\b/, f.key) +}) + +test('ABRECHNUNG_OPTIONEN und fragtAbrechnung: nur Aktivieren, nicht nach Team-Sperre', () => { + assert.deepEqual(ABRECHNUNG_OPTIONEN, [{ value: 'abo', label: 'Aus dem Abo' }, { value: 'projekt', label: 'Im Projekt enthalten' }]) + for (const key of ['news', 'termine', 'shop', 'zeiten', 'unterkunft']) { + assert.equal(fragtAbrechnung(key, 'activate', { state: 'off' }), true, key) + assert.equal(fragtAbrechnung(key, 'activate', undefined), true, key) + assert.equal(fragtAbrechnung(key, 'activate', { state: 'disabled', disabledReason: 'abo_ende' }), true, key) + assert.equal(fragtAbrechnung(key, 'activate', { state: 'disabled', disabledReason: 'team' }), false, key) + for (const action of ['disable', 'abnahme', 'ticket']) assert.equal(fragtAbrechnung(key, action, { state: 'setup' }), false, `${key} ${action}`) + } + assert.equal(fragtAbrechnung('analyse', 'activate', { state: 'off' }), false) +}) + +test('ABRECHNUNG_HINWEIS: erklärt beide Wege ohne Preis', () => { + assert.equal( + ABRECHNUNG_HINWEIS, + 'Aus dem Abo: Die Einrichtungszeit geht aufs Stundenkonto des Kunden. Im Projekt enthalten: Sie zählt nicht aufs Stundenkonto und läuft über die Projektkosten.' + ) + assert.doesNotMatch(ABRECHNUNG_HINWEIS, /€|\bEuro\b/) +}) + +test('abrechnungBereit: ohne Frage immer bereit, mit Frage nur mit abo oder projekt', () => { + const b = (o) => abrechnungBereit({ key: 'news', action: 'activate', feature: { state: 'off' }, abrechnung: '', ...o }) + assert.equal(b({}), false); assert.equal(b({ abrechnung: 'gratis' }), false); assert.equal(b({ abrechnung: undefined }), false) + assert.equal(b({ abrechnung: 'abo' }), true); assert.equal(b({ abrechnung: 'projekt' }), true) + assert.equal(b({ action: 'disable' }), true) + assert.equal(b({ feature: { state: 'disabled', disabledReason: 'team' } }), true) +}) + +test('featureBody trägt abrechnung nur beim Aktivieren und nur mit gültigem Wert', () => { + assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', abrechnung: 'projekt' }), { projectId: 'p1', action: 'activate', note: '', abrechnung: 'projekt' }) + assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', abrechnung: 'gratis' }), { projectId: 'p1', action: 'activate', note: '' }) + assert.deepEqual(featureBody({ projectId: 'p1', action: 'disable', note: 'x', abrechnung: 'abo' }), { projectId: 'p1', action: 'disable', note: 'x' }) + assert.deepEqual(featureBody({ projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: 'chat', abrechnung: 'abo' }), { projectId: 'p1', action: 'activate', note: '', textformDate: '2026-10-06', textformChannel: 'chat', abrechnung: 'abo' }) +}) + +test('aboFehltText und featureFehlerText: jeder Grund, unbekannter Grund, andere Fehler', () => { + const fehler = (grund, code = 'abo_fehlt') => Object.assign(new Error('Text des Portals'), { status: 409, code, grund }) + assert.equal(featureFehlerText(fehler('kein_abo'), 'news'), 'News: Der Kunde hat kein laufendes Abo. Die Funktion läuft nur mit passendem Abo.') + assert.equal(featureFehlerText(fehler('falsches_abo'), 'termine'), 'Terminplaner: Das Hosting-Abo des Kunden enthält diese Funktion nicht. Sie ist im Wartungs-Abo enthalten.') + assert.equal(featureFehlerText(fehler('gekuendigt'), 'shop'), 'Webshop: Das Abo des Kunden ist gekündigt. Eingeschaltet wird nicht, solange die Kündigung vorgemerkt ist.') + assert.equal(featureFehlerText(fehler('nicht_aktiv'), 'zeiten'), 'Öffnungszeiten live: Das Abo des Kunden läuft nicht (Status nicht aktiv). Bitte zuerst das Abo klären.') + assert.equal(featureFehlerText(fehler('unbekannt'), 'unterkunft'), 'Buchungskalender: Das passende Abo fehlt.') + assert.equal(featureFehlerText(fehler(undefined), 'news'), 'News: Das passende Abo fehlt.') + assert.equal(featureFehlerText(fehler('kein_abo', 'andere'), 'news'), 'Text des Portals') + assert.equal(featureFehlerText(null, 'news'), 'Speichern fehlgeschlagen.') +})