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>
This commit is contained in:
1 parent
dbabbf1c0b
commit
48b13f4488
14 files changed
+2040
-9
No files matched your search
@@ -17,11 +17,15 @@ import { listAppointments, saveAppointment } from '../services/appointments.js'
|
||||
import { getCustomerActivity } from '../services/customerActivity.js'
|
||||
import { listInvoicesForClient } from '../services/invoiceNinja.js'
|
||||
import { sanitizeMessage } from './chat.js'
|
||||
import { takeoverAdminRouter } from './takeoverCredits.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.use(requireAdminSession)
|
||||
|
||||
// Uebernahmegutschriften: Pruefung und Entscheidung (Tab "Gutschriften")
|
||||
router.use('/takeover-credits', takeoverAdminRouter)
|
||||
|
||||
function norm(value) {
|
||||
return String(value || '').toLowerCase()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
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')
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user