diff --git a/public/shop-tab-bestellungen.js b/public/shop-tab-bestellungen.js
index da88d91..1493627 100644
--- a/public/shop-tab-bestellungen.js
+++ b/public/shop-tab-bestellungen.js
@@ -290,7 +290,6 @@ export function render(panel, ctx) {
teile.push(abschnitt('Käufer und Lieferung', null, fakten(
['Name', zeichen(kaeufer.name)],
['E-Mail', mailLink(zeichen(kaeufer.email))],
- zeichen(kaeufer.telefon) ? ['Telefon', kaeufer.telefon] : null,
['Lieferart', `${lieferartText(b.lieferart)}${b.lieferart === 'versand' && zeichen(b.land) ? ` (${b.land})` : ''}`],
lieferung,
b.rechnungsadresse && adresseText(b.rechnungsadresse) !== adresseText(b.lieferadresse) ? ['Rechnungsadresse', adresseText(b.rechnungsadresse)] : null,
diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js
index 157aa6b..96e7a89 100644
--- a/server/services/shop/bestellungen.js
+++ b/server/services/shop/bestellungen.js
@@ -20,8 +20,9 @@ import { holeProdukt, listeProdukte, parseProdukt } 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 im selben JSON: kaeufer { name, email, telefon }, lieferadresse / rechnungsadresse { name?, line1, line2, postal_code,
+ * E2 ergaenzt im selben JSON: kaeufer { name, email } (keine Telefonnummer), lieferadresse / rechnungsadresse { name?, line1, line2, postal_code,
* city, country }, zahlungsart ('card' | 'klarna' | 'sepa_debit', nur wenn eindeutig), ueberverkauft, spaet; E3 erstattungen[]. Status offen reserviert Bestand bis ablaufAt.
+ * ipHash steht nur, solange die Bestellung offen ist (Begrenzung offener Bestellungen je IP); schliesseAb und verwirf entfernen ihn.
* Fehler: Error mit status, code und Kaeufertext (Sie-Form) in message.
*/
@@ -565,9 +566,11 @@ export async function schliesseAb({ accountId, session, now = new Date() }) {
const at = jetzt.toISOString()
let verlauf = verlaufMit(daten, { at, was: 'bezahlt', ...(spaet ? { spaet: true } : {}) })
if (abweichend) verlauf = verlaufMit({ verlauf }, { at, was: 'betrag_abweichend', erwartetCent: order.summeCent, bezahltCent: Number.isInteger(session.amount_total) ? session.amount_total : null })
+ // Datenminimierung: Der IP-Hash dient nur der Begrenzung offener Bestellungen und entfaellt mit der Zahlung; eine Telefonnummer wird nie gespeichert
+ const { ipHash: _ipHash, ...datenOhneIp } = daten
const neueDaten = {
- ...daten,
- kaeufer: { name: kurz(kunde.name, 200), email, telefon: kurz(kunde.phone, 50) },
+ ...datenOhneIp,
+ kaeufer: { name: kurz(kunde.name, 200), email },
...(lieferadresse ? { lieferadresse } : {}),
...(rechnungsadresse ? { rechnungsadresse } : {}),
...(zahlungsart ? { zahlungsart } : {}),
@@ -663,9 +666,11 @@ export async function verwirf(orderId, grund, { now = new Date(), mail = false,
if (!shop || shop.stripeAccountId !== accountId) return { order: null, verworfen: false }
}
if (order.status !== 'offen') return { order, verworfen: false }
+ // Der IP-Hash dient nur der Begrenzung offener Bestellungen und entfaellt, sobald die Bestellung nicht mehr offen ist
+ const { ipHash: _ipHash, ...datenOhneIp } = order.daten
const doc = await updateDocument(col(), orderId, {
status: 'storniert',
- bestellung: JSON.stringify({ ...order.daten, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }),
+ bestellung: JSON.stringify({ ...datenOhneIp, verlauf: verlaufMit(order.daten, { at: jetzt.toISOString(), was: String(grund).slice(0, 40) }) }),
})
return { order: { ...doc, daten: bestellDaten(doc) }, verworfen: true }
})
@@ -1037,7 +1042,8 @@ export function bestellungKunde(order) {
erstattetText: euro(erstattet),
restCent: Math.max(0, summeCent - erstattet),
restText: euro(Math.max(0, summeCent - erstattet)),
- kaeufer: istObjekt(daten.kaeufer) ? daten.kaeufer : { name: '', email: order?.kaeuferEmail ?? '', telefon: '' },
+ // Alte Datensaetze koennen noch eine Telefonnummer enthalten: nie ausgeben
+ kaeufer: istObjekt(daten.kaeufer) ? { name: daten.kaeufer.name ?? '', email: daten.kaeufer.email ?? '' } : { name: '', email: order?.kaeuferEmail ?? '' },
lieferart: daten.lieferart ?? '',
land: daten.land ?? '',
lieferadresse: istObjekt(daten.lieferadresse) ? daten.lieferadresse : null,
diff --git a/server/services/shop/jobs.js b/server/services/shop/jobs.js
index 653b1eb..02a1d79 100644
--- a/server/services/shop/jobs.js
+++ b/server/services/shop/jobs.js
@@ -161,7 +161,7 @@ async function anonymisiereBestellungen(lauf, shopId, grenze) {
delete daten.lieferadresse
delete daten.rechnungsadresse
delete daten.ipHash
- daten.kaeufer = { name: '', email: '', telefon: '' }
+ daten.kaeufer = { name: '', email: '' }
await updateDocument(sammlung, doc.$id, {
kaeuferEmail: '',
bestellung: JSON.stringify(daten),
diff --git a/server/services/shop/mails.js b/server/services/shop/mails.js
index 1a5442d..aa873ab 100644
--- a/server/services/shop/mails.js
+++ b/server/services/shop/mails.js
@@ -30,7 +30,7 @@ import { gewaehrleistungsHinweis } from './rechtstexte.js'
* snap.positionen[] { name, varianteText, menge, summeCent, summeText?, widerruf ('gesetzlich'|'verderblich'|'individuell'|'hygiene') }
* snap.summen | snap { zwischensummeCent, versandCent, gesamtCent, texte?: { zwischensumme, versand, gesamt, steuerHinweis } }
* snap.lieferart ('versand'|'abholung'), snap.land ('DE'|'AT')
- * snap.kaeufer { name, email, telefon } (oder snap.name)
+ * snap.kaeufer { name, email } (oder snap.name)
* snap.lieferadresse { name?, line1|strasse, line2?, postal_code|plz, city|ort, country|land }
* snap.zahlungsart 'card' | 'klarna' | 'sepa_debit' | Text
* snap.einstellungenAuszug { anbieter, lieferzeit, abholung } (sonst die aktuellen Einstellungen des Shops)
diff --git a/test/shopAbschluss.test.js b/test/shopAbschluss.test.js
index dd82910..7516f73 100644
--- a/test/shopAbschluss.test.js
+++ b/test/shopAbschluss.test.js
@@ -159,7 +159,8 @@ test('schliesseAb: Normalfall - bezahlt, Käuferdaten, Bestand abgezogen, G09 an
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.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'Stripe liefert eine Telefonnummer, gespeichert wird sie nicht')
+ assert.ok(!doc.bestellung.includes('1234567'), 'keine Telefonnummer irgendwo im Bestell-JSON')
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')
@@ -397,7 +398,39 @@ test('schliesseAb: Abholung ohne Lieferadresse, Telefon und Namen fehlen', async
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: '' })
+ assert.deepEqual(daten.kaeufer, { name: '', email: 'x@example.com' })
+})
+
+test('IP-Hash: nach der Zahlung weg (auch bei später Zahlung einer verworfenen Bestellung), vorher da', async () => {
+ await aufbau()
+ const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
+ const b = { ...r, doc: order(r.orderId), sessionId: order(r.orderId).stripeSessionId }
+ assert.equal(JSON.parse(b.doc.bestellung).ipHash, 'abcdef0123456789', 'solange offen, steht der IP-Hash im Datensatz')
+ await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b), now: nach(60) })
+ assert.equal(order(b.orderId).status, 'bezahlt')
+ assert.equal(JSON.parse(order(b.orderId).bestellung).ipHash, undefined, 'bezahlt: kein IP-Hash mehr')
+ assert.ok(!order(b.orderId).bestellung.includes('ipHash'))
+
+ // spaete Zahlung: verworfen (IP-Hash weg), danach bezahlt - bleibt ohne IP-Hash
+ const r2 = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
+ const b2 = { ...r2, doc: order(r2.orderId), sessionId: order(r2.orderId).stripeSessionId }
+ await B.verwirf(r2.orderId, 'abgelaufen', { now: nach(1900) })
+ await B.schliesseAb({ accountId: ACCOUNT, session: zahle(b2), now: nach(2000) })
+ assert.equal(order(r2.orderId).status, 'bezahlt')
+ assert.ok(!order(r2.orderId).bestellung.includes('ipHash'))
+})
+
+test('IP-Hash: verwirf entfernt ihn, Verlauf und übrige Daten bleiben', async () => {
+ await aufbau()
+ const r = await B.legeBestellungAn(ctx, { positionen: [{ produktId: 'a1', menge: 1 }], lieferart: 'versand', land: 'DE' }, { now: T0, ipHash: 'abcdef0123456789' })
+ assert.equal(JSON.parse(order(r.orderId).bestellung).ipHash, 'abcdef0123456789')
+ const erg = await B.verwirf(r.orderId, 'abgelaufen', { now: nach(1900) })
+ assert.equal(erg.verworfen, true)
+ const daten = JSON.parse(order(r.orderId).bestellung)
+ assert.equal(daten.ipHash, undefined)
+ assert.equal(erg.order.daten.ipHash, undefined)
+ assert.deepEqual(daten.verlauf.map((v) => v.was), ['angelegt', 'abgelaufen'])
+ assert.equal(daten.positionen.length, 1)
})
test('verwirf: nur aus offen; Verlauf, Reservierung frei; G14 nur mit mail und bekannter Adresse', async () => {
diff --git a/test/shopBestellAktionen.test.js b/test/shopBestellAktionen.test.js
index 006d981..912ff4f 100644
--- a/test/shopBestellAktionen.test.js
+++ b/test/shopBestellAktionen.test.js
@@ -145,7 +145,7 @@ function bestellung(over = {}, datenOver = {}) {
shopname: 'Brot & Mehr',
lieferzeit: '2–4 Werktage',
zahlungsarten: ['card'],
- kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' },
+ kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '+49 170 1234567' }, // alter Datensatz mit Telefonnummer: wird nie ausgegeben
lieferadresse: lieferart === 'versand' ? { name: 'Max Mustermann', line1: 'Lieferweg 7', line2: '', postal_code: '67655', city: 'Kaiserslautern', country: 'DE' } : undefined,
zahlungsart: 'card',
verlauf: [{ at: T0.toISOString(), was: 'angelegt' }, { at: nach(60).toISOString(), was: 'bezahlt' }],
@@ -355,7 +355,7 @@ test('erstatte: Stripe-Fehler => 502 mit festem Text ohne Stripe-Wortlaut, nicht
test('listeBestellungen: neueste zuerst, ohne Filter nur bezahlte, Statusfilter, Suche (Nummer-Anfang, Name, E-Mail), Blättern mit 50 je Seite', async () => {
const ids = []
- for (let i = 0; i < 55; i += 1) ids.push(bestellung({ kaeuferEmail: `k${i}@example.com` }, { kaeufer: { name: i === 3 ? 'Erika Beispiel' : `Kunde ${i}`, email: `k${i}@example.com`, telefon: '' } }))
+ for (let i = 0; i < 55; i += 1) ids.push(bestellung({ kaeuferEmail: `k${i}@example.com` }, { kaeufer: { name: i === 3 ? 'Erika Beispiel' : `Kunde ${i}`, email: `k${i}@example.com` } }))
const offen = bestellung({ status: 'offen' })
const storniert = bestellung({ status: 'storniert' })
const versendet = bestellung({ status: 'versendet' })
@@ -390,6 +390,8 @@ test('bestellungKunde: alle Händlerdaten, keine Token-/Stripe-Kennungen, mögli
const id = bestellung({}, { erstattungen: [{ rid: 're_1', betragCent: 190, grund: 'Kulanz', at: nach(9).toISOString(), quelle: 'portal' }], ueberverkauft: true })
const ansicht = B.bestellungKunde(await B.bestellungLesen(id))
assert.equal(ansicht.kaeufer.email, 'max@example.com')
+ assert.deepEqual(ansicht.kaeufer, { name: 'Max Mustermann', email: 'max@example.com' }, 'eine alte Telefonnummer im Datensatz wird nicht ausgegeben')
+ assert.ok(!JSON.stringify(ansicht).includes('1234567'))
assert.equal(ansicht.lieferadresse.line1, 'Lieferweg 7')
assert.equal(ansicht.erstattetCent, 190)
assert.equal(ansicht.restCent, 1500)
@@ -407,10 +409,10 @@ test('bestellungKunde: alle Händlerdaten, keine Token-/Stripe-Kennungen, mögli
// ------------------------------------------------------------------------------------------------ CSV
test('csv: BOM, Semikolon, Kopfzeile, Beträge ohne NBSP, Berliner Datum, Formel-Injektion entschärft, Anführungszeichen verdoppelt', async () => {
- const a = bestellung({ bezahltAt: '2026-10-04T22:30:00.000Z', summeCent: 1250, kaeuferEmail: '+evil@example.com' }, { kaeufer: { name: '=HYPERLINK("http://x.example";"a")', email: '+evil@example.com', telefon: '' } })
+ const a = bestellung({ bezahltAt: '2026-10-04T22:30:00.000Z', summeCent: 1250, kaeuferEmail: '+evil@example.com' }, { kaeufer: { name: '=HYPERLINK("http://x.example";"a")', email: '+evil@example.com' } })
const b = bestellung(
{ bezahltAt: '2026-10-06T08:00:00.000Z', status: 'erstattet', kaeuferEmail: 'anna@example.com' },
- { kaeufer: { name: 'Anna; "Annie" Schmidt', email: 'anna@example.com', telefon: '' }, erstattungen: [{ rid: 're_x', betragCent: 1690, grund: '', at: T0.toISOString(), quelle: 'portal' }] }
+ { kaeufer: { name: 'Anna; "Annie" Schmidt', email: 'anna@example.com' }, erstattungen: [{ rid: 're_x', betragCent: 1690, grund: '', at: T0.toISOString(), quelle: 'portal' }] }
)
bestellung({ status: 'offen' })
bestellung({ status: 'storniert' })
@@ -551,7 +553,7 @@ test('Routen: Kundenansicht darf lesen (Liste, Detail, CSV, Widerrufe), aber wed
})
test('Routen: CSV als Download mit BOM, Dateiname und Zeitraum; ungültiges Datum 400', async () => {
- bestellung({ bezahltAt: '2026-10-05T10:00:00.000Z' }, { kaeufer: { name: '@SUM(A1)', email: 'a@example.com', telefon: '' } })
+ bestellung({ bezahltAt: '2026-10-05T10:00:00.000Z' }, { kaeufer: { name: '@SUM(A1)', email: 'a@example.com' } })
bestellung({ bezahltAt: '2026-09-01T10:00:00.000Z' })
const res = await fetch(`${app.url}/api/shop/bestellungen.csv?projectId=${PROJEKT}&von=2026-10-01&bis=2026-10-31`, { headers: { cookie } })
assert.equal(res.status, 200)
diff --git a/test/shopBestellungen.test.js b/test/shopBestellungen.test.js
index c3cf583..6e6b6c8 100644
--- a/test/shopBestellungen.test.js
+++ b/test/shopBestellungen.test.js
@@ -323,6 +323,25 @@ test('Reservierung: der letzte Artikel geht nur einmal weg, abgelaufene Reservie
assert.notEqual(zweite.orderId, erste.orderId)
})
+test('Begrenzung offener Bestellungen je IP-Hash: drei offen in 30 Minuten, danach 429; wieder frei, sobald eine nicht mehr offen ist', async () => {
+ const ctx = await aufbau({ produkte: [artikel('a1', { bestand: 50 })] })
+ const mit = (ip, now) => B.legeBestellungAn(ctx, eingabe({ positionen: [{ produktId: 'a1', menge: 1 }] }), { now, ipHash: ip })
+ const ids = []
+ for (let i = 0; i < 3; i += 1) ids.push((await mit('aaaaaaaaaaaaaaaa', nach(i))).orderId)
+ assert.ok(orders().every((o) => JSON.parse(o.bestellung).ipHash === 'aaaaaaaaaaaaaaaa'), 'offen: IP-Hash steht im Datensatz')
+ const err = await mit('aaaaaaaaaaaaaaaa', nach(10)).catch((e) => e)
+ assert.equal(err.status, 429)
+ assert.equal(err.code, 'zu_viele_offen')
+ assert.ok((await mit('bbbbbbbbbbbbbbbb', nach(11))).orderId, 'andere IP unberuehrt')
+
+ // Eine verworfene Bestellung traegt keinen IP-Hash mehr und zaehlt nicht mehr
+ assert.equal((await B.verwirf(ids[0], 'zahlung_fehlgeschlagen', { now: nach(20) })).verworfen, true)
+ assert.equal(JSON.parse(orders().find((o) => o.$id === ids[0]).bestellung).ipHash, undefined)
+ assert.ok((await mit('aaaaaaaaaaaaaaaa', nach(21))).orderId, 'nach verwirf wieder eine offene moeglich')
+ const err2 = await mit('aaaaaaaaaaaaaaaa', nach(22)).catch((e) => e)
+ assert.equal(err2.code, 'zu_viele_offen')
+})
+
test('reserviert: nur offene Bestellungen dieses Shops, Varianten mit Doppelpunkt-Schluessel, Mengen summiert', async () => {
await aufbau()
const ablauf = nach(1800).toISOString()
diff --git a/test/shopJobs.test.js b/test/shopJobs.test.js
index 2f57c23..5747e1d 100644
--- a/test/shopJobs.test.js
+++ b/test/shopJobs.test.js
@@ -47,7 +47,7 @@ const shop = (shopId, over = {}) => aw.seed('shopConfig', { $id: shopId, custome
const feature = (shopId, over = {}) =>
aw.seed('portalFeatures', { $id: shopId, customerId: 'cs1', projectId: 'p1', featureKey: 'shop', siteKey: KEY, state: 'active', enabled: true, ...over })
const PERSON = {
- kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '0123' },
+ kaeufer: { name: 'Max Mustermann', email: 'max@example.com', telefon: '0123' }, // alter Datensatz: Telefonnummer aus der Zeit vor der Datenminimierung
lieferadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' },
rechnungsadresse: { line1: 'Hauptstr. 1', postal_code: '67722', city: 'Winnweiler', country: 'DE' },
}
@@ -123,7 +123,7 @@ test('Bestellung aus 2026 wird genau am 1. Januar 2030 (Berlin) anonymisiert, ni
assert.ok(doc.anonymisiertAt)
const d = daten(o)
assert.equal(d.ipHash, undefined, 'der IP-Hash wird mit anonymisiert')
- assert.deepEqual(d.kaeufer, { name: '', email: '', telefon: '' })
+ assert.deepEqual(d.kaeufer, { name: '', email: '' }, 'auch eine alte Telefonnummer ist weg')
assert.equal('lieferadresse' in d, false)
assert.equal('rechnungsadresse' in d, false)
assert.equal(d.positionen[0].name, 'Tasse', 'Positionen bleiben')
diff --git a/test/shopTabBestellungen.test.js b/test/shopTabBestellungen.test.js
index 219cb63..52ad71e 100644
--- a/test/shopTabBestellungen.test.js
+++ b/test/shopTabBestellungen.test.js
@@ -17,7 +17,7 @@ const liste = (zeilen, extra = {}) => ({ bestellungen: zeilen, seite: 1, mehr: f
const detail = (extra = {}) => ({
id: 'o1', bestellnummer: 'WK-1', status: 'bezahlt', erstelltAt: '2026-10-05T10:00:00.000Z', bezahltAt: '2026-10-05T10:05:00.000Z',
summeCent: 4590, summeText: '45,90 €', erstattetCent: 0, erstattetText: '0,00 €', restCent: 4590, restText: '45,90 €',
- kaeufer: { name: 'Anna Beispiel', email: 'anna@example.com', telefon: '0151 123' },
+ kaeufer: { name: 'Anna Beispiel', email: 'anna@example.com', telefon: '0151 123' }, // alte API-Antwort: die Anzeige zeigt keine Telefonnummer
lieferart: 'versand', land: 'DE',
lieferadresse: { name: 'Anna Beispiel', line1: 'Hauptstr. 1', line2: '', postal_code: '10115', city: 'Berlin', country: 'DE' },
rechnungsadresse: { name: 'Anna Beispiel', line1: 'Hauptstr. 1', line2: '', postal_code: '10115', city: 'Berlin', country: 'DE' },
@@ -164,6 +164,7 @@ test('Detail: Positionen, Summen, Käufer, Adresse, Zahlung, Verlauf; Fremdtext
assert.ok(text.includes(erwartet), erwartet)
}
assert.equal(fx.alle(panel, 'b').length, 0, 'Käufername ist Text, kein Markup')
+ assert.ok(!text.includes('0151 123') && !text.includes('Telefon'), 'keine Käufer-Telefonnummer in der Anzeige')
const mail = mailto(fx, panel)
assert.equal(mail.getAttribute('href'), 'mailto:anna@example.com')
assert.equal(fx.finde(panel, '[data-hinweis="sepa"]'), null)
@@ -173,7 +174,7 @@ test('Detail: Positionen, Summen, Käufer, Adresse, Zahlung, Verlauf; Fremdtext
}))
test('Detail: Abholung zeigt Adresse und Hinweis, keine Lieferadresse; E-Mail mit Sonderzeichen ist kein Link', mit(server({
- 'GET /api/shop/bestellungen/o1': () => ({ body: { bestellung: detail({ lieferart: 'abholung', lieferadresse: null, abholung: { adresse: 'Laden 5, 80331 München', hinweis: 'Mo–Fr 10–18 Uhr' }, kaeufer: { name: 'A', email: 'a@b.de"> ({ body: { bestellung: detail({ lieferart: 'abholung', lieferadresse: null, abholung: { adresse: 'Laden 5, 80331 München', hinweis: 'Mo–Fr 10–18 Uhr' }, kaeufer: { name: 'A', email: 'a@b.de"> {
const { panel } = await oeffneDetail(fx)
const text = fx.text(panel)