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:
1 parent
eb616ddbe8
commit
6d8487633b
3 files changed
+1159
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user