Shop E2: Abschluss (schliesseAb), verwirf, Abgleich offener Bestellungen, Minutenjob
schliesseAb ist die einzige Stelle fuer den Status bezahlt: Sperre je Bestellung, Konto muss zum Shop passen, idempotent, spaete Zahlung nach Ablauf wird angenommen, SEPA erst bei bezahlt, Bestandsabzug mit Ueberverkauft-Hinweis, G09 an den Kaeufer und K25 an den Haendler (Mailfehler machen nichts rueckgaengig). pruefeOffene und jobs.js minutenJob gleichen offene Bestellungen mit Stripe ab. fakeStripe: completeSession. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9de23c7fe2
commit
bf99f659eb
4 files changed
+741
-4
No files matched your search
@@ -3,10 +3,11 @@ 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 { checkoutAnlegen, sessionAblaufen, sessionHolen } from './connect.js'
|
||||
import { istGeoeffnet, leereCache, shopPerSiteKey } from './kontext.js'
|
||||
import { anHaendler, anKaeufer, haendlerK25 } from './mails.js'
|
||||
import { berechne } from './preise.js'
|
||||
import { listeProdukte } from './produkte.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).
|
||||
@@ -16,7 +17,8 @@ import { listeProdukte } from './produkte.js'
|
||||
* 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.
|
||||
* E2 ergaenzt im selben JSON: kaeufer { name, email, telefon }, lieferadresse / rechnungsadresse { name?, line1, line2, postal_code,
|
||||
* city, country }, zahlungsart ('card' | 'klarna' | 'sepa_debit', nur wenn eindeutig), ueberverkauft, spaet; E3 erstattungen[]. Status offen reserviert Bestand bis ablaufAt.
|
||||
* Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message.
|
||||
*/
|
||||
|
||||
@@ -357,3 +359,253 @@ export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {})
|
||||
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). Unter den Sperren des Bestands und der Artikel.
|
||||
* @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])
|
||||
}
|
||||
if (nachArtikel.size === 0) return false
|
||||
await withLocks([`shop-bestand:${shopId}`, `shop-produkte:${shopId}`], async () => {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
const ergebnis = await withLocks([`shop-order:${orderId}`], async () => {
|
||||
const 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')
|
||||
if (order.status !== 'offen' && !spaet) return { order, neu: false }
|
||||
if (session.payment_status !== 'paid') return { order, neu: false }
|
||||
|
||||
const kunde = istObjekt(session.customer_details) ? session.customer_details : {}
|
||||
const versandDetails = session.collected_information?.shipping_details ?? session.shipping_details
|
||||
const lieferadresse = order.daten.lieferart === 'versand' ? anschrift(versandDetails?.address, versandDetails?.name) : null
|
||||
const rechnungsadresse = anschrift(kunde.address, kunde.name)
|
||||
const zahlungsart = zahlungsartVon(session)
|
||||
const email = kurz(kunde.email, 320)
|
||||
const abweichend = !Number.isInteger(session.amount_total) || session.amount_total !== order.summeCent
|
||||
if (abweichend) console.warn(`[shop] Bestellung ${orderId.slice(0, 36)}: gezahlter Betrag weicht vom Bestellwert ab (Stripe ${session.amount_total}, Bestellung ${order.summeCent})`)
|
||||
|
||||
const at = jetzt.toISOString()
|
||||
let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) })
|
||||
if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null })
|
||||
const neueDaten = {
|
||||
...daten,
|
||||
kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) },
|
||||
...(lieferadresse ? { lieferadresse } : {}),
|
||||
...(rechnungsadresse ? { rechnungsadresse } : {}),
|
||||
...(zahlungsart ? { zahlungsart } : {}),
|
||||
...(spaet ? { spaet: true } : {}),
|
||||
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 })
|
||||
let doc = await updateDocument(col(), orderId, felder)
|
||||
|
||||
const ueberverkauft = await zieheBestandAb(order.shopId, daten.positionen)
|
||||
if (ueberverkauft) {
|
||||
const mitHinweis = { ...neueDaten, ueberverkauft: true }
|
||||
doc = await updateDocument(col(), orderId, { bestellung: JSON.stringify(mitHinweis) }).catch(() => doc)
|
||||
}
|
||||
return { order: { ...doc, daten: bestellDaten(doc) }, neu: true, ueberverkauft, siteKey: shop.siteKey }
|
||||
})
|
||||
if (!ergebnis.neu) return { order: ergebnis.order, neu: false }
|
||||
|
||||
// Mails: ausserhalb der Sperre, Fehler zaehlen nicht
|
||||
const { order } = ergebnis
|
||||
try {
|
||||
leereCache(ergebnis.siteKey)
|
||||
const ctx = await shopPerSiteKey(ergebnis.siteKey, { now: jetzt })
|
||||
if (!ctx) throw new Error('Shop-Kontext nicht gefunden')
|
||||
await anKaeufer('G09', ctx, order, {}, jetzt)
|
||||
const k25 = haendlerK25(ctx, order, { ueberverkauft: ergebnis.ueberverkauft })
|
||||
await anHaendler('K25', ctx, k25.values, k25.blocks)
|
||||
} catch (err) {
|
||||
console.warn(`[shop] Mails zur bezahlten Bestellung ${orderId.slice(0, 36)} nicht verschickt: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||||
}
|
||||
return { order, neu: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft eine offene Bestellung (Status storniert, Bestand wird frei). Nur aus offen; sonst keine Wirkung.
|
||||
* @param {string} orderId
|
||||
* @param {'abgelaufen'|'zahlung_fehlgeschlagen'|string} grund Eintrag im Verlauf
|
||||
* @param {{ now?: Date, mail?: boolean, email?: string, accountId?: string }} [opt] mail: G14 an den Kaeufer (email oder kaeuferEmail);
|
||||
* accountId: Konto der Sitzung, muss zum Shop der Bestellung passen (Webhook)
|
||||
* @returns {Promise<{ order: object|null, verworfen: boolean }>}
|
||||
*/
|
||||
export async function verwirf(orderId, grund, { now = new Date(), mail = false, email, accountId } = {}) {
|
||||
const jetzt = new Date(now)
|
||||
const ergebnis = await withLocks([`shop-order:${orderId}`], async () => {
|
||||
const order = await bestellungLesen(orderId)
|
||||
if (!order) return { order: null, verworfen: false }
|
||||
if (accountId !== undefined) {
|
||||
const shop = await shopDokument(order.shopId)
|
||||
if (!shop || shop.stripeAccountId !== accountId) return { order: null, verworfen: false }
|
||||
}
|
||||
if (order.status !== 'offen') return { order, verworfen: false }
|
||||
const doc = await updateDocument(col(), orderId, {
|
||||
status: 'storniert',
|
||||
bestellung: JSON.stringify({ ...order.daten, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }),
|
||||
})
|
||||
return { order: { ...doc, daten: bestellDaten(doc) }, verworfen: true }
|
||||
})
|
||||
if (ergebnis.verworfen && mail) {
|
||||
const an = kurz(email, 320) || kurz(ergebnis.order.kaeuferEmail, 320)
|
||||
if (an) {
|
||||
try {
|
||||
const shop = await shopDokument(ergebnis.order.shopId)
|
||||
if (shop) {
|
||||
leereCache(shop.siteKey)
|
||||
const ctx = await shopPerSiteKey(shop.siteKey, { now: jetzt })
|
||||
if (ctx) await anKaeufer('G14', ctx, ergebnis.order, { an }, jetzt)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[shop] G14 zur Bestellung ${String(orderId).slice(0, 36)} nicht eingereiht: ${err?.name}: ${String(err?.message).slice(0, 120)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgleich der offenen Bestellungen, deren Frist (ablaufAt) seit mehr als 5 Minuten verstrichen ist (Minutenjob): Stripe fragen
|
||||
* und bezahlt abschliessen, verwerfen oder (noch offene Sitzung) beenden und verwerfen. Lesefehler: naechster Lauf.
|
||||
* @returns {Promise<{ geprueft: number, bezahlt: number, verworfen: number, wartend: number, fehler: number }>}
|
||||
*/
|
||||
export async function pruefeOffene(now = new Date()) {
|
||||
const grenze = new Date(now).getTime() - ABGLEICH_PUFFER_MS
|
||||
const zaehler = { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 }
|
||||
const shops = await listAllDocuments(config.collections.shopConfig, [])
|
||||
for (const shop of shops) {
|
||||
const offene = (await listAllDocuments(col(), [Query.equal('shopId', shop.$id), Query.equal('status', 'offen')])).filter(
|
||||
(d) => new Date(d.ablaufAt).getTime() < grenze
|
||||
)
|
||||
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 zaehler.wartend += 1 // SEPA unterwegs: async_payment_succeeded/failed entscheidet
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { pruefeOffene } from './bestellungen.js'
|
||||
import { shopModus } from './schalter.js'
|
||||
|
||||
/**
|
||||
* Jobs des Webshops (Plan 2026-10-05-webshop.md, E2; J3 ergaenzt den Tagesjob und die Registrierung in index.js).
|
||||
* Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten Takt) und wirft nie.
|
||||
*/
|
||||
|
||||
const laufend = new Set()
|
||||
|
||||
async function einzeln(name, arbeit) {
|
||||
if (laufend.has(name)) {
|
||||
console.warn(`[shop-jobs] ${name}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
|
||||
return { uebersprungen: true }
|
||||
}
|
||||
laufend.add(name)
|
||||
try {
|
||||
return await arbeit()
|
||||
} catch (err) {
|
||||
console.error(`[shop-jobs] ${name} fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 200)}`)
|
||||
return { fehler: true }
|
||||
} finally {
|
||||
laufend.delete(name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Minutenjob: offene Bestellungen mit verstrichener Frist bei Stripe abgleichen (bezahlt abschliessen oder verwerfen). Bei SHOP_ENABLED=off aus. */
|
||||
export async function minutenJob(now = new Date()) {
|
||||
if (shopModus() === 'off') return { aus: true }
|
||||
return einzeln('minutenJob', () => pruefeOffene(now))
|
||||
}
|
||||
Reference in new issue
Block a user