Files
Webklar-Kundenbereich/test/mailStorage.test.js
T

720 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, after } 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'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js (ueber testApp/fixtures/Routen) geladen wird
const aw = await startAppwriteMock({
schema: {
subscriptions: schemaKeys('subscriptions'),
contractEvents: schemaKeys('contractEvents'),
hourCredits: schemaKeys('hourCredits'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
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 } =
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 app = await startTestApp({ '/api/abo': aboRoutes })
after(() => 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
// stripeSubscription() haengt dieselben IDs an die Abo-Posten
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
st.prices.set('price_hosting', price('price_hosting', config.products.hosting.lookupKey, config.products.hosting.amountCents))
st.prices.set('price_wartung', price('price_wartung', config.products.wartung.lookupKey, config.products.wartung.amountCents))
const unix = (iso) => Math.floor(new Date(iso).getTime() / 1000)
const PERIOD_END_UNIX = unix(hostingDoc.currentPeriodEnd)
async function clearEvents(customerId) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
/**
* 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).
*/
async function resetSubH({ units = 0, paidUnits = null, patch = {}, stripe = {} } = {}) {
await clearEvents('c1')
aw.seed('subscriptions', {
...hostingDoc,
storageUnits: units,
storagePaidUnits: paidUnits ?? 0,
storagePaidPeriodEnd: paidUnits === null ? null : hostingDoc.currentPeriodEnd,
...patch,
})
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: units, ...stripe }))
}
// Rechnungszeilen in der Form der SDK-API-Version (2025-10-29.clover)
const line = (priceId, amount, { quantity = 1, proration = false, subscription = 'sub_h', periodStart = null, periodEnd = PERIOD_END_UNIX } = {}) => ({
amount,
quantity,
period: { start: periodStart ?? unix(hostingDoc.startedAt), end: periodEnd },
pricing: { type: 'price_details', price_details: { price: priceId } },
parent: { type: 'subscription_item_details', subscription_item_details: { proration, subscription } },
})
const previewInvoice = (...lines) => ({
object: 'invoice',
lines: { object: 'list', data: lines, has_more: false },
amount_due: lines.reduce((sum, l) => sum + l.amount, 0),
})
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')
// ---------------------------------------------------------------------------
// Reine Funktionen: Plan und bezahlte Menge
// ---------------------------------------------------------------------------
test('Plan: erstes Plus anteilig', () => {
assert.deepEqual(planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: 2, maxUnits: 10 }), {
direction: 'plus',
steps: [{ quantity: 2, proration_behavior: 'create_prorations' }],
})
})
test('Plan: Minus ohne Erstattung', () => {
assert.deepEqual(planStorageChange({ currentUnits: 3, paidUnits: 3, newUnits: 1, maxUnits: 10 }).steps, [
{ quantity: 1, proration_behavior: 'none' },
])
assert.equal(planStorageChange({ currentUnits: 3, paidUnits: 3, newUnits: 1, maxUnits: 10 }).direction, 'minus')
})
test('Plan: erneutes Plus bis zur bezahlten Menge kostet nichts', () => {
assert.deepEqual(planStorageChange({ currentUnits: 1, paidUnits: 3, newUnits: 3, maxUnits: 10 }).steps, [
{ quantity: 3, proration_behavior: 'none' },
])
assert.deepEqual(planStorageChange({ currentUnits: 1, paidUnits: 3, newUnits: 5, maxUnits: 10 }).steps, [
{ quantity: 3, proration_behavior: 'none' },
{ quantity: 5, proration_behavior: 'create_prorations' },
])
// Bezahlt weniger als aktuell (z. B. altes Dokument ohne Buchung): ein Schritt anteilig
assert.deepEqual(planStorageChange({ currentUnits: 2, paidUnits: 0, newUnits: 3, maxUnits: 10 }).steps, [
{ quantity: 3, proration_behavior: 'create_prorations' },
])
})
test('Plan: Grenzen', () => {
for (const n of [-1, 11, 1.5, '2', null, undefined, NaN]) {
assert.throws(() => planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: n, maxUnits: 10 }), (e) => e.status === 400, String(n))
}
assert.equal(planStorageChange({ currentUnits: 2, paidUnits: 2, newUnits: 2, maxUnits: 10 }).direction, 'none')
assert.deepEqual(planStorageChange({ currentUnits: 2, paidUnits: 2, newUnits: 2, maxUnits: 10 }).steps, [])
assert.equal(planStorageChange({ currentUnits: 0, paidUnits: 0, newUnits: 10, maxUnits: 10 }).direction, 'plus')
assert.equal(planStorageChange({ currentUnits: 1, paidUnits: 1, newUnits: 0, maxUnits: 10 }).direction, 'minus')
})
test('Bezahlte Menge gilt nur im laufenden Zeitraum', () => {
assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'A' }), 3)
assert.equal(paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: 'A', currentPeriodEnd: 'B' }), 1)
// Appwrite gibt Datumswerte mit +00:00 zurueck, Stripe-Spiegel schreibt Z: gleicher Zeitpunkt zaehlt
assert.equal(
paidUnitsOf({ storageUnits: 1, storagePaidUnits: 3, storagePaidPeriodEnd: '2026-10-18T10:00:00.000+00:00', currentPeriodEnd: '2026-10-18T10:00:00.000Z' }),
3
)
// Ohne Buchung (null) oder ohne Attribut (Altbestand) gilt die aktuelle Menge
assert.equal(paidUnitsOf({ storageUnits: 2, storagePaidUnits: 0, storagePaidPeriodEnd: null, currentPeriodEnd: null }), 2)
assert.equal(paidUnitsOf({ storageUnits: 2 }), 2)
assert.equal(paidUnitsOf({}), 0)
})
// ---------------------------------------------------------------------------
// Vorschau
// ---------------------------------------------------------------------------
test('Vorschau Plus: anteiliger Betrag aus der Stripe-Vorschau, Gesamtgröße, nächste Abbuchung', async () => {
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 })
)
const before = st.calls.length
const p = await previewStorageChange('c1', 2)
assert.equal(p.direction, 'plus')
assert.equal(p.units, 2)
assert.equal(p.totalGb, 15)
assert.equal(p.monthlyCents, 200)
assert.equal(p.deltaMonthlyCents, 200)
assert.equal(p.prorationCents, 133)
assert.equal(p.nextChargeAt, hostingDoc.currentPeriodEnd)
assert.ok(Math.abs(new Date(p.effectiveAt).getTime() - Date.now()) < 5000, 'wirksam jetzt')
const [call] = previewCalls(before)
assert.ok(call, 'invoices.createPreview aufgerufen')
assert.equal(call.body['subscription'], 'sub_h')
// Noch kein Speicher-Posten: Vorschau mit Preis statt Posten-ID
assert.equal(call.body['subscription_details[items][0][price]'], 'price_mailStorage')
assert.equal(call.body['subscription_details[items][0][quantity]'], '2')
assert.equal(call.body['subscription_details[proration_behavior]'], 'create_prorations')
assert.match(call.body['subscription_details[proration_date]'], /^\d{10}$/)
// Keine Aenderung bei Stripe durch eine Vorschau
assert.deepEqual(subscriptionUpdates(before), [])
})
test('Vorschau Plus mit Posten: Gutschrift und Belastung der Speicher-Zeilen, ä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 })
)
const before = st.calls.length
const p = await previewStorageChange('c1', 3)
assert.equal(p.prorationCents, 133)
assert.equal(p.deltaMonthlyCents, 200)
assert.equal(p.totalGb, 20)
const [call] = previewCalls(before)
assert.equal(call.body['subscription_details[items][0][id]'], 'si_sub_h_speicher')
assert.equal(call.body['subscription_details[items][0][price]'], undefined)
assert.equal(call.body['subscription_details[items][0][quantity]'], '3')
})
test('Vorschau Minus: keine Stripe-Vorschau, wirksam zum Periodenende, keine Erstattung', async () => {
await resetSubH({ units: 3 })
const before = st.calls.length
const p = await previewStorageChange('c1', 1)
assert.deepEqual(p, {
direction: 'minus',
units: 1,
totalGb: 10,
monthlyCents: 100,
deltaMonthlyCents: -200,
prorationCents: 0,
nextChargeAt: hostingDoc.currentPeriodEnd,
effectiveAt: hostingDoc.currentPeriodEnd,
})
assert.deepEqual(previewCalls(before), [])
})
test('Vorschau Minus bei gekündigtem Abo: wirksam mit dem Abo-Ende, wenn das früher liegt', async () => {
const cancelAt = new Date(new Date(hostingDoc.currentPeriodEnd).getTime() - 86400000).toISOString()
await resetSubH({ units: 3, patch: { cancelAtPeriodEnd: false, cancelAt } })
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 () => {
await resetSubH({ units: 1, paidUnits: 3 })
const now = Math.floor(Date.now() / 1000)
// 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 })
)
const before = st.calls.length
const p = await previewStorageChange('c1', 5)
// 266 × (5 − max(1, 3)) / (5 − 1) = 133
assert.equal(p.prorationCents, 133)
assert.equal(p.direction, 'plus')
assert.equal(p.deltaMonthlyCents, 400)
assert.equal(previewCalls(before)[0].body['subscription_details[items][0][quantity]'], '5')
// Plus bis zur bezahlten Menge: nichts anteilig, keine Vorschau bei Stripe
const before2 = st.calls.length
const p2 = await previewStorageChange('c1', 3)
assert.equal(p2.prorationCents, 0)
assert.equal(p2.direction, 'plus')
assert.equal(p2.deltaMonthlyCents, 200)
assert.deepEqual(previewCalls(before2), [])
})
test('Vorschau: unveränderte Menge ohne Stripe-Aufruf, Grenzen 400, kein Abo 404', async () => {
await resetSubH({ units: 2 })
const before = st.calls.length
const p = await previewStorageChange('c1', 2)
assert.equal(p.direction, 'none')
assert.equal(p.prorationCents, 0)
assert.equal(p.deltaMonthlyCents, 0)
assert.equal(st.calls.length, before)
await assert.rejects(previewStorageChange('c1', 11), (e) => e.status === 400)
await assert.rejects(previewStorageChange('c1', '2'), (e) => e.status === 400)
await assert.rejects(previewStorageChange('c3', 1), (e) => e.status === 404)
})
// ---------------------------------------------------------------------------
// Aenderung: Plus
// ---------------------------------------------------------------------------
test('Plus: Stripe-Aufrufe, Ereignis offen, bezahlte Menge', async () => {
await resetSubH({ units: 0 }) // Nachbauten: sub_h active, ohne Speicher
const before = st.calls.length
const result = await changeStorage('c1', 2, consent)
const upd = st.calls.filter((c) => c.path === '/v1/subscriptions/sub_h' && c.method === 'POST')
assert.equal(upd.at(-1).body['proration_behavior'], 'create_prorations')
assert.equal(upd.at(-1).body['items[0][quantity]'], '2')
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)
// Erste Position: mit Preis angelegt, nicht mit Posten-ID; anteilig ab jetzt (proration_date)
assert.equal(subscriptionUpdates(before).length, 1)
assert.equal(upd.at(-1).body['items[0][price]'], 'price_mailStorage')
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(ev.subscriptionId, 'sub_h')
assert.equal(ev.customerId, 'c1')
assert.equal(ev.productKey, 'hosting')
assert.equal(ev.termsVersion, consent.termsVersion)
assert.equal(ev.consentAt, consent.consentAt)
assert.equal(ev.consentIp, '127.0.0.1')
assert.equal(ev.consentUserAgent, 'test')
assert.equal(ev.earlyStartConsentAt ?? null, null)
assert.equal(ev.details.fromUnits, 0)
assert.equal(ev.details.toUnits, 2)
assert.equal(ev.details.totalGb, 15)
assert.equal(ev.details.monthlyCents, 200)
assert.ok(Math.abs(new Date(ev.dueAt).getTime() - Date.now()) < 5000, 'faellig jetzt')
assert.equal(ev.details.effectiveAt, ev.dueAt)
// Rueckgabe
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 }
)
})
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 upd = subscriptionUpdates(before)
assert.equal(upd.length, 2)
assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher')
assert.equal(upd[0].body['items[0][quantity]'], '3')
assert.equal(upd[0].body['proration_behavior'], 'none')
assert.equal(upd[0].body['proration_date'], undefined)
assert.equal(upd[1].body['items[0][id]'], 'si_sub_h_speicher')
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)
})
test('Plus bis zur bezahlten Menge: ein Schritt ohne Verrechnung, bezahlte Menge bleibt', async () => {
await resetSubH({ units: 1, paidUnits: 3 })
const before = st.calls.length
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)
})
test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => {
await assert.rejects(changeStorage('c1', 3, 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)
})
test('Plus ohne Zustimmung nennt die Regeln zum Zusatzspeicher, ohne Stripe-Aufruf', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
await assert.rejects(changeStorage('c1', 1, null), (e) => e.status === 400 && /Regeln zum Zusatzspeicher/.test(e.message))
await assert.rejects(changeStorage('c1', 1, {}), (e) => e.status === 400)
assert.equal(st.calls.length, before)
assert.deepEqual(await listContractEvents('c1'), [])
})
test('Keine Änderung bei trialing, past_due, cancelAt oder offenem Wechsel (409)', async () => {
for (const patch of [
{ status: 'trialing', trialEnd: '2026-11-01T05:00:00.000Z', startChoice: 'nach_frist' },
{ status: 'past_due' },
{ cancelAtPeriodEnd: false, cancelAt: '2027-10-05T10:00:00.000Z' },
]) {
await resetSubH({ units: 1, patch })
const before = st.calls.length
await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 409, JSON.stringify(patch))
await assert.rejects(changeStorage('c1', 0, null), (e) => e.status === 409, JSON.stringify(patch))
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf')
}
// F12: solange ein Wechsel auf die Zahlung wartet, keine Speicheraenderung (wie canChangeStorage)
await resetSubH({ units: 1 })
await recordContractEvent({
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
details: { from: 'hosting' }, consent, status: 'offen',
})
await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 409 && /Wechsel/.test(e.message))
await clearEvents('c1')
})
test('Grenzen: 11 Pakete oder unveränderte Menge 400, kein Abo 404, ohne Stripe-Aufruf', async () => {
await resetSubH({ units: 2 })
const before = st.calls.length
await assert.rejects(changeStorage('c1', 11, consent), (e) => e.status === 400)
await assert.rejects(changeStorage('c1', 2, consent), (e) => e.status === 400 && /unverändert/.test(e.message))
await assert.rejects(changeStorage('c3', 1, consent), (e) => e.status === 404)
assert.equal(st.calls.length, before)
})
// ---------------------------------------------------------------------------
// Aenderung: Minus
// ---------------------------------------------------------------------------
// Rechtsrunde 28.09.2026: ein Minus hat kein Pflicht-Haekchen, das Ereignis traegt keine Zustimmungsdaten
test('Minus: sofort bei Stripe ohne Erstattung, Aufgabe zum Periodenende, keine Zustimmungsdaten', async () => {
await resetSubH({ units: 3, paidUnits: 3 })
const before = st.calls.length
const result = await changeStorage('c1', 1, null)
const upd = subscriptionUpdates(before)
assert.equal(upd.length, 1)
assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher')
assert.equal(upd[0].body['items[0][quantity]'], '1')
assert.equal(upd[0].body['proration_behavior'], 'none')
assert.equal(upd[0].body['proration_date'], undefined)
const [ev] = await listContractEvents('c1')
assert.equal(ev.type, 'speicher')
assert.equal(ev.status, 'offen')
assert.equal(ev.dueAt, hostingDoc.currentPeriodEnd)
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(result.change.direction, 'minus')
assert.equal(result.change.effectiveAt, hostingDoc.currentPeriodEnd)
// Plus zurueck auf 3 im selben Monat: ohne Verrechnung
const before2 = st.calls.length
await changeStorage('c1', 3, consent)
assert.equal(subscriptionUpdates(before2).length, 1)
assert.equal(subscriptionUpdates(before2)[0].body['proration_behavior'], 'none')
})
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)
const before2 = st.calls.length
await changeStorage('c1', 4, consent)
const upd = subscriptionUpdates(before2)
assert.equal(upd[0].body['items[0][id]'], 'si_sub_h_speicher')
assert.equal(upd[0].body['items[0][price]'], undefined)
assert.equal(storageItem().quantity, 4)
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 () => {
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)
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
await recordContractEvent({
customerId: 'c2', subscriptionId: 'sub_h2', type: 'speicher', productKey: 'hosting',
details: { fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(),
})
await changeStorage('c1', 2, consent)
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 () => {
await resetSubH({
units: 2,
patch: { cancelAtPeriodEnd: true, cancelAt: hostingDoc.currentPeriodEnd },
stripe: { cancel_at_period_end: true },
})
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)')
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')
} finally {
console.error = original
}
})
test('Bezahlte Menge nicht speicherbar: Stripe-Änderung bleibt, Ereignis und Aufruf gelingen, Fehler im Log', async () => {
await resetSubH({ units: 0 })
const errors = []
const original = console.error
console.error = (...args) => errors.push(args.join(' '))
try {
// Erste PATCH ist der Spiegel (syncSubscription), die zweite die bezahlte Menge - nur die scheitert
aw.failNext({ collection: config.collections.subscriptions, method: 'PATCH', status: 400, after: 1, message: 'Invalid document structure: Unknown attribute: "storagePaidUnits"' })
const result = await changeStorage('c1', 2, consent)
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.ok(errors.some((e) => /Bezahlte Menge/.test(e) && /sub_h/.test(e) && /400/.test(e)))
assert.equal((await listContractEvents('c1')).length, 1)
} finally {
console.error = original
}
})
test('Ereignis nicht speicherbar: Stripe-Änderung bleibt, Aufruf gelingt, Fehler im Log', 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.ok(errors.some((e) => /Ereignis/.test(e) && /sub_h/.test(e)))
assert.deepEqual(await listContractEvents('c1'), [])
} finally {
console.error = original
}
})
// ---------------------------------------------------------------------------
// Bezahlte Menge aus Rechnungen (Webhook invoice.paid)
// ---------------------------------------------------------------------------
test('invoice.paid: bezahlte Menge aus regulärer Speicher-Zeile', async () => {
await resetSubH({ units: 3 })
await recordPaidStorage({
lines: {
data: [
{
amount: 300,
quantity: 3,
period: { end: 1793000000 },
pricing: { price_details: { price: 'price_mailStorage' } },
parent: { type: 'subscription_item_details', subscription_item_details: { proration: false, subscription: 'sub_h' } },
},
],
},
})
const doc = aw.docs('subscriptions').get('sub_h')
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 () => {
await resetSubH({ units: 2, paidUnits: 2 })
const r1 = await recordPaidStorage({ id: 'in_x', lines: { data: [line('price_mailStorage', 90, { quantity: 3, proration: true })] } })
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)
})
test('invoice.paid: Spiegel fehlt noch → aus Stripe angelegt, dann gebucht', async () => {
const subId = 'sub_1SD6yStorageNeuAbCdEfGhIj'
st.subscriptions.set(subId, stripeSubscription({ id: subId, customerId: 'c2', productKey: 'hosting', storageUnits: 1 }))
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())
} finally {
await deleteDocument(config.collections.subscriptions, subId).catch(() => {})
}
})
// 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 () => {
st.subscriptions.set('sub_w', stripeSubscription({ id: 'sub_w', customerId: 'c1', productKey: 'wartung', storageUnits: 2 }))
const now = Math.floor(Date.now() / 1000)
// 10 anteilige Speicher-Zeilen (Aenderungen im Monat) stehen vor den regulaeren Zeilen
const prorations = Array.from({ length: 10 }, (_, i) => line('price_mailStorage', 10 + i, { proration: true, subscription: 'sub_w', periodStart: now - i * 60 }))
st.invoices.set('in_w12', {
id: 'in_w12',
object: 'invoice',
number: 'WK-12',
created: now,
status_transitions: { paid_at: now },
parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_w' } },
lines: {
object: 'list',
data: [...prorations, line('price_wartung', 5000, { subscription: 'sub_w', periodEnd: 1793000000 }), line('price_mailStorage', 200, { quantity: 2, subscription: 'sub_w', periodEnd: 1793000000 })],
},
})
const before = st.calls.length
try {
const res = await postWebhook({
id: 'evt_in_w12',
object: 'event',
type: 'invoice.paid',
created: now,
data: { object: { id: 'in_w12', object: 'invoice', parent: { type: 'subscription_details', subscription_details: { subscription: 'sub_w' } } } },
})
assert.equal(res.status, 200, JSON.stringify(res.body))
const calls = st.calls.slice(before)
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())
} finally {
await deleteDocument(config.collections.subscriptions, 'sub_w').catch(() => {})
await deleteDocument(config.collections.hourCredits, 'in_w12').catch(() => {})
}
})
// ---------------------------------------------------------------------------
// Routen
// ---------------------------------------------------------------------------
test('Routen: ohne Session 401, im Modus „Als Kunde ansehen“ 403, ohne Stripe-Aufruf', async () => {
await resetSubH({ units: 0 })
const before = st.calls.length
assert.equal((await post('/api/abo/storage/preview', { units: 2 })).status, 401)
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion })).status, 401)
assert.equal((await post('/api/abo/storage/preview', { units: 2 }, { cookie: viewAsCookie() })).status, 403)
assert.equal((await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie: viewAsCookie() })).status, 403)
assert.equal(st.calls.length, before)
assert.equal((await rawGet('/api/abo/storage')).status, 404)
})
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) }))
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')
assert.equal(res.body.prorationCents, 133)
assert.equal(res.body.totalGb, 15)
assert.equal(res.body.nextChargeAt, hostingDoc.currentPeriodEnd)
assert.equal((await post('/api/abo/storage/preview', { units: 11 }, { cookie: customerCookie() })).status, 400)
assert.equal((await post('/api/abo/storage/preview', { units: '2' }, { cookie: customerCookie() })).status, 400)
assert.equal((await post('/api/abo/storage/preview', {}, { cookie: customerCookie() })).status, 400)
})
test('Route Änderung: Plus braucht acceptTerms und die aktuelle Fassung, Antwort mit Abo-Ansicht', async () => {
await resetSubH({ units: 0 })
const cookie = customerCookie()
const before = st.calls.length
const noTerms = await post('/api/abo/storage', { units: 2 }, { cookie })
assert.equal(noTerms.status, 400)
assert.match(noTerms.body.error, /Regeln zum Zusatzspeicher/)
const stale = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: '2026-09-25-v3' }, { cookie })
assert.equal(stale.status, 409)
assert.equal(subscriptionUpdates(before).length, 0)
const plus = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie })
assert.equal(plus.status, 200, JSON.stringify(plus.body))
assert.deepEqual(plus.body.subscription.storage, { units: 2, pendingSetup: true, pendingReduction: null })
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.toUnits, 2)
assert.equal(subscriptionUpdates(before).length, 1)
const [ev] = await listContractEvents('c1')
assert.equal(ev.termsVersion, config.abo.termsVersion)
assert.equal(ev.consentIp, '127.0.0.1')
assert.ok(ev.consentAt)
// Minus ohne Haekchen: Speichern; Antwort zeigt 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,
pendingReduction: { units: 1, effectiveAt: minus.body.subscription.currentPeriodEnd },
})
assert.equal(minus.body.change.direction, 'minus')
assert.equal(minus.body.change.effectiveAt, minus.body.subscription.currentPeriodEnd)
// Gekuendigt: 409 mit Fehlertext
aw.seed('subscriptions', { ...hostingDoc, storageUnits: 1, cancelAtPeriodEnd: true })
const cancelled = await post('/api/abo/storage', { units: 2, acceptTerms: true, termsVersion: config.abo.termsVersion }, { cookie })
assert.equal(cancelled.status, 409)
assert.ok(cancelled.body.error)
await clearEvents('c1')
})