Shop F2: POST /pruefen, POST /bestellung und GET /bestellung/:id (Herkunft, Honigtopf, Formular-Token, Grenzen, Stripe-Abgleich, maskierte E-Mail)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c13bc94a0f
commit
583a2d33c2
2 files changed
+644
-1
No files matched your search
+170
-1
@@ -3,9 +3,11 @@ import { config } from '../config.js'
|
||||
import { analyseDomains } from '../services/analyse.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { bildMeta, bildPfad } from '../services/shop/bilder.js'
|
||||
import { reserviert } from '../services/shop/bestellungen.js'
|
||||
import { bestellungLesen, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js'
|
||||
import { sessionHolen } from '../services/shop/connect.js'
|
||||
import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js'
|
||||
import { euro } from '../services/shop/geld.js'
|
||||
import { berechne } from '../services/shop/preise.js'
|
||||
import { listeProdukte, parseProdukt, produktOeffentlich } from '../services/shop/produkte.js'
|
||||
import { GEWAEHRLEISTUNG_BILD, gewaehrleistungsHinweis, versandUndZahlung, zahlungsartenText } from '../services/shop/rechtstexte.js'
|
||||
import { shopFuerKunde, shopModus } from '../services/shop/schalter.js'
|
||||
@@ -81,6 +83,7 @@ function fehlerErgebnis(err) {
|
||||
if (Number.isInteger(status) && status >= 400 && status < 500 && Object.keys(fehler).length > 0) {
|
||||
const body = { fehler }
|
||||
if (typeof err.code === 'string') body.code = err.code
|
||||
if (typeof err.produktId === 'string') body.produktId = err.produktId
|
||||
return { status, body, headers: Number.isInteger(err.retryAfter) ? { 'Retry-After': String(err.retryAfter) } : {} }
|
||||
}
|
||||
console.error(`[publicShop] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
|
||||
@@ -450,6 +453,172 @@ router.post(
|
||||
),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Pruefen, Bestellung, Status (F2)
|
||||
|
||||
const PRUEFEN_JE_IP = 60
|
||||
const BESTELLUNG_JE_IP = 10
|
||||
const BESTELLUNG_FENSTER_MS = 10 * MINUTE_MS
|
||||
const BESTELLUNG_JE_SHOP = 60
|
||||
const STATUS_JE_IP = 120
|
||||
const STUNDE_MS = 60 * MINUTE_MS
|
||||
const BESTELL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
|
||||
const BESTELL_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/
|
||||
const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.'
|
||||
const GESCHLOSSEN = () => fehlerMit(409, { _: GESCHLOSSEN_TEXT }, { code: 'geschlossen' })
|
||||
// Fehlercodes aus berechne/legeBestellungAn, die Kaeufer sehen duerfen (Stripe-Fehler haben ebenfalls einen String-code)
|
||||
const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'zu_viele', 'zahlung_nicht_moeglich'])
|
||||
const KONFLIKT_CODES = new Set(['ausverkauft', 'nicht_verfuegbar', 'nur_abholung'])
|
||||
|
||||
/** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON (wie bei legeBestellungAn). */
|
||||
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)) karte.set(doc.$id, { ...p, id: doc.$id, aktiv: doc.aktiv !== false && p.aktiv !== false })
|
||||
}
|
||||
return karte
|
||||
}
|
||||
|
||||
/** Fehler aus legeBestellungAn fuer den Kaeufer: eigene Fehler (bekannter code) mit ihrem Text, alles andere 503 ohne Rohtext. */
|
||||
function bestellFehler(err) {
|
||||
if (typeof err?.code === 'string' && KAEUFER_CODES.has(err.code) && Number.isInteger(err.status) && err.status >= 400 && err.status < 600) {
|
||||
const extra = { code: err.code, ...(typeof err.produktId === 'string' ? { produktId: err.produktId } : {}) }
|
||||
if (err.status < 500) return fehlerMit(err.status, { _: String(err.message) }, extra)
|
||||
return Object.assign(new Error(String(err.message)), { status: 503, fehler: { _: String(err.message) }, ...extra, dienst: true })
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 5xx mit Kaeufertext (zahlung_nicht_moeglich): fehlerErgebnis kennt nur 4xx, also hier eigene Abbildung
|
||||
const bestellErgebnis = (err) => {
|
||||
const e = bestellFehler(err)
|
||||
if (e?.dienst) return { status: 503, body: { fehler: e.fehler, code: e.code } }
|
||||
throw e
|
||||
}
|
||||
|
||||
/** Eingabe-Objekt des Kaeufers: nur positionen, lieferart und land (Preise, Namen u. a. werden nie gelesen). */
|
||||
const warenkorbEingabe = (b) => ({ positionen: b.positionen, lieferart: b.lieferart, land: b.land })
|
||||
|
||||
router.post(
|
||||
'/:siteKey/pruefen',
|
||||
route(async (req, now, cors) => {
|
||||
const { siteKey } = req.params
|
||||
if (!zaehle(`ip:shop-pruefen:${ipSchluessel(req.ip)}`, PRUEFEN_JE_IP, MINUTE_MS, now)) throw zuViele(60)
|
||||
const ctx = await erreichbarerShop(siteKey, now)
|
||||
if (!ctx) return aktivFalse(req)
|
||||
pruefeHerkunft(req, cors, ctx.project)
|
||||
const b = req.body
|
||||
if (!istObjekt(b)) throw fehlerMit(400, UNGUELTIG.fehler)
|
||||
|
||||
const docs = await listeProdukte(ctx.shop.id)
|
||||
const [offen, belegt] = await Promise.all([istGeoeffnet(ctx, { produkte: docs }), reserviert(ctx.shop.id, now)])
|
||||
if (!offen) throw GESCHLOSSEN()
|
||||
const eingabe = warenkorbEingabe(b)
|
||||
const ergebnis = berechne({ produkte: produktKarte(docs), ...eingabe, einstellungen: ctx.shop.einstellungen, reserviert: belegt })
|
||||
if (!ergebnis.ok) {
|
||||
const { code, text, produktId } = ergebnis.fehler
|
||||
throw fehlerMit(KONFLIKT_CODES.has(code) ? 409 : 400, { _: text }, { code, ...(produktId ? { produktId } : {}) })
|
||||
}
|
||||
const widerrufTexte = new Map()
|
||||
for (const doc of docs) widerrufTexte.set(doc.$id, produktOeffentlich(doc, { einstellungen: ctx.shop.einstellungen, siteKey })?.widerrufText ?? '')
|
||||
return {
|
||||
body: {
|
||||
ok: true,
|
||||
positionen: ergebnis.positionen.map((p) => ({
|
||||
produktId: p.produktId,
|
||||
varianteId: p.varianteId,
|
||||
name: p.name,
|
||||
varianteText: p.varianteText,
|
||||
menge: p.menge,
|
||||
einzelText: p.einzelText,
|
||||
summeText: p.summeText,
|
||||
widerrufText: widerrufTexte.get(p.produktId) ?? '',
|
||||
})),
|
||||
texte: ergebnis.texte,
|
||||
lieferart: ergebnis.lieferart,
|
||||
land: ergebnis.land,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/:siteKey/bestellung',
|
||||
route(
|
||||
async (req, now, cors) => {
|
||||
const { siteKey } = req.params
|
||||
if (!zaehle(`ip:shop-bestellung:${ipSchluessel(req.ip)}`, BESTELLUNG_JE_IP, BESTELLUNG_FENSTER_MS, now)) throw zuViele(600)
|
||||
const ctx = await erreichbarerShop(siteKey, now)
|
||||
if (!ctx) return aktivFalse(req)
|
||||
pruefeHerkunft(req, cors, ctx.project)
|
||||
if (!zaehle(`shop-bestellung:${siteKey}`, BESTELLUNG_JE_SHOP, STUNDE_MS, now)) throw zuViele(3600)
|
||||
const b = req.body
|
||||
if (!istObjekt(b)) throw fehlerMit(400, { _: MELDUNG_NEU_LADEN }, { code: 'neu_laden' })
|
||||
if (istFalle(siteKey, b, now)) return { body: { url: pruefeRueckUrl(b.rueckUrl, ctx) } }
|
||||
|
||||
try {
|
||||
const { url } = await legeBestellungAn(ctx, { ...warenkorbEingabe(b), rueckUrl: b.rueckUrl }, { now })
|
||||
return { body: { url } }
|
||||
} catch (err) {
|
||||
return bestellErgebnis(err)
|
||||
}
|
||||
},
|
||||
{ mindestdauer: true },
|
||||
),
|
||||
)
|
||||
|
||||
/** E-Mail-Adresse verdeckt: erstes Zeichen, ***, Domain ('m***@example.com'); leer, wenn es keine gibt. */
|
||||
function maskiereEmail(email) {
|
||||
const t = typeof email === 'string' ? email.trim() : ''
|
||||
const at = t.lastIndexOf('@')
|
||||
if (at < 1 || at === t.length - 1) return ''
|
||||
return `${t[0]}***@${t.slice(at + 1)}`
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/:siteKey/bestellung/:id',
|
||||
route(async (req, now) => {
|
||||
const { siteKey, id } = req.params
|
||||
if (!zaehle(`ip:shop-status:${ipSchluessel(req.ip)}`, STATUS_JE_IP, MINUTE_MS, now)) throw zuViele(60)
|
||||
const keine = () => fehlerMit(404, { _: 'Bestellung nicht gefunden.' }) // fuer jeden Fehlerfall dieselbe Antwort
|
||||
const t = typeof req.query.t === 'string' ? req.query.t : ''
|
||||
if (!BESTELL_ID_RE.test(id) || !BESTELL_TOKEN_RE.test(t)) throw keine()
|
||||
const ctx = await erreichbarerShop(siteKey, now) // active und disabled
|
||||
if (!ctx) throw keine()
|
||||
let order = await bestellungLesen(id)
|
||||
if (!order || order.shopId !== ctx.shop.id || !tokenPasst(order, t)) throw keine()
|
||||
|
||||
// Noch offen: einmal bei Stripe nachsehen (der Webhook kann spaeter kommen als die Rueckkehr des Kaeufers)
|
||||
const accountId = ctx.shop.stripeAccountId
|
||||
if (order.status === 'offen' && order.stripeSessionId && accountId) {
|
||||
try {
|
||||
const session = await sessionHolen(accountId, order.stripeSessionId)
|
||||
if (session?.status === 'complete' && session.payment_status === 'paid') {
|
||||
await schliesseAb({ accountId, session, now })
|
||||
order = (await bestellungLesen(id)) ?? order
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[publicShop] Bestellstatus: Stripe-Abgleich nicht möglich: ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 120)}`)
|
||||
}
|
||||
}
|
||||
const gesamt = order.daten?.summen?.texte?.gesamt
|
||||
return {
|
||||
body: {
|
||||
status: String(order.status ?? ''),
|
||||
bestellnummer: String(order.bestellnummer ?? ''),
|
||||
gesamtText: typeof gesamt === 'string' && gesamt !== '' ? gesamt : euro(Number.isInteger(order.summeCent) ? order.summeCent : 0),
|
||||
lieferart: order.daten?.lieferart === 'abholung' ? 'abholung' : 'versand',
|
||||
email: maskiereEmail(order.kaeuferEmail),
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- Artikelbilder
|
||||
|
||||
router.get(
|
||||
|
||||
Reference in new issue
Block a user