Öffnungszeiten: Fixrunde Server (Schema-Kopplung, K27, Wertersatz, Widerruf, Ticket nachlegen)

- activate() schreibt die Zeiten-Felder (aboEndeAt, behaltenSessionId) nur noch über extra
  (nur Abo-Rückkehr und Team-Aktivierung der Zeiten); News und Terminplaner laufen auch vor zeiten-setup.mjs
- Abo-Abgleich: K27 gilt als offen, solange der Marker vor disabledAt liegt (kein Zurücksetzen nach disable() nötig);
  K27 geht erst nach dem Ersatz-Ticket raus (scheitert es, wiederholt der nächste Lauf beides);
  Löschung setzt auch activatedAt zurück
- Zustimmung "beginn" (bezahlt) mit Wertersatz-Satz (§ 357a Abs. 2 BGB) wie bei News und Terminplaner, Fassung 2026-10-05
- Widerruf: Bestellung "zeiten" in ORDERS, FEATURE_OF_ORDER, orderLabel und widerrufen.html
- Team "Ticket nachlegen" der Zeiten ohne Kauf geht über holeZeitenNach statt über den Zahlungsweg
- Einschalten-Route nimmt nur { zustimmung }, nicht die Altform { consent: true }
- Team-Aktivierung behält kauf/unlockedByPurchase bei einem bisher gekauften Dokument

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:04:05 +00:00
1 parent c10e50d0b3
commit ecf67d6978
13 files changed
+337 -48

No files matched your search

+80 -11
View File
@@ -34,7 +34,7 @@ const { subscriptionDoc, hostingDoc, wartungDoc } = await import('./support/fixt
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { withLocks } = await import('../server/services/keyedLock.js')
const { activate } = await import('../server/services/siteFeatures.js')
const { activate, adminActivate, ZEITEN_AKTIVIERUNG_FELDER } = await import('../server/services/siteFeatures.js')
const { zeitenDeps } = await import('../server/services/zeiten/neubau.js')
const { einschaltenZeiten } = await import('../server/services/zeiten/einschalten.js')
const { speichereZeiten, ladeZeiten } = await import('../server/services/zeiten/speicher.js')
@@ -394,7 +394,7 @@ test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste
}
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled')
assert.ok(!funktion().aboEndeMailAt, 'der Marker der K26 gilt nicht für die K27')
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'der Marker der K26 gilt nicht für die K27')
assert.equal(ersatzTickets().length, 1)
sink.sendMail = roh
@@ -405,17 +405,72 @@ test('K27 scheitert (Mail): abgeschaltet bleibt, der Marker fehlt, der nächste
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
})
test('Ersatz-Ticket scheitert: abgeschaltet und K27 gehen trotzdem durch, fehler zählt', async () => {
test('Ersatz-Ticket scheitert: abgeschaltet bleibt, fehler zählt, K27 und Marker fehlen; der nächste Lauf legt das Ticket an und sendet K27 genau einmal', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
mails().length = 0
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
const erg = await laufe(nach(30))
assert.deepEqual(await laufe(nach(30)), ergebnis({ geprueft: 1, abgeschaltet: 1, fehler: 1 }))
assert.equal(funktion().state, 'disabled')
assert.equal(erg.abgeschaltet, 1)
assert.equal(erg.mails, 1)
assert.equal(erg.fehler, 1)
assert.equal(ersatzTickets().length, 0)
assert.equal(mailsMit(K27).length, 0, 'K27 erst nach dem Ersatz-Ticket: so geht sie nie ein zweites Mal raus')
assert.ok(!funktion().aboEndeMailAt || ms(funktion().aboEndeMailAt) < ms(funktion().disabledAt), 'kein K27-Marker')
// der nächste Lauf wiederholt beides (Ticket idempotent über den Hash) und sendet K27 einmal
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(ersatzTickets().length, 1)
assert.equal(mailsMit(K27).length, 1)
assert.match(mailsMit(K27)[0].text, /Eingaben gespeichert bis: 04\.12\.2026/, 'Löschdatum von der echten Abschaltung')
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker gilt jetzt der K27')
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1 }))
assert.equal(ersatzTickets().length, 1)
assert.equal(mailsMit(K27).length, 1)
})
test('Ersatz-Ticket scheitert mehrere Läufe: kein K27, jeder Lauf zählt fehler; sobald es klappt, genau eine K27', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
mails().length = 0
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
await laufe(nach(30))
aw.failNext({ collection: 'workorders', method: 'POST', status: 500, message: 'Ticket kaputt' })
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1, fehler: 1 }))
assert.equal(mailsMit(K27).length, 0)
assert.deepEqual(await laufe(nach(32)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K27).length, 1)
assert.equal(ersatzTickets().length, 1)
})
test('Abschalten gelingt, die Folge scheitert (Marker der K26 steht noch, älter als disabledAt): der nächste Lauf holt K27 und das Ersatz-Ticket nach, einmal', async () => {
// So steht das Dokument, wenn nach disable() nichts mehr geschrieben wurde: disabled/abo_ende, der Marker ist der der K26
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR, aboEndeMailAt: ZUVOR })
abgelaufen()
assert.deepEqual(await laufe(nach(1)), ergebnis({ geprueft: 1, mails: 1 }))
assert.equal(mailsMit(K27).length, 1)
assert.equal(ersatzTickets().length, 1)
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt))
assert.deepEqual(await laufe(nach(2)), ergebnis({ geprueft: 1 }))
assert.equal(mails().length, 1)
assert.equal(ersatzTickets().length, 1)
})
test('Wanduhr nach dem Laufstart (disabledAt später als now): der K27-Marker liegt nie davor, K27 geht nicht täglich raus', async () => {
await legeFunktion(1)
abgelaufen()
await laufe(JETZT)
mails().length = 0
const tag30 = nach(30)
mock.timers.enable({ apis: ['Date'], now: tag30.getTime() + 5000 }) // disable() schreibt "jetzt" 5 s nach dem now des Laufs
try {
assert.deepEqual(await gleicheZeitenAboAb(tag30), ergebnis({ geprueft: 1, abgeschaltet: 1, mails: 1 }))
} finally {
mock.timers.reset()
}
assert.ok(ms(funktion().aboEndeMailAt) >= ms(funktion().disabledAt), 'Marker nicht vor disabledAt')
assert.deepEqual(await laufe(nach(31)), ergebnis({ geprueft: 1 }))
assert.equal(mailsMit(K27).length, 1)
})
// ---------------------------------------------------------------- Schritt 3: Rückkehr des Abos
@@ -560,9 +615,9 @@ test('Tag 60 nach dem Abo-Ende (30 Tage nach der Abschaltung): Daten gelöscht,
assert.equal(doc.state, 'off')
assert.equal(doc.enabled, false)
assert.deepEqual(
[doc.ticketId ?? '', doc.einbau ?? '', doc.k24At ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''],
['', '', null, null, ''],
'ticketId, einbau, k24At, aboEndeAt, behaltenSessionId geleert'
[doc.ticketId ?? '', doc.einbau ?? '', doc.k24At ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? '', doc.activatedAt ?? null],
['', '', null, null, '', null],
'ticketId, einbau, k24At, aboEndeAt, behaltenSessionId, activatedAt geleert (sonst käme ein späterer Lauf zu früh auf active)'
)
assert.match(doc.adminNote, /Öffnungszeiten gelöscht, 30 Tage nach dem Abschalten/)
assert.equal(await ladeZeiten(feature.$id), null, 'das Zeiten-Dokument (samt ersatzHash) ist gelöscht')
@@ -809,7 +864,8 @@ test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Mark
behaltenSessionId: 'cs_test_offen',
zahlungMeldung: 'abo_ende',
})
const doc = await activate(funktionsId(1), { adminNote: 'Test' })
// der Zeiten-Pfad gibt die Zeiten-Felder als extra mit (activate() schreibt sie nie von selbst, News und Terminplaner teilen die Collection)
const doc = await activate(funktionsId(1), { adminNote: 'Test', extra: ZEITEN_AKTIVIERUNG_FELDER })
assert.deepEqual([doc.state, doc.enabled], ['active', true])
assert.deepEqual([doc.disabledAt ?? null, doc.disabledReason ?? null, doc.aboEndeMailAt ?? null, doc.aboEndeAt ?? null, doc.behaltenSessionId ?? ''], [null, null, null, null, ''])
assert.equal(doc.zahlungMeldung, 'abo_ende')
@@ -818,6 +874,19 @@ test('activate() leert aboEndeAt und behaltenSessionId (zusammen mit Grund, Mark
assert.deepEqual(await laufe(nach(5)), ergebnis({ geprueft: 1 }))
})
test('activate() ohne extra lässt aboEndeAt und behaltenSessionId stehen; adminActivate() der Zeiten gibt sie als extra mit', async () => {
const offen = { state: 'disabled', enabled: false, disabledAt: ZUVOR, disabledReason: 'abo_ende', aboEndeAt: ZUVOR, behaltenSessionId: 'cs_test_offen' }
await legeFunktion(1, offen)
const ohne = await activate(funktionsId(1), { adminNote: 'Test' })
assert.equal(ohne.state, 'active')
assert.deepEqual([ms(ohne.aboEndeAt), ohne.behaltenSessionId], [ms(ZUVOR), 'cs_test_offen'])
await aw.seed('portalFeatures', { ...funktion(1), ...offen })
const team = await adminActivate(KUNDE, projektId(1), 'zeiten', { by: 'Kenso' })
assert.equal(team.state, 'active')
assert.deepEqual([team.aboEndeAt ?? null, team.behaltenSessionId ?? ''], [null, ''])
})
test('abgeschaltet mit abo_ende, aber ohne Marker (Lauf nach dem Abschalten unterbrochen): der nächste Lauf holt K27 und das Ersatz-Ticket nach', async () => {
await legeFunktion(1, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: JETZT.toISOString(), aboEndeAt: ZUVOR })
abgelaufen()