Files
Webklar-Kundenbereich/server/routes/takeoverCredits.js
T
ClaudeandClaude Opus 5.5 48b13f4488 Abo: Uebernahmegutschrift fuer beim Altanbieter bezahlte Leistungen
- Kunde (Dashboard, nur mit laufendem Abo): Antrag mit Leistung, Anbieter,
  Domain, Rechnungsbetrag/-zeitraum, Grund, Rechnungsnachweis (PDF/JPG/PNG)
  und E-Mail fuer die Antwort; Eingangsbestaetigung per Mail, Status und
  Antwort auf Rueckfragen im Portal
- Admin-Mail mit Link admin.html?gutschrift=<id> (Login leitet zurueck)
- Admin-Tab "Gutschriften": Kunde und Vertrag, Nachweise, Verlauf,
  automatische Pruefungen (Duplikate per Datei-Hash/Domain, Abo, Zeitraum,
  DNS-Abgleich Nameserver/MX mit Anbieter), Pflicht-Checkliste, Rechner
  (tagesgenau anteilig), Genehmigen/Rueckfrage/Ablehnen mit Kundenmail
- Genehmigte Gutschrift als Stripe-Kundenguthaben (idempotent, Nachbuchen
  bei Stripe-Stoerung)
- Nachweise in upload-data/ (gitignored), Loeschung abgelehnter nach 6 Monaten
- Stundenkontingent auch fuer Monate, die ein Guthaben komplett deckt
  (invoice.total statt amount_paid)
- Datenschutzerklaerung Abschnitt 7 ergaenzt, PDF neu erzeugt
- Collection takeoverCredits in setup-abo-collections.mjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:34:33 +00:00

225 lines
8.3 KiB
JavaScript

import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
import {
answerQuestion,
applyTakeoverCredit,
calculationText,
computeTakeoverCredit,
createTakeoverRequest,
customerView,
decideTakeoverRequest,
getAdminDetail,
getTakeoverRequest,
listTakeoverRequests,
parseUploads,
readInvoiceFigures,
readUpload,
servicesLabel,
shortRef,
STATUS,
validateRequestInput,
} from '../services/takeoverCredits.js'
/**
* Uebernahmegutschrift.
* Kunde: /api/abo/takeover-credits (Antrag stellen, Rueckfrage beantworten)
* Admin: /api/portal-admin/takeover-credits (Liste, Pruefung, Entscheidung, Nachweise)
* Der JSON-Body darf hier groesser sein (Nachweise als Base64), siehe server/index.js.
*/
// Missbrauchsschutz: je Kunde hoechstens 20 Versuche pro Stunde (zusaetzlich max. 3 offene Antraege)
const recentByCustomer = new Map()
function rateLimited(customerId) {
const now = Date.now()
const list = (recentByCustomer.get(customerId) || []).filter((t) => now - t < 60 * 60 * 1000)
list.push(now)
recentByCustomer.set(customerId, list)
return list.length > 20
}
function sendError(res, err, fallback) {
const status = err.status || 500
if (status >= 500) console.error('[takeover]', fallback, err.message)
return res.status(status).json({ error: err.message || fallback })
}
// ---------------------------------------------------------------------------
// Kunde (eingeloggt)
// ---------------------------------------------------------------------------
const router = Router()
router.use(requireSession)
router.get('/', async (req, res) => {
try {
const docs = await listTakeoverRequests({ customerId: getSessionCustomerId(req) })
return res.json({
requests: docs.map(customerView),
conditionsVersion: config.takeover.conditionsVersion,
limits: {
maxFiles: config.takeover.maxFiles,
maxFileBytes: config.takeover.maxFileBytes,
maxTotalBytes: config.takeover.maxTotalBytes,
},
})
} catch (err) {
return sendError(res, err, 'Anträge konnten nicht geladen werden')
}
})
router.post('/', async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann kein Antrag gestellt werden.' })
}
const customerId = getSessionCustomerId(req)
if (rateLimited(customerId)) {
return res.status(429).json({ error: 'Zu viele Anträge in kurzer Zeit. Bitte später erneut versuchen.' })
}
try {
const input = validateRequestInput(req.body || {})
const uploads = parseUploads(req.body.files, { required: true })
const customer = await getDocument(config.collections.customers, customerId)
const doc = await createTakeoverRequest({ customer, input, uploads, consentIp: req.ip })
return res.status(201).json({ request: customerView(doc) })
} catch (err) {
return sendError(res, err, 'Antrag konnte nicht gespeichert werden')
}
})
router.post('/:id/reply', async (req, res) => {
if (req.session.viewAs) {
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus nicht möglich.' })
}
const customerId = getSessionCustomerId(req)
if (rateLimited(customerId)) {
return res.status(429).json({ error: 'Zu viele Anfragen in kurzer Zeit. Bitte später erneut versuchen.' })
}
try {
const doc = await getTakeoverRequest(req.params.id).catch(() => null)
if (!doc || doc.customerId !== customerId) return res.status(404).json({ error: 'Antrag nicht gefunden' })
const uploads = parseUploads(req.body?.files, { required: false })
const customer = await getDocument(config.collections.customers, customerId)
const updated = await answerQuestion({ customer, doc, message: req.body?.message, uploads })
return res.json({ request: customerView(updated) })
} catch (err) {
return sendError(res, err, 'Antwort konnte nicht gespeichert werden')
}
})
export default router
// ---------------------------------------------------------------------------
// Admin (eingehaengt in routes/portalAdmin.js, dort hinter requireAdminSession)
// ---------------------------------------------------------------------------
export const takeoverAdminRouter = Router()
takeoverAdminRouter.get('/', async (req, res) => {
try {
const [docs, customers] = await Promise.all([
listTakeoverRequests(),
listDocuments(config.collections.customers, [Query.limit(1000)]),
])
const byId = new Map(customers.map((c) => [c.$id, c]))
const onlyOpen = req.query.status !== 'all'
const requests = docs
.filter((d) => !onlyOpen || d.status === STATUS.open || d.status === STATUS.question)
.map((d) => {
const c = byId.get(d.customerId)
return {
id: d.$id,
ref: shortRef(d.$id),
createdAt: d.$createdAt,
customerId: d.customerId,
customerName: c?.name || c?.companyName || d.email,
customerCode: c?.code || '',
services: servicesLabel(d.services),
provider: d.provider || '',
domain: d.domain || '',
invoiceAmountCents: d.invoiceAmountCents || 0,
billingMonths: d.billingMonths || 0,
paidUntil: d.paidUntil || null,
status: d.status,
amountCents: d.amountCents ?? null,
appliedAt: d.appliedAt || null,
}
})
return res.json({ requests, openCount: docs.filter((d) => d.status === STATUS.open).length })
} catch (err) {
return sendError(res, err, 'Anträge konnten nicht geladen werden')
}
})
/** Rechner im Pruefformular: dieselbe Berechnung wie beim Genehmigen (eine Quelle der Wahrheit). */
takeoverAdminRouter.post('/calc', (req, res) => {
try {
const figures = readInvoiceFigures(req.body, {})
const calc = computeTakeoverCredit({ ...figures, takeoverFrom: req.body?.takeoverFrom })
if (!calc) return res.status(400).json({ error: 'Bitte das Datum „Übernahme ab“ angeben.' })
return res.json({ calc, text: calculationText(figures, calc, calc.suggestedCents) })
} catch (err) {
return sendError(res, err, 'Berechnung fehlgeschlagen')
}
})
takeoverAdminRouter.get('/open-count', async (_req, res) => {
try {
const docs = await listTakeoverRequests()
return res.json({ open: docs.filter((d) => d.status === STATUS.open).length })
} catch {
return res.json({ open: 0 })
}
})
takeoverAdminRouter.get('/:id', async (req, res) => {
try {
return res.json(await getAdminDetail(req.params.id))
} catch (err) {
return sendError(res, err, 'Antrag konnte nicht geladen werden')
}
})
takeoverAdminRouter.get('/:id/files/:fileId', async (req, res) => {
try {
const doc = await getTakeoverRequest(req.params.id)
const { file, content } = await readUpload(doc, req.params.fileId)
res.setHeader('Content-Type', file.type)
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`)
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Cache-Control', 'private, no-store')
return res.send(content)
} catch (err) {
return res.status(err.status || 500).send(err.message || 'Datei nicht verfügbar')
}
})
takeoverAdminRouter.post('/:id/decision', async (req, res) => {
try {
const admin = { name: req.session.adminName || req.session.name, email: req.session.adminEmail || req.session.email }
const result = await decideTakeoverRequest(req.params.id, req.body || {}, admin)
return res.json({
success: true,
status: result.doc.status,
applied: result.applied ?? null,
reason: result.reason || '',
mailSent: result.mail?.sent ?? false,
mailError: result.mail?.reason || '',
})
} catch (err) {
return sendError(res, err, 'Entscheidung konnte nicht gespeichert werden')
}
})
/** Genehmigte, aber noch nicht verrechnete Gutschrift erneut an Stripe senden. */
takeoverAdminRouter.post('/:id/apply', async (req, res) => {
try {
const doc = await getTakeoverRequest(req.params.id)
const result = await applyTakeoverCredit(doc)
return res.json({ success: result.applied, applied: result.applied, reason: result.reason || '' })
} catch (err) {
return sendError(res, err, 'Verrechnung fehlgeschlagen')
}
})