Phase3: Billing Customerpage/Mailings
Some checks failed
Build and Push / build (push) Failing after 46s
Some checks failed
Build and Push / build (push) Failing after 46s
This commit is contained in:
@@ -61,6 +61,7 @@ import { listTenants } from "./k8s";
|
||||
import { getTeamSpendLogsV2 } from "./litellm";
|
||||
import { getUsage as getThreemaUsage } from "./threema-relay";
|
||||
import { renderInvoicePdf } from "./billing-pdf";
|
||||
import { sendInvoiceIssuedEmail } from "./email";
|
||||
import { formatLineDescription } from "./billing-i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -779,6 +780,50 @@ export async function generateInvoice(opts: {
|
||||
// Pass 2: store the PDF bytes.
|
||||
await updateInvoicePdf(placeholder.id, pdfBuffer, filename);
|
||||
const finalInvoice = await getInvoiceById(placeholder.id);
|
||||
|
||||
// Phase 3: best-effort notification to the billing contact.
|
||||
// We send AFTER the PDF is fully persisted (so the deep link
|
||||
// in the email immediately resolves to a downloadable PDF) but
|
||||
// BEFORE returning, since the cron caller doesn't otherwise
|
||||
// know to trigger this. Failure is logged, never thrown — a
|
||||
// mail-server hiccup must not roll back an issued invoice.
|
||||
// The recipient is the billing email captured in the invoice
|
||||
// snapshot (immutable; reflects who was on file at issue time).
|
||||
try {
|
||||
const settled = finalInvoice ?? placeholder;
|
||||
const snapshot = settled.billingSnapshot;
|
||||
if (snapshot.billingEmail) {
|
||||
const supportedLocales: Array<"en" | "de" | "fr" | "it"> = [
|
||||
"en", "de", "fr", "it",
|
||||
];
|
||||
const locale = supportedLocales.includes(settled.locale as any)
|
||||
? (settled.locale as "en" | "de" | "fr" | "it")
|
||||
: "de";
|
||||
await sendInvoiceIssuedEmail({
|
||||
to: snapshot.billingEmail,
|
||||
contactName: snapshot.companyName, // no separate contact-name field
|
||||
companyName: snapshot.companyName,
|
||||
invoiceNumber: settled.invoiceNumber,
|
||||
totalChf: settled.totalChf,
|
||||
currency: "CHF",
|
||||
dueAt: settled.dueAt,
|
||||
lineCount: draft.lines.length,
|
||||
periodStart: settled.periodStart,
|
||||
periodEnd: settled.periodEnd,
|
||||
locale,
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
`Invoice ${settled.invoiceNumber} issued but billing snapshot has no email — notification skipped.`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Invoice ${placeholder.invoiceNumber} issued; notification email failed:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
return { draft, invoice: finalInvoice ?? placeholder };
|
||||
} catch (e) {
|
||||
// Render failed — leave the persisted row in place so admin can
|
||||
|
||||
@@ -2407,6 +2407,38 @@ export async function getInvoiceDetail(
|
||||
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3 — customer-scoped lookup by human-readable invoice
|
||||
* number with ownership enforcement in a single query. The org
|
||||
* filter is part of the WHERE clause so a customer can't probe
|
||||
* another org's invoice numbers (which are sequential and easy
|
||||
* to guess) and get a different status code (404 vs 403) than
|
||||
* for their own — both miss-and-not-yours return null.
|
||||
*
|
||||
* Used by /api/billing/invoices/[invoiceNumber] and the
|
||||
* /billing/[invoiceNumber] customer page.
|
||||
*/
|
||||
export async function getInvoiceByNumberForOrg(
|
||||
invoiceNumber: string,
|
||||
zitadelOrgId: string
|
||||
): Promise<InvoiceDetail | null> {
|
||||
await ensureSchema();
|
||||
const head = await getPool().query(
|
||||
`SELECT ${INVOICE_LIST_COLUMNS} FROM invoices
|
||||
WHERE invoice_number = $1 AND zitadel_org_id = $2
|
||||
LIMIT 1`,
|
||||
[invoiceNumber, zitadelOrgId]
|
||||
);
|
||||
if (head.rows.length === 0) return null;
|
||||
const invoice = rowToInvoice(head.rows[0]);
|
||||
const lines = await getPool().query(
|
||||
`SELECT * FROM invoice_lines WHERE invoice_id = $1
|
||||
ORDER BY display_order, id`,
|
||||
[invoice.id]
|
||||
);
|
||||
return { invoice, lines: lines.rows.map(rowToInvoiceLine) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the PDF bytes for an invoice. Returns null if no PDF was
|
||||
* stored (shouldn't happen in v1; defensive against partial state).
|
||||
|
||||
114
src/lib/email.ts
114
src/lib/email.ts
@@ -900,3 +900,117 @@ export async function sendSkillActivationRejectionEmail(params: {
|
||||
console.error("Failed to send skill activation rejection email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invoice issuance — Phase 3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Notify the billing contact when a new invoice has been issued.
|
||||
* Includes a brief summary (total + due date + line count) so the
|
||||
* recipient can triage without opening the portal, plus a deep
|
||||
* link to /billing/<invoice number> where they can download the
|
||||
* PDF. The PDF itself is NOT attached — it lives in the portal,
|
||||
* keeps mail payloads small, and avoids the audit-trail headache
|
||||
* of "which copy is authoritative".
|
||||
*/
|
||||
export async function sendInvoiceIssuedEmail(params: {
|
||||
to: string;
|
||||
contactName: string;
|
||||
companyName: string;
|
||||
invoiceNumber: string;
|
||||
totalChf: number;
|
||||
currency: string; // "CHF" — passed for future-proofing
|
||||
dueAt: string; // ISO date
|
||||
lineCount: number;
|
||||
periodStart: string; // ISO date
|
||||
periodEnd: string; // ISO date
|
||||
locale: "de" | "en" | "fr" | "it";
|
||||
}): Promise<void> {
|
||||
// All four locales — the email is sent in the invoice's locale,
|
||||
// which was frozen at issue time. No fallback to admin's locale.
|
||||
const L = params.locale;
|
||||
const subjectsByLocale: Record<typeof L, string> = {
|
||||
en: `New invoice ${params.invoiceNumber} from PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||
de: `Neue Rechnung ${params.invoiceNumber} von PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||
fr: `Nouvelle facture ${params.invoiceNumber} de PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||
it: `Nuova fattura ${params.invoiceNumber} da PieCed IT — ${params.currency} ${params.totalChf.toFixed(2)}`,
|
||||
};
|
||||
const greetingsByLocale: Record<typeof L, string> = {
|
||||
en: `Hello ${params.contactName},`,
|
||||
de: `Sehr geehrte/r ${params.contactName},`,
|
||||
fr: `Bonjour ${params.contactName},`,
|
||||
it: `Gentile ${params.contactName},`,
|
||||
};
|
||||
const introByLocale: Record<typeof L, string> = {
|
||||
en: `A new invoice has been issued for ${params.companyName}.`,
|
||||
de: `Für ${params.companyName} wurde eine neue Rechnung ausgestellt.`,
|
||||
fr: `Une nouvelle facture a été émise pour ${params.companyName}.`,
|
||||
it: `È stata emessa una nuova fattura per ${params.companyName}.`,
|
||||
};
|
||||
const labels: Record<typeof L, Record<string, string>> = {
|
||||
en: { number: "Invoice", period: "Period", total: "Total", due: "Due by", lines: "Line items", cta: "View invoice & download PDF", signoff: "Best regards", brand: "PieCed IT" },
|
||||
de: { number: "Rechnung", period: "Zeitraum", total: "Gesamt", due: "Zahlbar bis", lines: "Positionen", cta: "Rechnung ansehen & PDF herunterladen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT" },
|
||||
fr: { number: "Facture", period: "Période", total: "Total", due: "À régler avant", lines: "Lignes", cta: "Voir la facture & télécharger le PDF", signoff: "Cordialement", brand: "PieCed IT" },
|
||||
it: { number: "Fattura", period: "Periodo", total: "Totale", due: "Scadenza", lines: "Voci", cta: "Visualizza fattura & scarica PDF", signoff: "Cordiali saluti", brand: "PieCed IT" },
|
||||
};
|
||||
const l = labels[L];
|
||||
|
||||
const safeName = escapeHtml(params.contactName);
|
||||
const safeCompany = escapeHtml(params.companyName);
|
||||
const safeNumber = escapeHtml(params.invoiceNumber);
|
||||
const totalFmt = `${params.currency} ${params.totalChf.toFixed(2)}`;
|
||||
const periodFmt = `${params.periodStart.slice(0, 10)} → ${params.periodEnd.slice(0, 10)}`;
|
||||
const dueFmt = params.dueAt.slice(0, 10);
|
||||
|
||||
// Both bodies built in the invoice's locale.
|
||||
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
|
||||
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: getFrom(),
|
||||
to: params.to,
|
||||
subject: subjectsByLocale[L],
|
||||
text: [
|
||||
greetingsByLocale[L],
|
||||
"",
|
||||
introByLocale[L],
|
||||
"",
|
||||
`${l.number}: ${params.invoiceNumber}`,
|
||||
`${l.period}: ${periodFmt}`,
|
||||
`${l.total}: ${totalFmt}`,
|
||||
`${l.due}: ${dueFmt}`,
|
||||
`${l.lines}: ${params.lineCount}`,
|
||||
"",
|
||||
`${l.cta}:`,
|
||||
link,
|
||||
"",
|
||||
`${l.signoff},`,
|
||||
l.brand,
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 560px; padding: 24px; background: #1a1a1a; color: #e5e5e5;">
|
||||
<h2 style="margin: 0 0 16px; color: #10B981;">${escapeHtml(introByLocale[L])}</h2>
|
||||
<p>${escapeHtml(greetingsByLocale[L])}</p>
|
||||
<p>${escapeHtml(introByLocale[L])}</p>
|
||||
<table style="width:100%; border-collapse:collapse; margin:16px 0; font-size:14px;">
|
||||
<tr><td style="color:#888; padding:6px 0; width:120px;">${l.number}</td><td><strong>${safeNumber}</strong></td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.period}</td><td>${escapeHtml(periodFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.total}</td><td style="color:#10B981; font-weight:600;">${escapeHtml(totalFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.due}</td><td>${escapeHtml(dueFmt)}</td></tr>
|
||||
<tr><td style="color:#888; padding:6px 0;">${l.lines}</td><td>${params.lineCount}</td></tr>
|
||||
</table>
|
||||
<p>
|
||||
<a href="${link}" style="display:inline-block; padding:10px 24px; background:#10B981; color:#fff; text-decoration:none; border-radius:8px; font-weight:500;">
|
||||
${l.cta}
|
||||
</a>
|
||||
</p>
|
||||
<hr style="border:none; border-top:1px solid #333; margin:24px 0;" />
|
||||
<p style="color:#666; font-size:12px;">${l.brand}</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send invoice issued email:", err);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user