feat(admin): CSV-Export der Kunden für die Buchhaltung

GET /admin/export/accounts.csv — eine Zeile pro Kunde (Profil + aktuelles
Abo + Plan-Preis + Instanzen). Semikolon-getrennt, UTF-8 BOM (Excel-CH),
Content-Disposition mit Datum. Nur mit Admin-Token (401 sonst).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:15:56 +02:00
parent 4d45cdcba3
commit 6471221dec
+39
View File
@@ -99,6 +99,45 @@ adminRouter.get("/accounts/:id", async (req, res) => {
res.json({ account, subscriptions, instances }); res.json({ account, subscriptions, instances });
}); });
// — CSV-Export für die Buchhaltung —
// Eine Zeile pro Kunde mit aktuellem Abo + Plan-Preis. Semikolon-getrennt
// (Excel-CH-freundlich), UTF-8 BOM, damit Umlaute korrekt erscheinen.
function csvCell(v) {
const s = v == null ? "" : String(v);
return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
adminRouter.get("/export/accounts.csv", async (_req, res) => {
const { rows } = await query(`
select a.email, a.company, a.contact_name, a.street, a.zip, a.city, a.country, a.created_at,
s.plan, s.status as sub_status, s.current_period_end,
coalesce(i.cnt, 0)::int as instance_count
from accounts a
left join lateral (
select plan, status, current_period_end from subscriptions
where account_id = a.id order by created_at desc limit 1
) s on true
left join (select account_id, count(*) cnt from instances group by account_id) i
on i.account_id = a.id
order by a.created_at
`);
const header = ["Email","Firma","Ansprechperson","Strasse","PLZ","Ort","Land",
"Kunde_seit","Plan","Abo_Status","Periode_bis","Preis_CHF_mtl","Instanzen"];
const lines = [header.join(";")];
for (const r of rows) {
const price = getPlan(r.plan)?.priceChf ?? "";
lines.push([
r.email, r.company, r.contact_name, r.street, r.zip, r.city, r.country,
r.created_at ? new Date(r.created_at).toISOString().slice(0, 10) : "",
r.plan || "", r.sub_status || "", r.current_period_end ? new Date(r.current_period_end).toISOString().slice(0, 10) : "",
r.sub_status === "active" ? price : "", r.instance_count,
].map(csvCell).join(";"));
}
const csv = "" + lines.join("\r\n");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="rapport-kunden-${new Date().toISOString().slice(0,10)}.csv"`);
res.send(csv);
});
// — Instanzen eines Kontos (Kurzform) — // — Instanzen eines Kontos (Kurzform) —
adminRouter.get("/accounts/:id/instances", async (req, res) => { adminRouter.get("/accounts/:id/instances", async (req, res) => {
const { rows } = await query( const { rows } = await query(