Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)
onFeaturePaid wertet den Fall aus markPaid aus. Trifft die Zahlung eine Funktion, die schon freigeschaltet, in Einrichtung oder abgeschaltet ist, geht nur eine Team-Meldung raus (T16 Block bereits_aktiv, bei Abo-Ende vor der Einrichtung T19), kein Ticket und keine K15. Beide Meldungen laufen genau einmal je Dokument: unter feature-meldung:<id> frisch lesen, Marker zahlungMeldung gemeldet erst nach gesendeter Mail. Scheitert der Versand, wirft onFeaturePaid (Webhook 500), Stripe wiederholt und der naechste Versuch sendet genau einmal. meldeErstattung ist die einzige Stelle fuer T19 (Zahlung nach dem Abo-Ende und spaeter die Meldung des Abo-Abgleichs), wirft nie. Bei Funktionen mit Wartungs-Abo-Pflicht zeigt T16 nach der Verbuchung den Block kein_abo, wenn das Abo nicht mehr laeuft; ein Lesefehler laesst den Block aus und blockiert nichts. customerOf und projectName sind exportiert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
468df49812
commit
f4754f573a
3 files changed
+817
-11
No files matched your search
+135
-10
@@ -6,6 +6,7 @@ import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
|
||||
import { anleitungsLink, featureConfig, featureKeys, ticketTexte } from './featureRegistry.js'
|
||||
import { zustimmungFuerSitzung } from './featureConsents.js'
|
||||
import { laeuft } from './aboStatus.js'
|
||||
import { createFeatureTicket } from './ticketAdmin.js'
|
||||
|
||||
export const FEATURE_KEYS = featureKeys()
|
||||
@@ -587,7 +588,8 @@ export function isFeatureCheckoutEvent(event) {
|
||||
return obj?.mode === 'payment' && Boolean(obj.metadata?.featureKey) && FEATURE_EVENT_TYPES.includes(event.type)
|
||||
}
|
||||
|
||||
async function projectName(projectId) {
|
||||
/** Projektname zur ID; ein unbekanntes Projekt ergibt ''. */
|
||||
export async function projectName(projectId) {
|
||||
try {
|
||||
return (await getDocument(config.collections.websiteProjects, projectId)).projectName || ''
|
||||
} catch (err) {
|
||||
@@ -596,7 +598,8 @@ async function projectName(projectId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function customerOf(customerId) {
|
||||
/** Kundendokument zur ID; ein unbekannter Kunde ergibt null. */
|
||||
export async function customerOf(customerId) {
|
||||
try {
|
||||
return await getDocument(config.collections.customers, customerId)
|
||||
} catch (err) {
|
||||
@@ -624,8 +627,8 @@ async function ticketWoidOf(doc) {
|
||||
|
||||
/**
|
||||
* T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus.
|
||||
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". kein_abo, bereits_aktiv und gesundheit sind in der Vorlage
|
||||
* deklariert und bleiben hier aus (B10a und D4 fuellen sie).
|
||||
* Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der
|
||||
* Block gesundheit bleibt hier aus (D4 fuellt ihn).
|
||||
*/
|
||||
function sendT16({ funktion, ...werte }, bloecke) {
|
||||
return sendAdminNotice({
|
||||
@@ -647,7 +650,8 @@ function sendT16({ funktion, ...werte }, bloecke) {
|
||||
})
|
||||
}
|
||||
|
||||
async function teamNotice(doc, customer, { failed = false } = {}) {
|
||||
/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */
|
||||
async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) {
|
||||
const mails = featureConfig(doc.featureKey)?.mails
|
||||
// Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde
|
||||
const anleitung = Boolean(mails?.anleitung) && !failed
|
||||
@@ -661,7 +665,7 @@ async function teamNotice(doc, customer, { failed = false } = {}) {
|
||||
sitekey: failed ? '' : doc.siteKey || '',
|
||||
anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '',
|
||||
},
|
||||
{ zahlung_fehlgeschlagen: failed, anleitung }
|
||||
{ zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -707,15 +711,127 @@ async function adoptPaidSession(session) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldung zu einer Zahlung genau einmal (T16 bereits_aktiv, T19): unter feature-meldung:<docId> das Dokument frisch lesen;
|
||||
* zahlungMeldung === 'gemeldet' => { sent: false, schonGemeldet: true } ohne Versand. Sonst senden(frisch) (liefert { sent }) und
|
||||
* erst bei sent === true den Marker 'gemeldet' schreiben (auch wenn dort vorher ein offener Fall stand). Wirft nie: jeder Fehler
|
||||
* (Lesen, Aufbau, Versand) ergibt { sent: false } samt Log, der Aufrufer wiederholt. Scheitert nur das Schreiben des Markers, nachdem
|
||||
* die Mail raus ist, bleibt es bei { sent: true } (Log): die Spur ist da, eine Wiederholung durch den Aufrufer waere ein Doppel.
|
||||
* Nie innerhalb einer feature-paid:*-Sperre aufrufen (withLocks ist nicht wiederbetretbar).
|
||||
*/
|
||||
async function meldeEinmal(doc, was, senden) {
|
||||
try {
|
||||
return await withLocks([`feature-meldung:${doc.$id}`], async () => {
|
||||
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
|
||||
if (frisch.zahlungMeldung === 'gemeldet') return { sent: false, schonGemeldet: true }
|
||||
const { sent } = await senden(frisch)
|
||||
if (!sent) return { sent: false }
|
||||
try {
|
||||
await updateDocument(config.collections.portalFeatures, frisch.$id, { zahlungMeldung: 'gemeldet' })
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] ${was} zur Sitzung ${frisch.stripeSessionId} gesendet, aber der Marker gemeldet blieb ungeschrieben:`, err.message)
|
||||
}
|
||||
return { sent: true }
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] ${was} zur Sitzung ${doc.stripeSessionId} nicht gesendet:`, err.message)
|
||||
return { sent: false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Kunde, Projekt und Funktionsname fuer die Team-Meldungen; was sich nicht lesen laesst, faellt auf IDs zurueck (die Meldung geht trotzdem raus). */
|
||||
async function meldeKopf(doc, customer) {
|
||||
const kunde = customer ?? (await customerOf(doc.customerId).catch(() => null))
|
||||
return {
|
||||
kunde_name: kunde?.name || kunde?.email || doc.customerId,
|
||||
email: kunde?.email || '',
|
||||
projekt: (await projectName(doc.projectId).catch(() => '')) || doc.projectId,
|
||||
funktion: featureConfig(doc.featureKey)?.mails.funktion,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zahlung auf eine Funktion, die schon freigeschaltet, in Einrichtung oder abgeschaltet war (markPaid-Fall 'bereits_aktiv' |
|
||||
* 'abgeschaltet'): T16 nur mit Block bereits_aktiv (zustand "freigeschaltet", "in Einrichtung" bzw. "abgeschaltet", woid leer), keine
|
||||
* K15, kein Ticket. Genau einmal je Dokument (siehe meldeEinmal).
|
||||
* @param {object} doc portalFeatures-Dokument
|
||||
* @param {'bereits_aktiv'|'abgeschaltet'} fall
|
||||
* @param {{ customer?: object|null }} [options] bereits gelesener Kunde, sonst wird er gelesen
|
||||
* @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>}
|
||||
*/
|
||||
export function meldeZahlungFall(doc, fall, { customer = null } = {}) {
|
||||
return meldeEinmal(doc, 'T16 bereits_aktiv', async (frisch) => {
|
||||
const zustand = fall === 'abgeschaltet' ? 'abgeschaltet' : frisch.state === 'setup' ? 'in Einrichtung' : 'freigeschaltet'
|
||||
return sendT16({ ...(await meldeKopf(frisch, customer)), sitzung: frisch.stripeSessionId || '', zustand }, { bereits_aktiv: true })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* T19 "Erstattung faellig" an das Team: die einzige Stelle, die T19 sendet (Zahlung nach dem Abo-Ende hier, die Meldung des
|
||||
* Abo-Abgleichs fuer bezahlte, abgeschaltete Einrichtungen in Block E). Blocks: zahlung_nach_ende, wenn nachAboEnde gesetzt ist oder
|
||||
* am (frisch gelesenen) Dokument der offene Fall 'abo_ende' steht - so auch, wenn der Abgleich vor dem Webhook-Versand zuerst meldet;
|
||||
* ticket_offen, wenn das Dokument ein Ticket hat (kein Ticket und keine spaete Zahlung: keiner von beiden); rechnung mit Rechnungslink.
|
||||
* Genau einmal je Dokument (siehe meldeEinmal), wirft nie.
|
||||
* @param {object} doc portalFeatures-Dokument
|
||||
* @param {{ customer?: object|null, sitzung?: string, nachAboEnde?: boolean }} [options]
|
||||
* @returns {Promise<{ sent: boolean, schonGemeldet?: boolean }>}
|
||||
*/
|
||||
export async function meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) {
|
||||
return meldeEinmal(doc, 'T19 Erstattung faellig', async (frisch) => {
|
||||
const kopf = await meldeKopf(frisch, customer)
|
||||
const woid = frisch.ticketId ? await ticketWoidOf(frisch).catch(() => frisch.ticketId) : ''
|
||||
return sendAdminNotice({
|
||||
template: 'T19',
|
||||
values: {
|
||||
...kopf,
|
||||
funktion: kopf.funktion || 'Funktion',
|
||||
betrag: featureConfig(frisch.featureKey)?.priceLabel || '',
|
||||
sitzung: sitzung || '',
|
||||
woid,
|
||||
rechnung_url: frisch.invoiceUrl || '',
|
||||
},
|
||||
blocks: {
|
||||
zahlung_nach_ende: nachAboEnde || frisch.zahlungMeldung === 'abo_ende',
|
||||
ticket_offen: Boolean(frisch.ticketId),
|
||||
rechnung: Boolean(frisch.invoiceUrl),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zahlung traf ein Dokument, das nicht mehr bestellt war (markPaid-Fall): gebucht ist sie schon, hier geht die Meldung ans Team -
|
||||
* 'abo_ende' als T19 (Erstattung), 'bereits_aktiv' und 'abgeschaltet' als T16. Kein ordentliches Ticket, keine K15.
|
||||
* Wirft, wenn die Meldung weder raus ist noch schon draussen war: der Webhook antwortet 500, Stripe wiederholt, markPaid liefert den
|
||||
* noch offenen Fall erneut und der naechste Versuch sendet genau einmal.
|
||||
*/
|
||||
async function meldeFallZurZahlung(doc, fall, session) {
|
||||
console.error(
|
||||
`[funktionen] BEZAHLTE Sitzung ${session.id} traf die Funktion ${doc.featureKey} im Zustand ${doc.state} (Fall ${fall}, Kunde ${doc.customerId}, Projekt ${doc.projectId}) - nichts eingerichtet, das Team wird benachrichtigt`
|
||||
)
|
||||
const customer = await customerOf(doc.customerId).catch((err) => {
|
||||
console.error('[funktionen] Kunde fuer die Zahlungsmeldung nicht lesbar:', err.message)
|
||||
return null
|
||||
})
|
||||
const ergebnis =
|
||||
fall === 'abo_ende'
|
||||
? await meldeErstattung(doc, { customer, nachAboEnde: true })
|
||||
: await meldeZahlungFall(doc, fall, { customer })
|
||||
if (!ergebnis.sent && !ergebnis.schonGemeldet) throw new Error(`Meldung zur Zahlung ${session.id} (${fall}) nicht gesendet`)
|
||||
}
|
||||
|
||||
async function onFeaturePaid(session) {
|
||||
const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice)
|
||||
const paid = { sessionId: session.id, invoiceId, invoiceUrl }
|
||||
let { doc, created } = await markPaid(paid)
|
||||
let { doc, created, fall } = await markPaid(paid)
|
||||
// Kein Dokument zur Sitzung, oder es fiel auf 'off' (Ablauf/SEPA-Fehler) und die Zahlung kam doch noch an
|
||||
if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created } = await markPaid(paid))
|
||||
if ((!doc || doc.state === 'off') && (await adoptPaidSession(session))) ({ doc, created, fall } = await markPaid(paid))
|
||||
// Das Dokument wartet nicht mehr auf die Zahlung (aktiv, abgeschaltet, in Einrichtung): nur Meldung, kein Ticket, keine K15
|
||||
if (fall) return meldeFallZurZahlung(doc, fall, session)
|
||||
if (!created) return
|
||||
// Ab hier ist die Bestellung gebucht; ein Wiederholungsversuch von Stripe sieht created:false und
|
||||
// wuerde keine Mail mehr schicken. Darum scheitert jede Mail einzeln und der Webhook antwortet trotzdem 200.
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
let customer = null
|
||||
try {
|
||||
customer = await customerOf(doc.customerId)
|
||||
@@ -724,7 +840,6 @@ async function onFeaturePaid(session) {
|
||||
}
|
||||
if (customer?.email) {
|
||||
try {
|
||||
const feature = featureConfig(doc.featureKey)
|
||||
if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`)
|
||||
await sendCustomerMail(
|
||||
customer.email,
|
||||
@@ -734,8 +849,18 @@ async function onFeaturePaid(session) {
|
||||
console.error('[funktionen] K15 fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
// Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo).
|
||||
// Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500.
|
||||
let keinAbo = false
|
||||
if (feature?.requiresWartungsAbo) {
|
||||
try {
|
||||
keinAbo = !(await laeuft(doc.customerId))
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Wartungs-Abo von Kunde ${doc.customerId} fuer T16 nicht lesbar, Hinweis kein_abo entfaellt:`, err.message)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await teamNotice(doc, customer)
|
||||
await teamNotice(doc, customer, { keinAbo })
|
||||
} catch (err) {
|
||||
console.error('[funktionen] T16 fehlgeschlagen:', err.message)
|
||||
}
|
||||
|
||||
@@ -353,6 +353,8 @@ test('Termine-Mails: K16 mit den Werten des Terminplaners', () => {
|
||||
|
||||
// -------------------------------------------------------------------- T16 je Funktion (Team-Hinweis) ---
|
||||
test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () => {
|
||||
// c1 hat ein laufendes Wartungs-Abo: ohne es meldet T16 den Block kein_abo (Task B10b, test/featurePaid.test.js)
|
||||
aw.seed('subscriptions', { $id: 'sub_w_mails', stripeSubscriptionId: 'sub_w_mails', customerId: 'c1', productKey: 'wartung', status: 'active' })
|
||||
const { mail, projectId, woid, doc } = await t16Bezahlt('termine', 'Salon Haarlinie')
|
||||
assert.equal(mail.subject, 'Neue Funktion bestellt: Terminplaner')
|
||||
for (const teil of [mail.subject, mail.text, mail.html]) assert.doesNotMatch(teil, /News|Beitr/)
|
||||
@@ -363,7 +365,7 @@ test('Termine-T16: Betreff, Text und Knopf nennen den Terminplaner', async () =>
|
||||
assert.equal(link, reg.anleitungsLink('c1', projectId))
|
||||
assert.ok(mail.text.includes(`Im Ticketsystem öffnen: ${link}`), mail.text)
|
||||
assert.ok(mail.html.includes(`href="${link.replaceAll('&', '&')}"`), mail.html)
|
||||
// die Warnblöcke bleiben aus, solange B10a/D4 sie nicht füllen
|
||||
// die Warnblöcke bleiben bei einem ordentlich bezahlten Kauf aus (kein_abo nur ohne laufendes Abo, bereits_aktiv nur bei einer Zahlung auf ein vorhandenes Dokument: featurePaid.test.js; gesundheit füllt D4)
|
||||
for (const warnung of ['Zahlung fehlgeschlagen', 'Bezahlt, aber nicht zuzuordnen', 'Kein laufendes Wartungs-Abo', 'Gesundheitsbereich prüfen', 'Bezahlt, aber schon vorhanden']) {
|
||||
assert.ok(!mail.text.includes(warnung), `kein Block "${warnung}"`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
|
||||
// Zahlungseingang in jedem Zustand (Task B10b, Review Focus #1): nie Geld ohne Spur, nie doppelt.
|
||||
// Immer gilt nach einem Fall: Tickets + Team-Mails >= 1 und Tickets <= 1 (spur()). Bestellt wird ueber die echte Route
|
||||
// POST /api/funktionen/termine/checkout, bezahlt ueber den signierten Webhook; websiteProjects, workorders und customers
|
||||
// legt das Setup-Skript nicht an: ohne Schema im Nachbau.
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: schemaKeys('portalFeatures'),
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
featureConsents: schemaKeys('featureConsents'),
|
||||
termineConfig: schemaKeys('termineConfig'),
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, post, postWebhook } = await import('./support/testApp.js')
|
||||
const { customerCookie, seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js')
|
||||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||||
const { default: aboRoutes } = await import('../server/routes/abo.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
const { mailSink } = await import('./support/mailSink.js')
|
||||
|
||||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/abo': aboRoutes })
|
||||
|
||||
// Mail-Senke, deren Versand sich gezielt scheitern lassen laesst: versandFehler(mail) => true wirft wie ein SMTP-Fehler
|
||||
const sink = mailSink()
|
||||
const mails = {
|
||||
sent: sink.sent,
|
||||
versandFehler: null,
|
||||
async sendMail(mail) {
|
||||
if (mails.versandFehler?.(mail)) throw new Error('SMTP nicht erreichbar')
|
||||
return sink.sendMail(mail)
|
||||
},
|
||||
}
|
||||
setMailTransportForTests(mails)
|
||||
|
||||
const T = config.features.termine
|
||||
const STANDARD_TERMINE_MODUS = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails }
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
config.termine.mode = STANDARD_TERMINE_MODUS.mode
|
||||
config.termine.pilotEmails = STANDARD_TERMINE_MODUS.pilotEmails
|
||||
delete T.bestellpruefung
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
// seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo
|
||||
const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const col of ['subscriptions', 'portalFeatures', 'featureConsents', 'workorders']) {
|
||||
for (const id of aw.docs(col).keys()) await deleteDocument(col, id)
|
||||
}
|
||||
seedFixtures(aw)
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Salon' })
|
||||
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Studio' })
|
||||
wartung()
|
||||
st.checkoutSessions.clear()
|
||||
st.invoices.clear()
|
||||
mails.sent.length = 0
|
||||
mails.versandFehler = null
|
||||
config.termine.mode = 'pilot'
|
||||
config.termine.pilotEmails = ['*']
|
||||
// Die Fragebogen-Pruefung gehoert zu Block C: hier ist jedes Projekt bestellbar
|
||||
T.bestellpruefung = async () => []
|
||||
})
|
||||
|
||||
const c1 = customerCookie('c1')
|
||||
const SUBJECT = {
|
||||
K15: 'Deine Bestellung: Terminplaner',
|
||||
T16: 'Neue Funktion bestellt: Terminplaner',
|
||||
T16_NEWS: 'Neue Funktion bestellt: News-Pinnwand',
|
||||
T19: '[Abo] Erstattung fällig: Terminplaner-Einrichtung',
|
||||
}
|
||||
const KEIN_ABO = /Kein laufendes Wartungs-Abo/
|
||||
const BEREITS_DA = /Bezahlt, aber schon vorhanden/
|
||||
|
||||
const tickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).startsWith('Terminplaner einbauen'))
|
||||
const mailsMit = (subject) => mails.sent.filter((m) => m.subject === subject)
|
||||
const teamMails = () => mails.sent.filter((m) => m.to.some((t) => config.chatNotify.emails.includes(t.address)))
|
||||
const doc = (projectId = 'p1', key = 'termine') => sf.getFeatureDoc('c1', projectId, key)
|
||||
const event = (type, session) => ({ id: `evt_${Math.random()}`, object: 'event', type, data: { object: session } })
|
||||
const webhook = (type, session) => postWebhook(event(type, session))
|
||||
const STUMM = (t) => t.mock.method(console, 'error', () => {})
|
||||
const geloggt = (stumm) => stumm.mock.calls.map((c) => c.arguments.join(' ')).join('\n')
|
||||
|
||||
/** Hilfspruefung nach jedem Fall: Geld nie ohne Spur (Ticket oder Team-Mail) und nie mehr als ein Ticket. */
|
||||
function spur() {
|
||||
assert.ok(tickets().length + teamMails().length >= 1, 'Geld ohne Spur: weder Ticket noch Team-Mail')
|
||||
assert.ok(tickets().length <= 1, `hoechstens ein Ticket, gefunden ${tickets().length}`)
|
||||
}
|
||||
|
||||
/** Echter Bestellweg: Checkout-Route legt die Stripe-Sitzung an und das Dokument auf 'ordered'. */
|
||||
async function bestelle(projectId = 'p1') {
|
||||
const haken = T.consent.items.filter((item) => item.haken).map((item) => item.id)
|
||||
const res = await post('/api/funktionen/termine/checkout', { projectId, zustimmung: { version: T.consent.version, haken } }, { cookie: c1 })
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body))
|
||||
const sessionId = [...st.checkoutSessions.values()].at(-1).id
|
||||
assert.equal((await doc(projectId)).state, 'ordered')
|
||||
assert.equal((await doc(projectId)).stripeSessionId, sessionId)
|
||||
return sessionId
|
||||
}
|
||||
|
||||
/** Bezahlte Sitzung im Ereignis; die Rechnung liegt im Stripe-Nachbau. */
|
||||
function bezahlt(sessionId, { projectId = 'p1', featureKey = 'termine', invoice = `in_${sessionId}`, ...extra } = {}) {
|
||||
st.invoices.set(invoice, { id: invoice, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/${invoice}` })
|
||||
return {
|
||||
id: sessionId,
|
||||
object: 'checkout.session',
|
||||
mode: 'payment',
|
||||
payment_status: 'paid',
|
||||
invoice,
|
||||
metadata: { customerId: 'c1', projectId, featureKey },
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
const rechnungUrl = (sessionId) => `https://invoice.stripe.test/in_${sessionId}`
|
||||
|
||||
/** Die Systemzeile der verspaeteten Zahlung steht vorn in adminNote. */
|
||||
const SYSTEMZEILE = (sessionId) => new RegExp(`^\\d{4}-\\d\\d-\\d\\dT[\\d:.]+Z System: Zahlung eingegangen \\(Sitzung ${sessionId}\\), nichts eingerichtet`)
|
||||
|
||||
// ------------------------------------------------------------------------------------ (1) ordered + Abo laeuft ---
|
||||
test('(1) ordered + Abo laeuft: setup, ein Ticket, K15 und T16 je einmal, T16 ohne kein_abo', async () => {
|
||||
const sessionId = await bestelle()
|
||||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const d = await doc()
|
||||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['setup', 'paid', true, ''])
|
||||
assert.equal(d.invoiceUrl, rechnungUrl(sessionId))
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.match(tickets()[0].topic, /^Terminplaner einbauen – Salon/)
|
||||
assert.equal(aw.docs('workorders').get(d.ticketId).topic, tickets()[0].topic)
|
||||
|
||||
const [k15] = mailsMit(SUBJECT.K15)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.equal(k15.to[0].address, 'k@example.invalid')
|
||||
const t16 = mailsMit(SUBJECT.T16)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.doesNotMatch(t16[0].text, KEIN_ABO)
|
||||
assert.doesNotMatch(t16[0].text, BEREITS_DA)
|
||||
assert.match(t16[0].text, new RegExp(String(aw.docs('workorders').get(d.ticketId).woid)))
|
||||
spur()
|
||||
|
||||
// Stripe-Wiederholung: nichts doppelt
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length + mailsMit(SUBJECT.T16).length, 2)
|
||||
})
|
||||
|
||||
test('(1) Abo vorgemerkt gekuendigt, laeuft aber noch: kein kein_abo-Block', async () => {
|
||||
const sessionId = await bestelle()
|
||||
wartung({ cancelAtPeriodEnd: true })
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO)
|
||||
spur()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (2) ordered + Abo beendet ---
|
||||
test('(2) ordered + Abo beendet (canceled): setup, Ticket, K15, T16 mit kein_abo', async () => {
|
||||
const sessionId = await bestelle()
|
||||
wartung({ status: 'canceled' })
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
|
||||
assert.equal((await doc()).state, 'setup')
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
const t16 = mailsMit(SUBJECT.T16)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.match(t16[0].text, KEIN_ABO)
|
||||
assert.match(t16[0].text, new RegExp(String(aw.docs('workorders').get((await doc()).ticketId).woid)))
|
||||
spur()
|
||||
// Wiederholung: stumm
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
})
|
||||
|
||||
test('(2) News braucht kein Abo: nie kein_abo', async () => {
|
||||
await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_news_a', consent: {} })
|
||||
wartung({ status: 'canceled' })
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_a', { featureKey: 'news' }))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T16_NEWS).length, 1)
|
||||
assert.doesNotMatch(mailsMit(SUBJECT.T16_NEWS)[0].text, KEIN_ABO)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (3) laeuft scheitert ---
|
||||
test('(3) Abo nicht lesbar: Webhook 200, Ticket, K15, T16 ohne kein_abo, Fehler im Log', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
const stumm = STUMM(t)
|
||||
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })
|
||||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
assert.equal((await doc()).state, 'setup')
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO)
|
||||
assert.match(geloggt(stumm), /Wartungs-Abo/)
|
||||
spur()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (4) Staff hat aktiviert ---
|
||||
test('(4) Staff aktiviert die ordered-Bestellung, dann Zahlung: active bleibt, kein Ticket, keine K15, T16 bereits_aktiv genau einmal', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' })
|
||||
const stumm = STUMM(t)
|
||||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const d = await doc()
|
||||
assert.deepEqual([d.state, d.enabled, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['active', true, 'paid', true, 'gemeldet'])
|
||||
assert.equal(d.invoiceUrl, rechnungUrl(sessionId))
|
||||
assert.equal(d.stripeInvoiceId, `in_${sessionId}`)
|
||||
assert.match(d.adminNote, SYSTEMZEILE(sessionId))
|
||||
assert.match(d.adminNote, /Freigeschaltet von Andrej/, 'die aeltere Notiz bleibt dahinter')
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||||
const t16 = mailsMit(SUBJECT.T16)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.match(t16[0].text, BEREITS_DA)
|
||||
assert.match(t16[0].text, /schon freigeschaltet/)
|
||||
assert.match(t16[0].text, new RegExp(sessionId))
|
||||
assert.doesNotMatch(t16[0].text, KEIN_ABO)
|
||||
assert.match(geloggt(stumm), new RegExp(sessionId), 'console.error nennt die Sitzungs-ID')
|
||||
assert.equal(mails.sent.length, 1, 'sonst keine Mail')
|
||||
spur()
|
||||
|
||||
// zweite Zustellung desselben Ereignisses und async_payment_succeeded nach completed: stumm
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.equal((await doc()).adminNote, d.adminNote, 'nichts doppelt verbucht')
|
||||
})
|
||||
|
||||
test('(4) zwei parallele Zustellungen: genau ein T16', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' })
|
||||
STUMM(t)
|
||||
const ergebnis = await Promise.all([
|
||||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||||
])
|
||||
assert.deepEqual(ergebnis.map((r) => r.status), [200, 200])
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||||
assert.equal(tickets().length, 0)
|
||||
spur()
|
||||
})
|
||||
|
||||
test('(4) normal bezahlte Bestellung (inzwischen aktiv): ein Folgeereignis derselben Sitzung ist kein Fall und bleibt stumm', async () => {
|
||||
const sessionId = await bestelle()
|
||||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
const vorher = mails.sent.length
|
||||
// Folgeereignis zu einer normal bezahlten Bestellung (Zustand inzwischen active): kein Fall
|
||||
await sf.activate((await doc()).$id, { by: 'Andrej' })
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mails.sent.length, vorher)
|
||||
assert.equal(tickets().length, 1)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (5) Team hat abgeschaltet ---
|
||||
test('(5) Team schaltet die ordered-Bestellung ab, dann Zahlung: disabled bleibt, T16 abgeschaltet einmal, Wiederholung stumm', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
const abgeschaltet = await sf.disable((await doc()).$id, { by: 'Andrej' })
|
||||
STUMM(t)
|
||||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const d = await doc()
|
||||
assert.deepEqual([d.state, d.enabled, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', false, 'paid', true, 'gemeldet'])
|
||||
assert.equal(d.disabledAt, abgeschaltet.disabledAt, 'disabledAt (Beginn der Loeschfrist) unveraendert')
|
||||
assert.equal(d.disabledReason, 'team')
|
||||
assert.match(d.adminNote, SYSTEMZEILE(sessionId))
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 0, 'kein T19: der Grund ist team, nicht das Abo')
|
||||
const t16 = mailsMit(SUBJECT.T16)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.match(t16[0].text, BEREITS_DA)
|
||||
assert.match(t16[0].text, /schon abgeschaltet/)
|
||||
spur()
|
||||
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (6) Abo-Ende vor der Einrichtung ---
|
||||
test('(6) disabled abo_ende_vor_einrichtung + Zahlung: T19 einmal mit zahlung_nach_ende, kein T16, kein Ticket, keine K15, Wiederholung stumm', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung', adminNote: 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' })
|
||||
STUMM(t)
|
||||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const d = await doc()
|
||||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', 'paid', true, 'gemeldet'])
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 0)
|
||||
const t19 = mailsMit(SUBJECT.T19)
|
||||
assert.equal(t19.length, 1)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.match(t19[0].text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.doesNotMatch(t19[0].text, /Ticket noch offen/)
|
||||
assert.match(t19[0].text, /149 €/)
|
||||
assert.match(t19[0].text, new RegExp(sessionId))
|
||||
assert.match(t19[0].text, /Kunde Eins/)
|
||||
assert.match(t19[0].text, /Salon/)
|
||||
assert.ok(t19[0].html.includes(rechnungUrl(sessionId)), 'Knopf zur Rechnung')
|
||||
assert.ok(t19[0].to.every((x) => config.chatNotify.emails.includes(x.address)), 'T19 geht an das Team')
|
||||
spur()
|
||||
|
||||
// Wiederholung, async_payment_succeeded nach completed und paralleles Paar: stumm
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||||
const parallel = await Promise.all([
|
||||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||||
webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId)),
|
||||
])
|
||||
assert.deepEqual(parallel.map((r) => r.status), [200, 200])
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('(6) parallel von Anfang an: zwei Zustellungen, ein T19', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' })
|
||||
STUMM(t)
|
||||
const ergebnis = await Promise.all([
|
||||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||||
webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId)),
|
||||
])
|
||||
assert.deepEqual(ergebnis.map((r) => r.status), [200, 200, 200])
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.equal(tickets().length, 0)
|
||||
spur()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (7) Sitzung abgelaufen ---
|
||||
test('(7) Stripe-Sitzung laeuft ab, die Zahlung kommt doch: Uebernahme, setup, ein Ticket, K15 und T16 je einmal', async () => {
|
||||
const sessionId = await bestelle()
|
||||
const expired = { id: sessionId, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'termine' } }
|
||||
assert.equal((await webhook('checkout.session.expired', expired)).status, 200)
|
||||
assert.equal((await doc()).state, 'off')
|
||||
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
const d = await doc()
|
||||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.stripeSessionId], ['setup', 'paid', true, sessionId])
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO)
|
||||
spur()
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mails.sent.length, 2)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (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) => {
|
||||
const alt = await bestelle()
|
||||
const expired = { id: alt, object: 'checkout.session', mode: 'payment', metadata: { customerId: 'c1', projectId: 'p1', featureKey: 'termine' } }
|
||||
st.checkoutSessions.get(alt).status = 'expired'
|
||||
await webhook('checkout.session.expired', expired)
|
||||
const neu = await bestelle()
|
||||
assert.notEqual(neu, alt)
|
||||
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(alt))).status, 200)
|
||||
const d = await doc()
|
||||
assert.deepEqual([d.state, d.stripeSessionId], ['setup', alt], 'Uebernahme in die laufende Bestellung')
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
spur()
|
||||
|
||||
const stumm = STUMM(t)
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(neu))).status, 200)
|
||||
assert.equal(tickets().length, 1, 'kein zweites Ticket')
|
||||
const t16 = mailsMit(SUBJECT.T16)
|
||||
assert.equal(t16.length, 2)
|
||||
assert.match(t16[1].text, /Bezahlt, aber nicht zuzuordnen/)
|
||||
assert.match(t16[1].text, new RegExp(neu))
|
||||
assert.match(geloggt(stumm), new RegExp(neu))
|
||||
assert.equal((await doc()).stripeSessionId, alt)
|
||||
spur()
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (9) SEPA ---
|
||||
test('(9) SEPA: completed ohne paid tut nichts, async_payment_succeeded wie (1)', async () => {
|
||||
const sessionId = await bestelle()
|
||||
const unbezahlt = bezahlt(sessionId, { payment_status: 'unpaid' })
|
||||
assert.equal((await webhook('checkout.session.completed', unbezahlt)).status, 200)
|
||||
assert.equal((await doc()).state, 'ordered')
|
||||
assert.equal(tickets().length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||||
assert.equal((await doc()).state, 'setup')
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 1)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.doesNotMatch(mailsMit(SUBJECT.T16)[0].text, KEIN_ABO)
|
||||
spur()
|
||||
})
|
||||
|
||||
test('(9) SEPA fehlgeschlagen: T16 zahlung_fehlgeschlagen mit Terminplaner, Bestellung zurueck auf off', async () => {
|
||||
const sessionId = await bestelle('p2')
|
||||
const res = await webhook('checkout.session.async_payment_failed', bezahlt(sessionId, { projectId: 'p2', payment_status: 'unpaid' }))
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal((await doc('p2')).state, 'off')
|
||||
const t16 = mailsMit(SUBJECT.T16)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.match(t16[0].text, /Zahlung fehlgeschlagen/)
|
||||
assert.match(t16[0].text, /Terminplaner/)
|
||||
assert.equal(tickets().length, 0)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (10) Versandfehler ---
|
||||
test('(10) T16 bereits_aktiv scheitert beim Versand: Webhook 500, Zahlung gebucht, Marker offen; Wiederholung sendet genau eine Mail', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' })
|
||||
STUMM(t)
|
||||
mails.versandFehler = (m) => m.subject === SUBJECT.T16
|
||||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
assert.equal(res.status, 500)
|
||||
|
||||
const d = await doc()
|
||||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['active', 'paid', true, 'bereits_aktiv'])
|
||||
assert.match(d.adminNote, SYSTEMZEILE(sessionId))
|
||||
assert.equal(mails.sent.length, 0)
|
||||
|
||||
mails.versandFehler = null
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.T16)[0].text, BEREITS_DA)
|
||||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||||
assert.equal((await doc()).adminNote, d.adminNote, 'die Wiederholung bucht nichts doppelt')
|
||||
// weitere Wiederholungen stumm
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
spur()
|
||||
})
|
||||
|
||||
test('(10) T19 scheitert beim Versand: Webhook 500, Marker offen; Wiederholung sendet genau ein T19', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' })
|
||||
STUMM(t)
|
||||
mails.versandFehler = (m) => m.subject === SUBJECT.T19
|
||||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
assert.equal(res.status, 500)
|
||||
|
||||
const d = await doc()
|
||||
assert.deepEqual([d.state, d.purchaseStatus, d.unlockedByPurchase, d.zahlungMeldung], ['disabled', 'paid', true, 'abo_ende'])
|
||||
assert.match(d.adminNote, SYSTEMZEILE(sessionId))
|
||||
assert.equal(mails.sent.length, 0)
|
||||
|
||||
mails.versandFehler = null
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal((await webhook('checkout.session.async_payment_succeeded', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
spur()
|
||||
})
|
||||
|
||||
test('(10) K15 und T16 beim normalen created scheitern weiterhin gefangen: Webhook 200, Ticket da', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
STUMM(t)
|
||||
mails.versandFehler = () => true
|
||||
const res = await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal((await doc()).state, 'setup')
|
||||
assert.equal(tickets().length, 1)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
// der Wiederholungsversuch sieht created:false und schickt nichts mehr (wie bisher)
|
||||
mails.versandFehler = null
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt(sessionId))).status, 200)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
assert.equal(tickets().length, 1)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (11) News ---
|
||||
test('(11) News + Staff-Aktivierung + Zahlung: T16 bereits_aktiv (die bisher stille Luecke)', async (t) => {
|
||||
await sf.markOrdered({ customerId: 'c1', projectId: 'p1', key: 'news', sessionId: 'cs_news_b', consent: {} })
|
||||
await sf.adminActivate('c1', 'p1', 'news', { by: 'Andrej' })
|
||||
const stumm = STUMM(t)
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_b', { featureKey: 'news' }))).status, 200)
|
||||
|
||||
const d = await doc('p1', 'news')
|
||||
assert.deepEqual([d.state, d.purchaseStatus, d.zahlungMeldung], ['active', 'paid', 'gemeldet'])
|
||||
assert.equal(aw.docs('workorders').size, 0, 'kein Ticket')
|
||||
assert.equal(mails.sent.length, 1)
|
||||
const t16 = mailsMit(SUBJECT.T16_NEWS)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.match(t16[0].text, BEREITS_DA)
|
||||
assert.match(t16[0].text, /schon freigeschaltet/)
|
||||
assert.match(geloggt(stumm), /cs_news_b/)
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_news_b', { featureKey: 'news' }))).status, 200)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('(11) Zahlung auf ein Dokument in Einrichtung (setup mit Ticket, Zahlung noch nicht gebucht): T16 bereits_aktiv mit "in Einrichtung"', async (t) => {
|
||||
aw.seed('portalFeatures', {
|
||||
$id: 'f_p1_termine',
|
||||
customerId: 'c1',
|
||||
projectId: 'p1',
|
||||
featureKey: 'termine',
|
||||
state: 'setup',
|
||||
enabled: false,
|
||||
siteKey: 'abcdefghijklmnop',
|
||||
stripeSessionId: 'cs_alt_setup',
|
||||
ticketId: 'wo_alt',
|
||||
unlockedByPurchase: false,
|
||||
purchaseStatus: 'ordered',
|
||||
})
|
||||
STUMM(t)
|
||||
assert.equal((await webhook('checkout.session.completed', bezahlt('cs_alt_setup'))).status, 200)
|
||||
const t16 = mailsMit(SUBJECT.T16)
|
||||
assert.equal(t16.length, 1)
|
||||
assert.match(t16[0].text, /schon in Einrichtung/)
|
||||
assert.equal((await doc()).state, 'setup')
|
||||
assert.equal(mailsMit(SUBJECT.K15).length, 0)
|
||||
})
|
||||
|
||||
// ------------------------------------------------------------------------------------ (12) meldeErstattung direkt ---
|
||||
test('(12) meldeErstattung: bezahltes setup-Dokument => ein T19 mit ticket_offen, Marker gemeldet, zweiter Aufruf schonGemeldet', async () => {
|
||||
const sessionId = await bestelle()
|
||||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
const vorher = mails.sent.length
|
||||
const d = await doc()
|
||||
const woid = aw.docs('workorders').get(d.ticketId).woid
|
||||
|
||||
const erst = await sf.meldeErstattung(d)
|
||||
assert.deepEqual(erst, { sent: true })
|
||||
const t19 = mailsMit(SUBJECT.T19)
|
||||
assert.equal(t19.length, 1)
|
||||
assert.equal(mails.sent.length, vorher + 1)
|
||||
assert.match(t19[0].text, /Ticket noch offen/)
|
||||
assert.match(t19[0].text, new RegExp(String(woid)))
|
||||
assert.doesNotMatch(t19[0].text, /Zahlung nach dem Abo-Ende/, 'die Zahlung kam vor dem Abo-Ende')
|
||||
assert.match(t19[0].text, /149 €/)
|
||||
assert.match(t19[0].text, new RegExp(sessionId))
|
||||
assert.ok(t19[0].html.includes(rechnungUrl(sessionId)))
|
||||
const gemeldet = await doc()
|
||||
assert.equal(gemeldet.zahlungMeldung, 'gemeldet')
|
||||
assert.equal(gemeldet.purchaseStatus, 'paid')
|
||||
|
||||
assert.deepEqual(await sf.meldeErstattung(d), { sent: false, schonGemeldet: true })
|
||||
assert.deepEqual(await sf.meldeErstattung(await doc()), { sent: false, schonGemeldet: true })
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
})
|
||||
|
||||
test('(12) meldeErstattung parallel: nie zwei Mails', async () => {
|
||||
const sessionId = await bestelle()
|
||||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
const d = await doc()
|
||||
const ergebnis = await Promise.all([sf.meldeErstattung(d), sf.meldeErstattung(d), sf.meldeErstattung(d)])
|
||||
assert.equal(ergebnis.filter((r) => r.sent).length, 1)
|
||||
assert.equal(ergebnis.filter((r) => r.schonGemeldet).length, 2)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
})
|
||||
|
||||
test('(12) offener Fall abo_ende + Aufruf des Abgleichs + Webhook-Wiederholung: insgesamt genau ein T19 (zahlung_nach_ende)', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' })
|
||||
// Die erste Zustellung bucht den Fall nur (hier direkt ueber markPaid), die Meldung fehlt noch
|
||||
const gebucht = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) })
|
||||
assert.equal(gebucht.fall, 'abo_ende')
|
||||
assert.equal(mails.sent.length, 0)
|
||||
|
||||
// Der Abgleich (nachAboEnde: false) meldet zuerst: der offene Fall abo_ende macht daraus die Zahlung nach dem Abo-Ende
|
||||
assert.deepEqual(await sf.meldeErstattung(gebucht.doc, { nachAboEnde: false }), { sent: true })
|
||||
const t19 = mailsMit(SUBJECT.T19)
|
||||
assert.equal(t19.length, 1)
|
||||
assert.match(t19[0].text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.doesNotMatch(t19[0].text, /Ticket noch offen/, 'kein Ticket')
|
||||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||||
|
||||
// die Wiederholung des Webhooks danach bleibt stumm, auch parallel zu einem weiteren Aufruf
|
||||
STUMM(t)
|
||||
const parallel = await Promise.all([
|
||||
webhook('checkout.session.completed', bezahlt(sessionId)),
|
||||
sf.meldeErstattung(await doc()),
|
||||
])
|
||||
assert.equal(parallel[0].status, 200)
|
||||
assert.deepEqual(parallel[1], { sent: false, schonGemeldet: true })
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.equal(tickets().length, 0)
|
||||
spur()
|
||||
})
|
||||
|
||||
test('(12) Abgleich und Webhook gleichzeitig auf den offenen Fall: ein T19', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.disable((await doc()).$id, { reason: 'abo_ende_vor_einrichtung' })
|
||||
const gebucht = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) })
|
||||
STUMM(t)
|
||||
const [abgleich, hook] = await Promise.all([sf.meldeErstattung(gebucht.doc), webhook('checkout.session.completed', bezahlt(sessionId))])
|
||||
assert.equal(hook.status, 200)
|
||||
assert.ok(abgleich.sent || abgleich.schonGemeldet)
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.T19)[0].text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('(12) meldeErstattung: Mail scheitert => { sent: false }, kein Marker, kein Wurf; spaeter sendet der naechste Aufruf', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
const d = await doc()
|
||||
STUMM(t)
|
||||
mails.versandFehler = () => true
|
||||
const ergebnis = await sf.meldeErstattung(d)
|
||||
assert.deepEqual(ergebnis, { sent: false })
|
||||
assert.equal((await doc()).zahlungMeldung, '', 'kein Marker')
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 0)
|
||||
|
||||
mails.versandFehler = null
|
||||
assert.deepEqual(await sf.meldeErstattung(d), { sent: true })
|
||||
assert.equal((await doc()).zahlungMeldung, 'gemeldet')
|
||||
assert.equal(mailsMit(SUBJECT.T19).length, 1)
|
||||
})
|
||||
|
||||
test('(12) meldeErstattung: nachAboEnde setzt den Block auch ohne offenen Fall, Sitzung und Kunde sind uebersteuerbar', async () => {
|
||||
const sessionId = await bestelle()
|
||||
await webhook('checkout.session.completed', bezahlt(sessionId))
|
||||
const d = await doc()
|
||||
const ergebnis = await sf.meldeErstattung(d, { nachAboEnde: true, sitzung: 'cs_andere', customer: { name: 'Frau Muster', email: 'muster@example.invalid' } })
|
||||
assert.deepEqual(ergebnis, { sent: true })
|
||||
const [t19] = mailsMit(SUBJECT.T19)
|
||||
assert.match(t19.text, /Zahlung nach dem Abo-Ende/)
|
||||
assert.match(t19.text, /cs_andere/)
|
||||
assert.match(t19.text, /Frau Muster/)
|
||||
assert.match(t19.text, /muster@example\.invalid/)
|
||||
})
|
||||
|
||||
test('(12) meldeErstattung wirft nie: unbekanntes Dokument => { sent: false }', async (t) => {
|
||||
STUMM(t)
|
||||
assert.deepEqual(await sf.meldeErstattung({ $id: 'gibt_es_nicht', customerId: 'c1', projectId: 'p1', featureKey: 'termine' }), { sent: false })
|
||||
})
|
||||
|
||||
test('meldeZahlungFall direkt: einmal je Dokument, Zustand im Text, kein Wurf bei Versandfehler', async (t) => {
|
||||
const sessionId = await bestelle()
|
||||
await sf.adminActivate('c1', 'p1', 'termine', { by: 'Andrej' })
|
||||
const { doc: gebucht, fall } = await sf.markPaid({ sessionId, invoiceId: `in_${sessionId}`, invoiceUrl: rechnungUrl(sessionId) })
|
||||
assert.equal(fall, 'bereits_aktiv')
|
||||
STUMM(t)
|
||||
mails.versandFehler = () => true
|
||||
assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: false })
|
||||
assert.equal((await doc()).zahlungMeldung, 'bereits_aktiv')
|
||||
mails.versandFehler = null
|
||||
assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: true })
|
||||
assert.deepEqual(await sf.meldeZahlungFall(gebucht, fall), { sent: false, schonGemeldet: true })
|
||||
assert.equal(mailsMit(SUBJECT.T16).length, 1)
|
||||
assert.match(mailsMit(SUBJECT.T16)[0].text, /schon freigeschaltet/)
|
||||
})
|
||||
|
||||
test('customerOf und projectName sind exportiert', async () => {
|
||||
assert.equal((await sf.customerOf('c1')).name, 'Kunde Eins')
|
||||
assert.equal(await sf.customerOf('gibt_es_nicht'), null)
|
||||
assert.equal(await sf.projectName('p1'), 'Salon')
|
||||
assert.equal(await sf.projectName('gibt_es_nicht'), '')
|
||||
})
|
||||
Reference in new issue
Block a user