Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
export { InvoiceEmail, invoiceTemplate } from "./invoice.js";
export type {
InvoiceLineItem,
InvoiceProps,
} from "./invoice.types.js";

View File

@@ -0,0 +1,93 @@
# invoice
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `invoiceTemplate` is the receipt / invoice / order-confirmation
email. It renders a heading carrying the invoice number, a metadata line
(date, optional due date), a line-items table with description /
quantity / unit-price / total columns, a subtotal-tax-total summary, and
an optional "Pay Now" CTA when `paymentUrl` is supplied.
The template is intentionally generic — it does not assume a specific
tax-jurisdiction or currency formatting; consumers pass pre-formatted
strings (date, currency symbol) and pre-computed numbers (subtotal,
tax, total). The template focuses on layout, not arithmetic.
## Features
- Line-items table with description, quantity, unit price, total columns.
- Subtotal + optional tax + total summary at right-align.
- Optional `paymentUrl` renders a "Pay Now" CTA.
- Currency symbol is configurable (`currency` defaults to `"$"`).
- Optional `dueDate` appears alongside the invoice date.
## Usage: Basic
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(invoiceTemplate, {
recipientName: "Jane",
invoiceNumber: "INV-001",
invoiceDate: "2026-01-15",
items: [
{ description: "Pro plan – Jan 2026", quantity: 1, unitPrice: 29, total: 29 },
],
subtotal: 29,
total: 29,
});
```
## Usage: Advanced
A multi-line invoice with tax, a due date, a payment CTA, and a
non-USD currency:
```ts
import { renderToHtml } from "@suaveplan/email-templates/renderer";
import { invoiceTemplate } from "@suaveplan/email-templates/templates";
renderToHtml(invoiceTemplate, {
recipientName: "Jane",
invoiceNumber: "INV-001",
invoiceDate: "2026-01-15",
dueDate: "2026-01-31",
currency: "€",
items: [
{ description: "Pro plan – Jan 2026", quantity: 1, unitPrice: 29, total: 29 },
{ description: "Add-on: extra seat", quantity: 2, unitPrice: 5, total: 10 },
],
subtotal: 39,
tax: 3.9,
total: 42.9,
paymentUrl: "https://billing.example.test/pay?invoice=INV-001",
});
```
## API Reference
| Symbol | Kind | Description |
| ----------------- | -------- | ---------------------------------------------------- |
| `invoiceTemplate` | const | `EmailTemplate<InvoiceProps>`. |
| `InvoiceEmail` | function | Component returning a `VNode`. |
| `InvoiceProps` | type | `{ recipientName, invoiceNumber, invoiceDate, dueDate?, items, subtotal, tax?, total, currency?, paymentUrl?, companyName? }` |
| `InvoiceLineItem` | type | `{ description, quantity, unitPrice, total }`. |
## Implementation Notes
Currency formatting uses `Number.prototype.toFixed(2)` — locale-agnostic
two-decimal-place rendering. Consumers needing locale-aware grouping
separators (e.g. `1,234.56` vs `1 234,56`) should pre-format the strings
upstream; the template intentionally does not depend on `Intl.NumberFormat`
because email-template environments span Node, Bun, and browsers where
the Intl data sets vary.
The line-items table renders a single `<tbody>` per call; the table
header sits in its own `<thead>` so accessibility scanners report the
correct table-with-headers structure even though most email clients
ignore the distinction.

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { findEvery, findFirst, textOf } from "../../../tests/setup/html.js";
import { renderToHtml } from "../../renderer/renderer.js";
import { renderVNode } from "../../vnode/vnode.js";
import { InvoiceEmail, invoiceTemplate } from "./invoice.js";
const baseProps = {
recipientName: "Jane",
invoiceNumber: "INV-001",
invoiceDate: "2026-01-01",
items: [
{ description: "Widget", quantity: 2, unitPrice: 10, total: 20 },
{ description: "Gizmo", quantity: 1, unitPrice: 15.5, total: 15.5 },
],
subtotal: 35.5,
total: 35.5,
};
describe("invoiceTemplate", () => {
it("renders line-item rows", { timeout: 5000 }, () => {
const { html } = renderToHtml(invoiceTemplate, baseProps);
const widget = findFirst(
html,
(el) => el.name === "td" && textOf(el).includes("Widget")
);
expect(widget).not.toBeNull();
const gizmo = findFirst(
html,
(el) => el.name === "td" && textOf(el).includes("Gizmo")
);
expect(gizmo).not.toBeNull();
});
it("renders the invoice number in the subject and heading", {
timeout: 5000,
}, () => {
const { html, subject } = renderToHtml(invoiceTemplate, baseProps);
expect(subject).toBe("Invoice #INV-001");
expect(html).toContain("Invoice #INV-001");
});
it("formats currency to two decimal places", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
expect(html).toContain("$10.00");
expect(html).toContain("$15.50");
expect(html).toContain("$35.50");
});
it("uses custom currency symbol", { timeout: 5000 }, () => {
const html = renderVNode(
InvoiceEmail({
...baseProps,
currency: "€",
})
);
expect(html).toContain("€10.00");
expect(html).not.toContain("$10.00");
});
it("omits dueDate when not provided", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
expect(html).toContain("Date: 2026-01-01");
expect(html).not.toContain("Due:");
});
it("renders dueDate when provided", { timeout: 5000 }, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, dueDate: "2026-01-31" })
);
expect(html).toContain("Due: 2026-01-31");
});
it("renders tax row when tax provided", { timeout: 5000 }, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, tax: 3.55, total: 39.05 })
);
expect(html).toContain("Tax: $3.55");
});
it("omits tax row when not provided", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
expect(html).not.toContain("Tax:");
});
it("renders payment button when paymentUrl provided", {
timeout: 5000,
}, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, paymentUrl: "https://x/pay" })
);
const a = findFirst(
html,
(el) => el.name === "a" && el.attribs.href === "https://x/pay"
);
expect(a).not.toBeNull();
});
it("omits payment button when paymentUrl absent", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
expect(html).not.toContain("Pay Now");
});
it("uses explicit companyName", { timeout: 5000 }, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, companyName: "Acme" })
);
expect(html).toContain("from Acme");
});
it("renders the column headers", { timeout: 5000 }, () => {
const html = renderVNode(InvoiceEmail(baseProps));
const headers = findEvery(html, (el) => el.name === "th");
const labels = headers.map((h) => textOf(h));
expect(labels).toEqual(["Item", "Qty", "Price", "Total"]);
});
it("renders an empty line-items section when items=[]", {
timeout: 5000,
}, () => {
const html = renderVNode(
InvoiceEmail({ ...baseProps, items: [], subtotal: 0, total: 0 })
);
const tbody = findFirst(html, (el) => el.name === "tbody");
expect(tbody).not.toBeNull();
});
});

View File

@@ -0,0 +1,229 @@
/**
* Invoice / receipt email. Renders line items in a themed table with
* subtotal / tax / total summary and an optional payment CTA.
*/
import type { Clock } from "@suaveplan/types";
import { EmailButton } from "../../components/email-button/email-button.js";
import { EmailDivider } from "../../components/email-divider/email-divider.js";
import { EmailFooter } from "../../components/email-footer/email-footer.js";
import { EmailHeading } from "../../components/email-heading/email-heading.js";
import { EmailLayout } from "../../components/email-layout/email-layout.js";
import { EmailText } from "../../components/email-text/email-text.js";
import type { EmailTemplate } from "../../renderer/renderer.types.js";
import { defaultTheme } from "../../theme/theme.js";
import type { EmailTheme } from "../../theme/theme.types.js";
import { h } from "../../vnode/vnode.js";
import type { StyleObject, VNode } from "../../vnode/vnode.types.js";
import type { InvoiceLineItem, InvoiceProps } from "./invoice.types.js";
function formatCurrency(amount: number, currency: string): string {
return `${currency}${amount.toFixed(2)}`;
}
function cellStyle(theme: EmailTheme): StyleObject {
return {
padding: `${theme.spacing.sm} ${theme.spacing.xs}`,
"border-bottom": `1px solid ${theme.colors.border}`,
"font-family": theme.typography.fontFamily,
"font-size": "14px",
};
}
function headerStyle(theme: EmailTheme): StyleObject {
return {
padding: `${theme.spacing.sm} ${theme.spacing.xs}`,
"border-bottom": `2px solid ${theme.colors.foreground}`,
"font-family": theme.typography.fontFamily,
"font-size": "13px",
"font-weight": 600,
"text-transform": "uppercase",
color: theme.colors.mutedForeground,
};
}
function lineItemRow(
item: InvoiceLineItem,
currency: string,
theme: EmailTheme
): VNode {
const base = cellStyle(theme);
return h("tr", {}, [
h("td", { style: base }, item.description),
h(
"td",
{ style: { ...base, "text-align": "center" } },
String(item.quantity)
),
h(
"td",
{ style: { ...base, "text-align": "right" } },
formatCurrency(item.unitPrice, currency)
),
h(
"td",
{ style: { ...base, "text-align": "right" } },
formatCurrency(item.total, currency)
),
]);
}
function summaryRow(
label: string,
amount: number,
currency: string,
theme: EmailTheme,
emphasis = false
): VNode {
return h(
"tr",
{},
h(
"td",
{
style: {
"font-family": theme.typography.fontFamily,
"font-size": emphasis ? "18px" : "14px",
"font-weight": emphasis ? 700 : 400,
padding: theme.spacing.xs,
"text-align": "right",
},
},
`${label}: ${formatCurrency(amount, currency)}`
)
);
}
function lineItemsTable(
items: ReadonlyArray<InvoiceLineItem>,
currency: string,
theme: EmailTheme
): VNode {
const hs = headerStyle(theme);
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: { width: "100%", margin: `${theme.spacing.md} 0` },
},
[
h(
"thead",
{},
h("tr", {}, [
h("th", { style: { ...hs, "text-align": "left" } }, "Item"),
h(
"th",
{ style: { ...hs, "text-align": "center" } },
"Qty"
),
h(
"th",
{ style: { ...hs, "text-align": "right" } },
"Price"
),
h(
"th",
{ style: { ...hs, "text-align": "right" } },
"Total"
),
])
),
h(
"tbody",
{},
items.map((item) => lineItemRow(item, currency, theme))
),
]
);
}
function summaryTable(
subtotal: number,
tax: number | undefined,
total: number,
currency: string,
theme: EmailTheme
): VNode {
const rows: VNode[] = [summaryRow("Subtotal", subtotal, currency, theme)];
if (tax !== undefined) {
rows.push(summaryRow("Tax", tax, currency, theme));
}
rows.push(summaryRow("Total", total, currency, theme, true));
return h(
"table",
{
role: "presentation",
cellpadding: 0,
cellspacing: 0,
style: { width: "100%" },
},
h("tbody", {}, rows)
);
}
export function InvoiceEmail(
props: InvoiceProps & { theme?: EmailTheme; clock?: Clock }
): VNode {
const {
recipientName,
invoiceNumber,
invoiceDate,
dueDate,
items,
subtotal,
tax,
total,
currency = "$",
paymentUrl,
companyName,
theme = defaultTheme,
clock,
} = props;
const name = companyName ?? theme.branding.companyName;
const children: Array<VNode> = [
EmailHeading({
level: 1,
theme,
children: `Invoice #${invoiceNumber}`,
}),
EmailText({
theme,
children: `Hi ${recipientName}, here is your invoice from ${name}.`,
}),
EmailText({
theme,
variant: "caption",
muted: true,
children:
dueDate !== undefined
? `Date: ${invoiceDate} · Due: ${dueDate}`
: `Date: ${invoiceDate}`,
}),
lineItemsTable(items, currency, theme),
summaryTable(subtotal, tax, total, currency, theme),
];
if (paymentUrl !== undefined) {
children.push(
EmailButton({ href: paymentUrl, theme, children: "Pay Now" })
);
}
children.push(
EmailDivider({ theme }),
EmailFooter({ theme, clock, companyName: name })
);
return EmailLayout({ theme, children });
}
export const invoiceTemplate: EmailTemplate<InvoiceProps> = {
name: "invoice",
subject: (props) => `Invoice #${props.invoiceNumber}`,
component: InvoiceEmail,
defaultProps: {
currency: "$",
},
};

View File

@@ -0,0 +1,20 @@
export interface InvoiceLineItem {
readonly description: string;
readonly quantity: number;
readonly unitPrice: number;
readonly total: number;
}
export interface InvoiceProps {
readonly recipientName: string;
readonly invoiceNumber: string;
readonly invoiceDate: string;
readonly dueDate?: string | undefined;
readonly items: ReadonlyArray<InvoiceLineItem>;
readonly subtotal: number;
readonly tax?: number | undefined;
readonly total: number;
readonly currency?: string | undefined;
readonly paymentUrl?: string | undefined;
readonly companyName?: string | undefined;
}