Phase5: Automate bill creation
All checks were successful
Build and Push / build (push) Successful in 1m43s
All checks were successful
Build and Push / build (push) Successful in 1m43s
This commit is contained in:
144
src/lib/email.ts
144
src/lib/email.ts
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user