Zusatzspeicher: Plus sofort anteilig, Minus zum Monatsende, Aufgaben für konsoleH
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
887e1e54a0
commit
379cc26dcd
11 files changed
+1531
-27
No files matched your search
+62
-1
@@ -9,6 +9,7 @@ import { eventIdFor, listContractEvents, openWithdrawals, recordContractEvent }
|
|||||||
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
|
import { lateStartAt, withdrawalDeadline } from '../services/deadlines.js'
|
||||||
import { getLedger } from '../services/hourLedger.js'
|
import { getLedger } from '../services/hourLedger.js'
|
||||||
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
import { sendAdminNotice, sendCustomerMail } from '../services/mailer.js'
|
||||||
|
import { changeStorage, previewStorageChange, recordPaidStorage } from '../services/mailStorage.js'
|
||||||
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
|
import { activateOfferFromCheckout, getOpenOffer, offerText, offerView } from '../services/offers.js'
|
||||||
import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js'
|
import { PRODUCT_KEYS, productView, termsDocFor } from '../services/products.js'
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
createBillingPortalUrl,
|
createBillingPortalUrl,
|
||||||
getCurrentSubscription,
|
getCurrentSubscription,
|
||||||
grantCreditForInvoice,
|
grantCreditForInvoice,
|
||||||
|
loadInvoice,
|
||||||
minTermMonthsFor,
|
minTermMonthsFor,
|
||||||
isStripeConfigured,
|
isStripeConfigured,
|
||||||
syncSubscription,
|
syncSubscription,
|
||||||
@@ -256,6 +258,59 @@ router.post('/portal', requireSession, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// E-Mail-Zusatzspeicher (Spec 3.4, 4.3): Vorschau und Aenderung (mailStorage.js)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Vorschau fuer { units }: Richtung, Gesamtgroesse, Mehrpreis, anteiliger Betrag, naechste Abbuchung, Wirksamkeit. */
|
||||||
|
router.post('/storage/preview', requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.session.viewAs) {
|
||||||
|
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
|
||||||
|
}
|
||||||
|
return res.json(await previewStorageChange(getSessionCustomerId(req), req.body?.units))
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(err.status || 500).json({ error: err.message || 'Vorschau fehlgeschlagen' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aenderung { units, acceptTerms, termsVersion }: ein Plus ist eine Bestellung ("Zahlungspflichtig bestellen")
|
||||||
|
* und braucht acceptTerms zur aktuellen Fassung - der Nachweis (Fassung, Zeitpunkt, IP, Browser) landet im
|
||||||
|
* Ereignis `speicher`; ein Minus ("Speichern") braucht kein Haekchen und speichert keine Zustimmungsdaten.
|
||||||
|
* Antwort: die Abo-Ansicht wie in GET /api/abo (mit Speicherstand) und die Aenderung selbst.
|
||||||
|
*/
|
||||||
|
router.post('/storage', requireSession, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.session.viewAs) {
|
||||||
|
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann der Zusatzspeicher nicht geändert werden.' })
|
||||||
|
}
|
||||||
|
const customerId = getSessionCustomerId(req)
|
||||||
|
const { units, acceptTerms, termsVersion } = req.body || {}
|
||||||
|
let consent = null
|
||||||
|
if (acceptTerms === true) {
|
||||||
|
if (termsVersion !== config.abo.termsVersion) {
|
||||||
|
return res.status(409).json({ error: 'Die Bedingungen wurden aktualisiert. Bitte Seite neu laden.' })
|
||||||
|
}
|
||||||
|
consent = {
|
||||||
|
termsVersion,
|
||||||
|
consentAt: new Date().toISOString(),
|
||||||
|
consentIp: req.ip,
|
||||||
|
consentUserAgent: req.get('user-agent') || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { subscription, change } = await changeStorage(customerId, units, consent)
|
||||||
|
const events = await listContractEvents(customerId).catch((e) => {
|
||||||
|
console.error('[abo] Vertragsereignisse nicht lesbar:', e.message)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
return res.json({ subscription: publicSubscription(subscription, events), change })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[abo] Zusatzspeicher-Änderung fehlgeschlagen:', err.message)
|
||||||
|
return res.status(err.status || 500).json({ error: err.message || 'Änderung fehlgeschlagen' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Stripe-Webhook
|
// Stripe-Webhook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -396,10 +451,16 @@ async function handleStripeEvent(event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'invoice.paid': {
|
case 'invoice.paid': {
|
||||||
const result = await grantCreditForInvoice(obj)
|
// Einmal frisch laden, mit allen Zeilen (F18): Stundengutschrift (Wartung) und bezahlte Speichermenge
|
||||||
|
const fresh = await loadInvoice(obj.id)
|
||||||
|
const result = await grantCreditForInvoice(fresh, { fresh: true })
|
||||||
if (result.granted) {
|
if (result.granted) {
|
||||||
console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`)
|
console.log(`[abo] 60 min gutgeschrieben: Kunde ${result.customerId}, Rechnung ${obj.id}`)
|
||||||
}
|
}
|
||||||
|
const storage = await recordPaidStorage(fresh)
|
||||||
|
if (storage.recorded) {
|
||||||
|
console.log(`[abo] Zusatzspeicher bezahlt: Abo ${storage.subscriptionId}, ${storage.units} Pakete bis ${storage.periodEnd}`)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case 'invoice.payment_failed': {
|
case 'invoice.payment_failed': {
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { getDocument, updateDocument } from './appwriteAdmin.js'
|
||||||
|
import { listContractEvents, recordContractEvent, updateContractEvent } from './contractEvents.js'
|
||||||
|
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
|
||||||
|
import { contractAttachmentFor, describeItems, priceIdsOfInvoice, regularStorageLine } from './products.js'
|
||||||
|
import {
|
||||||
|
ACTIVE_STATUSES,
|
||||||
|
ensurePrice,
|
||||||
|
getCurrentSubscription,
|
||||||
|
getStripe,
|
||||||
|
invoiceLines,
|
||||||
|
resolvePriceKeys,
|
||||||
|
subscriptionIdOfInvoice,
|
||||||
|
syncSubscription,
|
||||||
|
} from './stripeBilling.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-Mail-Zusatzspeicher (Spec 3.4, Bedingungen Fassung 4 § 10): Pakete zu 5 GB fuer 1,00 € je Monat als
|
||||||
|
* zweite Position (Stueckzahl) im Stripe-Abo des Kunden, 0 bis 10 Pakete, in beiden Abos.
|
||||||
|
*
|
||||||
|
* - Plus (Bestellung, "Zahlungspflichtig bestellen"): sofort. Den Rest des laufenden Monats berechnet
|
||||||
|
* Stripe anteilig mit der naechsten Abbuchung (proration_behavior create_prorations). Pakete, die fuer
|
||||||
|
* den laufenden Monat schon bezahlt sind (nach einem Minus im selben Monat), kosten nicht noch einmal:
|
||||||
|
* bis zur bezahlten Menge proration_behavior none, darueber anteilig - notfalls in zwei Schritten
|
||||||
|
* (planStorageChange).
|
||||||
|
* - Minus ("Speichern"): Stueckzahl sofort bei Stripe, ohne Erstattung (none). Die Postfachgrenze in
|
||||||
|
* konsoleH sinkt erst zum Ende des Abrechnungsmonats (Aufgabe fuers Team, dueAt = Periodenende).
|
||||||
|
* - Jede Aenderung ist ein Ereignis `speicher` mit Status offen (= Aufgabe fuers Team in konsoleH); nur ein
|
||||||
|
* Plus traegt Zustimmungsdaten, ein Minus keine (Rechtsrunde 28.09.2026). Eine neue Aenderung ersetzt
|
||||||
|
* die aeltere offene Aufgabe desselben Abos (F21): eine Aufgabe je Abo mit Zielgroesse und Datum.
|
||||||
|
* - Bezahlte Menge je Abrechnungsmonat (storagePaidUnits, storagePaidPeriodEnd): beim Plus gesetzt, sonst
|
||||||
|
* aus der regulaeren Speicher-Zeile jeder bezahlten Rechnung (recordPaidStorage, Webhook invoice.paid).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function httpError(status, message) {
|
||||||
|
const err = new Error(message)
|
||||||
|
err.status = status
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDate = (iso) =>
|
||||||
|
iso
|
||||||
|
? new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
: '–'
|
||||||
|
// Preis wie in den Bedingungen: "2,00 €" (normales Leerzeichen)
|
||||||
|
const fmtEuro = (cents) => `${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')} €`
|
||||||
|
const toIso = (unixSeconds) => (unixSeconds ? new Date(unixSeconds * 1000).toISOString() : null)
|
||||||
|
|
||||||
|
const unitsOf = (doc) => Number(doc?.storageUnits) || 0
|
||||||
|
const totalGbOf = (units) => config.mailStorage.includedGb + units * config.mailStorage.gbPerUnit
|
||||||
|
const monthlyCentsOf = (units) => units * config.mailStorage.amountCents
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stripe-Schritte fuer eine Aenderung auf newUnits Pakete: { direction: 'plus' | 'minus' | 'none', steps:
|
||||||
|
* [{ quantity, proration_behavior }] }. Ein Plus bis zur schon bezahlten Menge (paidUnits) laeuft ohne
|
||||||
|
* Verrechnung (none); darueber hinaus anteilig (create_prorations), bei currentUnits < paidUnits in zwei
|
||||||
|
* Schritten. Ein Minus laeuft ohne Erstattung (none). newUnits muss eine ganze Zahl von 0 bis maxUnits sein.
|
||||||
|
*/
|
||||||
|
export function planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits }) {
|
||||||
|
if (!Number.isInteger(newUnits) || newUnits < 0 || newUnits > maxUnits) {
|
||||||
|
throw httpError(400, `Bitte wählen Sie zwischen 0 und ${maxUnits} Paketen Zusatzspeicher.`)
|
||||||
|
}
|
||||||
|
const current = Number(currentUnits) || 0
|
||||||
|
const paid = Number(paidUnits) || 0
|
||||||
|
if (newUnits === current) return { direction: 'none', steps: [] }
|
||||||
|
if (newUnits < current) return { direction: 'minus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] }
|
||||||
|
if (newUnits <= paid) return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'none' }] }
|
||||||
|
if (current < paid) {
|
||||||
|
return {
|
||||||
|
direction: 'plus',
|
||||||
|
steps: [
|
||||||
|
{ quantity: paid, proration_behavior: 'none' },
|
||||||
|
{ quantity: newUnits, proration_behavior: 'create_prorations' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { direction: 'plus', steps: [{ quantity: newUnits, proration_behavior: 'create_prorations' }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuer den laufenden Abrechnungsmonat schon bezahlte Pakete: storagePaidUnits, wenn storagePaidPeriodEnd
|
||||||
|
* der laufende Zeitraum ist (Appwrite gibt Datumswerte mit +00:00 zurueck, der Spiegel schreibt Z: gleicher
|
||||||
|
* Zeitpunkt zaehlt), sonst die aktuelle Menge (die regulaere Rechnung des Monats hat sie berechnet).
|
||||||
|
*/
|
||||||
|
export function paidUnitsOf(doc) {
|
||||||
|
const paidEnd = doc?.storagePaidPeriodEnd
|
||||||
|
const periodEnd = doc?.currentPeriodEnd
|
||||||
|
const samePeriod =
|
||||||
|
Boolean(paidEnd && periodEnd) && (paidEnd === periodEnd || new Date(paidEnd).getTime() === new Date(periodEnd).getTime())
|
||||||
|
return samePeriod ? Number(doc.storagePaidUnits) || 0 : unitsOf(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireSubscription(customerId) {
|
||||||
|
const doc = await getCurrentSubscription(customerId)
|
||||||
|
if (!doc) throw httpError(404, 'Kein Abo vorhanden.')
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position fuer subscriptions.update / invoices.createPreview: vorhandener Speicher-Posten per ID, sonst neu per Preis
|
||||||
|
const storageItemParam = (storageItemId, priceId, quantity) => (storageItemId ? { id: storageItemId, quantity } : { price: priceId, quantity })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Minus wirkt zum Ende des Abrechnungsmonats; endet das Abo frueher (cancelAt), endet der Speicher
|
||||||
|
* mit ihm (Spec 3.4 "Der Zusatzspeicher endet automatisch mit dem Abo").
|
||||||
|
*/
|
||||||
|
function reductionEffectiveAt(doc) {
|
||||||
|
const periodEnd = doc.currentPeriodEnd || null
|
||||||
|
const cancelAt = doc.cancelAt || null
|
||||||
|
if (periodEnd && cancelAt && new Date(cancelAt) < new Date(periodEnd)) return cancelAt
|
||||||
|
return periodEnd || cancelAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anteiliger Betrag der Speicher-Zeilen einer Stripe-Vorschau (naechste Rechnung): Belastung fuer die neue
|
||||||
|
* Menge abzueglich Gutschrift fuer die alte, jeweils ab prorationDate. Aeltere anteilige Zeilen (eine
|
||||||
|
* fruehere Aenderung im selben Monat, schon als Rechnungsposition vorgemerkt) und die regulaeren Zeilen des
|
||||||
|
* naechsten Monats zaehlen nicht.
|
||||||
|
*/
|
||||||
|
function storageProrationCents(preview, priceIds, prorationDate) {
|
||||||
|
return (preview?.lines?.data || [])
|
||||||
|
.filter((line) => {
|
||||||
|
const parent = line.parent || {}
|
||||||
|
const details = parent.subscription_item_details || parent.invoice_item_details || {}
|
||||||
|
if (details.proration !== true) return false
|
||||||
|
if (!priceIds.has(line.pricing?.price_details?.price)) return false
|
||||||
|
// Toleranz fuer Sekunden zwischen Vorschau und Stripe-Rundung
|
||||||
|
return !line.period?.start || line.period.start >= prorationDate - 300
|
||||||
|
})
|
||||||
|
.reduce((sum, line) => sum + (Number(line.amount) || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschau fuer newUnits Pakete: Richtung, Gesamtgroesse, Monatspreis des Speichers, Mehr-/Minderpreis je
|
||||||
|
* Monat, anteiliger Betrag fuer den Rest des laufenden Monats (aus invoices.createPreview), naechste Abbuchung
|
||||||
|
* und Wirksamkeit (Minus: Periodenende, sonst jetzt). Bis zur schon bezahlten Menge kostet ein Plus nichts.
|
||||||
|
*/
|
||||||
|
export async function previewStorageChange(customerId, newUnits) {
|
||||||
|
const doc = await requireSubscription(customerId)
|
||||||
|
const currentUnits = unitsOf(doc)
|
||||||
|
const paidUnits = paidUnitsOf(doc)
|
||||||
|
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits })
|
||||||
|
const now = new Date()
|
||||||
|
let prorationCents = 0
|
||||||
|
if (plan.direction === 'plus' && newUnits > paidUnits) {
|
||||||
|
const stripe = getStripe()
|
||||||
|
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
|
||||||
|
const items = stripeSub.items?.data || []
|
||||||
|
const { storageItemId } = describeItems(items)
|
||||||
|
const prorationDate = Math.floor(now.getTime() / 1000)
|
||||||
|
const preview = await stripe.invoices.createPreview({
|
||||||
|
subscription: doc.$id,
|
||||||
|
subscription_details: {
|
||||||
|
items: [storageItemParam(storageItemId, priceId, newUnits)],
|
||||||
|
proration_behavior: 'create_prorations',
|
||||||
|
proration_date: prorationDate,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Gutschrift-Zeilen tragen den Preis des vorhandenen Postens (kann ein aelterer Speicher-Preis sein)
|
||||||
|
const priceIds = new Set([priceId, items.find((item) => item.id === storageItemId)?.price?.id].filter(Boolean))
|
||||||
|
const previewed = storageProrationCents(preview, priceIds, prorationDate)
|
||||||
|
// F19: Stripe rechnet die Vorschau von der heutigen Menge (currentUnits) aus; berechnet werden nur die
|
||||||
|
// Pakete ueber der schon bezahlten Menge, der erste Schritt bis dahin laeuft ohne Verrechnung
|
||||||
|
prorationCents = Math.round((previewed * (newUnits - Math.max(currentUnits, paidUnits))) / (newUnits - currentUnits))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
direction: plan.direction,
|
||||||
|
units: newUnits,
|
||||||
|
totalGb: totalGbOf(newUnits),
|
||||||
|
monthlyCents: monthlyCentsOf(newUnits),
|
||||||
|
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
|
||||||
|
prorationCents,
|
||||||
|
nextChargeAt: doc.currentPeriodEnd || null,
|
||||||
|
effectiveAt: plan.direction === 'minus' ? reductionEffectiveAt(doc) : now.toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darf der Kunde die Menge aendern? Im Portal nur bei Status active, ohne Kuendigung und ohne Wechsel, der
|
||||||
|
* auf die Zahlung wartet (wie canChangeStorage in GET /api/abo, F12). Ueber das Kuendigungsformular
|
||||||
|
* (viaCancellation, Task 10; Bedingungen § 10 Abs. 5 "jederzeit") ist eine Verringerung in jedem laufenden
|
||||||
|
* Status erlaubt, auch bei gekuendigtem Abo oder offener Zahlung - eine Erhoehung dort nie (F20).
|
||||||
|
*/
|
||||||
|
function assertChangeAllowed(doc, events, direction, viaCancellation) {
|
||||||
|
if (viaCancellation) {
|
||||||
|
if (direction !== 'minus') throw httpError(409, 'Über die Kündigung lässt sich der Zusatzspeicher nur verringern.')
|
||||||
|
if (!ACTIVE_STATUSES.has(doc.status)) throw httpError(409, 'Das Abo läuft nicht mehr.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (doc.status !== 'active') {
|
||||||
|
throw httpError(
|
||||||
|
409,
|
||||||
|
'Der Zusatzspeicher lässt sich nur bei laufendem Abo ändern, nicht in der Wartezeit bis zum Leistungsbeginn und nicht bei einem Zahlungsproblem.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (doc.cancelAtPeriodEnd || doc.cancelAt) throw httpError(409, 'Ihr Abo ist gekündigt. Der Zusatzspeicher endet mit dem Abo.')
|
||||||
|
if (events.some((e) => e.type === 'wechsel' && e.status === 'offen')) {
|
||||||
|
throw httpError(409, 'Ihr Wechsel wartet noch auf die Zahlung. Bitte versuchen Sie es danach erneut.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ereignis `speicher` (Nachweis der Bestellung bzw. Aufgabe fuers Team). Aeltere offene Aufgaben desselben
|
||||||
|
* Abos sind damit ueberholt (F21: erledigt, doneBy 'ersetzt'). Ein Fehler beim Speichern bricht die schon
|
||||||
|
* bei Stripe wirksame Aenderung nicht ab: er wird protokolliert, T13 traegt die Aufgabe trotzdem zum Team.
|
||||||
|
*/
|
||||||
|
async function recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation }) {
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
for (const e of events.filter((e) => e.type === 'speicher' && e.status === 'offen')) {
|
||||||
|
await updateContractEvent(e.$id, { status: 'erledigt', doneAt: now, doneBy: 'ersetzt' })
|
||||||
|
}
|
||||||
|
return await recordContractEvent({
|
||||||
|
customerId: doc.customerId,
|
||||||
|
subscriptionId: doc.$id,
|
||||||
|
type: 'speicher',
|
||||||
|
productKey: doc.productKey || 'wartung',
|
||||||
|
details: {
|
||||||
|
fromUnits: change.fromUnits,
|
||||||
|
toUnits: change.toUnits,
|
||||||
|
effectiveAt: change.effectiveAt,
|
||||||
|
totalGb: change.totalGb,
|
||||||
|
monthlyCents: change.monthlyCents,
|
||||||
|
deltaMonthlyCents: change.deltaMonthlyCents,
|
||||||
|
...(viaCancellation ? { viaCancellation: true } : {}),
|
||||||
|
},
|
||||||
|
// Nur eine Bestellung (Plus) traegt Zustimmungsdaten; ein Minus keine (Rechtsrunde 28.09.2026)
|
||||||
|
consent:
|
||||||
|
change.direction === 'plus'
|
||||||
|
? {
|
||||||
|
termsVersion: consent.termsVersion,
|
||||||
|
consentAt: consent.consentAt,
|
||||||
|
consentIp: consent.consentIp || null,
|
||||||
|
consentUserAgent: consent.consentUserAgent || null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
status: 'offen',
|
||||||
|
dueAt,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[speicher] Ereignis für Abo ${doc.$id} nicht gespeichert:`, err.message)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertragsunterlagen (PDF) in der zugestimmten Fassung als Mail-Anhang; ohne Datei geht die Mail ohne Anhang raus. */
|
||||||
|
async function contractPdfAttachment(termsVersion) {
|
||||||
|
const { filename, path } = contractAttachmentFor(termsVersion)
|
||||||
|
const content = await readFile(path).catch((err) => {
|
||||||
|
console.error(`[speicher] Vertragsunterlagen ${filename} nicht lesbar:`, err.message)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
return content ? [{ filename, content, contentType: 'application/pdf' }] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* K13 an den Kunden (Plus: mit PDF der zugestimmten Fassung, F22) und T13 ans Team. Ueber das
|
||||||
|
* Kuendigungsformular bestaetigt K03, K13 entfaellt dort (F31). Ein Mailfehler bricht die Aenderung nie ab.
|
||||||
|
*/
|
||||||
|
async function sendStorageMails({ doc, change, dueAt, consent, viaCancellation, proofMissing }) {
|
||||||
|
const plus = change.direction === 'plus'
|
||||||
|
const customer = await getDocument(config.collections.customers, doc.customerId).catch(() => null)
|
||||||
|
const email = customer?.email || doc.email || ''
|
||||||
|
const wirksamAb = fmtDate(change.effectiveAt)
|
||||||
|
if (!viaCancellation && email) {
|
||||||
|
const attachments = plus ? await contractPdfAttachment(consent.termsVersion) : []
|
||||||
|
await sendCustomerMail(email, {
|
||||||
|
template: 'K13',
|
||||||
|
values: {
|
||||||
|
name: customer?.name || '',
|
||||||
|
pakete: change.toUnits,
|
||||||
|
gesamt_gb: change.totalGb,
|
||||||
|
monatspreis_zusatz: fmtEuro(change.monthlyCents),
|
||||||
|
wirksam_ab: wirksamAb,
|
||||||
|
},
|
||||||
|
blocks: { plus, minus: !plus },
|
||||||
|
attachments,
|
||||||
|
}).catch((e) => console.error(`[speicher] K13 an ${email} fehlgeschlagen:`, e.message))
|
||||||
|
}
|
||||||
|
await sendAdminNotice({
|
||||||
|
template: 'T13',
|
||||||
|
values: {
|
||||||
|
aktion: plus ? 'einrichten' : `reduzieren zum ${wirksamAb}`,
|
||||||
|
email,
|
||||||
|
postfaecher_hinweis:
|
||||||
|
(doc.productKey || 'wartung') === 'hosting' ? 'Hosting-Abo: 1 Postfach' : 'Wartungs-Abo: alle Postfächer des Kunden zusammen',
|
||||||
|
neue_groesse_gb: change.totalGb,
|
||||||
|
faellig: fmtDate(dueAt),
|
||||||
|
},
|
||||||
|
blocks: proofMissing ? { nachweis_fehlt: true } : {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menge auf newUnits Pakete aendern: Stripe-Schritte nacheinander (die erste Position mit { price, quantity },
|
||||||
|
* danach { id, quantity }; bei 0 bleibt die Position mit Menge 0 bestehen), Spiegel und bezahlte Menge
|
||||||
|
* (Plus: max(paidUnits, newUnits) fuer den laufenden Monat), Ereignis `speicher` (offen; dueAt jetzt bzw.
|
||||||
|
* Periodenende), K13 und T13. consent { termsVersion, consentAt, consentIp, consentUserAgent } ist beim Plus
|
||||||
|
* Pflicht (400), beim Minus null. options.viaCancellation: Kuendigungsformular (nur Verringerung, F20).
|
||||||
|
* Liefert { subscription, event, change }.
|
||||||
|
*/
|
||||||
|
export async function changeStorage(customerId, newUnits, consent, options = {}) {
|
||||||
|
const viaCancellation = options.viaCancellation === true
|
||||||
|
const doc = await requireSubscription(customerId)
|
||||||
|
const currentUnits = unitsOf(doc)
|
||||||
|
const paidUnits = paidUnitsOf(doc)
|
||||||
|
const plan = planStorageChange({ currentUnits, paidUnits, newUnits, maxUnits: config.mailStorage.maxUnits })
|
||||||
|
if (plan.direction === 'none') throw httpError(400, 'Die Anzahl der Pakete ist unverändert.')
|
||||||
|
const plus = plan.direction === 'plus'
|
||||||
|
if (plus && !(consent?.termsVersion && consent?.consentAt)) {
|
||||||
|
throw httpError(400, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
|
||||||
|
}
|
||||||
|
const events = (await listContractEvents(customerId)).filter((e) => e.subscriptionId === doc.$id)
|
||||||
|
assertChangeAllowed(doc, events, plan.direction, viaCancellation)
|
||||||
|
|
||||||
|
// Stripe: Speicher-Posten anlegen oder Menge aendern, Schritt fuer Schritt (planStorageChange)
|
||||||
|
const stripe = getStripe()
|
||||||
|
const [priceId, stripeSub] = await Promise.all([ensurePrice('mailStorage'), stripe.subscriptions.retrieve(doc.$id)])
|
||||||
|
let { storageItemId } = describeItems(stripeSub.items?.data)
|
||||||
|
const prorationDate = Math.floor(Date.now() / 1000)
|
||||||
|
for (const step of plan.steps) {
|
||||||
|
const updated = await stripe.subscriptions.update(doc.$id, {
|
||||||
|
items: [storageItemParam(storageItemId, priceId, step.quantity)],
|
||||||
|
proration_behavior: step.proration_behavior,
|
||||||
|
...(step.proration_behavior === 'create_prorations' ? { proration_date: prorationDate } : {}),
|
||||||
|
})
|
||||||
|
storageItemId = describeItems(updated.items?.data).storageItemId || storageItemId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spiegel (storageUnits aus den Positionen) und bezahlte Menge des laufenden Monats. Die Aenderung ist
|
||||||
|
// bei Stripe schon wirksam: scheitert die Buchung der bezahlten Menge (z. B. Attribut fehlt, Setup-Skript
|
||||||
|
// nicht gelaufen), laufen Ereignis und Mails trotzdem; ein Plus nach einem Minus im selben Monat wuerde
|
||||||
|
// dann anteilig berechnet statt frei bleiben - Fehler im Log.
|
||||||
|
let subscription = (await syncSubscription(doc.$id)) || doc
|
||||||
|
if (plus) {
|
||||||
|
const paid = { storagePaidUnits: Math.max(paidUnits, newUnits), storagePaidPeriodEnd: subscription.currentPeriodEnd || doc.currentPeriodEnd || null }
|
||||||
|
subscription = await updateDocument(config.collections.subscriptions, doc.$id, paid).catch((err) => {
|
||||||
|
console.error(`[speicher] Bezahlte Menge für Abo ${doc.$id} nicht gespeichert (${err.status || '?'}):`, err.message)
|
||||||
|
return subscription
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const effectiveAt = plus ? now : reductionEffectiveAt(subscription)
|
||||||
|
const change = {
|
||||||
|
direction: plan.direction,
|
||||||
|
fromUnits: currentUnits,
|
||||||
|
toUnits: newUnits,
|
||||||
|
effectiveAt,
|
||||||
|
totalGb: totalGbOf(newUnits),
|
||||||
|
monthlyCents: monthlyCentsOf(newUnits),
|
||||||
|
deltaMonthlyCents: monthlyCentsOf(newUnits) - monthlyCentsOf(currentUnits),
|
||||||
|
}
|
||||||
|
// Aufgabe fuers Team: Plus sofort einrichten, Minus zum Periodenende reduzieren
|
||||||
|
const dueAt = plus ? now : effectiveAt
|
||||||
|
const event = await recordStorageEvent({ doc, events, change, consent, dueAt, viaCancellation })
|
||||||
|
await sendStorageMails({ doc: subscription, change, dueAt, consent, viaCancellation, proofMissing: !event })
|
||||||
|
return { subscription, event, change }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bezahlte Rechnung (Webhook invoice.paid, frisch geladen mit loadInvoice): hat sie eine regulaere
|
||||||
|
* Zusatzspeicher-Zeile (nicht anteilig), ist deren Stueckzahl fuer den Zeitraum bis line.period.end bezahlt
|
||||||
|
* (storagePaidUnits, storagePaidPeriodEnd). Idempotent; fehlt der Spiegel noch (Webhook-Reihenfolge),
|
||||||
|
* wird er aus Stripe angelegt.
|
||||||
|
*/
|
||||||
|
export async function recordPaidStorage(invoice) {
|
||||||
|
const lines = await invoiceLines(invoice)
|
||||||
|
const loaded = { ...invoice, lines: { ...(invoice?.lines || {}), data: lines, has_more: false } }
|
||||||
|
const line = regularStorageLine(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded)))
|
||||||
|
if (!line) return { skipped: 'keine reguläre Speicher-Zeile' }
|
||||||
|
const ref = line.parent?.subscription_item_details?.subscription || subscriptionIdOfInvoice(loaded)
|
||||||
|
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
||||||
|
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
||||||
|
const data = { storagePaidUnits: Number(line.quantity) || 0, storagePaidPeriodEnd: toIso(line.period?.end) }
|
||||||
|
try {
|
||||||
|
await updateDocument(config.collections.subscriptions, subscriptionId, data)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status !== 404) throw err
|
||||||
|
if (!(await syncSubscription(subscriptionId))) return { skipped: 'Abo ohne Kundenzuordnung' }
|
||||||
|
await updateDocument(config.collections.subscriptions, subscriptionId, data)
|
||||||
|
}
|
||||||
|
return { recorded: true, subscriptionId, units: data.storagePaidUnits, periodEnd: data.storagePaidPeriodEnd }
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +36,19 @@ export function termsDocFor(termsVersion) {
|
|||||||
return { url: '/vertragsunterlagen/v3/abo-bedingungen.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' }
|
return { url: '/vertragsunterlagen/v3/abo-bedingungen.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertragsunterlagen als Mail-Anhang in der Fassung, der der Kunde zugestimmt hat (§ 312f BGB):
|
||||||
|
* { filename, path } mit dem absoluten Pfad unter public/. Ein Speicher-Plus haengt die Fassung aus der
|
||||||
|
* Zustimmung an (consent.termsVersion), nicht die des Abos (F22): Bestandskunden mit Fassung 3 bestellen
|
||||||
|
* den Zusatzspeicher nach Fassung 4.
|
||||||
|
*/
|
||||||
|
export function contractAttachmentFor(termsVersion) {
|
||||||
|
const { pdfFile } = termsDocFor(termsVersion)
|
||||||
|
return { filename: pdfFile, path: path.join(PUBLIC_DIR, pdfFile) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Ansicht eines Produkts fuers Kundenportal (Vergleich, Bestellseite): Preis und Leistungen. */
|
/** Ansicht eines Produkts fuers Kundenportal (Vergleich, Bestellseite): Preis und Leistungen. */
|
||||||
export function productView(key) {
|
export function productView(key) {
|
||||||
const product = getProduct(key)
|
const product = getProduct(key)
|
||||||
@@ -78,12 +93,27 @@ export function describeItems(items) {
|
|||||||
|
|
||||||
const priceIdOfLine = (line) => line?.pricing?.price_details?.price || null
|
const priceIdOfLine = (line) => line?.pricing?.price_details?.price || null
|
||||||
|
|
||||||
|
// Regulaere Abo-Zeile (Monatsposition): keine anteilige Verrechnung, keine einzelne Rechnungsposition
|
||||||
|
const isRegularSubscriptionLine = (line) =>
|
||||||
|
line?.parent?.type === 'subscription_item_details' && line.parent.subscription_item_details?.proration === false
|
||||||
|
|
||||||
/** Preis-IDs aller Rechnungszeilen, jede nur einmal. */
|
/** Preis-IDs aller Rechnungszeilen, jede nur einmal. */
|
||||||
export function priceIdsOfInvoice(invoice) {
|
export function priceIdsOfInvoice(invoice) {
|
||||||
const ids = (invoice?.lines?.data || []).map(priceIdOfLine).filter(Boolean)
|
const ids = (invoice?.lines?.data || []).map(priceIdOfLine).filter(Boolean)
|
||||||
return [...new Set(ids)]
|
return [...new Set(ids)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regulaere Zusatzspeicher-Zeile einer bezahlten Rechnung (Stueckzahl des Abrechnungsmonats) oder null.
|
||||||
|
* Anteilige Zeilen (Plus im laufenden Monat) zaehlen nicht. priceKeys wie bei creditMinutesForInvoice.
|
||||||
|
*/
|
||||||
|
export function regularStorageLine(invoice, priceKeys) {
|
||||||
|
return (
|
||||||
|
(invoice?.lines?.data || []).find((line) => priceKeys.get(priceIdOfLine(line)) === 'mailStorage' && isRegularSubscriptionLine(line)) ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Arbeitszeit, die eine bezahlte Rechnung gutschreibt: die Minuten des Produkts der ersten
|
* Arbeitszeit, die eine bezahlte Rechnung gutschreibt: die Minuten des Produkts der ersten
|
||||||
* regulären (nicht anteiligen) Abo-Zeile über 0 €, deren Produkt Arbeitszeit enthält. Anteilige
|
* regulären (nicht anteiligen) Abo-Zeile über 0 €, deren Produkt Arbeitszeit enthält. Anteilige
|
||||||
@@ -100,10 +130,7 @@ export function creditMinutesForInvoice(invoice, priceKeys) {
|
|||||||
const key = legacy && known == null ? 'wartung' : known
|
const key = legacy && known == null ? 'wartung' : known
|
||||||
if (!PRODUCT_KEYS.includes(key)) continue
|
if (!PRODUCT_KEYS.includes(key)) continue
|
||||||
const minutes = config.products[key].minutesPerMonth
|
const minutes = config.products[key].minutesPerMonth
|
||||||
const regular =
|
if (minutes > 0 && isRegularSubscriptionLine(line) && line.amount > 0) return minutes
|
||||||
line.parent?.type === 'subscription_item_details' &&
|
|
||||||
line.parent.subscription_item_details?.proration === false
|
|
||||||
if (minutes > 0 && regular && line.amount > 0) return minutes
|
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -326,7 +326,8 @@ export function termStartOf(sub) {
|
|||||||
return new Date(((sub.trial_end || sub.start_date) || 0) * 1000)
|
return new Date(((sub.trial_end || sub.start_date) || 0) * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscriptionIdOfInvoice(invoice) {
|
/** Stripe-Abo-ID (oder -Objekt) einer Rechnung: Webhook-Nutzlast (alt/neu) oder frisch geladene Rechnung. */
|
||||||
|
export function subscriptionIdOfInvoice(invoice) {
|
||||||
return (
|
return (
|
||||||
invoice.subscription ||
|
invoice.subscription ||
|
||||||
invoice.parent?.subscription_details?.subscription ||
|
invoice.parent?.subscription_details?.subscription ||
|
||||||
@@ -356,8 +357,33 @@ export async function resolvePriceKeys(priceIds) {
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). */
|
/**
|
||||||
export async function grantCreditForInvoice(invoice) {
|
* Alle Zeilen einer Rechnung. invoices.retrieve bettet hoechstens 10 Zeilen ein (lines.has_more); anteilige
|
||||||
|
* Zeilen stehen vor den Abo-Zeilen, mehrere Speicheraenderungen in einem Monat schieben also die Wartungs-
|
||||||
|
* oder Speicher-Zeile hinaus. Bei has_more kommen alle Seiten ueber invoices.listLineItems (F18). Eine
|
||||||
|
* Vorschau-Rechnung hat keine ID und laesst sich nicht nachladen: dort zaehlen die eingebetteten Zeilen.
|
||||||
|
*/
|
||||||
|
export async function invoiceLines(invoice) {
|
||||||
|
const embedded = invoice?.lines?.data || []
|
||||||
|
if (!invoice?.lines?.has_more || !invoice.id) return embedded
|
||||||
|
return getStripe().invoices.listLineItems(invoice.id, { limit: 100 }).autoPagingToArray({ limit: 1000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnung frisch laden (in der API-Version des SDK, nicht der des Webhook-Endpunkts), mit allen Zeilen.
|
||||||
|
* Der Webhook invoice.paid laedt sie genau einmal und reicht sie an Stundengutschrift und Speicher-Buchung weiter.
|
||||||
|
*/
|
||||||
|
export async function loadInvoice(invoiceId) {
|
||||||
|
const fresh = await getStripe().invoices.retrieve(invoiceId)
|
||||||
|
const data = await invoiceLines(fresh)
|
||||||
|
return { ...fresh, lines: { ...(fresh.lines || {}), data, has_more: false } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bezahlte Wartungs-Rechnung -> 1 Stunde gutschreiben (idempotent ueber Invoice-ID). invoice ist die
|
||||||
|
* Webhook-Nutzlast (wird frisch geladen) oder, mit fresh: true, eine schon per loadInvoice geladene Rechnung.
|
||||||
|
*/
|
||||||
|
export async function grantCreditForInvoice(invoice, { fresh = false } = {}) {
|
||||||
const ref = subscriptionIdOfInvoice(invoice)
|
const ref = subscriptionIdOfInvoice(invoice)
|
||||||
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
const subscriptionId = typeof ref === 'string' ? ref : ref?.id
|
||||||
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
if (!subscriptionId) return { skipped: 'keine Abo-Rechnung' }
|
||||||
@@ -365,28 +391,28 @@ export async function grantCreditForInvoice(invoice) {
|
|||||||
// Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot
|
// Es zählt die reguläre Wartungs-Zeile vor Rabatt und Guthaben: Auch ein Monat, den ein Angebot
|
||||||
// oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €),
|
// oder eine Übernahmegutschrift ganz deckt, ist ein bezahlter Leistungsmonat. Probezeit (0 €),
|
||||||
// anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde.
|
// anteilige Verrechnungen, Hosting und Zusatzspeicher bringen keine Stunde.
|
||||||
const fresh = await getStripe().invoices.retrieve(invoice.id)
|
const loaded = fresh ? invoice : await loadInvoice(invoice.id)
|
||||||
const minutes = creditMinutesForInvoice(fresh, await resolvePriceKeys(priceIdsOfInvoice(fresh)))
|
const minutes = creditMinutesForInvoice(loaded, await resolvePriceKeys(priceIdsOfInvoice(loaded)))
|
||||||
if (!minutes) return { skipped: 'keine Wartungs-Leistung' }
|
if (!minutes) return { skipped: 'keine Wartungs-Leistung' }
|
||||||
|
|
||||||
const sub = await syncSubscription(subscriptionId)
|
const sub = await syncSubscription(subscriptionId)
|
||||||
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
|
if (!sub) return { skipped: 'Abo ohne Kundenzuordnung' }
|
||||||
|
|
||||||
const grantedAt = new Date((fresh.status_transitions?.paid_at || fresh.created) * 1000)
|
const grantedAt = new Date((loaded.status_transitions?.paid_at || loaded.created) * 1000)
|
||||||
try {
|
try {
|
||||||
await createDocument(
|
await createDocument(
|
||||||
config.collections.hourCredits,
|
config.collections.hourCredits,
|
||||||
{
|
{
|
||||||
customerId: sub.customerId,
|
customerId: sub.customerId,
|
||||||
stripeSubscriptionId: subscriptionId,
|
stripeSubscriptionId: subscriptionId,
|
||||||
stripeInvoiceId: invoice.id,
|
stripeInvoiceId: loaded.id,
|
||||||
minutes,
|
minutes,
|
||||||
grantedAt: grantedAt.toISOString(),
|
grantedAt: grantedAt.toISOString(),
|
||||||
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
|
expiresAt: addMonths(grantedAt, config.abo.creditValidityMonths).toISOString(),
|
||||||
source: 'stripe_invoice',
|
source: 'stripe_invoice',
|
||||||
note: fresh.number ? `Rechnung ${fresh.number}` : '',
|
note: loaded.number ? `Rechnung ${loaded.number}` : '',
|
||||||
},
|
},
|
||||||
invoice.id
|
loaded.id
|
||||||
)
|
)
|
||||||
return { granted: true, customerId: sub.customerId, subscription: sub }
|
return { granted: true, customerId: sub.customerId, subscription: sub }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const aw = await startAppwriteMock({
|
|||||||
const st = await startFakeStripe()
|
const st = await startFakeStripe()
|
||||||
after(() => Promise.all([aw.close(), st.close()]))
|
after(() => Promise.all([aw.close(), st.close()]))
|
||||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||||
const { grantCreditForInvoice } = await import('../server/services/stripeBilling.js')
|
const { grantCreditForInvoice, loadInvoice, invoiceLines } = await import('../server/services/stripeBilling.js')
|
||||||
|
|
||||||
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true })
|
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', lookup_key, unit_amount, active: true })
|
||||||
st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000))
|
st.prices.set('price_w', price('price_w', 'webklar_wartung_monatlich_5000', 5000))
|
||||||
@@ -116,3 +116,49 @@ test('Altbestand: Abo ohne bekannten Preis bekommt weiter seine Stunde, auch bei
|
|||||||
assert.equal(aw.docs('hourCredits').get('in_6').minutes, 60)
|
assert.equal(aw.docs('hourCredits').get('in_6').minutes, 60)
|
||||||
assert.equal(aw.docs('subscriptions').get('sub_alt').productKey, 'wartung')
|
assert.equal(aw.docs('subscriptions').get('sub_alt').productKey, 'wartung')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// F18 / Hinweis aus dem Review von Plan 1: invoices.retrieve bettet hoechstens 10 Zeilen ein. Anteilige
|
||||||
|
// Zeilen stehen vor den Abo-Zeilen; mehrere Speicheraenderungen in einem Monat schieben die Wartungs-Zeile
|
||||||
|
// hinaus. Der Nachbau bettet wie Stripe 10 Zeilen ein (has_more), der Rest kommt ueber /lines.
|
||||||
|
test('Rechnung mit 11 Zeilen: die Wartungs-Zeile als elfte wird nachgeladen → 60 Minuten', async () => {
|
||||||
|
st.prices.set('price_s', price('price_s', 'webklar_mailspeicher_5gb_monatlich_100', 100))
|
||||||
|
const proration = (amount) => ({
|
||||||
|
amount,
|
||||||
|
pricing: { type: 'price_details', price_details: { price: 'price_s' } },
|
||||||
|
parent: { type: 'subscription_item_details', subscription_item_details: { proration: true } },
|
||||||
|
})
|
||||||
|
const prorations = Array.from({ length: 10 }, (_, i) => proration(10 + i))
|
||||||
|
st.invoices.set('in_11', inv('in_11', 'sub_w', [...prorations, regular('price_w', 5000)]))
|
||||||
|
const before = st.calls.length
|
||||||
|
const r = await grantCreditForInvoice(payload('in_11', 'sub_w', 5000))
|
||||||
|
assert.equal(r.granted, true)
|
||||||
|
assert.equal(aw.docs('hourCredits').get('in_11').minutes, 60)
|
||||||
|
const calls = st.calls.slice(before)
|
||||||
|
assert.equal(calls.filter((c) => c.method === 'GET' && c.path === '/v1/invoices/in_11').length, 1)
|
||||||
|
assert.ok(calls.some((c) => c.method === 'GET' && c.path === '/v1/invoices/in_11/lines'), 'weitere Zeilen ueber /lines geladen')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invoiceLines: eingebettete Zeilen ohne has_more, sonst alle Seiten; loadInvoice liefert alle Zeilen', async () => {
|
||||||
|
const embedded = { id: 'in_egal', lines: { data: [{ amount: 1 }, { amount: 2 }], has_more: false } }
|
||||||
|
const before = st.calls.length
|
||||||
|
assert.deepEqual(await invoiceLines(embedded), [{ amount: 1 }, { amount: 2 }])
|
||||||
|
assert.equal(st.calls.length, before, 'kein Stripe-Aufruf ohne has_more')
|
||||||
|
const loaded = await loadInvoice('in_11')
|
||||||
|
assert.equal(loaded.lines.data.length, 11)
|
||||||
|
assert.equal(loaded.lines.has_more, false)
|
||||||
|
assert.equal(loaded.lines.data[10].amount, 5000)
|
||||||
|
// Vorschau-Rechnungen haben keine ID: has_more laesst sich dann nicht nachladen, die eingebetteten zaehlen
|
||||||
|
assert.deepEqual(await invoiceLines({ lines: { data: [{ amount: 3 }], has_more: true } }), [{ amount: 3 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
// F18: der Webhook laedt die Rechnung einmal (loadInvoice) und reicht sie als fresh weiter - kein zweiter Abruf
|
||||||
|
test('Schon frisch geladene Rechnung (fresh: true) wird nicht noch einmal geladen', async () => {
|
||||||
|
st.invoices.set('in_7', inv('in_7', 'sub_w', [regular('price_w', 5000)]))
|
||||||
|
const fresh = await loadInvoice('in_7')
|
||||||
|
const before = st.calls.length
|
||||||
|
const r = await grantCreditForInvoice(fresh, { fresh: true })
|
||||||
|
assert.equal(r.granted, true)
|
||||||
|
assert.equal(aw.docs('hourCredits').get('in_7').minutes, 60)
|
||||||
|
assert.equal(aw.docs('hourCredits').get('in_7').note, 'Rechnung WK-in_7')
|
||||||
|
assert.deepEqual(st.calls.slice(before).filter((c) => c.path.startsWith('/v1/invoices')), [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
+37
-2
@@ -1,9 +1,20 @@
|
|||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
const { config } = await import('../server/config.js')
|
const { config } = await import('../server/config.js')
|
||||||
const { getProduct, productKeyForLookupKey, describeItems, priceIdsOfInvoice, creditMinutesForInvoice, termsDocFor, productView } =
|
const {
|
||||||
await import('../server/services/products.js')
|
getProduct,
|
||||||
|
productKeyForLookupKey,
|
||||||
|
describeItems,
|
||||||
|
priceIdsOfInvoice,
|
||||||
|
creditMinutesForInvoice,
|
||||||
|
termsDocFor,
|
||||||
|
productView,
|
||||||
|
contractAttachmentFor,
|
||||||
|
regularStorageLine,
|
||||||
|
} = await import('../server/services/products.js')
|
||||||
|
|
||||||
const line = (price, amount, proration = false) => ({
|
const line = (price, amount, proration = false) => ({
|
||||||
amount,
|
amount,
|
||||||
@@ -161,3 +172,27 @@ test('Produktansicht: Domain-Zeile nennt die Endungen', () => {
|
|||||||
assert.ok(flat.includes('.de') && flat.includes('.com') && flat.includes('.eu') && flat.includes('.net') && flat.includes('.org'))
|
assert.ok(flat.includes('.de') && flat.includes('.com') && flat.includes('.eu') && flat.includes('.net') && flat.includes('.org'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// F22 / Task 12: Vertragsunterlagen als Mail-Anhang in der Fassung, der der Kunde zugestimmt hat
|
||||||
|
test('contractAttachmentFor: Datei je Fassung mit absolutem Pfad unter public/', () => {
|
||||||
|
const v3 = contractAttachmentFor('2026-09-25-v3')
|
||||||
|
const v4 = contractAttachmentFor('2026-10-05-v4')
|
||||||
|
assert.equal(v3.filename, 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')
|
||||||
|
assert.equal(v4.filename, 'WEBklar-Abo-Vertragsunterlagen.pdf')
|
||||||
|
assert.ok(path.isAbsolute(v4.path) && v4.path.endsWith(path.join('public', v4.filename)))
|
||||||
|
assert.ok(existsSync(v4.path), v4.path)
|
||||||
|
assert.ok(existsSync(v3.path), v3.path)
|
||||||
|
assert.deepEqual(contractAttachmentFor(null), v3)
|
||||||
|
assert.deepEqual(Object.keys(v4).sort(), ['filename', 'path'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Task 8: die regulaere Zusatzspeicher-Zeile einer bezahlten Rechnung (Stueckzahl des Monats), nie eine anteilige
|
||||||
|
test('regularStorageLine: reguläre Zusatzspeicher-Zeile, keine anteilige, kein anderes Produkt', () => {
|
||||||
|
const inv = (...lines) => ({ lines: { data: lines } })
|
||||||
|
const storage = { ...line('price_s', 300), quantity: 3 }
|
||||||
|
assert.equal(regularStorageLine(inv(line('price_s', 90, true), line('price_w', 5000), storage), keys), storage)
|
||||||
|
assert.equal(regularStorageLine(inv(line('price_s', 90, true)), keys), null)
|
||||||
|
assert.equal(regularStorageLine(inv(line('price_w', 5000), line('price_h', 1500)), keys), null)
|
||||||
|
assert.equal(regularStorageLine(inv({ amount: 5 }), keys), null)
|
||||||
|
assert.equal(regularStorageLine({}, keys), null)
|
||||||
|
})
|
||||||
@@ -48,6 +48,12 @@ test('Appwrite-Nachbau: failNext liefert einmal den gewuenschten Fehler', async
|
|||||||
aw.failNext({ collection: 'hourCredits', method: 'POST', status: 404 })
|
aw.failNext({ collection: 'hourCredits', method: 'POST', status: 404 })
|
||||||
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
|
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
|
||||||
await assert.rejects(createDocument('hourCredits', { customerId: 'c9' }, 'hc_1'), (e) => e.status === 404)
|
await assert.rejects(createDocument('hourCredits', { customerId: 'c9' }, 'hc_1'), (e) => e.status === 404)
|
||||||
|
// after: so viele passende Anfragen gehen vorher noch durch (Task 8: erst die zweite PATCH scheitert).
|
||||||
|
// adminFetch bildet jeden 5xx-Status auf status 500 ab.
|
||||||
|
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 503, after: 1 })
|
||||||
|
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
|
||||||
|
await assert.rejects(getDocument('subscriptions', 'sub_fail'), (e) => e.status === 500 && /503/.test(e.message))
|
||||||
|
assert.equal((await getDocument('subscriptions', 'sub_fail')).$id, 'sub_fail')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Stripe-Nachbau: getStripe spricht mit 127.0.0.1', async () => {
|
test('Stripe-Nachbau: getStripe spricht mit 127.0.0.1', async () => {
|
||||||
@@ -64,3 +70,94 @@ test('useTestEnv lehnt fremde Hosts ab', () => {
|
|||||||
assert.throws(() => useTestEnv({ appwriteUrl: 'https://ticket.webklar.com/v1', stripeUrl: st.url }))
|
assert.throws(() => useTestEnv({ appwriteUrl: 'https://ticket.webklar.com/v1', stripeUrl: st.url }))
|
||||||
assert.equal(new URL(process.env.APPWRITE_ENDPOINT).hostname, '127.0.0.1')
|
assert.equal(new URL(process.env.APPWRITE_ENDPOINT).hostname, '127.0.0.1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// F17 (Task 8): POST /v1/subscriptions/:id mit Positionen (anlegen per Preis, aendern per ID), Metadaten
|
||||||
|
// (leerer Wert loescht wie bei Stripe) und einmaligem Zusatz nextSubscriptionUpdate fuer die Antwort
|
||||||
|
test('Stripe-Nachbau: Abo-Update mit Positionen, Metadaten, einmaliger Zusatz', async () => {
|
||||||
|
const stripe = getStripe()
|
||||||
|
st.prices.set('price_a', { id: 'price_a', object: 'price', active: true, lookup_key: 'lookup_a', unit_amount: 100 })
|
||||||
|
st.subscriptions.set('sub_u', {
|
||||||
|
id: 'sub_u',
|
||||||
|
object: 'subscription',
|
||||||
|
status: 'active',
|
||||||
|
metadata: { keep: '1', drop: 'x' },
|
||||||
|
items: { object: 'list', data: [{ id: 'si_1', object: 'subscription_item', quantity: 1, price: { id: 'price_0', object: 'price' } }] },
|
||||||
|
})
|
||||||
|
const added = await stripe.subscriptions.update('sub_u', {
|
||||||
|
items: [{ price: 'price_a', quantity: 2 }],
|
||||||
|
metadata: { drop: '', neu: 'y' },
|
||||||
|
proration_behavior: 'none',
|
||||||
|
})
|
||||||
|
assert.equal(added.items.data.length, 2)
|
||||||
|
const item = added.items.data[1]
|
||||||
|
assert.equal(item.quantity, 2)
|
||||||
|
assert.equal(item.price.id, 'price_a')
|
||||||
|
assert.equal(item.price.lookup_key, 'lookup_a')
|
||||||
|
assert.match(item.id, /^si_/)
|
||||||
|
assert.deepEqual(added.metadata, { keep: '1', neu: 'y' })
|
||||||
|
assert.equal(added.status, 'active')
|
||||||
|
assert.equal(st.calls.at(-1).body['items[0][price]'], 'price_a')
|
||||||
|
|
||||||
|
const changed = await stripe.subscriptions.update('sub_u', { items: [{ id: item.id, quantity: 0 }] })
|
||||||
|
assert.equal(changed.items.data[1].quantity, 0)
|
||||||
|
assert.equal((await stripe.subscriptions.retrieve('sub_u')).items.data[1].quantity, 0, 'gespeichert')
|
||||||
|
|
||||||
|
// Wie Stripe: derselbe Preis nicht zweimal, unbekannter Preis oder Posten 400/404, unbekanntes Abo 404
|
||||||
|
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ price: 'price_a', quantity: 1 }] }), (e) => e.statusCode === 400)
|
||||||
|
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ price: 'price_gibt_es_nicht', quantity: 1 }] }), (e) => e.statusCode === 400)
|
||||||
|
await assert.rejects(stripe.subscriptions.update('sub_u', { items: [{ id: 'si_gibt_es_nicht', quantity: 1 }] }), (e) => e.statusCode === 400)
|
||||||
|
await assert.rejects(stripe.subscriptions.update('sub_gibt_es_nicht', { metadata: { a: 'b' } }), (e) => e.statusCode === 404)
|
||||||
|
|
||||||
|
// Kuendigungsfelder wie cancelAtPeriodEnd/cancelSubscriptionAt sie setzen
|
||||||
|
const cancelled = await stripe.subscriptions.update('sub_u', { cancel_at_period_end: true, metadata: { cancelSource: 'test' } })
|
||||||
|
assert.equal(cancelled.cancel_at_period_end, true)
|
||||||
|
assert.equal(cancelled.metadata.cancelSource, 'test')
|
||||||
|
const dated = await stripe.subscriptions.update('sub_u', { cancel_at: 1800000000, proration_behavior: 'none' })
|
||||||
|
assert.equal(dated.cancel_at, 1800000000)
|
||||||
|
|
||||||
|
// nextSubscriptionUpdate: einmalig in die naechste Antwort gemischt, nicht gespeichert
|
||||||
|
st.nextSubscriptionUpdate = { pending_update: { expires_at: 1 }, latest_invoice: { status: 'open', hosted_invoice_url: 'https://pay.example/inv' } }
|
||||||
|
const pending = await stripe.subscriptions.update('sub_u', { metadata: { a: 'b' } })
|
||||||
|
assert.deepEqual(pending.pending_update, { expires_at: 1 })
|
||||||
|
assert.equal(pending.latest_invoice.hosted_invoice_url, 'https://pay.example/inv')
|
||||||
|
assert.equal(pending.metadata.a, 'b')
|
||||||
|
assert.equal((await stripe.subscriptions.retrieve('sub_u')).pending_update, undefined)
|
||||||
|
assert.equal((await stripe.subscriptions.update('sub_u', { metadata: { a: 'c' } })).pending_update, undefined)
|
||||||
|
assert.equal(st.nextSubscriptionUpdate, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
// F17: GET /v1/invoices/:id bettet wie Stripe hoechstens 10 Zeilen ein, GET /v1/invoices/:id/lines blaettert
|
||||||
|
test('Stripe-Nachbau: Rechnung bettet höchstens 10 Zeilen ein, /lines blättert', async () => {
|
||||||
|
const stripe = getStripe()
|
||||||
|
st.invoices.set('in_p', { id: 'in_p', object: 'invoice', lines: { object: 'list', data: Array.from({ length: 12 }, (_, i) => ({ amount: i })) } })
|
||||||
|
const invoice = await stripe.invoices.retrieve('in_p')
|
||||||
|
assert.equal(invoice.lines.data.length, 10)
|
||||||
|
assert.equal(invoice.lines.has_more, true)
|
||||||
|
assert.ok(invoice.lines.data.every((l) => l.id && l.object === 'line_item'))
|
||||||
|
const page = await stripe.invoices.listLineItems('in_p', { limit: 5 })
|
||||||
|
assert.equal(page.data.length, 5)
|
||||||
|
assert.equal(page.has_more, true)
|
||||||
|
const all = await stripe.invoices.listLineItems('in_p', { limit: 5 }).autoPagingToArray({ limit: 100 })
|
||||||
|
assert.deepEqual(all.map((l) => l.amount), [...Array(12).keys()])
|
||||||
|
const small = await stripe.invoices.retrieve('in_p') // unveraendert seedbar: die Quelle behaelt alle Zeilen
|
||||||
|
assert.equal(small.lines.data.length, 10)
|
||||||
|
st.invoices.set('in_q', { id: 'in_q', object: 'invoice', lines: { object: 'list', data: [{ amount: 1 }] } })
|
||||||
|
assert.equal((await stripe.invoices.retrieve('in_q')).lines.has_more, false)
|
||||||
|
await assert.rejects(stripe.invoices.listLineItems('in_nix'), (e) => e.statusCode === 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
// F17: POST /v1/invoices/create_preview liefert die vorab hinterlegte Vorschau (Objekt oder Funktion des Aufrufs)
|
||||||
|
test('Stripe-Nachbau: Rechnungsvorschau aus dem Seed', async () => {
|
||||||
|
const stripe = getStripe()
|
||||||
|
const params = { subscription: 'sub_u', subscription_details: { items: [{ id: 'si_1', quantity: 2 }], proration_behavior: 'create_prorations', proration_date: 1 } }
|
||||||
|
const empty = await stripe.invoices.createPreview(params)
|
||||||
|
assert.deepEqual(empty.lines.data, [])
|
||||||
|
st.invoicePreview = { object: 'invoice', lines: { object: 'list', data: [{ amount: 42 }], has_more: false } }
|
||||||
|
assert.equal((await stripe.invoices.createPreview(params)).lines.data[0].amount, 42)
|
||||||
|
assert.equal(st.calls.at(-1).body['subscription_details[items][0][quantity]'], '2')
|
||||||
|
assert.equal(st.calls.at(-1).body['subscription_details[proration_behavior]'], 'create_prorations')
|
||||||
|
st.invoicePreview = (body) => ({ object: 'invoice', lines: { data: [{ amount: Number(body.subscription_details.items[0].quantity) * 100 }] } })
|
||||||
|
assert.equal((await stripe.invoices.createPreview(params)).lines.data[0].amount, 200)
|
||||||
|
await assert.rejects(stripe.invoices.createPreview({ subscription: 'sub_nix' }), (e) => e.statusCode === 404)
|
||||||
|
st.invoicePreview = null
|
||||||
|
})
|
||||||
@@ -5,8 +5,9 @@ import http from 'node:http'
|
|||||||
* Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie
|
* Speicher. Mit schema = { collection: ['attr', ...] } lehnt er unbekannte Attribute ab wie
|
||||||
* Appwrite ("Invalid document structure: Unknown attribute"). Dokument-IDs prüft er wie Appwrite
|
* Appwrite ("Invalid document structure: Unknown attribute"). Dokument-IDs prüft er wie Appwrite
|
||||||
* (höchstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, kein Sonderzeichen am
|
* (höchstens 36 Zeichen aus a-z, A-Z, 0-9, Punkt, Bindestrich, Unterstrich, kein Sonderzeichen am
|
||||||
* Anfang). failNext({ collection?, method?, status, message? }) lässt die nächste passende Anfrage
|
* Anfang). failNext({ collection?, method?, status, message?, after? }) lässt die nächste passende Anfrage
|
||||||
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt").
|
* mit diesem Status scheitern (Fehlerpfade wie "Appwrite nicht erreichbar" oder "Collection fehlt");
|
||||||
|
* after = Zahl passender Anfragen, die vorher noch durchgehen (z. B. erst die zweite PATCH scheitert).
|
||||||
*/
|
*/
|
||||||
export async function startAppwriteMock({ schema = {} } = {}) {
|
export async function startAppwriteMock({ schema = {} } = {}) {
|
||||||
const collections = new Map()
|
const collections = new Map()
|
||||||
@@ -104,9 +105,13 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
|||||||
(!pendingFailure.collection || pendingFailure.collection === collection) &&
|
(!pendingFailure.collection || pendingFailure.collection === collection) &&
|
||||||
(!pendingFailure.method || pendingFailure.method === req.method)
|
(!pendingFailure.method || pendingFailure.method === req.method)
|
||||||
) {
|
) {
|
||||||
const { status, message } = pendingFailure
|
if (pendingFailure.after > 0) {
|
||||||
pendingFailure = null
|
pendingFailure.after -= 1
|
||||||
return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }]
|
} else {
|
||||||
|
const { status, message } = pendingFailure
|
||||||
|
pendingFailure = null
|
||||||
|
return [status, { message: message || `Nachbau: eingeplanter Fehler ${status}`, code: status, type: 'general_server_error' }]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]'))
|
if (!id && req.method === 'GET') return list(collection, url.searchParams.getAll('queries[]'))
|
||||||
@@ -155,9 +160,9 @@ export async function startAppwriteMock({ schema = {} } = {}) {
|
|||||||
url: `http://127.0.0.1:${server.address().port}/v1`,
|
url: `http://127.0.0.1:${server.address().port}/v1`,
|
||||||
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
|
docs: (collection) => new Map([...docs(collection)].map(([id, doc]) => [id, publicDoc(doc)])),
|
||||||
seed,
|
seed,
|
||||||
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig). */
|
/** Naechste passende Dokument-Anfrage scheitert mit status (einmalig); after passende Anfragen gehen vorher durch. */
|
||||||
failNext: ({ collection = null, method = null, status = 500, message = '' }) => {
|
failNext: ({ collection = null, method = null, status = 500, message = '', after = 0 }) => {
|
||||||
pendingFailure = { collection, method, status, message }
|
pendingFailure = { collection, method, status, message, after }
|
||||||
},
|
},
|
||||||
close: () => new Promise((resolve) => server.close(resolve)),
|
close: () => new Promise((resolve) => server.close(resolve)),
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-1
@@ -4,6 +4,12 @@ import http from 'node:http'
|
|||||||
* Stripe-Nachbau für Tests. stripe-node spricht ihn über config.stripe.apiBase (STRIPE_API_BASE)
|
* Stripe-Nachbau für Tests. stripe-node spricht ihn über config.stripe.apiBase (STRIPE_API_BASE)
|
||||||
* an. Objekte werden vorab in die Maps gelegt; jeder Aufruf landet in calls als
|
* an. Objekte werden vorab in die Maps gelegt; jeder Aufruf landet in calls als
|
||||||
* { method, path, query, body } (Formular-Schlüssel flach, z. B. 'items[0][price]').
|
* { method, path, query, body } (Formular-Schlüssel flach, z. B. 'items[0][price]').
|
||||||
|
*
|
||||||
|
* Abo-Änderungen (F17): POST /v1/subscriptions/:id mischt items[i][id|price|quantity|deleted] und
|
||||||
|
* metadata (leerer Wert löscht) in das gespeicherte Abo; st.nextSubscriptionUpdate wird einmalig nur
|
||||||
|
* in die nächste Antwort gemischt (z. B. pending_update, latest_invoice). Rechnungen: GET /v1/invoices/:id
|
||||||
|
* bettet wie Stripe höchstens 10 Zeilen ein, GET /v1/invoices/:id/lines blättert (limit, starting_after);
|
||||||
|
* POST /v1/invoices/create_preview liefert st.invoicePreview (Objekt, oder Funktion des Aufruf-Bodys).
|
||||||
*/
|
*/
|
||||||
export async function startFakeStripe() {
|
export async function startFakeStripe() {
|
||||||
const subscriptions = new Map()
|
const subscriptions = new Map()
|
||||||
@@ -16,11 +22,18 @@ export async function startFakeStripe() {
|
|||||||
const checkoutSessions = new Map()
|
const checkoutSessions = new Map()
|
||||||
const calls = []
|
const calls = []
|
||||||
let seq = 0
|
let seq = 0
|
||||||
|
let nextSubscriptionUpdate = null
|
||||||
|
let invoicePreview = null
|
||||||
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
|
const nextId = (prefix) => `${prefix}_test_${(seq += 1)}`
|
||||||
|
|
||||||
const flat = (params) => Object.fromEntries(params.entries())
|
const flat = (params) => Object.fromEntries(params.entries())
|
||||||
const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }]
|
const noSuch = (what, id) => [404, { error: { type: 'invalid_request_error', code: 'resource_missing', message: `No such ${what}: '${id}'` } }]
|
||||||
|
const invalid = (param, message) => [400, { error: { type: 'invalid_request_error', param, message } }]
|
||||||
const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' })
|
const listOf = (data) => ({ object: 'list', data, has_more: false, url: '/v1/list' })
|
||||||
|
// Rechnungszeilen mit IDs (fuer starting_after), wie Stripe sie liefert; die Quelle behaelt alle Zeilen
|
||||||
|
const invoiceLinesOf = (invoiceId) =>
|
||||||
|
(invoices.get(invoiceId)?.lines?.data || []).map((line, i) => ({ id: `li_${invoiceId}_${i + 1}`, object: 'line_item', ...line }))
|
||||||
|
const EMBEDDED_LINES = 10
|
||||||
|
|
||||||
const readBody = (req) =>
|
const readBody = (req) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
@@ -53,6 +66,56 @@ export async function startFakeStripe() {
|
|||||||
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
if (method === 'GET' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
||||||
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
|
return subscriptions.has(m[1]) ? [200, subscriptions.get(m[1])] : noSuch('subscription', m[1])
|
||||||
}
|
}
|
||||||
|
if (method === 'POST' && (m = path.match(/^\/v1\/subscriptions\/([^/]+)$/))) {
|
||||||
|
const existing = subscriptions.get(m[1])
|
||||||
|
if (!existing) return noSuch('subscription', m[1])
|
||||||
|
const data = unflatten(body)
|
||||||
|
const items = (existing.items?.data || []).map((item) => ({ ...item }))
|
||||||
|
for (const [i, entry] of (data.items || []).entries()) {
|
||||||
|
if (entry.id) {
|
||||||
|
// Vorhandene Position: Menge/Preis aendern oder entfernen
|
||||||
|
const index = items.findIndex((item) => item.id === entry.id)
|
||||||
|
if (index < 0) return invalid(`items[${i}][id]`, `No such subscription_item: '${entry.id}'`)
|
||||||
|
if (entry.deleted === 'true') {
|
||||||
|
items.splice(index, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.price) {
|
||||||
|
if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`)
|
||||||
|
items[index].price = prices.get(entry.price)
|
||||||
|
}
|
||||||
|
if (entry.quantity !== undefined) items[index].quantity = Number(entry.quantity)
|
||||||
|
} else if (entry.price) {
|
||||||
|
// Neue Position per Preis; wie Stripe: derselbe Preis hoechstens einmal je Abo
|
||||||
|
if (!prices.has(entry.price)) return invalid(`items[${i}][price]`, `No such price: '${entry.price}'`)
|
||||||
|
if (items.some((item) => item.price?.id === entry.price)) {
|
||||||
|
return invalid(`items[${i}][price]`, 'You cannot combine multiple subscription items with the same price.')
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
id: nextId('si'),
|
||||||
|
object: 'subscription_item',
|
||||||
|
quantity: entry.quantity !== undefined ? Number(entry.quantity) : 1,
|
||||||
|
price: prices.get(entry.price),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return invalid(`items[${i}]`, 'Each subscription item must have an id or a price.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Metadaten wie bei Stripe zusammenfuehren: leerer Wert loescht den Schluessel
|
||||||
|
const metadata = { ...(existing.metadata || {}) }
|
||||||
|
for (const [key, value] of Object.entries(data.metadata || {})) {
|
||||||
|
if (value === '') delete metadata[key]
|
||||||
|
else metadata[key] = value
|
||||||
|
}
|
||||||
|
const updated = { ...existing, items: { object: 'list', ...(existing.items || {}), data: items }, metadata }
|
||||||
|
if (data.cancel_at_period_end !== undefined) updated.cancel_at_period_end = data.cancel_at_period_end === 'true'
|
||||||
|
if (data.cancel_at !== undefined) updated.cancel_at = data.cancel_at === '' ? null : Number(data.cancel_at)
|
||||||
|
subscriptions.set(m[1], updated)
|
||||||
|
// Einmaliger Zusatz nur fuer diese Antwort (pending_update, latest_invoice, ...), nicht gespeichert
|
||||||
|
const response = nextSubscriptionUpdate ? { ...updated, ...nextSubscriptionUpdate } : updated
|
||||||
|
nextSubscriptionUpdate = null
|
||||||
|
return [200, response]
|
||||||
|
}
|
||||||
if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) {
|
if (method === 'GET' && (m = path.match(/^\/v1\/prices\/([^/]+)$/))) {
|
||||||
return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1])
|
return prices.has(m[1]) ? [200, prices.get(m[1])] : noSuch('price', m[1])
|
||||||
}
|
}
|
||||||
@@ -90,7 +153,32 @@ export async function startFakeStripe() {
|
|||||||
return [200, obj]
|
return [200, obj]
|
||||||
}
|
}
|
||||||
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
|
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)$/))) {
|
||||||
return invoices.has(m[1]) ? [200, invoices.get(m[1])] : noSuch('invoice', m[1])
|
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
|
||||||
|
// Wie Stripe: hoechstens 10 Zeilen eingebettet, weitere ueber GET /v1/invoices/:id/lines (has_more)
|
||||||
|
const lines = invoiceLinesOf(m[1])
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
...invoices.get(m[1]),
|
||||||
|
lines: { object: 'list', data: lines.slice(0, EMBEDDED_LINES), has_more: lines.length > EMBEDDED_LINES, url: `/v1/invoices/${m[1]}/lines` },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (method === 'GET' && (m = path.match(/^\/v1\/invoices\/([^/]+)\/lines$/))) {
|
||||||
|
if (!invoices.has(m[1])) return noSuch('invoice', m[1])
|
||||||
|
const lines = invoiceLinesOf(m[1])
|
||||||
|
const limit = Math.min(Number(query.get('limit')) || 10, 100)
|
||||||
|
const after = query.get('starting_after')
|
||||||
|
const afterIndex = after ? lines.findIndex((line) => line.id === after) : -1
|
||||||
|
if (after && afterIndex < 0) return invalid('starting_after', `No such line item: '${after}'`)
|
||||||
|
const start = afterIndex + 1
|
||||||
|
return [200, { object: 'list', data: lines.slice(start, start + limit), has_more: start + limit < lines.length, url: `/v1/invoices/${m[1]}/lines` }]
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === '/v1/invoices/create_preview') {
|
||||||
|
const data = unflatten(body)
|
||||||
|
if (data.subscription && !subscriptions.has(data.subscription)) return noSuch('subscription', data.subscription)
|
||||||
|
const preview = typeof invoicePreview === 'function' ? invoicePreview(data) : invoicePreview
|
||||||
|
return [200, preview || { object: 'invoice', lines: { object: 'list', data: [], has_more: false }, amount_due: 0, subtotal: 0, total: 0 }]
|
||||||
}
|
}
|
||||||
// scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild)
|
// scripts/stripe-products.mjs --pruefen liest das Produkt zurueck (Beschreibung, Bild)
|
||||||
if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) {
|
if (method === 'GET' && (m = path.match(/^\/v1\/products\/([^/]+)$/))) {
|
||||||
@@ -234,6 +322,20 @@ export async function startFakeStripe() {
|
|||||||
billingPortalConfigurations,
|
billingPortalConfigurations,
|
||||||
customers,
|
customers,
|
||||||
checkoutSessions,
|
checkoutSessions,
|
||||||
|
/** Einmalig in die naechste Antwort von POST /v1/subscriptions/:id gemischt (z. B. pending_update). */
|
||||||
|
get nextSubscriptionUpdate() {
|
||||||
|
return nextSubscriptionUpdate
|
||||||
|
},
|
||||||
|
set nextSubscriptionUpdate(value) {
|
||||||
|
nextSubscriptionUpdate = value
|
||||||
|
},
|
||||||
|
/** Antwort von POST /v1/invoices/create_preview: Objekt oder Funktion (body) => Objekt; null = leere Vorschau. */
|
||||||
|
get invoicePreview() {
|
||||||
|
return invoicePreview
|
||||||
|
},
|
||||||
|
set invoicePreview(value) {
|
||||||
|
invoicePreview = value
|
||||||
|
},
|
||||||
close: () => new Promise((resolve) => server.close(resolve)),
|
close: () => new Promise((resolve) => server.close(resolve)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,9 @@ import { config } from '../../server/config.js'
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const DAY_MS = 86400000
|
const DAY_MS = 86400000
|
||||||
const daysFromNow = (days) => new Date(Date.now() + days * DAY_MS).toISOString()
|
// Sekundengenau wie Stripe (unix-Zeitstempel): der Spiegel schreibt toIso(unix) und trifft so die
|
||||||
|
// Fixture-Werte wortgleich (z. B. storagePaidPeriodEnd = currentPeriodEnd nach einem Speicher-Plus)
|
||||||
|
const daysFromNow = (days) => new Date(Math.floor((Date.now() + days * DAY_MS) / 1000) * 1000).toISOString()
|
||||||
|
|
||||||
export const customers = {
|
export const customers = {
|
||||||
c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },
|
c1: { $id: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid', name: 'Kunde Eins', status: 'active', portalAccessEnabled: true },
|
||||||
|
|||||||
Reference in new issue
Block a user