From 6d8487633ba21b4bb0a6792bd8709fc760429995 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:08:55 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20E3:=20H=C3=A4ndler-Aktionen=20(versendet?= =?UTF-8?q?,=20abholbereit,=20abgeschlossen,=20storniert),=20Erstattung=20?= =?UTF-8?q?unter=20Sperre,=20Bestellliste,=20Detail,=20CSV=20und=20Kundenr?= =?UTF-8?q?outen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/routes/shop.js | 70 ++++ server/services/shop/bestellungen.js | 495 +++++++++++++++++++++- test/shopBestellAktionen.test.js | 595 +++++++++++++++++++++++++++ 3 files changed, 1159 insertions(+), 1 deletion(-) create mode 100644 test/shopBestellAktionen.test.js diff --git a/server/routes/shop.js b/server/routes/shop.js index 73a8b03..27ec5c7 100644 --- a/server/routes/shop.js +++ b/server/routes/shop.js @@ -9,6 +9,8 @@ import { createWriteLimiter } from '../services/writeLimiter.js' import { anfrageAnsicht, ensureShopConfig, leseShopConfig, stelleAnfrage, zieheZurueck } from '../services/shop/anfrage.js' import { shopFuerKunde } from '../services/shop/schalter.js' import { kontoAnlegen, kontoStatus, onboardingLink, statusAusKonto } from '../services/shop/connect.js' +import { bestellungKunde, bestellungLesen, csv as bestellungenCsv, erstatte, listeBestellungen, setzeStatus } from '../services/shop/bestellungen.js' +import { listeWiderrufe, markiereBearbeitet } from '../services/shop/widerrufe.js' import { uploadSlot } from './chat.js' import { istVollstaendig, speichereEinstellungen } from '../services/shop/einstellungen.js' import { bereitschaft, istGeoeffnet, ladeShop, leereCache } from '../services/shop/kontext.js' @@ -39,6 +41,7 @@ const router = Router() const MAX_ANFRAGE_WRITES_PER_HOUR = 20 const MAX_WRITES_PER_HOUR = 300 +const MAX_CSV_PER_HOUR = 30 const PRODUKT_DOC_MAX_JSON = 100000 const BILD_DATEI_RE = /^([a-z0-9-]{1,64})-(l|s)\.webp$/ const MELDUNG = { @@ -84,11 +87,14 @@ const route = (fn) => async (req, res, next) => { const anfrageLimiter = createWriteLimiter({ max: MAX_ANFRAGE_WRITES_PER_HOUR }) const limitAnfrage = anfrageLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) const schreibLimiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR }) +const csvLimiter = createWriteLimiter({ max: MAX_CSV_PER_HOUR }) +const limitCsv = csvLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) const limitWrites = schreibLimiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele) export function resetShopRoutesForTests() { anfrageLimiter.reset() schreibLimiter.reset() + csvLimiter.reset() setzeBildLimitZurueck() } @@ -521,6 +527,70 @@ router.post('/stripe/aktualisieren', requireSession, nurKundeStripe, limitWrites res.json({ stripe: stripeAnsicht({ ...shop, stripeStatus: neu }) }) })) +// ---- Bestellungen und Widerrufe (E3) + +const seiteVon = (wert) => { + const nr = Number.parseInt(wert, 10) + return Number.isInteger(nr) && nr >= 1 ? nr : 1 +} +const textParam = (wert) => (typeof wert === 'string' ? wert : undefined) +/** Status aendern verschickt Mails an Kaeufer und erstattet Geld: nie in der Kundenansicht oder "Ansehen als". */ +const nurHaendler = nurKunde + +// Liste (Lesen auch in der Kundenansicht und bei deaktiviertem Shop) +router.get('/bestellungen', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + res.json(await listeBestellungen(shop.id, { status: textParam(req.query.status), suche: textParam(req.query.suche), seite: seiteVon(req.query.seite) })) +})) + +// CSV fuer die Buchhaltung (vor /bestellungen/:id, damit der Pfad eindeutig bleibt) +router.get('/bestellungen.csv', requireSession, limitCsv, route(async (req, res) => { + const { shop } = await mitShop(req) + const erg = await bestellungenCsv(shop.id, { von: textParam(req.query.von), bis: textParam(req.query.bis) }) + res.set({ + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${erg.dateiname}"`, + 'X-Content-Type-Options': 'nosniff', + }) + res.send(erg.csv) +})) + +router.get('/bestellungen/:id', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + const order = await bestellungLesen(req.params.id) + if (!order || order.shopId !== shop.id) throw stop(404, 'Diese Bestellung gibt es nicht.') + res.json({ bestellung: bestellungKunde(order) }) +})) + +router.post('/bestellungen/:id/aktion', requireSession, nurHaendler, limitWrites, route(async (req, res) => { + const { shop, doc, project } = await mitShop(req, { schreiben: true }) + const aktion = typeof req.body?.aktion === 'string' ? req.body.aktion : '' + const { order, mailOk } = await setzeStatus({ doc, shop, project }, req.params.id, aktion, { + versanddienst: req.body?.versanddienst, + sendungsnummer: req.body?.sendungsnummer, + }) + res.json({ bestellung: bestellungKunde(order), mailOk }) +})) + +router.post('/bestellungen/:id/erstatten', requireSession, nurHaendler, limitWrites, route(async (req, res) => { + const { shop, doc, project } = await mitShop(req, { schreiben: true }) + const { order, erstattung, voll, mailOk } = await erstatte({ doc, shop, project }, req.params.id, { + betragCent: req.body?.betragCent, + grund: req.body?.grund, + }) + res.json({ bestellung: bestellungKunde(order), erstattung, voll, mailOk }) +})) + +router.get('/widerrufe', requireSession, route(async (req, res) => { + const { shop } = await mitShop(req) + res.json(await listeWiderrufe(shop.id, { seite: seiteVon(req.query.seite) })) +})) + +router.post('/widerrufe/:id/erledigt', requireSession, nurHaendler, limitWrites, route(async (req, res) => { + const { shop } = await mitShop(req, { schreiben: true }) + res.json({ widerruf: await markiereBearbeitet(shop.id, req.params.id) }) +})) + // ---- Bilder // Bild als rohe Bytes (application/octet-stream), hoechstens 8 MB; die Pruefung des Shops laeuft vor dem Einlesen diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js index 1b59c7f..020a3ab 100644 --- a/server/services/shop/bestellungen.js +++ b/server/services/shop/bestellungen.js @@ -3,7 +3,9 @@ import { config } from '../../config.js' import { createDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' import { analyseDomains } from '../analyse.js' import { withLocks } from '../keyedLock.js' -import { checkoutAnlegen, sessionAblaufen, sessionHolen } from './connect.js' +import { checkoutAnlegen, erstatten, sessionAblaufen, sessionHolen } from './connect.js' +import { datumKurz, heute, plusTage, uhrzeit, zuUtc } from '../termine/zeit.js' +import { euro } from './geld.js' import { istGeoeffnet, leereCache, shopPerSiteKey } from './kontext.js' import { anHaendler, anKaeufer, haendlerK25 } from './mails.js' import { berechne } from './preise.js' @@ -609,3 +611,494 @@ export async function pruefeOffene(now = new Date()) { } return zaehler } + + +// --------------------------------------------------------------------------------------------------------------------- +// Haendler-Aktionen, Erstattung, Liste, CSV (E3). Texte hier gehen an den Haendler (Du-Form). + +const STATUS_BEZAHLT = ['bezahlt', 'versendet', 'abholbereit', 'abgeschlossen'] +/** Alle Status echter (bezahlter) Bestellungen; offen und storniert sind nie bezahlt worden. */ +const STATUS_ECHT = [...STATUS_BEZAHLT, 'erstattet'] +const STATUS_ALLE = [...STATUS_ECHT, 'offen', 'storniert'] +const STATUS_TEXT = { + offen: 'offen', + bezahlt: 'bezahlt', + versendet: 'versendet', + abholbereit: 'abholbereit', + abgeschlossen: 'abgeschlossen', + erstattet: 'erstattet', + storniert: 'storniert', +} +const AKTIONEN = ['versendet', 'abholbereit', 'abgeschlossen', 'storniert'] +const MAX_DIENST = 60 +const MAX_SENDUNG = 80 +const MAX_GRUND = 300 +const LISTE_SEITE = 50 +/** Obergrenze fuer das Durchsuchen/Blaettern der Liste: so viele neueste Bestellungen werden hoechstens gelesen. */ +const LISTE_MAX_GELESEN = 2000 +const LISTE_BATCH = 100 +const CSV_MAX_ZEILEN = 20000 +const CSV_BATCH = 200 +const TAG_RE = /^\d{4}-\d{2}-\d{2}$/ + +const NICHT_GEFUNDEN_BESTELLUNG = 'Diese Bestellung gibt es nicht.' +const ERSTATTUNG_FEHLER = + 'Die Erstattung hat bei Stripe nicht geklappt. Bitte versuch es noch einmal oder erstatte direkt im Stripe-Dashboard.' +const ERSTATTUNG_NICHT_GESPEICHERT = + 'Die Erstattung wurde bei Stripe ausgeführt, konnte hier aber nicht gespeichert werden. Bitte lade die Bestellung neu und prüfe sie im Stripe-Dashboard.' + +// eslint-disable-next-line no-control-regex +const STEUERZEICHEN = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g +const einzeiler = (wert) => (typeof wert === 'string' ? wert.replace(STEUERZEICHEN, ' ').replace(/\s+/g, ' ').trim() : '') + +/** Summe der Erstattungen in Cent (nur gueltige ganze Betraege). */ +export function erstattetCent(daten) { + const liste = Array.isArray(daten?.erstattungen) ? daten.erstattungen : [] + return liste.reduce((summe, e) => summe + (Number.isInteger(e?.betragCent) && e.betragCent > 0 ? e.betragCent : 0), 0) +} + +/** Shop-Kontext aus ctx ({ doc, shop }) oder Shop-ID (dann aus shopConfig + shopPerSiteKey geladen). */ +async function kontextFuer(shopOderCtx, now) { + if (istObjekt(shopOderCtx) && istObjekt(shopOderCtx.shop) && typeof shopOderCtx.shop.id === 'string' && shopOderCtx.shop.id) return shopOderCtx + if (typeof shopOderCtx === 'string' && shopOderCtx) { + const doc = await shopDokument(shopOderCtx) + const ctx = doc ? await shopPerSiteKey(doc.siteKey, { now }) : null + if (ctx) return ctx + throw fehler(404, 'nicht_gefunden', 'Diesen Shop gibt es nicht.') + } + throw new TypeError('Shop-Kontext oder Shop-ID erwartet') +} + +/** Bestellung im Shop oder 404 (auch bei fremden Bestellungen: nie verraten, dass es sie gibt). */ +async function eigeneBestellung(shopId, orderId) { + const order = await bestellungLesen(orderId) + if (!order || order.shopId !== shopId) throw fehler(404, 'nicht_gefunden', NICHT_GEFUNDEN_BESTELLUNG) + return order +} + +/** JSON der Bestellung; zu lang => mittlere Verlaufseintraege fallen weg (erster und letzte bleiben), nie ein Fehler. */ +function jsonVon(daten) { + let json = JSON.stringify(daten) + const verlauf = Array.isArray(daten.verlauf) ? [...daten.verlauf] : [] + while (json.length > MAX_JSON && verlauf.length > 3) { + verlauf.splice(1, 1) + json = JSON.stringify({ ...daten, verlauf }) + } + return json +} + +function statusFehler(status, aktion) { + if (status === 'offen') return fehler(409, 'falscher_status', 'Diese Bestellung ist noch nicht bezahlt.') + if (status === 'storniert') return fehler(409, 'falscher_status', 'Diese Bestellung wurde storniert.') + if (status === 'erstattet') return fehler(409, 'falscher_status', 'Diese Bestellung wurde bereits vollständig erstattet.') + if (status === aktion) return fehler(409, 'falscher_status', `Diese Bestellung ist schon als ${STATUS_TEXT[aktion] ?? aktion} markiert.`) + return fehler(409, 'falscher_status', `Das geht bei dieser Bestellung nicht mehr (Status: ${STATUS_TEXT[status] ?? status}).`) +} + +/** + * Stellt die Aktion fuer den Haendler zusammen: Statusuebergaenge einer bezahlten Bestellung. + * versendet aus bezahlt, nur Lieferart versand; extra { versanddienst (<= 60), sendungsnummer (<= 80) } optional => G10 + * abholbereit aus bezahlt, nur Lieferart abholung => G11 + * abgeschlossen aus bezahlt | versendet | abholbereit + * storniert nur aus offen (unbezahlt): beendet die Kasse bei Stripe und verwirft (Bestand wird frei) + * Unter der Sperre shop-order:; Eintrag im Verlauf. Die Bestellung muss zum Shop gehoeren (sonst 404), ein falscher Zustand ist + * 409 mit Du-Text. Mailfehler aendern nichts (mailOk false). + * @param {object|string} shopOderCtx Shop-Kontext { doc, shop } oder Shop-ID + * @param {string} orderId + * @param {'versendet'|'abholbereit'|'abgeschlossen'|'storniert'} aktion + * @param {{ versanddienst?: string, sendungsnummer?: string }} [extra] + * @param {{ now?: Date }} [opt] + * @returns {Promise<{ order: object, mailOk: boolean|null }>} order mit daten; mailOk null, wenn keine Mail vorgesehen ist + */ +export async function setzeStatus(shopOderCtx, orderId, aktion, extra = {}, { now = new Date() } = {}) { + if (!AKTIONEN.includes(aktion)) throw fehler(400, 'aktion', 'Diese Aktion gibt es nicht.') + const jetzt = new Date(now) + const ctx = await kontextFuer(shopOderCtx, jetzt) + const shopId = ctx.shop.id + const eingabe = istObjekt(extra) ? extra : {} + let dienst = '' + let sendung = '' + if (aktion === 'versendet') { + for (const [wert, max, name] of [[eingabe.versanddienst, MAX_DIENST, 'Der Versanddienst'], [eingabe.sendungsnummer, MAX_SENDUNG, 'Die Sendungsnummer']]) { + if (wert !== undefined && wert !== null && typeof wert !== 'string') throw fehler(400, 'eingabe', `${name} ist ungültig.`) + if (einzeiler(wert).length > max) throw fehler(400, 'eingabe', `${name} darf höchstens ${max} Zeichen lang sein.`) + } + dienst = einzeiler(eingabe.versanddienst) + sendung = einzeiler(eingabe.sendungsnummer) + } + + if (aktion === 'storniert') { + const order = await eigeneBestellung(shopId, orderId) + if (order.status !== 'offen') throw statusFehler(order.status, aktion) + // Die Kasse beenden, damit nicht nach dem Stornieren noch bezahlt werden kann + if (order.stripeSessionId && ctx.shop.stripeAccountId) { + try { + await sessionAblaufen(ctx.shop.stripeAccountId, order.stripeSessionId) + } catch { + let session = null + try { + session = await sessionHolen(ctx.shop.stripeAccountId, order.stripeSessionId) + } catch { + session = null + } + if (session?.status === 'complete') { + throw fehler(409, 'falscher_status', 'Diese Bestellung wurde gerade bezahlt. Bitte lade die Seite neu.') + } + if (session?.status !== 'expired') { + throw fehler(409, 'stripe_fehler', 'Die Bestellung kann gerade nicht storniert werden. Bitte versuch es gleich noch einmal.') + } + } + } + const erg = await verwirf(orderId, 'vom_haendler', { now: jetzt }) + if (!erg.verworfen) throw statusFehler(erg.order?.status ?? 'bezahlt', aktion) + return { order: erg.order, mailOk: null } + } + + const order = await withLocks([`shop-order:${orderId}`], async () => { + const aktuell = await eigeneBestellung(shopId, orderId) + const daten = aktuell.daten + const erlaubt = aktion === 'abgeschlossen' ? ['bezahlt', 'versendet', 'abholbereit'] : ['bezahlt'] + if (!erlaubt.includes(aktuell.status)) throw statusFehler(aktuell.status, aktion) + if (aktion === 'versendet' && daten.lieferart !== 'versand') throw fehler(409, 'falsche_lieferart', 'Diese Bestellung wird abgeholt, nicht versendet.') + if (aktion === 'abholbereit' && daten.lieferart !== 'abholung') throw fehler(409, 'falsche_lieferart', 'Diese Bestellung wird versendet, nicht abgeholt.') + const at = jetzt.toISOString() + const eintrag = { at, was: aktion, ...(aktion === 'versendet' ? { ...(dienst ? { versanddienst: dienst } : {}), ...(sendung ? { sendungsnummer: sendung } : {}) } : {}) } + const neu = { + ...daten, + ...(aktion === 'versendet' ? { versand: { ...(dienst ? { versanddienst: dienst } : {}), ...(sendung ? { sendungsnummer: sendung } : {}), at } } : {}), + verlauf: verlaufMit(daten, eintrag), + } + const doc = await updateDocument(col(), orderId, { status: aktion, bestellung: jsonVon(neu) }) + return { ...doc, daten: bestellDaten(doc) } + }) + + let mailOk = null + if (aktion === 'versendet') mailOk = (await anKaeufer('G10', ctx, order, { versanddienst: dienst, sendungsnummer: sendung }, jetzt)).ok + else if (aktion === 'abholbereit') mailOk = (await anKaeufer('G11', ctx, order, {}, jetzt)).ok + return { order, mailOk } +} + +/** + * Erstattet eine bezahlte Bestellung ganz oder teilweise ueber Stripe (Direct Charge des Haendlerkontos). + * Erlaubt aus bezahlt | versendet | abholbereit | abgeschlossen (auch nach Teilerstattungen); ein Status bleibt bei Teilerstattung + * unveraendert, bei voller Erstattung wird er `erstattet`. betragCent: ganze Cent von 1 bis zum Rest (summeCent minus bisherige + * Erstattungen), ohne Angabe der ganze Rest. Die ganze Sache einschliesslich des Stripe-Aufrufs laeuft unter der Sperre + * shop-order:: zwei gleichzeitige Erstattungen erstatten nie doppelt (die zweite sieht den neuen Rest). Idempotenz-Schluessel + * `shop-erstattung--`. Eintrag in erstattungen[]: { rid, betragCent, grund, at, quelle:'portal' }. + * Danach G13 an den Kaeufer (Mailfehler aendern nichts). + * @param {object|string} shopOderCtx Shop-Kontext { doc, shop } oder Shop-ID + * @param {string} orderId + * @param {{ betragCent?: number, grund?: string }} [eingabe] grund hoechstens 300 Zeichen + * @param {{ now?: Date }} [opt] + * @returns {Promise<{ order: object, erstattung: object, voll: boolean, mailOk: boolean }>} + * @throws {Error} 400 Eingabe, 404 fremde/unbekannte Bestellung, 409 falscher Zustand, 502 Stripe-Fehler (fester Text) + */ +export async function erstatte(shopOderCtx, orderId, { betragCent, grund } = {}, { now = new Date() } = {}) { + const jetzt = new Date(now) + const ctx = await kontextFuer(shopOderCtx, jetzt) + const shopId = ctx.shop.id + if (grund !== undefined && grund !== null && typeof grund !== 'string') throw fehler(400, 'eingabe', 'Der Grund ist ungültig.') + const grundText = typeof grund === 'string' ? grund.replace(/\r\n?/g, '\n').replace(STEUERZEICHEN_OHNE_UMBRUCH, '').trim() : '' + if (grundText.length > MAX_GRUND) throw fehler(400, 'eingabe', `Der Grund darf höchstens ${MAX_GRUND} Zeichen lang sein.`) + if (betragCent !== undefined && betragCent !== null && !Number.isInteger(betragCent)) throw fehler(400, 'betrag', 'Bitte gib einen gültigen Betrag an.') + + const ergebnis = await withLocks([`shop-order:${orderId}`], async () => { + const order = await eigeneBestellung(shopId, orderId) + const daten = order.daten + if (!STATUS_BEZAHLT.includes(order.status)) { + if (order.status === 'erstattet') throw fehler(409, 'falscher_status', 'Diese Bestellung wurde bereits vollständig erstattet.') + throw fehler(409, 'falscher_status', 'Diese Bestellung ist nicht bezahlt. Es gibt nichts zu erstatten.') + } + const accountId = ctx.shop.stripeAccountId + if (!accountId || !order.paymentIntentId) throw fehler(409, 'keine_zahlung', 'Zu dieser Bestellung gibt es keine Zahlung bei Stripe. Bitte erstatte direkt im Stripe-Dashboard.') + const bisher = Array.isArray(daten.erstattungen) ? daten.erstattungen : [] + const rest = (Number.isInteger(order.summeCent) ? order.summeCent : 0) - erstattetCent(daten) + if (rest <= 0) throw fehler(409, 'falscher_status', 'Diese Bestellung wurde bereits vollständig erstattet.') + const betrag = betragCent === undefined || betragCent === null ? rest : betragCent + if (betrag < 1 || betrag > rest) { + throw fehler(400, 'betrag', `Bitte gib einen Betrag zwischen ${euro(1)} und ${euro(rest)} an (so viel ist noch nicht erstattet).`) + } + + let refund + try { + refund = await erstatten(accountId, { + paymentIntent: order.paymentIntentId, + betragCent: betrag, + grund: grundText || undefined, + idempotencyKey: `shop-erstattung-${orderId}-${bisher.length}`, + }) + if (typeof refund?.id !== 'string' || refund.id === '' || refund.status === 'failed' || refund.status === 'canceled') throw new Error('Erstattung nicht ausgeführt') + } catch (err) { + // Nur Art und Code: Stripe-Texte gehen nie an den Browser + console.error(`[shop] Erstattung fehlgeschlagen: ${String(err?.type ?? err?.name ?? 'Fehler').slice(0, 60)} ${String(err?.code ?? '').slice(0, 60)}`.trim()) + throw fehler(502, 'erstattung_fehlgeschlagen', ERSTATTUNG_FEHLER) + } + + const at = jetzt.toISOString() + const eintrag = { rid: refund.id, betragCent: betrag, grund: grundText, at, quelle: 'portal' } + // Defensiv: hat der Stripe-Abgleich (erstattungen.js) dieselbe Erstattung schon gebucht, wird sie nicht doppelt eingetragen + const erstattungen = bisher.some((e) => e?.rid === refund.id) ? bisher : [...bisher, eintrag] + const voll = erstattetCent({ erstattungen }) >= order.summeCent + const neu = { ...daten, erstattungen, verlauf: verlaufMit(daten, { at, was: voll ? 'erstattet' : 'teilerstattet', betragCent: betrag }) } + try { + const doc = await updateDocument(col(), orderId, { ...(voll ? { status: 'erstattet' } : {}), bestellung: jsonVon(neu) }) + return { order: { ...doc, daten: bestellDaten(doc) }, erstattung: eintrag, voll } + } catch (err) { + console.error(`[shop] Erstattung ${refund.id} bei Stripe gebucht, aber Bestellung ${String(orderId).slice(0, 36)} nicht gespeichert: ${String(err?.message).slice(0, 120)}`) + throw fehler(502, 'erstattung_nicht_gespeichert', ERSTATTUNG_NICHT_GESPEICHERT) + } + }) + + const mail = await anKaeufer('G13', ctx, ergebnis.order, { betragCent: ergebnis.erstattung.betragCent, grund: ergebnis.erstattung.grund }, jetzt) + return { ...ergebnis, mailOk: mail.ok } +} +// eslint-disable-next-line no-control-regex +const STEUERZEICHEN_OHNE_UMBRUCH = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u2028\u2029]/g + +// --------------------------------------------------------------------------------------------------------------------- +// Ansichten fuer den Haendler + +function naechsteAktionen(status, lieferart) { + if (status === 'offen') return ['storniert'] + if (status === 'bezahlt') return [lieferart === 'abholung' ? 'abholbereit' : 'versendet', 'abgeschlossen'] + if (status === 'versendet' || status === 'abholbereit') return ['abgeschlossen'] + return [] +} + +/** + * Ansicht einer Bestellung fuer den Haendler (Detail): alles inklusive Kaeuferdaten, Verlauf und Erstattungen, aber ohne + * Token-Hash, Stripe-Sitzungs- und Zahlungs-ID. `aktionen` sind die Aktionen, die der Status gerade erlaubt (der Server prueft sie trotzdem). + * @param {object} order shopOrders-Dokument (mit oder ohne `daten`) + */ +export function bestellungKunde(order) { + const daten = istObjekt(order?.daten) ? order.daten : bestellDaten(order) + const summen = istObjekt(daten.summen) ? daten.summen : {} + const summeCent = Number.isInteger(order?.summeCent) ? order.summeCent : Number.isInteger(summen.gesamtCent) ? summen.gesamtCent : 0 + const erstattet = erstattetCent(daten) + const bezahlbar = STATUS_BEZAHLT.includes(order?.status) + return { + id: order?.$id ?? '', + bestellnummer: order?.bestellnummer ?? '', + status: order?.status ?? '', + erstelltAt: order?.erstelltAt ?? null, + bezahltAt: order?.bezahltAt ?? null, + summeCent, + summeText: euro(summeCent), + erstattetCent: erstattet, + erstattetText: euro(erstattet), + restCent: Math.max(0, summeCent - erstattet), + restText: euro(Math.max(0, summeCent - erstattet)), + kaeufer: istObjekt(daten.kaeufer) ? daten.kaeufer : { name: '', email: order?.kaeuferEmail ?? '', telefon: '' }, + lieferart: daten.lieferart ?? '', + land: daten.land ?? '', + lieferadresse: istObjekt(daten.lieferadresse) ? daten.lieferadresse : null, + rechnungsadresse: istObjekt(daten.rechnungsadresse) ? daten.rechnungsadresse : null, + abholung: istObjekt(daten.abholung) ? daten.abholung : null, + zahlungsart: typeof daten.zahlungsart === 'string' ? daten.zahlungsart : '', + lieferzeit: typeof daten.lieferzeit === 'string' ? daten.lieferzeit : '', + positionen: Array.isArray(daten.positionen) ? daten.positionen : [], + summen: { + zwischensummeCent: summen.zwischensummeCent ?? null, + versandCent: summen.versandCent ?? null, + gesamtCent: summen.gesamtCent ?? null, + steuer: summen.steuer ?? null, + texte: istObjekt(summen.texte) ? summen.texte : {}, + }, + versand: istObjekt(daten.versand) ? daten.versand : null, + ueberverkauft: daten.ueberverkauft === true, + spaet: daten.spaet === true, + verlauf: Array.isArray(daten.verlauf) ? daten.verlauf : [], + erstattungen: Array.isArray(daten.erstattungen) ? daten.erstattungen : [], + aktionen: naechsteAktionen(order?.status, daten.lieferart), + kannErstatten: bezahlbar && summeCent - erstattet > 0 && Boolean(order?.paymentIntentId), + } +} + +/** Zeile der Liste: das Wichtigste auf einen Blick. */ +function listenZeile(doc) { + const daten = bestellDaten(doc) + const kaeufer = istObjekt(daten.kaeufer) ? daten.kaeufer : {} + const summeCent = Number.isInteger(doc.summeCent) ? doc.summeCent : 0 + const erstattet = erstattetCent(daten) + return { + id: doc.$id, + bestellnummer: doc.bestellnummer ?? '', + status: doc.status ?? '', + erstelltAt: doc.erstelltAt ?? null, + bezahltAt: doc.bezahltAt ?? null, + summeCent, + summeText: euro(summeCent), + erstattetCent: erstattet, + kaeuferName: typeof kaeufer.name === 'string' ? kaeufer.name : '', + kaeuferEmail: doc.kaeuferEmail || (typeof kaeufer.email === 'string' ? kaeufer.email : ''), + lieferart: daten.lieferart ?? '', + anzahlArtikel: Array.isArray(daten.positionen) ? daten.positionen.reduce((s, p) => s + (Number.isInteger(p?.menge) ? p.menge : 0), 0) : 0, + ueberverkauft: daten.ueberverkauft === true, + spaet: daten.spaet === true, + } +} + +/** + * Bestellungen des Shops, neueste zuerst (nach erstelltAt), 50 je Seite. + * status: eines von offen|bezahlt|versendet|abholbereit|abgeschlossen|erstattet|storniert; ohne Angabe (oder 'alle') alle bezahlten + * Bestellungen (bezahlt bis erstattet), also ohne unbezahlte `offen` und `storniert`. + * suche: Bestellnummer als Anfang (ohne Beruecksichtigung der Schreibweise) oder Teil des Kaeufernamens bzw. der E-Mail. Name und + * E-Mail stehen im JSON der Bestellung und nicht in einer durchsuchbaren Spalte, deshalb wird im Speicher gesucht – ueber hoechstens + * die neuesten 2000 Bestellungen (`abgeschnitten: true`, wenn dort Schluss war und es aelteres gibt). Das Blaettern liest ebenfalls + * hoechstens 2000 Bestellungen (Seite 40). + * @param {string} shopId + * @param {{ status?: string, suche?: string, seite?: number }} [filter] + * @returns {Promise<{ bestellungen: object[], seite: number, mehr: boolean, abgeschnitten: boolean }>} + */ +export async function listeBestellungen(shopId, { status, suche, seite = 1 } = {}) { + if (status !== undefined && status !== null && status !== '' && status !== 'alle' && !STATUS_ALLE.includes(status)) { + throw fehler(400, 'status', 'Diesen Status gibt es nicht.') + } + const nr = Number.isInteger(seite) && seite >= 1 ? Math.min(seite, Math.floor(LISTE_MAX_GELESEN / LISTE_SEITE)) : 1 + const gesucht = typeof suche === 'string' ? einzeiler(suche).toLowerCase().slice(0, 100) : '' + const statusFilter = !status || status === 'alle' ? STATUS_ECHT : [status] + const passt = (doc) => { + if (!gesucht) return true + if (String(doc.bestellnummer ?? '').toLowerCase().startsWith(gesucht)) return true + const kaeufer = bestellDaten(doc).kaeufer + const name = typeof kaeufer?.name === 'string' ? kaeufer.name.toLowerCase() : '' + const mails = [doc.kaeuferEmail, kaeufer?.email].map((m) => String(m ?? '').toLowerCase()) + return name.includes(gesucht) || mails.some((m) => m.includes(gesucht)) + } + + const brauche = nr * LISTE_SEITE + 1 + const treffer = [] + let gelesen = 0 + let cursor = null + let ende = false + while (treffer.length < brauche && gelesen < LISTE_MAX_GELESEN) { + const page = await listDocuments(col(), [ + Query.equal('shopId', shopId), + Query.equal('status', statusFilter), + Query.orderDesc('erstelltAt'), + ...(cursor ? [Query.cursorAfter(cursor)] : []), + Query.limit(LISTE_BATCH), + ]) + gelesen += page.length + for (const doc of page) if (passt(doc)) treffer.push(doc) + if (page.length < LISTE_BATCH) { + ende = true + break + } + cursor = page[page.length - 1].$id + } + const von = (nr - 1) * LISTE_SEITE + return { + bestellungen: treffer.slice(von, von + LISTE_SEITE).map(listenZeile), + seite: nr, + mehr: treffer.length > von + LISTE_SEITE, + abgeschnitten: !ende && treffer.length < brauche, + } +} + +// --------------------------------------------------------------------------------------------------------------------- +// CSV + +const CSV_STATUS = { bezahlt: 'Bezahlt', versendet: 'Versendet', abholbereit: 'Abholbereit', abgeschlossen: 'Abgeschlossen', erstattet: 'Erstattet' } +const CSV_KOPF = ['Bestellnummer', 'Datum', 'Status', 'Name', 'E-Mail', 'Lieferart', 'Adresse', 'Positionen', 'Versand', 'Gesamt', 'Erstattet'] + +/** Betrag fuer Tabellen: '12,50' (kein Tausenderpunkt, kein Euro-Zeichen, kein geschuetztes Leerzeichen). */ +const betragCsv = (cent) => `${Math.floor(cent / 100)},${String(cent % 100).padStart(2, '0')}` + +/** + * Eine CSV-Zelle: Zellen, die mit = + - @ (auch nach fuehrendem Leerraum), Tab oder Wagenruecklauf beginnen, bekommen ein + * Apostroph vorangestellt (Formel-Injektion in Excel/Calc); enthaelt die Zelle ; " oder Zeilenumbruch, wird sie in "..." gesetzt. + */ +export function csvZelle(wert) { + let text = wert === null || wert === undefined ? '' : String(wert) + if (/^[\s]*[=+\-@]/.test(text) || /^[\t\r]/.test(text)) text = `'${text}` + return /[;"\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text +} + +function adresseCsv(daten) { + const a = istObjekt(daten.lieferadresse) ? daten.lieferadresse : null + if (!a) return daten.lieferart === 'abholung' ? 'Abholung' : '' + return [a.line1, a.line2, [a.postal_code, a.city].filter(Boolean).join(' '), a.country].filter((t) => typeof t === 'string' && t !== '').join(', ') +} + +function positionenCsv(daten) { + const liste = Array.isArray(daten.positionen) ? daten.positionen : [] + return liste.map((p) => `${p.menge} × ${p.name}${p.varianteText ? ` (${p.varianteText})` : ''}`).join(' | ') +} + +/** Eine Bestellung als Zeile; Datum = Zahlungszeitpunkt (sonst Bestellzeitpunkt) in Berliner Zeit. */ +function csvZeile(doc) { + const daten = bestellDaten(doc) + const kaeufer = istObjekt(daten.kaeufer) ? daten.kaeufer : {} + const zeit = new Date(doc.bezahltAt || doc.erstelltAt) + const summe = Number.isInteger(doc.summeCent) ? doc.summeCent : 0 + const versand = Number.isInteger(daten.summen?.versandCent) ? daten.summen.versandCent : 0 + return [ + doc.bestellnummer, + Number.isNaN(zeit.getTime()) ? '' : `${datumKurz(zeit)} ${uhrzeit(zeit)}`, + CSV_STATUS[doc.status] ?? doc.status, + kaeufer.name, + doc.kaeuferEmail || kaeufer.email, + daten.lieferart === 'abholung' ? 'Abholung' : 'Versand', + adresseCsv(daten), + positionenCsv(daten), + betragCsv(versand), + betragCsv(summe), + betragCsv(erstattetCent(daten)), + ].map(csvZelle).join(';') +} + +/** 'JJJJ-MM-TT' ist ein wirklich vorhandener Kalendertag (kein 30. Februar). */ +function echterTag(tag) { + const [j, m, t] = tag.split('-').map(Number) + const d = new Date(Date.UTC(j, m - 1, t)) + return d.getUTCFullYear() === j && d.getUTCMonth() === m - 1 && d.getUTCDate() === t +} + +function csvTag(wert, name) { + if (wert === undefined || wert === null || wert === '') return '' + if (typeof wert !== 'string' || !TAG_RE.test(wert) || !echterTag(wert)) { + throw fehler(400, 'datum', `${name} ist kein gültiges Datum (Jahr-Monat-Tag, zum Beispiel 2026-10-31).`) + } + return wert +} + +/** + * Bestellungen als CSV fuer die Buchhaltung: UTF-8 mit BOM, `;`-getrennt, Zeilenende CRLF. Enthalten sind alle bezahlten + * Bestellungen (auch erstattete), deren Datum (Zahlung, sonst Bestellung; Berliner Kalendertag) zwischen von und bis liegt + * (beide einschliesslich, 'JJJJ-MM-TT', jede Grenze optional). Spalten: Bestellnummer, Datum, Status, Name, E-Mail, Lieferart, Adresse, + * Positionen, Versand, Gesamt, Erstattet (Betraege wie 12,50 in Euro). Zellen, die mit = + - @ beginnen, bekommen ein Apostroph. + * @param {string} shopId + * @param {{ von?: string, bis?: string }} [zeitraum] + * @param {{ now?: Date }} [opt] + * @returns {Promise<{ csv: string, dateiname: string, anzahl: number }>} + */ +export async function csv(shopId, { von, bis } = {}, { now = new Date() } = {}) { + const vonTag = csvTag(von, '„Von“') + const bisTag = csvTag(bis, '„Bis“') + if (vonTag && bisTag && vonTag > bisTag) throw fehler(400, 'datum', '„Von“ darf nicht nach „Bis“ liegen.') + const zeilen = [] + let cursor = null + for (;;) { + const page = await listDocuments(col(), [ + Query.equal('shopId', shopId), + Query.equal('status', STATUS_ECHT), + // Eine Bestellung kann nicht vor ihrer Anlage bezahlt sein: spaeter Angelegtes faellt sicher aus dem Zeitraum + ...(bisTag ? [Query.lessThan('erstelltAt', zuUtc(plusTage(bisTag, 1), '00:00').toISOString())] : []), + Query.orderAsc('$id'), + ...(cursor ? [Query.cursorAfter(cursor)] : []), + Query.limit(CSV_BATCH), + ]) + for (const doc of page) { + const tag = heute(new Date(doc.bezahltAt || doc.erstelltAt)) + if ((vonTag && tag < vonTag) || (bisTag && tag > bisTag)) continue + zeilen.push({ tag, zeit: new Date(doc.bezahltAt || doc.erstelltAt).getTime(), zeile: csvZeile(doc) }) + if (zeilen.length > CSV_MAX_ZEILEN) throw fehler(400, 'zu_viele', 'Das sind zu viele Bestellungen auf einmal. Bitte wähle einen kürzeren Zeitraum.') + } + if (page.length < CSV_BATCH) break + cursor = page[page.length - 1].$id + } + zeilen.sort((a, b) => a.zeit - b.zeit) + const inhalt = `${[CSV_KOPF.join(';'), ...zeilen.map((z) => z.zeile)].join('\r\n')}\r\n` + return { csv: inhalt, dateiname: `webshop-bestellungen-${vonTag || 'anfang'}-${bisTag || heute(new Date(now))}.csv`, anzahl: zeilen.length } +} diff --git a/test/shopBestellAktionen.test.js b/test/shopBestellAktionen.test.js new file mode 100644 index 0000000..6f9b989 --- /dev/null +++ b/test/shopBestellAktionen.test.js @@ -0,0 +1,595 @@ +import { test, after, beforeEach, afterEach, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { mailSink } from './support/mailSink.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { SHOP_DATETIMES } from '../server/services/shop/schema.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Webshop E3: Haendler-Aktionen (setzeStatus), Erstattung (erstatte), Liste, Detail, CSV und die Kundenrouten /api/shop/bestellungen*, /widerrufe* +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + shopConfig: schemaKeys('shopConfig'), + shopProducts: schemaKeys('shopProducts'), + shopOrders: schemaKeys('shopOrders'), + shopWiderrufe: schemaKeys('shopWiderrufe'), + termineOutbox: schemaKeys('termineOutbox'), + customerViewLog: schemaKeys('customerViewLog'), + }, + datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') +const K = await import('../server/services/shop/kontext.js') +const B = await import('../server/services/shop/bestellungen.js') +const sf = await import('../server/services/siteFeatures.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie, viewAsCookie, seedFixtures, customers } = await import('./support/fixtures.js') +const { default: shopRoutes, resetShopRoutesForTests } = await import('../server/routes/shop.js') +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js') +const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js') + +const app = await startTestApp({ '/': kundenansichtGuard, '/api/shop': shopRoutes }) +after(async () => { + setMailTransportForTests(null) + await Promise.all([app.close(), aw.close(), st.close()]) +}) + +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Brotladen', archived: false }) +aw.seed('websiteProjects', { $id: 'pt3', customerId: 'c2', projectName: 'Fremdes Projekt' }) + +const KUNDE = 'c1' +const PROJEKT = 'pt1' +const ACCOUNT = 'acct_haendler1' +const T0 = new Date('2026-10-05T12:00:00.000Z') +const nach = (s) => new Date(T0.getTime() + s * 1000) +const cookie = customerCookie('c1') + +const EINSTELLUNGEN = { + ...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)), + shopname: 'Brot & Mehr', + anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' }, + abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' }, + versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null }, + zahlungsarten: ['card'], +} +const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' } +const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN) + +let sink +let log +let ctx +let feature +let shopId +let zaehler = 0 + +async function aufbau(state = 'active') { + for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'shopWiderrufe', 'termineOutbox', 'customerViewLog']) { + for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + } + feature = await sf.ensureFeatureDoc(KUNDE, PROJEKT, 'shop') + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields(state)) + feature = await sf.getFeatureDoc(KUNDE, PROJEKT, 'shop') + shopId = feature.$id + aw.seed('shopConfig', { + $id: shopId, + customerId: KUNDE, + projectId: PROJEKT, + siteKey: feature.siteKey, + anfrageStatus: 'angenommen', + stripeAccountId: ACCOUNT, + stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }), + einstellungen: JSON.stringify(EINSTELLUNGEN), + rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }), + geoeffnet: true, + version: 1, + }) + st.refunds(ACCOUNT).clear() + st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } }) + K.leereCache() + ctx = await K.shopPerSiteKey(feature.siteKey, { now: T0 }) + assert.ok(ctx, 'Shop-Kontext') + return ctx +} + +beforeEach(async () => { + st.calls.length = 0 + config.publicBaseUrl = 'https://project.webklar.com' + config.smtp.from = 'noreply@webklar.com' + config.shop.mode = 'pilot' + config.shop.pilotEmails = [customers.c1.email] + resetShopRoutesForTests() + resetOutboxForTests() + sink = mailSink() + setMailTransportForTests(sink) + log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) } + await aufbau() +}) +afterEach(async () => { + await warteAufVersand() + mock.restoreAll() +}) + +const order = (id) => aw.docs('shopOrders').get(id) +const daten = (id) => JSON.parse(order(id).bestellung) +const eintraege = () => [...aw.docs('termineOutbox').values()] +const refunds = () => [...st.refunds(ACCOUNT).values()] + +const POS = [{ produktId: 'a1', varianteId: null, name: 'Brot', varianteText: '', menge: 2, einzelCent: 600, summeCent: 1200, steuersatz: 0, widerruf: 'gesetzlich' }] + +/** Bestellung direkt in die Ablage legen (Zustand wie nach schliesseAb). */ +function bestellung(over = {}, datenOver = {}) { + zaehler += 1 + const id = over.$id ?? `o${String(zaehler).padStart(3, '0')}` + const lieferart = datenOver.lieferart ?? 'versand' + const summeCent = over.summeCent ?? 1690 + const d = { + positionen: POS, + summen: { zwischensummeCent: 1200, versandCent: 490, gesamtCent: summeCent, steuer: null, texte: { gesamt: '16,90 €' } }, + lieferart, + land: 'DE', + anbieter: EINSTELLUNGEN.anbieter, + shopname: 'Brot & Mehr', + lieferzeit: '2–4 Werktage', + zahlungsarten: ['card'], + kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' }, + lieferadresse: lieferart === 'versand' ? { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: '', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } : undefined, + zahlungsart: 'card', + verlauf: [{ at: T0.toISOString(), was: 'angelegt' }, { at: nach(60).toISOString(), was: 'bezahlt' }], + ...(lieferart === 'abholung' ? { abholung: { adresse: EINSTELLUNGEN.abholung.adresse, hinweis: EINSTELLUNGEN.abholung.hinweis } } : {}), + ...datenOver, + } + aw.seed('shopOrders', { + $id: id, + shopId, + customerId: KUNDE, + bestellnummer: `26-${String(zaehler).padStart(6, 'A')}`, + status: 'bezahlt', + tokenHash: 'h'.repeat(64), + stripeSessionId: `cs_${id}`, + paymentIntentId: `pi_${id}`, + summeCent, + erstelltAt: nach(zaehler).toISOString(), + ablaufAt: nach(1800).toISOString(), + bezahltAt: nach(60 + zaehler).toISOString(), + kaeuferEmail: 'max@example.com', + bestellung: JSON.stringify(d), + ...over, + }) + st.setPaymentIntent(ACCOUNT, `pi_${id}`, { amount: summeCent }) + return id +} + +// ------------------------------------------------------------------------------------------------ setzeStatus + +test('versendet: aus bezahlt, Verlauf mit Dienst und Sendungsnummer, G10 an den Käufer', async () => { + const id = bestellung() + const erg = await B.setzeStatus(ctx, id, 'versendet', { versanddienst: ' DHL ', sendungsnummer: 'JJD0123 456' }, { now: nach(600) }) + assert.equal(erg.order.status, 'versendet') + assert.equal(erg.mailOk, true) + assert.equal(order(id).status, 'versendet') + const v = daten(id).verlauf.at(-1) + assert.deepEqual(v, { at: nach(600).toISOString(), was: 'versendet', versanddienst: 'DHL', sendungsnummer: 'JJD0123 456' }) + assert.equal(daten(id).versand.sendungsnummer, 'JJD0123 456') + await warteAufVersand() + assert.deepEqual(eintraege().map((e) => e.art), ['G10']) + const mail = sink.sent.find((m) => m.to[0].address === 'max@example.com') + assert.ok(mail.text.includes('JJD0123 456') && mail.text.includes('DHL')) + // zweiter Aufruf: 409 + await assert.rejects(B.setzeStatus(ctx, id, 'versendet', {}, { now: nach(700) }), (e) => e.status === 409 && /schon als versendet/.test(e.message)) + assert.equal(eintraege().length, 1) +}) + +test('versendet: Eingaben prüfen (Länge, Typ), nur bei Versand; abholbereit nur bei Abholung (G11)', async () => { + const v = bestellung() + await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { versanddienst: 'x'.repeat(61) }), (e) => e.status === 400) + await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { sendungsnummer: 'x'.repeat(81) }), (e) => e.status === 400) + await assert.rejects(B.setzeStatus(ctx, v, 'versendet', { sendungsnummer: 5 }), (e) => e.status === 400) + assert.equal(order(v).status, 'bezahlt') + await assert.rejects(B.setzeStatus(ctx, v, 'abholbereit'), (e) => e.status === 409 && /versendet/.test(e.message)) + const a = bestellung({}, { lieferart: 'abholung' }) + await assert.rejects(B.setzeStatus(ctx, a, 'versendet'), (e) => e.status === 409 && /abgeholt/.test(e.message)) + const erg = await B.setzeStatus(ctx, a, 'abholbereit', {}, { now: nach(30) }) + assert.equal(erg.order.status, 'abholbereit') + await warteAufVersand() + assert.deepEqual(eintraege().map((e) => e.art), ['G11']) + assert.ok(sink.sent.find((m) => m.to[0].address === 'max@example.com').text.includes('Mo–Fr 8–12 Uhr')) + await assert.rejects(B.setzeStatus(ctx, a, 'abholbereit'), (e) => e.status === 409) +}) + +test('abgeschlossen: aus bezahlt, versendet und abholbereit; nicht aus offen, erstattet, storniert; keine Mail', async () => { + const a = bestellung() + const b = bestellung({ status: 'versendet' }) + const c = bestellung({ status: 'abholbereit' }, { lieferart: 'abholung' }) + for (const id of [a, b, c]) assert.equal((await B.setzeStatus(ctx, id, 'abgeschlossen', {}, { now: nach(10) })).order.status, 'abgeschlossen') + assert.equal(daten(a).verlauf.at(-1).was, 'abgeschlossen') + for (const status of ['offen', 'erstattet', 'storniert', 'abgeschlossen']) { + const id = bestellung({ status }) + await assert.rejects(B.setzeStatus(ctx, id, 'abgeschlossen'), (e) => e.status === 409, status) + } + await warteAufVersand() + assert.equal(eintraege().length, 0) + await assert.rejects(B.setzeStatus(ctx, a, 'quatsch'), (e) => e.status === 400) +}) + +test('storniert: nur aus offen - beendet die Kasse bei Stripe und verwirft; bezahlte Bestellung 409', async () => { + const id = bestellung({ status: 'offen', stripeSessionId: 'cs_offen1' }) + st.accountSessions(ACCOUNT).set('cs_offen1', { id: 'cs_offen1', object: 'checkout.session', status: 'open', payment_status: 'unpaid' }) + const erg = await B.setzeStatus(ctx, id, 'storniert', {}, { now: nach(100) }) + assert.equal(erg.order.status, 'storniert') + assert.equal(st.accountSessions(ACCOUNT).get('cs_offen1').status, 'expired') + assert.equal(daten(id).verlauf.at(-1).was, 'vom_haendler') + const bezahlt = bestellung() + await assert.rejects(B.setzeStatus(ctx, bezahlt, 'storniert'), (e) => e.status === 409 && /noch nicht|nicht mehr/.test(e.message)) + assert.equal(order(bezahlt).status, 'bezahlt') +}) + +test('fremde und unbekannte Bestellungen: 404, nichts verändert; auch per Shop-ID statt Kontext', async () => { + const fremd = bestellung({ shopId: 'f_anderer_shop' }) + await assert.rejects(B.setzeStatus(ctx, fremd, 'abgeschlossen'), (e) => e.status === 404) + await assert.rejects(B.erstatte(ctx, fremd, {}), (e) => e.status === 404) + await assert.rejects(B.setzeStatus(ctx, 'gibtsnicht', 'abgeschlossen'), (e) => e.status === 404) + await assert.rejects(B.setzeStatus(ctx, '../x', 'abgeschlossen'), (e) => e.status === 404) + assert.equal(order(fremd).status, 'bezahlt') + assert.equal(refunds().length, 0) + const eigene = bestellung() + assert.equal((await B.setzeStatus(shopId, eigene, 'abgeschlossen')).order.status, 'abgeschlossen') +}) + +// ------------------------------------------------------------------------------------------------ erstatte + +test('erstatte: Teilerstattung behält den Status, Restbetrag zählt, dann Rest voll => erstattet; G13 je Erstattung', async () => { + const id = bestellung({ status: 'versendet' }) + const t = await B.erstatte(ctx, id, { betragCent: 500, grund: 'Brot war hart' }, { now: nach(100) }) + assert.equal(t.voll, false) + assert.equal(t.order.status, 'versendet') + assert.equal(t.mailOk, true) + const r1 = refunds() + assert.equal(r1.length, 1) + assert.equal(r1[0].amount, 500) + assert.equal(r1[0].payment_intent, `pi_${id}`) + assert.deepEqual(daten(id).erstattungen, [{ rid: r1[0].id, betragCent: 500, grund: 'Brot war hart', at: nach(100).toISOString(), quelle: 'portal' }]) + assert.equal(order(id).status, 'versendet') + assert.equal(daten(id).verlauf.at(-1).was, 'teilerstattet') + + await assert.rejects(B.erstatte(ctx, id, { betragCent: 1191 }), (e) => e.status === 400 && /11,90/.test(e.message)) + const rest = await B.erstatte(ctx, id, {}, { now: nach(200) }) + assert.equal(rest.voll, true) + assert.equal(rest.erstattung.betragCent, 1190) + assert.equal(order(id).status, 'erstattet') + assert.equal(refunds().length, 2) + assert.equal(daten(id).erstattungen.length, 2) + // erstattet: nichts mehr zu holen + await assert.rejects(B.erstatte(ctx, id, {}), (e) => e.status === 409) + await warteAufVersand() + assert.deepEqual(eintraege().map((e) => e.art), ['G13', 'G13']) + const texte = sink.sent.filter((m) => m.to[0].address === 'max@example.com').map((m) => m.text) + assert.ok(texte.some((t) => t.includes('5,00') && t.includes('Brot war hart'))) + // Idempotenz-Schluessel je Zaehlerstand, Aufruf im Namen des Haendlerkontos + const aufrufe = st.calls.filter((c) => c.path === '/v1/refunds') + assert.deepEqual(aufrufe.map((c) => c.idempotencyKey), [`shop-erstattung-${id}-0`, `shop-erstattung-${id}-1`]) + assert.ok(aufrufe.every((c) => c.account === ACCOUNT)) +}) + +test('erstatte: Eingaben und Zustände - Betrag ganze Cent 1..Rest, Grund ≤ 300, unbezahlt/storniert/ohne Zahlung 409', async () => { + const id = bestellung() + for (const betragCent of [0, -5, 1.5, 1691, '500']) { + await assert.rejects(B.erstatte(ctx, id, { betragCent }), (e) => e.status === 400, String(betragCent)) + } + await assert.rejects(B.erstatte(ctx, id, { grund: 'x'.repeat(301) }), (e) => e.status === 400) + await assert.rejects(B.erstatte(ctx, id, { grund: 42 }), (e) => e.status === 400) + assert.equal(refunds().length, 0) + for (const status of ['offen', 'storniert']) { + await assert.rejects(B.erstatte(ctx, bestellung({ status }), {}), (e) => e.status === 409, status) + } + await assert.rejects(B.erstatte(ctx, bestellung({ paymentIntentId: '' }), {}), (e) => e.status === 409 && e.code === 'keine_zahlung') + assert.equal(refunds().length, 0) + // Ohne Betrag: der ganze Rest; Grund ganz weglassen geht + const voll = await B.erstatte(ctx, id, {}, { now: nach(5) }) + assert.equal(voll.erstattung.betragCent, 1690) + assert.equal(voll.erstattung.grund, '') + assert.equal(order(id).status, 'erstattet') +}) + +test('erstatte: Erstattungen aus dem Stripe-Dashboard (quelle stripe) zählen zum Rest; gleiche rid wird nicht doppelt eingetragen', async () => { + const id = bestellung({}, { erstattungen: [{ rid: 're_dash', betragCent: 1000, grund: 'Stripe-Dashboard', at: nach(5).toISOString(), quelle: 'stripe' }] }) + await assert.rejects(B.erstatte(ctx, id, { betragCent: 691 }), (e) => e.status === 400 && /6,90/.test(e.message)) + // Stripe kennt die Dashboard-Erstattung ebenfalls (sonst waere der Rest dort groesser) + st.refunds(ACCOUNT).set('re_dash', { id: 're_dash', amount: 1000, payment_intent: `pi_${id}` }) + const erg = await B.erstatte(ctx, id, {}, { now: nach(50) }) + assert.equal(erg.erstattung.betragCent, 690) + assert.equal(erg.voll, true) + assert.deepEqual(daten(id).erstattungen.map((e) => e.quelle), ['stripe', 'portal']) + assert.equal(order(id).status, 'erstattet') +}) + +test('erstatte: zwei gleichzeitige Voll-Erstattungen - genau eine geht an Stripe, die andere 409', async () => { + const id = bestellung() + const ergebnisse = await Promise.allSettled([B.erstatte(ctx, id, {}, { now: nach(1) }), B.erstatte(ctx, id, {}, { now: nach(2) }), B.erstatte(ctx, id, {}, { now: nach(3) })]) + assert.equal(ergebnisse.filter((e) => e.status === 'fulfilled').length, 1) + for (const e of ergebnisse.filter((x) => x.status === 'rejected')) assert.equal(e.reason.status, 409) + assert.equal(refunds().length, 1) + assert.equal(daten(id).erstattungen.length, 1) + await warteAufVersand() + assert.equal(eintraege().filter((e) => e.art === 'G13').length, 1) +}) + +test('erstatte: zwei gleichzeitige Teil-Erstattungen, die zusammen zu viel wären - die zweite wird abgelehnt', async () => { + const id = bestellung() + const ergebnisse = await Promise.allSettled([B.erstatte(ctx, id, { betragCent: 1000 }), B.erstatte(ctx, id, { betragCent: 1000 })]) + assert.equal(ergebnisse.filter((e) => e.status === 'fulfilled').length, 1) + assert.equal(ergebnisse.find((e) => e.status === 'rejected').reason.status, 400) + assert.equal(refunds().length, 1) +}) + +test('erstatte: Stripe-Fehler => 502 mit festem Text ohne Stripe-Wortlaut, nichts gespeichert, keine Mail', async () => { + const id = bestellung() + st.setPaymentIntent(ACCOUNT, `pi_${id}`, { amount: 500 }) // Stripe kennt nur 5 € Rest => Betrag zu gross + const err = await B.erstatte(ctx, id, { betragCent: 1000, grund: 'x' }).catch((e) => e) + assert.equal(err.status, 502) + assert.equal(err.message, 'Die Erstattung hat bei Stripe nicht geklappt. Bitte versuch es noch einmal oder erstatte direkt im Stripe-Dashboard.') + assert.doesNotMatch(err.message, /greater|amount/i) + assert.equal(daten(id).erstattungen, undefined) + assert.equal(order(id).status, 'bezahlt') + await warteAufVersand() + assert.equal(eintraege().length, 0) + // danach geht es mit passendem Betrag wieder + const ok = await B.erstatte(ctx, id, { betragCent: 500 }) + assert.equal(ok.erstattung.betragCent, 500) +}) + +// ------------------------------------------------------------------------------------------------ Liste und Detail + +test('listeBestellungen: neueste zuerst, ohne Filter nur bezahlte, Statusfilter, Suche (Nummer-Anfang, Name, E-Mail), Blättern mit 50 je Seite', async () => { + const ids = [] + for (let i = 0; i < 55; i += 1) ids.push(bestellung({ kaeuferEmail: `k${i}@example.com` }, { kaeufer: { name: i === 3 ? 'Erika Beispiel' : `Kunde ${i}`, email: `k${i}@example.com`, telefon: '' } })) + const offen = bestellung({ status: 'offen' }) + const storniert = bestellung({ status: 'storniert' }) + const versendet = bestellung({ status: 'versendet' }) + + const s1 = await B.listeBestellungen(shopId, {}) + assert.equal(s1.bestellungen.length, 50) + assert.equal(s1.mehr, true) + assert.equal(s1.bestellungen[0].id, versendet, 'zuletzt angelegt zuerst') + assert.ok(!s1.bestellungen.some((b) => [offen, storniert].includes(b.id))) + const s2 = await B.listeBestellungen(shopId, { seite: 2 }) + assert.equal(s2.bestellungen.length, 6) + assert.equal(s2.mehr, false) + assert.equal(new Set([...s1.bestellungen, ...s2.bestellungen].map((b) => b.id)).size, 56) + + assert.deepEqual((await B.listeBestellungen(shopId, { status: 'offen' })).bestellungen.map((b) => b.id), [offen]) + assert.deepEqual((await B.listeBestellungen(shopId, { status: 'versendet' })).bestellungen.map((b) => b.id), [versendet]) + await assert.rejects(B.listeBestellungen(shopId, { status: 'quatsch' }), (e) => e.status === 400) + + const name = await B.listeBestellungen(shopId, { suche: 'erika' }) + assert.deepEqual(name.bestellungen.map((b) => [b.id, b.kaeuferName]), [[ids[3], 'Erika Beispiel']]) + const nr = await B.listeBestellungen(shopId, { suche: order(ids[7]).bestellnummer.toLowerCase() }) + assert.deepEqual(nr.bestellungen.map((b) => b.id), [ids[7]]) + assert.equal((await B.listeBestellungen(shopId, { suche: 'k12@' })).bestellungen.length, 1) + assert.equal((await B.listeBestellungen(shopId, { suche: 'gibtsnicht' })).bestellungen.length, 0) + // andere Shops sind unsichtbar + assert.equal((await B.listeBestellungen('f_anderer', {})).bestellungen.length, 0) + const zeile = s1.bestellungen[0] + assert.deepEqual(Object.keys(zeile).sort(), ['anzahlArtikel', 'bestellnummer', 'bezahltAt', 'erstattetCent', 'erstelltAt', 'id', 'kaeuferEmail', 'kaeuferName', 'lieferart', 'spaet', 'status', 'summeCent', 'summeText', 'ueberverkauft']) +}) + +test('bestellungKunde: alle Händlerdaten, keine Token-/Stripe-Kennungen, mögliche Aktionen und Rest', async () => { + const id = bestellung({}, { erstattungen: [{ rid: 're_1', betragCent: 190, grund: 'Kulanz', at: nach(9).toISOString(), quelle: 'portal' }], ueberverkauft: true }) + const ansicht = B.bestellungKunde(await B.bestellungLesen(id)) + assert.equal(ansicht.kaeufer.email, 'max@example.com') + assert.equal(ansicht.lieferadresse.line1, 'Lieferweg 7') + assert.equal(ansicht.erstattetCent, 190) + assert.equal(ansicht.restCent, 1500) + assert.deepEqual(ansicht.aktionen, ['versendet', 'abgeschlossen']) + assert.equal(ansicht.kannErstatten, true) + assert.equal(ansicht.ueberverkauft, true) + assert.equal(ansicht.verlauf.length, 2) + assert.equal(ansicht.positionen[0].name, 'Brot') + const text = JSON.stringify(ansicht) + assert.ok(!text.includes('hhhhhh') && !text.includes('cs_') && !text.includes('pi_')) + assert.deepEqual(B.bestellungKunde(await B.bestellungLesen(bestellung({ status: 'abholbereit' }, { lieferart: 'abholung' }))).aktionen, ['abgeschlossen']) + assert.deepEqual(B.bestellungKunde(await B.bestellungLesen(bestellung({ status: 'erstattet' }))).aktionen, []) +}) + +// ------------------------------------------------------------------------------------------------ CSV + +test('csv: BOM, Semikolon, Kopfzeile, Beträge ohne NBSP, Berliner Datum, Formel-Injektion entschärft, Anführungszeichen verdoppelt', async () => { + const a = bestellung({ bezahltAt: '2026-10-04T22:30:00.000Z', summeCent: 1250, kaeuferEmail: '+evil@example.com' }, { kaeufer: { name: '=HYPERLINK("http://x.example";"a")', email: '+evil@example.com', telefon: '' } }) + const b = bestellung( + { bezahltAt: '2026-10-06T08:00:00.000Z', status: 'erstattet', kaeuferEmail: 'anna@example.com' }, + { kaeufer: { name: 'Anna; "Annie" Schmidt', email: 'anna@example.com', telefon: '' }, erstattungen: [{ rid: 're_x', betragCent: 1690, grund: '', at: T0.toISOString(), quelle: 'portal' }] } + ) + bestellung({ status: 'offen' }) + bestellung({ status: 'storniert' }) + const erg = await B.csv(shopId, {}, { now: nach(0) }) + assert.ok(erg.csv.startsWith('\uFEFFBestellnummer;Datum;Status;Name;E-Mail;Lieferart;Adresse;Positionen;Versand;Gesamt;Erstattet\r\n')) + assert.equal(erg.anzahl, 2) + const zeilen = erg.csv.slice(1).trimEnd().split('\r\n') + assert.equal(zeilen.length, 3) + assert.equal(erg.dateiname, 'webshop-bestellungen-anfang-2026-10-05.csv') + assert.ok(zeilen[1].startsWith(`${order(a).bestellnummer};05.10.2026 00:30;Bezahlt;`), zeilen[1]) // 22:30 UTC = 00:30 Berlin am Folgetag + assert.ok(zeilen[1].includes(`;"'=HYPERLINK(""http://x.example"";""a"")";'+evil@example.com;Versand;Lieferweg 7, 67655 Kaiserslautern, DE;2 × Brot;4,90;12,50;0,00`), zeilen[1]) + assert.ok(zeilen[2].includes(';Erstattet;"Anna; ""Annie"" Schmidt";anna@example.com;'), zeilen[2]) + assert.ok(zeilen[2].endsWith(';4,90;16,90;16,90'), zeilen[2]) + assert.doesNotMatch(erg.csv, /\u00a0|€/) + for (const z of zeilen.slice(1)) assert.doesNotMatch(z, /(^|;)[=+@]/) + assert.ok(order(b)) +}) + +test('csv: Zeitraum von/bis (Berliner Kalendertage, inklusiv), Dateiname, ungültige Eingaben 400', async () => { + bestellung({ bezahltAt: '2026-10-04T21:59:00.000Z' }) // 04.10. 23:59 Berlin + const b = bestellung({ bezahltAt: '2026-10-04T22:00:00.000Z' }) // 05.10. 00:00 Berlin + const c = bestellung({ bezahltAt: '2026-10-05T21:59:00.000Z' }) // 05.10. 23:59 Berlin + bestellung({ bezahltAt: '2026-10-05T22:00:00.000Z' }) // 06.10. 00:00 Berlin + const erg = await B.csv(shopId, { von: '2026-10-05', bis: '2026-10-05' }) + assert.equal(erg.dateiname, 'webshop-bestellungen-2026-10-05-2026-10-05.csv') + const nummern = erg.csv.split('\r\n').slice(1, -1).map((z) => z.split(';')[0]) + assert.deepEqual(nummern, [order(b).bestellnummer, order(c).bestellnummer]) + assert.equal((await B.csv(shopId, { von: '2026-10-06' })).anzahl, 1) + for (const eingabe of [{ von: '05.10.2026' }, { bis: '2026-02-30' }, { von: '2026-10-06', bis: '2026-10-05' }, { von: 5 }]) { + await assert.rejects(B.csv(shopId, eingabe), (e) => e.status === 400, JSON.stringify(eingabe)) + } + assert.equal((await B.csv('f_anderer', {})).anzahl, 0) +}) + +// ------------------------------------------------------------------------------------------------ Routen + +const lesen = (pfad, projectId = PROJEKT, opts = { cookie }) => send('GET', `/api/shop/${pfad}${pfad.includes('?') ? '&' : '?'}projectId=${projectId}`, opts) +const schreiben = (pfad, body = {}, opts = { cookie }) => send('POST', `/api/shop/${pfad}`, { ...opts, body: { projectId: PROJEKT, ...body } }) + +test('Routen: Liste, Detail, Aktion, Erstatten - Antworten ohne Token und Stripe-Kennungen', async () => { + const id = bestellung() + bestellung({ status: 'versendet' }) + const liste = await lesen('bestellungen') + assert.equal(liste.status, 200) + assert.equal(liste.body.bestellungen.length, 2) + assert.deepEqual([liste.body.seite, liste.body.mehr], [1, false]) + assert.equal((await lesen('bestellungen?status=versendet')).body.bestellungen.length, 1) + assert.equal((await lesen('bestellungen?status=quatsch')).status, 400) + assert.equal((await lesen('bestellungen?suche=max')).body.bestellungen.length, 2) + + const detail = await lesen(`bestellungen/${id}`) + assert.equal(detail.status, 200) + assert.equal(detail.body.bestellung.kaeufer.name, 'Max Mustermann') + assert.ok(!JSON.stringify(detail.body).includes('tokenHash') && !JSON.stringify(detail.body).includes('pi_')) + + const versand = await schreiben(`bestellungen/${id}/aktion`, { aktion: 'versendet', versanddienst: 'DHL', sendungsnummer: 'ABC123' }) + assert.equal(versand.status, 200) + assert.equal(versand.body.bestellung.status, 'versendet') + assert.equal(versand.body.mailOk, true) + const nochmal = await schreiben(`bestellungen/${id}/aktion`, { aktion: 'versendet' }) + assert.deepEqual([nochmal.status, nochmal.body.code], [409, 'falscher_status']) + assert.equal((await schreiben(`bestellungen/${id}/aktion`, { aktion: 'quatsch' })).status, 400) + assert.equal((await schreiben(`bestellungen/${id}/aktion`, {})).status, 400) + + const teil = await schreiben(`bestellungen/${id}/erstatten`, { betragCent: 300, grund: 'Kulanz' }) + assert.equal(teil.status, 200) + assert.deepEqual([teil.body.voll, teil.body.bestellung.erstattetCent, teil.body.bestellung.status], [false, 300, 'versendet']) + assert.equal(teil.body.erstattung.quelle, 'portal') + const zuViel = await schreiben(`bestellungen/${id}/erstatten`, { betragCent: 99999 }) + assert.equal(zuViel.status, 400) + const rest = await schreiben(`bestellungen/${id}/erstatten`, {}) + assert.deepEqual([rest.status, rest.body.voll, rest.body.bestellung.status], [200, true, 'erstattet']) + + // Stripe-Fehler: 502, fester Text + const kaputt = bestellung() + st.setPaymentIntent(ACCOUNT, `pi_${kaputt}`, { amount: 100 }) + const fehler = await schreiben(`bestellungen/${kaputt}/erstatten`, { betragCent: 500 }) + assert.equal(fehler.status, 502) + assert.match(fehler.body.error, /^Die Erstattung hat bei Stripe nicht geklappt\./) +}) + +test('Routen: parallele Erstattungen über HTTP erstatten nie doppelt', async () => { + const id = bestellung() + const antworten = await Promise.all([1, 2, 3].map(() => schreiben(`bestellungen/${id}/erstatten`, {}))) + assert.deepEqual(antworten.map((a) => a.status).sort(), [200, 409, 409]) + assert.equal(refunds().length, 1) +}) + +test('Routen: fremde Bestellung 404, fremdes Projekt 403, ohne Session 401, nicht eingerichtet 409', async () => { + const fremd = bestellung({ shopId: 'f_anderer_shop' }) + assert.equal((await lesen(`bestellungen/${fremd}`)).status, 404) + assert.equal((await schreiben(`bestellungen/${fremd}/aktion`, { aktion: 'abgeschlossen' })).status, 404) + assert.equal((await schreiben(`bestellungen/${fremd}/erstatten`, {})).status, 404) + assert.equal((await lesen('bestellungen/nix')).status, 404) + assert.equal(order(fremd).status, 'bezahlt') + assert.equal(refunds().length, 0) + assert.equal((await lesen('bestellungen', 'pt3')).status, 403) + assert.equal((await send('GET', `/api/shop/bestellungen?projectId=${PROJEKT}`)).status, 401) + assert.equal((await send('POST', '/api/shop/bestellungen/x/aktion', { body: { projectId: PROJEKT } })).status, 401) + config.shop.mode = 'off' + assert.equal((await lesen('bestellungen')).body.code, 'nicht_verfuegbar') +}) + +test('Routen: Kundenansicht darf lesen (Liste, Detail, CSV, Widerrufe), aber weder Status ändern noch erstatten (403)', async () => { + const id = bestellung() + const KV = { cookie: kundenansichtCookie({ customerId: 'c1' }), headers: { 'X-Kundenansicht': 'a'.repeat(32) } } + assert.equal((await lesen('bestellungen', PROJEKT, KV)).status, 200) + assert.equal((await lesen(`bestellungen/${id}`, PROJEKT, KV)).status, 200) + assert.equal((await lesen('bestellungen.csv', PROJEKT, KV)).status, 200) + assert.equal((await lesen('widerrufe', PROJEKT, KV)).status, 200) + for (const [pfad, body] of [[`bestellungen/${id}/aktion`, { aktion: 'abgeschlossen' }], [`bestellungen/${id}/erstatten`, {}], ['widerrufe/w1/erledigt', {}]]) { + const res = await schreiben(pfad, body, KV) + assert.equal(res.status, 403, pfad) + } + // Der Router selbst sperrt ebenfalls (ohne den vorgeschalteten Guard), auch bei "Ansehen als" + const express = (await import('express')).default + const { sessionMiddleware } = await import('../server/middleware/session.js') + const roh = express() + roh.use(sessionMiddleware()) + roh.use(express.json()) + roh.use('/api/shop', shopRoutes) + const server = await new Promise((resolve) => { + const s = roh.listen(0, '127.0.0.1', () => resolve(s)) + }) + try { + const url = `http://127.0.0.1:${server.address().port}` + for (const c of [kundenansichtCookie({ customerId: 'c1' }), viewAsCookie('c1')]) { + for (const pfad of [`bestellungen/${id}/aktion`, `bestellungen/${id}/erstatten`, 'widerrufe/w1/erledigt']) { + const res = await fetch(`${url}/api/shop/${pfad}`, { method: 'POST', headers: { 'content-type': 'application/json', cookie: c }, body: JSON.stringify({ projectId: PROJEKT, aktion: 'abgeschlossen' }) }) + assert.equal(res.status, 403, pfad) + } + } + } finally { + await new Promise((resolve) => server.close(resolve)) + } + assert.equal(order(id).status, 'bezahlt') + assert.equal(refunds().length, 0) +}) + +test('Routen: CSV als Download mit BOM, Dateiname und Zeitraum; ungültiges Datum 400', async () => { + bestellung({ bezahltAt: '2026-10-05T10:00:00.000Z' }, { kaeufer: { name: '@SUM(A1)', email: 'a@example.com', telefon: '' } }) + bestellung({ bezahltAt: '2026-09-01T10:00:00.000Z' }) + const res = await fetch(`${app.url}/api/shop/bestellungen.csv?projectId=${PROJEKT}&von=2026-10-01&bis=2026-10-31`, { headers: { cookie } }) + assert.equal(res.status, 200) + assert.match(res.headers.get('content-type'), /^text\/csv; charset=utf-8/) + assert.equal(res.headers.get('content-disposition'), 'attachment; filename="webshop-bestellungen-2026-10-01-2026-10-31.csv"') + const bytes = Buffer.from(await res.arrayBuffer()) + assert.deepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf], 'BOM') + const text = bytes.toString('utf8') + assert.ok(text.includes('Bestellnummer;Datum;')) + assert.equal(text.trimEnd().split('\r\n').length, 2) + assert.ok(text.includes(";'@SUM(A1);")) + assert.equal((await lesen('bestellungen.csv?von=morgen')).status, 400) + assert.equal((await send('GET', `/api/shop/bestellungen.csv?projectId=${PROJEKT}`)).status, 401) +}) + +test('Routen: deaktivierter Shop - Lesen und CSV gehen, Schreiben 409', async () => { + const id = bestellung() + await updateDocument(config.collections.portalFeatures, feature.$id, sf.stateFields('disabled')) + assert.equal((await lesen('bestellungen')).status, 200) + assert.equal((await lesen(`bestellungen/${id}`)).status, 200) + assert.equal((await lesen('bestellungen.csv')).status, 200) + assert.equal((await lesen('widerrufe')).status, 200) + for (const [pfad, body] of [[`bestellungen/${id}/aktion`, { aktion: 'abgeschlossen' }], [`bestellungen/${id}/erstatten`, {}], ['widerrufe/w1/erledigt', {}]]) { + const res = await schreiben(pfad, body) + assert.deepEqual([res.status, res.body.code], [409, 'nicht_eingerichtet'], pfad) + } + assert.equal(order(id).status, 'bezahlt') + assert.equal(refunds().length, 0) +}) + +test('Routen: Widerrufe - Liste (neueste zuerst, nur eigener Shop) und als erledigt markieren', async () => { + aw.seed('shopWiderrufe', { $id: 'w_alt', shopId, customerId: KUNDE, bestellnummer: '26-A', name: 'Alt', email: 'alt@example.com', nachricht: '', eingangAt: nach(1).toISOString() }) + aw.seed('shopWiderrufe', { $id: 'w_neu', shopId, customerId: KUNDE, bestellnummer: '26-B', name: 'Neu', email: 'neu@example.com', nachricht: 'Hallo', eingangAt: nach(99).toISOString() }) + aw.seed('shopWiderrufe', { $id: 'w_fremd', shopId: 'f_anderer', customerId: 'c2', bestellnummer: '26-C', name: 'Fremd', email: 'f@example.com', nachricht: '', eingangAt: nach(200).toISOString() }) + const liste = await lesen('widerrufe') + assert.equal(liste.status, 200) + assert.deepEqual(liste.body.widerrufe.map((w) => w.id), ['w_neu', 'w_alt']) + const erledigt = await schreiben('widerrufe/w_neu/erledigt') + assert.equal(erledigt.status, 200) + assert.ok(erledigt.body.widerruf.bearbeitetAt) + assert.equal((await schreiben('widerrufe/w_fremd/erledigt')).status, 404) + assert.equal(aw.docs('shopWiderrufe').get('w_fremd').bearbeitetAt ?? null, null) +})