diff --git a/server/services/aboFunktionen.js b/server/services/aboFunktionen.js index 2c645e0..0a71923 100644 --- a/server/services/aboFunktionen.js +++ b/server/services/aboFunktionen.js @@ -45,6 +45,7 @@ async function lies(customerId, now) { // Ereignisse dieses Abos: Wechsel-Regel und offene Widerrufsfristen const ereignisse = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === abo.$id) const wechselWiderrufen = ereignisse.find((e) => e.type === 'wechsel')?.status === 'widerrufen' // neueste zuerst + const wechselOffen = ereignisse.some((e) => e.type === 'wechsel' && e.status === 'offen') const produkt = spiegel === 'wartung' && wechselWiderrufen ? 'hosting' : spiegel const enden = openWithdrawals(ereignisse.filter((e) => FRIST_TYPEN.has(e.type)), now).map((f) => zeitpunkt(f.until)) @@ -56,6 +57,7 @@ async function lies(customerId, now) { widerrufsfristBis: spaetestes === null ? null : new Date(spaetestes).toISOString(), status: abo.status, trialEnd: abo.trialEnd, + wechselOffen, } } @@ -65,6 +67,15 @@ export async function aboProdukt(customerId, { now = new Date() } = {}) { return { produkt, laeuft, gekuendigt, widerrufsfristBis } } +/** + * Kann der Kunde jetzt ins Wartungs-Abo wechseln (E-Fix M8, wie canSwitch in GET /api/abo und assertCanSwitch in planSwitch.js)? Hosting-Abo, Status active, + * nicht gekuendigt (cancelAtPeriodEnd oder cancelAt), kein Wechsel, der auf die Zahlung wartet. Lesefehler werden weitergereicht. + */ +export async function wechselMoeglich(customerId, { now = new Date() } = {}) { + const l = await lies(customerId, now) + return l.laeuft && l.produkt === 'hosting' && !l.gekuendigt && l.status === 'active' && !l.wechselOffen +} + /** * Deckt das Abo des Kunden diese Funktion? { ok: true } | { ok: false, grund } mit grund in der Reihenfolge * kein_abo, falsches_abo, gekuendigt, beginnt_spaeter (mit ab = Ende der Probezeit als ISO), nicht_aktiv. diff --git a/server/services/funktionEinschalten.js b/server/services/funktionEinschalten.js index a21f602..a3bc2d3 100644 --- a/server/services/funktionEinschalten.js +++ b/server/services/funktionEinschalten.js @@ -1,6 +1,6 @@ import { config } from '../config.js' import { updateDocument } from './appwriteAdmin.js' -import { aboDeckt } from './aboFunktionen.js' +import { aboDeckt, wechselMoeglich } from './aboFunktionen.js' import { meldeEingeschaltet } from './aboFunktionenMeldung.js' import { berlinDay } from './deadlines.js' import { bestellFehler } from './featureOrder.js' @@ -54,9 +54,14 @@ export function aboFehlerText(grund, ab = null) { return grund === 'beginnt_spaeter' ? text.replace('{datum}', datumDe(ab)) : text } -/** 409 abo_fehlt: body.grund, body.wechsel (Hosting-Kunde: Wechsel ins Wartungs-Abo anbieten) und bei beginnt_spaeter body.ab. */ -const aboFehlt = (grund, ab) => - bestellFehler(409, aboFehlerText(grund, ab), 'abo_fehlt', { grund, wechsel: grund === 'falsches_abo', ...(grund === 'beginnt_spaeter' ? { ab } : {}) }) +/** + * 409 abo_fehlt: body.grund, body.wechsel (Hosting-Kunde, der wirklich wechseln kann: Wechsel ins Wartungs-Abo anbieten) und bei beginnt_spaeter body.ab. + * wechsel ist nur bei falsches_abo und moeglichem Wechsel true (E-Fix M8): aboDeckt nennt falsches_abo vor gekuendigt, beginnt_spaeter und nicht_aktiv + * (die Reihenfolge der Gruende bleibt), ein Hosting-Abo in der Probezeit, bei offener Zahlung oder mit vorgemerkter Kuendigung bekaeme sonst ein + * Angebot, das auf #wechsel in "Ein Wechsel ist derzeit nicht möglich" endet. + */ +const aboFehlt = (grund, ab, wechsel = false) => + bestellFehler(409, aboFehlerText(grund, ab), 'abo_fehlt', { grund, wechsel: grund === 'falsches_abo' && wechsel === true, ...(grund === 'beginnt_spaeter' ? { ab } : {}) }) const konflikt = (code, text) => bestellFehler(409, text, code) @@ -72,7 +77,13 @@ export async function pruefeAboDeckung(customerId, key, now = new Date()) { console.error(`[funktionen] Abo für das Einschalten nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) throw bestellFehler(503, EINSCHALTEN_TEXTE.nichtPruefbar) } - if (ergebnis.ok !== true) throw aboFehlt(ergebnis.grund, ergebnis.ab) + if (ergebnis.ok === true) return + let wechsel = false + if (ergebnis.grund === 'falsches_abo') { + // Ein Lesefehler hier nimmt nur das Angebot weg (fail-closed fuer das Angebot, nicht fuer die Pruefung) + wechsel = await wechselMoeglich(customerId, { now }).catch(() => false) + } + throw aboFehlt(ergebnis.grund, ergebnis.ab, wechsel) } /** setup ohne Ticket und durch Abo oder Projekt eingeschaltet: der Dienst heilt (nur das Ticket), es entsteht nichts Neues. */ diff --git a/test/funktionEinschalten.test.js b/test/funktionEinschalten.test.js index 1cf7a62..0aa17e7 100644 --- a/test/funktionEinschalten.test.js +++ b/test/funktionEinschalten.test.js @@ -160,6 +160,25 @@ test('gleichzeitig: ein Erfolg, ein 409, ein Ticket, ein Nachweis', async () => assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1]) }) +test('M8: das Wechselangebot (wechsel true) gibt es nur, wenn der Wechsel auch möglich ist (Hosting, aktiv, nicht gekündigt, kein offener Wechsel)', async () => { + const ohneWechsel = async (doc, name) => { + await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_h', doc)) + await assert.rejects(E(), (e) => e.status === 409 && e.body.grund === 'falsches_abo' && e.body.wechsel === false && e.message === EINSCHALTEN_TEXTE.abo.falsches_abo, name) + } + await ohneWechsel({ ...hostingDoc, cancelAtPeriodEnd: true }, 'Hosting zum Periodenende gekündigt') + await ohneWechsel({ ...hostingDoc, cancelAt: '2026-12-01T00:00:00.000Z' }, 'Hosting mit cancelAt') + await ohneWechsel({ ...hostingDoc, status: 'past_due' }, 'Hosting past_due') + await ohneWechsel({ ...hostingDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' }, 'Hosting in der Probezeit') + // offener Wechsel (Zahlung steht aus): das Angebot käme zu #wechsel, dort ist nichts mehr zu bestellen + await loescheAbos(); aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) + aw.seed('contractEvents', { $id: 'ev_wechsel_offen', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', status: 'offen', details: '{}', termsVersion: 'x', consentAt: '2026-10-16T08:00:00.000Z' }) + await assert.rejects(E(), (e) => e.body.grund === 'falsches_abo' && e.body.wechsel === false, 'offener Wechsel') + await deleteDocument('contractEvents', 'ev_wechsel_offen') + // Hosting aktiv und nicht gekündigt: das Angebot bleibt + await assert.rejects(E(), (e) => e.body.grund === 'falsches_abo' && e.body.wechsel === true, 'Hosting aktiv') + assert.deepEqual(spuren(), { docs: 0, tickets: 0 }) +}) + test('Nachweis scheitert: kein Zustand, kein Ticket', async () => { await assert.rejects(E({ nachweis: async () => { throw Object.assign(new Error('Nachweis weg'), { status: 500 }) } }), /Nachweis weg/) assert.deepEqual(spuren(), { docs: 0, tickets: 0 })