Zusatzspeicher: Vorschau im flexiblen Abrechnungsmodus, Aufgaben nach Zeitpunkt, keine stillen Abbrüche

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-29 09:16:07 +00:00
1 parent 188db4b3c1
commit bc20ce0673
9 files changed
+922 -228

No files matched your search

+443 -128
View File
@@ -4,6 +4,7 @@ import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
@@ -19,13 +20,20 @@ const { config } = await import('../server/config.js')
const { startTestApp, post, rawGet, postWebhook } = await import('./support/testApp.js')
const { hostingDoc, consent, customerCookie, viewAsCookie, seedFixtures, stripeSubscription } = await import('./support/fixtures.js')
const { default: aboRoutes } = await import('../server/routes/abo.js')
const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage } =
const { planStorageChange, paidUnitsOf, previewStorageChange, changeStorage, recordPaidStorage, storageTaskKind } =
await import('../server/services/mailStorage.js')
const { listContractEvents, listOpenStorageTasks, recordContractEvent } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/abo': aboRoutes })
after(() => Promise.all([app.close(), aw.close(), st.close()]))
// F25: Mails landen in der Senke statt bei SMTP - K13 (Kunde, beim Plus mit PDF) und T13 (Team) sind pruefbar
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
seedFixtures(aw)
// Preise im Stripe-Nachbau mit den lookup_keys des Katalogs: ensurePrice findet sie, die Fixture
@@ -37,6 +45,10 @@ st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lo
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
const PERIOD_END_UNIX = unix(hostingDoc.currentPeriodEnd)
const DAY = 86400
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
const PERIOD_END_LABEL = fmtDate(hostingDoc.currentPeriodEnd)
const K13_SUBJECT = 'Ihr E-Mail-Zusatzspeicher – Bestätigung'
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
@@ -46,10 +58,11 @@ async function clearEvents(customerId) {
* Hosting-Abo sub_h von c1 in beiden Nachbauten auf einen Stand setzen: units Pakete bei Stripe und im
* Spiegel; paidUnits (mit storagePaidPeriodEnd = currentPeriodEnd) als schon bezahlte Menge dieses Monats,
* sonst wie die Fixture (keine Buchung: bezahlt gilt dann die aktuelle Menge). patch aendert den Spiegel,
* stripe das Stripe-Abo (der Spiegel wird nach jeder Aenderung aus Stripe neu geschrieben).
* stripe das Stripe-Abo (der Spiegel wird nach jeder Aenderung aus Stripe neu geschrieben). Leert die Senke.
*/
async function resetSubH({ units = 0, paidUnits = null, patch = {}, stripe = {} } = {}) {
await clearEvents('c1')
mails.sent.length = 0
aw.seed('subscriptions', {
...hostingDoc,
storageUnits: units,
@@ -75,9 +88,14 @@ const previewInvoice = (...lines) => ({
})
const subscriptionUpdates = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/subscriptions/sub_h')
const previewCalls = (from = 0) => st.calls.slice(from).filter((c) => c.method === 'POST' && c.path === '/v1/invoices/create_preview')
const storageItem = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)
const doc = () => aw.docs('subscriptions').get('sub_h')
const openTasks = async () => (await listOpenStorageTasks()).filter((t) => t.subscriptionId === 'sub_h')
const k13 = () => mails.sent.find((m) => m.subject === K13_SUBJECT) || null
const t13 = () => mails.sent.find((m) => m.subject.startsWith('[Speicher]')) || null
// ---------------------------------------------------------------------------
// Reine Funktionen: Plan und bezahlte Menge
// Reine Funktionen: Plan, bezahlte Menge, Aufgabenart
// ---------------------------------------------------------------------------
test('Plan: erstes Plus anteilig', () => {
@@ -130,6 +148,18 @@ test('Bezahlte Menge gilt nur im laufenden Zeitraum', () => {
assert.equal(paidUnitsOf({ storageUnits: 2, storagePaidUnits: 0, storagePaidPeriodEnd: null, currentPeriodEnd: null }), 2)
assert.equal(paidUnitsOf({ storageUnits: 2 }), 2)
assert.equal(paidUnitsOf({}), 0)
// Fix 1 (4): frisches Periodenende aus Stripe statt des Spiegels
assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'B' }, 'A'), 3)
assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'A' }, 'C'), 1)
})
// Fix 1 (2): Aufgabenart aus details.kind; aeltere Ereignisse ohne kind (Aufgabe 7) nach der Richtung
test('storageTaskKind: details.kind, sonst Richtung', () => {
assert.equal(storageTaskKind({ details: { kind: 'einrichten', fromUnits: 3, toUnits: 1 } }), 'einrichten')
assert.equal(storageTaskKind({ details: { kind: 'periodenende', fromUnits: 1, toUnits: 2 } }), 'periodenende')
assert.equal(storageTaskKind({ details: { fromUnits: 0, toUnits: 2 } }), 'einrichten')
assert.equal(storageTaskKind({ details: { fromUnits: 2, toUnits: 1 } }), 'periodenende')
assert.equal(storageTaskKind({}), 'periodenende')
})
// ---------------------------------------------------------------------------
@@ -140,11 +170,12 @@ test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, n
await resetSubH({ units: 0 })
// Stripe-Vorschau = naechste Rechnung: anteilige Speicher-Zeile fuer den Rest des Monats plus die
// regulaeren Zeilen des naechsten Monats (die zaehlen nicht zum anteiligen Betrag)
st.invoicePreview = previewInvoice(
line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Math.floor(Date.now() / 1000) }),
line('price_hosting', 1500),
line('price_mailStorage', 200, { quantity: 2 })
)
st.invoicePreview = (body) =>
previewInvoice(
line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) }),
line('price_hosting', 1500),
line('price_mailStorage', 200, { quantity: 2 })
)
const before = st.calls.length
const p = await previewStorageChange('c1', 2)
assert.equal(p.direction, 'plus')
@@ -167,17 +198,20 @@ test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, n
assert.deepEqual(subscriptionUpdates(before), [])
})
test('Vorschau Plus mit Posten: Gutschrift und Belastung der Speicher-Zeilen, ältere anteilige Zeilen zählen nicht', async () => {
// Fix 1 (6): nur die Zeilen dieser Vorschau (period.start = proration_date, ±2 s); eine Aenderung von vor
// einer Minute steckt schon als offene Rechnungsposition in der naechsten Rechnung und zaehlt nicht mit
test('Vorschau Plus mit Posten: Gutschrift und Belastung ab proration_date, ältere anteilige Zeilen zählen nicht', async () => {
await resetSubH({ units: 1 })
const now = Math.floor(Date.now() / 1000)
st.invoicePreview = previewInvoice(
// Aenderung von vorhin (schon als offene Rechnungsposition vorgemerkt): nicht Teil dieser Bestellung
line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: now - 3600 }),
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: now }),
line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: now }),
line('price_hosting', 1500),
line('price_mailStorage', 300, { quantity: 3 })
)
st.invoicePreview = (body) => {
const at = Number(body.subscription_details.proration_date)
return previewInvoice(
line('price_mailStorage', 40, { quantity: 1, proration: true, periodStart: at - 60 }),
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at - 1 }),
line('price_mailStorage', 200, { quantity: 3, proration: true, periodStart: at + 1 }),
line('price_hosting', 1500),
line('price_mailStorage', 300, { quantity: 3 })
)
}
const before = st.calls.length
const p = await previewStorageChange('c1', 3)
assert.equal(p.prorationCents, 133)
@@ -212,18 +246,51 @@ test('Vorschau Minus bei gekündigtem Abo: wirksam mit dem Abo-Ende, wenn das fr
assert.equal((await previewStorageChange('c1', 1)).effectiveAt, cancelAt)
})
// F19: Stripe rechnet die Vorschau von der heutigen Stueckzahl (1) aus; bis zur schon bezahlten Menge (3)
// laeuft der erste Schritt ohne Verrechnung, berechnet werden nur die 2 Pakete darueber
test('Vorschau Plus in zwei Schritten: anteilig nur für die Pakete über der bezahlten Menge', async () => {
// Fix 1 (1): Abos aus dem Checkout laufen im flexiblen Abrechnungsmodus (billing_mode.type 'flexible', bei
// Stripe der Standard). Dort schreibt Stripe den zuletzt berechneten Betrag gut - die Vorschau hat die im
// Monat schon bezahlten Pakete also selbst verrechnet und wird unveraendert angezeigt.
test('Vorschau Plus in zwei Schritten, flexibler Modus (Standard): Betrag der Stripe-Vorschau unverändert', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const now = Math.floor(Date.now() / 1000)
// Gutschrift der letzten Berechnung (3 Pakete, -200), Belastung fuer 5 Pakete (333) = 133 fuer die 2 Pakete darueber
st.invoicePreview = (body) => {
const at = Number(body.subscription_details.proration_date)
return previewInvoice(
line('price_mailStorage', -200, { quantity: 3, proration: true, periodStart: at }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }),
line('price_hosting', 1500),
line('price_mailStorage', 500, { quantity: 5 })
)
}
const p = await previewStorageChange('c1', 5)
assert.equal(p.prorationCents, 133)
assert.equal(p.direction, 'plus')
assert.equal(p.deltaMonthlyCents, 400)
// Ausdruecklich flexible: ebenfalls keine Umrechnung (F19 gilt nur im klassischen Modus)
await resetSubH({ units: 1, paidUnits: 3, stripe: { billing_mode: { type: 'flexible' } } })
st.invoicePreview = (body) => {
const at = Number(body.subscription_details.proration_date)
return previewInvoice(
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at })
)
}
assert.equal((await previewStorageChange('c1', 5)).prorationCents, 266)
})
// F19 (klassischer Modus): Stripe rechnet die Vorschau von der heutigen Stueckzahl (1) aus; bis zur schon
// bezahlten Menge (3) laeuft der erste Schritt ohne Verrechnung, berechnet werden nur die 2 Pakete darueber
test('Vorschau Plus in zwei Schritten, klassischer Modus: anteilig nur für die Pakete über der bezahlten Menge', async () => {
await resetSubH({ units: 1, paidUnits: 3, stripe: { billing_mode: { type: 'classic' } } })
// Vorschau 1 -> 5: Gutschrift fuer 1 Paket (-67), Belastung fuer 5 Pakete (333) = 266 fuer 4 Pakete
st.invoicePreview = previewInvoice(
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: now }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: now }),
line('price_hosting', 1500),
line('price_mailStorage', 500, { quantity: 5 })
)
st.invoicePreview = (body) => {
const at = Number(body.subscription_details.proration_date)
return previewInvoice(
line('price_mailStorage', -67, { quantity: 1, proration: true, periodStart: at }),
line('price_mailStorage', 333, { quantity: 5, proration: true, periodStart: at }),
line('price_hosting', 1500),
line('price_mailStorage', 500, { quantity: 5 })
)
}
const before = st.calls.length
const p = await previewStorageChange('c1', 5)
// 266 × (5 − max(1, 3)) / (5 − 1) = 133
@@ -268,7 +335,7 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
const [ev] = await listContractEvents('c1')
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(aw.docs('subscriptions').get('sub_h').storagePaidUnits, 2)
assert.equal(doc().storagePaidUnits, 2)
// Erste Position: mit Preis angelegt, nicht mit Posten-ID; anteilig ab jetzt (proration_date)
assert.equal(subscriptionUpdates(before).length, 1)
@@ -276,13 +343,10 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
assert.equal(upd.at(-1).body['items[0][id]'], undefined)
assert.match(upd.at(-1).body['proration_date'], /^\d{10}$/)
// Stripe-Nachbau hat jetzt den Speicher-Posten mit Menge 2, der Spiegel ebenso
const stripeSub = st.subscriptions.get('sub_h')
const storageItem = stripeSub.items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)
assert.equal(storageItem.quantity, 2)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 2)
assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
// Ereignis: Nachweis mit Zustimmung, Aufgabe faellig jetzt, Ziel und Wirksamkeit in den details
assert.equal(storageItem().quantity, 2)
assert.equal(doc().storageUnits, 2)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
// Ereignis: Nachweis mit Zustimmung, Aufgabe "einrichten" faellig jetzt, Ziel und Wirksamkeit in den details
assert.equal(ev.subscriptionId, 'sub_h')
assert.equal(ev.customerId, 'c1')
assert.equal(ev.productKey, 'hosting')
@@ -291,6 +355,7 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
assert.equal(ev.consentIp, '127.0.0.1')
assert.equal(ev.consentUserAgent, 'test')
assert.equal(ev.earlyStartConsentAt ?? null, null)
assert.equal(ev.details.kind, 'einrichten')
assert.equal(ev.details.fromUnits, 0)
assert.equal(ev.details.toUnits, 2)
assert.equal(ev.details.totalGb, 15)
@@ -301,15 +366,31 @@ test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
assert.equal(result.subscription.storageUnits, 2)
assert.equal(result.event.$id, ev.$id)
assert.deepEqual(
{ direction: result.change.direction, fromUnits: result.change.fromUnits, toUnits: result.change.toUnits },
{ direction: 'plus', fromUnits: 0, toUnits: 2 }
{ direction: result.change.direction, kind: result.change.kind, fromUnits: result.change.fromUnits, toUnits: result.change.toUnits, partial: result.change.partial },
{ direction: 'plus', kind: 'einrichten', fromUnits: 0, toUnits: 2, partial: false }
)
// K13 an den Kunden mit den Vertragsunterlagen der zugestimmten Fassung (F22), T13 ans Team
assert.equal(mails.sent.length, 2)
const customerMail = k13()
assert.deepEqual(customerMail.to.map((t) => t.address), ['k@example.invalid'])
assert.deepEqual(customerMail.attachments.map((a) => [a.filename, a.contentType]), [['WEBklar-Abo-Vertragsunterlagen.pdf', 'application/pdf']])
assert.ok(customerMail.attachments[0].content.length > 1000, 'PDF-Inhalt angehaengt')
for (const text of ['Guten Tag Kunde Eins,', '2 Pakete', '15 GB', '2,00 €', 'gemäß § 19 UStG ohne Umsatzsteuer', 'wird eingerichtet', '14 Tagen widerrufen']) {
assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`)
}
assert.doesNotMatch(customerMail.text, /ohne anteilige Erstattung/)
const teamMail = t13()
assert.equal(teamMail.subject, '[Speicher] einrichten: k@example.invalid')
for (const text of ['Kunde Eins', 'sub_h', 'Hosting-Abo: 1 Postfach', '15 GB', '2 Pakete']) {
assert.ok(teamMail.text.includes(text), `T13 ohne "${text}":\n${teamMail.text}`)
}
assert.doesNotMatch(teamMail.text, /Nachweis nicht gespeichert|Schritt 2 fehlgeschlagen/)
})
test('Plus in zwei Schritten: erst die bezahlte Menge ohne Verrechnung, dann anteilig', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const before = st.calls.length
await changeStorage('c1', 5, consent)
const result = await changeStorage('c1', 5, consent)
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 2)
assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher')
@@ -320,29 +401,40 @@ test('Plus in zwei Schritten: erst die bezahlte Menge ohne Verrechnung, dann ant
assert.equal(upd[1].body['items[0][quantity]'], '5')
assert.equal(upd[1].body['proration_behavior'], 'create_prorations')
assert.match(upd[1].body['proration_date'], /^\d{10}$/)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 5)
assert.equal(doc.storagePaidUnits, 5)
assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(doc().storageUnits, 5)
assert.equal(doc().storagePaidUnits, 5)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(result.change.kind, 'einrichten')
assert.equal(result.change.partial, false)
})
test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, bezahlte Menge bleibt', async () => {
// Fix 1 (2b): ein Plus bis zur bezahlten Menge kostet nichts und braucht keine Einrichtung - konsoleH steht
// noch auf der bezahlten Menge; zum Periodenende gilt dann die neue Zielgroesse statt der frueheren Verringerung
test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, Aufgabe zum Periodenende, kein Einrichten', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const before = st.calls.length
await changeStorage('c1', 3, consent)
const result = await changeStorage('c1', 3, consent)
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 1)
assert.equal(upd[0].body['items[0][quantity]'], '3')
assert.equal(upd[0].body['proration_behavior'], 'none')
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 3)
assert.equal(doc.storagePaidUnits, 3)
assert.equal(doc().storageUnits, 3)
assert.equal(doc().storagePaidUnits, 3)
const [ev] = await listContractEvents('c1')
assert.equal(ev.details.kind, 'periodenende')
assert.equal(ev.details.toUnits, 3)
assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd)
assert.equal(ev.termsVersion, consent.termsVersion, 'Bestellung mit Zustimmung')
assert.equal(result.change.kind, 'periodenende')
assert.ok(Math.abs(new Date(result.change.effectiveAt).getTime() - Date.now()) < 5000, 'fuer den Kunden sofort wirksam')
assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`)
assert.ok(k13().attachments.length === 1, 'K13 mit PDF (Bestellung)')
})
test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => {
await assert.rejects(changeStorage('c1', 3, null), (e) => e.status === 400)
await assert.rejects(changeStorage('c1', 4, null), (e) => e.status === 400)
aw.seed('subscriptions', { ...hostingDoc, $id: 'sub_h', cancelAtPeriodEnd: true })
await assert.rejects(changeStorage('c1', 3, consent), (e) => e.status === 409)
await assert.rejects(changeStorage('c1', 4, consent), (e) => e.status === 409)
})
test('Plus ohne Zustimmung nennt die Regeln zum Zusatzspeicher, ohne Stripe-Aufruf', async () => {
@@ -352,6 +444,7 @@ test('Plus ohne Zustimmung nennt die Regeln zum Zusatzspeicher, ohne Stripe-Aufr
await assert.rejects(changeStorage('c1', 1, {}), (e) => e.status === 400)
assert.equal(st.calls.length, before)
assert.deepEqual(await listContractEvents('c1'), [])
assert.deepEqual(mails.sent, [])
})
test('Keine Änderung bei trialing, past_due, cancelAt oder offenem Wechsel (409)', async () => {
@@ -404,18 +497,28 @@ test('Minus: sofort bei Stripe ohne Erstattung, Aufgabe zum Periodenende, keine
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd)
assert.equal(ev.details.kind, 'periodenende')
assert.deepEqual([ev.details.fromUnits, ev.details.toUnits, ev.details.effectiveAt], [3, 1, hostingDoc.currentPeriodEnd])
assert.equal(ev.consentIp ?? null, null)
assert.equal(ev.consentUserAgent ?? null, null)
assert.equal(ev.termsVersion ?? null, null)
assert.equal(ev.consentAt ?? null, null)
// Spiegel: Stripe-Menge sofort 1, bezahlt bleiben 3 fuer diesen Monat (ein Plus zurueck auf 3 kostet nichts)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 1)
assert.equal(doc.storagePaidUnits, 3)
assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(doc().storageUnits, 1)
assert.equal(doc().storagePaidUnits, 3)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(result.change.direction, 'minus')
assert.equal(result.change.kind, 'periodenende')
assert.equal(result.change.effectiveAt, hostingDoc.currentPeriodEnd)
// K13 ohne Anhang mit dem Hinweis zum Monatsende, T13 "reduzieren zum"
const customerMail = k13()
assert.deepEqual(customerMail.attachments, [])
for (const text of [`zum ${PERIOD_END_LABEL}`, 'ohne anteilige Erstattung', 'keine neuen E-Mails', '10 GB', '1,00 €']) {
assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`)
}
assert.doesNotMatch(customerMail.text, /wird eingerichtet|14 Tagen widerrufen/)
assert.equal(t13().subject, `[Speicher] reduzieren zum ${PERIOD_END_LABEL}: k@example.invalid`)
assert.ok(t13().text.includes(PERIOD_END_LABEL))
// Plus zurueck auf 3 im selben Monat: ohne Verrechnung
const before2 = st.calls.length
@@ -424,14 +527,29 @@ test('Minus: sofort bei Stripe ohne Erstattung, Aufgabe zum Periodenende, keine
assert.equal(subscriptionUpdates(before2)[0].body['proration_behavior'], 'none')
})
// Fix 1 (4): jede Position war fuer den laufenden Monat berechnet - ein Minus haelt das fest, auch ohne
// fruehere Buchung (Altbestand); ein Plus zurueck bis dahin kostet dann nichts
test('Minus bucht die bezahlte Menge des Monats (max aus gebucht und aktuell), auch ohne frühere Buchung', async () => {
await resetSubH({ units: 3 }) // storagePaidPeriodEnd null: bisher galt nur die aktuelle Menge
await changeStorage('c1', 1, null)
assert.equal(doc().storagePaidUnits, 3)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
const before = st.calls.length
await changeStorage('c1', 3, consent)
assert.equal(subscriptionUpdates(before)[0].body['proration_behavior'], 'none')
// Eine hoehere Buchung bleibt beim Minus erhalten
await resetSubH({ units: 2, paidUnits: 4 })
await changeStorage('c1', 1, null)
assert.equal(doc().storagePaidUnits, 4)
})
test('Minus auf 0: Position bleibt mit Menge 0, ein späteres Plus nutzt sie wieder', async () => {
await resetSubH({ units: 2, paidUnits: 2 })
const before = st.calls.length
await changeStorage('c1', 0, null)
assert.equal(subscriptionUpdates(before)[0].body['items[0][quantity]'], '0')
const storageItem = () => st.subscriptions.get('sub_h').items.data.find((i) => i.price.lookup_key === config.mailStorage.lookupKey)
assert.equal(storageItem().quantity, 0)
assert.equal(aw.docs('subscriptions').get('sub_h').storageUnits, 0)
assert.equal(doc().storageUnits, 0)
const before2 = st.calls.length
await changeStorage('c1', 4, consent)
const upd = subscriptionUpdates(before2)
@@ -441,72 +559,167 @@ test('Minus auf 0: Position bleibt mit Menge 0, ein späteres Plus nutzt sie wie
assert.equal(st.subscriptions.get('sub_h').items.data.length, 2, 'kein zweiter Speicher-Posten')
})
// F21: aeltere offene Aufgaben desselben Abos sind ueberholt; fuers Team zaehlt eine Aufgabe je Abo
test('Neue Änderung ersetzt die offene Aufgabe: eine Aufgabe je Abo mit Zielgröße und Datum', async () => {
// ---------------------------------------------------------------------------
// Aufgaben fuers Team nach Zeitpunkt (Fix 1, 2; F21): hoechstens eine offene Aufgabe je Art und Abo
// ---------------------------------------------------------------------------
test('Plus noch nicht eingerichtet, dann Minus: Einrichtung bleibt offen, dazu die Verringerung zum Periodenende', async () => {
await resetSubH({ units: 0 })
await changeStorage('c1', 2, consent)
const [plusEvent] = await listContractEvents('c1')
await changeStorage('c1', 1, null)
const events = await listContractEvents('c1')
assert.equal(events.length, 2)
const superseded = events.find((e) => e.$id === plusEvent.$id)
const tasks = await openTasks()
assert.equal(tasks.length, 2)
const setup = tasks.find((t) => storageTaskKind(t) === 'einrichten')
const reduction = tasks.find((t) => storageTaskKind(t) === 'periodenende')
assert.equal(setup.details.toUnits, 2)
assert.equal(setup.details.totalGb, 15)
assert.ok(Math.abs(new Date(setup.dueAt).getTime() - Date.now()) < 10000)
assert.equal(reduction.details.toUnits, 1)
assert.equal(reduction.details.totalGb, 10)
assert.equal(reduction.dueAt, hostingDoc.currentPeriodEnd)
assert.equal((await listContractEvents('c1')).filter((e) => e.status === 'offen').length, 2)
})
test('Minus, dann Plus bis zur bezahlten Menge: eine offene Aufgabe zum Periodenende mit der neuen Zielgröße', async () => {
await resetSubH({ units: 3, paidUnits: 3 })
await changeStorage('c1', 1, null)
const [minusEvent] = await listContractEvents('c1')
await changeStorage('c1', 2, consent)
const tasks = await openTasks()
assert.equal(tasks.length, 1)
assert.equal(storageTaskKind(tasks[0]), 'periodenende')
assert.equal(tasks[0].details.toUnits, 2)
assert.equal(tasks[0].dueAt, hostingDoc.currentPeriodEnd)
const superseded = (await listContractEvents('c1')).find((e) => e.$id === minusEvent.$id)
assert.equal(superseded.status, 'erledigt')
assert.equal(superseded.doneBy, 'ersetzt')
assert.ok(superseded.doneAt)
assert.deepEqual(superseded.details, plusEvent.details, 'details des ersetzten Ereignisses bleiben')
const open = (await listOpenStorageTasks()).filter((t) => t.subscriptionId === 'sub_h')
assert.equal(open.length, 1)
assert.equal(open[0].details.toUnits, 1)
assert.equal(open[0].dueAt, hostingDoc.currentPeriodEnd)
// Ein fremdes Abo (c2) bleibt unberuehrt
assert.deepEqual(superseded.details, minusEvent.details, 'details des ersetzten Ereignisses bleiben')
})
test('Plus über die geplante Verringerung hinaus hebt sie auf: nur noch die Einrichtung', async () => {
await resetSubH({ units: 3, paidUnits: 3 })
await changeStorage('c1', 1, null)
await changeStorage('c1', 5, consent)
const tasks = await openTasks()
assert.equal(tasks.length, 1)
assert.equal(storageTaskKind(tasks[0]), 'einrichten')
assert.equal(tasks[0].details.toUnits, 5)
const events = await listContractEvents('c1')
assert.equal(events.filter((e) => e.status === 'erledigt' && e.doneBy === 'ersetzt').length, 1)
})
test('Neues Plus ersetzt die offene Einrichtung; ein fremdes Abo bleibt unberührt', async () => {
await resetSubH({ units: 0 })
await changeStorage('c1', 2, consent)
const [first] = await listContractEvents('c1')
await recordContractEvent({
customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting',
details: { fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(),
details: { kind: 'einrichten', fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(),
})
await changeStorage('c1', 2, consent)
await changeStorage('c1', 3, consent)
const tasks = await openTasks()
assert.equal(tasks.length, 1)
assert.equal(tasks[0].details.toUnits, 3)
const superseded = (await listContractEvents('c1')).find((e) => e.$id === first.$id)
assert.equal(superseded.status, 'erledigt')
assert.equal(superseded.doneBy, 'ersetzt')
assert.equal((await listContractEvents('c2'))[0].status, 'offen')
await clearEvents('c2')
})
// F20 / Rechtsrunde: ueber das Kuendigungsformular (viaCancellation) sind Verringerungen bei jedem
// laufenden Status und auch bei gekuendigtem Abo erlaubt, Erhoehungen nie; K13 entfaellt dort (F31)
test('Kündigungsweg: Verringerung auch bei gekündigtem Abo, Erhöhung nie', async () => {
test('Kündigungsweg: Verringerung auch bei gekündigtem Abo, Erhöhung nie, K13 entfällt', async () => {
await resetSubH({
units: 2,
patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd },
stripe: { cancel_at_period_end: true },
})
const result = await changeStorage('c1', 1, null, { viaCancellation: true })
assert.equal(result.change.direction, 'minus')
assert.equal(doc().storageUnits, 1)
const [ev] = await listContractEvents('c1')
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.viaCancellation, true)
assert.equal(ev.consentIp ?? null, null)
assert.equal(k13(), null, 'K13 entfaellt beim Kuendigungsformular (K03 bestaetigt)')
assert.ok(t13(), 'T13 geht trotzdem raus')
assert.equal(mails.sent.length, 1)
await assert.rejects(changeStorage('c1', 3, consent, { viaCancellation: true }), (e) => e.status === 409)
// Der Spiegel bleibt gekuendigt (aus Stripe); ohne viaCancellation bleibt er gesperrt, auch fuer ein Minus
assert.equal(doc().cancelAtPeriodEnd, true)
await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409)
// past_due: Verringerung erlaubt; beendetes Abo (canceled): nicht mehr
await resetSubH({ units: 2, patch: { status: 'past_due' }, stripe: { status: 'past_due' } })
assert.equal((await changeStorage('c1', 0, null, { viaCancellation: true })).change.toUnits, 0)
assert.equal(doc().status, 'past_due')
await resetSubH({ units: 2, patch: { status: 'canceled', endedAt: new Date().toISOString() } })
await assert.rejects(changeStorage('c1', 1, null, { viaCancellation: true }), (e) => e.status === 409)
})
// ---------------------------------------------------------------------------
// Fehlerpfade (Fix 1, 3/7/8): nach der Stripe-Aenderung bricht nichts mehr stillschweigend ab
// ---------------------------------------------------------------------------
test('Stripe lehnt die Änderung ab: Ereignis gescheitert, Fehler an den Kunden, nichts gebucht, keine Mails', async () => {
await resetSubH({ units: 0 })
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'No such price: price_weg' })
await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 502 && /Stripe/.test(e.message) && /nichts berechnet/.test(e.message))
const events = await listContractEvents('c1')
assert.equal(events.length, 1)
assert.equal(events[0].status, 'gescheitert')
assert.equal(events[0].details.kind, 'einrichten')
assert.match(events[0].details.error, /No such price/)
assert.equal(events[0].termsVersion, consent.termsVersion, 'Nachweis der Bestellung bleibt')
assert.equal(storageItem(), undefined, 'nichts bei Stripe')
assert.equal(doc().storageUnits, 0)
assert.equal(doc().storagePaidUnits, 0)
assert.deepEqual(mails.sent, [])
assert.deepEqual(await openTasks(), [])
// Danach geht eine neue Aenderung wieder (Sperre freigegeben)
assert.equal((await changeStorage('c1', 2, consent)).change.toUnits, 2)
})
test('Spiegel nicht aktualisierbar nach der Stripe-Änderung: Aufruf gelingt, Ereignis offen, K13 und T13', async () => {
await resetSubH({ units: 0 })
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
const result = await changeStorage('c1', 1, null, { viaCancellation: true })
assert.equal(result.change.direction, 'minus')
assert.equal(aw.docs('subscriptions').get('sub_h').storageUnits, 1)
const [ev] = await listContractEvents('c1')
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.viaCancellation, true)
assert.equal(ev.consentIp ?? null, null)
assert.ok(!errors.some((e) => e.includes('K13')), 'K13 entfaellt beim Kuendigungsformular (K03 bestaetigt)')
// Erste PATCH nach der Stripe-Aenderung ist der Spiegel (syncSubscription)
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 })
const result = await changeStorage('c1', 2, consent)
assert.equal(result.subscription.storageUnits, 2, 'Rueckgabe zeigt die neue Menge')
assert.equal(result.event.status, 'offen')
assert.equal(storageItem().quantity, 2)
assert.ok(errors.some((e) => /Spiegel/.test(e) && /sub_h/.test(e)))
assert.equal(mails.sent.length, 2)
assert.ok(k13() && t13())
} finally {
console.error = original
}
})
await assert.rejects(changeStorage('c1', 3, consent, { viaCancellation: true }), (e) => e.status === 409)
// Der Spiegel bleibt gekuendigt (aus Stripe); ohne viaCancellation bleibt er gesperrt, auch fuer ein Minus
assert.equal(aw.docs('subscriptions').get('sub_h').cancelAtPeriodEnd, true)
await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409)
// past_due: Verringerung erlaubt; beendetes Abo (canceled): nicht mehr
await resetSubH({ units: 2, patch: { status: 'past_due' }, stripe: { status: 'past_due' } })
assert.equal((await changeStorage('c1', 0, null, { viaCancellation: true })).change.toUnits, 0)
assert.equal(aw.docs('subscriptions').get('sub_h').status, 'past_due')
await resetSubH({ units: 2, patch: { status: 'canceled', endedAt: new Date().toISOString() } })
await assert.rejects(changeStorage('c1', 1, null, { viaCancellation: true }), (e) => e.status === 409)
// Zum Vergleich: ein Minus im Portal versucht K13 (scheitert hier nur am fehlenden SMTP)
errors.length = 0
await resetSubH({ units: 2 })
await changeStorage('c1', 1, null)
assert.ok(errors.some((e) => e.includes('K13')), 'K13 wird im Portal versucht')
test('Ereignis nicht speicherbar: Stripe-Änderung läuft, T13 mit Block nachweis_fehlt', async () => {
await resetSubH({ units: 0 })
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
const result = await changeStorage('c1', 1, consent)
assert.equal(result.event, null)
assert.equal(result.subscription.storageUnits, 1)
assert.equal(storageItem().quantity, 1)
assert.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e)))
assert.deepEqual(await listContractEvents('c1'), [])
assert.ok(k13(), 'K13 geht raus')
assert.match(t13().text, /Nachweis nicht gespeichert/)
assert.match(t13().text, /contractEvents/)
} finally {
console.error = original
}
@@ -524,33 +737,59 @@ test('Bezahlte Menge nicht speicherbar: Stripe-Änderung bleibt, Ereignis und Au
assert.equal(result.change.direction, 'plus')
assert.ok(result.event)
assert.equal(result.subscription.storageUnits, 2)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storageUnits, 2)
assert.equal(doc.storagePaidUnits, 0, 'Buchung fehlt, der Rest lief durch')
assert.equal(doc().storageUnits, 2)
assert.equal(doc().storagePaidUnits, 0, 'Buchung fehlt, der Rest lief durch')
assert.ok(errors.some((e) => /Bezahlte Menge/.test(e) && /sub_h/.test(e) && /400/.test(e)))
assert.equal((await listContractEvents('c1')).length, 1)
assert.equal(mails.sent.length, 2)
} finally {
console.error = original
}
})
test('Ereignis nicht speicherbar: Stripe-Änderung bleibt, Aufruf gelingt, Fehler im Log', async () => {
await resetSubH({ units: 0 })
// Fix 1 (7): scheitert Schritt 2 nach Schritt 1, bleibt die Bestellung offen (partial), das Team setzt die
// Menge in Stripe von Hand; nichts wird stillschweigend verworfen und dem Kunden nichts Falsches gemeldet
test('Zwei Schritte, zweiter scheitert: Menge bleibt bei der bezahlten, Ereignis offen mit partial, T13 mit Handgriff', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
const result = await changeStorage('c1', 1, consent)
assert.equal(result.event, null)
assert.equal(result.subscription.storageUnits, 1)
assert.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e)))
assert.deepEqual(await listContractEvents('c1'), [])
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, after: 1, message: 'Nachbau: Schritt 2 abgelehnt' })
const result = await changeStorage('c1', 5, consent)
assert.equal(result.change.partial, true)
assert.equal(result.change.toUnits, 5)
assert.equal(storageItem().quantity, 3, 'Schritt 1 gilt')
assert.equal(doc().storageUnits, 3)
assert.equal(doc().storagePaidUnits, 3, 'nichts ueber die bezahlte Menge hinaus gebucht')
const [ev] = await listContractEvents('c1')
assert.equal(ev.status, 'offen')
assert.equal(ev.details.partial, true)
assert.equal(ev.details.toUnits, 5)
assert.match(ev.details.error, /Schritt 2 abgelehnt/)
assert.ok(errors.some((e) => /Schritt 2/.test(e)))
assert.match(t13().text, /Schritt 2 fehlgeschlagen/)
assert.match(t13().text, /von Hand auf 5 setzen/)
assert.ok(k13(), 'Bestellung bestaetigt')
} finally {
console.error = original
}
})
// Fix 1 (8): Doppelklick oder zwei Tabs - die zweite gleichzeitige Aenderung desselben Abos bekommt 409
test('Doppelte Änderung gleichzeitig: die zweite bekommt 409, danach geht es wieder', async () => {
await resetSubH({ units: 0 })
const results = await Promise.allSettled([changeStorage('c1', 2, consent), changeStorage('c1', 3, consent)])
const ok = results.filter((r) => r.status === 'fulfilled')
const failed = results.filter((r) => r.status === 'rejected')
assert.equal(ok.length, 1)
assert.equal(failed.length, 1)
assert.equal(failed[0].reason.status, 409)
assert.match(failed[0].reason.message, /läuft bereits/)
assert.equal((await listContractEvents('c1')).length, 1)
assert.equal((await changeStorage('c1', 4, consent)).change.toUnits, 4, 'Sperre freigegeben')
})
// ---------------------------------------------------------------------------
// Bezahlte Menge aus Rechnungen (Webhook invoice.paid)
// ---------------------------------------------------------------------------
@@ -570,9 +809,8 @@ test('invoice.paid: bezahlte Menge aus regulärer Speicher-Zeile', async () => {
],
},
})
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storagePaidUnits, 3)
assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
assert.equal(doc().storagePaidUnits, 3)
assert.equal(doc().storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
})
test('invoice.paid: anteilige Speicher-Zeile oder Rechnung ohne Speicher ändert nichts', async () => {
@@ -581,9 +819,26 @@ test('invoice.paid: anteilige Speicher-Zeile oder Rechnung ohne Speicher ändert
assert.ok(r1.skipped)
const r2 = await recordPaidStorage({ id: 'in_y', lines: { data: [line('price_hosting', 1500)] } })
assert.ok(r2.skipped)
const doc = aw.docs('subscriptions').get('sub_h')
assert.equal(doc.storagePaidUnits, 2)
assert.equal(doc.storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
assert.equal(doc().storagePaidUnits, 2)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
})
// Fix 1 (4): eine verspaetete Rechnung des laufenden Monats senkt die nach einem Plus gebuchte Menge nicht,
// eine wiederholte aeltere Rechnung setzt das Periodenende nie zurueck, eine neuere Periode setzt neu
test('invoice.paid: verspätet nach einem Plus (max), ältere Rechnung (übersprungen), neuere Periode (neu)', async () => {
await resetSubH({ units: 5, paidUnits: 5 })
const late = await recordPaidStorage({ id: 'in_late', lines: { data: [line('price_mailStorage', 300, { quantity: 3 })] } })
assert.equal(late.recorded, true)
assert.equal(doc().storagePaidUnits, 5)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
const older = await recordPaidStorage({ id: 'in_alt', lines: { data: [line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX - 30 * DAY })] } })
assert.ok(older.skipped)
assert.equal(doc().storagePaidUnits, 5)
assert.equal(doc().storagePaidPeriodEnd, hostingDoc.currentPeriodEnd)
const newer = await recordPaidStorage({ id: 'in_neu', lines: { data: [line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] } })
assert.equal(newer.recorded, true)
assert.equal(doc().storagePaidUnits, 3)
assert.equal(doc().storagePaidPeriodEnd, new Date((PERIOD_END_UNIX + 30 * DAY) * 1000).toISOString())
})
test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', async () => {
@@ -592,16 +847,47 @@ test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', a
try {
const r = await recordPaidStorage({ id: 'in_z', lines: { data: [line('price_mailStorage', 100, { quantity: 1, subscription: subId, periodEnd: 1793000000 })] } })
assert.equal(r.recorded, true)
const doc = aw.docs('subscriptions').get(subId)
assert.equal(doc.customerId, 'c2')
assert.equal(doc.storageUnits, 1)
assert.equal(doc.storagePaidUnits, 1)
assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
const created = aw.docs('subscriptions').get(subId)
assert.equal(created.customerId, 'c2')
assert.equal(created.storageUnits, 1)
assert.equal(created.storagePaidUnits, 1)
assert.equal(created.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
} finally {
await deleteDocument(config.collections.subscriptions, subId).catch(() => {})
}
})
// Fix 1 (5): wie beim Webhook aus Aufgabe 7 - ein dauerhafter Appwrite-Fehler (4xx) wird protokolliert und
// uebersprungen (200), ein voruebergehender (5xx) gibt 500, Stripe wiederholt
test('Webhook invoice.paid: Appwrite 4xx → 200 und übersprungen, 5xx → 500', async () => {
await resetSubH({ units: 2, paidUnits: 2 })
const now = Math.floor(Date.now() / 1000)
st.invoices.set('in_h2', {
id: 'in_h2', object: 'invoice', number: 'WK-h2', created: now, status_transitions: { paid_at: now },
parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } },
lines: { object: 'list', data: [line('price_hosting', 1500, { periodEnd: PERIOD_END_UNIX + 30 * DAY }), line('price_mailStorage', 300, { quantity: 3, periodEnd: PERIOD_END_UNIX + 30 * DAY })] },
})
const event = { id: 'evt_in_h2', object: 'event', type: 'invoice.paid', created: now, data: { object: { id: 'in_h2', object: 'invoice', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_h' } } } } }
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 400 })
const permanent = await postWebhook(event)
assert.equal(permanent.status, 200, JSON.stringify(permanent.body))
assert.equal(doc().storagePaidUnits, 2, 'uebersprungen')
assert.ok(errors.some((e) => /Bezahlte Menge|Speicher/.test(e) && /400/.test(e)))
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 })
assert.equal((await postWebhook(event)).status, 500)
assert.equal(doc().storagePaidUnits, 2)
// Wiederholung ohne Stoerung bucht
assert.equal((await postWebhook(event)).status, 200)
assert.equal(doc().storagePaidUnits, 3)
} finally {
console.error = original
}
})
// F18: der Webhook laedt die Rechnung genau einmal frisch (mit allen Zeilen, auch ueber die 10 eingebetteten
// hinaus) und bucht daraus Stundengutschrift und bezahlte Speichermenge
test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde und bezahlte Speichermenge', async () => {
@@ -635,11 +921,11 @@ test('Webhook invoice.paid: Rechnung einmal frisch geladen, alle Zeilen, Stunde
assert.equal(calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices/in_w12').length, 1, 'genau einmal frisch geladen')
assert.ok(calls.some((c) => c.method === 'GET' && c.path === '/v1/invoices/in_w12/lines'), 'weitere Zeilen nachgeladen')
assert.equal(aw.docs('hourCredits').get('in_w12').minutes, 60)
const doc = aw.docs('subscriptions').get('sub_w')
assert.equal(doc.productKey, 'wartung')
assert.equal(doc.storageUnits, 2)
assert.equal(doc.storagePaidUnits, 2)
assert.equal(doc.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
const mirrored = aw.docs('subscriptions').get('sub_w')
assert.equal(mirrored.productKey, 'wartung')
assert.equal(mirrored.storageUnits, 2)
assert.equal(mirrored.storagePaidUnits, 2)
assert.equal(mirrored.storagePaidPeriodEnd, new Date(1793000000 * 1000).toISOString())
} finally {
await deleteDocument(config.collections.subscriptions, 'sub_w').catch(() => {})
await deleteDocument(config.collections.hourCredits, 'in_w12').catch(() => {})
@@ -663,7 +949,8 @@ test('Routen: ohne Session 401, im Modus „Als Kunde ansehen“ 403, ohne Strip
test('Route Vorschau: { units } → Vorschau; Grenzen 400', async () => {
await resetSubH({ units: 0 })
st.invoicePreview = previewInvoice(line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Math.floor(Date.now() / 1000) }))
st.invoicePreview = (body) =>
previewInvoice(line('price_mailStorage', 133, { quantity: 2, proration: true, periodStart: Number(body.subscription_details.proration_date) }))
const res = await post('/api/abo/storage/preview', { units: 2 }, { cookie: customerCookie() })
assert.equal(res.status, 200, JSON.stringify(res.body))
assert.equal(res.body.direction, 'plus')
@@ -692,6 +979,7 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor
assert.equal(plus.body.subscription.withdrawalOpen, true)
assert.deepEqual(plus.body.subscription.withdrawals.map((w) => w.type), ['speicher'])
assert.equal(plus.body.change.direction, 'plus')
assert.equal(plus.body.change.kind, 'einrichten')
assert.equal(plus.body.change.toUnits, 2)
assert.equal(subscriptionUpdates(before).length, 1)
const [ev] = await listContractEvents('c1')
@@ -699,15 +987,16 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor
assert.equal(ev.consentIp, '127.0.0.1')
assert.ok(ev.consentAt)
// Minus ohne Haekchen: Speichern; Antwort zeigt die geplante Verringerung
// Minus ohne Haekchen: Speichern; die Einrichtung ist noch offen, dazu die geplante Verringerung
const minus = await post('/api/abo/storage', { units: 1 }, { cookie })
assert.equal(minus.status, 200, JSON.stringify(minus.body))
assert.deepEqual(minus.body.subscription.storage, {
units: 1,
pendingSetup: false,
pendingSetup: true,
pendingReduction: { units: 1, effectiveAt: minus.body.subscription.currentPeriodEnd },
})
assert.equal(minus.body.change.direction, 'minus')
assert.equal(minus.body.change.kind, 'periodenende')
assert.equal(minus.body.change.effectiveAt, minus.body.subscription.currentPeriodEnd)
// Gekuendigt: 409 mit Fehlertext
@@ -717,3 +1006,29 @@ test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwor
assert.ok(cancelled.body.error)
await clearEvents('c1')
})
// Fix 1 (3): Stripe-Fehler → 5xx mit Fehlertext, nichts gebucht; Spiegel-Fehler nach Stripe → 200 mit neuer Menge
test('Route Änderung: Stripe-Fehler 502 ohne Mails, Spiegel-Fehler nach der Stripe-Änderung 200', async () => {
await resetSubH({ units: 0 })
const cookie = customerCookie()
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau: abgelehnt' })
const failed = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie })
assert.equal(failed.status, 502)
assert.match(failed.body.error, /Stripe/)
assert.deepEqual(mails.sent, [])
assert.equal((await listContractEvents('c1'))[0].status, 'gescheitert')
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 500 })
const ok = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie })
assert.equal(ok.status, 200, JSON.stringify(ok.body))
assert.deepEqual(ok.body.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null })
assert.equal((await listContractEvents('c1')).find((e) => e.status === 'offen').details.toUnits, 2)
assert.equal(mails.sent.length, 2)
} finally {
console.error = original
}
})
+40 -2
View File
@@ -4,13 +4,16 @@ import { test } from 'node:test'
import { escapeHtml, htmlToText, loadTemplate, renderMail, templateIds } from '../server/services/mailTemplates.js'
const DIR = new URL('../server/mail-templates/', import.meta.url)
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9; K13/T13 und die Endfassung der Texte folgen in Task 12
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
// die Endfassung der Texte folgt in Task 12
const EXPECTED = [
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
'K12',
'K13',
'K14',
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
'T12',
'T13',
]
const sampleValues = (tpl) => Object.fromEntries(tpl.placeholders.map((p) => [p, `Wert-${p}`]))
@@ -26,7 +29,8 @@ for (const id of EXPECTED) {
const source = readFileSync(new URL(tpl.file, DIR), 'utf8')
const title = source.match(/<title>([\s\S]*?)<\/title>/)[1]
assert.equal(title, tpl.subject, 'Betreff im Kopf und <title> sind gleich')
assert.equal(id.startsWith('T'), tpl.subject.startsWith('[Abo]') || id === 'T01', 'Team-Hinweise ab T02 mit [Abo]')
// F34: Team-Hinweise tragen [Abo], [Speicher] (Zusatzspeicher-Aufgaben) oder [Datenschutz] (Loeschfristen)
assert.equal(id.startsWith('T'), /^\[(Abo|Speicher|Datenschutz)\]/.test(tpl.subject) || id === 'T01', 'Team-Hinweise ab T02 mit [Abo], [Speicher] oder [Datenschutz]')
const used = new Set([...tpl.body.matchAll(/\{\{(\w+)\}\}/g), ...tpl.subject.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]))
assert.deepEqual([...used].sort(), [...tpl.placeholders].sort(), 'deklarierte = verwendete Platzhalter')
@@ -200,3 +204,37 @@ test('Wechsel-Mails nennen keine feste Frist von 23 Stunden', () => {
assert.doesNotMatch(renderMail(id, sampleValues(tpl), allBlocks(tpl, true)).text, /23 Stunden/, id)
}
})
// Task 8 (Fix 1): K13 (Kunde) und T13 (Team) fuer den Zusatzspeicher, Aufbau wie K12/T12; Endfassung der Texte in Aufgabe 12
test('K13, T13: Betreff, Platzhalter und Blöcke des Zusatzspeichers', () => {
assert.equal(loadTemplate('K13').subject, 'Ihr E-Mail-Zusatzspeicher – Bestätigung')
assert.deepEqual(loadTemplate('K13').placeholders, ['name', 'pakete', 'gesamt_gb', 'monatspreis_zusatz', 'wirksam_ab'])
assert.deepEqual(loadTemplate('K13').blocks, ['plus', 'minus'])
assert.equal(loadTemplate('T13').subject, '[Speicher] {{aktion}}: {{email}}')
for (const p of ['aktion', 'email', 'name', 'stripe_abo_id', 'postfaecher_hinweis', 'neue_groesse_gb', 'pakete', 'faellig']) {
assert.ok(loadTemplate('T13').placeholders.includes(p), p)
}
assert.deepEqual([...loadTemplate('T13').blocks].sort(), ['einrichten', 'nachweis_fehlt', 'periodenende', 'teilweise'])
const values = sampleValues(loadTemplate('K13'))
const plus = renderMail('K13', values, { plus: true })
assert.deepEqual(plus.problems, [])
assert.match(plus.text, /gemäß § 19 UStG ohne Umsatzsteuer/)
assert.match(plus.text, /wird eingerichtet/)
assert.match(plus.text, /14 Tagen widerrufen/)
assert.match(plus.text, /Vertragsunterlagen im Anhang/)
assert.doesNotMatch(plus.text, /ohne anteilige Erstattung/)
const minus = renderMail('K13', values, { minus: true })
assert.match(minus.text, /ohne anteilige Erstattung/)
assert.match(minus.text, /keine neuen E-Mails/)
assert.doesNotMatch(minus.text, /wird eingerichtet|14 Tagen widerrufen|im Anhang/)
const t = loadTemplate('T13')
const teilweise = renderMail('T13', { ...sampleValues(t), pakete: 5 }, { einrichten: true, teilweise: true })
assert.deepEqual(teilweise.problems, [])
assert.match(teilweise.text, /Schritt 2 fehlgeschlagen/)
assert.match(teilweise.text, /von Hand auf 5 setzen/)
assert.match(renderMail('T13', sampleValues(t), { nachweis_fehlt: true }).text, /Nachweis nicht gespeichert/)
assert.match(renderMail('T13', sampleValues(t), { periodenende: true }).text, /Periodenende|zum Wert-faellig/)
assert.doesNotMatch(renderMail('T13', sampleValues(t), {}).text, /Schritt 2 fehlgeschlagen|Nachweis nicht gespeichert/)
})
+8
View File
@@ -196,3 +196,11 @@ test('regularStorageLine: reguläre Zusatzspeicher-Zeile, keine anteilige, kein
assert.equal(regularStorageLine(inv({ amount: 5 }), keys), null)
assert.equal(regularStorageLine({}, keys), null)
})
// Task 8 (Fix 1, Testluecke 9): eine Rechnung nur mit Zusatzspeicher bringt keine Stunde, auch nicht als
// "Altbestand" (der Speicherpreis ist bekannt, nur kein Abo-Produkt)
test('Nur Zusatzspeicher auf der Rechnung: keine Stunde', () => {
const inv = (...lines) => ({ lines: { data: lines } })
assert.equal(creditMinutesForInvoice(inv({ ...line('price_s', 300), quantity: 3 }), keys), 0)
assert.equal(creditMinutesForInvoice(inv(line('price_s', 100), line('price_s', 33, true)), keys), 0)
})
+19
View File
@@ -257,3 +257,22 @@ test('Stripe-Nachbau: Rechnungsvorschau aus dem Seed', async () => {
await assert.rejects(stripe.invoices.createPreview({ subscription: 'sub_nix' }), (e) => e.statusCode === 404)
st.invoicePreview = null
})
// F41d / Task 8 (Fix 1): Fehler im Stripe-Nachbau einplanen - einmalig, nur fuer den passenden Aufruf
// (method, path als String oder RegExp); after passende Aufrufe gehen vorher durch. Der gescheiterte Aufruf
// steht trotzdem in calls; 4xx wiederholt stripe-node nicht (5xx schon, maxNetworkRetries).
test('Stripe-Nachbau: failNext lässt den nächsten passenden Aufruf scheitern', async () => {
const stripe = getStripe()
st.subscriptions.set('sub_f', { id: 'sub_f', object: 'subscription', status: 'active', metadata: {}, items: { object: 'list', data: [] } })
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_f', status: 400, message: 'Nachbau: abgelehnt' })
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } }), (e) => e.statusCode === 400 && /abgelehnt/.test(e.message))
assert.equal(st.calls.at(-1).body['metadata[a]'], 'b', 'gescheiterter Aufruf steht in calls')
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, undefined, 'gescheiterter Aufruf aendert nichts')
assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'b' } })).metadata.a, 'b', 'einmalig')
// Nur der passende Pfad (RegExp) und erst nach `after` passenden Aufrufen
st.failNext({ method: 'POST', path: /\/v1\/subscriptions\/sub_f$/, status: 402, after: 1 })
assert.equal((await stripe.subscriptions.retrieve('sub_f')).id, 'sub_f')
assert.equal((await stripe.subscriptions.update('sub_f', { metadata: { a: 'c' } })).metadata.a, 'c')
await assert.rejects(stripe.subscriptions.update('sub_f', { metadata: { a: 'd' } }), (e) => e.statusCode === 402)
assert.equal((await stripe.subscriptions.retrieve('sub_f')).metadata.a, 'c')
})
+25 -1
View File
@@ -38,8 +38,24 @@ export async function startFakeStripe() {
let seq = 0
let nextSubscriptionUpdate = null
let invoicePreview = null
// Eingeplanter Fehler (failNext, F41d): der naechste passende Aufruf scheitert einmalig
let pendingFailure = null
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname)
const plannedFailure = (method, pathname) => {
if (!pendingFailure) return null
if (pendingFailure.method && pendingFailure.method !== method) return null
if (pendingFailure.path && !matchesPath(pendingFailure.path, pathname)) return null
if (pendingFailure.after > 0) {
pendingFailure.after -= 1
return null
}
const { status, message } = pendingFailure
pendingFailure = null
return [status, { error: { type: status >= 500 ? 'api_error' : 'invalid_request_error', message: message || `Nachbau: eingeplanter Fehler ${status}` } }]
}
const flat = (params) => Object.fromEntries(params.entries())
const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }]
const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }]
@@ -337,7 +353,7 @@ export async function startFakeStripe() {
const url = new URL(req.url, 'http://127.0.0.1')
const body = await readBody(req)
calls.push({ method: req.method, path: url.pathname, query: flat(url.searchParams), body: flat(body) })
const [status, payload] = route(req.method, url.pathname, url.searchParams, body)
const [status, payload] = plannedFailure(req.method, url.pathname) || route(req.method, url.pathname, url.searchParams, body)
res.writeHead(status, { 'Content-Type': 'application/json', 'Request-Id': `req_test_${calls.length}` })
res.end(JSON.stringify(payload))
})
@@ -380,6 +396,14 @@ export async function startFakeStripe() {
set invoicePreview(value) {
invoicePreview = value
},
/**
* Naechster passender Aufruf (method, path als String oder RegExp) scheitert einmalig mit status; after
* passende Aufrufe gehen vorher durch. Der Aufruf steht trotzdem in calls. 4xx wiederholt stripe-node
* nicht, 5xx schon (maxNetworkRetries) - fuer "Stripe lehnt ab" also 4xx einplanen.
*/
failNext: ({ method = null, path = null, status = 500, message = '', after = 0 }) => {
pendingFailure = { method, path, status, message, after }
},
close: () => new Promise((resolve) => server.close(resolve)),
}
}