Kundenansicht neu P10: Skript zählt offene Bestellungen und Widerrufsfristen gekaufter Funktionen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
17317ada05
commit
1b79210a7c
2 files changed
+155
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
})
|
||||
Reference in new issue
Block a user