G15 "Wir haben Ihre Bestellung erhalten" geht einmal raus, sobald Stripe die Sitzung abgeschlossen, die Zahlung aber noch nicht gemeldet hat (meldeEingang, Marker mailsEingangAt, aus dem Webhook und aus pruefeOffene). G09 sagt ausdruecklich, dass der Kaufvertrag mit der Mail geschlossen ist, trennt Bestell- und Zahlungsdatum und nennt die Telefonnummer des Anbieters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1253 lines
63 KiB
JavaScript
1253 lines
63 KiB
JavaScript
import { createHash, randomBytes, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
|
||
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 { ipSchluessel } from '../termine/limits.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'
|
||
import { holeProdukt, listeProdukte, parseProdukt } from './produkte.js'
|
||
|
||
/**
|
||
* Bestellungen des Webshops (Plan 2026-10-05-webshop.md, Task E1: Anlegen; E2/E3 ergaenzen Abschluss, Aktionen, Erstattung).
|
||
*
|
||
* Datenlage einer Bestellung (shopOrders): Spalten shopId, customerId, bestellnummer, status, tokenHash, stripeSessionId,
|
||
* paymentIntentId, summeCent, erstelltAt, ablaufAt, bezahltAt, kaeuferEmail, bestellung (JSON-Text, <= 60000), anonymisiertAt.
|
||
* bestellung (geparst: bestellDaten(doc), auch als doc.daten aus bestellungLesen):
|
||
* { positionen, summen: { zwischensummeCent, versandCent, gesamtCent, steuer, texte }, lieferart, land, anbieter, shopname,
|
||
* lieferzeit, zahlungsarten, abholung?, rechtstexteHash, verlauf: [{ at, was, ... }] }
|
||
* E2 ergaenzt im selben JSON: kaeufer { name, email, telefon }, lieferadresse / rechnungsadresse { name?, line1, line2, postal_code,
|
||
* city, country }, zahlungsart ('card' | 'klarna' | 'sepa_debit', nur wenn eindeutig), ueberverkauft, spaet; E3 erstattungen[]. Status offen reserviert Bestand bis ablaufAt.
|
||
* Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message.
|
||
*/
|
||
|
||
const NUMMER_ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||
const NUMMER_VERSUCHE = 5
|
||
/** Stripe verlangt mindestens 30 Minuten; die Reserve deckt Zeit zwischen Berechnung und Aufruf ab. */
|
||
const SESSION_RESERVE_S = 30
|
||
const MAX_JSON = 60000
|
||
/** Offene Bestellungen, die eine IP je Shop gleichzeitig haben darf (Fenster: Reservierungsdauer), und der Text dazu. */
|
||
const OFFEN_JE_IP = 3
|
||
const OFFEN_FENSTER_MS = 30 * 60 * 1000
|
||
const ZU_VIELE_OFFEN_TEXT = 'Sie haben bereits offene Bestellungen. Bitte schließen Sie diese zuerst ab oder warten Sie 30 Minuten.'
|
||
|
||
const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.'
|
||
const ZAHLUNG_TEXT = 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.'
|
||
const KLARNA_HINWEIS =
|
||
'Klarna/SEPA ist in deinem Stripe-Konto nicht aktiviert. Bestellungen wurden deshalb nur mit Karte angeboten. ' +
|
||
'Bitte aktiviere die Zahlungsarten in deinem Stripe-Dashboard unter Einstellungen > Zahlungsmethoden.'
|
||
|
||
const col = () => config.collections.shopOrders
|
||
|
||
/** Gesalzener Kurz-Hash der IP (16 Hex-Zeichen) fuer die Obergrenze offener Bestellungen; die Adresse selbst wird nie gespeichert. */
|
||
export function ipHashVon(ip) {
|
||
return createHash('sha256').update(`shop-ip:${config.sessionSecret}:${ipSchluessel(ip)}`).digest('hex').slice(0, 16)
|
||
}
|
||
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
||
|
||
function fehler(status, code, text, extra = {}) {
|
||
return Object.assign(new Error(text), { status, code, ...extra })
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// Hilfen
|
||
|
||
/** Gespeicherten JSON-Text einer Bestellung lesen; bei kaputten Daten ein leeres Objekt. */
|
||
export function bestellDaten(doc) {
|
||
try {
|
||
const d = JSON.parse(doc?.bestellung)
|
||
return istObjekt(d) ? d : {}
|
||
} catch {
|
||
return {}
|
||
}
|
||
}
|
||
|
||
/** Bestellung per ID: Dokument mit zusaetzlichem Feld daten (geparstes bestellung) oder null, wenn es sie nicht gibt. */
|
||
export async function bestellungLesen(orderId) {
|
||
if (typeof orderId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/.test(orderId)) return null
|
||
let doc
|
||
try {
|
||
doc = await getDocument(col(), orderId)
|
||
} catch (err) {
|
||
if (err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found') return null
|
||
throw err
|
||
}
|
||
return { ...doc, daten: bestellDaten(doc) }
|
||
}
|
||
|
||
export const tokenHash = (token) => createHash('sha256').update(String(token ?? '')).digest('hex')
|
||
|
||
/** Passt der Token aus der Rueckkehr-Adresse zur Bestellung? Vergleich in konstanter Zeit ueber den SHA-256-Hash. */
|
||
export function tokenPasst(order, token) {
|
||
if (typeof token !== 'string' || token === '' || typeof order?.tokenHash !== 'string' || order.tokenHash === '') return false
|
||
const a = Buffer.from(tokenHash(token), 'hex')
|
||
const b = Buffer.from(order.tokenHash, 'hex')
|
||
return a.length === b.length && timingSafeEqual(a, b)
|
||
}
|
||
|
||
const schluesselFuer = (pos) => (pos.varianteId ? `${pos.produktId}:${pos.varianteId}` : pos.produktId)
|
||
|
||
/**
|
||
* Menge je Artikel, die in noch laufenden offenen Bestellungen steckt (Status offen, ablaufAt in der Zukunft) oder in bezahlten
|
||
* Bestellungen, deren Bestandsabzug noch aussteht (bestandAbgezogenAt === null, Teilfehler beim Abschluss). So gibt es nie einen
|
||
* Moment, in dem eine Bestellung weder reserviert noch abgezogen ist.
|
||
* @returns {Promise<Map<string, number>>} Schluessel produktId bzw. `${produktId}:${varianteId}`
|
||
*/
|
||
export async function reserviert(shopId, now = new Date()) {
|
||
const nowMs = new Date(now).getTime()
|
||
const docs = await listAllDocuments(col(), [Query.equal('shopId', shopId), Query.equal('status', ['offen', 'bezahlt'])])
|
||
const karte = new Map()
|
||
for (const doc of docs) {
|
||
const daten = bestellDaten(doc)
|
||
if (doc.status === 'offen') {
|
||
const ablauf = new Date(doc.ablaufAt).getTime()
|
||
if (!(ablauf > nowMs)) continue
|
||
} else if (daten.bestandAbgezogenAt !== null) continue
|
||
const positionen = daten.positionen
|
||
if (!Array.isArray(positionen)) continue
|
||
for (const pos of positionen) {
|
||
if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue
|
||
const key = schluesselFuer(pos)
|
||
karte.set(key, (karte.get(key) ?? 0) + pos.menge)
|
||
}
|
||
}
|
||
return karte
|
||
}
|
||
|
||
const berlinJahr = (now) =>
|
||
new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', year: 'numeric' }).format(now).slice(-2)
|
||
|
||
/** Bestellnummer `<JJ>-<6 Zeichen>` (JJ = Jahr in Berlin; Zeichen ohne 0, 1, O, I). Eindeutigkeit prueft der Aufrufer. */
|
||
export function neueBestellnummer(now = new Date()) {
|
||
let zeichen = ''
|
||
for (let i = 0; i < 6; i += 1) zeichen += NUMMER_ZEICHEN[randomInt(NUMMER_ZEICHEN.length)]
|
||
return `${berlinJahr(now)}-${zeichen}`
|
||
}
|
||
|
||
async function freieBestellnummer(shopId, now) {
|
||
for (let i = 0; i < NUMMER_VERSUCHE; i += 1) {
|
||
const nummer = neueBestellnummer(now)
|
||
const treffer = await listDocuments(col(), [Query.equal('shopId', shopId), Query.equal('bestellnummer', nummer), Query.limit(1)])
|
||
if (treffer.length === 0) return nummer
|
||
}
|
||
throw fehler(503, 'zahlung_nicht_moeglich', ZAHLUNG_TEXT)
|
||
}
|
||
|
||
/**
|
||
* Rueck-URL fuer success_url/cancel_url: https (http nur, wenn die Portal-Adresse selbst http ist, also in Tests), Host aus
|
||
* analyseDomains(project) oder Host der Portal-Adresse, ohne Zugangsdaten. Entfernt wk_*-Parameter und Fragment. Sonst
|
||
* `<Portal>/shop/<siteKey>`.
|
||
*/
|
||
export function pruefeRueckUrl(rueckUrl, ctx) {
|
||
const basis = String(config.publicBaseUrl).replace(/\/$/, '')
|
||
const ausweich = `${basis}/shop/${ctx?.shop?.siteKey ?? ''}`
|
||
if (typeof rueckUrl !== 'string' || rueckUrl === '' || rueckUrl.length > 2000) return ausweich
|
||
let url
|
||
let portal = null
|
||
try {
|
||
url = new URL(rueckUrl)
|
||
} catch {
|
||
return ausweich
|
||
}
|
||
try {
|
||
portal = new URL(basis)
|
||
} catch {
|
||
portal = null
|
||
}
|
||
if (url.username || url.password) return ausweich
|
||
const portalHost = portal ? portal.host.toLowerCase() : ''
|
||
const istPortal = portal !== null && url.host.toLowerCase() === portalHost && url.protocol === portal.protocol
|
||
const betrieb = ctx?.project ? analyseDomains(ctx.project) : []
|
||
const istBetrieb = url.protocol === 'https:' && url.port === '' && betrieb.includes(url.hostname.toLowerCase())
|
||
if (!istPortal && !istBetrieb) return ausweich
|
||
if (url.protocol !== 'https:' && !(portal?.protocol === 'http:' && istPortal)) return ausweich
|
||
for (const key of [...url.searchParams.keys()]) if (key.startsWith('wk_')) url.searchParams.delete(key)
|
||
url.hash = ''
|
||
return url.toString()
|
||
}
|
||
|
||
const mitParametern = (basis, params) => {
|
||
const url = new URL(basis)
|
||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||
return url.toString()
|
||
}
|
||
|
||
/** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON. */
|
||
function produktKarte(docs) {
|
||
const karte = new Map()
|
||
for (const doc of docs) {
|
||
let p
|
||
try {
|
||
p = JSON.parse(doc.produkt)
|
||
} catch {
|
||
continue
|
||
}
|
||
if (!istObjekt(p)) continue
|
||
karte.set(doc.$id, { ...p, id: doc.$id, aktiv: doc.aktiv !== false && p.aktiv !== false })
|
||
}
|
||
return karte
|
||
}
|
||
|
||
function stripeProduktName(pos, produkte) {
|
||
if (!pos.varianteId) return pos.name
|
||
const wert = produkte.get(pos.produktId)?.varianten?.werte?.find((w) => w.id === pos.varianteId)
|
||
return `${pos.name} – ${wert?.name ?? pos.varianteText}`
|
||
}
|
||
|
||
function versandName(lieferart, land) {
|
||
if (lieferart === 'abholung') return 'Abholung'
|
||
return land === 'AT' ? 'Versand nach Österreich' : 'Versand nach Deutschland'
|
||
}
|
||
|
||
/** Absolute https-Bild-URL des ersten Bildes, sonst undefined (Stripe holt Bilder selbst; http oder lokal geht nicht). */
|
||
function bildUrl(produkt, siteKey) {
|
||
const id = Array.isArray(produkt?.bilder) ? produkt.bilder[0] : undefined
|
||
if (typeof id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(id)) return undefined
|
||
const basis = String(config.publicBaseUrl).replace(/\/$/, '')
|
||
if (!basis.startsWith('https://')) return undefined
|
||
return `${basis}/api/public/shop/v1/${encodeURIComponent(siteKey)}/bild/${id}-l.webp`
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// Bestellung anlegen
|
||
|
||
/** Stripe lehnt Klarna/SEPA ab, weil das Konto sie nicht aktiviert hat. */
|
||
function zahlartAbgelehnt(err) {
|
||
if (err?.type !== 'StripeInvalidRequestError') return false
|
||
return /payment_method_types|klarna|sepa/i.test(`${err.param ?? ''} ${err.message ?? ''}`)
|
||
}
|
||
|
||
async function merkeHinweis(ctx) {
|
||
const shopId = ctx.shop.id
|
||
await withLocks([`shop-cfg:${shopId}`], async () => {
|
||
const doc = await getDocument(config.collections.shopConfig, shopId)
|
||
let status = {}
|
||
try {
|
||
const s = JSON.parse(doc.stripeStatus)
|
||
if (istObjekt(s)) status = s
|
||
} catch {
|
||
status = {}
|
||
}
|
||
await updateDocument(config.collections.shopConfig, shopId, { stripeStatus: JSON.stringify({ ...status, hinweis: KLARNA_HINWEIS }) })
|
||
})
|
||
leereCache(ctx.shop.siteKey)
|
||
}
|
||
|
||
async function storniere(orderId, daten, was, now) {
|
||
const verlauf = [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), { at: new Date(now).toISOString(), was }]
|
||
await updateDocument(col(), orderId, { status: 'storniert', bestellung: JSON.stringify({ ...daten, verlauf }) })
|
||
}
|
||
|
||
/**
|
||
* Legt eine offene Bestellung an und startet die Stripe-Kasse auf dem Konto des Haendlers.
|
||
* Preise, Bestand und Versand kommen immer aus der Datenbank; der Browser liefert nur Artikel, Mengen, Lieferart und Land.
|
||
* @param {object} ctx Kontext aus shopPerSiteKey
|
||
* @param {{ positionen: Array, lieferart: string, land?: string, rueckUrl?: string }} eingabe
|
||
* @param {{ now?: Date, ipHash?: string }} [opt] ipHash (ipHashVon): hoechstens 3 offene Bestellungen je IP in 30 Minuten (429 zu_viele_offen),
|
||
* der Hash steht in der Bestellung (wird vom Tagesjob mit anonymisiert)
|
||
* @returns {Promise<{ url: string, orderId: string, bestellnummer: string }>}
|
||
*/
|
||
export async function legeBestellungAn(ctx, eingabe, { now = new Date(), ipHash } = {}) {
|
||
const { shop } = ctx
|
||
const e = shop.einstellungen
|
||
const geschlossen = () => fehler(409, 'geschlossen', GESCHLOSSEN_TEXT)
|
||
if (!shop.stripeAccountId) throw geschlossen()
|
||
const jetzt = new Date(now)
|
||
const start = performance.now()
|
||
const { positionen, lieferart, land } = istObjekt(eingabe) ? eingabe : {}
|
||
|
||
// 1. Pruefen, rechnen und reservieren: alles unter einer Sperre je Shop, damit der letzte Artikel nur einmal weggeht
|
||
const angelegt = await withLocks([`shop-bestand:${shop.id}`], async () => {
|
||
const docs = await listeProdukte(shop.id)
|
||
if (!(await istGeoeffnet(ctx, { produkte: docs }))) throw geschlossen()
|
||
if (typeof ipHash === 'string' && ipHash !== '') {
|
||
const seit = jetzt.getTime() - OFFEN_FENSTER_MS
|
||
const offene = await listAllDocuments(col(), [Query.equal('shopId', shop.id), Query.equal('status', 'offen')])
|
||
const eigene = offene.filter((d) => new Date(d.erstelltAt).getTime() > seit && bestellDaten(d).ipHash === ipHash).length
|
||
if (eigene >= OFFEN_JE_IP) throw fehler(429, 'zu_viele_offen', ZU_VIELE_OFFEN_TEXT, { retryAfter: OFFEN_FENSTER_MS / 1000 })
|
||
}
|
||
const produkte = produktKarte(docs)
|
||
const ergebnis = berechne({
|
||
produkte,
|
||
positionen,
|
||
lieferart,
|
||
land,
|
||
einstellungen: e,
|
||
reserviert: await reserviert(shop.id, jetzt),
|
||
})
|
||
if (!ergebnis.ok) {
|
||
const { code, text, produktId } = ergebnis.fehler
|
||
const konflikt = code === 'ausverkauft' || code === 'nicht_verfuegbar' || code === 'nur_abholung'
|
||
throw fehler(konflikt ? 409 : 400, code, text, produktId ? { produktId } : {})
|
||
}
|
||
|
||
const orderId = randomUUID()
|
||
const token = randomBytes(32).toString('base64url')
|
||
const bestellnummer = await freieBestellnummer(shop.id, jetzt)
|
||
const sessionEnde = Math.ceil(jetzt.getTime() / 1000) + config.shop.bestellAblaufMinuten * 60 + SESSION_RESERVE_S
|
||
const daten = {
|
||
positionen: ergebnis.positionen,
|
||
summen: {
|
||
zwischensummeCent: ergebnis.zwischensummeCent,
|
||
versandCent: ergebnis.versandCent,
|
||
gesamtCent: ergebnis.gesamtCent,
|
||
steuer: ergebnis.steuer,
|
||
texte: ergebnis.texte,
|
||
},
|
||
lieferart: ergebnis.lieferart,
|
||
land: ergebnis.land,
|
||
anbieter: e.anbieter,
|
||
shopname: e.shopname,
|
||
lieferzeit: e.lieferzeit,
|
||
zahlungsarten: e.zahlungsarten,
|
||
...(ergebnis.lieferart === 'abholung' ? { abholung: { adresse: e.abholung.adresse, hinweis: e.abholung.hinweis } } : {}),
|
||
rechtstexteHash: shop.rechtstexte.bestaetigt.hash,
|
||
...(typeof ipHash === 'string' && ipHash !== '' ? { ipHash } : {}),
|
||
verlauf: [{ at: jetzt.toISOString(), was: 'angelegt' }],
|
||
}
|
||
const json = JSON.stringify(daten)
|
||
if (json.length > MAX_JSON) throw fehler(400, 'zu_viele', 'Ihr Warenkorb enthält zu viele verschiedene Artikel.')
|
||
await createDocument(
|
||
col(),
|
||
{
|
||
shopId: shop.id,
|
||
customerId: shop.customerId,
|
||
bestellnummer,
|
||
status: 'offen',
|
||
tokenHash: tokenHash(token),
|
||
summeCent: ergebnis.gesamtCent,
|
||
erstelltAt: jetzt.toISOString(),
|
||
ablaufAt: new Date(sessionEnde * 1000).toISOString(),
|
||
bestellung: json,
|
||
},
|
||
orderId
|
||
)
|
||
return { orderId, token, bestellnummer, daten, ergebnis, produkte, sessionEnde }
|
||
})
|
||
|
||
// 2. Kasse bei Stripe starten (ausserhalb der Sperre: die offene Bestellung haelt den Bestand schon)
|
||
const { orderId, token, bestellnummer, daten, ergebnis, produkte } = angelegt
|
||
// Ende der Sitzung direkt vor dem Aufruf nachfuehren: die Zeit seit Beginn (Warten auf Sperren, Lesen) ganzer Sekunden schiebt es
|
||
// weiter, damit Stripes Minimum von 30 Minuten sicher bleibt (30 s Reserve decken den Rest); ablaufAt folgt demselben Wert
|
||
const sessionEnde = angelegt.sessionEnde + Math.floor((performance.now() - start) / 1000)
|
||
if (sessionEnde !== angelegt.sessionEnde) {
|
||
try {
|
||
await updateDocument(col(), orderId, { ablaufAt: new Date(sessionEnde * 1000).toISOString() })
|
||
} catch {
|
||
await storniere(orderId, daten, 'stripe_fehler', jetzt).catch(() => {})
|
||
throw fehler(503, 'zahlung_nicht_moeglich', ZAHLUNG_TEXT)
|
||
}
|
||
}
|
||
const rueck = pruefeRueckUrl(eingabe?.rueckUrl, ctx)
|
||
const metadata = { wkShop: '1', shopId: shop.id, orderId, bestellnummer }
|
||
const parameter = (zahlungsarten) => ({
|
||
mode: 'payment',
|
||
line_items: ergebnis.positionen.map((pos) => {
|
||
const bild = bildUrl(produkte.get(pos.produktId), shop.siteKey)
|
||
return {
|
||
quantity: pos.menge,
|
||
price_data: {
|
||
currency: 'eur',
|
||
unit_amount: pos.einzelCent,
|
||
product_data: { name: stripeProduktName(pos, produkte), ...(bild ? { images: [bild] } : {}) },
|
||
},
|
||
}
|
||
}),
|
||
shipping_options: [
|
||
{
|
||
shipping_rate_data: {
|
||
type: 'fixed_amount',
|
||
fixed_amount: { amount: ergebnis.versandCent, currency: 'eur' },
|
||
display_name: versandName(ergebnis.lieferart, ergebnis.land),
|
||
},
|
||
},
|
||
],
|
||
...(ergebnis.lieferart === 'versand' ? { shipping_address_collection: { allowed_countries: [ergebnis.land] } } : {}),
|
||
billing_address_collection: 'required',
|
||
phone_number_collection: { enabled: true },
|
||
payment_method_types: zahlungsarten,
|
||
locale: 'de',
|
||
expires_at: sessionEnde,
|
||
client_reference_id: orderId,
|
||
metadata,
|
||
payment_intent_data: { metadata, description: `Bestellung ${bestellnummer} – ${e.shopname}` },
|
||
success_url: mitParametern(rueck, { wk_bestellung: orderId, wk_t: token }),
|
||
cancel_url: mitParametern(rueck, { wk_abbruch: '1' }),
|
||
})
|
||
|
||
let session
|
||
try {
|
||
try {
|
||
session = await checkoutAnlegen(shop.stripeAccountId, parameter(e.zahlungsarten), { idempotencyKey: `shop-order-${orderId}` })
|
||
} catch (err) {
|
||
if (!zahlartAbgelehnt(err) || e.zahlungsarten.every((z) => z === 'card')) throw err
|
||
session = await checkoutAnlegen(shop.stripeAccountId, parameter(['card']), { idempotencyKey: `shop-order-${orderId}-card` })
|
||
await merkeHinweis(ctx).catch(() => {})
|
||
}
|
||
if (typeof session?.id !== 'string' || typeof session?.url !== 'string' || session.url === '') throw new Error('Stripe lieferte keine Kasse')
|
||
} catch {
|
||
await storniere(orderId, daten, 'stripe_fehler', jetzt).catch(() => {})
|
||
throw fehler(503, 'zahlung_nicht_moeglich', ZAHLUNG_TEXT)
|
||
}
|
||
await updateDocument(col(), orderId, { stripeSessionId: session.id })
|
||
return { url: session.url, orderId, bestellnummer }
|
||
}
|
||
|
||
|
||
// ---------------------------------------------------------------------------------------------------------------------
|
||
// Abschluss (E2): bezahlt, verwerfen, Ablauf
|
||
|
||
const ZAHLUNGSARTEN = new Set(['card', 'klarna', 'sepa_debit'])
|
||
const ABGLEICH_PUFFER_MS = 5 * 60 * 1000
|
||
const kurz = (wert, max) => (typeof wert === 'string' ? wert.trim().slice(0, max) : '')
|
||
const stripeId = (wert) => (typeof wert === 'string' ? wert : typeof wert?.id === 'string' ? wert.id : '')
|
||
const verlaufMit = (daten, eintrag) => [...(Array.isArray(daten.verlauf) ? daten.verlauf : []), eintrag]
|
||
|
||
/** Stripe-Anschrift {line1, line2, postal_code, city, country} (+ name) als schlankes Objekt oder null. */
|
||
function anschrift(adresse, name) {
|
||
if (!istObjekt(adresse)) return null
|
||
const a = {
|
||
...(kurz(name, 200) ? { name: kurz(name, 200) } : {}),
|
||
line1: kurz(adresse.line1, 200),
|
||
line2: kurz(adresse.line2, 200),
|
||
postal_code: kurz(adresse.postal_code, 20),
|
||
city: kurz(adresse.city, 100),
|
||
country: kurz(adresse.country, 2).toUpperCase(),
|
||
}
|
||
return a.line1 || a.postal_code || a.city ? a : null
|
||
}
|
||
|
||
/** Zahlungsart nur, wenn sie eindeutig ist (eine erlaubte Art oder die bezahlte Art des erweiterten PaymentIntents). */
|
||
function zahlungsartVon(session) {
|
||
const arten = Array.isArray(session.payment_method_types) ? session.payment_method_types.filter((a) => ZAHLUNGSARTEN.has(a)) : []
|
||
if (arten.length === 1 && session.payment_method_types.length === 1) return arten[0]
|
||
const bezahlt = session.payment_intent?.payment_method?.type
|
||
return ZAHLUNGSARTEN.has(bezahlt) ? bezahlt : ''
|
||
}
|
||
|
||
async function shopDokument(shopId) {
|
||
try {
|
||
return await getDocument(config.collections.shopConfig, shopId)
|
||
} catch (err) {
|
||
if (err?.status === 404) return null
|
||
throw err
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Zieht den Bestand der Positionen ab (Artikel mit bestand null bleiben unberuehrt). Der AUFRUFER haelt die Sperren
|
||
* `shop-bestand:<shopId>` und danach `shop-produkte:<shopId>` (Reihenfolge immer so; withLocks ist nicht reentrant).
|
||
* @returns {Promise<boolean>} true, wenn der Bestand eines Artikels nicht reichte (auf 0 gesetzt)
|
||
*/
|
||
async function zieheBestandAb(shopId, positionen) {
|
||
let ueberverkauft = false
|
||
const nachArtikel = new Map()
|
||
for (const pos of Array.isArray(positionen) ? positionen : []) {
|
||
if (typeof pos?.produktId !== 'string' || !Number.isInteger(pos.menge) || pos.menge < 1) continue
|
||
nachArtikel.set(pos.produktId, [...(nachArtikel.get(pos.produktId) ?? []), pos])
|
||
}
|
||
for (const [produktId, liste] of nachArtikel) {
|
||
const doc = await holeProdukt(shopId, produktId)
|
||
const produkt = doc ? parseProdukt(doc) : null
|
||
if (!produkt) continue
|
||
let geaendert = false
|
||
const abziehen = (objekt, menge) => {
|
||
if (!Number.isInteger(objekt.bestand)) return
|
||
if (objekt.bestand < menge) ueberverkauft = true
|
||
objekt.bestand = Math.max(0, objekt.bestand - menge)
|
||
geaendert = true
|
||
}
|
||
for (const pos of liste) {
|
||
const wert = pos.varianteId ? produkt.varianten?.werte?.find((w) => w.id === pos.varianteId) : null
|
||
if (pos.varianteId && !wert) continue
|
||
abziehen(wert ?? produkt, pos.menge)
|
||
}
|
||
if (geaendert) await updateDocument(config.collections.shopProducts, produktId, { produkt: JSON.stringify(produkt) })
|
||
}
|
||
return ueberverkauft
|
||
}
|
||
|
||
const MAIL_VERSUCHE_MAX = 5
|
||
const bestandSperren = (shopId) => [`shop-bestand:${shopId}`, `shop-produkte:${shopId}`]
|
||
|
||
/** Welche Schritte nach dem Statuswechsel noch fehlen (Marker im Bestell-JSON stehen dann explizit auf null). */
|
||
function offeneSchritte(daten) {
|
||
const versuche = istObjekt(daten.mailsVersuche) ? daten.mailsVersuche : {}
|
||
return {
|
||
bestand: daten.bestandAbgezogenAt === null,
|
||
kaeufer: daten.mailsKaeuferAt === null && (versuche.kaeufer ?? 0) < MAIL_VERSUCHE_MAX,
|
||
haendler: daten.mailsHaendlerAt === null && (versuche.haendler ?? 0) < MAIL_VERSUCHE_MAX,
|
||
}
|
||
}
|
||
|
||
/** Fehlt bei einer bezahlten Bestellung noch der Bestandsabzug oder eine Mail? (Webhook-Wiederholung, Status, Minutenjob) */
|
||
export function abschlussAusstehend(order) {
|
||
if (!STATUS_BEZAHLT.includes(order?.status)) return false
|
||
const daten = order.daten ?? bestellDaten(order)
|
||
const o = offeneSchritte(daten)
|
||
return o.bestand || o.kaeufer || o.haendler
|
||
}
|
||
|
||
/** Bestandsabzug mit Marker; Sperren muessen gehalten werden. Gibt die neue Bestellung (mit daten) zurueck. */
|
||
async function bestandSchritt(order, jetzt) {
|
||
const ueberverkauft = await zieheBestandAb(order.shopId, order.daten.positionen)
|
||
const daten = { ...order.daten, bestandAbgezogenAt: jetzt.toISOString(), ...(ueberverkauft ? { ueberverkauft: true } : {}) }
|
||
const doc = await updateDocument(col(), order.$id, { bestellung: JSON.stringify(daten) })
|
||
return { ...doc, daten: bestellDaten(doc) }
|
||
}
|
||
|
||
/** Fehlende Mails (G09, K25) einzeln verschicken; Marker erst nach Erfolg, Versuche gezaehlt. Unter der Bestellsperre. */
|
||
async function mailSchritte(order, siteKey, jetzt) {
|
||
let aktuell = order
|
||
const merke = async (aenderung) => {
|
||
const doc = await updateDocument(col(), aktuell.$id, { bestellung: JSON.stringify({ ...aktuell.daten, ...aenderung }) })
|
||
aktuell = { ...doc, daten: bestellDaten(doc) }
|
||
}
|
||
const zaehle = (art) => ({ mailsVersuche: { ...(istObjekt(aktuell.daten.mailsVersuche) ? aktuell.daten.mailsVersuche : {}), [art]: (aktuell.daten.mailsVersuche?.[art] ?? 0) + 1 } })
|
||
try {
|
||
leereCache(siteKey)
|
||
const ctx = await shopPerSiteKey(siteKey, { now: jetzt })
|
||
if (!ctx) throw new Error('Shop-Kontext nicht gefunden')
|
||
if (offeneSchritte(aktuell.daten).kaeufer) {
|
||
const r = await anKaeufer('G09', ctx, aktuell, {}, jetzt)
|
||
await merke(r.ok ? { mailsKaeuferAt: jetzt.toISOString() } : zaehle('kaeufer'))
|
||
}
|
||
if (offeneSchritte(aktuell.daten).haendler) {
|
||
const k25 = haendlerK25(ctx, aktuell, { ueberverkauft: aktuell.daten.ueberverkauft === true })
|
||
const r = await anHaendler('K25', ctx, k25.values, k25.blocks)
|
||
await merke(r.ok ? { mailsHaendlerAt: jetzt.toISOString() } : zaehle('haendler'))
|
||
}
|
||
} catch (err) {
|
||
console.warn(`[shop] Mails zur bezahlten Bestellung ${String(order.$id).slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||
}
|
||
return aktuell
|
||
}
|
||
|
||
/**
|
||
* Schliesst eine bezahlte Checkout-Sitzung ab: einzige Stelle fuer den Status bezahlt. Idempotent (zweiter Aufruf aendert nichts
|
||
* und schickt keine Mail), nimmt auch spaete Zahlungen einer abgelaufenen Bestellung an. Mailfehler machen die Zahlung nie rueckgaengig.
|
||
* @param {{ accountId: string, session: object, now?: Date }} p session = Stripe-Checkout-Session (Webhook oder retrieve)
|
||
* @returns {Promise<{ order: object|null, neu: boolean }>} order mit daten; null, wenn es keine passende Bestellung gibt
|
||
*/
|
||
export async function schliesseAb({ accountId, session, now = new Date() }) {
|
||
const orderId = session?.metadata?.orderId
|
||
if (!istObjekt(session) || session.metadata?.wkShop !== '1' || typeof orderId !== 'string' || !orderId) return { order: null, neu: false }
|
||
const jetzt = new Date(now)
|
||
|
||
return withLocks([`shop-order:${orderId}`], async () => {
|
||
let order = await bestellungLesen(orderId)
|
||
if (!order) return { order: null, neu: false }
|
||
const shop = await shopDokument(order.shopId)
|
||
if (!shop || typeof accountId !== 'string' || shop.stripeAccountId !== accountId) {
|
||
console.warn(`[shop] Bestellung ${orderId.slice(0, 36)} gehört nicht zum Stripe-Konto der Sitzung, ignoriert`)
|
||
return { order: null, neu: false }
|
||
}
|
||
const daten = order.daten
|
||
const spaet = order.status === 'storniert' && Array.isArray(daten.verlauf) && daten.verlauf.some((v) => v?.was === 'abgelaufen')
|
||
let neu = false
|
||
|
||
if (order.status === 'offen' || spaet) {
|
||
if (session.payment_status !== 'paid') return { order, neu: false }
|
||
const kunde = istObjekt(session.customer_details) ? session.customer_details : {}
|
||
const versandDetails = session.collected_information?.shipping_details ?? session.shipping_details
|
||
const lieferadresse = order.daten.lieferart === 'versand' ? anschrift(versandDetails?.address, versandDetails?.name) : null
|
||
const rechnungsadresse = anschrift(kunde.address, kunde.name)
|
||
const zahlungsart = zahlungsartVon(session)
|
||
const email = kurz(kunde.email, 320)
|
||
const abweichend = !Number.isInteger(session.amount_total) || session.amount_total !== order.summeCent
|
||
if (abweichend) console.warn(`[shop] Bestellung ${orderId.slice(0, 36)}: gezahlter Betrag weicht vom Bestellwert ab (Stripe ${session.amount_total}, Bestellung ${order.summeCent})`)
|
||
|
||
const at = jetzt.toISOString()
|
||
let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) })
|
||
if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null })
|
||
const neueDaten = {
|
||
...daten,
|
||
kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) },
|
||
...(lieferadresse ? { lieferadresse } : {}),
|
||
...(rechnungsadresse ? { rechnungsadresse } : {}),
|
||
...(zahlungsart ? { zahlungsart } : {}),
|
||
...(spaet ? { spaet: true } : {}),
|
||
// Marker: null = Schritt steht noch aus, wird erst nach Erfolg mit der Zeit gesetzt (siehe offeneSchritte)
|
||
bestandAbgezogenAt: null,
|
||
mailsKaeuferAt: null,
|
||
mailsHaendlerAt: null,
|
||
verlauf,
|
||
}
|
||
const felder = {
|
||
status: 'bezahlt',
|
||
bezahltAt: at,
|
||
bestellung: JSON.stringify(neueDaten),
|
||
...(stripeId(session.payment_intent) ? { paymentIntentId: stripeId(session.payment_intent) } : {}),
|
||
...(email ? { kaeuferEmail: email } : {}),
|
||
}
|
||
if (felder.bestellung.length > MAX_JSON) felder.bestellung = JSON.stringify({ ...neueDaten, rechnungsadresse: undefined })
|
||
// Statuswechsel und Bestandsabzug unter derselben Sperre: legeBestellungAn sieht nie "weder reserviert noch abgezogen"
|
||
order = await withLocks(bestandSperren(order.shopId), async () => {
|
||
const doc = await updateDocument(col(), orderId, felder)
|
||
return bestandSchritt({ ...doc, daten: bestellDaten(doc) }, jetzt)
|
||
})
|
||
neu = true
|
||
} else if (STATUS_BEZAHLT.includes(order.status) && offeneSchritte(daten).bestand) {
|
||
// Wiederholung nach Teilfehler: Bestandsabzug nachholen (genau einmal, Marker)
|
||
order = await withLocks(bestandSperren(order.shopId), async () => {
|
||
const frisch = await bestellungLesen(orderId)
|
||
return frisch && offeneSchritte(frisch.daten).bestand ? bestandSchritt(frisch, jetzt) : (frisch ?? order)
|
||
})
|
||
} else if (!STATUS_BEZAHLT.includes(order.status)) return { order, neu: false }
|
||
|
||
if (offeneSchritte(order.daten).kaeufer || offeneSchritte(order.daten).haendler) order = await mailSchritte(order, shop.siteKey, jetzt)
|
||
return { order, neu }
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Eingang der Bestellung (G15): Stripe meldet die Sitzung abgeschlossen, die Zahlung steht aber noch aus (SEPA-Lastschrift,
|
||
* payment_status unpaid / no_payment_required). Die Bestellung bleibt offen, der Kaeufer bekommt einmal eine Eingangsmail ohne
|
||
* Vertragsschluss (der Vertrag kommt mit G09 nach Zahlungseingang zustande). Marker `mailsEingangAt` im Bestell-JSON, Versuche in
|
||
* mailsVersuche.eingang (hoechstens MAIL_VERSUCHE_MAX). Aufrufer: Webhook (checkout.session.completed) und pruefeOffene.
|
||
* Nie ein Wurf wegen der Mail; falsches Konto, fremde Sitzung oder eine nicht mehr offene Bestellung bleiben wirkungslos.
|
||
* @param {{ accountId: string, session: object, now?: Date }} p
|
||
* @returns {Promise<{ gesendet: boolean }>}
|
||
*/
|
||
export async function meldeEingang({ accountId, session, now = new Date() }) {
|
||
const orderId = session?.metadata?.orderId
|
||
if (!istObjekt(session) || session.metadata?.wkShop !== '1' || typeof orderId !== 'string' || !orderId) return { gesendet: false }
|
||
if (session.payment_status !== 'unpaid' && session.payment_status !== 'no_payment_required') return { gesendet: false }
|
||
const an = kurz(session.customer_details?.email, 320)
|
||
if (!an) return { gesendet: false }
|
||
const jetzt = new Date(now)
|
||
|
||
return withLocks([`shop-order:${orderId}`], async () => {
|
||
const order = await bestellungLesen(orderId)
|
||
if (!order || order.status !== 'offen') return { gesendet: false }
|
||
const shop = await shopDokument(order.shopId)
|
||
if (!shop || typeof accountId !== 'string' || shop.stripeAccountId !== accountId) return { gesendet: false }
|
||
const daten = order.daten
|
||
const versuche = istObjekt(daten.mailsVersuche) ? daten.mailsVersuche : {}
|
||
if (daten.mailsEingangAt || (versuche.eingang ?? 0) >= MAIL_VERSUCHE_MAX) return { gesendet: false }
|
||
let ok = false
|
||
try {
|
||
leereCache(shop.siteKey)
|
||
const ctx = await shopPerSiteKey(shop.siteKey, { now: jetzt })
|
||
if (!ctx) throw new Error('Shop-Kontext nicht gefunden')
|
||
ok = (await anKaeufer('G15', ctx, order, { an }, jetzt)).ok
|
||
} catch (err) {
|
||
console.warn(`[shop] G15 zur Bestellung ${orderId.slice(0, 36)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||
}
|
||
const neu = ok ? { ...daten, mailsEingangAt: jetzt.toISOString() } : { ...daten, mailsVersuche: { ...versuche, eingang: (versuche.eingang ?? 0) + 1 } }
|
||
await updateDocument(col(), orderId, { bestellung: JSON.stringify(neu) })
|
||
return { gesendet: ok }
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Verwirft eine offene Bestellung (Status storniert, Bestand wird frei). Nur aus offen; sonst keine Wirkung.
|
||
* @param {string} orderId
|
||
* @param {'abgelaufen'|'zahlung_fehlgeschlagen'|string} grund Eintrag im Verlauf
|
||
* @param {{ now?: Date, mail?: boolean, email?: string, accountId?: string }} [opt] mail: G14 an den Kaeufer (email oder kaeuferEmail);
|
||
* accountId: Konto der Sitzung, muss zum Shop der Bestellung passen (Webhook)
|
||
* @returns {Promise<{ order: object|null, verworfen: boolean }>}
|
||
*/
|
||
export async function verwirf(orderId, grund, { now = new Date(), mail = false, email, accountId } = {}) {
|
||
const jetzt = new Date(now)
|
||
const ergebnis = await withLocks([`shop-order:${orderId}`], async () => {
|
||
const order = await bestellungLesen(orderId)
|
||
if (!order) return { order: null, verworfen: false }
|
||
if (accountId !== undefined) {
|
||
const shop = await shopDokument(order.shopId)
|
||
if (!shop || shop.stripeAccountId !== accountId) return { order: null, verworfen: false }
|
||
}
|
||
if (order.status !== 'offen') return { order, verworfen: false }
|
||
const doc = await updateDocument(col(), orderId, {
|
||
status: 'storniert',
|
||
bestellung: JSON.stringify({ ...order.daten, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }),
|
||
})
|
||
return { order: { ...doc, daten: bestellDaten(doc) }, verworfen: true }
|
||
})
|
||
if (ergebnis.verworfen && mail) {
|
||
const an = kurz(email, 320) || kurz(ergebnis.order.kaeuferEmail, 320)
|
||
if (an) {
|
||
try {
|
||
const shop = await shopDokument(ergebnis.order.shopId)
|
||
if (shop) {
|
||
leereCache(shop.siteKey)
|
||
const ctx = await shopPerSiteKey(shop.siteKey, { now: jetzt })
|
||
if (ctx) await anKaeufer('G14', ctx, ergebnis.order, { an }, jetzt)
|
||
}
|
||
} catch (err) {
|
||
console.warn(`[shop] G14 zur Bestellung ${String(orderId).slice(0, 36)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||
}
|
||
}
|
||
}
|
||
return ergebnis
|
||
}
|
||
|
||
/**
|
||
* Abgleich der offenen Bestellungen, deren Frist (ablaufAt) seit mehr als 5 Minuten verstrichen ist (Minutenjob): Stripe fragen
|
||
* und bezahlt abschliessen, verwerfen oder (noch offene Sitzung) beenden und verwerfen. Lesefehler: naechster Lauf.
|
||
* @returns {Promise<{ geprueft: number, bezahlt: number, verworfen: number, wartend: number, fehler: number }>}
|
||
*/
|
||
export async function pruefeOffene(now = new Date()) {
|
||
const grenze = new Date(now).getTime() - ABGLEICH_PUFFER_MS
|
||
const zaehler = { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 }
|
||
const shops = await listAllDocuments(config.collections.shopConfig, [])
|
||
for (const shop of shops) {
|
||
const offene = (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'offen')])).filter(
|
||
(d) => new Date(d.ablaufAt).getTime() < grenze
|
||
)
|
||
// Bezahlte Bestellungen, bei denen nach einem Teilfehler noch Bestandsabzug oder Mails fehlen: ueber schliesseAb nachholen
|
||
const unfertig = shop.stripeAccountId
|
||
? (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'bezahlt')])).filter(
|
||
(d) => new Date(d.bezahltAt).getTime() < grenze && abschlussAusstehend({ ...d, daten: bestellDaten(d) })
|
||
)
|
||
: []
|
||
for (const order of unfertig) {
|
||
zaehler.geprueft += 1
|
||
try {
|
||
if (!order.stripeSessionId) continue
|
||
const session = await sessionHolen(shop.stripeAccountId, order.stripeSessionId)
|
||
if (session) await schliesseAb({ accountId: shop.stripeAccountId, session, now })
|
||
} catch (err) {
|
||
zaehler.fehler += 1
|
||
console.warn(`[shop] Nachholen der Bestellung ${String(order.$id).slice(0, 36)} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||
}
|
||
}
|
||
for (const order of offene) {
|
||
zaehler.geprueft += 1
|
||
try {
|
||
if (!order.stripeSessionId || !shop.stripeAccountId) {
|
||
// Es wurde nie eine Sitzung gespeichert: es kann nichts bezahlt werden
|
||
if (!order.stripeSessionId) {
|
||
const erg = await verwirf(order.$id, 'abgelaufen', { now })
|
||
if (erg.verworfen) zaehler.verworfen += 1
|
||
} else zaehler.wartend += 1
|
||
continue
|
||
}
|
||
let session = await sessionHolen(shop.stripeAccountId, order.stripeSessionId)
|
||
if (session?.status === 'complete') {
|
||
if (session.payment_status === 'paid') {
|
||
const erg = await schliesseAb({ accountId: shop.stripeAccountId, session, now })
|
||
if (erg.neu) zaehler.bezahlt += 1
|
||
} else {
|
||
// SEPA unterwegs: async_payment_succeeded/failed entscheidet; bis dahin hat der Kaeufer die Eingangsmail (G15) zu bekommen
|
||
zaehler.wartend += 1
|
||
await meldeEingang({ accountId: shop.stripeAccountId, session, now })
|
||
}
|
||
continue
|
||
}
|
||
if (session?.status === 'open') {
|
||
session = await sessionAblaufen(shop.stripeAccountId, order.stripeSessionId)
|
||
if (session?.status !== 'expired') {
|
||
zaehler.wartend += 1
|
||
continue
|
||
}
|
||
} else if (session?.status !== 'expired') {
|
||
zaehler.wartend += 1
|
||
continue
|
||
}
|
||
const erg = await verwirf(order.$id, 'abgelaufen', { now })
|
||
if (erg.verworfen) zaehler.verworfen += 1
|
||
} catch (err) {
|
||
zaehler.fehler += 1
|
||
console.warn(`[shop] Abgleich der Bestellung ${String(order.$id).slice(0, 36)} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||
}
|
||
}
|
||
}
|
||
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 }
|
||
}
|