feat(billing): Usage & Billing API for the peergos-flutter app (+ commit live ns1 drift) #2
4 changed files with 563 additions and 12 deletions
chore: commit live drift from ns1:/opt/verae/zappier-edge (send-invoices job, overdue email HTML, portal #billing deep link)
These files were running on ns1 but never committed. Copied verbatim so the next deploy from git does not regress them.
commit
0e41be62ca
|
|
@ -7,7 +7,8 @@
|
|||
"start": "node dist/index.js",
|
||||
"dev": "ts-node src/index.ts",
|
||||
"test": "jest",
|
||||
"overdue": "node dist/jobs/overdue-balances.js"
|
||||
"overdue": "node dist/jobs/overdue-balances.js",
|
||||
"send-invoices": "node dist/jobs/send-invoices.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.18.0",
|
||||
|
|
|
|||
|
|
@ -1038,18 +1038,49 @@ function renderDocs() {
|
|||
|
||||
/* ---------------- tabs + boot ---------------- */
|
||||
|
||||
function activateTab(tabId) {
|
||||
const btn = document.querySelector(`nav button[data-tab="${tabId}"]`);
|
||||
if (!btn) return false;
|
||||
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.querySelectorAll('main section').forEach((s) => (s.hidden = true));
|
||||
const section = document.getElementById(tabId);
|
||||
if (section) section.hidden = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function tabFromHash() {
|
||||
const raw = (location.hash || '').replace(/^#/, '').toLowerCase();
|
||||
if (raw === 'billing' || raw === 'reload') return 'billing';
|
||||
if (raw === 'invoices' || raw === 'statement' || raw === 'security' || raw === 'docs' || raw === 'dashboard') return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyHashTab() {
|
||||
const tab = tabFromHash();
|
||||
if (tab) activateTab(tab);
|
||||
}
|
||||
|
||||
document.querySelectorAll('nav button').forEach((btn) =>
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('nav button').forEach((b) => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.querySelectorAll('main section').forEach((s) => (s.hidden = true));
|
||||
document.getElementById(btn.dataset.tab).hidden = false;
|
||||
activateTab(btn.dataset.tab);
|
||||
if (btn.dataset.tab && location.hash) {
|
||||
// keep deep-link in sync when user clicks Billing after arriving via #billing
|
||||
const h = btn.dataset.tab === 'billing' ? '#billing' : `#${btn.dataset.tab}`;
|
||||
if (location.hash.toLowerCase() !== h) history.replaceState(null, '', h);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (token()) applyHashTab();
|
||||
});
|
||||
|
||||
if (token()) {
|
||||
showShell();
|
||||
load().catch((err) => say(err.message, true));
|
||||
load()
|
||||
.then(() => applyHashTab())
|
||||
.catch((err) => say(err.message, true));
|
||||
} else {
|
||||
showAuth();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
* node dist/jobs/overdue-balances.js --threshold-cents -500 # only owing > $5
|
||||
* node dist/jobs/overdue-balances.js --send # email each customer
|
||||
* node dist/jobs/overdue-balances.js --send --to-override ops@example.com
|
||||
* node dist/jobs/overdue-balances.js --send --customer-id cust_xxx --to-override ops@example.com
|
||||
*
|
||||
* Env: BILLING_DB, SMTP_* (see src/mailer.ts), PORTAL_URL
|
||||
* (default https://zap.verae.ai/portal/), OVERDUE_THRESHOLD_CENTS (default 0;
|
||||
|
|
@ -142,11 +143,30 @@ export function usd(cents: number): string {
|
|||
return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
const DEFAULT_PORTAL_URL = 'https://zap.verae.ai/portal/';
|
||||
const DEFAULT_LOGO_URL = 'https://mcp.verae.ai/assets/VeraeFullLogo.png';
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function paymentRequestEmail(
|
||||
pos: AccountPosition,
|
||||
portalUrl = 'https://zap.verae.ai/portal/',
|
||||
): { subject: string; text: string } {
|
||||
portalUrl = DEFAULT_PORTAL_URL,
|
||||
logoUrl = DEFAULT_LOGO_URL,
|
||||
): { subject: string; text: string; html: string } {
|
||||
const due = Math.max(0, -pos.effectiveBalanceCents);
|
||||
const portal = portalUrl.endsWith('/') ? portalUrl : `${portalUrl}/`;
|
||||
const loginUrl = portal;
|
||||
const billingUrl = `${portal}#billing`;
|
||||
const invNote = pos.outstandingInvoiceIds.length
|
||||
? ` (${pos.outstandingInvoiceIds.join(', ')})`
|
||||
: '';
|
||||
const subject = `Verae payment request — ${usd(due)} due on ${pos.customerId}`;
|
||||
const lines = [
|
||||
`Hello ${pos.name},`,
|
||||
'',
|
||||
|
|
@ -154,17 +174,126 @@ export function paymentRequestEmail(
|
|||
'Your timestamps keep working while the balance is negative; please top up at your convenience.',
|
||||
'',
|
||||
` Prepaid balance: ${usd(pos.prepaidCents)}`,
|
||||
` Unpaid issued invoices: ${usd(pos.outstandingInvoiceCents)}${pos.outstandingInvoiceIds.length ? ` (${pos.outstandingInvoiceIds.join(', ')})` : ''}`,
|
||||
` Unpaid issued invoices: ${usd(pos.outstandingInvoiceCents)}${invNote}`,
|
||||
` Usage not yet invoiced: ${usd(pos.unbilledUsageCents)}`,
|
||||
` Effective balance: ${usd(pos.effectiveBalanceCents)}`,
|
||||
'',
|
||||
`Reload your balance in the customer portal: ${portalUrl} (Billing → Reload balance).`,
|
||||
`Log in: ${loginUrl}`,
|
||||
`Reload balance (Billing tab): ${billingUrl}`,
|
||||
'',
|
||||
'Questions? Just reply to this email.',
|
||||
'',
|
||||
'— Verae',
|
||||
];
|
||||
return { subject: `Verae payment request — ${usd(due)} due on ${pos.customerId}`, text: lines.join('\n') };
|
||||
const text = lines.join('\n');
|
||||
|
||||
const name = escHtml(pos.name);
|
||||
const custId = escHtml(pos.customerId);
|
||||
const dueStr = escHtml(usd(due));
|
||||
const prepaidStr = escHtml(usd(pos.prepaidCents));
|
||||
const unpaidStr = escHtml(usd(pos.outstandingInvoiceCents) + invNote);
|
||||
const unbilledStr = escHtml(usd(pos.unbilledUsageCents));
|
||||
const effectiveStr = escHtml(usd(pos.effectiveBalanceCents));
|
||||
const logo = escHtml(logoUrl);
|
||||
const login = escHtml(loginUrl);
|
||||
const billing = escHtml(billingUrl);
|
||||
const issued = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>${escHtml(subject)}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 12px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;box-shadow:0 2px 8px rgba(0,0,0,0.04);">
|
||||
<tr>
|
||||
<td style="background:#0b1220;padding:20px 28px;text-align:left;">
|
||||
<img src="${logo}" alt="Verae" width="160" style="display:block;max-width:160px;height:auto;border:0;"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px 28px 8px;">
|
||||
<p style="margin:0;font-size:12px;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;font-weight:700;">Payment request / invoice</p>
|
||||
<h1 style="margin:6px 0 0;font-size:22px;line-height:1.3;color:#111827;">Balance due: ${dueStr}</h1>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:#4b5563;">Account <strong style="color:#111827;">${custId}</strong> · Issued ${escHtml(issued)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 28px 0;">
|
||||
<p style="margin:0;font-size:15px;line-height:1.55;color:#374151;">Hello ${name},</p>
|
||||
<p style="margin:12px 0 0;font-size:15px;line-height:1.55;color:#374151;">
|
||||
Your Verae timestamping account has a negative prepaid balance. Timestamps keep working while the balance is negative — please reload at your convenience.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 28px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
|
||||
<tr style="background:#f9fafb;">
|
||||
<th align="left" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Description</th>
|
||||
<th align="right" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Amount</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Prepaid balance</td>
|
||||
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${prepaidStr}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Unpaid issued invoices${escHtml(invNote)}</td>
|
||||
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${unpaidStr}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Usage not yet invoiced</td>
|
||||
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${unbilledStr}</td>
|
||||
</tr>
|
||||
<tr style="background:#fff7ed;">
|
||||
<td style="padding:14px;font-size:15px;font-weight:700;color:#9a3412;">Effective balance</td>
|
||||
<td align="right" style="padding:14px;font-size:15px;font-weight:700;font-variant-numeric:tabular-nums;color:#9a3412;">${effectiveStr}</td>
|
||||
</tr>
|
||||
<tr style="background:#0b1220;">
|
||||
<td style="padding:14px;font-size:15px;font-weight:700;color:#ffffff;">Amount due</td>
|
||||
<td align="right" style="padding:14px;font-size:18px;font-weight:800;font-variant-numeric:tabular-nums;color:#ffffff;">${dueStr}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 28px 24px;" align="center">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td style="padding:6px;">
|
||||
<a href="${login}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 20px;border-radius:8px;">Log in to portal</a>
|
||||
</td>
|
||||
<td style="padding:6px;">
|
||||
<a href="${billing}" style="display:inline-block;background:#059669;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 20px;border-radius:8px;">Reload balance</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:14px 0 0;font-size:12px;color:#6b7280;line-height:1.5;">
|
||||
After sign-in, open the <strong>Billing</strong> tab to reload.<br/>
|
||||
Portal: <a href="${login}" style="color:#2563eb;">${login}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:18px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
|
||||
<p style="margin:0;font-size:13px;color:#6b7280;line-height:1.5;">Questions? Just reply to this email.</p>
|
||||
<p style="margin:8px 0 0;font-size:13px;color:#374151;font-weight:600;">— Verae</p>
|
||||
<p style="margin:12px 0 0;font-size:11px;color:#9ca3af;">
|
||||
<img src="${logo}" alt="" width="72" style="vertical-align:middle;max-width:72px;height:auto;border:0;opacity:0.85;"/>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
export async function sendPaymentRequests(
|
||||
|
|
@ -239,7 +368,14 @@ async function main(argv = process.argv.slice(2)): Promise<void> {
|
|||
}
|
||||
})(),
|
||||
};
|
||||
const rows = await listOverdueCustomers(deps, threshold);
|
||||
let rows = await listOverdueCustomers(deps, threshold);
|
||||
const onlyId = argValue(argv, '--customer-id');
|
||||
if (onlyId) {
|
||||
rows = rows.filter((r) => r.customerId === onlyId);
|
||||
if (!rows.length) {
|
||||
console.error(`overdue-balances: no negative-balance customer matching --customer-id ${onlyId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (argv.includes('--json')) {
|
||||
console.log(JSON.stringify({ thresholdCents: threshold, count: rows.length, customers: rows }, null, 2));
|
||||
|
|
|
|||
383
src/jobs/send-invoices.ts
Normal file
383
src/jobs/send-invoices.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
/**
|
||||
* One-shot / CLI invoice emailer.
|
||||
*
|
||||
* Sends a proper HTML invoice email (Verae logo, portal login + billing links,
|
||||
* invoice id, line items, totals) for ISSUED invoices with billableCents > 0.
|
||||
* Draft invoices and $0 billable invoices are never emailed.
|
||||
*
|
||||
* Usage (from /opt/verae/zappier-edge, after `npm run build`):
|
||||
* node dist/jobs/send-invoices.js --invoice-id INV-2026-10-0003
|
||||
* node dist/jobs/send-invoices.js --ids INV-2026-10-0003,INV-2026-10-0007 --send
|
||||
* node dist/jobs/send-invoices.js --period 2026-10 --status issued --send
|
||||
* node dist/jobs/send-invoices.js --send --to-override ops@example.com --invoice-id INV-...
|
||||
*
|
||||
* Env: BILLING_DB, SMTP_* (see src/mailer.ts), PORTAL_URL
|
||||
* (default https://zap.verae.ai/portal/), INVOICE_LOGO_URL
|
||||
* (default https://mcp.verae.ai/assets/VeraeFullLogo.png).
|
||||
* Nothing is emailed unless --send is passed.
|
||||
*/
|
||||
import type { Customer, CustomerRepo } from '../auth';
|
||||
import type { Invoice, InvoiceRepo } from '../invoicing';
|
||||
import type { Mailer } from '../mailer';
|
||||
|
||||
export interface InvoiceEmailDeps {
|
||||
customers: Pick<CustomerRepo, 'findById'>;
|
||||
invoices: Pick<InvoiceRepo, 'get' | 'list'>;
|
||||
}
|
||||
|
||||
const DEFAULT_PORTAL_URL = 'https://zap.verae.ai/portal/';
|
||||
const DEFAULT_LOGO_URL = 'https://mcp.verae.ai/assets/VeraeFullLogo.png';
|
||||
|
||||
export function usd(cents: number): string {
|
||||
const sign = cents < 0 ? '-' : '';
|
||||
return `${sign}$${(Math.abs(cents) / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** True only for issued invoices that have a positive amount due. */
|
||||
export function isEmailableInvoice(inv: Pick<Invoice, 'status' | 'billableCents'>): boolean {
|
||||
return inv.status === 'issued' && Number.isFinite(inv.billableCents) && inv.billableCents > 0;
|
||||
}
|
||||
|
||||
export function invoiceEmail(
|
||||
invoice: Invoice,
|
||||
customer: Pick<Customer, 'id' | 'name' | 'email'>,
|
||||
portalUrl = DEFAULT_PORTAL_URL,
|
||||
logoUrl = DEFAULT_LOGO_URL,
|
||||
): { subject: string; text: string; html: string } {
|
||||
const portal = portalUrl.endsWith('/') ? portalUrl : `${portalUrl}/`;
|
||||
const loginUrl = portal;
|
||||
const billingUrl = `${portal}#billing`;
|
||||
const due = usd(invoice.billableCents);
|
||||
const issued =
|
||||
invoice.issuedAtMs != null
|
||||
? new Date(invoice.issuedAtMs).toISOString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const dueDate =
|
||||
invoice.dueAtMs != null ? new Date(invoice.dueAtMs).toISOString().slice(0, 10) : '—';
|
||||
|
||||
const subject = `Verae invoice ${invoice.id} — ${due} due`;
|
||||
const lineText = invoice.lines
|
||||
.map((l) => ` ${l.endpointId.padEnd(24)} ${String(l.calls).padStart(6)} calls ${usd(l.cents)}`)
|
||||
.join('\n');
|
||||
const text = [
|
||||
`Hello ${customer.name},`,
|
||||
'',
|
||||
`Please find your Verae timestamping invoice ${invoice.id} for period ${invoice.period}.`,
|
||||
'',
|
||||
` Invoice: ${invoice.id}`,
|
||||
` Account: ${customer.id}`,
|
||||
` Period: ${invoice.period}`,
|
||||
` Status: ${invoice.status}`,
|
||||
` Issued: ${issued}`,
|
||||
` Due date: ${dueDate}`,
|
||||
'',
|
||||
'Line items:',
|
||||
lineText || ' (none)',
|
||||
'',
|
||||
` Usage total: ${usd(invoice.totalCents)}`,
|
||||
` Monthly credit: −${usd(invoice.creditCents)}`,
|
||||
` Amount due: ${due}`,
|
||||
'',
|
||||
`Log in: ${loginUrl}`,
|
||||
`Pay / Billing tab: ${billingUrl}`,
|
||||
'',
|
||||
'Questions? Just reply to this email.',
|
||||
'',
|
||||
'— Verae',
|
||||
].join('\n');
|
||||
|
||||
const name = escHtml(customer.name);
|
||||
const custId = escHtml(customer.id);
|
||||
const invId = escHtml(invoice.id);
|
||||
const period = escHtml(invoice.period);
|
||||
const status = escHtml(invoice.status.toUpperCase());
|
||||
const dueStr = escHtml(due);
|
||||
const totalStr = escHtml(usd(invoice.totalCents));
|
||||
const creditStr = escHtml(usd(invoice.creditCents));
|
||||
const logo = escHtml(logoUrl);
|
||||
const login = escHtml(loginUrl);
|
||||
const billing = escHtml(billingUrl);
|
||||
const issuedEsc = escHtml(issued);
|
||||
const dueDateEsc = escHtml(dueDate);
|
||||
|
||||
const rows = invoice.lines
|
||||
.map(
|
||||
(l) => `
|
||||
<tr>
|
||||
<td style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">${escHtml(l.endpointId)}</td>
|
||||
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${l.calls}</td>
|
||||
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${escHtml(usd(l.cents))}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>${escHtml(subject)}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f4f6f8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f6f8;padding:24px 12px;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;box-shadow:0 2px 8px rgba(0,0,0,0.04);">
|
||||
<tr>
|
||||
<td style="background:#0b1220;padding:20px 28px;text-align:left;">
|
||||
<img src="${logo}" alt="Verae" width="160" style="display:block;max-width:160px;height:auto;border:0;"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px 28px 8px;">
|
||||
<p style="margin:0;font-size:12px;letter-spacing:0.08em;text-transform:uppercase;color:#6b7280;font-weight:700;">Invoice</p>
|
||||
<h1 style="margin:6px 0 0;font-size:22px;line-height:1.3;color:#111827;">${invId}</h1>
|
||||
<p style="margin:8px 0 0;font-size:14px;color:#4b5563;">
|
||||
Account <strong style="color:#111827;">${custId}</strong>
|
||||
· Period ${period}
|
||||
· Issued ${issuedEsc}
|
||||
· Due ${dueDateEsc}
|
||||
· <span style="display:inline-block;padding:2px 8px;border-radius:999px;background:#eef0fe;color:#4f46e5;font-weight:700;font-size:12px;">${status}</span>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 28px 0;">
|
||||
<p style="margin:0;font-size:15px;line-height:1.55;color:#374151;">Hello ${name},</p>
|
||||
<p style="margin:12px 0 0;font-size:15px;line-height:1.55;color:#374151;">
|
||||
Here is your Verae timestamping invoice for period <strong>${period}</strong>. Amount due: <strong>${dueStr}</strong>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 28px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
|
||||
<tr style="background:#f9fafb;">
|
||||
<th align="left" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Endpoint</th>
|
||||
<th align="right" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Calls</th>
|
||||
<th align="right" style="padding:10px 14px;font-size:12px;text-transform:uppercase;letter-spacing:0.04em;color:#6b7280;border-bottom:1px solid #e5e7eb;">Amount</th>
|
||||
</tr>
|
||||
${rows || `<tr><td colspan="3" style="padding:12px 14px;font-size:14px;color:#6b7280;">No line items</td></tr>`}
|
||||
<tr>
|
||||
<td colspan="2" style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Usage total</td>
|
||||
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">${totalStr}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding:12px 14px;font-size:14px;color:#111827;border-bottom:1px solid #f3f4f6;">Monthly credit</td>
|
||||
<td align="right" style="padding:12px 14px;font-size:14px;font-variant-numeric:tabular-nums;color:#111827;border-bottom:1px solid #f3f4f6;">−${creditStr}</td>
|
||||
</tr>
|
||||
<tr style="background:#0b1220;">
|
||||
<td colspan="2" style="padding:14px;font-size:15px;font-weight:700;color:#ffffff;">Amount due</td>
|
||||
<td align="right" style="padding:14px;font-size:18px;font-weight:800;font-variant-numeric:tabular-nums;color:#ffffff;">${dueStr}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 28px 24px;" align="center">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 auto;">
|
||||
<tr>
|
||||
<td style="padding:6px;">
|
||||
<a href="${login}" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 20px;border-radius:8px;">Log in to portal</a>
|
||||
</td>
|
||||
<td style="padding:6px;">
|
||||
<a href="${billing}" style="display:inline-block;background:#059669;color:#ffffff;text-decoration:none;font-weight:700;font-size:14px;padding:12px 20px;border-radius:8px;">Pay / Billing</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:14px 0 0;font-size:12px;color:#6b7280;line-height:1.5;">
|
||||
After sign-in, open the <strong>Billing</strong> tab to pay or reload.<br/>
|
||||
Portal: <a href="${login}" style="color:#2563eb;">${login}</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:18px 28px;background:#f9fafb;border-top:1px solid #e5e7eb;">
|
||||
<p style="margin:0;font-size:13px;color:#6b7280;line-height:1.5;">Questions? Just reply to this email.</p>
|
||||
<p style="margin:8px 0 0;font-size:13px;color:#374151;font-weight:600;">— Verae</p>
|
||||
<p style="margin:12px 0 0;font-size:11px;color:#9ca3af;">
|
||||
<img src="${logo}" alt="" width="72" style="vertical-align:middle;max-width:72px;height:auto;border:0;opacity:0.85;"/>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
export async function sendInvoices(
|
||||
invoiceIds: string[],
|
||||
deps: InvoiceEmailDeps,
|
||||
mailer: Mailer,
|
||||
opts: {
|
||||
toOverride?: string;
|
||||
portalUrl?: string;
|
||||
logoUrl?: string;
|
||||
log?: (m: string) => void;
|
||||
} = {},
|
||||
): Promise<{
|
||||
sent: { invoiceId: string; to: string }[];
|
||||
skipped: { invoiceId: string; reason: string }[];
|
||||
}> {
|
||||
const log = opts.log ?? console.log;
|
||||
const sent: { invoiceId: string; to: string }[] = [];
|
||||
const skipped: { invoiceId: string; reason: string }[] = [];
|
||||
if (!mailer.available()) throw new Error('SMTP not configured (SMTP_USER/SMTP_PASS)');
|
||||
|
||||
for (const id of invoiceIds) {
|
||||
const invoice = deps.invoices.get(id);
|
||||
if (!invoice) {
|
||||
skipped.push({ invoiceId: id, reason: 'invoice not found' });
|
||||
log(`skipped ${id}: invoice not found`);
|
||||
continue;
|
||||
}
|
||||
if (invoice.status === 'draft') {
|
||||
skipped.push({ invoiceId: id, reason: 'draft invoice (not emailed)' });
|
||||
log(`skipped ${id}: draft invoice (not emailed)`);
|
||||
continue;
|
||||
}
|
||||
if (!isEmailableInvoice(invoice)) {
|
||||
const reason =
|
||||
invoice.status !== 'issued'
|
||||
? `status ${invoice.status} (only issued)`
|
||||
: `billable ${usd(invoice.billableCents)} (must be > $0)`;
|
||||
skipped.push({ invoiceId: id, reason });
|
||||
log(`skipped ${id}: ${reason}`);
|
||||
continue;
|
||||
}
|
||||
const customer = await deps.customers.findById(invoice.customerId);
|
||||
if (!customer) {
|
||||
skipped.push({ invoiceId: id, reason: `customer ${invoice.customerId} not found` });
|
||||
log(`skipped ${id}: customer ${invoice.customerId} not found`);
|
||||
continue;
|
||||
}
|
||||
const to = opts.toOverride || customer.email;
|
||||
if (!to) {
|
||||
skipped.push({ invoiceId: id, reason: 'no email on file' });
|
||||
log(`skipped ${id}: no email on file (customer ${customer.id} / ${customer.name})`);
|
||||
continue;
|
||||
}
|
||||
const msg = invoiceEmail(invoice, customer, opts.portalUrl, opts.logoUrl);
|
||||
try {
|
||||
await mailer.send({ to, ...msg });
|
||||
sent.push({ invoiceId: id, to });
|
||||
log(
|
||||
`sent invoice ${id} to ${to} for ${customer.id} (${customer.name}) due ${usd(invoice.billableCents)}`,
|
||||
);
|
||||
} catch (err) {
|
||||
skipped.push({ invoiceId: id, reason: (err as Error).message });
|
||||
log(`failed ${id} → ${to}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return { sent, skipped };
|
||||
}
|
||||
|
||||
function argValue(argv: string[], name: string): string | undefined {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 ? argv[i + 1] : undefined;
|
||||
}
|
||||
|
||||
function collectInvoiceIds(argv: string[]): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--invoice-id' && argv[i + 1]) {
|
||||
ids.push(argv[++i]);
|
||||
}
|
||||
}
|
||||
const csv = argValue(argv, '--ids');
|
||||
if (csv) {
|
||||
for (const part of csv.split(',')) {
|
||||
const id = part.trim();
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
}
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const path = await import('path');
|
||||
const { config: loadEnv } = await import('dotenv');
|
||||
const { PROJECT_ROOT } = await import('../paths');
|
||||
loadEnv({ path: path.join(PROJECT_ROOT, '.env') });
|
||||
const { openSqlite } = await import('../db/open-sqlite');
|
||||
const { billingDbPath } = await import('../db/db-paths');
|
||||
const { SqliteCustomerRepo } = await import('../db/customer-repo');
|
||||
const { SqliteInvoiceRepo } = await import('../db/invoice-repo');
|
||||
const { smtpMailerFromEnv } = await import('../mailer');
|
||||
|
||||
const db = openSqlite(billingDbPath());
|
||||
const customers = new SqliteCustomerRepo(db);
|
||||
const invoices = new SqliteInvoiceRepo(db);
|
||||
|
||||
let ids = collectInvoiceIds(argv);
|
||||
const period = argValue(argv, '--period');
|
||||
const status = argValue(argv, '--status') as Invoice['status'] | undefined;
|
||||
if (!ids.length && (period || status)) {
|
||||
ids = invoices
|
||||
.list({
|
||||
...(period ? { period } : {}),
|
||||
...(status ? { status } : {}),
|
||||
})
|
||||
.map((i) => i.id);
|
||||
}
|
||||
if (!ids.length) {
|
||||
console.error(
|
||||
'send-invoices: pass --invoice-id INV-... (repeatable), --ids a,b,c, or --period / --status',
|
||||
);
|
||||
db.close();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(`send-invoices: ${ids.length} invoice id(s) selected`);
|
||||
for (const id of ids) {
|
||||
const inv = invoices.get(id);
|
||||
if (!inv) {
|
||||
console.log(` ${id} (not found)`);
|
||||
continue;
|
||||
}
|
||||
const cust = await customers.findById(inv.customerId);
|
||||
console.log(
|
||||
[
|
||||
id.padEnd(20),
|
||||
inv.status.padEnd(8),
|
||||
`due ${usd(inv.billableCents)}`.padEnd(12),
|
||||
(cust?.email || '(no email)').padEnd(28),
|
||||
cust ? `${cust.id} ${cust.name}` : inv.customerId,
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
if (argv.includes('--send')) {
|
||||
const result = await sendInvoices(ids, { customers, invoices }, smtpMailerFromEnv(), {
|
||||
toOverride: argValue(argv, '--to-override'),
|
||||
portalUrl: process.env.PORTAL_URL || DEFAULT_PORTAL_URL,
|
||||
logoUrl: process.env.INVOICE_LOGO_URL || DEFAULT_LOGO_URL,
|
||||
});
|
||||
console.log(
|
||||
`send-invoices: sent ${result.sent.length}, skipped ${result.skipped.length}`,
|
||||
);
|
||||
for (const s of result.sent) console.log(` SENT ${s.invoiceId} → ${s.to}`);
|
||||
for (const s of result.skipped) console.log(` skipped ${s.invoiceId}: ${s.reason}`);
|
||||
} else {
|
||||
console.log('send-invoices: dry run (pass --send to email invoices)');
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue