Die PDFs der Fassung 6 entstehen erst nach der Freigabe. Bis dahin gingen K01, K12 und K13 ohne Anhang raus, sagten aber "im Anhang" bzw. "beigefügt", und das Team erfuhr es nicht (nur K38/T24 hatten dafür den Block anhang_fehlt). - Neuer Helfer vertragsAnhang.js: lädt das PDF der zugestimmten Fassung und meldet fehlt nur bei Fassung 6 ohne lesbare Datei. - K01, K12, K13: Block anhang (Anhangsaussagen) und ohne_anhang (Satz, dass die Unterlagen gesondert kommen). - T02, T12, T13: Block anhang_fehlt "Unterlagen von Hand nachsenden" (T12 auch beim späteren Wirksamwerden, T13 auch ohne Aufgabe). - Alte Fassungen unverändert: Hash-Pins auf die Wiedergabe der Vorlagen vor der Änderung, Anhang und Texte wie bisher. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
15 KiB
JavaScript
238 lines
15 KiB
JavaScript
import { test, after } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { createHash } from 'node:crypto'
|
||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||
import { startFakeStripe } from './support/fakeStripe.js'
|
||
import { useTestEnv } from './support/testEnv.js'
|
||
import { schemaKeys } from './support/schema.js'
|
||
import { mailSink } from './support/mailSink.js'
|
||
|
||
// Abschlussprüfung F5: Fassung 6 ohne PDF. Fehlt der Anhang, behaupten K01, K12 und K13 kein „im Anhang“, und das Team bekommt in T02, T12 bzw. T13
|
||
// den Block anhang_fehlt (Muster K38/T24). Alte Fassungen bleiben unverändert (Hash-Pins auf die Wiedergabe der alten Vorlagen).
|
||
const aw = await startAppwriteMock({
|
||
schema: {
|
||
subscriptions: schemaKeys('subscriptions'),
|
||
contractEvents: schemaKeys('contractEvents'),
|
||
hourCredits: schemaKeys('hourCredits'),
|
||
featureConsents: schemaKeys('featureConsents'),
|
||
},
|
||
})
|
||
const st = await startFakeStripe()
|
||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||
Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'K@example.invalid, k3@example.invalid' })
|
||
const { config } = await import('../server/config.js')
|
||
const { startTestApp, postWebhook } = await import('./support/testApp.js')
|
||
const { hostingDoc, consent, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
|
||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||
const { switchToWartung } = await import('../server/services/planSwitch.js')
|
||
const { changeStorage } = await import('../server/services/mailStorage.js')
|
||
const { listContractEvents } = await import('../server/services/contractEvents.js')
|
||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||
const { loadTemplate, renderMail } = await import('../server/services/mailTemplates.js')
|
||
|
||
const app = await startTestApp({ '/api/abo': aboRoutes })
|
||
const mails = mailSink()
|
||
setMailTransportForTests(mails)
|
||
after(() => {
|
||
setMailTransportForTests(null)
|
||
return Promise.all([app.close(), aw.close(), st.close()])
|
||
})
|
||
seedFixtures(aw)
|
||
|
||
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
|
||
st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents))
|
||
st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents))
|
||
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
|
||
|
||
const NEU = config.abo.termsVersionNeu
|
||
const MIT_PDF = '2099-01-01-v4' // eine Fassung, deren PDF im Repo liegt (Archiv v4), um „Fassung 6 mit PDF“ nachzustellen
|
||
const nowUnix = () => Math.floor(Date.now() / 1000)
|
||
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
|
||
const sha = (html) => createHash('sha256').update(html).digest('hex')
|
||
const HINWEIS = 'Unterlagen von Hand nachsenden'
|
||
|
||
/** Fassung 6 ist die neue Fassung dieses Laufs: mit termsVersionNeu auf eine Fassung mit PDF zeigt die Neu-Fassung eine vorhandene Datei. */
|
||
async function mitNeuFassung(fassung, fn) {
|
||
const vorher = config.abo.termsVersionNeu
|
||
config.abo.termsVersionNeu = fassung
|
||
try {
|
||
return await fn()
|
||
} finally {
|
||
config.abo.termsVersionNeu = vorher
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Vorlagen
|
||
test('Vorlagen: K01, K12 und K13 kennen anhang und ohne_anhang, T02, T12 und T13 den Block anhang_fehlt', () => {
|
||
for (const id of ['K01', 'K12', 'K13']) assert.deepEqual(['anhang', 'ohne_anhang'].filter((b) => !loadTemplate(id).blocks.includes(b)), [], id)
|
||
for (const id of ['T02', 'T12', 'T13']) assert.ok(loadTemplate(id).blocks.includes('anhang_fehlt'), id)
|
||
// ohne Anhang keine Behauptung „im Anhang“ / „beigefügt“, dafür der Satz, dass die Unterlagen gesondert kommen
|
||
const K13_BESTELLUNG = { plus: true, bestellung: true }
|
||
for (const [id, blocks] of [['K01', { wartung: true, sofortbeginn: true }], ['K12', {}], ['K13', K13_BESTELLUNG]]) {
|
||
const ohne = renderMail(id, sampleValues(loadTemplate(id)), { ...blocks, ohne_anhang: true })
|
||
assert.doesNotMatch(ohne.text, /Anhang|beigefügt/i, `${id} ohne Anhang`)
|
||
assert.match(ohne.text, /senden wir Ihnen gesondert zu/, id)
|
||
assert.deepEqual(ohne.problems, [], id)
|
||
const mit = renderMail(id, sampleValues(loadTemplate(id)), { ...blocks, anhang: true })
|
||
assert.match(mit.text, /im Anhang|beigefügten/, `${id} mit Anhang`)
|
||
assert.doesNotMatch(mit.text, /gesondert zu/, id)
|
||
}
|
||
for (const id of ['T02', 'T12', 'T13']) {
|
||
const mit = renderMail(id, sampleValues(loadTemplate(id)), { anhang_fehlt: true })
|
||
assert.match(mit.text, new RegExp(HINWEIS), id)
|
||
assert.match(mit.text, /nicht mit der Bestätigung beim Kunden angekommen/, id)
|
||
assert.doesNotMatch(renderMail(id, sampleValues(loadTemplate(id)), {}).text, new RegExp(HINWEIS), id)
|
||
}
|
||
})
|
||
|
||
// Hash der Wiedergabe der Vorlagen vor der Änderung (Stand kn-p7 plus P8-Nachträge, ohne anhang-Blöcke): die alten Fassungen bleiben bytegleich
|
||
const ALT = [
|
||
['K01', {"hosting": true, "sofortbeginn": true}, 'ea5fed3076d4bf6c4f5aa7042d20d61c1d90d11716f99811e439df09c95abd56'],
|
||
['K01', {"wartung": true, "beginn_nach_widerrufsfrist": true, "angebot": true}, '434d4945815af9e8df98039184e09058b992c32f1f7a21844b25923be2130aca'],
|
||
['K01', {"wartung_v3": true, "sofortbeginn": true}, '4a5a3252ad7ea5c5eda35b42480f95611a4cb5648e9a847afc6720692240d729'],
|
||
['K12', {"zahlung_offen": true, "zusatzspeicher": true, "sofortverlangen": true}, '8c18e9a2455e28ec5467fb68dcdfe9b66afe9cc676ef16cc84281a4b2d97caf2'],
|
||
['K12', {"lastschrift": true}, '9731b26f4043f29537e289edf7b00d4d024522c6bafb9166d3a0b6289e592963'],
|
||
['K12', {}, '76a6f5f1f350f80a938828ce2cd1d84305aca2251427e2ad0df7bb550dc18ff7'],
|
||
['K13', {"plus": true, "bestellung": true}, 'b01d4f6a30f562ccee3739f8f5b77bb9a660a61165366cd3d294a627ccfdc8c8'],
|
||
['K13', {"plus_bezahlt": true, "bestellung": true}, '7a41e74fe6bf45a33bb6e9dd35d29f6995b8995961e3b6905ed5ba45e19b0e51'],
|
||
['K13', {"minus": true}, 'a82497427b0d7af96aadd380974ce5e73930ec2c9a7f5b6357f5f13f8190d109'],
|
||
['T02', {"angebot": true, "angebot_fehler": true, "nachweis_fehlt": true}, '438a623fb53046b34930a30110fa35d3c08c6f27ff84af21466426496202a441'],
|
||
['T02', {}, '386199538e7a59eb09b41511f4cab3924c36cacdbbdba281af45b2fa86158755'],
|
||
['T12', {"wirksam": true, "nachweis_fehlt": true}, '3777c2edc646e53b5b63280132e0958e31dba5d084848244525882a9b2fb1d07'],
|
||
['T12', {"lastschrift": true, "gescheitert": true}, '56e1c70e82d260b6c62788fbed5503d2e4e6c6fff1046281b7e76261ced5f1c9'],
|
||
['T12', {}, '928f6ee5118da82fce612d77f6dab22be4c3b0aefc736e64668a9d24b69da98a'],
|
||
['T13', {"einrichten": true, "nachweis_fehlt": true}, '2f5543fec1ecdc7726931967b164d42386a9d32f0f062e8d5770002958c33a72'],
|
||
['T13', {"periodenende": true, "teilweise": true}, '44fa59476f1edb46f977500b1d8ebed7f07205ca2f6087609c2602e15f10eaeb'],
|
||
['T13', {}, '793af7c1cb98df24d914d1a5bf3330ace1751a2794713045b78a9ac2a93f1a9f'],
|
||
]
|
||
test('Alte Fassungen: die Wiedergabe der Vorlagen bleibt bytegleich (K mit anhang, T ohne anhang_fehlt)', () => {
|
||
for (const [id, blocks, soll] of ALT) {
|
||
const mitAnhang = id.startsWith('K') ? { ...blocks, anhang: true } : blocks
|
||
assert.equal(sha(renderMail(id, sampleValues(loadTemplate(id)), mitAnhang).html), soll, `${id} ${JSON.stringify(blocks)}`)
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- K01 und T02
|
||
const SUB_NEW = 'sub_1SD6vPRxAbCdEfGhIjKlMnOp'
|
||
const checkoutCompleted = (subscriptionId, metadata = {}) => ({
|
||
id: `evt_${subscriptionId}`, object: 'event', type: 'checkout.session.completed', created: nowUnix(),
|
||
data: { object: { id: `cs_${subscriptionId}`, object: 'checkout.session', mode: 'subscription', subscription: subscriptionId, customer: 'cus_c3',
|
||
customer_details: { email: 'k3@example.invalid', name: 'Kunde Drei' }, metadata: { webklarCustomerId: 'c3', productKey: 'wartung', ...metadata } } },
|
||
})
|
||
async function clearEvents(customerId) {
|
||
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
|
||
}
|
||
async function k01(termsVersion) {
|
||
st.subscriptions.set(SUB_NEW, stripeSubscription({ id: SUB_NEW, customerId: 'c3', productKey: 'wartung', metadata: { termsVersion, startChoice: 'sofort', earlyStartConsentAt: new Date().toISOString() } }))
|
||
mails.sent.length = 0
|
||
try {
|
||
assert.equal((await postWebhook(checkoutCompleted(SUB_NEW))).status, 200)
|
||
return { kunde: mails.sent.find((m) => m.subject.includes('Vertragsbestätigung')), team: mails.sent.find((m) => m.subject.startsWith('[Abo] Neues')) }
|
||
} finally {
|
||
await clearEvents('c3')
|
||
await deleteDocument(config.collections.subscriptions, SUB_NEW).catch(() => {})
|
||
}
|
||
}
|
||
|
||
test('K01: Fassung 6 ohne PDF behauptet keinen Anhang, T02 sagt es dem Team', async () => {
|
||
const { kunde, team } = await k01(NEU)
|
||
assert.equal(kunde.attachments.length, 0)
|
||
assert.doesNotMatch(kunde.text, /Anhang|beigefügt/i)
|
||
assert.match(kunde.text, /senden wir Ihnen gesondert zu/)
|
||
assert.match(team.text, new RegExp(HINWEIS))
|
||
})
|
||
test('K01: Fassung 6 mit PDF und alte Fassung wie bisher (Anhang, kein Hinweis)', async () => {
|
||
for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) {
|
||
const { kunde, team } = await mitNeuFassung(neu, () => k01(fassung))
|
||
assert.equal(kunde.attachments.length, 1, name)
|
||
assert.match(kunde.text, /im Anhang/, name)
|
||
assert.doesNotMatch(kunde.text, /gesondert zu/, name)
|
||
assert.doesNotMatch(team.text, new RegExp(HINWEIS), name)
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- K12 und T12
|
||
const CARD = { id: 'pm_card_visa', object: 'payment_method', type: 'card' }
|
||
const switchInvoice = (overrides = {}) => ({ id: 'in_wechsel', object: 'invoice', status: 'paid', amount_due: 4100, billing_reason: 'subscription_update', hosted_invoice_url: 'https://invoice.stripe.test/i/in_wechsel', ...overrides })
|
||
async function resetHosting() {
|
||
await clearEvents('c1')
|
||
aw.seed('subscriptions', { ...hostingDoc })
|
||
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', default_payment_method: CARD }))
|
||
st.nextSubscriptionUpdate = null
|
||
mails.sent.length = 0
|
||
}
|
||
async function k12(termsVersion) {
|
||
await resetHosting()
|
||
st.nextSubscriptionUpdate = { latest_invoice: switchInvoice() }
|
||
await switchToWartung('c1', { ...consent, termsVersion }, nowUnix())
|
||
return { kunde: mails.sent.find((m) => m.subject === 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung'), team: mails.sent.find((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo')) }
|
||
}
|
||
test('K12: Fassung 6 ohne PDF behauptet keinen Anhang, T12 sagt es dem Team; mit PDF und alte Fassung wie bisher', async () => {
|
||
const ohne = await k12(NEU)
|
||
assert.equal(ohne.kunde.attachments.length, 0)
|
||
assert.doesNotMatch(ohne.kunde.text, /Anhang|beigefügt/i)
|
||
assert.match(ohne.kunde.text, /senden wir Ihnen gesondert zu/)
|
||
assert.match(ohne.team.text, new RegExp(HINWEIS))
|
||
for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) {
|
||
const r = await mitNeuFassung(neu, () => k12(fassung))
|
||
assert.equal(r.kunde.attachments.length, 1, name)
|
||
assert.match(r.kunde.text, /im Anhang/, name)
|
||
assert.doesNotMatch(r.team.text, new RegExp(HINWEIS), name)
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------- K13 und T13
|
||
async function resetSubH({ units, paidUnits }) {
|
||
await clearEvents('c1')
|
||
mails.sent.length = 0
|
||
aw.seed('subscriptions', { ...hostingDoc, storageUnits: units, storagePaidUnits: paidUnits, storagePaidPeriodEnd: hostingDoc.currentPeriodEnd })
|
||
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units }))
|
||
}
|
||
async function k13({ units, paidUnits, ziel, termsVersion }) {
|
||
await resetSubH({ units, paidUnits })
|
||
await changeStorage('c1', ziel, { ...consent, termsVersion })
|
||
return { kunde: mails.sent.find((m) => m.subject === 'Ihr E-Mail-Zusatzspeicher – Bestätigung'), team: mails.sent.find((m) => m.subject.startsWith('[Speicher]')) }
|
||
}
|
||
test('K13: Fassung 6 ohne PDF behauptet keinen Anhang, T13 sagt es dem Team (auch ohne Aufgabe); mit PDF und alte Fassung wie bisher', async () => {
|
||
const ohne = await k13({ units: 0, paidUnits: 0, ziel: 2, termsVersion: NEU })
|
||
assert.equal(ohne.kunde.attachments.length, 0)
|
||
assert.doesNotMatch(ohne.kunde.text, /Anhang|beigefügt/i)
|
||
assert.match(ohne.kunde.text, /senden wir Ihnen gesondert zu/)
|
||
assert.match(ohne.team.text, new RegExp(HINWEIS))
|
||
// Plus nur bis zur schon bezahlten Menge: keine Aufgabe, aber die Unterlagen müssen nachgesendet werden
|
||
const bezahlt = await k13({ units: 1, paidUnits: 3, ziel: 3, termsVersion: NEU })
|
||
assert.equal(bezahlt.kunde.attachments.length, 0)
|
||
assert.ok(bezahlt.team, 'T13 geht auch ohne Aufgabe raus, wenn der Anhang fehlt')
|
||
assert.match(bezahlt.team.text, new RegExp(HINWEIS))
|
||
for (const [name, fassung, neu] of [['Fassung 6 mit PDF', MIT_PDF, MIT_PDF], ['Fassung 5', config.abo.termsVersion, NEU]]) {
|
||
const r = await mitNeuFassung(neu, () => k13({ units: 0, paidUnits: 0, ziel: 2, termsVersion: fassung }))
|
||
assert.equal(r.kunde.attachments.length, 1, name)
|
||
assert.match(r.kunde.text, /beigefügten Vertragsunterlagen/, name)
|
||
assert.doesNotMatch(r.team.text, new RegExp(HINWEIS), name)
|
||
}
|
||
// Verringerung: nie ein Anhang, nie ein Hinweis
|
||
const minus = await k13({ units: 3, paidUnits: 3, ziel: 1, termsVersion: NEU })
|
||
assert.equal(minus.kunde.attachments.length, 0)
|
||
assert.doesNotMatch(minus.team.text, new RegExp(HINWEIS))
|
||
})
|
||
|
||
// Karte mit Bestätigung bei der Bank: K12 geht beim Klick, T12 "wirksam" erst mit pending_update_applied; auch dieses T12 sagt, dass die PDFs fehlen
|
||
test('T12 beim späteren Wirksamwerden (Karte, pending_update_applied) trägt anhang_fehlt, solange die PDFs der Fassung 6 fehlen', async () => {
|
||
await resetHosting()
|
||
const nowU = nowUnix()
|
||
st.nextSubscriptionUpdate = {
|
||
pending_update: { expires_at: nowU + 23 * 3600, billing_cycle_anchor: nowU, subscription_items: [], trial_end: null, trial_from_plan: null },
|
||
latest_invoice: switchInvoice({ status: 'open', hosted_invoice_url: 'https://pay.example/karte' }),
|
||
}
|
||
await switchToWartung('c1', { ...consent, termsVersion: NEU }, nowU)
|
||
assert.ok(mails.sent.find((m) => m.subject === 'Ihr Wechsel ins WEBklar Wartungs-Abo – Bestätigung'), 'K12 ging beim Klick raus')
|
||
assert.equal(mails.sent.filter((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo')).length, 0, 'T12 wartet auf die Zahlung')
|
||
st.applyPendingUpdate('sub_h')
|
||
const res = await postWebhook({ id: 'evt_pua', object: 'event', type: 'customer.subscription.pending_update_applied', created: nowU, data: { object: { id: 'sub_h', object: 'subscription' } } })
|
||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||
const t12 = mails.sent.filter((m) => m.subject.startsWith('[Abo] Wechsel ins Wartungs-Abo'))
|
||
assert.equal(t12.length, 1)
|
||
assert.match(t12[0].text, new RegExp(HINWEIS))
|
||
})
|