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 }
}