Phase5: Automate bill creation
All checks were successful
Build and Push / build (push) Successful in 1m43s

This commit is contained in:
2026-05-25 10:41:51 +02:00
parent 6a8ad7b4be
commit 427c7c6204
16 changed files with 1343 additions and 4 deletions

View File

@@ -1014,3 +1014,147 @@ export async function sendInvoiceIssuedEmail(params: {
console.error("Failed to send invoice issued email:", err);
}
}
// ---------------------------------------------------------------------------
// Reminder emails — Phase 5
// ---------------------------------------------------------------------------
/**
* Send a payment reminder for an open/overdue invoice.
*
* Three escalation levels:
* 1 — Gentle nudge: ~7 days past due. Friendly tone, "in case
* you missed it".
* 2 — Firmer reminder: ~14 days past due. Clear that payment is
* outstanding, please pay.
* 3 — Final notice: ~30 days past due. Explicit consequences
* (service may be suspended). Last automated touch — beyond
* this, admin involvement is expected.
*
* Failure is logged, never thrown — the cron sweep must continue
* past a single failed send.
*/
export async function sendInvoiceReminderEmail(params: {
to: string;
contactName: string;
companyName: string;
invoiceNumber: string;
totalChf: number;
currency: string;
dueAt: string;
daysPastDue: number;
level: 1 | 2 | 3;
locale: "de" | "en" | "fr" | "it";
}): Promise<void> {
const L = params.locale;
// Per-locale strings keyed by the three escalation levels.
// Kept inline (rather than the next-intl message files) because
// the email layer doesn't import from React's i18n context.
const SUBJECTS: Record<typeof L, Record<1 | 2 | 3, string>> = {
en: {
1: `Friendly reminder: invoice ${params.invoiceNumber} is overdue`,
2: `Second reminder: invoice ${params.invoiceNumber} is still unpaid`,
3: `Final notice: invoice ${params.invoiceNumber} requires immediate payment`,
},
de: {
1: `Freundliche Erinnerung: Rechnung ${params.invoiceNumber} ist überfällig`,
2: `Zweite Mahnung: Rechnung ${params.invoiceNumber} ist weiterhin unbezahlt`,
3: `Letzte Mahnung: Rechnung ${params.invoiceNumber} erfordert sofortige Zahlung`,
},
fr: {
1: `Rappel amical : la facture ${params.invoiceNumber} est en retard`,
2: `Deuxième rappel : la facture ${params.invoiceNumber} reste impayée`,
3: `Dernier avis : la facture ${params.invoiceNumber} doit être réglée sans délai`,
},
it: {
1: `Promemoria amichevole: la fattura ${params.invoiceNumber} è scaduta`,
2: `Secondo sollecito: la fattura ${params.invoiceNumber} è ancora insoluta`,
3: `Avviso finale: la fattura ${params.invoiceNumber} richiede pagamento immediato`,
},
};
const INTROS: Record<typeof L, Record<1 | 2 | 3, string>> = {
en: {
1: "We noticed this invoice hasn't been settled yet — in case it slipped through.",
2: "This invoice remains unpaid. Please arrange payment at your earliest convenience.",
3: "This invoice is significantly overdue. Service may be suspended if payment is not received promptly.",
},
de: {
1: "Diese Rechnung scheint noch nicht beglichen — falls sie übersehen wurde, möchten wir freundlich daran erinnern.",
2: "Diese Rechnung ist weiterhin unbezahlt. Bitte veranlassen Sie die Zahlung umgehend.",
3: "Diese Rechnung ist erheblich überfällig. Bei nicht zeitnaher Zahlung kann der Dienst ausgesetzt werden.",
},
fr: {
1: "Cette facture n'a pas encore été réglée — au cas où elle vous aurait échappé.",
2: "Cette facture reste impayée. Merci d'effectuer le paiement dans les meilleurs délais.",
3: "Cette facture est en grand retard. Le service pourra être suspendu en l'absence de paiement rapide.",
},
it: {
1: "Questa fattura non risulta ancora saldata — nel caso vi fosse sfuggita.",
2: "Questa fattura risulta ancora insoluta. Si prega di provvedere al pagamento al più presto.",
3: "Questa fattura è significativamente in ritardo. In assenza di pagamento tempestivo il servizio potrà essere sospeso.",
},
};
const LABELS: Record<typeof L, Record<string, string>> = {
en: { num: "Invoice", total: "Total", due: "Due date", days: "Days past due", cta: "View invoice & pay", signoff: "Best regards", brand: "PieCed IT", greeting: "Hello" },
de: { num: "Rechnung", total: "Gesamt", due: "Fälligkeitsdatum", days: "Tage überfällig", cta: "Rechnung ansehen & bezahlen", signoff: "Mit freundlichen Grüssen", brand: "PieCed IT", greeting: "Sehr geehrte/r" },
fr: { num: "Facture", total: "Total", due: "Échéance", days: "Jours de retard", cta: "Voir la facture & payer", signoff: "Cordialement", brand: "PieCed IT", greeting: "Bonjour" },
it: { num: "Fattura", total: "Totale", due: "Scadenza", days: "Giorni di ritardo", cta: "Vedi fattura & paga", signoff: "Cordiali saluti", brand: "PieCed IT", greeting: "Gentile" },
};
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 dueFmt = params.dueAt.slice(0, 10);
const link = `https://app.pieced.ch/billing/${encodeURIComponent(params.invoiceNumber)}`;
// Final-notice gets red accent; earlier levels keep the brand green.
const accent = params.level === 3 ? "#dc2626" : "#10B981";
try {
await getTransporter().sendMail({
from: getFrom(),
to: params.to,
subject: SUBJECTS[L][params.level],
text: [
`${l.greeting} ${params.contactName},`,
"",
INTROS[L][params.level],
"",
`${l.num}: ${params.invoiceNumber}`,
`${l.total}: ${totalFmt}`,
`${l.due}: ${dueFmt}`,
`${l.days}: ${params.daysPastDue}`,
"",
`${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:${accent};">${escapeHtml(SUBJECTS[L][params.level])}</h2>
<p>${l.greeting} ${safeName},</p>
<p>${escapeHtml(INTROS[L][params.level])}</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px;">
<tr><td style="color:#888;padding:6px 0;width:140px;">${l.num}</td><td><strong>${safeNumber}</strong></td></tr>
<tr><td style="color:#888;padding:6px 0;">${l.total}</td><td style="color:${accent};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.days}</td><td>${params.daysPastDue}</td></tr>
</table>
<p>
<a href="${link}" style="display:inline-block;padding:10px 24px;background:${accent};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 reminder L${params.level} for invoice ${params.invoiceNumber}:`,
err
);
}
}