Shop E3: Händler-Aktionen (versendet, abholbereit, abgeschlossen, storniert), Erstattung unter Sperre, Bestellliste, Detail, CSV und Kundenrouten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:08:55 +00:00
1 parent eb616ddbe8
commit 6d8487633b
3 files changed
+1159 -1

No files matched your search

+70
View File
@@ -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
+494 -1
View File
@@ -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:<id>; 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:<id>: zwei gleichzeitige Erstattungen erstatten nie doppelt (die zweite sieht den neuen Rest). Idempotenz-Schluessel
* `shop-erstattung-<orderId>-<Anzahl bisheriger Erstattungen>`. 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 }
}
+595
View File
@@ -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)
})