From 6471221deca8c9d68171afe0fa70518105b4b245 Mon Sep 17 00:00:00 2001 From: karim Date: Sun, 31 May 2026 12:15:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20CSV-Export=20der=20Kunden=20f?= =?UTF-8?q?=C3=BCr=20die=20Buchhaltung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/routes/admin.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/server/routes/admin.js b/server/routes/admin.js index 501c405..82ecb20 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -99,6 +99,45 @@ adminRouter.get("/accounts/:id", async (req, res) => { 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) — adminRouter.get("/accounts/:id/instances", async (req, res) => { const { rows } = await query(