Files
Webklar-Kundenbereich/server/routes/customerData.js
T
ClaudeandClaude Opus 5.5 ff7fe35c06 Rechnungen: Stripe-Rechnungen im Kundenportal anzeigen (Abo, Funktionen, Projektrechnungen)
Der Tab "Rechnungen" las nur InvoiceNinja. Seit dem Finanzen-Livegang laufen Rechnungen
aber ueber Stripe; die bezahlte Abo-Rechnung eines Kunden fehlte deshalb im Portal.

- /api/invoices fuehrt Stripe (alle Stripe-Kunden des Kunden: Abo-Spiegel und Suche nach
  metadata.webklarCustomerId) und InvoiceNinja zusammen, neueste zuerst; Entwuerfe und
  0-EUR-Rechnungen bleiben verborgen. Faellt eine Quelle aus, kommt der Rest mit incomplete.
- /api/invoices/:id/pdf laedt Stripe-PDFs frisch ueber den Server, nur fuer eigene Rechnungen
  (sonst 404) und nur von *.stripe.com.
- Seite: Status-Farben fuer Bezahlt/Offen/Ueberfaellig/Storniert, "Ansehen" statt
  "Ansehen / Zahlen" bei bezahlten Rechnungen, Hinweis bei unvollstaendiger Liste; der
  Abo-Hinweis verweist nur noch fuer die Zahlungsart auf "Abo verwalten & Rechnungen".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 09:33:22 +00:00

141 lines
6.1 KiB
JavaScript

import { Router } from 'express'
import { createAvatar } from '@dicebear/core'
import * as bigSmile from '@dicebear/big-smile'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
import { getCustomerActivity } from '../services/customerActivity.js'
import { customerOrder, customerWorkEntries } from '../services/customerProjectView.js'
import { CustomerInvoiceError, TRY_LATER, isStripeInvoiceId, listCustomerInvoices, stripeInvoicePdf } from '../services/customerInvoices.js'
import { fetchInvoicePdf, getInvoiceRaw } from '../services/invoiceNinja.js'
const router = Router()
/**
* Rechnungen des eingeloggten Kunden: Stripe (Abo, Funktionen, Projektrechnungen aus dem WOMS) und InvoiceNinja
* (Altbestand, sofern verknuepft), neueste zuerst. incomplete: eine der Quellen hat nicht geantwortet.
*/
router.get('/invoices', requireSession, async (req, res) => {
try {
const customer = await getDocument(
config.collections.customers,
getSessionCustomerId(req)
).catch(() => null)
if (!customer) {
return res.status(404).json({ error: 'Kunde nicht gefunden' })
}
return res.json(await listCustomerInvoices(customer))
} catch (err) {
if (err instanceof CustomerInvoiceError) return res.status(err.status).json({ error: err.message })
console.error('[rechnungen] Liste fehlgeschlagen:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
return res.status(500).json({ error: TRY_LATER })
}
})
/** PDF an den Browser (Download), nie zwischengespeichert. */
function sendPdf(res, pdf, filename) {
res.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
})
return res.end(pdf)
}
/** Rechnungs-PDF des eingeloggten Kunden (Download ueber Server-Proxy; InvoiceNinja oder Stripe). */
router.get('/invoices/:invoiceId/pdf', requireSession, async (req, res) => {
// Stripe-Rechnung (in_...): Besitz ueber die Stripe-Kunden des Kunden, PDF frisch von Stripe
if (isStripeInvoiceId(req.params.invoiceId)) {
try {
const { pdf, filename } = await stripeInvoicePdf(getSessionCustomerId(req), req.params.invoiceId)
return sendPdf(res, pdf, filename)
} catch (err) {
if (err instanceof CustomerInvoiceError) return res.status(err.status).json({ error: err.message })
console.error('[rechnungen] PDF-Abruf fehlgeschlagen:', err?.type || err?.name || 'Error', err?.statusCode ?? err?.status ?? '')
return res.status(500).json({ error: 'PDF konnte nicht geladen werden' })
}
}
try {
const customer = await getDocument(
config.collections.customers,
getSessionCustomerId(req)
).catch(() => null)
if (!customer?.invoiceNinjaClientId) {
return res.status(404).json({ error: 'Rechnung nicht gefunden' })
}
const invoice = await getInvoiceRaw(req.params.invoiceId).catch(() => null)
// Ownership-Pruefung: Rechnung muss zum InvoiceNinja-Client des Kunden gehoeren
if (!invoice || invoice.client_id !== customer.invoiceNinjaClientId) {
return res.status(404).json({ error: 'Rechnung nicht gefunden' })
}
const invitationKey = invoice.invitations?.[0]?.key
if (!invitationKey) {
return res.status(404).json({ error: 'Kein PDF verfuegbar' })
}
const pdf = await fetchInvoicePdf(invitationKey)
return sendPdf(res, pdf, `Rechnung-${invoice.number || invoice.id}.pdf`)
} catch (err) {
return res.status(err.status || 500).json({ error: err.message || 'PDF konnte nicht geladen werden' })
}
})
/** Ticket-/Arbeitshistorie des eingeloggten Kunden ("was wurde gemacht"). */
router.get('/activity', requireSession, async (req, res) => {
try {
const tickets = await getCustomerActivity(getSessionCustomerId(req), {
ticketLimit: 25,
// Interne Akquise-Tickets gehoeren nicht in die Kundenansicht
excludeTypes: ['Akquise'],
})
// Status auf Deutsch, Arbeiten ohne Git-Technik (customerProjectView.js)
const orders = tickets
.map((t) => ({ order: customerOrder(t), createdAt: t.createdAt, entries: customerWorkEntries(t.worksheets) }))
.filter((t) => t.order)
.map(({ order, createdAt, entries }) => ({ ...order, createdAt, entries }))
return res.json({ tickets: orders })
} catch (err) {
return res.status(500).json({ error: err.message || 'Aktivität konnte nicht geladen werden' })
}
})
/**
* "Dein Ansprechpartner" in der Seitenleiste: der Mitarbeiter, dem das juengste zugewiesene
* Ticket des Kunden gehoert (workorders.assignedTo -> employees). Sonst das Team.
*/
router.get('/contact', requireSession, async (req, res) => {
const fallback = { name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' }
try {
const [tickets, employees] = await Promise.all([
listDocuments(config.collections.workorders, [
Query.equal('customerId', getSessionCustomerId(req)),
Query.orderDesc('$createdAt'),
Query.limit(50),
]),
listDocuments(config.collections.employees, [Query.limit(100)]),
])
const nameOf = new Map(employees.flatMap((e) => [[e.userId, e.displayName], [e.$id, e.displayName]]))
const assignee = (t) => nameOf.get(String(t.assignedTo || '')) || t.assignedName || ''
const ticket = tickets.find((t) => assignee(t))
return res.json({ ...fallback, name: ticket ? assignee(ticket) : fallback.name })
} catch {
return res.json(fallback)
}
})
/** Profilbild: DiceBear "Big Smile", lokal erzeugt (keine Anfrage an dicebear.com).
Die Kunden-ID ist der Seed - zufaellig pro Kunde, aber bei jedem Login dasselbe Bild. */
router.get('/avatar.svg', requireSession, (req, res) => {
const svg = createAvatar(bigSmile, {
seed: String(getSessionCustomerId(req) || 'webklar'),
backgroundColor: ['b6e3f4', 'c0aede', 'd1d4f9', 'ffd5dc', 'ffdfbf'],
}).toString()
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8')
res.setHeader('Cache-Control', 'private, max-age=86400')
return res.send(svg)
})
export default router