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:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:01:04 +00:00
1 parent 9de23c7fe2
commit bf99f659eb
4 files changed
+741 -4

No files matched your search

+431
View File
@@ -0,0 +1,431 @@
import { test, after, beforeEach, afterEach, mock } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { mailSink } from './support/mailSink.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { SHOP_DATETIMES } from '../server/services/shop/schema.js'
import { TERMINE_DATETIMES } from '../server/services/termine/schema.js'
// Webshop E2: Abschluss einer bezahlten Sitzung (schliesseAb), Verwerfen, Abgleich offener Bestellungen (pruefeOffene), Minutenjob.
// Mails landen in der Senke (Kaeufer ueber termineOutbox, Haendler direkt), nichts verlaesst den Prozess.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
shopConfig: schemaKeys('shopConfig'),
shopProducts: schemaKeys('shopProducts'),
shopOrders: schemaKeys('shopOrders'),
termineOutbox: schemaKeys('termineOutbox'),
},
datetimes: { ...SHOP_DATETIMES, ...TERMINE_DATETIMES },
defaultLimit: 25,
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js')
const K = await import('../server/services/shop/kontext.js')
const B = await import('../server/services/shop/bestellungen.js')
const J = await import('../server/services/shop/jobs.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')
after(async () => {
setMailTransportForTests(null)
await aw.close()
await st.close()
})
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 = {}) => ({
$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, eingabe = {}) => {
const r = normalisiereProdukt({ name: `Artikel ${id}`, preisCent: 1250, ...eingabe }, { 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) }
}
let sink
let log
let ctx
async function aufbau({ produkte = [artikel('a1', { bestand: 5 })], shop = {} } = {}) {
for (const c of ['portalFeatures', 'shopConfig', 'shopProducts', 'shopOrders', 'websiteProjects', 'termineOutbox', 'customers']) {
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' })
aw.seed('portalFeatures', featureDoc)
aw.seed('shopConfig', shopDoc(shop))
aw.seed('customers', { $id: KUNDE, name: 'Erika Muster', email: 'erika@brot.example' })
for (const p of produkte) aw.seed('shopProducts', p)
st.setAccount(ACCOUNT, { charges_enabled: true, details_submitted: true, requirements: { currently_due: [] } })
K.leereCache()
ctx = await K.shopPerSiteKey(SITE_KEY, { now: T0 })
return ctx
}
beforeEach(() => {
st.calls.length = 0
config.publicBaseUrl = PORTAL
config.smtp.from = 'noreply@webklar.com'
config.shop.mode = 'on'
resetOutboxForTests()
sink = mailSink()
setMailTransportForTests(sink)
log = { warn: mock.method(console, 'warn', () => {}), error: mock.method(console, 'error', () => {}) }
K.leereCache()
})
afterEach(async () => {
await warteAufVersand()
mock.restoreAll()
})
const orders = () => [...aw.docs('shopOrders').values()]
const order = (id) => aw.docs('shopOrders').get(id)
const produkt = (id) => JSON.parse(aw.docs('shopProducts').get(id).produkt)
const eintraege = () => [...aw.docs('termineOutbox').values()]
const warnLog = () => log.warn.mock.calls.map((a) => a.arguments.map(String).join(' ')).join('\n')
const kunde = { name: 'Max Mustermann', email: 'max@example.com', phone: '+49 170 1234567', address: { line1: 'Rechnungsweg 1', line2: null, postal_code: '12345', city: 'Musterstadt', country: 'DE' } }
const versandAn = { name: 'Max Mustermann', address: { line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE', state: null } }
/** Bestellung anlegen (offen) und die zugehoerige Stripe-Sitzung liefern. */
async function bestelle(positionen = [{ produktId: 'a1', menge: 2 }], over = {}) {
const r = await B.legeBestellungAn(ctx, { positionen, lieferart: 'versand', land: 'DE', ...over }, { now: T0 })
const doc = order(r.orderId)
return { ...r, doc, sessionId: doc.stripeSessionId }
}
/** Sitzung als bezahlt markieren und so zurueckgeben, wie der Webhook sie schickt. */
const zahle = (b, felder = {}) =>
st.completeSession(ACCOUNT, b.sessionId, {
payment_status: 'paid',
amount_total: b.doc.summeCent,
customer_details: kunde,
shipping: versandAn,
payment_intent: 'pi_test1',
payment_method_types: ['card'],
...felder,
})
test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an den Käufer und K25 an den Händler ohne BCC', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
const erg = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(120) })
assert.equal(erg.neu, true)
assert.equal(erg.order.status, 'bezahlt')
const doc = order(b.orderId)
assert.equal(doc.status, 'bezahlt')
assert.equal(new Date(doc.bezahltAt).toISOString(), nach(120).toISOString())
assert.equal(doc.paymentIntentId, 'pi_test1')
assert.equal(doc.kaeuferEmail, 'max@example.com')
const daten = JSON.parse(doc.bestellung)
assert.deepEqual(daten.kaeufer, { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' })
assert.deepEqual(daten.lieferadresse, { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: 'Hinterhaus', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' })
assert.equal(daten.rechnungsadresse.line1, 'Rechnungsweg 1')
assert.equal(daten.zahlungsart, 'card')
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'bezahlt'])
assert.equal(daten.ueberverkauft, undefined)
assert.equal(produkt('a1').bestand, 3)
await warteAufVersand()
const [g09] = eintraege()
assert.equal(eintraege().length, 1)
assert.equal(g09.art, 'G09')
assert.equal(g09.bookingId, b.orderId)
assert.equal(g09.featureId, FEATURE)
const mails = sink.sent
assert.equal(mails.length, 2)
const kaeufer = mails.find((m) => m.to[0].address === 'max@example.com')
const haendler = mails.find((m) => m.to[0].address === 'erika@brot.example')
assert.ok(kaeufer && haendler)
assert.match(kaeufer.subject, new RegExp(`Ihre Bestellung ${doc.bestellnummer} bei Brot & Mehr`))
assert.ok(kaeufer.text.includes('Lieferweg 7') && kaeufer.text.includes('Hinterhaus'))
assert.match(haendler.text, /Max Mustermann/)
assert.ok(!haendler.bcc && !kaeufer.bcc, 'kein BCC')
assert.doesNotMatch(haendler.text, /Bestand reichte nicht/)
assert.doesNotMatch(haendler.text, /Lastschrift/)
assert.equal(order(b.orderId).status, 'bezahlt')
})
test('schliesseAb: idempotent - zweiter Aufruf ändert nichts, keine zweite Mail, Bestand nur einmal', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
const [a, z] = await Promise.all([B.schliesseAb({ accountId: ACCOUNT, session, now: nach(60) }), B.schliesseAb({ accountId: ACCOUNT, session, now: nach(61) })])
assert.deepEqual([a.neu, z.neu].sort(), [false, true])
const dritte = await B.schliesseAb({ accountId: ACCOUNT, session, now: nach(300) })
assert.equal(dritte.neu, false)
assert.equal(dritte.order.status, 'bezahlt')
await warteAufVersand()
assert.equal(eintraege().length, 1)
assert.equal(sink.sent.length, 2)
assert.equal(produkt('a1').bestand, 3)
assert.equal(JSON.parse(order(b.orderId).bestellung).verlauf.filter((v) => v.was === 'bezahlt').length, 1)
})
test('schliesseAb: falsches Konto, fremde oder unbekannte Sitzung ändern nichts', async () => {
await aufbau()
const b = await bestelle()
const session = zahle(b)
assert.deepEqual(await B.schliesseAb({ accountId: 'acct_fremd', session, now: nach(60) }), { order: null, neu: false })
assert.match(warnLog(), /gehört nicht zum Stripe-Konto/)
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { ...session.metadata, wkShop: undefined } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1', orderId: 'gibt-es-nicht' } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: { ...session, metadata: { wkShop: '1' } }, now: nach(60) }), { order: null, neu: false })
assert.deepEqual(await B.schliesseAb({ accountId: ACCOUNT, session: null, now: nach(60) }), { order: null, neu: false })
assert.equal(order(b.orderId).status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
})
test('schliesseAb: SEPA noch nicht bezahlt ist wirkungslos, bis async_payment_succeeded kommt; SEPA-Hinweis in K25', async () => {
await aufbau()
const b = await bestelle()
const unbezahlt = zahle(b, { payment_status: 'unpaid', payment_method_types: ['sepa_debit'] })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: unbezahlt, now: nach(60) })
assert.equal(erg.neu, false)
assert.equal(erg.order.status, 'offen')
assert.equal(produkt('a1').bestand, 5)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
const bezahlt = zahle(b, { payment_status: 'paid', payment_method_types: ['sepa_debit'] })
const spaeter = await B.schliesseAb({ accountId: ACCOUNT, session: bezahlt, now: nach(3 * 86400) })
assert.equal(spaeter.neu, true)
assert.equal(JSON.parse(order(b.orderId).bestellung).zahlungsart, 'sepa_debit')
await warteAufVersand()
assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Lastschrift/)
})
test('schliesseAb: späte Zahlung nach Ablauf wird angenommen, andere Stornos nicht', async () => {
await aufbau()
const a = await bestelle()
assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(1900) })).verworfen, true)
assert.equal(order(a.orderId).status, 'storniert')
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(a), now: nach(2000) })
assert.equal(erg.neu, true)
assert.equal(order(a.orderId).status, 'bezahlt')
const daten = JSON.parse(order(a.orderId).bestellung)
assert.equal(daten.spaet, true)
assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen', 'bezahlt'])
assert.equal(produkt('a1').bestand, 3)
const c = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(c.orderId, 'zahlung_fehlgeschlagen', { now: nach(10) })).verworfen, true)
const nein = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(c), now: nach(20) })
assert.equal(nein.neu, false)
assert.equal(order(c.orderId).status, 'storniert')
assert.equal(produkt('a1').bestand, 3)
})
test('schliesseAb: abweichender Betrag - trotzdem bezahlt, Vermerk im Verlauf, Warnung ohne Käuferdaten', async () => {
await aufbau()
const b = await bestelle()
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { amount_total: b.doc.summeCent + 100 }), now: nach(60) })
assert.equal(erg.neu, true)
const daten = JSON.parse(order(b.orderId).bestellung)
const vermerk = daten.verlauf.find((v) => v.was === 'betrag_abweichend')
assert.deepEqual({ e: vermerk.erwartetCent, b: vermerk.bezahltCent }, { e: b.doc.summeCent, b: b.doc.summeCent + 100 })
assert.match(warnLog(), /weicht vom Bestellwert ab/)
assert.doesNotMatch(warnLog(), /max@example|Mustermann|Lieferweg/)
})
test('schliesseAb: Variantenbestand wird in der Variante abgezogen, Artikel ohne Bestand bleiben unberührt', async () => {
await aufbau({
produkte: [
artikel('a1', { bestand: 10, varianten: { name: 'Größe', werte: [{ name: 'M', preisCent: 2000, bestand: 4 }, { name: 'L', preisCent: 2200, bestand: 6 }] } }),
artikel('a2'),
],
})
const werte = produkt('a1').varianten.werte
const L = werte.find((w) => w.name === 'L')
const M = werte.find((w) => w.name === 'M')
const b = await bestelle([{ produktId: 'a1', varianteId: L.id, menge: 2 }, { produktId: 'a1', varianteId: M.id, menge: 1 }, { produktId: 'a2', menge: 3 }])
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
const danach = produkt('a1')
assert.equal(danach.varianten.werte.find((w) => w.name === 'L').bestand, 4)
assert.equal(danach.varianten.werte.find((w) => w.name === 'M').bestand, 3)
assert.equal(danach.bestand, null, 'bei Varianten führen die Varianten den Bestand')
assert.equal(produkt('a2').bestand, null)
})
test('schliesseAb: Bestand reicht nicht - auf 0 gesetzt, Händler-Hinweis ueberverkauft in K25', async () => {
await aufbau()
const b = await bestelle([{ produktId: 'a1', menge: 4 }])
// Der Haendler hat den Bestand inzwischen verringert
const p = produkt('a1')
p.bestand = 1
await updateDocument(config.collections.shopProducts, 'a1', { produkt: JSON.stringify(p) })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
assert.equal(erg.neu, true)
assert.equal(produkt('a1').bestand, 0)
assert.equal(JSON.parse(order(b.orderId).bestellung).ueberverkauft, true)
await warteAufVersand()
assert.match(sink.sent.find((m) => m.to[0].address === 'erika@brot.example').text, /Bestand reichte nicht/)
assert.equal(order(b.orderId).status, 'bezahlt')
})
test('schliesseAb: Mailfehler machen die Zahlung nicht rückgängig', async () => {
await aufbau()
const b = await bestelle()
aw.docs('customers').clear()
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
assert.equal(erg.neu, true)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.equal(produkt('a1').bestand, 3)
})
test('schliesseAb: Abholung ohne Lieferadresse, Telefon und Namen fehlen', async () => {
await aufbau()
const b = await bestelle([{ produktId: 'a1', menge: 1 }], { lieferart: 'abholung', land: undefined })
const erg = await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b, { shipping: null, customer_details: { email: 'x@example.com' } }), now: nach(60) })
assert.equal(erg.neu, true)
const daten = JSON.parse(order(b.orderId).bestellung)
assert.equal(daten.lieferadresse, undefined)
assert.equal(daten.rechnungsadresse, undefined)
assert.deepEqual(daten.kaeufer, { name: '', email: 'x@example.com', telefon: '' })
})
test('verwirf: nur aus offen; Verlauf, Reservierung frei; G14 nur mit mail und bekannter Adresse', async () => {
await aufbau()
const a = await bestelle()
assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), 2)
const erg = await B.verwirf(a.orderId, 'abgelaufen', { now: nach(10) })
assert.equal(erg.verworfen, true)
assert.equal(erg.order.status, 'storniert')
assert.equal(JSON.parse(order(a.orderId).bestellung).verlauf.at(-1).was, 'abgelaufen')
assert.equal((await B.reserviert(FEATURE, nach(10))).get('a1'), undefined)
assert.equal((await B.verwirf(a.orderId, 'abgelaufen', { now: nach(11) })).verworfen, false, 'zweites Mal wirkungslos')
assert.equal((await B.verwirf('gibt-es-nicht', 'abgelaufen')).order, null)
await warteAufVersand()
assert.equal(sink.sent.length, 0)
// bezahlt wird nie verworfen
const bz = await bestelle()
await B.schliesseAb({ accountId: ACCOUNT, session: zahle(bz), now: nach(20) })
assert.equal((await B.verwirf(bz.orderId, 'abgelaufen', { now: nach(30) })).verworfen, false)
assert.equal(order(bz.orderId).status, 'bezahlt')
// falsches Konto
const f = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(f.orderId, 'abgelaufen', { accountId: 'acct_fremd' })).verworfen, false)
assert.equal(order(f.orderId).status, 'offen')
// G14 mit der Adresse aus der Sitzung
sink.sent.length = 0
const g = await bestelle([{ produktId: 'a1', menge: 1 }])
const v = await B.verwirf(g.orderId, 'zahlung_fehlgeschlagen', { now: nach(40), mail: true, email: 'max@example.com', accountId: ACCOUNT })
assert.equal(v.verworfen, true)
await warteAufVersand()
const g14 = eintraege().filter((e) => e.art === 'G14')
assert.equal(g14.length, 1)
assert.equal(sink.sent.length, 1)
assert.equal(sink.sent[0].to[0].address, 'max@example.com')
// ohne Adresse keine Mail, kein Fehler
const h = await bestelle([{ produktId: 'a1', menge: 1 }])
assert.equal((await B.verwirf(h.orderId, 'zahlung_fehlgeschlagen', { mail: true })).verworfen, true)
await warteAufVersand()
assert.equal(eintraege().filter((e) => e.art === 'G14').length, 1)
})
test('pruefeOffene: bezahlt abschließen, abgelaufene und noch offene Sitzungen verwerfen, Lesefehler und junge Bestellungen überspringen', async () => {
await aufbau({ produkte: [artikel('a1', { bestand: 20 })] })
const bezahlt = await bestelle([{ produktId: 'a1', menge: 1 }])
const abgelaufen = await bestelle([{ produktId: 'a1', menge: 1 }])
const offen = await bestelle([{ produktId: 'a1', menge: 1 }])
const sepa = await bestelle([{ produktId: 'a1', menge: 1 }])
const fehler = await bestelle([{ produktId: 'a1', menge: 1 }])
zahle(bezahlt)
st.accountSessions(ACCOUNT).set(abgelaufen.sessionId, { ...st.accountSessions(ACCOUNT).get(abgelaufen.sessionId), status: 'expired' })
zahle(sepa, { payment_status: 'unpaid' })
const spaet = nach(30 * 60 + 30 + 6 * 60) // Frist + 6 Minuten
const jung = await (async () => {
const x = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: nach(10 * 60) })
return x
})()
st.failNext({ method: 'GET', path: `/v1/checkout/sessions/${fehler.sessionId}`, status: 400 })
st.calls.length = 0
const z = await B.pruefeOffene(spaet)
assert.deepEqual(z, { geprueft: 5, bezahlt: 1, verworfen: 2, wartend: 1, fehler: 1 })
assert.equal(order(bezahlt.orderId).status, 'bezahlt')
assert.equal(order(abgelaufen.orderId).status, 'storniert')
assert.equal(order(offen.orderId).status, 'storniert')
assert.ok(st.calls.some((c) => c.method === 'POST' && c.path === `/v1/checkout/sessions/${offen.sessionId}/expire` && c.account === ACCOUNT))
assert.equal(st.calls.some((c) => c.path.endsWith('/expire') && c.path.includes(abgelaufen.sessionId)), false, 'bereits abgelaufene Sitzung wird nicht erneut beendet')
assert.equal(order(sepa.orderId).status, 'offen', 'SEPA unterwegs bleibt offen')
assert.equal(order(fehler.orderId).status, 'offen', 'Lesefehler: nächster Lauf')
assert.equal(order(jung.orderId).status, 'offen', 'Frist noch nicht um mehr als 5 Minuten verstrichen')
assert.match(warnLog(), /Abgleich der Bestellung/)
assert.equal(produkt('a1').bestand, 19)
// Nächster Lauf: der Lesefehler ist weg
const z2 = await B.pruefeOffene(spaet)
assert.deepEqual({ ...z2 }, { geprueft: 2, bezahlt: 0, verworfen: 1, wartend: 1, fehler: 0 })
assert.equal(order(fehler.orderId).status, 'storniert')
})
test('pruefeOffene: Bestellung ohne gespeicherte Sitzung wird verworfen; ohne offene Bestellungen passiert nichts', async () => {
await aufbau()
assert.deepEqual(await B.pruefeOffene(nach(7200)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 })
const b = await bestelle()
await updateDocument(config.collections.shopOrders, b.orderId, { stripeSessionId: '' })
const z = await B.pruefeOffene(nach(7200))
assert.equal(z.verworfen, 1)
assert.equal(order(b.orderId).status, 'storniert')
})
test('minutenJob: bei SHOP_ENABLED=off aus, sonst Abgleich; läuft nicht doppelt gleichzeitig', async () => {
await aufbau()
const b = await bestelle()
zahle(b)
config.shop.mode = 'off'
assert.deepEqual(await J.minutenJob(nach(7200)), { aus: true })
assert.equal(order(b.orderId).status, 'offen')
config.shop.mode = 'pilot'
const [erster, zweiter] = await Promise.all([J.minutenJob(nach(7200)), J.minutenJob(nach(7200))])
assert.ok(erster.bezahlt === 1 || zweiter.bezahlt === 1)
assert.ok(erster.uebersprungen === true || zweiter.uebersprungen === true)
assert.equal(order(b.orderId).status, 'bezahlt')
assert.deepEqual(await J.minutenJob(nach(7300)), { geprueft: 0, bezahlt: 0, verworfen: 0, wartend: 0, fehler: 0 })
})
+23
View File
@@ -805,6 +805,29 @@ export async function startFakeStripe() {
accounts.set(id, next)
return next
},
/**
* Sitzung eines Haendlerkontos als abgeschlossen markieren (status complete) und die Felder setzen, die Stripe nach der Kasse
* liefert: completeSession('acct_x', 'cs_x', { payment_status: 'paid', amount_total, customer_details, shipping, payment_intent,
* payment_method_types }). shipping steht wie bei Stripe unter collected_information.shipping_details und shipping_details.
* Gibt die neue Sitzung zurueck (so schickt der Webhook sie).
*/
completeSession(accountId, id, { payment_status = 'paid', amount_total = null, customer_details = null, shipping = null, payment_intent = null, payment_method_types } = {}) {
const sessions = bucket(accountSessionMaps, accountId)
const found = sessions.get(id)
if (!found) throw new Error(`Stripe-Nachbau: keine Sitzung ${id} bei ${accountId}`)
const next = {
...found,
status: 'complete',
payment_status,
amount_total,
customer_details,
payment_intent,
...(payment_method_types ? { payment_method_types } : {}),
...(shipping ? { collected_information: { shipping_details: shipping }, shipping_details: shipping } : {}),
}
sessions.set(id, next)
return next
},
/** Zahlung eines Haendlerkontos (Betrag in Cent) fuer Erstattungen: setPaymentIntent('acct_x', 'pi_x', { amount: 2500 }). */
setPaymentIntent(accountId, id, { amount, currency = 'eur' }) {
const obj = { id, object: 'payment_intent', amount, currency }