Admin: Reiter E-Mail-Speicher, Abo mit Produkt; Schnittstelle fürs Ticketsystem

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Fable 5.1 committed 2026-09-29 14:37:42 +00:00
1 parent 19f9c747a2
commit 63d3bb11bf
10 files changed
+525 -4

No files matched your search

+28
View File
@@ -123,6 +123,12 @@
.credit-input option { color: #111; }
textarea.credit-input { min-height: 80px; resize: vertical; }
.calc-result { margin: 10px 0; padding: 9px 12px; border-radius: 10px; font-size: 14px; background: rgba(94,234,212,0.08); border: 1px solid rgba(94,234,212,0.3); }
/* E-Mail-Speicher: ab der Warnschwelle des Hetzner-Pakets rot */
.calc-result.warn { background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.45); color: #fca5a5; }
.storage-task strong { font-size: 15px; }
.storage-partial { color: #fca5a5; }
.storage-failed { margin: 14px 0 0; }
.storage-failed li { margin: 4px 0; font-size: 14px; }
.credit-field { margin-top: 10px; }
.credit-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
.mini-btn:disabled { opacity: 0.45; cursor: default; }
@@ -158,6 +164,7 @@
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
<button class="admin-tab" data-tab="credits">Gutschriften <span id="admin-credit-badge" class="badge-pill hidden"></span></button>
<button class="admin-tab" data-tab="offers">Abo-Angebote</button>
<button class="admin-tab" data-tab="storage">E-Mail-Speicher</button>
</div>
<!-- Übersicht -->
@@ -263,6 +270,27 @@
</div>
<p class="admin-note">Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.</p>
</section>
<!-- E-Mail-Speicher (Zusatzspeicher-Aufgaben fuers Team, Hetzner-Paket) -->
<section class="admin-section" id="tab-storage">
<div id="storage-summary" class="calc-result">Lade…</div>
<div class="admin-table-wrap no-scroll">
<table class="admin-table">
<colgroup>
<col style="width:14%"><col style="width:22%"><col style="width:34%"><col style="width:18%"><col style="width:12%">
</colgroup>
<thead>
<tr><th>Fällig</th><th>Kunde</th><th>Aufgabe in konsoleH</th><th>Bestellt</th><th></th></tr>
</thead>
<tbody id="storage-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
</table>
</div>
<div id="storage-failed" class="storage-failed hidden">
<h4>Gescheiterte Änderungen (letzte 30 Tage)</h4>
<ul class="recent-list" id="storage-failed-list"></ul>
</div>
<p class="admin-note">Jedes Abo bringt 5 GB E-Mail-Speicher mit, jedes Zusatzpaket 5 GB dazu. „Einrichten“: Postfachgrenze in konsoleH jetzt auf den Zielwert setzen. „Zum Periodenende“: erst am genannten Tag auf den Zielwert senken. Danach „Erledigt“ drücken – der Kunde hat seine Bestätigung schon bekommen.</p>
</section>
</main>
<script src="/chat-composer.js"></script>
+85 -4
View File
@@ -41,6 +41,10 @@ function fmtEuro(value) {
return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })
}
// Katalogname wie im Formular ("Hosting-Abo"/"Wartungs-Abo"); Altbestand ohne Produkt gilt als Wartungs-Abo
const PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
const productLabelOf = (key) => PRODUCT_LABEL[key] || (key ? String(key) : 'Abo')
function isLead(customer) {
return String(customer.customerStatus || '').toLowerCase() === 'lead'
}
@@ -634,7 +638,7 @@ function renderCreditDetail(panel, d, message) {
<dl class="kv">
<dt>Kunde</dt><dd>${escapeHtml(c.name || c.companyName)}${c.code ? ` · Kd.-Nr. ${escapeHtml(c.code)}` : ''}</dd>
<dt>E-Mail</dt><dd>${escapeHtml(c.email || '–')}</dd>
<dt>${escapeHtml(s?.productName?.replace(/^WEBklar /, '') || 'Abo')}</dt><dd>${aboPill}</dd>
<dt>Abo</dt><dd>${s ? `${escapeHtml(productLabelOf(s.productKey))} ${aboPill}<br><span class="muted">Zusatzspeicher: ${storageLabel(s.storageUnits)}</span>` : aboPill}</dd>
${s ? `
<dt>Leistungsbeginn</dt><dd>${fmtDate(s.serviceStart)}</dd>
<dt>Nächste Abbuchung</dt><dd>${s.active ? fmtDate(s.currentPeriodEnd) : '–'}</dd>
@@ -778,6 +782,80 @@ async function applyCredit(id) {
}
}
/* ----------------------------------------------------- E-Mail-Speicher --- */
const GB_INCLUDED = 5
const GB_PER_UNIT = 5
const gbOfUnits = (units) => GB_INCLUDED + GB_PER_UNIT * (Number(units) || 0)
// "2 Pakete (15 GB)" / "kein Paket (5 GB)"
function storageLabel(units) {
const n = Number(units) || 0
const packages = n === 0 ? 'kein Paket' : n === 1 ? '1 Paket' : `${n} Pakete`
return `${packages} (${gbOfUnits(n)} GB)`
}
function storageDueHtml(t) {
const overdue = t.dueAt && new Date(t.dueAt).getTime() <= Date.now()
if (t.kind === 'einrichten') {
return `<span class="pill pill-warn">jetzt einrichten</span><br><span class="muted">bestellt ${fmtDateTime(t.createdAt)}</span>`
}
return `<span class="pill ${overdue ? 'pill-warn' : 'pill-muted'}">zum Periodenende</span><br>${fmtDate(t.dueAt)}`
}
function storageTaskHtml(t) {
const action = t.kind === 'einrichten'
? `Postfachgrenze in konsoleH <strong>jetzt</strong> auf <strong>${escapeHtml(t.targetGb)} GB</strong> setzen`
: `Postfachgrenze in konsoleH <strong>am ${fmtDate(t.dueAt)}</strong> auf <strong>${escapeHtml(t.targetGb)} GB</strong> senken`
const partial = t.partial
? `<br><span class="storage-partial">Teilweise: Stripe steht noch auf der bezahlten Menge, nicht auf ${escapeHtml(t.toUnits)} Paket(en) – Stückzahl in Stripe von Hand nachziehen.${t.error ? ` Fehler: ${escapeHtml(t.error)}` : ''}</span>`
: ''
return `<div class="storage-task">${action}<br><span class="muted">${escapeHtml(storageLabel(t.fromUnits))} → ${escapeHtml(storageLabel(t.toUnits))}${t.viaCancellation ? ' · über die Kündigungsseite' : ''}</span>${partial}</div>`
}
async function loadStorageTasks() {
const summary = document.getElementById('storage-summary')
const tbody = document.getElementById('storage-rows')
const failedBox = document.getElementById('storage-failed')
const failedList = document.getElementById('storage-failed-list')
try {
const d = await api('/api/portal-admin/storage-tasks')
const warn = Number(d.allocatedGb) >= Number(d.warnGb)
summary.classList.toggle('warn', warn)
summary.innerHTML = `<strong>${escapeHtml(d.allocatedGb)} von ${escapeHtml(d.packageGb)} GB vergeben</strong> (Paket ${escapeHtml(d.packageName)})${
warn ? ` – <strong>Warnschwelle ${escapeHtml(d.warnGb)} GB erreicht: größeres Hetzner-Paket buchen.</strong>` : ` · Warnung ab ${escapeHtml(d.warnGb)} GB`
}`
tbody.innerHTML = d.tasks.length
? d.tasks.map((t) => `
<tr data-storage-row="${escapeHtml(t.id)}">
<td>${storageDueHtml(t)}</td>
<td><strong>${escapeHtml(t.customerName)}</strong>${t.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(t.customerCode)}</span>` : ''}${t.email ? `<br><span class="muted">${escapeHtml(t.email).replace('@', '@<wbr>')}</span>` : ''}<br><span class="muted">${escapeHtml(t.productLabel)}</span></td>
<td>${storageTaskHtml(t)}</td>
<td>${fmtDateTime(t.createdAt)}<br><span class="muted">${escapeHtml(t.toUnits > t.fromUnits ? 'Bestellung (Plus)' : 'Verringerung (Minus)')}</span></td>
<td><button class="mini-btn primary" data-storage-done="${escapeHtml(t.id)}">Erledigt</button></td>
</tr>`).join('')
: '<tr><td colspan="5" class="muted">Keine offenen Speicher-Aufgaben.</td></tr>'
failedBox.classList.toggle('hidden', !d.failed.length)
failedList.innerHTML = d.failed.map((f) => `
<li>${fmtDateTime(f.createdAt)} · <strong>${escapeHtml(f.customerName)}</strong> · ${escapeHtml(storageLabel(f.fromUnits))} → ${escapeHtml(storageLabel(f.toUnits))}
<span class="muted">– Stripe hat die Änderung abgelehnt, nichts berechnet, keine Aufgabe.${f.error ? ` Fehler: ${escapeHtml(f.error)}` : ''}</span></li>`).join('')
tbody.querySelectorAll('[data-storage-done]').forEach((btn) => btn.addEventListener('click', () => completeStorageTask(btn.dataset.storageDone, btn)))
} catch (err) {
summary.classList.remove('warn')
summary.textContent = ''
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
}
}
async function completeStorageTask(id, btn) {
btn.disabled = true
try {
await api(`/api/portal-admin/storage-tasks/${encodeURIComponent(id)}/done`, { method: 'POST', body: '{}' })
await loadStorageTasks()
} catch (err) {
btn.disabled = false
alert('Aufgabe konnte nicht als erledigt gespeichert werden: ' + err.message)
}
}
/* ------------------------------------------------------- Abo-Angebote --- */
const OFFER_STATUS = {
offen: ['pill-warn', 'offen'],
@@ -788,8 +866,7 @@ const OFFER_STATUS = {
const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null, offers: new Map() }
// Katalogname wie im Formular ("Hosting-Abo"/"Wartungs-Abo")
const OFFER_PRODUCT_LABEL = { hosting: 'Hosting-Abo', wartung: 'Wartungs-Abo' }
const OFFER_PRODUCT_LABEL = PRODUCT_LABEL
function offerStatusPill(o) {
const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status]
@@ -876,7 +953,7 @@ async function loadOffers() {
<tr>
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerEmail ? `<br><span class="muted">${escapeHtml(o.customerEmail).replace('@', '@<wbr>')}</span>` : ''}${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
<td><span class="muted">${OFFER_PRODUCT_LABEL[o.productKey] || o.productKey}</span><br>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
<td><span class="muted">${escapeHtml(productLabelOf(o.productKey))}</span><br>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
${discount ? `<br><span class="muted">${escapeHtml(discount)}</span>` : ''}
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
@@ -1161,6 +1238,10 @@ async function initAdminPage() {
offersLoaded = true
loadOffers()
}
if (tab.dataset.tab === 'storage') {
// Bei jedem Wechsel auf den Reiter neu laden: der Stand aendert sich, sobald jemand bestellt
loadStorageTasks()
}
})
})
+3
View File
@@ -83,6 +83,9 @@ const mailStorage = {
includedGb: 5,
maxUnits: 10,
priceNickname: 'E-Mail-Zusatzspeicher 5 GB monatlich',
// Hetzner-Paket (konsoleH), auf dem alle Postfaecher liegen: Gesamtgroesse und die Schwelle, ab der
// der Admin-Reiter "E-Mail-Speicher" rot warnt (Task 13)
hetznerPackage: { name: 'Webhosting M', gb: 50, warnGb: 40 },
}
export const config = {
+19
View File
@@ -1,5 +1,7 @@
import { Router } from 'express'
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
import { PRODUCT_KEYS } from '../../services/products.js'
import { ACTIVE_STATUSES, getCurrentSubscription } from '../../services/stripeBilling.js'
import {
createCustomerWithPortalAccess,
deleteCustomerWithPortalAccess,
@@ -41,6 +43,23 @@ router.patch('/:customerId', async (req, res) => {
}
})
// Schnittstelle fuers Ticketsystem (Task 13): Produkt und Stand des Abos eines Kunden. WOMS liest daraus,
// ob der Kunde ein Wartungs-Abo hat (Arbeitszeit-Kontingent). Altbestand ohne productKey gilt als
// Wartungs-Abo (wie offers.js/takeoverCredits.js); ohne Abo productKey null.
router.get('/:customerId/abo', async (req, res) => {
try {
const sub = await getCurrentSubscription(req.params.customerId)
if (!sub) return res.json({ productKey: null, active: false })
return res.json({
productKey: PRODUCT_KEYS.includes(sub.productKey) ? sub.productKey : 'wartung',
active: ACTIVE_STATUSES.has(sub.status),
})
} catch (err) {
const status = err.status || 500
return res.status(status).json({ error: err.message || 'Abo konnte nicht geladen werden' })
}
})
router.delete('/:customerId', async (req, res) => {
try {
return res.json(await deleteCustomerWithPortalAccess(req.params.customerId))
+3
View File
@@ -27,6 +27,7 @@ import {
uploadRateLimited,
} from '../services/chatAttachments.js'
import { offersAdminRouter } from './offers.js'
import { storageTasksAdminRouter } from './storageTasks.js'
import { takeoverAdminRouter } from './takeoverCredits.js'
const router = Router()
@@ -37,6 +38,8 @@ router.use(requireAdminSession)
router.use('/takeover-credits', takeoverAdminRouter)
// Individuelle Abo-Angebote (Tab "Abo-Angebote")
router.use('/offers', offersAdminRouter)
// Zusatzspeicher-Aufgaben fuers Team (Tab "E-Mail-Speicher")
router.use('/storage-tasks', storageTasksAdminRouter)
function norm(value) {
return String(value || '').toLowerCase()
+133
View File
@@ -0,0 +1,133 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
import { completeContractEvent, getContractEvent, listContractEventsOf, listOpenStorageTasks } from '../services/contractEvents.js'
import { storageTaskKind } from '../services/mailStorage.js'
import { reconcilePendingSwitch } from '../services/planSwitch.js'
import { PRODUCT_KEYS, productLabel } from '../services/products.js'
import { ACTIVE_STATUSES } from '../services/stripeBilling.js'
/**
* Admin-Reiter "E-Mail-Speicher" (Task 13): offene Zusatzspeicher-Aufgaben fuers Team (Postfachgrenze in
* konsoleH setzen), die auf dem Hetzner-Paket vergebenen GB und die gescheiterten Aenderungen der letzten
* Wochen. Eingehaengt in routes/portalAdmin.js unter /api/portal-admin/storage-tasks, dort hinter
* requireAdminSession.
*
* Jede Aufgabe traegt ihre Art (details.kind, storageTaskKind): "einrichten" = jetzt auf das Ziel setzen,
* "periodenende" = zum Faelligkeitstag auf das Ziel setzen. Hoechstens eine offene Aufgabe je Art und Abo
* (mailStorage.js). Ein teilweise gescheitertes Plus (details.partial: Stripe steht auf der bezahlten
* Menge) bleibt eine offene Aufgabe, das Team setzt die Menge in Stripe von Hand nach (T13).
*
* Beim Laden gleicht der Reiter ausserdem offene Wechsel mit verfallener Zahlung mit Stripe ab
* (reconcilePendingSwitch): ein verlorener Stripe-Webhook faellt so auch ohne den Kunden auf.
*/
export const storageTasksAdminRouter = Router()
// Gescheiterte Aenderungen (Stripe lehnte ab, nichts berechnet): so lange sichtbar
const FAILED_VISIBLE_MS = 30 * 24 * 60 * 60 * 1000
const staffEmailOf = (req) => req.session.adminEmail || req.session.email || ''
function sendError(res, err, fallback) {
const status = err.status || 500
if (status >= 500) console.error('[speicher-aufgaben]', err.message)
return res.status(status).json({ error: status >= 500 ? fallback : err.message || fallback })
}
/** Namen und Adressen der Kunden zu den Ereignissen, ein Abruf je Kunde. */
async function customersOf(events) {
const ids = [...new Set(events.map((e) => e.customerId).filter(Boolean))]
const docs = await Promise.all(ids.map((id) => getDocument(config.collections.customers, id).catch(() => null)))
return new Map(ids.map((id, index) => [id, docs[index]]))
}
const gbOf = (units) => config.mailStorage.includedGb + config.mailStorage.gbPerUnit * (Number(units) || 0)
/** Ansicht eines Ereignisses `speicher` fuer den Reiter. */
function taskView(event, customer) {
const d = event.details || {}
const productKey = PRODUCT_KEYS.includes(event.productKey) ? event.productKey : null
return {
id: event.$id,
customerId: event.customerId,
customerName: customer?.name || customer?.companyName || customer?.code || event.customerId,
customerCode: customer?.code || '',
email: customer?.email || '',
subscriptionId: event.subscriptionId,
productKey,
productLabel: productLabel(productKey),
kind: storageTaskKind(event),
fromUnits: Number(d.fromUnits) || 0,
toUnits: Number(d.toUnits) || 0,
targetGb: gbOf(d.toUnits),
dueAt: event.dueAt || null,
createdAt: event.$createdAt || null,
status: event.status,
doneAt: event.doneAt || null,
doneBy: event.doneBy || '',
partial: Boolean(d.partial),
viaCancellation: Boolean(d.viaCancellation),
error: d.error || '',
}
}
/** Auf dem Hetzner-Paket vergebene GB: 5 GB je laufendem Abo plus 5 GB je gebuchtem Paket (F40). */
async function allocatedGb() {
const subs = await listDocuments(config.collections.subscriptions, [
Query.equal('status', [...ACTIVE_STATUSES]),
Query.limit(1000),
])
return subs.reduce((sum, sub) => sum + gbOf(sub.storageUnits), 0)
}
/** Offene Wechsel mit Stripe abgleichen; reconcilePendingSwitch prueft selbst, ob die Zahlung verfallen ist. */
async function reconcileOpenSwitches() {
try {
const open = await listContractEventsOf('wechsel', 'offen')
for (const event of open) await reconcilePendingSwitch(event)
} catch (err) {
console.error('[speicher-aufgaben] Wechsel-Abgleich beim Laden fehlgeschlagen:', err.message)
}
}
storageTasksAdminRouter.get('/', async (_req, res) => {
try {
await reconcileOpenSwitches()
const since = new Date(Date.now() - FAILED_VISIBLE_MS).toISOString()
const [open, failedAll, gb] = await Promise.all([
listOpenStorageTasks(),
listContractEventsOf('speicher', 'gescheitert'),
allocatedGb(),
])
const failed = failedAll.filter((e) => String(e.$createdAt || '') >= since)
const customers = await customersOf([...open, ...failed])
const { name, gb: packageGb, warnGb } = config.mailStorage.hetznerPackage
return res.json({
tasks: open.map((e) => taskView(e, customers.get(e.customerId))),
failed: failed.map((e) => taskView(e, customers.get(e.customerId))),
allocatedGb: gb,
packageGb,
warnGb,
packageName: name,
})
} catch (err) {
return sendError(res, err, 'Speicher-Aufgaben konnten nicht geladen werden')
}
})
storageTasksAdminRouter.post('/:id/done', async (req, res) => {
try {
const event = await getContractEvent(req.params.id).catch((err) => {
if (err.status === 404) return null
throw err
})
if (!event || event.type !== 'speicher') return res.status(404).json({ error: 'Speicher-Aufgabe nicht gefunden' })
if (event.status !== 'offen') return res.status(409).json({ error: `Diese Aufgabe ist nicht offen (Status: ${event.status})` })
await completeContractEvent(event.$id, staffEmailOf(req))
const done = await getContractEvent(event.$id)
const customer = await getDocument(config.collections.customers, done.customerId).catch(() => null)
return res.json({ task: taskView(done, customer) })
} catch (err) {
return sendError(res, err, 'Aufgabe konnte nicht als erledigt gespeichert werden')
}
})
+14
View File
@@ -154,6 +154,20 @@ export async function listOpenStorageTasks() {
return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) }))
}
/**
* Ereignisse eines Typs in einem oder mehreren Status (z. B. offene Wechsel fuer den Abgleich, gescheiterte
* Speicher-Aenderungen fuer den Admin-Reiter), neueste zuerst, mit geparstem details.
*/
export async function listContractEventsOf(type, status) {
const docs = await listDocuments(COLLECTION(), [
Query.equal('type', type),
Query.equal('status', Array.isArray(status) ? status : [status]),
Query.orderDesc('$createdAt'),
Query.limit(1000),
])
return docs.map((doc) => ({ ...doc, details: parseJson(doc.details, {}) }))
}
/** Zusatzspeicher-Aufgabe erledigt (Team hat die Postfachgroesse angepasst). */
export async function completeContractEvent(id, staffEmail) {
return updateDocument(COLLECTION(), id, {
+2
View File
@@ -739,6 +739,8 @@ export async function getAdminDetail(id) {
active: isActiveSub(subscription),
productKey: productKeyOf(subscription),
productName: getProduct(productKeyOf(subscription)).name,
// Zusatzspeicher (Pakete zu 5 GB) fuer die Zeile "Abo" im Admin (Task 13)
storageUnits: Number(subscription.storageUnits) || 0,
startedAt: subscription.startedAt,
serviceStart: serviceStartOf(subscription),
currentPeriodEnd: subscription.currentPeriodEnd,
+230
View File
@@ -0,0 +1,230 @@
import { test, after, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
import { mailSink } from './support/mailSink.js'
/**
* Task 13: Admin-Reiter "E-Mail-Speicher" (GET/POST /api/portal-admin/storage-tasks), Schnittstelle fuers
* Ticketsystem (GET /api/admin/customers/:id/abo) und der Wechsel-Abgleich beim Laden des Reiters.
*/
// 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'),
},
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { hostingDoc, hosting2Doc, wartungDoc, subscriptionDoc, consent, customerCookie, adminCookie, seedFixtures, stripeSubscription } =
await import('./support/fixtures.js')
const { default: portalAdminRoutes } = await import('../server/routes/portalAdmin.js')
const { default: adminCustomersRoutes } = await import('../server/routes/admin/customers.js')
const { listContractEvents, recordContractEvent, getContractEvent } = await import('../server/services/contractEvents.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js')
const app = await startTestApp({ '/api/portal-admin': portalAdminRoutes, '/api/admin/customers': adminCustomersRoutes })
// F25: Mails (K14 beim Wechsel-Abgleich) landen in der Senke statt bei SMTP
const mails = mailSink()
setMailTransportForTests(mails)
after(() => {
setMailTransportForTests(null)
return Promise.all([app.close(), aw.close(), st.close()])
})
const price = (id, lookup_key, unit_amount) => ({ id, object: 'price', active: true, lookup_key, unit_amount, recurring: { interval: 'month' } })
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))
st.prices.set('price_mailStorage', price('price_mailStorage', config.mailStorage.lookupKey, config.mailStorage.amountCents))
const HOUR = 3600000
const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString()
const ADMIN = { cookie: adminCookie() }
const STAFF_JWT = { headers: { Authorization: 'Bearer jwt-admin' } }
async function clearEvents() {
for (const customerId of ['c1', 'c2', 'c3']) {
for (const e of await listContractEvents(customerId)) await deleteDocument(config.collections.contractEvents, e.$id)
}
}
const storageEvent = ({ id, customerId, subscriptionId, kind, fromUnits, toUnits, dueAt, status = 'offen', extra = {} }) =>
recordContractEvent({
id,
customerId,
subscriptionId,
type: 'speicher',
productKey: 'hosting',
details: { kind, fromUnits, toUnits, effectiveAt: dueAt, totalGb: 5 + 5 * toUnits, monthlyCents: 100 * toUnits, ...extra },
consent: toUnits > fromUnits ? consent : null,
status,
dueAt,
})
/**
* Ausgangslage jedes Tests: zwei aktive Abos (sub_h von c1 ohne Pakete, sub_h2 von c2 mit 2 Paketen ->
* 5 + 15 = 20 GB vergeben), dazu eine Einrichtung fuer c2 (faellig seit einer Stunde, 0 -> 2) und eine
* Verringerung fuer c1 zum Periodenende (3 -> 1).
*/
beforeEach(async () => {
await clearEvents()
mails.sent.length = 0
seedFixtures(aw)
aw.seed('subscriptions', { ...hosting2Doc, storageUnits: 2, storagePaidUnits: 2, storagePaidPeriodEnd: hosting2Doc.currentPeriodEnd })
st.subscriptions.set('sub_h', stripeSubscription({ id: 'sub_h', customerId: 'c1', productKey: 'hosting' }))
st.subscriptions.set('sub_h2', stripeSubscription({ id: 'sub_h2', customerId: 'c2', productKey: 'hosting', storageUnits: 2 }))
await storageEvent({ id: 'speicher-einrichten-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 0, toUnits: 2, dueAt: iso(-HOUR) })
await storageEvent({ id: 'speicher-periodenende-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'periodenende', fromUnits: 3, toUnits: 1, dueAt: hostingDoc.currentPeriodEnd })
})
test('Admin: offene Speicher-Aufgaben und vergebene GB', async () => {
// zwei aktive Abos: storageUnits 0 und 2 → 5 + 15 = 20 GB
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 20)
assert.equal(r.packageGb, 50)
assert.equal(r.warnGb, 40)
assert.equal(r.packageName, 'Webhosting M')
assert.equal(r.tasks.length, 2)
// faelligste zuerst: die Einrichtung (seit einer Stunde faellig) vor der Verringerung zum Periodenende
assert.equal(r.tasks[0].toUnits, 2)
assert.equal(r.tasks[0].fromUnits, 0)
assert.equal(r.tasks[0].kind, 'einrichten')
assert.equal(r.tasks[0].customerId, 'c2')
assert.equal(r.tasks[0].customerName, 'Kunde Zwei')
assert.equal(r.tasks[0].email, 'k2@example.invalid')
assert.equal(r.tasks[0].targetGb, 15)
assert.equal(r.tasks[0].productKey, 'hosting')
assert.ok(r.tasks[0].dueAt && r.tasks[0].createdAt)
assert.equal(r.tasks[1].kind, 'periodenende')
assert.equal(r.tasks[1].toUnits, 1)
assert.equal(r.tasks[1].targetGb, 10)
assert.equal(r.tasks[1].dueAt, hostingDoc.currentPeriodEnd)
assert.equal(r.tasks[1].customerName, 'Kunde Eins')
assert.deepEqual(r.failed, [])
})
test('Admin: vergebene GB zaehlen nur laufende Abos (active, trialing, past_due)', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_alt', { ...hostingDoc, customerId: 'c3', status: 'canceled', storageUnits: 10 }))
aw.seed('subscriptions', subscriptionDoc('sub_pd', { ...wartungDoc, customerId: 'c3', status: 'past_due', storageUnits: 1 }))
try {
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.allocatedGb, 30)
} finally {
await deleteDocument(config.collections.subscriptions, 'sub_alt')
await deleteDocument(config.collections.subscriptions, 'sub_pd')
}
})
test('Admin: Aufgabe erledigen', async () => {
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
const { status, body: { task } } = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal(status, 200)
assert.equal(task.status, 'erledigt')
assert.equal(task.doneBy, 'kenso@webklar.com')
assert.ok(task.doneAt)
assert.equal(task.id, tasks[0].id)
const stored = await getContractEvent(tasks[0].id)
assert.equal(stored.status, 'erledigt')
assert.equal(stored.doneBy, 'kenso@webklar.com')
// Die Liste zeigt sie nicht mehr
const after = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.deepEqual(after.tasks.map((t) => t.id), [tasks[1].id])
})
test('Admin: Erledigt nur fuer offene Speicher-Aufgaben (404 unbekannt, 409 schon erledigt)', async () => {
assert.equal((await post('/api/portal-admin/storage-tasks/gibt-es-nicht/done', {}, ADMIN)).status, 404)
const { tasks } = await get('/api/portal-admin/storage-tasks', ADMIN)
await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
const again = await post(`/api/portal-admin/storage-tasks/${tasks[0].id}/done`, {}, ADMIN)
assert.equal(again.status, 409)
// Ein Wechsel-Ereignis ist keine Speicher-Aufgabe
await recordContractEvent({ id: 'wechsel-offen-x', customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung', details: {}, consent, status: 'offen' })
assert.equal((await post('/api/portal-admin/storage-tasks/wechsel-offen-x/done', {}, ADMIN)).status, 404)
})
test('Admin: teilweise (Stripe-Schritt 2 gescheitert) und gescheiterte Aenderungen sind sichtbar', async () => {
await storageEvent({ id: 'speicher-teilweise-c2', customerId: 'c2', subscriptionId: 'sub_h2', kind: 'einrichten', fromUnits: 2, toUnits: 5, dueAt: iso(-1000), extra: { partial: true, error: 'Stripe: card_declined' } })
await storageEvent({ id: 'speicher-gescheitert-c1', customerId: 'c1', subscriptionId: 'sub_h', kind: 'einrichten', fromUnits: 0, toUnits: 4, dueAt: iso(-2000), status: 'gescheitert', extra: { error: 'Stripe: resource_missing' } })
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
const partial = r.tasks.find((t) => t.id === 'speicher-teilweise-c2')
assert.equal(partial.partial, true)
assert.equal(partial.error, 'Stripe: card_declined')
assert.equal(r.tasks.some((t) => t.id === 'speicher-gescheitert-c1'), false, 'gescheitert ist keine offene Aufgabe')
assert.equal(r.failed.length, 1)
assert.equal(r.failed[0].id, 'speicher-gescheitert-c1')
assert.equal(r.failed[0].status, 'gescheitert')
assert.equal(r.failed[0].error, 'Stripe: resource_missing')
assert.equal(r.failed[0].customerName, 'Kunde Eins')
assert.equal(r.failed[0].toUnits, 4)
})
test('Admin: Wechsel-Abgleich beim Laden des Reiters (verlorener Stripe-Webhook)', async () => {
// Offener Kartenwechsel, dessen zurueckgehaltene Aenderung laengst verfallen ist; Stripe hat sie verworfen
await recordContractEvent({
id: 'wechsel-abgelaufen-c1',
customerId: 'c1',
subscriptionId: 'sub_h',
type: 'wechsel',
productKey: 'wartung',
details: { switchedAt: iso(-30 * HOUR), pendingExpiresAt: iso(-60000), from: 'hosting', to: 'wartung' },
consent,
status: 'offen',
})
const r = await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal(r.tasks.length, 2)
const ev = await getContractEvent('wechsel-abgelaufen-c1')
assert.equal(ev.status, 'gescheitert')
assert.equal(ev.doneBy, 'abgleich')
})
test('Admin: Wechsel-Abgleich laesst einen noch laufenden Wechsel in Ruhe', async () => {
await recordContractEvent({
id: 'wechsel-laufend-c1',
customerId: 'c1',
subscriptionId: 'sub_h',
type: 'wechsel',
productKey: 'wartung',
details: { switchedAt: iso(-HOUR), pendingExpiresAt: iso(22 * HOUR) },
consent,
status: 'offen',
})
const before = st.calls.length
await get('/api/portal-admin/storage-tasks', ADMIN)
assert.equal((await getContractEvent('wechsel-laufend-c1')).status, 'offen')
assert.equal(st.calls.length, before, 'kein Stripe-Abruf fuer einen nicht abgelaufenen Wechsel')
})
test('Ohne Admin-Session 401', async () => {
assert.equal((await rawGet('/api/portal-admin/storage-tasks', { cookie: customerCookie() })).status, 401)
assert.equal((await rawGet('/api/portal-admin/storage-tasks')).status, 401)
assert.equal((await post('/api/portal-admin/storage-tasks/speicher-einrichten-c2/done', {}, { cookie: customerCookie() })).status, 401)
assert.equal((await getContractEvent('speicher-einrichten-c2')).status, 'offen')
})
test('Ticketsystem-Schnittstelle: Produkt und aktiv', async () => {
const r = await get('/api/admin/customers/c1/abo', STAFF_JWT)
assert.deepEqual(r, { productKey: 'hosting', active: true })
assert.equal((await rawGet('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-kunde' } })).status, 403)
assert.equal((await rawGet('/api/admin/customers/c1/abo')).status, 401)
assert.equal((await rawGet('/api/admin/customers/c1/abo', { headers: { Authorization: 'Bearer jwt-fremd' } })).status, 401)
})
test('Ticketsystem-Schnittstelle: Wartungs-Abo, gekuendigtes Abo, kein Abo', async () => {
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))
assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'wartung', active: true })
// Altbestand ohne productKey gilt als Wartungs-Abo
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, productKey: null }))
assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'wartung', active: true })
// Beendetes Abo: Produkt bekannt, aber nicht aktiv
aw.seed('subscriptions', subscriptionDoc('sub_h', { ...hostingDoc, status: 'canceled' }))
assert.deepEqual(await get('/api/admin/customers/c1/abo', STAFF_JWT), { productKey: 'hosting', active: false })
// Kein Abo
assert.deepEqual(await get('/api/admin/customers/c3/abo', STAFF_JWT), { productKey: null, active: false })
})
+8
View File
@@ -93,6 +93,14 @@ export async function startAppwriteMock({ schema = {} } = {}) {
const docPath = url.pathname.match(/^\/v1\/databases\/[^/]+\/collections\/([^/]+)\/documents(?:\/([^/]+))?$/)
const userPath = url.pathname.match(/^\/v1\/users\/([^/]+)$/)
if (userPath && req.method === 'GET') return [200, { $id: userPath[1], passwordUpdate: '' }]
// GET /v1/account (requireAppwriteStaff, Ticketsystem-JWT): jwt-admin = Mitarbeiter mit Label admin,
// jwt-kunde = angemeldet ohne Label, alles andere 401 wie Appwrite bei einem ungueltigen JWT
if (url.pathname === '/v1/account' && req.method === 'GET') {
const jwt = req.headers['x-appwrite-jwt']
if (jwt === 'jwt-admin') return [200, { $id: 'u_admin', email: 'kenso@webklar.com', labels: ['admin'] }]
if (jwt === 'jwt-kunde') return [200, { $id: 'u_admin', email: 'kenso@webklar.com', labels: [] }]
return [401, { message: 'User (role: guests) missing scope (account)', code: 401, type: 'general_unauthorized_scope' }]
}
if (!docPath) return [404, { message: 'Route not found', code: 404 }]
const [, collection, id] = docPath