Buchungskalender: Abschluss – S5 Hinweis-Mail K35 vor der Loeschung, Rechnungen im Zustand abgeschaltet lesbar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 15:23:56 +00:00
1 parent e2fc4eb676
commit ab838d07bc
6 files changed
+225 -4

No files matched your search

+5 -2
View File
@@ -8,7 +8,7 @@ const DIR = new URL('../server/mail-templates/', import.meta.url)
// G01–G08 Gästemails des Terminplaners (Task 9, neutrales Layout im Auftrag eines Betriebs), K20 und K21 Hinweise an den Betrieb;
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1); Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand, Terminplaner; K15, K16 und T16 gelten je Funktion, Terminplaner TP2 B9a und B9b); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse; K22, K23 Terminplaner beendet/läuft wieder (Terminplaner TP2 E3); T18 Bankzugang erneuern (Finanzen); T19 Erstattung fällig und T20 Terminplaner Betriebsmeldung (Terminplaner TP2, Team-Hinweise; T16 gilt je Funktion, T19 aus B9b); T21 Neue Webshop-Anfrage (Webshop B1, Team-Hinweis); K24 Webshop-Anfrage entschieden (Webshop B2, Kunde); G09–G14 Käufermails des Webshops (Bestellbestätigung, versendet, abholbereit, Eingang Widerruf, Erstattung, Zahlung fehlgeschlagen; Webshop E4, eigener Fuß statt Terminplaner-Fuß, Sie-Form) und K25, K26 an den Händler (neue Bestellung, Widerruf; Webshop E4); K27–K30 Öffnungszeiten live (eingeschaltet, Feiertage prüfen, Abo beendet, abgeschaltet; Öffnungszeiten live Task 13); K04 und T09 gelten beim Widerruf einer Funktion je Funktion (produkt, T09 zusätzlich preis; Terminplaner TP2 F9)
const ids = (prefix, count) => Array.from({ length: count }, (_, i) => `${prefix}${String(i + 1).padStart(2, '0')}`)
const EXPECTED = [...ids('G', 22), ...ids('K', 34), ...ids('T', 22)]
const EXPECTED = [...ids('G', 22), ...ids('K', 35), ...ids('T', 22)]
// GUEST = Gästemails des Terminplaners (gemeinsamer Fuß, Anrede-Blöcke); SHOP_GUEST = Käufermails des Webshops (G09–G14, siehe unten)
const GUEST = ids('G', 8)
const SHOP_GUEST = ['G09', 'G10', 'G11', 'G12', 'G13', 'G14', 'G15']
@@ -1335,12 +1335,13 @@ test('G22: Erstattung „wird veranlasst“ nur im Block', () => {
assert.ok(!renderMail('G22', sampleValues(tpl), { erstattung: false }).text.includes('Erstattung von'))
})
test('K31–K34, T22: Dateien, Platzhalter, Du-Form', () => {
test('K31–K35, T22: Dateien, Platzhalter, Du-Form', () => {
const erwartet = {
K31: ['kunde_name', 'gast_name', 'gast_email', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'nachricht_gast', 'portal_url'],
K32: ['kunde_name', 'gast_name', 'unterkunft', 'zeitraum', 'naechte', 'personen', 'nummer', 'gesamt', 'zahlart_text', 'portal_url'],
K33: ['kunde_name', 'unterkunft', 'quelle', 'seit', 'portal_url'],
K34: ['kunde_name', 'projekt', 'preis', 'ust_hinweis', 'begruendung', 'portal_url'],
K35: ['kunde_name', 'projekt', 'loeschdatum', 'portal_url'],
T22: ['kunde_name', 'email', 'projekt', 'nachricht', 'woms_url'],
}
for (const [id, p] of Object.entries(erwartet)) {
@@ -1350,5 +1351,7 @@ test('K31–K34, T22: Dateien, Platzhalter, Du-Form', () => {
assert.deepEqual(mail.problems, [], id)
}
assert.equal(loadTemplate('K32').file, 'K32 Neue Buchung.html')
assert.equal(loadTemplate('K35').file, 'K35 Buchungskalender abgeschaltet.html')
assert.equal(loadTemplate('K35').subject, 'Buchungskalender abgeschaltet – bitte sichere Deine Rechnungen')
assert.equal(loadTemplate('T22').subject, '[Buchungskalender] Neue Anfrage: {{kunde_name}}')
})
+68 -1
View File
@@ -15,13 +15,16 @@ after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1/v1' })
const { config } = await import('../server/config.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js')
const { anonymisiere, loescheAbgeschaltete, loescheUkDaten, schliesseUkVonKunde } = await import('../server/services/unterkunft/aufbewahrung.js')
const jobs = await import('../server/services/unterkunft/jobs.js')
const mails = await import('../server/services/unterkunft/mails.js')
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { raeumeAuf } = await import('../server/services/termine/outbox.js')
const { sendeHinweiseVorLoeschung } = await import('../server/services/unterkunft/abschaltHinweis.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.js')
const TAG = 24 * 3600 * 1000
const JETZT = new Date('2026-10-07T10:00:00.000Z')
@@ -183,6 +186,70 @@ test('Abgeschaltete Funktion: nach 89 Tagen alles da, nach 91 Tagen alle uk*-Dat
assert.equal(anzahl('f3'), 6)
})
test('K35: einmal pro Abschaltung, mit Loeschdatum, nur mit Daten; Marker; ohne Adresse nur Marker; Fehler wird wiederholt; Tagesjob sendet vor der Loeschung', async () => {
const senke = mailSink()
setMailTransportForTests(senke)
try {
aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' })
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau Home' })
funktion({ state: 'disabled', enabled: false, disabledAt: vor(1) })
funktion({ $id: 'f2', state: 'disabled', enabled: false, disabledAt: vor(1), customerId: 'c9' }) // Kunde fehlt
funktion({ $id: 'f3', state: 'disabled', enabled: false, disabledAt: vor(1) }) // ohne Daten
funktion({ $id: 'f4', state: 'active' })
for (const f of ['f1', 'f2', 'f4']) datenFuer(f)
const r = await sendeHinweiseVorLoeschung(JETZT)
assert.deepEqual([r.gesendet, r.ohneAdresse, r.fehler], [1, 1, 0])
assert.equal(senke.sent.length, 1)
const m = senke.sent[0]
assert.equal(m.to[0].address, 'mimi@example.com')
assert.match(m.subject, /Buchungskalender abgeschaltet/)
assert.match(m.text, /Donau Home/)
assert.match(m.text, /04.01.2027/, 'disabledAt + 90 Tage')
assert.match(aw.docs('portalFeatures').get('f1').adminNote, /K35 am 2026-10-07/)
assert.match(aw.docs('portalFeatures').get('f2').adminNote, /K35 am 2026-10-07/)
assert.ok(!aw.docs('portalFeatures').get('f3').adminNote)
assert.ok(!aw.docs('portalFeatures').get('f4').adminNote)
// zweiter Lauf: nichts mehr
assert.equal((await sendeHinweiseVorLoeschung(new Date(JETZT.getTime() + TAG))).gesendet, 0)
assert.equal(senke.sent.length, 1)
// erneut abgeschaltet (alter Marker liegt vor disabledAt): wieder ein Hinweis
await updateDocument(config.collections.portalFeatures, 'f1', { disabledAt: vor(-5) })
assert.equal((await sendeHinweiseVorLoeschung(new Date(JETZT.getTime() + 6 * TAG))).gesendet, 1)
// Versandfehler: kein Marker, naechster Lauf wiederholt
aw.seed('portalFeatures', { ...aw.docs('portalFeatures').get('f3'), adminNote: '' })
datenFuer('f3')
setMailTransportForTests({ async sendMail() { throw new Error('smtp down') } })
const log = console.warn
console.warn = () => {}
const f = await sendeHinweiseVorLoeschung(JETZT)
console.warn = log
assert.equal(f.fehler, 1)
assert.ok(!aw.docs('portalFeatures').get('f3').adminNote)
setMailTransportForTests(senke)
assert.equal((await sendeHinweiseVorLoeschung(JETZT)).gesendet, 1)
} finally {
setMailTransportForTests(null)
}
})
test('K35: Tagesjob schickt den Hinweis und loescht erst nach 90 Tagen', async () => {
const senke = mailSink()
setMailTransportForTests(senke)
try {
aw.seed('customers', { $id: 'c1', name: 'Mimi', email: 'mimi@example.com' })
funktion({ state: 'disabled', enabled: false, disabledAt: vor(3) })
datenFuer('f1')
await jobs.taeglicherJob(JETZT)
assert.equal(senke.sent.length, 1)
assert.equal(anzahl('f1'), 6)
await jobs.taeglicherJob(new Date(JETZT.getTime() + 88 * TAG))
assert.equal(senke.sent.length, 1)
assert.equal(anzahl('f1'), 0)
} finally {
setMailTransportForTests(null)
}
})
test('loescheUkDaten mit Deckel: unvollstaendig, Fortsetzung loescht den Rest', async () => {
datenFuer('f1')
const a = await loescheUkDaten({ featureId: 'f1', deckel: 2 })
+12
View File
@@ -474,6 +474,18 @@ test('Stornieren: Erstattung fehlgeschlagen -> 502 erstattung_fehlgeschlagen mit
// ------------------------------------------------------------------------------------------------ Rechnungen
test('Rechnungen: im Zustand abgeschaltet bleiben Liste und PDF lesbar, Schreiben ist gesperrt (Rechnungen sichern vor der Loeschung)', async () => {
aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: JSON.stringify({ anfrageNummer: 'A-X', empfaenger: { name: 'Eva' }, aussteller: { firma: 'Haus' }, posten: [], summen: { bruttoCent: 100, nettoCent: 93, ust: [] } }) })
aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: P, featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false, disabledAt: '2026-10-01T00:00:00.000Z' })
const liste = await get('/rechnungen')
assert.equal(liste.status, 200)
assert.deepEqual(liste.body.rechnungen.map((r) => r.id), ['r1'])
const pdf = await get('/rechnungen/r1.pdf')
assert.equal(pdf.status, 200)
assert.equal(pdf.headers.get('content-type'), 'application/pdf')
assert.equal((await post('/unterkuenfte', { name: 'X', maxPersonen: 2 })).status, 409)
})
test('Rechnungen: Liste nur des Projekts; PDF fremde Rechnung 404, eigene 200 als PDF, unsichere ID 404', async () => {
aw.seed('ukRechnungen', { $id: 'r1', featureId: FEATURE, anfrageId: 'a1', art: 'rechnung', nummer: 'DH-2026-0001', datum: '2026-11-01', bruttoCent: 100, inhalt: JSON.stringify({ anfrageNummer: 'A-X', empfaenger: { name: 'Eva' } }) })
aw.seed('ukRechnungen', { $id: 'rfremd', featureId: 'anderes', anfrageId: 'a9', art: 'rechnung', nummer: 'XX-1', datum: '2026-11-01', bruttoCent: 100, inhalt: '{}' })