TP2 Abschlussprüfung: Zahlung/Team (Wiederzustellung, K16-Warnung, Sperrschlüssel)
- adoptPaidSession übernimmt ein erneut zugestelltes checkout.session.completed nicht mehr, wenn das Dokument nach 30 Tagen zurückgesetzt wurde (off, purchaseStatus zurueckgesetzt, gleiche Sitzung): kein Setup, kein Ticket, keine K15/T16 - meldeFreigeschaltet liefert true/false; die Staff-Aktivierung in Textform vermerkt eine nicht gesendete K16 mit Unterlagen in adminNote und antwortet mit warnung: true - disable() nutzt die Sperre feature-activate:<id> statt feature-disable:<id> (Freischaltung und Abschalten überholen sich nicht) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4dc447ed33
commit
b974b3a3ec
7 files changed
+153
-6
No files matched your search
@@ -2,7 +2,7 @@ import { Router } from 'express'
|
|||||||
import { config } from '../../config.js'
|
import { config } from '../../config.js'
|
||||||
import { aboMemo } from '../../services/aboStatus.js'
|
import { aboMemo } from '../../services/aboStatus.js'
|
||||||
import { validateTextform } from '../../services/analyse.js'
|
import { validateTextform } from '../../services/analyse.js'
|
||||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||||||
import { featureConfig } from '../../services/featureRegistry.js'
|
import { featureConfig } from '../../services/featureRegistry.js'
|
||||||
import { withLocks } from '../../services/keyedLock.js'
|
import { withLocks } from '../../services/keyedLock.js'
|
||||||
import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js'
|
import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js'
|
||||||
@@ -67,6 +67,26 @@ async function customerName(customerId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const K16_FEHLT = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vermerkt in der Notiz des Funktionsdokuments, dass die K16 mit den Unterlagen nicht rausging (Staff-Aktivierung in Textform, die
|
||||||
|
* Unterlagen sind der dauerhafte Datentraeger). Best effort unter der Sperre der Verwaltung: scheitert das Schreiben, steht der Fehler im
|
||||||
|
* Log, die Antwort traegt die Warnung trotzdem.
|
||||||
|
*/
|
||||||
|
async function notiereK16Fehlt(projectId, doc) {
|
||||||
|
try {
|
||||||
|
await withLocks([`feature-admin:${projectId}`], async () => {
|
||||||
|
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||||
|
await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||||
|
adminNote: composeAdminNote(frisch.adminNote, { by: 'System', action: K16_FEHLT }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[funktionen-admin] Notiz zur nicht gesendeten K16 nicht geschrieben:', err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
|
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,9 +251,14 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
|
|||||||
await disable(doc.$id, { by, adminNote })
|
await disable(doc.$id, { by, adminNote })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen
|
// Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen.
|
||||||
if (freigeschaltet) await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments })
|
// Ging K16 nicht raus (Versand, Kunde ohne Adresse), steht es in der Notiz und die Antwort traegt warnung: true - das Team sendet von Hand nach
|
||||||
res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) })
|
let warnung = false
|
||||||
|
if (freigeschaltet && !(await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }))) {
|
||||||
|
warnung = true
|
||||||
|
await notiereK16Fehlt(projectId, freigeschaltet.doc)
|
||||||
|
}
|
||||||
|
res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId), ...(warnung ? { warnung } : {}) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendError(res, err)
|
sendError(res, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -438,7 +438,10 @@ function assertReason(reason) {
|
|||||||
*/
|
*/
|
||||||
export async function disable(docId, { by, adminNote, reason = 'team' } = {}) {
|
export async function disable(docId, { by, adminNote, reason = 'team' } = {}) {
|
||||||
assertReason(reason)
|
assertReason(reason)
|
||||||
const doc = await withLocks([`feature-disable:${docId}`], async () => {
|
// Dieselbe Sperre wie die Freischaltung (activateClosedTickets, freischaltenNachAbnahme): beide schreiben den Zustand desselben Dokuments und
|
||||||
|
// duerfen sich nicht ueberholen. Kein Halter von feature-activate:<id> ruft disable() auf; die Reihenfolge feature-admin -> feature-activate
|
||||||
|
// besteht schon. Keine Mehrfach-Sperre (withLocks mit mehreren Schluesseln kann sich verklemmen).
|
||||||
|
const doc = await withLocks([`feature-activate:${docId}`], async () => {
|
||||||
const current = await getDocument(config.collections.portalFeatures, docId)
|
const current = await getDocument(config.collections.portalFeatures, docId)
|
||||||
if (current.state === 'disabled') {
|
if (current.state === 'disabled') {
|
||||||
if (current.disabledAt) return current
|
if (current.disabledAt) return current
|
||||||
@@ -1080,6 +1083,13 @@ async function unmatchedNotice(session) {
|
|||||||
async function adoptPaidSession(session) {
|
async function adoptPaidSession(session) {
|
||||||
const { customerId, projectId, featureKey } = session.metadata || {}
|
const { customerId, projectId, featureKey } = session.metadata || {}
|
||||||
const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null
|
const doc = customerId && projectId && featureKey ? await getFeatureDoc(customerId, projectId, featureKey) : null
|
||||||
|
// Erneut zugestelltes Ereignis ("Resend event" in Stripe) zu einer Bestellung, die laengst eingerichtet, abgeschaltet und nach 30 Tagen
|
||||||
|
// geloescht wurde (off, purchaseStatus 'zurueckgesetzt', dieselbe Sitzung): sie ist ausgeliefert, nichts wird neu eingerichtet und
|
||||||
|
// niemand benachrichtigt (kein Ticket, keine K15, kein T16).
|
||||||
|
if (doc?.state === 'off' && doc.purchaseStatus === 'zurueckgesetzt' && doc.stripeSessionId === session.id) {
|
||||||
|
console.warn(`[funktionen] Sitzung ${session.id} gehoert zu einer Funktion, die nach dem Abo-Ende schon geloescht und zurueckgesetzt wurde - nicht uebernommen, nichts gesendet`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) {
|
if (!doc || (doc.state !== 'ordered' && doc.state !== 'off')) {
|
||||||
console.error(
|
console.error(
|
||||||
`[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen`
|
`[funktionen] BEZAHLTE Sitzung ${session.id} ohne passende Bestellung (Kunde ${customerId}, Projekt ${projectId}, Zustand ${doc?.state || 'kein Dokument'}) - bitte von Hand pruefen`
|
||||||
|
|||||||
@@ -40,18 +40,23 @@ export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) {
|
|||||||
* Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail,
|
* Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail,
|
||||||
* die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler.
|
* die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler.
|
||||||
* attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a).
|
* attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a).
|
||||||
|
* Liefert true, wenn K16 rausging; false, wenn nicht (Fehler, Kunde ohne Adresse oder unbekannt). Nur die Staff-Aktivierung mit
|
||||||
|
* Unterlagen wertet das aus (sie muss die Unterlagen dann von Hand nachsenden); alle anderen Aufrufer ignorieren den Wert wie bisher.
|
||||||
* @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument
|
* @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument
|
||||||
* @param {{ attachments?: object[] }} [optionen]
|
* @param {{ attachments?: object[] }} [optionen]
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) {
|
export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) {
|
||||||
try {
|
try {
|
||||||
const customer = await customerOf(doc.customerId)
|
const customer = await customerOf(doc.customerId)
|
||||||
if (!customer?.email) return
|
if (!customer?.email) return false
|
||||||
const feature = featureConfig(doc.featureKey)
|
const feature = featureConfig(doc.featureKey)
|
||||||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||||||
const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
|
const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) })
|
||||||
await sendCustomerMail(customer.email, { ...mail, attachments })
|
await sendCustomerMail(customer.email, { ...mail, attachments })
|
||||||
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
|
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,6 +370,19 @@ test('Der Haken bekommt das unter der Sperre frisch gelesene Dokument; ein zwisc
|
|||||||
assert.deepEqual(gesehen, [{ state: 'setup', marker: 'frisch gelesen' }], 'der Haken sieht nur das frische, noch offene Dokument')
|
assert.deepEqual(gesehen, [{ state: 'setup', marker: 'frisch gelesen' }], 'der Haken sieht nur das frische, noch offene Dokument')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('A3: disable() teilt die Sperre feature-activate:<id> mit der Freischaltung - wer die Freischaltung haelt, laesst das Abschalten warten', async () => {
|
||||||
|
const id = einrichtung('termine', { einstellung: true })
|
||||||
|
let abschalten
|
||||||
|
await withLocks([`feature-activate:${id}`], async () => {
|
||||||
|
abschalten = sf.disable(id, { reason: 'team' })
|
||||||
|
await new Promise((r) => setTimeout(r, 80))
|
||||||
|
assert.equal(zustand(id), 'setup', 'solange die Freischaltung die Sperre haelt, schaltet disable() nicht ab')
|
||||||
|
})
|
||||||
|
const doc = await abschalten
|
||||||
|
assert.equal(doc.state, 'disabled')
|
||||||
|
assert.equal(zustand(id), 'disabled')
|
||||||
|
})
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------- Tickets, Kunden ---
|
// ------------------------------------------------------------------------------- Tickets, Kunden ---
|
||||||
test('Ticket Cancelled (oder Open) => bleibt setup, der Haken wird gar nicht gefragt', async () => {
|
test('Ticket Cancelled (oder Open) => bleibt setup, der Haken wird gar nicht gefragt', async () => {
|
||||||
let gefragt = 0
|
let gefragt = 0
|
||||||
|
|||||||
@@ -372,6 +372,37 @@ test('(7) Stripe-Sitzung laeuft ab, die Zahlung kommt doch: Uebernahme, setup, e
|
|||||||
assert.equal(mails.sent.length, 2)
|
assert.equal(mails.sent.length, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('(7) A1: erneut zugestelltes checkout.session.completed nach der 30-Tage-Loeschung (off, zurueckgesetzt, gleiche Sitzung) wird nicht uebernommen', async (t) => {
|
||||||
|
const sessionId = await bestelle()
|
||||||
|
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||||
|
// der 30-Tage-Lauf hat das Dokument zurueckgesetzt (siehe setzeFunktionZurueck); die Sitzungs-ID bleibt als Marke stehen
|
||||||
|
const d0 = await doc()
|
||||||
|
aw.seed('portalFeatures', {
|
||||||
|
...d0,
|
||||||
|
state: 'off',
|
||||||
|
enabled: false,
|
||||||
|
unlockedByPurchase: false,
|
||||||
|
purchaseStatus: 'zurueckgesetzt',
|
||||||
|
zahlungMeldung: '',
|
||||||
|
ticketId: '',
|
||||||
|
adminNote: 'Daten gelöscht (Löschfrist abgelaufen)',
|
||||||
|
})
|
||||||
|
const tickets0 = tickets().length
|
||||||
|
const mails0 = mails.sent.length
|
||||||
|
const stumm = STUMM(t)
|
||||||
|
const warn = t.mock.method(console, 'warn', () => {})
|
||||||
|
|
||||||
|
// "Resend event" im Stripe-Dashboard: dasselbe Ereignis noch einmal
|
||||||
|
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||||
|
const d = await doc()
|
||||||
|
assert.deepEqual([d.state, d.purchaseStatus, d.stripeSessionId, d.ticketId], ['off', 'zurueckgesetzt', sessionId, ''], 'nicht uebernommen')
|
||||||
|
assert.equal(tickets().length, tickets0, 'kein neues Ticket')
|
||||||
|
assert.equal(mails.sent.length, mails0, 'weder K15 noch T16')
|
||||||
|
const log = geloggt(stumm) + warn.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
|
||||||
|
assert.match(log, new RegExp(`Sitzung ${sessionId} .*zurueckgesetzt`))
|
||||||
|
assert.doesNotMatch(log, /k@example\.invalid|Salon/, 'keine personenbezogenen Daten im Log')
|
||||||
|
})
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------------ (8) Neubestellung nach Ablauf ---
|
// ------------------------------------------------------------------------------------ (8) Neubestellung nach Ablauf ---
|
||||||
test('(8) Neubestellung nach Ablauf: die alte Sitzung zahlt (Uebernahme, ein Ticket), die neue danach ungeklaert, kein zweites Ticket', async (t) => {
|
test('(8) Neubestellung nach Ablauf: die alte Sitzung zahlt (Uebernahme, ein Ticket), die neue danach ungeklaert, kein zweites Ticket', async (t) => {
|
||||||
const alt = await bestelle()
|
const alt = await bestelle()
|
||||||
|
|||||||
@@ -1444,6 +1444,44 @@ test('textform_pflicht_modus_on: ohne Angaben 400, mit gültigen Angaben 200, Na
|
|||||||
assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts')
|
assert.equal(mails.sent.length, mailsVorher + 2, 'je eine K16 für den Kunden und den Chat-Kunden, sonst nichts')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const K16_NICHT_GESENDET = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
|
||||||
|
|
||||||
|
test('textform A2: scheitert der Versand von K16 mit den Unterlagen, bleibt die Funktion freigeschaltet, adminNote und warnung:true sagen es; Erfolg und Kunde ohne Adresse', async (t) => {
|
||||||
|
modusMerken(t)
|
||||||
|
termineMit('on', [])
|
||||||
|
t.mock.method(console, 'error', () => {})
|
||||||
|
|
||||||
|
// Erfolg: keine Warnung, keine Notiz
|
||||||
|
const gut = legeKundeF13()
|
||||||
|
const ok = await aktivierenF13(gut, feldF13(gut))
|
||||||
|
assert.equal(ok.status, 200, JSON.stringify(ok.body))
|
||||||
|
assert.equal(ok.body.warnung, undefined, 'ohne Fehler kein Feld warnung')
|
||||||
|
assert.doesNotMatch((await sf.getFeatureDoc(gut.customerId, gut.projectId, 'termine')).adminNote, /NICHT gesendet/)
|
||||||
|
|
||||||
|
// Versandfehler: Freischaltung und Nachweis stehen, die Warnung kommt zurueck
|
||||||
|
const k = legeKundeF13()
|
||||||
|
setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } })
|
||||||
|
t.after(() => setMailTransportForTests(mails))
|
||||||
|
const res = await aktivierenF13(k, feldF13(k))
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||||
|
assert.equal(res.body.warnung, true)
|
||||||
|
const doc = await sf.getFeatureDoc(k.customerId, k.projectId, 'termine')
|
||||||
|
assert.equal(doc.state, 'active', 'die Freischaltung bleibt')
|
||||||
|
assert.equal(consentsDesF13(k.projectId).length, 1, 'der Nachweis bleibt')
|
||||||
|
assert.ok(doc.adminNote.includes(K16_NICHT_GESENDET), doc.adminNote)
|
||||||
|
assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z System: K16 mit Unterlagen NICHT gesendet – von Hand nachsenden \| .*Andrej: activate/)
|
||||||
|
assert.equal(featuresOf(res, k.projectId).termine.adminNote, doc.adminNote, 'das Ticketsystem liest die Notiz mit der Antwort')
|
||||||
|
|
||||||
|
// Kunde ohne E-Mail-Adresse: K16 kann nicht raus, auch das ist eine Warnung
|
||||||
|
setMailTransportForTests(mails)
|
||||||
|
const leer = legeKundeF13({ email: '' })
|
||||||
|
aw.docs('customers').get(leer.customerId).email = ''
|
||||||
|
const ohneAdresse = await aktivierenF13(leer, feldF13(leer))
|
||||||
|
assert.equal(ohneAdresse.status, 200, JSON.stringify(ohneAdresse.body))
|
||||||
|
assert.equal(ohneAdresse.body.warnung, true)
|
||||||
|
assert.ok((await sf.getFeatureDoc(leer.customerId, leer.projectId, 'termine')).adminNote.includes(K16_NICHT_GESENDET))
|
||||||
|
})
|
||||||
|
|
||||||
test('textform_entfaellt: Modus off und pilot, Modus on mit Pilotadresse, News und Abschalten brauchen keine Angaben und schreiben keinen Nachweis', async (t) => {
|
test('textform_entfaellt: Modus off und pilot, Modus on mit Pilotadresse, News und Abschalten brauchen keine Angaben und schreiben keinen Nachweis', async (t) => {
|
||||||
modusMerken(t)
|
modusMerken(t)
|
||||||
const mailsVorher = mails.sent.length
|
const mailsVorher = mails.sent.length
|
||||||
|
|||||||
@@ -315,6 +315,31 @@ test('meldeFreigeschaltet: Kunde nicht lesbar oder Funktion nicht im Register =>
|
|||||||
assert.equal(mails.sent.length, 0)
|
assert.equal(mails.sent.length, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('meldeFreigeschaltet liefert true nur, wenn K16 rausging: Erfolg true; Versandfehler, nicht lesbarer Kunde, unbekannte Funktion, Kunde ohne oder unbekannt false (A2)', async (t) => {
|
||||||
|
STUMM(t)
|
||||||
|
const doc = einrichtung('termine', { einstellung: true })
|
||||||
|
assert.equal(await meldeFreigeschaltet(doc), true)
|
||||||
|
assert.equal(mails.sent.length, 1)
|
||||||
|
|
||||||
|
setMailTransportForTests({
|
||||||
|
sendMail: async () => {
|
||||||
|
throw new Error('SMTP: Postfach voll')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
assert.equal(await meldeFreigeschaltet(doc), false, 'Versandfehler')
|
||||||
|
} finally {
|
||||||
|
setMailTransportForTests(mails)
|
||||||
|
}
|
||||||
|
aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Nachbau: Kunden kaputt' })
|
||||||
|
assert.equal(await meldeFreigeschaltet(doc), false, 'Kunde nicht lesbar')
|
||||||
|
assert.equal(await meldeFreigeschaltet(einrichtung('gibtsnicht')), false, 'Funktion nicht im Register')
|
||||||
|
aw.seed('customers', { $id: 'c_leer2', appwriteUserId: 'u_leer2', email: '', name: 'Ohne Adresse', status: 'active' })
|
||||||
|
assert.equal(await meldeFreigeschaltet(einrichtung('termine', { customerId: 'c_leer2' })), false, 'Kunde ohne Adresse')
|
||||||
|
assert.equal(await meldeFreigeschaltet(einrichtung('termine', { customerId: 'c_gibtsnicht' })), false, 'Kunde unbekannt')
|
||||||
|
assert.equal(mails.sent.length, 1, 'nur die erste Mail ging raus')
|
||||||
|
})
|
||||||
|
|
||||||
// -------------------------------------------------------------------------------------------- runActivation ---
|
// -------------------------------------------------------------------------------------------- runActivation ---
|
||||||
test('runActivation sendet fuer ein aktiviertes Termine-Dokument genau ein K16 (ueber meldeFreigeschaltet), ein zweiter Lauf keins', async () => {
|
test('runActivation sendet fuer ein aktiviertes Termine-Dokument genau ein K16 (ueber meldeFreigeschaltet), ein zweiter Lauf keins', async () => {
|
||||||
const doc = einrichtung('termine', { einstellung: true })
|
const doc = einrichtung('termine', { einstellung: true })
|
||||||
|
|||||||
Reference in new issue
Block a user