Abo-Ende: Sofortpfad beim Widerruf des Wechsels, Team-Aktivierung nur mit laufendem Abo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6bd6aed7d5
commit
c5009da570
6 files changed
+726
-10
No files matched your search
@@ -1059,6 +1059,15 @@ async function withdrawOrder(sub, d, receivedAt) {
|
||||
} catch (err) {
|
||||
console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
|
||||
}
|
||||
// Ebenso der Terminplaner (gehoert zum Wartungs-Abo): sofort abschalten und K22 (bzw. T19 bei bezahlter Einrichtung) melden, ohne
|
||||
// Abo-Pruefung - der Spiegel steht bis zur Handarbeit des Teams noch auf Wartung. Der Widerruf scheitert nie daran; was hier
|
||||
// misslingt, holt der Tagesabgleich nach (aboLage kennt den widerrufenen Wechsel).
|
||||
try {
|
||||
const { endeTermineFuerKunde } = await import('../services/termine/aboAbgleich.js')
|
||||
await endeTermineFuerKunde(sub.customerId, { now: receivedAt })
|
||||
} catch (err) {
|
||||
console.error(`[abo] Terminplaner nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
|
||||
}
|
||||
}
|
||||
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
|
||||
import { anleitungsLink, featureConfig, featureHaken, featureKeys, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js'
|
||||
import { zustimmungFuerSitzung } from './featureConsents.js'
|
||||
import { laeuft } from './aboStatus.js'
|
||||
import { aboLage, laeuft } from './aboStatus.js'
|
||||
import { loeschTag } from './termine/frist.js'
|
||||
import { createFeatureTicket } from './ticketAdmin.js'
|
||||
|
||||
@@ -477,11 +477,35 @@ async function stopAnalyseCollecting(doc) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */
|
||||
const KEIN_ABO_AKTIVIERUNG =
|
||||
'Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.'
|
||||
|
||||
/**
|
||||
* Schutz der Team-Aktivierung (E9): ein vom Kauf freigeschalteter Terminplaner gehoert zum Wartungs-Abo. Laeuft es nicht, wuerde der
|
||||
* naechste Abgleichslauf die Kulanz zuruecknehmen und K22 ein zweites Mal senden; darum 409 ohne Schreibzugriff. Frisch gelesen
|
||||
* (aboLage direkt, nie der Memo), mit der Wechsel-Regel des Abgleichs. Ein Lesefehler ist ein Fehler (503, geloggt), nie "laeuft".
|
||||
*/
|
||||
async function assertAboLaeuftFuerAktivierung(customerId) {
|
||||
let laeuftJetzt
|
||||
try {
|
||||
laeuftJetzt = (await aboLage(customerId)).laeuft
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Wartungs-Abo von Kunde ${customerId} für die Aktivierung nicht lesbar:`, err.message)
|
||||
throw httpError(503, 'Das Wartungs-Abo des Kunden konnte nicht geprüft werden. Bitte später erneut versuchen.')
|
||||
}
|
||||
if (!laeuftJetzt) throw httpError(409, KEIN_ABO_AKTIVIERUNG)
|
||||
}
|
||||
|
||||
/**
|
||||
* Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). Ausnahme: ein Terminplaner, den ein Kauf
|
||||
* freigeschaltet hat (unlockedByPurchase true), wird nur bei laufendem Wartungs-Abo aktiviert (409 sonst, Lesefehler => Fehler).
|
||||
* News und Dokumente ohne Kauf (Pilot, Kulanz) sind nicht betroffen.
|
||||
*/
|
||||
export async function adminActivate(customerId, projectId, key, { by, adminNote } = {}) {
|
||||
assertKey(key)
|
||||
await assertOwnProject(customerId, projectId)
|
||||
const doc = await ensureFeatureDoc(customerId, projectId, key)
|
||||
if (key === 'termine' && doc.unlockedByPurchase === true) await assertAboLaeuftFuerAktivierung(customerId)
|
||||
return activate(doc.$id, { by, adminNote })
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { aboLage, aboMemo } from '../aboStatus.js'
|
||||
import { Query, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js'
|
||||
import { withLocks } from '../keyedLock.js'
|
||||
import { sendCustomerMail } from '../mailer.js'
|
||||
import { activate, customerOf, disable, meldeErstattung, projectName } from '../siteFeatures.js'
|
||||
import { activate, customerOf, disable, listCustomerFeatureDocs, meldeErstattung, projectName } from '../siteFeatures.js'
|
||||
import { loeschzeit } from './frist.js'
|
||||
import { ladeEinstellung } from './kontext.js'
|
||||
import { pruefeNow, zeitMs } from './util.js'
|
||||
@@ -33,6 +33,11 @@ import { datumKurz } from './zeit.js'
|
||||
* das Dokument `off` und wird nie automatisch aktiv. Nicht automatisch: 'abo_ende_vor_einrichtung' (die Erstattung laeuft,
|
||||
* das Team entscheidet per WOMS), 'team', 'kunde_geloescht' und Altbestand ohne Grund.
|
||||
*
|
||||
* Sofortpfad (E9): endeTermineFuerKunde(customerId) beendet die Terminplaner eines Kunden sofort, wenn dessen Wechsel ins Wartungs-
|
||||
* Abo in der Frist widerrufen wird (routes/abo.js, withdrawOrder). Dieselben Aktionen wie Schritt 1 und 1b, aber OHNE Abo-Pruefung
|
||||
* (der Spiegel steht noch auf Wartung, das Team setzt ihn von Hand zurueck); die Meldung (meldeAboEnde) ist dieselbe wie im
|
||||
* Tagesabgleich. Danach laesst der Tagesabgleich diese Dokumente in Ruhe (aboLage kennt die Wechsel-Regel: kein Flattern).
|
||||
*
|
||||
* Regeln (Block E):
|
||||
* - Eine Listenabfrage fuer alle Kunden (Entwuerfe `off` bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird
|
||||
* fuer die Vorauswahl nur einmal gelesen. Aktive Dokumente, die nicht vom Kauf freigeschaltet wurden (Pilot, Kulanz:
|
||||
@@ -56,6 +61,9 @@ const NOTIZ = 'Abgeschaltet: Wartungs-Abo beendet'
|
||||
const GRUND_VOR = 'abo_ende_vor_einrichtung'
|
||||
const NOTIZ_VOR = 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung'
|
||||
const NOTIZ_WIEDER = 'Wieder eingeschaltet: Wartungs-Abo läuft wieder'
|
||||
const NOTIZ_WECHSEL = 'Abgeschaltet: Wechsel widerrufen'
|
||||
// Lage fuer den Sofortpfad: das Abo bleibt (oder wird wieder) ein Hosting-Abo, K22 nimmt den Block hosting
|
||||
const LAGE_WECHSEL_WIDERRUFEN = { laeuft: false, art: 'wechsel_widerrufen', endedAt: null }
|
||||
|
||||
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
|
||||
|
||||
@@ -226,6 +234,58 @@ async function holeErstattungNach(doc, ctx) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sofortpfad, ein Dokument: unter der Sperre feature-admin:<projectId> frisch lesen (das Team kann inzwischen aktiviert oder
|
||||
* abgeschaltet, der Webhook die Zahlung gebucht haben) und nach dem FRISCHEN Zustand abschalten: aktiv und vom Kauf freigeschaltet =>
|
||||
* 'abo_ende', ordered/setup => 'abo_ende_vor_einrichtung' (aktionFuer, wie im Tagesabgleich). Das Abo wird nicht gelesen.
|
||||
* @returns {Promise<object|null>} das abgeschaltete Dokument (mit Kauf- und Zahlungsfeldern fuer meldeAboEnde) oder null, wenn nichts zu tun war
|
||||
*/
|
||||
async function beendeSofort(doc) {
|
||||
return withLocks([`feature-admin:${doc.projectId}`], async () => {
|
||||
const frisch = await getDocument(portalFeatures(), doc.$id)
|
||||
const aktion = aktionFuer(frisch)
|
||||
const grund = aktion === 'abschalten' ? GRUND : aktion === 'einrichtung' ? GRUND_VOR : null
|
||||
if (!grund) return null
|
||||
const abgeschaltet = await disable(frisch.$id, { reason: grund, adminNote: NOTIZ_WECHSEL })
|
||||
return abgeschaltet.state === 'disabled' && abgeschaltet.disabledReason === grund ? abgeschaltet : null // sonst war jemand schneller
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sofortpfad beim Widerruf des Wechsels ins Wartungs-Abo (E9): alle Terminplaner des Kunden, die der Wechsel getragen hat, werden
|
||||
* sofort abgeschaltet, ohne Abo-Pruefung (der Spiegel ist veraltet). Aktiv und vom Kauf freigeschaltet => 'abo_ende'; ordered/setup =>
|
||||
* 'abo_ende_vor_einrichtung'. Aktive Dokumente ohne Kauf (Pilot, Kulanz), News, 'off' und schon Abgeschaltete bleiben unberuehrt.
|
||||
* Danach je Dokument NACH der Sperre meldeAboEnde: K22 (Block hosting bzw. vor_einrichtung); eine bezahlte Einrichtung meldet zuerst
|
||||
* T19 (genau einmal), ein unbezahltes ordered bekommt weder T19 noch K22 noch Marker. Scheitert eine Meldung, holt der Tagesabgleich
|
||||
* sie nach. Ein Fehler bei einem Dokument (Lesen, Abschalten) stoppt die anderen nicht; Fehler stehen im Log (ohne Namen und Adressen).
|
||||
* Wirft nie, wie endAnalyseForCustomer.
|
||||
* @param {string} customerId
|
||||
* @param {{ now?: Date }} [options] Zeitpunkt des Widerrufs (Marker aboEndeMailAt)
|
||||
* @returns {Promise<{ beendet: string[] }>} die IDs der abgeschalteten Funktionsdokumente
|
||||
*/
|
||||
export async function endeTermineFuerKunde(customerId, { now = new Date() } = {}) {
|
||||
const beendet = []
|
||||
if (!customerId) return { beendet }
|
||||
try {
|
||||
const kandidaten = (await listCustomerFeatureDocs(customerId)).filter(
|
||||
(doc) => doc.featureKey === 'termine' && doc.projectId && ['abschalten', 'einrichtung'].includes(aktionFuer(doc))
|
||||
)
|
||||
for (const doc of kandidaten) {
|
||||
try {
|
||||
const abgeschaltet = await beendeSofort(doc)
|
||||
if (!abgeschaltet) continue
|
||||
beendet.push(abgeschaltet.$id)
|
||||
await meldeAboEnde(abgeschaltet, { lage: LAGE_WECHSEL_WIDERRUFEN, now }) // nach der Sperre; bei Misserfolg wiederholt der Tagesabgleich
|
||||
} catch (err) {
|
||||
console.error(`[termine-abo] Sofortpfad: Funktion ${doc.$id} nicht vollständig beendet, der Tagesabgleich holt nach: ${fehlerText(err)}`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[termine-abo] Sofortpfad: Funktionen eines Kunden nicht lesbar, nichts beendet: ${fehlerText(err)}`)
|
||||
}
|
||||
return { beendet }
|
||||
}
|
||||
|
||||
/**
|
||||
* K23 an die E-Mail des Kundenkontos. Nur nach geschriebenem `active` gerufen, nie innerhalb einer Sperre. Scheitert der Versand,
|
||||
* bleibt die Reaktivierung bestehen: das Log (ohne Adresse) und `fehler` halten es fest; der Marker aboEndeMailAt ist mit dem
|
||||
|
||||
@@ -14,7 +14,7 @@ const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, get, rawGet, post } = await import('./support/testApp.js')
|
||||
const { customerCookie, seedFixtures } = await import('./support/fixtures.js')
|
||||
const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc, hostingDoc } = await import('./support/fixtures.js')
|
||||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||||
const { default: teamRoutes } = await import('../server/routes/team/index.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
@@ -263,3 +263,132 @@ test('Kunde loeschen gelingt auch, wenn das Abschalten scheitert (Fehler im Log)
|
||||
assert.equal(aw.docs('customers').has('kt2'), false)
|
||||
assert.ok(log.mock.calls.some((c) => /Terminplaner von Kunde kt2 nicht abgeschaltet/.test(String(c.arguments[0]))))
|
||||
})
|
||||
|
||||
// ------------------------------------------------ Team-Aktivierung nur mit laufendem Abo (E9) ---
|
||||
// Ein bezahlter Terminplaner (unlockedByPurchase) darf das Team nur einschalten, solange das Wartungs-Abo laeuft: sonst nimmt der
|
||||
// naechste Abgleichslauf die Kulanz zurueck und K22 ginge ein zweites Mal. Pilot und Kulanz ohne Kauf sowie News sind nicht betroffen.
|
||||
const KEIN_ABO = 'Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.'
|
||||
let kaNr = 0
|
||||
/** Kunde mit Projekt und Funktionsdokument (key, over); abo: 'wartung' | 'hosting' | null (kein Abo). Liefert { customerId, projectId, docId }. */
|
||||
function legeKunde({ abo = null, aboOver = {}, key = 'termine', over = {} } = {}) {
|
||||
kaNr += 1
|
||||
const customerId = `ka${kaNr}`
|
||||
const projectId = `kap${kaNr}`
|
||||
const docId = `f_${projectId}_${key}`
|
||||
aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${kaNr}` })
|
||||
if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_ka${kaNr}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId, ...aboOver }))
|
||||
aw.seed('portalFeatures', {
|
||||
$id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state: 'disabled', enabled: false,
|
||||
unlockedByPurchase: true, purchaseStatus: 'paid', disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z',
|
||||
aboEndeMailAt: '2026-10-01T10:05:00.000Z', adminNote: 'Abgeschaltet: Wartungs-Abo beendet', ...over,
|
||||
})
|
||||
return { customerId, projectId, docId }
|
||||
}
|
||||
const docById = (id) => aw.docs('portalFeatures').get(id)
|
||||
const aktivieren = (k, key = 'termine') => team(k.customerId, key, { projectId: k.projectId, action: 'activate', note: 'Kulanz' })
|
||||
|
||||
test('Team aktiviert einen bezahlten, abgeschalteten Terminplaner ohne laufendes Abo nicht: 409 mit Text, Zustand unveraendert', async () => {
|
||||
for (const abo of [null, 'hosting']) {
|
||||
const k = legeKunde({ abo })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
const res = await aktivieren(k)
|
||||
assert.equal(res.status, 409, `abo ${abo}`)
|
||||
assert.equal(res.body.error, KEIN_ABO)
|
||||
assert.deepEqual(docById(k.docId), vorher, 'kein Schreibzugriff: Zustand, Grund, Frist und Notiz bleiben')
|
||||
}
|
||||
// auch ein bezahlter Terminplaner, den das Team abgeschaltet hat, und ein ausgebliebenes Abo nach Kuendigung (canceled)
|
||||
const team_aus = legeKunde({ over: { disabledReason: 'team', aboEndeMailAt: null } })
|
||||
assert.equal((await aktivieren(team_aus)).status, 409)
|
||||
assert.equal(docById(team_aus.docId).state, 'disabled')
|
||||
const beendet = legeKunde({ abo: 'wartung', aboOver: { status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' } })
|
||||
assert.equal((await aktivieren(beendet)).status, 409)
|
||||
assert.equal(docById(beendet.docId).state, 'disabled')
|
||||
})
|
||||
|
||||
test('Bezahlter Terminplaner in Einrichtung (setup) ohne Abo: das Team kann ihn auch nicht von Hand freischalten', async () => {
|
||||
const k = legeKunde({ over: { state: 'setup', disabledReason: null, disabledAt: null, aboEndeMailAt: null, adminNote: '' } })
|
||||
const res = await aktivieren(k)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, KEIN_ABO)
|
||||
assert.equal(docById(k.docId).state, 'setup')
|
||||
})
|
||||
|
||||
test('Mit laufendem Wartungs-Abo aktiviert das Team den bezahlten Terminplaner: active, Grund und Frist leer, Marker leer', async () => {
|
||||
const k = legeKunde({ abo: 'wartung' })
|
||||
const res = await aktivieren(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const doc = docById(k.docId)
|
||||
assert.deepEqual([doc.state, doc.enabled, doc.disabledReason, doc.disabledAt, doc.aboEndeMailAt], ['active', true, null, null, null])
|
||||
assert.equal(doc.unlockedByPurchase, true, 'der Kauf bleibt vermerkt')
|
||||
assert.equal(featuresOf(res, k.projectId).termine.state, 'active')
|
||||
// eine vorgemerkte Kuendigung laesst das Abo bis zum Ende laufen
|
||||
const gekuendigt = legeKunde({ abo: 'wartung', aboOver: { cancelAtPeriodEnd: true } })
|
||||
assert.equal((await aktivieren(gekuendigt)).status, 200)
|
||||
assert.equal(docById(gekuendigt.docId).state, 'active')
|
||||
})
|
||||
|
||||
test('Wechsel widerrufen (Spiegel steht noch auf Wartung): 409, denn das Wartungs-Abo laeuft nicht', async () => {
|
||||
const k = legeKunde({ abo: 'wartung' })
|
||||
aw.seed('contractEvents', {
|
||||
$id: `ev_${k.customerId}`, customerId: k.customerId, subscriptionId: `sub_ka${kaNr}`, type: 'wechsel', productKey: 'wartung', details: '{}', status: 'widerrufen',
|
||||
})
|
||||
const res = await aktivieren(k)
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, KEIN_ABO)
|
||||
assert.equal(docById(k.docId).state, 'disabled')
|
||||
})
|
||||
|
||||
test('Ohne Kauf (unlockedByPurchase false: Pilot, Kulanz) braucht die Aktivierung kein Abo und liest es nicht', async () => {
|
||||
for (const unlockedByPurchase of [false, undefined]) {
|
||||
const k = legeKunde({ over: { unlockedByPurchase, purchaseStatus: '', disabledReason: 'team', aboEndeMailAt: null } })
|
||||
const vorher = aw.listCalls('subscriptions').length
|
||||
const res = await aktivieren(k)
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
assert.equal(aw.listCalls('subscriptions').length, vorher, 'keine Abo-Abfrage')
|
||||
}
|
||||
// kein Dokument bisher (ensureFeatureDoc legt es an): ebenfalls frei
|
||||
aw.seed('websiteProjects', { $id: 'kap_neu', customerId: 'ka_neu', projectName: 'Neu' })
|
||||
const neu = await team('ka_neu', 'termine', { projectId: 'kap_neu', action: 'activate' })
|
||||
assert.equal(neu.status, 200)
|
||||
})
|
||||
|
||||
test('News ist nicht betroffen: auch mit unlockedByPurchase und ohne Abo aktiviert das Team', async () => {
|
||||
const k = legeKunde({ key: 'news', over: { disabledReason: 'team', aboEndeMailAt: null } })
|
||||
const res = await aktivieren(k, 'news')
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
})
|
||||
|
||||
test('Abo nicht lesbar: Fehler (5xx), nicht aktiviert; danach geht es mit lesbarem Abo', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
const k = legeKunde({ abo: 'wartung' })
|
||||
const vorher = { ...docById(k.docId) }
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'kaputt' })
|
||||
const res = await aktivieren(k)
|
||||
assert.ok(res.status >= 500 && res.status < 600, `Status ${res.status}`)
|
||||
assert.equal(typeof res.body.error, 'string')
|
||||
assert.deepEqual(docById(k.docId), vorher, 'nicht aktiviert')
|
||||
assert.ok(log.mock.calls.length >= 1, 'der Fehler steht im Log')
|
||||
assert.equal((await aktivieren(k)).status, 200)
|
||||
assert.equal(docById(k.docId).state, 'active')
|
||||
})
|
||||
|
||||
test('Ereignisse nicht lesbar (Wechsel-Regel): Fehler, nicht aktiviert', async (t) => {
|
||||
t.mock.method(console, 'error', () => {})
|
||||
const k = legeKunde({ abo: 'wartung' })
|
||||
aw.failNext({ collection: 'contractEvents', method: 'GET', status: 500 })
|
||||
const res = await aktivieren(k)
|
||||
assert.ok(res.status >= 500 && res.status < 600, `Status ${res.status}`)
|
||||
assert.equal(docById(k.docId).state, 'disabled')
|
||||
})
|
||||
|
||||
test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei laufendem Abo und fuer Dokumente ohne Kauf aktiv', async () => {
|
||||
const k = legeKunde()
|
||||
await assert.rejects(() => sf.adminActivate(k.customerId, k.projectId, 'termine', { by: 'test' }), { status: 409, message: KEIN_ABO })
|
||||
assert.equal(docById(k.docId).state, 'disabled')
|
||||
const mit = legeKunde({ abo: 'wartung' })
|
||||
assert.equal((await sf.adminActivate(mit.customerId, mit.projectId, 'termine', { by: 'test' })).state, 'active')
|
||||
const pilot = legeKunde({ over: { unlockedByPurchase: false } })
|
||||
assert.equal((await sf.adminActivate(pilot.customerId, pilot.projectId, 'termine', { by: 'test' })).state, 'active')
|
||||
})
|
||||
+205
-5
@@ -29,6 +29,8 @@ const { listContractEvents, recordContractEvent } = await import('../server/serv
|
||||
const { withdrawalDeadline } = await import('../server/services/deadlines.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { gleicheAboAb } = await import('../server/services/termine/aboAbgleich.js')
|
||||
const { handleFeatureCheckoutEvent } = await import('../server/services/siteFeatures.js')
|
||||
|
||||
const { analyseDeps } = await import('../server/services/analyse.js')
|
||||
const app = await startTestApp({ '/api/abo': aboRoutes })
|
||||
@@ -445,6 +447,199 @@ test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Pre
|
||||
assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/)
|
||||
})
|
||||
|
||||
// Terminplaner (Dokument wf_termine) gehoert zum Wartungs-Abo: der Widerruf des Wechsels beendet ihn sofort (E9, endeTermineFuerKunde)
|
||||
const TERMINE = 'wf_termine'
|
||||
const termine = () => aw.docs('portalFeatures').get(TERMINE)
|
||||
const k22 = () => mails.sent.filter((m) => /^Dein Terminplaner wurde beendet/.test(m.subject))
|
||||
const k23 = () => mails.sent.filter((m) => /^Dein Terminplaner läuft wieder/.test(m.subject))
|
||||
const t19 = () => mails.sent.filter((m) => m.subject.startsWith('[Abo] Erstattung fällig'))
|
||||
const aufraeumen = async () => {
|
||||
for (const id of [TERMINE, 'wf_feat']) await deleteDocument(config.collections.portalFeatures, id).catch(() => {})
|
||||
await deleteDocument('workorders', 't_wf').catch(() => {})
|
||||
}
|
||||
/** Fuehrt fn mit einem Terminplaner-Dokument (Vorgabe: aktiv, bezahlt) von c1 aus und raeumt danach auf (andere Tests sehen es nie). */
|
||||
async function mitTermine(over, fn) {
|
||||
aw.seed('portalFeatures', {
|
||||
$id: TERMINE, customerId: 'c1', projectId: 'wf_pt', featureKey: 'termine', state: 'active', enabled: true,
|
||||
unlockedByPurchase: true, purchaseStatus: 'paid', siteKey: 'wftermine01234567', ...over,
|
||||
})
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
await aufraeumen()
|
||||
}
|
||||
}
|
||||
/** Bezahlter Aufbau: setup, Sitzung cs_test_1, Rechnungslink, offenes Ticket WO-4711. */
|
||||
const AUFBAU = { state: 'setup', enabled: false, stripeSessionId: 'cs_test_1', stripeInvoiceId: 'in_1', invoiceUrl: 'https://invoice.stripe.test/in_1', ticketId: 't_wf' }
|
||||
const legeTicket = () => aw.seed('workorders', { $id: 't_wf', woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen' })
|
||||
const T19_BETREFF = '[Abo] Erstattung fällig: Terminplaner-Einrichtung'
|
||||
/** Der Spiegel steht auf Wartung (so war es nach dem Wechsel): der Tagesabgleich darf den Widerruf nur ueber die Wechsel-Regel kennen. */
|
||||
const wartungSpiegel = () => resetSubH({ units: 0, patch: { productKey: 'wartung' } })
|
||||
|
||||
test('Widerruf Wechsel in der Frist beendet den bezahlten Terminplaner (disabled/abo_ende, K22 Hosting); die Analyse verhält sich wie zuvor', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await withActiveAnalyse(async (stops) => {
|
||||
await mitTermine({}, async () => {
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const d = termine()
|
||||
assert.deepEqual([d.state, d.enabled, d.disabledReason, d.adminNote], ['disabled', false, 'abo_ende', 'Abgeschaltet: Wechsel widerrufen'])
|
||||
assert.ok(Math.abs(Date.parse(d.disabledAt) - Date.now()) < 10000)
|
||||
assert.ok(d.aboEndeMailAt, 'Marker nach K22')
|
||||
assert.equal(k22().length, 1)
|
||||
assert.deepEqual(k22()[0].to.map((a) => a.address), ['k@example.invalid'])
|
||||
assert.match(k22()[0].text, /weil Dein Abo ein Hosting-Abo ist/)
|
||||
assert.equal(t19().length, 0)
|
||||
// Analyse wie bisher: Messdienst aus, off, K19
|
||||
assert.deepEqual(stops, ['wfsitekey0123456789'])
|
||||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'off')
|
||||
assert.ok(bySubject('Deine Website-Analyse wurde beendet'))
|
||||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||||
assert.ok(t09() && k04(), 'K04 und T09 wie immer')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel: scheitert die Abschaltung des Terminplaners, bleibt der Widerruf 200; der Tagesabgleich holt es über die Wechsel-Regel nach', async (t) => {
|
||||
const log = t.mock.method(console, 'error', () => {})
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await mitTermine({}, async () => {
|
||||
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||||
assert.ok((await events('widerruf'))[0], 'Widerrufsereignis gespeichert')
|
||||
assert.equal(termine().state, 'active', 'nicht abgeschaltet')
|
||||
assert.equal(k22().length, 0)
|
||||
assert.ok(log.mock.calls.some((c) => /Sofortpfad/.test(String(c.arguments[0]))), 'der Fehler steht im Log')
|
||||
assert.ok(k04() && t09())
|
||||
// der Abgleich am Folgetag schaltet ab (Spiegel Wartung, aber der Wechsel ist widerrufen) und sendet K22
|
||||
const lauf = await gleicheAboAb(new Date())
|
||||
assert.equal(lauf.abgeschaltet, 1)
|
||||
assert.equal(termine().disabledReason, 'abo_ende')
|
||||
assert.equal(k22().length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel: Pilot- und Kulanz-Terminplaner (ohne Kauf) bleiben aktiv', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await mitTermine({ unlockedByPurchase: false, purchaseStatus: '' }, async () => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal(termine().state, 'active')
|
||||
assert.equal(k22().length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel, bezahlter Aufbau: abo_ende_vor_einrichtung, genau ein T19 (Ticket offen), K22 vor_einrichtung, Marker, gemeldet; der Abgleich sendet danach nichts mehr', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
legeTicket()
|
||||
await mitTermine(AUFBAU, async () => {
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const d = termine()
|
||||
assert.deepEqual([d.state, d.disabledReason, d.purchaseStatus, d.zahlungMeldung, d.stripeSessionId], ['disabled', 'abo_ende_vor_einrichtung', 'paid', 'gemeldet', 'cs_test_1'])
|
||||
assert.ok(d.aboEndeMailAt)
|
||||
assert.equal(t19().length, 1)
|
||||
assert.match(t19()[0].text, /Ticket noch offen/)
|
||||
assert.match(t19()[0].text, /WO-4711/)
|
||||
assert.match(t19()[0].text, /cs_test_1/)
|
||||
assert.equal(k22().length, 1)
|
||||
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
|
||||
assert.deepEqual(mails.sent.filter((m) => m.subject === T19_BETREFF || /^Dein Terminplaner wurde beendet/.test(m.subject)).map((m) => m.subject === T19_BETREFF), [true, false], 'T19 vor K22')
|
||||
assert.equal(aw.docs('workorders').get('t_wf').status, 'Open', 'das Ticket bleibt')
|
||||
|
||||
const vorher = { ...termine() }
|
||||
const anzahl = mails.sent.length
|
||||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||||
assert.deepEqual(lauf, { geprueft: 1, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 })
|
||||
assert.deepEqual(termine(), vorher)
|
||||
assert.equal(mails.sent.length, anzahl, 'weder T19 noch K22 noch K23')
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel, unbezahltes ordered: abgeschaltet ohne T19, K22 und Marker; die spätere Zahlung meldet T19 genau einmal, der Abgleich K22', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await mitTermine({ state: 'ordered', enabled: false, unlockedByPurchase: false, purchaseStatus: 'ordered', stripeSessionId: 'cs_test_1' }, async () => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
let d = termine()
|
||||
assert.deepEqual([d.state, d.disabledReason, d.stripeSessionId], ['disabled', 'abo_ende_vor_einrichtung', 'cs_test_1'])
|
||||
assert.ok(!d.aboEndeMailAt && !d.zahlungMeldung, 'kein Marker')
|
||||
assert.equal(k22().length + t19().length, 0)
|
||||
|
||||
await handleFeatureCheckoutEvent({
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: 'c1', projectId: 'wf_pt', featureKey: 'termine' } } },
|
||||
})
|
||||
assert.equal(t19().length, 1)
|
||||
assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.equal(k22().length, 0)
|
||||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||||
assert.equal(lauf.mails, 1)
|
||||
assert.equal(t19().length, 1, 'kein zweites T19')
|
||||
assert.equal(k22().length, 1)
|
||||
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
|
||||
assert.equal(termine().state, 'disabled')
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel, bezahlter Aufbau: scheitert T19, bleibt der Widerruf 200, K22 und Marker fehlen; der Abgleich am Folgetag sendet T19 und K22 nach', async (t) => {
|
||||
t.mock.method(console, 'error', () => {})
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
legeTicket()
|
||||
await mitTermine(AUFBAU, async () => {
|
||||
setMailTransportForTests({
|
||||
sendMail: async (mail) => {
|
||||
if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen')
|
||||
return mails.sendMail(mail)
|
||||
},
|
||||
})
|
||||
try {
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
} finally {
|
||||
setMailTransportForTests(mails)
|
||||
}
|
||||
const d = termine()
|
||||
assert.deepEqual([d.state, d.disabledReason], ['disabled', 'abo_ende_vor_einrichtung'])
|
||||
assert.ok(!d.aboEndeMailAt && !d.zahlungMeldung)
|
||||
assert.equal(t19().length + k22().length, 0)
|
||||
assert.ok(k04() && t09(), 'K04 und T09 gehen trotzdem')
|
||||
|
||||
const lauf = await gleicheAboAb(new Date(Date.now() + DAY_MS))
|
||||
assert.equal(lauf.mails, 1)
|
||||
assert.equal(t19().length, 1)
|
||||
assert.equal(k22().length, 1)
|
||||
assert.equal(termine().zahlungMeldung, 'gemeldet')
|
||||
assert.ok(termine().aboEndeMailAt)
|
||||
})
|
||||
})
|
||||
|
||||
test('Kein Flattern: nach dem Sofortpfad (Spiegel Wartung/active, Wechsel widerrufen) bleibt der Terminplaner abgeschaltet, kein K23, kein zweites K22', async () => {
|
||||
await wartungSpiegel()
|
||||
await wechselIn(3)
|
||||
await mitTermine({}, async () => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal(termine().state, 'disabled')
|
||||
assert.equal(k22().length, 1)
|
||||
assert.equal(aw.docs('subscriptions').get('sub_h').productKey, 'wartung')
|
||||
assert.equal(aw.docs('subscriptions').get('sub_h').status, 'active')
|
||||
const vorher = { ...termine() }
|
||||
for (const tage of [1, 2, 40]) {
|
||||
const lauf = await gleicheAboAb(new Date(Date.now() + tage * DAY_MS))
|
||||
assert.equal(lauf.wiederEin + lauf.abgeschaltet + lauf.mails + lauf.fehler, 0, `Tag ${tage}`)
|
||||
}
|
||||
assert.deepEqual(termine(), vorher)
|
||||
assert.equal(k23().length, 0)
|
||||
assert.equal(k22().length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
// Analyse hat nur mit Wartungs-Abo Bestand: der Widerruf des Wechsels beendet sie (Messdienst aus, K19)
|
||||
async function withActiveAnalyse(fn) {
|
||||
const stops = []
|
||||
@@ -489,14 +684,19 @@ test('Widerruf Wechsel in der Frist beendet die Analyse des Kunden (stopSite, of
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse nicht', async () => {
|
||||
test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse und den Terminplaner nicht', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
await wechselIn(30)
|
||||
await withActiveAnalyse(async (stops) => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||||
assert.deepEqual(stops, [])
|
||||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active')
|
||||
await mitTermine({}, async () => {
|
||||
const vorher = { ...termine() }
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||||
assert.deepEqual(stops, [])
|
||||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active')
|
||||
assert.deepEqual(termine(), vorher, 'Terminplaner unveraendert')
|
||||
assert.equal(k22().length + t19().length, 0, 'weder K22 noch T19')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ 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 { disable, activateClosedTickets, handleFeatureCheckoutEvent } = await import('../server/services/siteFeatures.js')
|
||||
const { gleicheAboAb, meldeAboEnde } = await import('../server/services/termine/aboAbgleich.js')
|
||||
const { gleicheAboAb, meldeAboEnde, endeTermineFuerKunde } = await import('../server/services/termine/aboAbgleich.js')
|
||||
after(() => setMailTransportForTests(null))
|
||||
|
||||
const TAG = 24 * 60 * 60 * 1000
|
||||
@@ -1593,3 +1593,297 @@ test('Wechsel widerrufen: aktiv => abgeschaltet (K22 hosting), abgeschaltet blei
|
||||
assert.equal(k23().length, 1)
|
||||
assert.equal(k22().length, 1)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- Sofortpfad: Widerruf des Wechsels (E9)
|
||||
// endeTermineFuerKunde(customerId, { now }) beendet die Terminplaner eines Kunden sofort, ohne das Abo zu lesen (der Spiegel ist
|
||||
// veraltet), und meldet danach ueber meldeAboEnde (nach der Sperre feature-admin). Der Tagesabgleich laesst das Ergebnis in Ruhe.
|
||||
|
||||
const NOTIZ_WECHSEL = 'Abgeschaltet: Wechsel widerrufen'
|
||||
|
||||
/** endeTermineFuerKunde "zur Zeit now": die Wanduhr steht fest, damit disable() (new Date()) denselben Zeitpunkt schreibt. */
|
||||
async function beende(kunde = KUNDE, now = JETZT) {
|
||||
mock.timers.enable({ apis: ['Date'], now: now.getTime() })
|
||||
try {
|
||||
return await endeTermineFuerKunde(kunde, { now })
|
||||
} finally {
|
||||
mock.timers.reset()
|
||||
}
|
||||
}
|
||||
|
||||
test('Sofortpfad: aktiver, bezahlter Terminplaner => abgeschaltet (abo_ende, Notiz Wechsel widerrufen), K22 Block Hosting, Marker; das Abo wird nie gelesen', async () => {
|
||||
legeFunktion(1)
|
||||
legeDaten(1)
|
||||
const daten = datenStand(1)
|
||||
|
||||
assert.deepEqual(await beende(), { beendet: [F1] })
|
||||
const doc = funktion()
|
||||
assert.equal(doc.state, 'disabled')
|
||||
assert.equal(doc.enabled, false)
|
||||
assert.equal(doc.disabledReason, 'abo_ende')
|
||||
assert.equal(ms(doc.disabledAt), JETZT.getTime())
|
||||
assert.equal(doc.adminNote, NOTIZ_WECHSEL)
|
||||
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime(), 'Marker nach dem Versand, mit now')
|
||||
assert.equal(abfragenAufAbos(), 0, 'weder Abo noch Spiegel gelesen')
|
||||
assert.equal(aw.listCalls('contractEvents').length, 0)
|
||||
assert.deepEqual(datenStand(1), daten, 'Einstellung und Buchungen bleiben (30 Tage Frist)')
|
||||
|
||||
assert.equal(k22().length, 1)
|
||||
const mail = k22()[0]
|
||||
assert.deepEqual(mail.to.map((a) => a.address), ['silke@example.com'])
|
||||
assert.match(mail.text, /weil Dein Abo ein Hosting-Abo ist/, 'Block hosting: der Wechsel wurde widerrufen, das Abo bleibt Hosting')
|
||||
assert.match(mail.text, /Ab 04\.11\.2026 löschen wir/, 'Loeschdatum = Sofortpfad + 30 Tage')
|
||||
assert.equal(t19().length, 0, 'aktiv und bezahlt hat nichts zu erstatten')
|
||||
|
||||
// der Tagesabgleich findet nichts mehr zu tun, auch ohne Abo
|
||||
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(k22().length, 1)
|
||||
assert.equal(funktion().adminNote, NOTIZ_WECHSEL)
|
||||
})
|
||||
|
||||
test('Sofortpfad: Pilot und Kulanz (aktiv ohne Kauf), News, off, schon Abgeschaltete und andere Kunden bleiben unberuehrt', async () => {
|
||||
legeFunktion(1) // bezahlt: wird beendet
|
||||
legeFunktion(2, { unlockedByPurchase: false, purchaseStatus: '' }) // Pilot
|
||||
legeFunktion(3, { featureKey: 'news' })
|
||||
legeFunktion(4, { state: 'off', enabled: false, unlockedByPurchase: false })
|
||||
legeFunktion(5, { state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: plus(JETZT, -2 * TAG).toISOString() })
|
||||
legeFunktion(6, { state: 'disabled', enabled: false, disabledReason: 'abo_ende', disabledAt: plus(JETZT, -2 * TAG).toISOString() }) // K22 noch offen: Sache des Tagesabgleichs
|
||||
aw.seed('customers', { $id: 'c_anderer', name: 'Anderer Kunde', email: 'anderer@example.com' })
|
||||
legeFunktion(7, {}, { kunde: 'c_anderer' })
|
||||
const unberuehrt = [2, 3, 4, 5, 6, 7].map((nr) => ({ ...funktion(funktionsId(nr)) }))
|
||||
|
||||
assert.deepEqual(await beende(), { beendet: [F1] })
|
||||
assert.deepEqual([2, 3, 4, 5, 6, 7].map((nr) => funktion(funktionsId(nr))), unberuehrt)
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.equal(mails().length, 1)
|
||||
assert.deepEqual(mails()[0].to.map((a) => a.address), ['silke@example.com'])
|
||||
assert.deepEqual(await beende('c_ohne_funktionen'), { beendet: [] })
|
||||
assert.deepEqual(await beende(''), { beendet: [] })
|
||||
})
|
||||
|
||||
test('Sofortpfad: mehrere Projekte des Kunden, aktiv und im Aufbau, jedes unter seinem Grund', async () => {
|
||||
legeFunktion(1)
|
||||
legeAufbau(2)
|
||||
legeBestellung(3)
|
||||
|
||||
assert.deepEqual((await beende()).beendet.sort(), [1, 2, 3].map(funktionsId))
|
||||
assert.deepEqual([1, 2, 3].map((nr) => funktion(funktionsId(nr)).disabledReason), ['abo_ende', 'abo_ende_vor_einrichtung', 'abo_ende_vor_einrichtung'])
|
||||
assert.equal(t19().length, 1, 'nur die bezahlte Einrichtung wird erstattet')
|
||||
assert.equal(k22().length, 2, 'K22 fuer den aktiven (abo_ende) und die bezahlte Einrichtung (vor_einrichtung), nicht fuer das unbezahlte ordered')
|
||||
assert.equal(abfragenAufAbos(), 0)
|
||||
})
|
||||
|
||||
test('Sofortpfad, bezahlter Aufbau (setup): abgeschaltet vor der Einrichtung, genau ein T19 (Ticket offen), K22 vor_einrichtung, Marker, gemeldet; der Tagesabgleich sendet nichts mehr', async () => {
|
||||
legeAufbau(1)
|
||||
const ticketVorher = { ...aw.docs('workorders').get('t_1') }
|
||||
|
||||
assert.deepEqual(await beende(), { beendet: [F1] })
|
||||
const doc = funktion()
|
||||
assert.equal(doc.state, 'disabled')
|
||||
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
|
||||
assert.equal(doc.adminNote, NOTIZ_WECHSEL)
|
||||
assert.equal(doc.purchaseStatus, 'paid')
|
||||
assert.equal(doc.zahlungMeldung, 'gemeldet')
|
||||
assert.equal(ms(doc.aboEndeMailAt), JETZT.getTime())
|
||||
assert.equal(doc.stripeSessionId, 'cs_test_1')
|
||||
|
||||
assert.equal(t19().length, 1)
|
||||
assert.match(t19()[0].text, /Ticket noch offen/)
|
||||
assert.match(t19()[0].text, /WO-4711/)
|
||||
assert.doesNotMatch(t19()[0].text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.equal(k22().length, 1)
|
||||
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
|
||||
assert.doesNotMatch(k22()[0].text, /Was jetzt gilt|löschen wir/)
|
||||
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false], 'zuerst T19, dann K22')
|
||||
assert.deepEqual(aw.docs('workorders').get('t_1'), ticketVorher, 'das Ticket bleibt, wie es ist')
|
||||
|
||||
// der folgende Abgleichslauf: weder T19 noch K22 (auch wenn der Spiegel noch Wartung sagt, der Wechsel aber widerrufen ist)
|
||||
wartung('active')
|
||||
wechselEreignis('widerrufen')
|
||||
const vorher = { ...funktion() }
|
||||
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }))
|
||||
assert.deepEqual(funktion(), vorher)
|
||||
assert.equal(t19().length, 1)
|
||||
assert.equal(k22().length, 1)
|
||||
})
|
||||
|
||||
test('Sofortpfad, unbezahltes ordered: abgeschaltet, kein T19, kein K22, kein Marker; kommt die Zahlung danach, meldet das Ereignis T19 genau einmal und der Abgleich K22', async () => {
|
||||
legeBestellung(1)
|
||||
|
||||
assert.deepEqual(await beende(), { beendet: [F1] })
|
||||
let doc = funktion()
|
||||
assert.equal(doc.state, 'disabled')
|
||||
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
|
||||
assert.equal(doc.adminNote, NOTIZ_WECHSEL)
|
||||
assert.ok(!doc.aboEndeMailAt, 'kein Marker')
|
||||
assert.ok(!doc.zahlungMeldung)
|
||||
assert.equal(doc.stripeSessionId, 'cs_test_1', 'die offene Sitzung wird nicht angefasst')
|
||||
assert.equal(doc.unlockedByPurchase, false)
|
||||
assert.equal(mails().length, 0)
|
||||
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1 }), 'der Abgleich laesst es in Ruhe')
|
||||
assert.equal(mails().length, 0)
|
||||
|
||||
await handleFeatureCheckoutEvent({
|
||||
type: 'checkout.session.completed',
|
||||
data: { object: { id: 'cs_test_1', payment_status: 'paid', metadata: { customerId: KUNDE, projectId: 'p_projekt01', featureKey: 'termine' } } },
|
||||
})
|
||||
assert.equal(t19().length, 1)
|
||||
assert.match(t19()[0].text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.equal(k22().length, 0, 'K22 gehoert dem Abgleich')
|
||||
assert.deepEqual(await laufe(plus(JETZT, 2 * TAG)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(t19().length, 1, 'kein zweites T19')
|
||||
assert.equal(k22().length, 1)
|
||||
assert.match(k22()[0].text, /noch vor der Einrichtung Deines Terminplaners/)
|
||||
assert.deepEqual(await laufe(plus(JETZT, 3 * TAG)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mails().length, 2)
|
||||
})
|
||||
|
||||
test('Sofortpfad: T19 scheitert => beendet wird gemeldet, ohne Wurf; K22 und Marker fehlen; der Tagesabgleich sendet T19 und K22 nach, in dieser Reihenfolge', async () => {
|
||||
legeAufbau(1)
|
||||
setMailTransportForTests({
|
||||
sendMail: async (mail) => {
|
||||
if (mail.subject === T19_BETREFF) throw new Error('Verbindung abgebrochen')
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
assert.deepEqual(await beende(), { beendet: [F1] })
|
||||
let doc = funktion()
|
||||
assert.equal(doc.state, 'disabled', 'abgeschaltet wird trotzdem')
|
||||
assert.equal(doc.disabledReason, 'abo_ende_vor_einrichtung')
|
||||
assert.ok(!doc.zahlungMeldung, 'T19 nicht gesendet: kein Marker gemeldet')
|
||||
assert.ok(!doc.aboEndeMailAt)
|
||||
assert.equal(mails().length, 0, 'auch K22 geht erst nach T19')
|
||||
assert.ok(log.error.mock.calls.length >= 1)
|
||||
const disabledAt = doc.disabledAt
|
||||
|
||||
setMailTransportForTests(sink)
|
||||
const naechster = plus(JETZT, TAG)
|
||||
assert.deepEqual(await laufe(naechster), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.deepEqual(mails().map((m) => m.subject === T19_BETREFF), [true, false])
|
||||
doc = funktion()
|
||||
assert.equal(doc.zahlungMeldung, 'gemeldet')
|
||||
assert.equal(ms(doc.aboEndeMailAt), naechster.getTime())
|
||||
assert.equal(doc.disabledAt, disabledAt, 'die Frist wuchs nicht')
|
||||
assert.deepEqual(await laufe(plus(naechster, TAG)), ergebnis({ geprueft: 1 }))
|
||||
assert.equal(mails().length, 2)
|
||||
})
|
||||
|
||||
test('Sofortpfad: K22 scheitert bei einem aktiven Terminplaner => beendet, kein Marker; der Tagesabgleich holt K22 nach', async () => {
|
||||
legeFunktion(1)
|
||||
wartung('active') // Spiegel noch Wartung, der Wechsel ist widerrufen: der Abgleich nimmt denselben Block hosting
|
||||
wechselEreignis('widerrufen')
|
||||
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
|
||||
assert.deepEqual(await beende(), { beendet: [F1] })
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.ok(!funktion().aboEndeMailAt)
|
||||
const disabledAt = funktion().disabledAt
|
||||
setMailTransportForTests(sink)
|
||||
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
assert.equal(k22().length, 1)
|
||||
assert.match(k22()[0].text, /weil Dein Abo ein Hosting-Abo ist/)
|
||||
assert.match(k22()[0].text, /Ab 04\.11\.2026 löschen wir/, 'Loeschdatum aus dem Sofortpfad (disabledAt), nicht vom Folgetag')
|
||||
assert.equal(funktion().disabledAt, disabledAt, 'die Frist wuchs nicht')
|
||||
assert.ok(funktion().aboEndeMailAt)
|
||||
})
|
||||
|
||||
test('Sofortpfad: scheitert ein Dokument, werden die anderen trotzdem beendet; der Fehler steht im Log (ohne Adresse)', async () => {
|
||||
legeFunktion(1)
|
||||
legeFunktion(2)
|
||||
aw.failNext({ collection: 'portalFeatures', method: 'PATCH', status: 500, message: 'kaputt' })
|
||||
|
||||
const erg = await beende()
|
||||
assert.equal(erg.beendet.length, 1)
|
||||
const [beendet] = erg.beendet
|
||||
const uebrig = [F1, funktionsId(2)].find((id) => id !== beendet)
|
||||
assert.equal(funktion(beendet).state, 'disabled')
|
||||
assert.equal(funktion(uebrig).state, 'active', 'das gescheiterte bleibt aktiv (der Tagesabgleich holt es nach)')
|
||||
assert.equal(k22().length, 1)
|
||||
assert.ok(log.error.mock.calls.length >= 1)
|
||||
|
||||
// ein zweiter Aufruf beendet den Rest
|
||||
assert.deepEqual(await beende(), { beendet: [uebrig] })
|
||||
assert.equal(k22().length, 2)
|
||||
})
|
||||
|
||||
test('Sofortpfad: Dokumentenliste nicht lesbar => kein Wurf, nichts beendet, Fehler im Log', async () => {
|
||||
legeFunktion(1)
|
||||
aw.failNext({ collection: 'portalFeatures', method: 'GET', status: 500, message: 'kaputt' })
|
||||
assert.deepEqual(await beende(), { beendet: [] })
|
||||
assert.equal(funktion().state, 'active')
|
||||
assert.equal(mails().length, 0)
|
||||
assert.ok(log.error.mock.calls.length >= 1)
|
||||
assert.deepEqual(await beende(), { beendet: [F1] }, 'danach geht es wieder')
|
||||
})
|
||||
|
||||
test('Sofortpfad: ungueltiges now wirft nicht (die Meldung scheitert, das Dokument ist abgeschaltet)', async () => {
|
||||
legeFunktion(1)
|
||||
mock.timers.enable({ apis: ['Date'], now: JETZT.getTime() })
|
||||
let erg
|
||||
try {
|
||||
erg = await endeTermineFuerKunde(KUNDE, { now: 'gestern' })
|
||||
} finally {
|
||||
mock.timers.reset()
|
||||
}
|
||||
assert.deepEqual(erg, { beendet: [F1] })
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.ok(!funktion().aboEndeMailAt)
|
||||
assert.deepEqual(await laufe(plus(JETZT, TAG)), ergebnis({ geprueft: 1, mails: 1 }))
|
||||
})
|
||||
|
||||
test('Sofortpfad: frisch gelesen unter der Sperre feature-admin - hat das Team zwischen Liste und Sperre abgeschaltet, bleibt dessen Grund', async () => {
|
||||
legeFunktion(1)
|
||||
const teamAus = { ...funktion(), state: 'disabled', enabled: false, disabledReason: 'team', disabledAt: plus(JETZT, -TAG).toISOString() }
|
||||
const erg = await mitEinwurf(liste('portalFeatures'), () => aw.seed('portalFeatures', teamAus), () => beende())
|
||||
assert.deepEqual(erg, { beendet: [] })
|
||||
assert.deepEqual(funktion(), { ...funktion(), ...teamAus })
|
||||
assert.equal(funktion().disabledReason, 'team')
|
||||
assert.equal(mails().length, 0)
|
||||
})
|
||||
|
||||
test('Sofortpfad: eine Zahlung zwischen Liste und Sperre (ordered => setup, bezahlt) wird als bezahlt behandelt: T19 und K22 vor_einrichtung', async () => {
|
||||
legeBestellung(1)
|
||||
aw.seed('workorders', { $id: 't_1', woid: 'WO-4711', status: 'Open', topic: 'Terminplaner einbauen – Praxis Muster' })
|
||||
const bezahlt = { ...funktion(), state: 'setup', unlockedByPurchase: true, purchaseStatus: 'paid', ticketId: 't_1', stripeInvoiceId: 'in_1', invoiceUrl: 'https://invoice.stripe.test/in_1' }
|
||||
const erg = await mitEinwurf(liste('portalFeatures'), () => aw.seed('portalFeatures', bezahlt), () => beende())
|
||||
assert.deepEqual(erg, { beendet: [F1] })
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
assert.equal(funktion().zahlungMeldung, 'gemeldet')
|
||||
assert.equal(t19().length, 1)
|
||||
assert.equal(k22().length, 1)
|
||||
assert.ok(funktion().aboEndeMailAt)
|
||||
})
|
||||
|
||||
test('Sofortpfad: wartet auf die Sperre feature-admin des Projekts; T19 und K22 gehen erst nach ihrer Freigabe (nie innerhalb)', async () => {
|
||||
legeAufbau(1)
|
||||
legeFunktion(2)
|
||||
const sperre = (nr) => `feature-admin:${projektId(nr)}`
|
||||
// 1) eine fremde Sperre haelt das Dokument auf
|
||||
let freigeben
|
||||
const gehalten = withLocks([sperre(1)], () => new Promise((r) => (freigeben = r)))
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
const lauf = beende()
|
||||
await new Promise((r) => setTimeout(r, 60))
|
||||
assert.equal(funktion().state, 'setup', 'solange die Sperre gehalten wird, bleibt das Dokument, wie es ist')
|
||||
assert.equal(mails().length, 0)
|
||||
freigeben()
|
||||
await gehalten
|
||||
assert.deepEqual((await lauf).beendet.sort(), [F1, funktionsId(2)].sort())
|
||||
assert.equal(funktion().state, 'disabled')
|
||||
|
||||
// 2) beim Versand ist die Sperre frei (withLocks ist nicht wiederbetretbar: innerhalb haengte jede Mail)
|
||||
const frei = []
|
||||
for (const nr of [1, 2]) {
|
||||
await deleteDocument('portalFeatures', funktionsId(nr))
|
||||
}
|
||||
legeAufbau(1)
|
||||
legeFunktion(2)
|
||||
setMailTransportForTests({
|
||||
sendMail: async (mail) => {
|
||||
const projekte = [1, 2].map((nr) => Promise.race([withLocks([sperre(nr)], async () => true), new Promise((r) => setTimeout(() => r(false), 150))]))
|
||||
frei.push([mail.subject, ...(await Promise.all(projekte))])
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
})
|
||||
await beende()
|
||||
assert.equal(frei.length, 3, 'T19 und zwei K22')
|
||||
assert.ok(frei.every(([, ...rest]) => rest.every(Boolean)), JSON.stringify(frei))
|
||||
})
|
||||
Reference in new issue
Block a user