Initial Commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export { InvoiceEmail, invoiceTemplate } from "./invoice.js";
|
||||
export type {
|
||||
InvoiceLineItem,
|
||||
InvoiceProps,
|
||||
} from "./invoice.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: "$",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user