diff --git a/server/services/aboFunktionen.js b/server/services/aboFunktionen.js new file mode 100644 index 0000000..4b0cea3 --- /dev/null +++ b/server/services/aboFunktionen.js @@ -0,0 +1,89 @@ +import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js' +import { listContractEvents, openWithdrawals } from './contractEvents.js' + +/** + * Abo-Zuordnung der Funktionen (V5, Spec E3/E8): Welches Abo deckt welche Funktion? Ein Lesepfad (lies) fuer alle + * Fragen, damit Einschalten, Katalog und Abgleich nie verschieden urteilen. Fail-closed: ein Lesefehler wirft, er wird + * nie zu kein_abo. Wer ihn abfaengt, entscheidet selbst (Einschalten: 503, Abgleichsjob: Kunde ueberspringen). + * + * Produkt: ohne productKey gilt Wartung (Altbestand, wie routes/abo.js). Ist das juengste wechsel-Ereignis dieses Abos + * widerrufen, sagt der Spiegel noch Wartung, es gilt aber Hosting (der Widerruf setzt den Spiegel nicht zurueck, wie in + * aboStatus.js). Ein Abo mit unbekanntem productKey deckt nichts (produkt null, laeuft false). + */ +export const ENTHALTEN_IN = Object.freeze({ + analyse: ['hosting', 'wartung'], + news: ['wartung'], + termine: ['wartung'], + shop: ['wartung'], + unterkunft: ['wartung'], + zeiten: ['wartung'], +}) + +// Nur diese Ereignisse haben eine Widerrufsfrist, die den Beginn einer Funktion betrifft (Speicher-Plus zaehlt nicht) +const FRIST_TYPEN = new Set(['abschluss', 'wechsel']) + +function pruefeSchluessel(key) { + if (!Object.hasOwn(ENTHALTEN_IN, key)) throw new TypeError(`Unbekannte Funktion: ${String(key)}`) +} + +/** Zeitpunkt in ms aus einem Text oder Date; NaN, wenn es keiner ist (null und '' sind keine Zeitpunkte). */ +function zeitpunkt(wert) { + if (wert instanceof Date) return wert.getTime() + return typeof wert === 'string' && wert !== '' ? Date.parse(wert) : Number.NaN +} + +async function lies(customerId, now) { + const abo = await getCurrentSubscription(customerId) + const ohne = { laeuft: false, produkt: null, gekuendigt: false, widerrufsfristBis: null, status: abo?.status ?? null, trialEnd: null } + if (!abo || !ACTIVE_STATUSES.has(abo.status)) return ohne + const spiegel = abo.productKey || 'wartung' + if (spiegel !== 'wartung' && spiegel !== 'hosting') return ohne + + // 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 produkt = spiegel === 'wartung' && wechselWiderrufen ? 'hosting' : spiegel + + const enden = openWithdrawals(ereignisse.filter((e) => FRIST_TYPEN.has(e.type)), now).map((f) => zeitpunkt(f.until)) + const spaetestes = enden.length ? Math.max(...enden) : null + return { + laeuft: true, + produkt, + gekuendigt: Boolean(abo.cancelAtPeriodEnd || abo.cancelAt), + widerrufsfristBis: spaetestes === null ? null : new Date(spaetestes).toISOString(), + status: abo.status, + trialEnd: abo.trialEnd, + } +} + +/** { produkt: 'hosting' | 'wartung' | null, laeuft, gekuendigt, widerrufsfristBis: ISO | null } (genau diese vier Schluessel) */ +export async function aboProdukt(customerId, { now = new Date() } = {}) { + const { produkt, laeuft, gekuendigt, widerrufsfristBis } = await lies(customerId, now) + return { produkt, laeuft, gekuendigt, widerrufsfristBis } +} + +/** + * 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. + * beginnt_spaeter: Status trialing mit gueltigem trialEnd nach now (Privatkunde, der ohne Haekchen bestellt hat). + */ +export async function aboDeckt(customerId, key, { now = new Date() } = {}) { + pruefeSchluessel(key) + const l = await lies(customerId, now) + if (!l.laeuft) return { ok: false, grund: 'kein_abo' } + if (!ENTHALTEN_IN[key].includes(l.produkt)) return { ok: false, grund: 'falsches_abo' } + if (l.gekuendigt) return { ok: false, grund: 'gekuendigt' } + if (l.status === 'trialing') { + const ende = zeitpunkt(l.trialEnd) + if (ende > now.getTime()) return { ok: false, grund: 'beginnt_spaeter', ab: new Date(ende).toISOString() } + } + if (l.status !== 'active') return { ok: false, grund: 'nicht_aktiv' } + return { ok: true } +} + +/** Laeuft ein Abo, das diese Funktion enthaelt? Gekuendigt und past_due zaehlen noch (fuer Abgleichsjobs). */ +export async function aboLaeuftFuer(customerId, key) { + pruefeSchluessel(key) + const l = await lies(customerId, new Date()) + return l.laeuft && ENTHALTEN_IN[key].includes(l.produkt) +} diff --git a/test/aboFunktionen.test.js b/test/aboFunktionen.test.js new file mode 100644 index 0000000..f698f95 --- /dev/null +++ b/test/aboFunktionen.test.js @@ -0,0 +1,121 @@ +import { test, after, beforeEach } 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' + +// Abo-Zuordnung der Funktionen (V5): aboProdukt, aboDeckt, aboLaeuftFuer. Fail-closed: Lesefehler werden weitergereicht. +const aw = await startAppwriteMock({ schema: { subscriptions: schemaKeys('subscriptions'), contractEvents: schemaKeys('contractEvents') } }) +const st = await startFakeStripe() +after(() => Promise.all([aw.close(), st.close()])) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { subscriptionDoc, wartungDoc, hostingDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { ENTHALTEN_IN, aboProdukt, aboDeckt, aboLaeuftFuer } = await import('../server/services/aboFunktionen.js') + +const JETZT = new Date('2026-10-17T08:00:00.000Z') +const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc)) +const ereignis = (id, type, contractAt, over = {}) => + aw.seed('contractEvents', { $id: id, customerId: 'c1', subscriptionId: 'sub_h', type, productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }), ...over }) +// aw.docs() liefert eine Kopie: geloescht wird ueber die API +const loesche = (collection, id) => deleteDocument(collection, id) + +beforeEach(async () => { + for (const collection of ['subscriptions', 'contractEvents']) { + for (const id of aw.docs(collection).keys()) await deleteDocument(collection, id) + } +}) + +const FAELLE = [ // [Name, Abo-Dokument|null, aboProdukt-Teil, aboDeckt(termine), aboDeckt(analyse)] + ['kein Abo', null, { produkt: null, laeuft: false, gekuendigt: false }, 'kein_abo', 'kein_abo'], + ['Wartung aktiv', wartungDoc, { produkt: 'wartung', laeuft: true, gekuendigt: false }, null, null], + ['Altbestand ohne productKey', { ...wartungDoc, productKey: undefined }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, null, null], + ['Hosting aktiv', hostingDoc, { produkt: 'hosting', laeuft: true, gekuendigt: false }, 'falsches_abo', null], + ['Wartung zum Periodenende gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, { produkt: 'wartung', laeuft: true, gekuendigt: true }, 'gekuendigt', 'gekuendigt'], + ['Wartung mit cancelAt', { ...wartungDoc, cancelAt: '2026-12-01T00:00:00.000Z' }, { produkt: 'wartung', laeuft: true, gekuendigt: true }, 'gekuendigt', 'gekuendigt'], + ['Wartung past_due', { ...wartungDoc, status: 'past_due' }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, 'nicht_aktiv', 'nicht_aktiv'], + ['Wartung trialing ohne Probezeit-Ende', { ...wartungDoc, status: 'trialing' }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, 'nicht_aktiv', 'nicht_aktiv'], + ['Wartung beendet', { ...wartungDoc, status: 'canceled' }, { produkt: null, laeuft: false, gekuendigt: false }, 'kein_abo', 'kein_abo'], +] +for (const [name, doc, teil, termine, analyse] of FAELLE) { + test(`Abo-Lage: ${name}`, async () => { + if (doc) abo(doc) + assert.deepEqual(await aboProdukt('c1', { now: JETZT }), { ...teil, widerrufsfristBis: null }) + assert.deepEqual(await aboDeckt('c1', 'termine'), termine ? { ok: false, grund: termine } : { ok: true }) + assert.deepEqual(await aboDeckt('c1', 'analyse'), analyse ? { ok: false, grund: analyse } : { ok: true }) + }) +} + +test('Probezeit (Privatkunde bestellte ohne Häkchen, nach_frist): beginnt_spaeter mit Datum, nicht nicht_aktiv', async () => { + const ENDE = '2026-11-03T05:00:00.000Z' // 06:00 Berlin am Tag nach dem Fristende + const probe = { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: ENDE } + abo(probe) + assert.deepEqual(await aboProdukt('c1', { now: JETZT }), { produkt: 'wartung', laeuft: true, gekuendigt: false, widerrufsfristBis: null }) + for (const key of ['termine', 'analyse']) assert.deepEqual(await aboDeckt('c1', key, { now: JETZT }), { ok: false, grund: 'beginnt_spaeter', ab: ENDE }, key) + for (const trialEnd of [null, '', 'kaputt', '2026-10-17T07:59:59.000Z']) { // kein oder abgelaufenes Ende: wie bisher nicht_aktiv + abo({ ...probe, trialEnd }) + assert.deepEqual(await aboDeckt('c1', 'termine', { now: JETZT }), { ok: false, grund: 'nicht_aktiv' }, String(trialEnd)) + } + abo({ ...probe, cancelAtPeriodEnd: true }) + assert.equal((await aboDeckt('c1', 'termine', { now: JETZT })).grund, 'gekuendigt') + abo({ ...hostingDoc, status: 'trialing', trialEnd: ENDE }) + assert.equal((await aboDeckt('c1', 'termine', { now: JETZT })).grund, 'falsches_abo') // Reihenfolge: Abo vor Beginn + assert.deepEqual(await aboDeckt('c1', 'analyse', { now: JETZT }), { ok: false, grund: 'beginnt_spaeter', ab: ENDE }) +}) + +test('aboLaeuftFuer: gekündigt und past_due zählen noch, falsches Abo und beendet nicht', async () => { + abo({ ...wartungDoc, cancelAtPeriodEnd: true }) + assert.equal(await aboLaeuftFuer('c1', 'termine'), true) + abo({ ...wartungDoc, status: 'past_due' }) + assert.equal(await aboLaeuftFuer('c1', 'termine'), true) + abo({ ...hostingDoc }) + assert.deepEqual([await aboLaeuftFuer('c1', 'termine'), await aboLaeuftFuer('c1', 'analyse')], [false, true]) + abo({ ...wartungDoc, status: 'canceled' }) + assert.equal(await aboLaeuftFuer('c1', 'news'), false) +}) + +test('widerrufener Wechsel: Spiegel sagt Wartung, es gilt Hosting', async () => { + abo(wartungDoc) + ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z', { status: 'widerrufen' }) + assert.equal((await aboProdukt('c1')).produkt, 'hosting') + assert.deepEqual(await aboDeckt('c1', 'shop'), { ok: false, grund: 'falsches_abo' }) + assert.equal(await aboLaeuftFuer('c1', 'shop'), false) +}) + +test('widerrufsfristBis: spätestes offenes Ende von Abschluss und Wechsel; Speicher, fremde und abgelaufene zählen nicht', async () => { + abo(wartungDoc) + ereignis('ev_a', 'abschluss', '2026-10-17T07:00:00.000Z') // Sa + 14 Tage = Sa 31.10. (Feiertag), So 01.11. (Feiertag) -> Mo 02.11. + ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z') // -> Mo 26.10., 23:59:59.999 MEZ + ereignis('ev_s', 'speicher', '2026-10-16T09:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T09:00:00.000Z', fromUnits: 0, toUnits: 1 }) }) + ereignis('ev_f', 'wechsel', '2026-10-15T09:00:00.000Z', { subscriptionId: 'sub_alt', status: 'widerrufen' }) + assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, '2026-11-02T22:59:59.999Z') + await loesche('contractEvents', 'ev_a') + assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, '2026-10-26T22:59:59.999Z') + ereignis('ev_alt', 'abschluss', '2026-09-20T09:00:00.000Z') + await loesche('contractEvents', 'ev_w') // Frist endete Mo 05.10. + assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, null) +}) + +test('widerrufsfristBis: offene Frist eines anderen Abos zählt nicht', async () => { + abo(wartungDoc) + ereignis('ev_x', 'abschluss', '2026-10-16T09:00:00.000Z', { subscriptionId: 'sub_alt' }) + assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, null) +}) + +test('Form: ENTHALTEN_IN eingefroren und genau die Schlüssel von V5; unbekannte Funktion ist ein Programmfehler', async () => { + assert.ok(Object.isFrozen(ENTHALTEN_IN)) + assert.deepEqual(Object.keys(ENTHALTEN_IN), ['analyse', 'news', 'termine', 'shop', 'unterkunft', 'zeiten']) + assert.deepEqual(ENTHALTEN_IN, { analyse: ['hosting', 'wartung'], news: ['wartung'], termine: ['wartung'], shop: ['wartung'], unterkunft: ['wartung'], zeiten: ['wartung'] }) + assert.deepEqual(Object.keys(await aboProdukt('c1')).sort(), ['gekuendigt', 'laeuft', 'produkt', 'widerrufsfristBis']) + await assert.rejects(() => aboDeckt('c1', 'gibtEsNicht'), TypeError) + await assert.rejects(() => aboLaeuftFuer('c1', 'gibtEsNicht'), TypeError) +}) + +test('fail-closed: ein Lesefehler wird weitergereicht, nie zu kein_abo', async () => { + abo(wartungDoc) + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 }) + await assert.rejects(() => aboDeckt('c1', 'termine')) + aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 }) + await assert.rejects(() => aboProdukt('c1', { now: JETZT })) +})