schliesseAb und verwirf entfernen ipHash aus dem Bestell-JSON, sobald die Bestellung nicht mehr offen ist (Begrenzung offener Bestellungen je IP bleibt). customer_details.phone wird nicht mehr in kaeufer.telefon übernommen; Kundenansicht und Bestelldetail geben auch in alten Datensätzen keine Telefonnummer aus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1260 lines
64 KiB
JavaScript
1260 lines
64 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 } (keine Telefonnummer), 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.
|
||
* ipHash steht nur, solange die Bestellung offen ist (Begrenzung offener Bestellungen je IP); schliesseAb und verwirf entfernen ihn.
|
||
* 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] } } : {}),
|
||
// Datenminimierung (Art. 5 Abs. 1 lit. c DSGVO): keine Telefonnummer, Rechnungsanschrift nur wenn Stripe sie fuer die Zahlungsart braucht
|
||
billing_address_collection: 'auto',
|
||
phone_number_collection: { enabled: false },
|
||
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 })
|
||
// Datenminimierung: Der IP-Hash dient nur der Begrenzung offener Bestellungen und entfaellt mit der Zahlung; eine Telefonnummer wird nie gespeichert
|
||
const { ipHash: _ipHash, ...datenOhneIp } = daten
|
||
const neueDaten = {
|
||
...datenOhneIp,
|
||
kaeufer: { name: kurz(kunde.name, 200), email },
|
||
...(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 }
|
||
// Der IP-Hash dient nur der Begrenzung offener Bestellungen und entfaellt, sobald die Bestellung nicht mehr offen ist
|
||
const { ipHash: _ipHash, ...datenOhneIp } = order.daten
|
||
const doc = await updateDocument(col(), orderId, {
|
||
status: 'storniert',
|
||
bestellung: JSON.stringify({ ...datenOhneIp, 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)),
|
||
// Alte Datensaetze koennen noch eine Telefonnummer enthalten: nie ausgeben
|
||
kaeufer: istObjekt(daten.kaeufer) ? { name: daten.kaeufer.name ?? '', email: daten.kaeufer.email ?? '' } : { name: '', email: order?.kaeuferEmail ?? '' },
|
||
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 }
|
||
}
|