From 1b79210a7c9292ccb8b29e29369012150bfacb0a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:49:46 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu=20P10:=20Skript=20z=C3=A4hl?= =?UTF-8?q?t=20offene=20Bestellungen=20und=20Widerrufsfristen=20gekaufter?= =?UTF-8?q?=20Funktionen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- scripts/kauf-bestand-pruefen.mjs | 74 +++++++++++++++++++++++++++++ test/kaufBestandPruefen.test.js | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 scripts/kauf-bestand-pruefen.mjs create mode 100644 test/kaufBestandPruefen.test.js diff --git a/scripts/kauf-bestand-pruefen.mjs b/scripts/kauf-bestand-pruefen.mjs new file mode 100644 index 0000000..1b075a2 --- /dev/null +++ b/scripts/kauf-bestand-pruefen.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +/** + * Vor dem Umschalten der Kundenansicht (P10): Käufe lesen. Nur lesend, schreibt nie. + * + * Nach dem Umschalten gibt es keine neuen Käufe mehr (die Kaufwege sind dann 404). Bestehendes muss weiter laufen: die Zahlung einer + * vorher gestarteten Bestellung (SEPA braucht Tage) und der Widerruf gekaufter Funktionen innerhalb der Frist. Das Skript zählt beides: + * offene Bestellungen state 'ordered' (die Zahlung steht noch aus); Exit 1, solange es welche gibt. Erst abwarten, dann umschalten. + * Fristen laufen gekaufte Funktionen (state setup | active | disabled und unlockedByPurchase), deren Widerrufsfrist noch läuft. + * Nur zur Information, kein Hindernis: der Widerruf funktioniert nach dem Umschalten weiter. + * Die Frist ist eine Näherung: Anker ist consentAt am Dokument, sonst orderedAt (der Nachweis in featureConsents wird nicht gelesen). + * Ohne Datum gilt die Frist vorsichtshalber als laufend. + * + * Ausgabe: nur Zahlen und Dokument-IDs, nie Namen, Adressen oder Beträge. + * node scripts/kauf-bestand-pruefen.mjs + * Gegen Produktion nur mit Freigabe des Inhabers (liest alle portalFeatures-Dokumente). Tests laufen nur gegen den Appwrite-Nachbau. + */ +import 'dotenv/config' +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' +import { listAllDocuments } from '../server/services/appwriteAdmin.js' +import { withdrawalDeadline } from '../server/services/deadlines.js' + +const GEKAUFT_STAENDE = ['setup', 'active', 'disabled'] + +const istGekauft = (doc) => GEKAUFT_STAENDE.includes(doc.state) && Boolean(doc.unlockedByPurchase) + +/** Zeitpunkt des Vertragsschlusses (Näherung): Zustimmung am Dokument, sonst Bestellzeit; leer oder ungültig => null. */ +function vertragAnker(doc) { + const wert = doc.consentAt || doc.orderedAt + if (!wert) return null + return Number.isNaN(new Date(wert).getTime()) ? null : new Date(wert) +} + +/** @returns {{ laeuft: boolean, ohneDatum: boolean }} Läuft die Widerrufsfrist dieser gekauften Funktion noch? */ +function fristStand(doc, jetzt) { + const anker = vertragAnker(doc) + if (!anker) return { laeuft: true, ohneDatum: true } + return { laeuft: withdrawalDeadline(anker).endsAt > jetzt, ohneDatum: false } +} + +const liste = (ids) => (ids.length ? ` (${ids.join(', ')})` : '') + +/** + * @param {{ log?: (line: string) => void, jetzt?: Date }} [options] + * @returns {Promise<{ offeneBestellungen: string[], fristLaeuft: string[], gekauftGesamt: number }>} nur Dokument-IDs + */ +export async function pruefeKaeufe({ log = console.log, jetzt = new Date() } = {}) { + const docs = await listAllDocuments(config.collections.portalFeatures) + const offeneBestellungen = docs.filter((d) => d.state === 'ordered').map((d) => d.$id) + const gekauft = docs.filter(istGekauft) + const fristLaeuft = [] + const ohneDatum = [] + for (const doc of gekauft) { + const stand = fristStand(doc, jetzt) + if (!stand.laeuft) continue + fristLaeuft.push(doc.$id) + if (stand.ohneDatum) ohneDatum.push(doc.$id) + } + log(`Funktionsdokumente: ${docs.length}, davon gekauft: ${gekauft.length}`) + log(`Offene Bestellungen: ${offeneBestellungen.length}${liste(offeneBestellungen)}`) + log(`Fristen laufen: ${fristLaeuft.length}${liste(fristLaeuft)}${ohneDatum.length ? ` - davon ohne Datum: ${ohneDatum.length}` : ''}`) + if (offeneBestellungen.length) log('Offene Bestellungen vorhanden: erst die Zahlung abwarten (oder klären), dann umschalten.') + return { offeneBestellungen, fristLaeuft, gekauftGesamt: gekauft.length } +} + +if (import.meta.url === pathToFileURL(process.argv[1] || '').href) { + try { + const result = await pruefeKaeufe() + if (result.offeneBestellungen.length) process.exitCode = 1 + } catch (err) { + console.error('Fehler:', err.message) + process.exit(1) + } +} diff --git a/test/kaufBestandPruefen.test.js b/test/kaufBestandPruefen.test.js new file mode 100644 index 0000000..cad4c28 --- /dev/null +++ b/test/kaufBestandPruefen.test.js @@ -0,0 +1,81 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// P10 Task 5 (scripts/kauf-bestand-pruefen.mjs): vor dem Umschalten zaehlen, wie viele gekaufte Funktionen noch auf Zahlung warten +// und wie viele Widerrufsfristen noch laufen. Nur lesend; die Ausgabe nennt nur Dokument-IDs. Nur Nachbauten, nie Produktion. +const aw = await startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures') } }) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { pruefeKaeufe } = await import('../scripts/kauf-bestand-pruefen.mjs') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') + +const jetzt = new Date('2026-11-10T10:00:00Z') + +const feature = (id, over = {}) => + aw.seed('portalFeatures', { + $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', state: 'active', enabled: true, siteKey: `k_${id}`, ...over, + }) +const sammler = () => { + const zeilen = [] + return { log: (z) => zeilen.push(z), text: () => zeilen.join('\n') } +} + +feature('f_offen', { state: 'ordered', purchaseStatus: 'ordered' }) // wartet auf Zahlung +feature('f_frist', { state: 'setup', purchaseStatus: 'paid', unlockedByPurchase: true, orderedAt: '2026-11-08T09:00:00.000Z' }) // Frist laeuft +feature('f_alt', { state: 'active', purchaseStatus: 'paid', unlockedByPurchase: true, orderedAt: '2026-09-20T09:00:00.000Z' }) // Frist vorbei +feature('f_abo', { state: 'active', unlockedBy: 'abo' }) // ueber das Abo, nicht gekauft +feature('f_aus', { state: 'off' }) // nicht gekauft + +test('zählt offene Bestellungen und laufende Fristen, schreibt nichts, nennt nur IDs', async () => { + const vorher = JSON.stringify([...aw.docs('portalFeatures')]) + const out = sammler() + const e = await pruefeKaeufe({ log: out.log, jetzt }) + assert.deepEqual([e.offeneBestellungen, e.fristLaeuft, e.gekauftGesamt], [['f_offen'], ['f_frist'], 2]) + assert.equal(JSON.stringify([...aw.docs('portalFeatures')]), vorher) + assert.match(out.text(), /Offene Bestellungen: 1/) + assert.match(out.text(), /Fristen laufen: 1/) + assert.match(out.text(), /f_offen/) + assert.match(out.text(), /f_frist/) + assert.doesNotMatch(out.text(), /k_f_|p_f_|c1/, 'keine Website-Schlüssel, Projekte oder Kunden in der Ausgabe') +}) + +test('Frist: die Zustimmung am Dokument (consentAt) geht orderedAt vor, ohne beide gilt die Frist vorsichtig als laufend', async () => { + feature('f_consent', { state: 'active', unlockedByPurchase: true, orderedAt: '2026-09-01T09:00:00.000Z', consentAt: '2026-11-09T09:00:00.000Z' }) + feature('f_ohne_datum', { state: 'disabled', unlockedByPurchase: true }) + const out = sammler() + const e = await pruefeKaeufe({ log: out.log, jetzt }) + assert.deepEqual(e.fristLaeuft.sort(), ['f_consent', 'f_frist', 'f_ohne_datum']) + assert.equal(e.gekauftGesamt, 4) + assert.match(out.text(), /Fristen laufen: 3/) + assert.match(out.text(), /ohne Datum: 1/) + for (const id of ['f_consent', 'f_ohne_datum']) await deleteDocument('portalFeatures', id) +}) + +test('Fristende: am letzten Tag bis 24 Uhr läuft sie noch, danach nicht mehr', async () => { + // Vertrag Mo 2026-11-02 => letzter Tag Mo 2026-11-16 (14 Tage), Ende 23:59:59 deutscher Zeit + feature('f_grenze', { state: 'active', unlockedByPurchase: true, orderedAt: '2026-11-02T09:00:00.000Z' }) + const spaet = (await pruefeKaeufe({ log: () => {}, jetzt: new Date('2026-11-16T22:00:00Z') })).fristLaeuft + const danach = (await pruefeKaeufe({ log: () => {}, jetzt: new Date('2026-11-16T23:00:01Z') })).fristLaeuft + assert.ok(spaet.includes('f_grenze')) + assert.ok(!danach.includes('f_grenze')) + await deleteDocument('portalFeatures', 'f_grenze') +}) + +test('ohne Käufe: alles leer', async () => { + // aw.docs() liefert eine Kopie; geleert wird deshalb ueber die Schnittstelle + for (const id of aw.docs('portalFeatures').keys()) await deleteDocument('portalFeatures', id) + assert.equal(aw.docs('portalFeatures').size, 0) + const out = sammler() + assert.deepEqual(await pruefeKaeufe({ log: out.log, jetzt }), { offeneBestellungen: [], fristLaeuft: [], gekauftGesamt: 0 }) + assert.match(out.text(), /Offene Bestellungen: 0/) + assert.match(out.text(), /Fristen laufen: 0/) +}) + +test('Lesefehler: der Fehler geht nach oben, es wird nichts als leer gemeldet', async () => { + feature('f_x', { state: 'ordered' }) + aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500 }) + await assert.rejects(() => pruefeKaeufe({ log: () => {}, jetzt })) +})