Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Read-only by design — `zfy` cannot modify your Zeffy data, because the officia
- [Quick start (5 minutes)](#quick-start-5-minutes)
- [CLI commands](#cli-commands)
- [End-of-year report](#end-of-year-report)
- [What the PDF receipts look like](#what-the-pdf-receipts-look-like)
- [Logo spec for `--logo`](#logo-spec-for---logo)
- [Optional: use from Claude or other AI agents (MCP)](#optional-use-from-claude-or-other-ai-agents-mcp)
- [SDK usage](#sdk-usage)
Expand Down Expand Up @@ -165,7 +166,7 @@ zfy report eoy --year 2025 --format md --out eoy-2025.md --top 50
# One PDF receipt per donor (with optional logo)
zfy report eoy --year 2025 --format pdf --out ./receipts/ \
--org "Friends of the Library" \
--logo ./logo.png --logo-size 64 \
--logo ./logo.png --logo-size 48 \
--timezone America/Los_Angeles
```

Expand All @@ -180,11 +181,21 @@ zfy report eoy --year 2025 --format pdf --out ./receipts/ \
| `--top <n>` | Markdown: limit donor table. |
| `--org <name>` | PDF: organization name in the header. |
| `--logo <path>` | PDF: square PNG/JPEG mark — see spec below. |
| `--logo-size <pt>` | PDF: edge length of the logo slot in [PDF points](https://en.wikipedia.org/wiki/Point_(typography)) (default 64; 72 pt ≈ 1 inch). |
| `--logo-size <pt>` | PDF: edge length of the logo slot in [PDF points](https://en.wikipedia.org/wiki/Point_(typography)) (default 48; 72 pt ≈ 1 inch). |
| `--receipt-text <txt>` | PDF: override the default tax-receipt boilerplate. |

Run `zfy report eoy --help` for the complete list.

#### What the PDF receipts look like

<p align="center">
<a href="https://github.com/EssentialsDev/zfy-cli/blob/main/examples/sample-receipt.pdf">
<img src="https://raw.githubusercontent.com/EssentialsDev/zfy-cli/main/examples/sample-receipt.png" alt="Sample EOY donation receipt rendered by zfy" width="600">
</a>
<br>
<em><a href="https://github.com/EssentialsDev/zfy-cli/blob/main/examples/sample-receipt.pdf">Download the sample PDF</a> · regenerate with <code>pnpm build &amp;&amp; node examples/gen-sample-receipt.mjs</code></em>
</p>

### Logo spec for `--logo`

The PDF renderer reserves a small square slot for an org mark. Logos must meet these constraints — otherwise zfy prints a warning to stderr and falls back to text-only (no crash, batch keeps running):
Expand Down
50 changes: 50 additions & 0 deletions examples/gen-sample-receipt.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Generates a sample EOY receipt PDF using the real renderer.
// Requires the package to be built first (imports from ../dist/sdk).
// Run: pnpm build && node examples/gen-sample-receipt.mjs [outPath]
import path from "node:path";
import { fileURLToPath } from "node:url";
import { writeFile } from "node:fs/promises";
import { renderDonorPdf } from "../dist/sdk/index.js";
Comment thread
EssentialsDev marked this conversation as resolved.

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const out = process.argv[2] ?? path.join(__dirname, "sample-receipt.pdf");

const donor = {
contact_id: "ct_demo",
name: "Jordan Rivera",
email: "jordan.rivera@example.org",
address: {
line1: "1428 Maple Street",
line2: "Apt 3B",
city: "Portland",
state: "OR",
postal_code: "97214",
country: "United States",
},
total_amount: 1450,
donation_count: 6,
payments: [
{ id: "p1", date: "2025-01-15T00:00:00.000Z", amount: 250, campaign: "Winter Appeal" },
{ id: "p2", date: "2025-03-02T00:00:00.000Z", amount: 100, campaign: "General Fund" },
{ id: "p3", date: "2025-05-20T00:00:00.000Z", amount: 500, campaign: "Library Renovation" },
{ id: "p4", date: "2025-08-11T00:00:00.000Z", amount: 100, campaign: "General Fund" },
{ id: "p5", date: "2025-10-04T00:00:00.000Z", amount: 250, campaign: "Fall Membership Drive" },
{ id: "p6", date: "2025-12-22T00:00:00.000Z", amount: 250, campaign: "Year-End Match" },
],
};

const report = {
year: 2025,
timezone: "America/Los_Angeles",
currency: "USD",
generated_at: "2026-01-15T17:00:00.000Z",
totals: { total_amount: 1450, donor_count: 1, donation_count: 6 },
donors: [donor],
};

const buf = await renderDonorPdf(donor, report, {
orgName: "Friends of the Library",
logoPath: path.join(__dirname, "sample-logo.png"),
});
await writeFile(out, buf);
console.log(`Wrote ${out} (${buf.length} bytes)`);
Binary file added examples/sample-receipt.pdf
Binary file not shown.
Binary file added examples/sample-receipt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/cli/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function reportCommand(): Command {
.option("--top <n>", "Markdown: limit donor table to top N", (v) => Number(v))
.option("--org <name>", "PDF: organization name for the header")
.option("--logo <path>", "PDF: path to a square PNG/JPEG logo (~512×512, max 2 MB)")
.option("--logo-size <pt>", "PDF: edge length of the square logo slot in points (default 64)", (v) => Number(v))
.option("--logo-size <pt>", "PDF: edge length of the square logo slot in points (default 48)", (v) => Number(v))
.option("--receipt-text <text>", "PDF: override receipt boilerplate")
.action(async (opts: Record<string, string | number | boolean | undefined>) => {
const year = opts["year"] as number;
Expand Down
17 changes: 12 additions & 5 deletions src/report/formats/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface PdfOptions {
receiptText?: string;
/** Path to a square PNG or JPEG logo. See validateLogo() for the spec. */
logoPath?: string;
/** Edge length in pt for the square logo slot (default 64). */
/** Edge length in pt for the square logo slot (default 48). */
logoSize?: number;
/** Stream to write validation warnings to. Defaults to process.stderr. */
warnStream?: { write(s: string): void };
Expand All @@ -19,7 +19,7 @@ export interface PdfOptions {
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_MIN_PIXELS = 64;
const LOGO_ASPECT_TOLERANCE = 0.1; // ±10% off 1:1 is still accepted as "square"
const DEFAULT_LOGO_SIZE = 64;
const DEFAULT_LOGO_SIZE = 48;

Comment thread
EssentialsDev marked this conversation as resolved.
export type LogoValidation =
| { ok: true; width: number; height: number; format: "png" | "jpeg" }
Expand Down Expand Up @@ -177,6 +177,7 @@ export function renderDonorPdf(

const headerY = doc.y;
const logoSize = opts.logoSize ?? DEFAULT_LOGO_SIZE;
const logoGutter = opts.logoPath ? logoSize + 14 : 0;
if (opts.logoPath) {
try {
doc.image(opts.logoPath, left, headerY, { fit: [logoSize, logoSize] });
Expand All @@ -185,21 +186,27 @@ export function renderDonorPdf(
}
}

const textX = left + logoGutter;
const textWidth = usable - logoGutter;
doc
.font("Helvetica-Bold")
.fontSize(20)
.fillColor("black")
.text(orgName, left + (opts.logoPath ? logoSize + 14 : 0), headerY + 4, {
width: usable,
.text(orgName, textX, headerY + 4, {
width: textWidth,
});
doc
.font("Helvetica")
.fontSize(9)
.fillColor(MUTED)
.text(`OFFICIAL DONATION RECEIPT · ${report.year}`, {
.text(`OFFICIAL DONATION RECEIPT · ${report.year}`, textX, doc.y, {
width: textWidth,
characterSpacing: 1.2,
});

// Make sure the next section starts below the logo, not on top of it.
doc.x = left;
doc.y = Math.max(doc.y, headerY + logoSize);
doc.moveDown(1.5);
doc.fillColor("black");

Expand Down
Loading