1123 lines
60 KiB
JavaScript
1123 lines
60 KiB
JavaScript
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'
|
||
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({
|
||
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, 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 })
|
||
// 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
|
||
// 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)
|
||
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)
|
||
}
|
||
|
||
/**
|
||
* 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). 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,
|
||
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')
|
||
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, bezahlte Menge, Aufgabenart
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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)
|
||
// 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')
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 = (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')
|
||
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), [])
|
||
})
|
||
|
||
// 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 })
|
||
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)
|
||
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)
|
||
})
|
||
|
||
// 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 })
|
||
// 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 = (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
|
||
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(doc().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
|
||
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')
|
||
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.kind, 'einrichten')
|
||
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, 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
|
||
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')
|
||
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}$/)
|
||
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)
|
||
})
|
||
|
||
// 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
|
||
const result = await changeStorage('c1', 2, consent)
|
||
const upd = subscriptionUpdates(before)
|
||
assert.equal(upd.length, 1)
|
||
assert.equal(upd[0].body['items[0][quantity]'], '2')
|
||
assert.equal(upd[0].body['proration_behavior'], 'none')
|
||
assert.equal(doc().storageUnits, 2)
|
||
assert.equal(doc().storagePaidUnits, 3)
|
||
const [ev] = await listContractEvents('c1')
|
||
assert.equal(ev.status, 'offen')
|
||
assert.equal(ev.details.kind, 'periodenende')
|
||
assert.equal(ev.details.toUnits, 2)
|
||
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`)
|
||
// Fix 2 (3): K13 als Bestellung (PDF, Widerruf), aber ohne Einrichtung/anteilige Berechnung - schon bezahlt
|
||
const customerMail = k13()
|
||
assert.equal(customerMail.attachments.length, 1, 'K13 mit PDF (Bestellung)')
|
||
for (const text of ['schon bezahlt', 'keine zusätzlichen Kosten', '2 Pakete', '14 Tagen widerrufen', 'Vertragsunterlagen im Anhang']) {
|
||
assert.ok(customerMail.text.includes(text), `K13 ohne "${text}":\n${customerMail.text}`)
|
||
}
|
||
assert.doesNotMatch(customerMail.text, /wird eingerichtet|anteilig|ohne anteilige Erstattung/)
|
||
})
|
||
|
||
// Fix 2 (5): ein Plus genau zurueck auf die bezahlte Menge - konsoleH steht schon darauf, zum Periodenende ist
|
||
// nichts zu tun: die offene Verringerung wird geschlossen, keine neue Aufgabe, das Ereignis bleibt als Nachweis
|
||
test('Plus genau zurück auf die bezahlte Menge: Verringerung aufgehoben, keine neue Aufgabe, Nachweis bleibt', async () => {
|
||
await resetSubH({ units: 3, paidUnits: 3 })
|
||
await changeStorage('c1', 1, null)
|
||
const [minusEvent] = await listContractEvents('c1')
|
||
mails.sent.length = 0
|
||
const before = st.calls.length
|
||
const result = await changeStorage('c1', 3, consent)
|
||
assert.equal(subscriptionUpdates(before)[0].body['proration_behavior'], 'none')
|
||
assert.equal(doc().storageUnits, 3)
|
||
assert.deepEqual(await openTasks(), [])
|
||
const events = await listContractEvents('c1')
|
||
const superseded = events.find((e) => e.$id === minusEvent.$id)
|
||
assert.equal(superseded.status, 'erledigt')
|
||
assert.equal(superseded.doneBy, 'ersetzt')
|
||
const plusEvent = events.find((e) => e.$id === result.event.$id)
|
||
assert.equal(plusEvent.status, 'erledigt')
|
||
assert.equal(plusEvent.doneBy, 'keine Aufgabe')
|
||
assert.ok(plusEvent.doneAt)
|
||
assert.equal(plusEvent.details.kind, 'periodenende')
|
||
assert.equal(plusEvent.details.toUnits, 3)
|
||
assert.equal(plusEvent.termsVersion, consent.termsVersion, 'Nachweis der Bestellung')
|
||
assert.equal(result.change.task, false)
|
||
// K13 bestaetigt die Bestellung (nichts berechnet), T13 entfaellt: nichts zu tun
|
||
assert.equal(mails.sent.length, 1)
|
||
assert.ok(k13().text.includes('keine zusätzlichen Kosten'))
|
||
assert.equal(t13(), null)
|
||
})
|
||
|
||
// Fix 2 (1): weichen Spiegel und Stripe ab, wird der Plan nach dem Abgleich erneut geprueft - ein Plus ohne
|
||
// Zustimmung oder ueber das Kuendigungsformular darf nicht durchrutschen
|
||
test('Spiegel und Stripe weichen ab: Plan nach dem Abgleich erneut geprüft (400/409), nichts angelegt', async () => {
|
||
await resetSubH({ units: 3 })
|
||
// Spiegel sagt 3 Pakete, Stripe nur 1: aus dem vermeintlichen Minus auf 2 wird ein Plus
|
||
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting', storageUnits: 1 }))
|
||
const before = st.calls.length
|
||
await assert.rejects(changeStorage('c1', 2, null), (e) => e.status === 400 && /zustimmen/.test(e.message))
|
||
assert.deepEqual(await listContractEvents('c1'), [], 'kein Ereignis')
|
||
assert.deepEqual(subscriptionUpdates(before), [], 'keine Stripe-Aenderung')
|
||
assert.deepEqual(mails.sent, [])
|
||
assert.equal(storageItem().quantity, 1)
|
||
await assert.rejects(changeStorage('c1', 2, null, { viaCancellation: true }), (e) => e.status === 409)
|
||
assert.deepEqual(await listContractEvents('c1'), [])
|
||
assert.deepEqual(subscriptionUpdates(before), [])
|
||
// Mit Zustimmung geht das Plus, mit dem frischen Stand aus Stripe
|
||
const result = await changeStorage('c1', 2, consent)
|
||
assert.equal(result.change.fromUnits, 1)
|
||
assert.equal(result.change.kind, 'einrichten')
|
||
assert.equal(doc().storageUnits, 2)
|
||
})
|
||
|
||
test('Plus ohne Zustimmung: 400; gekündigt: 409', async () => {
|
||
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', 4, 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'), [])
|
||
assert.deepEqual(mails.sent, [])
|
||
})
|
||
|
||
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.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)
|
||
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
|
||
await changeStorage('c1', 3, consent)
|
||
assert.equal(subscriptionUpdates(before2).length, 1)
|
||
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')
|
||
assert.equal(storageItem().quantity, 0)
|
||
assert.equal(doc().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')
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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)
|
||
await changeStorage('c1', 1, null)
|
||
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, 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: { kind: 'einrichten', fromUnits: 0, toUnits: 1 }, consent, status: 'offen', dueAt: new Date().toISOString(),
|
||
})
|
||
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, 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 {
|
||
// 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
|
||
}
|
||
})
|
||
|
||
// Fix 2 (4): aeltere offene Aufgaben werden nur ersetzt, wenn die neue Aufgabe existiert und Stripe die Aenderung
|
||
// uebernommen hat - sonst bliebe das Team ohne Aufgabe
|
||
test('Ältere offene Aufgabe bleibt, wenn das Ereignis nicht speicherbar ist oder Stripe Schritt 1 ablehnt', async () => {
|
||
await resetSubH({ units: 0 })
|
||
await changeStorage('c1', 2, consent)
|
||
const [first] = await listContractEvents('c1')
|
||
const original = console.error
|
||
console.error = () => {}
|
||
try {
|
||
aw.failNext({ collection: config.collections.contractEvents, method: 'POST', status: 500 })
|
||
await changeStorage('c1', 3, consent)
|
||
let events = await listContractEvents('c1')
|
||
assert.equal(events.length, 1)
|
||
assert.equal(events[0].$id, first.$id)
|
||
assert.equal(events[0].status, 'offen', 'aeltere Aufgabe bleibt offen')
|
||
assert.equal(storageItem().quantity, 3)
|
||
|
||
st.failNext({ method: 'POST', path: '/v1/subscriptions/sub_h', status: 400, message: 'Nachbau: abgelehnt' })
|
||
await assert.rejects(changeStorage('c1', 4, consent), (e) => e.status === 502)
|
||
events = await listContractEvents('c1')
|
||
assert.equal(events.length, 2)
|
||
assert.equal(events.find((e) => e.$id === first.$id).status, 'offen', 'aeltere Aufgabe bleibt offen')
|
||
assert.equal(events.find((e) => e.$id !== first.$id).status, 'gescheitert')
|
||
assert.equal((await openTasks()).length, 1)
|
||
} finally {
|
||
console.error = original
|
||
}
|
||
})
|
||
|
||
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
|
||
}
|
||
})
|
||
|
||
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)
|
||
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
|
||
}
|
||
})
|
||
|
||
// 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 {
|
||
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)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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' } },
|
||
},
|
||
],
|
||
},
|
||
})
|
||
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)
|
||
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 () => {
|
||
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 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 () => {
|
||
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 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(() => {})
|
||
}
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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 = (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')
|
||
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.kind, 'einrichten')
|
||
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; 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: 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
|
||
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')
|
||
})
|
||
|
||
// 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
|
||
}
|
||
})
|