Kundenansicht P6: alte Abgleiche lassen Abo-Modell-Dokumente dem neuen Abgleich, Zeiten ohne Behalten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 16:46:15 +00:00
1 parent d8c952ac2a
commit 7a03f073bc
5 files changed
+125 -11

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { mailSink } from './support/mailSink.js'
import { schemaKeys } from './support/schema.js'
import { useTestEnv } from './support/testEnv.js'
import { ZEITEN_DATETIMES } from '../server/services/zeiten/schema.js'
// Koexistenz (P6 Task 10): Dokumente des Abo-Modells (unlockedBy UND einrichtungAbrechnung) gehören dem neuen Abgleich; der alte
// Terminplaner-Abgleich fasst sie nicht an. Zeiten koppeln auch projekt (außer in der Aufbauphase) und senden K40 und K41 ohne Preis.
const aw = await startAppwriteMock({
schema: {
portalFeatures: schemaKeys('portalFeatures'),
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
termineConfig: schemaKeys('termineConfig'),
oeffnungszeiten: schemaKeys('oeffnungszeiten'),
zeitenErinnerungen: schemaKeys('zeitenErinnerungen'),
},
datetimes: {
...ZEITEN_DATETIMES,
portalFeatures: ['orderedAt', 'activatedAt', 'disabledAt', 'consentAt', 'aboEndeMailAt', 'aboEndeAt', 'einschaltMailAt'],
},
defaultLimit: 25,
})
after(() => aw.close())
useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' })
const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { gleicheAboAb, endeTermineFuerKunde } = await import('../server/services/termine/aboAbgleich.js')
const { gleicheZeitenAboAb } = await import('../server/services/zeiten/aboAbgleich.js')
const mails = mailSink()
setMailTransportForTests(mails)
after(() => setMailTransportForTests(null))
seedFixtures(aw)
// aw.docs() liefert eine Kopie: Dokumente werden ueber die API geloescht
const leere = async (...collections) => {
for (const col of collections) for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
}
beforeEach(async () => {
await leere('portalFeatures', 'subscriptions', 'oeffnungszeiten', 'zeitenErinnerungen')
mails.sent.length = 0
})
const modell = { unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo' }
test('Terminplaner-Abgleich überspringt Abo-Modell-Dokumente in jedem Zustand, auch ohne Wartungs-Abo; Kauf-Dokumente werden weiter abgeschaltet', async () => {
// kein Abo; Abo-Modell: active, setup, disabled/abo_ende (ohne Marker); dazu ein gekaufter, aktiver Terminplaner
for (const [id, state, extra] of [['f_a', 'active', {}], ['f_s', 'setup', {}], ['f_d', 'disabled', { disabledReason: 'abo_ende', disabledAt: new Date().toISOString() }]]) aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'termine', state, enabled: state === 'active', ...modell, ...extra })
aw.seed('portalFeatures', { $id: 'f_k', customerId: 'c1', projectId: 'p_k', featureKey: 'termine', state: 'active', enabled: true, unlockedBy: 'kauf', unlockedByPurchase: true })
const r = await gleicheAboAb(new Date()); assert.deepEqual([r.abgeschaltet, r.ausgenommen], [1, 0]); assert.deepEqual(['f_a', 'f_s', 'f_d', 'f_k'].map((id) => aw.docs('portalFeatures').get(id).state), ['active', 'setup', 'disabled', 'disabled'])
assert.deepEqual((await endeTermineFuerKunde('c1')).beendet, []); assert.equal(mails.sent.filter((m) => /^Dein Terminplaner wurde beendet/.test(m.subject)).length, 1) // nur der Kauf
})
const T0 = new Date('2026-10-17T08:00:00.000Z'), TAG = 86400000 // Schritt 1 erkennt das Abo-Ende, Schritt 2 schaltet nach der 30-Tage-Frist ab (ohne gespeicherte Zeiten, also ohne Ersatz-Ticket)
const zeit = (id, over) => aw.seed('portalFeatures', { $id: id, customerId: 'c1', projectId: `p_${id}`, featureKey: 'zeiten', siteKey: `zk${id}`.padEnd(16, '2').slice(0, 16), state: 'active', enabled: true, activatedAt: '2026-10-01T09:00:00.000Z', unlockedByPurchase: false, einbau: 'angestossen', ticketId: 't1', einschaltMailAt: '2026-10-01T09:05:00.000Z', ...over })
const zdoc = (id) => aw.docs('portalFeatures').get(id)
const beendetesAbo = () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'canceled' }))
const k40Mails = () => mails.sent.filter((m) => m.subject.startsWith('Dein Abo ist beendet – Öffnungszeiten live läuft noch bis'))
test('Zeiten: Abo-Modell (abo und projekt) bekommt K40 und K41 ohne Preis, Gratis-Zeiten ohne Marker weiter K29 und K30 mit 30 €', async () => {
beendetesAbo() // das Wartungs-Abo des Kunden ist beendet (mit einem Abo-Dokument: sonst gälte für projekt die Aufbauphase, siehe nächster Test)
zeit('z_abo', { unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }); zeit('z_projekt', { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }); zeit('z_alt', { unlockedBy: 'abo' })
await gleicheZeitenAboAb(T0)
const k40 = k40Mails(); const k29 = mails.sent.filter((m) => m.subject.startsWith('Dein Wartungs-Abo ist beendet'))
assert.deepEqual([k40.length, k29.length], [2, 1]); assert.ok(k40.every((m) => !/€|behalten/i.test(m.text))); assert.match(k29[0].text, /30 €/)
await gleicheZeitenAboAb(new Date(T0.getTime() + 31 * TAG))
assert.deepEqual(['z_abo', 'z_projekt', 'z_alt'].map((id) => zdoc(id).disabledReason), ['abo_ende', 'abo_ende', 'abo_ende'])
const k41 = mails.sent.filter((m) => m.subject === 'Öffnungszeiten live ist abgeschaltet'); assert.equal(k41.length, 3); assert.equal(k41.filter((m) => /30 €/.test(m.text)).length, 1) // nur das Gratis-Dokument nennt noch den Preis
})
test('Zeiten, Aufbauphase (E9): projekt ohne jedes Abo bleibt unberührt, abo nicht; mit dem ersten beendeten Abo gilt die Abo-Regel auch für projekt', async () => {
zeit('z_projekt', { unlockedBy: 'projekt', einrichtungAbrechnung: 'projekt' }); zeit('z_abo', { unlockedBy: 'abo', einrichtungAbrechnung: 'abo' }) // kein Abo-Dokument
const r = await gleicheZeitenAboAb(T0); assert.equal(r.fehler, 0)
assert.deepEqual([Boolean(zdoc('z_projekt').aboEndeAt), Boolean(zdoc('z_abo').aboEndeAt), k40Mails().length], [false, true, 1]) // nur z_abo erkennt ein Abo-Ende
await gleicheZeitenAboAb(new Date(T0.getTime() + 31 * TAG)); assert.deepEqual([zdoc('z_projekt').state, zdoc('z_projekt').disabledReason ?? null], ['active', null]) // auch nach 31 Tagen
aw.seed('subscriptions', subscriptionDoc('sub_i', { ...wartungDoc, status: 'incomplete' })); await gleicheZeitenAboAb(T0); assert.equal(Boolean(zdoc('z_projekt').aboEndeAt), false) // abgebrochene Bestellung ist kein Abo
await leere('subscriptions'); beendetesAbo(); await gleicheZeitenAboAb(T0); assert.deepEqual([Boolean(zdoc('z_projekt').aboEndeAt), k40Mails().length], [true, 2])
})
+7
View File
@@ -800,3 +800,10 @@ test('createFeatureCheckout: zweck nur behalten (sonst 400 vor jedem Stripe-Aufr
assert.equal(Object.keys(news).some((k) => k.includes('zweck')), false)
assert.equal(news.success_url, `${config.publicBaseUrl}/dashboard.html#${config.features.news.returnHash}?bestellt=1`)
})
test('Behalten (30 €) ist für Abo-Modell-Dokumente gesperrt (409 ohne Spur), für Gratis-Zeiten im selben Zustand möglich', async () => {
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
seedeAbgeschaltet({ einrichtungAbrechnung: 'abo' }); await mitZeiten()
const res = await behalten(koerper()); assert.deepEqual([res.status, res.body.error], [409, NICHT_MOEGLICH]); keineSpuren()
await updateDocument('portalFeatures', 'f_p1_zeiten', { einrichtungAbrechnung: '' }); assert.equal((await behalten(koerper())).status, 200)
})