- 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>
225 lines
8.3 KiB
JavaScript
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')
|
|
}
|
|
})
|