Shop E1: Bestellung anlegen (Reservierung unter Sperre, Stripe-Kasse auf dem Händlerkonto, Rück-URL-Prüfung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
da43ee2b41
commit
9d19a2cbad
2 files changed
+775
No files matched your search
@@ -0,0 +1,359 @@
|
||||
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 { checkoutAnlegen } from './connect.js'
|
||||
import { istGeoeffnet, leereCache } from './kontext.js'
|
||||
import { berechne } from './preise.js'
|
||||
import { listeProdukte } 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 kaeufer / lieferadresse / erstattungen[] / hinweise im selben JSON. 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
|
||||
|
||||
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
|
||||
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).
|
||||
* @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')])
|
||||
const karte = new Map()
|
||||
for (const doc of docs) {
|
||||
const ablauf = new Date(doc.ablaufAt).getTime()
|
||||
if (!(ablauf > nowMs)) continue
|
||||
const positionen = bestellDaten(doc).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 }} [opt]
|
||||
* @returns {Promise<{ url: string, orderId: string, bestellnummer: string }>}
|
||||
*/
|
||||
export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {}) {
|
||||
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 { 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()
|
||||
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,
|
||||
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, sessionEnde } = angelegt
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
|
||||
|
||||
// Bestellung anlegen (E1): Berechnung nur aus der Datenbank, Reservierung unter Sperre, Stripe-Parameter auf dem Haendlerkonto,
|
||||
// Rueck-URL-Pruefung, Fehlerwege (geschlossen, ausverkauft, Stripe-Fehler, Klarna nicht freigeschaltet).
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
shopConfig: schemaKeys('shopConfig'),
|
||||
shopProducts: schemaKeys('shopProducts'),
|
||||
shopOrders: schemaKeys('shopOrders'),
|
||||
},
|
||||
datetimes: SHOP_DATETIMES,
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
after(async () => {
|
||||
await aw.close()
|
||||
await st.close()
|
||||
})
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const K = await import('../server/services/shop/kontext.js')
|
||||
const B = await import('../server/services/shop/bestellungen.js')
|
||||
const { normalisiereProdukt } = await import('../server/services/shop/produkte.js')
|
||||
const { STANDARD_EINSTELLUNGEN } = await import('../server/services/shop/einstellungen.js')
|
||||
const { rechtstexteHash } = await import('../server/services/shop/rechtstexte.js')
|
||||
|
||||
const KUNDE = 'c_kunde01'
|
||||
const PROJEKT = 'p_projekt01'
|
||||
const FEATURE = 'f_p_projekt01_shop'
|
||||
const SITE_KEY = 'abcdefghijklmnop'
|
||||
const ACCOUNT = 'acct_haendler1'
|
||||
const T0 = new Date('2026-10-05T12:00:00.000Z')
|
||||
const nach = (s) => new Date(T0.getTime() + s * 1000)
|
||||
const PORTAL = 'https://project.webklar.com'
|
||||
|
||||
const EINSTELLUNGEN = {
|
||||
...JSON.parse(JSON.stringify(STANDARD_EINSTELLUNGEN)),
|
||||
shopname: 'Brot & Mehr',
|
||||
anbieter: { name: 'Brot & Mehr GmbH', strasse: 'Hauptstraße 5', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'shop@brot.example', telefon: '', ustId: '' },
|
||||
abholung: { aktiv: true, adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' },
|
||||
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 790 }, freiAbCent: null },
|
||||
zahlungsarten: ['card'],
|
||||
}
|
||||
const TEXTE = { agb: 'AGB', widerrufsbelehrung: 'Widerruf', widerrufsformular: 'Formular', impressumUrl: 'https://brot.example/impressum', datenschutzUrl: 'https://brot.example/datenschutz' }
|
||||
const HASH = rechtstexteHash(TEXTE, EINSTELLUNGEN)
|
||||
|
||||
const featureDoc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, featureKey: 'shop', siteKey: SITE_KEY, state: 'active', enabled: true }
|
||||
const shopDoc = (over = {}, einstellungen = EINSTELLUNGEN) => ({
|
||||
$id: FEATURE,
|
||||
customerId: KUNDE,
|
||||
projectId: PROJEKT,
|
||||
siteKey: SITE_KEY,
|
||||
anfrageStatus: 'angenommen',
|
||||
stripeAccountId: ACCOUNT,
|
||||
stripeStatus: JSON.stringify({ charges_enabled: true, payouts_enabled: true }),
|
||||
einstellungen: JSON.stringify(einstellungen),
|
||||
rechtstexte: JSON.stringify({ entwurf: TEXTE, bestaetigt: { texte: TEXTE, hash: HASH, at: '2026-10-05T10:00:00.000Z', von: 'k@example.invalid' } }),
|
||||
geoeffnet: true,
|
||||
version: 1,
|
||||
...over,
|
||||
})
|
||||
const artikel = (id, produktEingabe = {}) => {
|
||||
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...produktEingabe }, { einstellungen: { kleinunternehmer: true } })
|
||||
assert.equal(r.fehler, undefined, JSON.stringify(r.fehler))
|
||||
return { $id: id, shopId: FEATURE, customerId: KUNDE, aktiv: r.produkt.aktiv, sort: 0, name: r.produkt.name, produkt: JSON.stringify(r.produkt) }
|
||||
}
|
||||
|
||||
async function aufbau({ shop = {}, einstellungen = EINSTELLUNGEN, produkte = [artikel('a1')], projekt = {} } = {}) {
|
||||
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects']) {
|
||||
for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id)
|
||||
}
|
||||
aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Brotladen', liveDomain: 'https://www.brot.example', ...projekt })
|
||||
aw.seed('portalFeatures', featureDoc)
|
||||
aw.seed('shopConfig', shopDoc(shop, einstellungen))
|
||||
for (const p of produkte) aw.seed('shopProducts', p)
|
||||
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
|
||||
K.leereCache()
|
||||
return K.shopPerSiteKey(SITE_KEY, { now: T0 })
|
||||
}
|
||||
beforeEach(() => {
|
||||
st.calls.length = 0
|
||||
config.publicBaseUrl = PORTAL
|
||||
K.leereCache()
|
||||
})
|
||||
|
||||
const sessionCalls = () => st.calls.filter((c) => c.path === '/v1/checkout/sessions' && c.method === 'POST')
|
||||
const orders = () => [...aw.docs('shopOrders').values()]
|
||||
const eingabe = (over = {}) => ({ positionen: [{ produktId: 'a1', menge: 2 }], lieferart: 'versand', land: 'DE', rueckUrl: 'https://www.brot.example/laden?x=1#warenkorb', ...over })
|
||||
const rejectsMit = (promise, status, code) =>
|
||||
assert.rejects(promise, (err) => {
|
||||
assert.equal(err.status, status, err.message)
|
||||
assert.equal(err.code, code)
|
||||
return true
|
||||
})
|
||||
|
||||
test('neueBestellnummer: JJ-XXXXXX ohne 0, 1, O, I; Jahr nach Berliner Zeit', () => {
|
||||
for (let i = 0; i < 200; i += 1) assert.match(B.neueBestellnummer(T0), /^26-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/)
|
||||
// Silvester 23:30 UTC ist in Berlin schon Neujahr
|
||||
assert.match(B.neueBestellnummer(new Date('2026-12-31T23:30:00.000Z')), /^27-/)
|
||||
assert.match(B.neueBestellnummer(new Date('2026-12-31T22:30:00.000Z')), /^26-/)
|
||||
})
|
||||
|
||||
test('tokenPasst: nur der passende Token, kein Hash als Token', () => {
|
||||
const token = 'abc123'
|
||||
const order = { tokenHash: B.tokenHash(token) }
|
||||
assert.equal(B.tokenPasst(order, token), true)
|
||||
assert.equal(B.tokenPasst(order, 'abc124'), false)
|
||||
assert.equal(B.tokenPasst(order, order.tokenHash), false)
|
||||
assert.equal(B.tokenPasst(order, ''), false)
|
||||
assert.equal(B.tokenPasst(order, undefined), false)
|
||||
assert.equal(B.tokenPasst({}, token), false)
|
||||
assert.equal(B.tokenPasst({ tokenHash: '' }, ''), false)
|
||||
})
|
||||
|
||||
test('pruefeRueckUrl: eigene Hosts und Portal ja, fremde, http, Zugangsdaten nein; wk_* und Fragment fallen weg', async () => {
|
||||
const ctx = await aufbau()
|
||||
const fallback = `${PORTAL}/shop/${SITE_KEY}`
|
||||
const p = (u) => B.pruefeRueckUrl(u, ctx)
|
||||
assert.equal(p('https://www.brot.example/laden?x=1&wk_t=alt&wk_abbruch=1#top'), 'https://www.brot.example/laden?x=1')
|
||||
assert.equal(p('https://brot.example/'), fallback) // Apex nur, wenn das Projekt weitere Hosts hat (analyseDomains)
|
||||
assert.equal(p(`${PORTAL}/shop/${SITE_KEY}/seite`), `${PORTAL}/shop/${SITE_KEY}/seite`)
|
||||
for (const schlecht of ['https://boese.example/laden', 'http://www.brot.example/laden', 'https://www.brot.example.boese.example/', 'https://user:pw@www.brot.example/', 'https://www.brot.example:8443/', 'javascript:alert(1)', '//boese.example', '/relativ', '', undefined, null, 42, 'x'.repeat(3000)]) {
|
||||
assert.equal(p(schlecht), fallback, String(schlecht).slice(0, 40))
|
||||
}
|
||||
// Ohne Projekt gilt nur das Portal
|
||||
assert.equal(B.pruefeRueckUrl('https://www.brot.example/', { ...ctx, project: null }), fallback)
|
||||
// Portal-Adresse http (Tests): http auf dem Portal-Host erlaubt, auf fremden Hosts nicht
|
||||
config.publicBaseUrl = 'http://127.0.0.1:4000'
|
||||
assert.equal(B.pruefeRueckUrl('http://127.0.0.1:4000/shop/x?wk_t=1', ctx), 'http://127.0.0.1:4000/shop/x')
|
||||
assert.equal(B.pruefeRueckUrl('http://www.brot.example/', ctx), 'http://127.0.0.1:4000/shop/' + SITE_KEY)
|
||||
})
|
||||
|
||||
test('Normalfall: Bestellung offen, Stripe-Kasse auf dem Haendlerkonto mit allen Parametern', async () => {
|
||||
const ctx = await aufbau()
|
||||
const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 })
|
||||
assert.match(r.bestellnummer, /^26-[A-Z2-9]{6}$/)
|
||||
assert.match(r.url, /^https:\/\/checkout\.stripe\.test\/c\/pay\/cs_/)
|
||||
|
||||
const [order] = orders()
|
||||
assert.equal(order.$id, r.orderId)
|
||||
assert.equal(order.status, 'offen')
|
||||
assert.equal(order.bestellnummer, r.bestellnummer)
|
||||
assert.equal(order.shopId, FEATURE)
|
||||
assert.equal(order.customerId, KUNDE)
|
||||
assert.equal(order.summeCent, 2 * 1250 + 490)
|
||||
assert.equal(new Date(order.erstelltAt).toISOString(), T0.toISOString())
|
||||
assert.equal(new Date(order.ablaufAt).getTime(), T0.getTime() + (30 * 60 + 30) * 1000)
|
||||
assert.match(order.stripeSessionId, /^cs_/)
|
||||
assert.match(order.tokenHash, /^[0-9a-f]{64}$/)
|
||||
const daten = JSON.parse(order.bestellung)
|
||||
assert.equal(daten.lieferart, 'versand')
|
||||
assert.equal(daten.land, 'DE')
|
||||
assert.equal(daten.positionen[0].menge, 2)
|
||||
assert.deepEqual({ z: daten.summen.zwischensummeCent, v: daten.summen.versandCent, g: daten.summen.gesamtCent }, { z: 2500, v: 490, g: 2990 })
|
||||
assert.equal(daten.anbieter.name, 'Brot & Mehr GmbH')
|
||||
assert.equal(daten.shopname, 'Brot & Mehr')
|
||||
assert.equal(daten.lieferzeit, EINSTELLUNGEN.lieferzeit)
|
||||
assert.deepEqual(daten.zahlungsarten, ['card'])
|
||||
assert.equal(daten.rechtstexteHash, HASH)
|
||||
assert.equal(daten.abholung, undefined)
|
||||
assert.equal(daten.verlauf.length, 1)
|
||||
assert.equal(daten.verlauf[0].was, 'angelegt')
|
||||
|
||||
const [call] = sessionCalls()
|
||||
assert.equal(sessionCalls().length, 1)
|
||||
assert.equal(call.account, ACCOUNT)
|
||||
assert.equal(call.idempotencyKey, `shop-order-${r.orderId}`)
|
||||
const b = call.body
|
||||
assert.equal(b.mode, 'payment')
|
||||
assert.equal(b['line_items[0][quantity]'], '2')
|
||||
assert.equal(b['line_items[0][price_data][currency]'], 'eur')
|
||||
assert.equal(b['line_items[0][price_data][unit_amount]'], '1250')
|
||||
assert.equal(b['line_items[0][price_data][product_data][name]'], 'Artikel a1')
|
||||
assert.equal(b['line_items[0][price_data][product_data][images][0]'], undefined)
|
||||
assert.equal(b['shipping_options[0][shipping_rate_data][type]'], 'fixed_amount')
|
||||
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490')
|
||||
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][currency]'], 'eur')
|
||||
assert.equal(b['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Deutschland')
|
||||
assert.equal(b['shipping_address_collection[allowed_countries][0]'], 'DE')
|
||||
assert.equal(b.billing_address_collection, 'required')
|
||||
assert.equal(b['phone_number_collection[enabled]'], 'true')
|
||||
assert.equal(b['payment_method_types[0]'], 'card')
|
||||
assert.equal(b['payment_method_types[1]'], undefined)
|
||||
assert.equal(b.locale, 'de')
|
||||
assert.equal(Number(b.expires_at), Math.ceil(T0.getTime() / 1000) + 30 * 60 + 30)
|
||||
assert.equal(b.client_reference_id, r.orderId)
|
||||
for (const prefix of ['metadata', 'payment_intent_data[metadata]']) {
|
||||
assert.equal(b[`${prefix}[wkShop]`], '1')
|
||||
assert.equal(b[`${prefix}[shopId]`], FEATURE)
|
||||
assert.equal(b[`${prefix}[orderId]`], r.orderId)
|
||||
assert.equal(b[`${prefix}[bestellnummer]`], r.bestellnummer)
|
||||
}
|
||||
assert.equal(b['payment_intent_data[description]'], `Bestellung ${r.bestellnummer} – Brot & Mehr`)
|
||||
const success = new URL(b.success_url)
|
||||
assert.equal(success.origin + success.pathname, 'https://www.brot.example/laden')
|
||||
assert.equal(success.searchParams.get('x'), '1')
|
||||
assert.equal(success.searchParams.get('wk_bestellung'), r.orderId)
|
||||
assert.equal(success.hash, '')
|
||||
const token = success.searchParams.get('wk_t')
|
||||
assert.match(token, /^[A-Za-z0-9_-]{43}$/)
|
||||
assert.equal(B.tokenPasst(order, token), true)
|
||||
assert.equal(order.tokenHash.includes(token), false)
|
||||
const cancel = new URL(b.cancel_url)
|
||||
assert.equal(cancel.searchParams.get('wk_abbruch'), '1')
|
||||
assert.equal(cancel.searchParams.get('x'), '1')
|
||||
assert.equal(cancel.searchParams.get('wk_t'), null)
|
||||
|
||||
// bestellungLesen
|
||||
const gelesen = await B.bestellungLesen(r.orderId)
|
||||
assert.equal(gelesen.daten.shopname, 'Brot & Mehr')
|
||||
assert.equal(gelesen.status, 'offen')
|
||||
assert.equal(await B.bestellungLesen('gibt-es-nicht'), null)
|
||||
assert.equal(await B.bestellungLesen('../x'), null)
|
||||
assert.deepEqual(B.bestellDaten({ bestellung: '{kaputt' }), {})
|
||||
})
|
||||
|
||||
test('Abholung, Variante, Bild und Landeswahl: Namen, Versandzeile und Bild-URL', async () => {
|
||||
const ctx = await aufbau({
|
||||
produkte: [
|
||||
artikel('a1', { bilder: ['abc-123'], varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000 }, { name: 'L', preisCent: 2200 }] } }),
|
||||
artikel('a2'),
|
||||
],
|
||||
})
|
||||
const m = ctx && JSON.parse(aw.docs('shopProducts').get('a1').produkt).varianten.werte.find((w) => w.name === 'L')
|
||||
const abholung = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], lieferart: 'abholung', land: undefined }), { now: T0 })
|
||||
const a = sessionCalls()[0].body
|
||||
assert.equal(a['line_items[0][price_data][product_data][name]'], 'Artikel a1 – L')
|
||||
assert.equal(a['line_items[0][price_data][unit_amount]'], '2200')
|
||||
assert.equal(a['line_items[0][price_data][product_data][images][0]'], `${PORTAL}/api/public/shop/v1/${SITE_KEY}/bild/abc-123-l.webp`)
|
||||
assert.equal(a['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '0')
|
||||
assert.equal(a['shipping_options[0][shipping_rate_data][display_name]'], 'Abholung')
|
||||
assert.equal(a['shipping_address_collection[allowed_countries][0]'], undefined)
|
||||
const daten = JSON.parse(aw.docs('shopOrders').get(abholung.orderId).bestellung)
|
||||
assert.deepEqual(daten.abholung, { adresse: 'Hauptstraße 5, 67655 Kaiserslautern', hinweis: 'Mo–Fr 8–12 Uhr' })
|
||||
assert.equal(daten.land, null)
|
||||
|
||||
st.calls.length = 0
|
||||
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a2', menge: 1 }], land: 'AT' }), { now: T0 })
|
||||
const v = sessionCalls()[0].body
|
||||
assert.equal(v['shipping_options[0][shipping_rate_data][display_name]'], 'Versand nach Österreich')
|
||||
assert.equal(v['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '790')
|
||||
assert.equal(v['shipping_address_collection[allowed_countries][0]'], 'AT')
|
||||
|
||||
// Bild-URL nur mit https-Portal
|
||||
st.calls.length = 0
|
||||
config.publicBaseUrl = 'http://127.0.0.1:4000'
|
||||
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', varianteId: m.id, menge: 1 }], rueckUrl: undefined }), { now: T0 })
|
||||
assert.equal(sessionCalls()[0].body['line_items[0][price_data][product_data][images][0]'], undefined)
|
||||
assert.ok(sessionCalls()[0].body.success_url.startsWith(`http://127.0.0.1:4000/shop/${SITE_KEY}?wk_bestellung=`))
|
||||
})
|
||||
|
||||
test('Preise nur aus der Datenbank: Preis, Summe und Versand vom Browser zaehlen nicht', async () => {
|
||||
const ctx = await aufbau()
|
||||
await B.legeBestellungAn(
|
||||
ctx,
|
||||
eingabe({ positionen: [{ produktId: 'a1', menge: 1, preisCent: 1, einzelCent: 1, summeCent: 1 }], versandCent: 0, gesamtCent: 1, summeCent: 1 }),
|
||||
{ now: T0 }
|
||||
)
|
||||
const b = sessionCalls()[0].body
|
||||
assert.equal(b['line_items[0][price_data][unit_amount]'], '1250')
|
||||
assert.equal(b['shipping_options[0][shipping_rate_data][fixed_amount][amount]'], '490')
|
||||
assert.equal(orders()[0].summeCent, 1740)
|
||||
})
|
||||
|
||||
test('Berechnungsfehler: unbekannter Artikel 409, falsche Menge 400, Lieferart 400; nichts angelegt, kein Stripe-Aufruf', async () => {
|
||||
const ctx = await aufbau()
|
||||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }), 409, 'nicht_verfuegbar')
|
||||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 0 }] }), { now: T0 }), 400, 'menge')
|
||||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ lieferart: 'post' }), { now: T0 }), 400, 'lieferart')
|
||||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ positionen: [] }), { now: T0 }), 400, 'leer')
|
||||
await rejectsMit(B.legeBestellungAn(ctx, eingabe({ land: 'FR' }), { now: T0 }), 400, 'land')
|
||||
await rejectsMit(B.legeBestellungAn(ctx, undefined, { now: T0 }), 400, 'leer')
|
||||
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'gibtsnicht', menge: 1 }] }), { now: T0 }).catch((e) => e)
|
||||
assert.match(err.message, /nicht mehr verfügbar/)
|
||||
assert.equal(orders().length, 0)
|
||||
assert.equal(st.calls.length, 0)
|
||||
})
|
||||
|
||||
test('Reservierung: der letzte Artikel geht nur einmal weg, abgelaufene Reservierungen geben ihn wieder frei', async () => {
|
||||
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
|
||||
const erste = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
|
||||
assert.deepEqual([...(await B.reserviert(FEATURE, nach(10))).entries()], [['a1', 1]])
|
||||
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(60) }).catch((e) => e)
|
||||
assert.equal(err.status, 409)
|
||||
assert.equal(err.code, 'ausverkauft')
|
||||
assert.match(err.message, /ausverkauft/)
|
||||
assert.equal(orders().length, 1)
|
||||
assert.equal(sessionCalls().length, 1)
|
||||
|
||||
// Nach Ablauf (30 min + 30 s) zaehlt die Reservierung nicht mehr
|
||||
assert.equal((await B.reserviert(FEATURE, nach(31 * 60))).size, 0)
|
||||
const zweite = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: nach(31 * 60) })
|
||||
assert.notEqual(zweite.orderId, erste.orderId)
|
||||
})
|
||||
|
||||
test('reserviert: nur offene Bestellungen dieses Shops, Varianten mit Doppelpunkt-Schluessel, Mengen summiert', async () => {
|
||||
await aufbau()
|
||||
const ablauf = nach(1800).toISOString()
|
||||
const pos = (produktId, menge, varianteId = null) => ({ produktId, varianteId, menge })
|
||||
const bestellung = (...positionen) => JSON.stringify({ positionen })
|
||||
aw.seed('shopOrders', { $id: 'o1', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 2), pos('p2', 1, 'v1')) })
|
||||
aw.seed('shopOrders', { $id: 'o2', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 3), pos('p2', 4, 'v1'), pos('p2', 1, 'v2')) })
|
||||
aw.seed('shopOrders', { $id: 'o3', shopId: FEATURE, status: 'bezahlt', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) })
|
||||
aw.seed('shopOrders', { $id: 'o4', shopId: 'anderer', status: 'offen', ablaufAt: ablauf, bestellung: bestellung(pos('p1', 9)) })
|
||||
aw.seed('shopOrders', { $id: 'o5', shopId: FEATURE, status: 'offen', ablaufAt: nach(-5).toISOString(), bestellung: bestellung(pos('p1', 9)) })
|
||||
aw.seed('shopOrders', { $id: 'o6', shopId: FEATURE, status: 'offen', ablaufAt: ablauf, bestellung: '{kaputt' })
|
||||
const karte = await B.reserviert(FEATURE, T0)
|
||||
assert.deepEqual(Object.fromEntries(karte), { p1: 5, 'p2:v1': 5, 'p2:v2': 1 })
|
||||
})
|
||||
|
||||
test('Gleichzeitig: zwei parallele Bestellungen fuer den letzten Artikel, genau eine gewinnt', async () => {
|
||||
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
|
||||
const lauf = () => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
|
||||
const ergebnisse = await Promise.allSettled([lauf(), lauf(), lauf()])
|
||||
assert.equal(ergebnisse.filter((x) => x.status === 'fulfilled').length, 1)
|
||||
const abgelehnt = ergebnisse.filter((x) => x.status === 'rejected')
|
||||
assert.equal(abgelehnt.length, 2)
|
||||
for (const x of abgelehnt) assert.equal(x.reason.code, 'ausverkauft')
|
||||
assert.equal(orders().length, 1)
|
||||
assert.equal(sessionCalls().length, 1)
|
||||
})
|
||||
|
||||
test('Geschlossener Shop: 409 geschlossen, nichts angelegt, kein Stripe-Aufruf', async () => {
|
||||
for (const shop of [{ geoeffnet: false }, { stripeStatus: JSON.stringify({ charges_enabled: false }) }, { stripeAccountId: '' }]) {
|
||||
const ctx = await aufbau({ shop })
|
||||
const err = await B.legeBestellungAn(ctx, eingabe(), { now: T0 }).catch((e) => e)
|
||||
assert.equal(err.status, 409, JSON.stringify(shop))
|
||||
assert.equal(err.code, 'geschlossen')
|
||||
assert.equal(err.message, 'Der Shop ist gerade geschlossen.')
|
||||
assert.equal(orders().length, 0)
|
||||
assert.equal(st.calls.length, 0)
|
||||
}
|
||||
// Funktion nicht aktiv
|
||||
const ctx = await aufbau()
|
||||
await rejectsMit(B.legeBestellungAn({ ...ctx, doc: { ...ctx.doc, state: 'disabled' } }, eingabe(), { now: T0 }), 409, 'geschlossen')
|
||||
// Kein lieferbarer Artikel mehr
|
||||
const leer = await aufbau({ produkte: [artikel('a1', { aktiv: false })] })
|
||||
await rejectsMit(B.legeBestellungAn(leer, eingabe(), { now: T0 }), 409, 'geschlossen')
|
||||
})
|
||||
|
||||
test('Fremde Rueck-URL: Fallback auf die Shop-Seite im Portal', async () => {
|
||||
const ctx = await aufbau()
|
||||
await B.legeBestellungAn(ctx, eingabe({ rueckUrl: 'https://boese.example/phishing?x=1' }), { now: T0 })
|
||||
const b = sessionCalls()[0].body
|
||||
const s = new URL(b.success_url)
|
||||
assert.equal(s.origin + s.pathname, `${PORTAL}/shop/${SITE_KEY}`)
|
||||
assert.equal(new URL(b.cancel_url).origin + new URL(b.cancel_url).pathname, `${PORTAL}/shop/${SITE_KEY}`)
|
||||
assert.ok(!b.success_url.includes('boese'))
|
||||
})
|
||||
|
||||
test('Stripe lehnt ab: Bestellung storniert (Reservierung frei), 503 mit Kaufertext', async () => {
|
||||
const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 1 })] })
|
||||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'Invalid something' })
|
||||
const err = await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 }).catch((e) => e)
|
||||
assert.equal(err.status, 503)
|
||||
assert.equal(err.code, 'zahlung_nicht_moeglich')
|
||||
assert.equal(err.message, 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.')
|
||||
assert.equal(sessionCalls().length, 1)
|
||||
const [order] = orders()
|
||||
assert.equal(order.status, 'storniert')
|
||||
const daten = JSON.parse(order.bestellung)
|
||||
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'stripe_fehler'])
|
||||
assert.equal(order.stripeSessionId ?? '', '')
|
||||
assert.equal((await B.reserviert(FEATURE, T0)).size, 0)
|
||||
// Der Artikel ist wieder bestellbar
|
||||
await B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now: T0 })
|
||||
})
|
||||
|
||||
test('Klarna nicht freigeschaltet: einmal mit Karte wiederholen, Hinweis fuer den Haendler speichern', async () => {
|
||||
const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'klarna'] } })
|
||||
assert.deepEqual(ctx.shop.einstellungen.zahlungsarten, ['card', 'klarna'])
|
||||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'The payment method type provided: klarna is invalid. Please ensure the provided type is activated in your dashboard (payment_method_types).' })
|
||||
const r = await B.legeBestellungAn(ctx, eingabe(), { now: T0 })
|
||||
const calls = sessionCalls()
|
||||
assert.equal(calls.length, 2)
|
||||
assert.equal(calls[0].body['payment_method_types[1]'], 'klarna')
|
||||
assert.equal(calls[1].body['payment_method_types[0]'], 'card')
|
||||
assert.equal(calls[1].body['payment_method_types[1]'], undefined)
|
||||
assert.equal(calls[1].account, ACCOUNT)
|
||||
assert.notEqual(calls[1].idempotencyKey, calls[0].idempotencyKey)
|
||||
assert.equal(calls[1].body.client_reference_id, r.orderId)
|
||||
assert.equal(calls[1].body.success_url, calls[0].body.success_url)
|
||||
const [order] = orders()
|
||||
assert.equal(order.status, 'offen')
|
||||
assert.match(order.stripeSessionId, /^cs_/)
|
||||
const status = JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus)
|
||||
assert.equal(status.charges_enabled, true)
|
||||
assert.match(status.hinweis, /^Klarna\/SEPA ist in Deinem Stripe-Konto nicht aktiviert/)
|
||||
// Zwischenspeicher geleert: der naechste Kontext enthaelt den Hinweis
|
||||
assert.match((await K.shopPerSiteKey(SITE_KEY, { now: T0 })).shop.stripeStatus.hinweis, /Klarna/)
|
||||
})
|
||||
|
||||
test('Fehler bei reiner Kartenzahlung wird nicht wiederholt', async () => {
|
||||
const ctx = await aufbau()
|
||||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'bad payment_method_types' })
|
||||
await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich')
|
||||
assert.equal(sessionCalls().length, 1)
|
||||
assert.equal(orders()[0].status, 'storniert')
|
||||
})
|
||||
|
||||
test('Anderer Stripe-Fehler trotz Klarna-Auswahl: keine Wiederholung mit Karte', async () => {
|
||||
const ctx = await aufbau({ einstellungen: { ...EINSTELLUNGEN, zahlungsarten: ['card', 'sepa_debit'] } })
|
||||
st.failNext({ method: 'POST', path: '/v1/checkout/sessions', status: 400, message: 'No such account: acct_x' })
|
||||
await rejectsMit(B.legeBestellungAn(ctx, eingabe(), { now: T0 }), 503, 'zahlung_nicht_moeglich')
|
||||
assert.equal(sessionCalls().length, 1)
|
||||
assert.equal(orders()[0].status, 'storniert')
|
||||
assert.equal(JSON.parse(aw.docs('shopConfig').get(FEATURE).stripeStatus).hinweis, undefined)
|
||||
})
|
||||
Reference in new issue
Block a user