@devessentials/zfy-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,708 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/sdk/schemas.ts
4
+ import { z } from "zod";
5
+ var dateOrUnix = z.union([z.string(), z.number()]);
6
+ var AddressSchema = z.object({
7
+ line1: z.string().nullish(),
8
+ line2: z.string().nullish(),
9
+ city: z.string().nullish(),
10
+ state: z.string().nullish(),
11
+ postal_code: z.string().nullish(),
12
+ country: z.string().nullish()
13
+ }).partial().passthrough();
14
+ var ContactSchema = z.object({
15
+ id: z.string(),
16
+ email: z.string().nullish(),
17
+ first_name: z.string().nullish(),
18
+ last_name: z.string().nullish(),
19
+ name: z.string().nullish(),
20
+ phone: z.string().nullish(),
21
+ address: AddressSchema.nullish(),
22
+ created: dateOrUnix.nullish(),
23
+ updated: dateOrUnix.nullish()
24
+ }).passthrough();
25
+ var CampaignSchema = z.object({
26
+ id: z.string(),
27
+ name: z.string().nullish(),
28
+ type: z.string().nullish(),
29
+ status: z.string().nullish(),
30
+ goal: z.number().nullish(),
31
+ raised: z.number().nullish(),
32
+ currency: z.string().nullish(),
33
+ created: dateOrUnix.nullish()
34
+ }).passthrough();
35
+ var PaymentLineItemSchema = z.object({
36
+ id: z.string().nullish(),
37
+ description: z.string().nullish(),
38
+ amount: z.number().nullish(),
39
+ quantity: z.number().nullish()
40
+ }).passthrough();
41
+ var PaymentSchema = z.object({
42
+ id: z.string(),
43
+ amount: z.number(),
44
+ currency: z.string().nullish(),
45
+ status: z.string().nullish(),
46
+ type: z.string().nullish(),
47
+ contact_id: z.string().nullish(),
48
+ contact: ContactSchema.nullish(),
49
+ campaign_id: z.string().nullish(),
50
+ campaign: CampaignSchema.nullish(),
51
+ line_items: z.array(PaymentLineItemSchema).nullish(),
52
+ tax_receipt_eligible_amount: z.number().nullish(),
53
+ refunded: z.boolean().nullish(),
54
+ refunded_amount: z.number().nullish(),
55
+ created: dateOrUnix
56
+ }).passthrough();
57
+ var ListResponseSchema = (item) => z.object({
58
+ data: z.array(item),
59
+ has_more: z.boolean(),
60
+ next_cursor: z.string().nullish()
61
+ });
62
+
63
+ // src/sdk/client.ts
64
+ var ZeffyApiError = class extends Error {
65
+ constructor(status, message, body) {
66
+ super(message);
67
+ this.status = status;
68
+ this.body = body;
69
+ this.name = "ZeffyApiError";
70
+ }
71
+ status;
72
+ body;
73
+ };
74
+ var ZeffyClient = class {
75
+ apiKey;
76
+ baseUrl;
77
+ maxRetries;
78
+ fetchFn;
79
+ requestTimestamps = [];
80
+ rateLimit;
81
+ constructor(opts) {
82
+ if (!opts.apiKey) throw new Error("ZeffyClient: apiKey is required");
83
+ this.apiKey = opts.apiKey;
84
+ this.baseUrl = (opts.baseUrl ?? "https://api.zeffy.com/api/v1").replace(/\/+$/, "");
85
+ this.maxRetries = opts.maxRetries ?? 5;
86
+ this.fetchFn = opts.fetch ?? globalThis.fetch;
87
+ this.rateLimit = opts.rateLimitPerMinute ?? 90;
88
+ }
89
+ async throttle() {
90
+ const now = Date.now();
91
+ const windowStart = now - 6e4;
92
+ this.requestTimestamps = this.requestTimestamps.filter((t) => t > windowStart);
93
+ if (this.requestTimestamps.length >= this.rateLimit) {
94
+ const oldest = this.requestTimestamps[0];
95
+ if (oldest !== void 0) {
96
+ const waitMs = 6e4 - (now - oldest) + 50;
97
+ await sleep(waitMs);
98
+ }
99
+ }
100
+ this.requestTimestamps.push(Date.now());
101
+ }
102
+ async request(path2, params) {
103
+ const url = new URL(this.baseUrl + path2);
104
+ if (params) {
105
+ for (const [k, v] of Object.entries(params)) {
106
+ if (v === void 0 || v === null) continue;
107
+ url.searchParams.set(k, String(v));
108
+ }
109
+ }
110
+ let attempt = 0;
111
+ while (true) {
112
+ await this.throttle();
113
+ const res = await this.fetchFn(url.toString(), {
114
+ headers: {
115
+ Authorization: `Bearer ${this.apiKey}`,
116
+ Accept: "application/json",
117
+ "User-Agent": "zfy-cli/0.1.0 (+https://github.com/EssentialsDev/zfy-cli)"
118
+ }
119
+ });
120
+ if (res.ok) {
121
+ return await res.json();
122
+ }
123
+ const retryable = res.status === 429 || res.status >= 500 && res.status < 600;
124
+ if (retryable && attempt < this.maxRetries) {
125
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after"));
126
+ const backoff = retryAfter ?? Math.min(2 ** attempt * 500, 3e4);
127
+ await sleep(backoff);
128
+ attempt++;
129
+ continue;
130
+ }
131
+ let body;
132
+ try {
133
+ body = await res.json();
134
+ } catch {
135
+ body = await res.text().catch(() => void 0);
136
+ }
137
+ const msg = typeof body === "object" && body && "message" in body ? String(body.message) : res.statusText || `HTTP ${res.status}`;
138
+ throw new ZeffyApiError(res.status, msg, body);
139
+ }
140
+ }
141
+ async list(path2, schema, params = {}) {
142
+ const raw = await this.request(path2, params);
143
+ const parsed = ListResponseSchema(schema).safeParse(raw);
144
+ if (!parsed.success) {
145
+ throw new ZeffyApiError(
146
+ 500,
147
+ `Failed to parse response from ${path2}: ${parsed.error.message}`,
148
+ raw
149
+ );
150
+ }
151
+ return parsed.data;
152
+ }
153
+ async *iterate(path2, schema, params = {}) {
154
+ let cursor;
155
+ const pageLimit = typeof params["limit"] === "number" ? params["limit"] : 100;
156
+ while (true) {
157
+ const page = await this.list(path2, schema, {
158
+ ...params,
159
+ limit: pageLimit,
160
+ starting_after: cursor
161
+ });
162
+ for (const item of page.data) yield item;
163
+ if (!page.has_more || !page.next_cursor) return;
164
+ cursor = page.next_cursor;
165
+ }
166
+ }
167
+ async collect(path2, schema, params = {}, max) {
168
+ const out = [];
169
+ for await (const item of this.iterate(path2, schema, params)) {
170
+ out.push(item);
171
+ if (max !== void 0 && out.length >= max) break;
172
+ }
173
+ return out;
174
+ }
175
+ };
176
+ function sleep(ms) {
177
+ return new Promise((r) => setTimeout(r, ms));
178
+ }
179
+ function parseRetryAfter(header) {
180
+ if (!header) return void 0;
181
+ const secs = Number(header);
182
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
183
+ const dateMs = Date.parse(header);
184
+ if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
185
+ return void 0;
186
+ }
187
+
188
+ // src/sdk/payments.ts
189
+ function toParams(f) {
190
+ const out = {
191
+ limit: f.limit,
192
+ starting_after: f.starting_after,
193
+ currency: f.currency,
194
+ status: f.status,
195
+ type: f.type,
196
+ contact_id: f.contact_id,
197
+ campaign_id: f.campaign_id
198
+ };
199
+ if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
200
+ if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
201
+ return out;
202
+ }
203
+ var PaymentsResource = class {
204
+ constructor(client) {
205
+ this.client = client;
206
+ }
207
+ client;
208
+ list(filters = {}) {
209
+ return this.client.list("/payments", PaymentSchema, toParams(filters));
210
+ }
211
+ iterate(filters = {}) {
212
+ return this.client.iterate("/payments", PaymentSchema, toParams(filters));
213
+ }
214
+ collect(filters = {}, max) {
215
+ return this.client.collect("/payments", PaymentSchema, toParams(filters), max);
216
+ }
217
+ };
218
+
219
+ // src/sdk/contacts.ts
220
+ function toParams2(f) {
221
+ const out = {
222
+ limit: f.limit,
223
+ starting_after: f.starting_after,
224
+ email: f.email
225
+ };
226
+ if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
227
+ if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
228
+ if (f.updated_gte !== void 0) out["updated[gte]"] = f.updated_gte;
229
+ if (f.updated_lte !== void 0) out["updated[lte]"] = f.updated_lte;
230
+ return out;
231
+ }
232
+ var ContactsResource = class {
233
+ constructor(client) {
234
+ this.client = client;
235
+ }
236
+ client;
237
+ list(filters = {}) {
238
+ return this.client.list("/contacts", ContactSchema, toParams2(filters));
239
+ }
240
+ iterate(filters = {}) {
241
+ return this.client.iterate("/contacts", ContactSchema, toParams2(filters));
242
+ }
243
+ collect(filters = {}, max) {
244
+ return this.client.collect("/contacts", ContactSchema, toParams2(filters), max);
245
+ }
246
+ };
247
+
248
+ // src/sdk/campaigns.ts
249
+ function toParams3(f) {
250
+ const out = {
251
+ limit: f.limit,
252
+ starting_after: f.starting_after
253
+ };
254
+ if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
255
+ if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
256
+ return out;
257
+ }
258
+ var CampaignsResource = class {
259
+ constructor(client) {
260
+ this.client = client;
261
+ }
262
+ client;
263
+ list(filters = {}) {
264
+ return this.client.list("/campaigns", CampaignSchema, toParams3(filters));
265
+ }
266
+ iterate(filters = {}) {
267
+ return this.client.iterate("/campaigns", CampaignSchema, toParams3(filters));
268
+ }
269
+ collect(filters = {}, max) {
270
+ return this.client.collect("/campaigns", CampaignSchema, toParams3(filters), max);
271
+ }
272
+ };
273
+
274
+ // src/report/eoy.ts
275
+ import { fromZonedTime } from "date-fns-tz";
276
+ async function buildEoyReport(zeffy, opts) {
277
+ const tz = opts.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
278
+ const start = fromZonedTime(`${opts.year}-01-01T00:00:00`, tz);
279
+ const end = fromZonedTime(`${opts.year + 1}-01-01T00:00:00`, tz);
280
+ const created_gte = Math.floor(start.getTime() / 1e3);
281
+ const created_lte = Math.floor(end.getTime() / 1e3) - 1;
282
+ const excludeRefunded = opts.excludeRefunded ?? true;
283
+ const payments = [];
284
+ for await (const p of zeffy.payments.iterate({
285
+ created_gte,
286
+ created_lte,
287
+ currency: opts.currency,
288
+ status: opts.status
289
+ })) {
290
+ if (excludeRefunded && p.refunded) continue;
291
+ payments.push(p);
292
+ }
293
+ const donorMap = /* @__PURE__ */ new Map();
294
+ let currency = opts.currency ?? "USD";
295
+ for (const p of payments) {
296
+ if (p.currency) currency = p.currency;
297
+ const contactId = p.contact_id ?? p.contact?.id ?? null;
298
+ const key = contactId ?? `__anon__:${p.id}`;
299
+ const dateIso = toIsoString(p.created);
300
+ let donor = donorMap.get(key);
301
+ if (!donor) {
302
+ const c = p.contact;
303
+ donor = {
304
+ contact_id: contactId,
305
+ name: contactName(c),
306
+ email: c?.email ?? null,
307
+ address: c?.address ?? null,
308
+ total_amount: 0,
309
+ donation_count: 0,
310
+ payments: []
311
+ };
312
+ donorMap.set(key, donor);
313
+ }
314
+ donor.total_amount += p.amount;
315
+ donor.donation_count += 1;
316
+ donor.payments.push({
317
+ id: p.id,
318
+ date: dateIso,
319
+ amount: p.amount,
320
+ campaign: p.campaign?.name ?? null,
321
+ campaign_id: p.campaign_id ?? p.campaign?.id ?? null,
322
+ status: p.status ?? null
323
+ });
324
+ }
325
+ const donors = [...donorMap.values()].sort((a, b) => b.total_amount - a.total_amount);
326
+ const totals = donors.reduce(
327
+ (acc, d) => {
328
+ acc.total_amount += d.total_amount;
329
+ acc.donation_count += d.donation_count;
330
+ acc.donor_count += 1;
331
+ return acc;
332
+ },
333
+ { total_amount: 0, donor_count: 0, donation_count: 0 }
334
+ );
335
+ return {
336
+ year: opts.year,
337
+ timezone: tz,
338
+ currency,
339
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
340
+ totals,
341
+ donors
342
+ };
343
+ }
344
+ function contactName(c) {
345
+ if (!c) return null;
346
+ if (c.name) return c.name;
347
+ const parts = [c.first_name, c.last_name].filter(Boolean);
348
+ return parts.length ? parts.join(" ") : null;
349
+ }
350
+ function toIsoString(v) {
351
+ if (v == null) return "";
352
+ if (typeof v === "number") return new Date(v * 1e3).toISOString();
353
+ const n = Number(v);
354
+ if (Number.isFinite(n) && String(n) === v) return new Date(n * 1e3).toISOString();
355
+ return new Date(v).toISOString();
356
+ }
357
+
358
+ // src/report/formats/json.ts
359
+ function formatJson(report, pretty = true) {
360
+ return pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
361
+ }
362
+
363
+ // src/report/formats/csv.ts
364
+ import { stringify } from "csv-stringify/sync";
365
+ function formatCsv(report) {
366
+ const rows = report.donors.map((d) => ({
367
+ contact_id: d.contact_id ?? "",
368
+ name: d.name ?? "",
369
+ email: d.email ?? "",
370
+ address_line1: d.address?.line1 ?? "",
371
+ address_line2: d.address?.line2 ?? "",
372
+ city: d.address?.city ?? "",
373
+ state: d.address?.state ?? "",
374
+ postal_code: d.address?.postal_code ?? "",
375
+ country: d.address?.country ?? "",
376
+ donation_count: d.donation_count,
377
+ total_amount: d.total_amount.toFixed(2),
378
+ currency: report.currency,
379
+ year: report.year
380
+ }));
381
+ return stringify(rows, { header: true });
382
+ }
383
+
384
+ // src/report/formats/md.ts
385
+ function formatMarkdown(report, topN = 25) {
386
+ const lines = [];
387
+ const cur = report.currency;
388
+ lines.push(`# Donation report \u2014 ${report.year}`);
389
+ lines.push("");
390
+ lines.push(`Generated ${report.generated_at} \xB7 Timezone ${report.timezone}`);
391
+ lines.push("");
392
+ lines.push(`## Totals`);
393
+ lines.push(`- Total raised: ${money(report.totals.total_amount, cur)}`);
394
+ lines.push(`- Unique donors: ${report.totals.donor_count}`);
395
+ lines.push(`- Total donations: ${report.totals.donation_count}`);
396
+ if (report.totals.donor_count > 0) {
397
+ const avg = report.totals.total_amount / report.totals.donor_count;
398
+ lines.push(`- Average per donor: ${money(avg, cur)}`);
399
+ }
400
+ lines.push("");
401
+ lines.push(`## Top ${Math.min(topN, report.donors.length)} donors`);
402
+ lines.push("");
403
+ lines.push("| # | Donor | Email | Gifts | Total |");
404
+ lines.push("| --- | --- | --- | ---: | ---: |");
405
+ report.donors.slice(0, topN).forEach((d, i) => {
406
+ lines.push(
407
+ `| ${i + 1} | ${escape(d.name ?? "(anonymous)")} | ${escape(d.email ?? "")} | ${d.donation_count} | ${money(d.total_amount, cur)} |`
408
+ );
409
+ });
410
+ if (report.donors.length > topN) {
411
+ lines.push("");
412
+ lines.push(`_\u2026and ${report.donors.length - topN} more donors._`);
413
+ }
414
+ return lines.join("\n") + "\n";
415
+ }
416
+ function money(n, currency) {
417
+ try {
418
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
419
+ } catch {
420
+ return `${n.toFixed(2)} ${currency}`;
421
+ }
422
+ }
423
+ function escape(s) {
424
+ return s.replace(/\|/g, "\\|");
425
+ }
426
+
427
+ // src/report/formats/pdf.ts
428
+ import { mkdir, writeFile, readFile, stat } from "fs/promises";
429
+ import path from "path";
430
+ import PDFDocument from "pdfkit";
431
+ var LOGO_MAX_BYTES = 2 * 1024 * 1024;
432
+ var LOGO_MIN_PIXELS = 64;
433
+ var LOGO_ASPECT_TOLERANCE = 0.1;
434
+ var DEFAULT_LOGO_SIZE = 64;
435
+ async function validateLogo(logoPath) {
436
+ let stats;
437
+ try {
438
+ stats = await stat(logoPath);
439
+ } catch {
440
+ return { ok: false, reason: `file not found: ${logoPath}` };
441
+ }
442
+ if (!stats.isFile()) return { ok: false, reason: `not a regular file: ${logoPath}` };
443
+ if (stats.size > LOGO_MAX_BYTES) {
444
+ return {
445
+ ok: false,
446
+ reason: `file too large (${formatBytes(stats.size)}, max ${formatBytes(LOGO_MAX_BYTES)})`
447
+ };
448
+ }
449
+ const head = await readFile(logoPath);
450
+ const dims = readImageDimensions(head);
451
+ if (!dims) {
452
+ return { ok: false, reason: "unsupported format (only PNG and JPEG are accepted)" };
453
+ }
454
+ if (dims.width < LOGO_MIN_PIXELS || dims.height < LOGO_MIN_PIXELS) {
455
+ return {
456
+ ok: false,
457
+ reason: `image too small (${dims.width}\xD7${dims.height}, minimum ${LOGO_MIN_PIXELS}\xD7${LOGO_MIN_PIXELS})`
458
+ };
459
+ }
460
+ const ratio = dims.width / dims.height;
461
+ if (Math.abs(ratio - 1) > LOGO_ASPECT_TOLERANCE) {
462
+ return {
463
+ ok: false,
464
+ reason: `image is not square (${dims.width}\xD7${dims.height}); supply a square PNG/JPEG (e.g. 512\xD7512)`
465
+ };
466
+ }
467
+ return { ok: true, width: dims.width, height: dims.height, format: dims.format };
468
+ }
469
+ function readImageDimensions(buf) {
470
+ if (buf.length >= 24 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) {
471
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), format: "png" };
472
+ }
473
+ if (buf.length >= 4 && buf[0] === 255 && buf[1] === 216) {
474
+ let off = 2;
475
+ while (off < buf.length - 9) {
476
+ if (buf[off] !== 255) {
477
+ off++;
478
+ continue;
479
+ }
480
+ while (off < buf.length && buf[off] === 255) off++;
481
+ const marker = buf[off];
482
+ if (marker === void 0) return null;
483
+ off++;
484
+ if (marker === 217 || marker === 218) return null;
485
+ const segLen = buf.readUInt16BE(off);
486
+ const isSof = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
487
+ if (isSof && off + 7 <= buf.length) {
488
+ const height = buf.readUInt16BE(off + 3);
489
+ const width = buf.readUInt16BE(off + 5);
490
+ return { width, height, format: "jpeg" };
491
+ }
492
+ off += segLen;
493
+ }
494
+ }
495
+ return null;
496
+ }
497
+ function formatBytes(n) {
498
+ if (n < 1024) return `${n} B`;
499
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
500
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
501
+ }
502
+ var DEFAULT_RECEIPT_TEXT = "Thank you for your generous support. No goods or services were provided in exchange for these contributions. Please consult your tax advisor regarding deductibility.";
503
+ async function writePdfReceipts(report, outDir, opts = {}) {
504
+ await mkdir(outDir, { recursive: true });
505
+ const effective = await resolveLogoOptions(opts);
506
+ const written = [];
507
+ for (const donor of report.donors) {
508
+ const filename = receiptFilename(donor, report.year);
509
+ const fullPath = path.join(outDir, filename);
510
+ const buf = await renderDonorPdf(donor, report, effective);
511
+ await writeFile(fullPath, buf);
512
+ written.push(fullPath);
513
+ }
514
+ return written;
515
+ }
516
+ async function resolveLogoOptions(opts) {
517
+ if (!opts.logoPath) return opts;
518
+ const result = await validateLogo(opts.logoPath);
519
+ if (result.ok) return opts;
520
+ const out = opts.warnStream ?? process.stderr;
521
+ out.write(`zfy: skipping logo \u2014 ${result.reason}
522
+ `);
523
+ const next = { ...opts };
524
+ delete next.logoPath;
525
+ return next;
526
+ }
527
+ var ACCENT = "#1f3a5f";
528
+ var MUTED = "#6b7280";
529
+ var RULE = "#e5e7eb";
530
+ var ROW_ALT = "#f9fafb";
531
+ function renderDonorPdf(donor, report, opts = {}) {
532
+ return new Promise((resolve, reject) => {
533
+ const doc = new PDFDocument({ size: "LETTER", margin: 54 });
534
+ const chunks = [];
535
+ doc.on("data", (c) => chunks.push(c));
536
+ doc.on("end", () => resolve(Buffer.concat(chunks)));
537
+ doc.on("error", reject);
538
+ const orgName = opts.orgName ?? "Your Organization";
539
+ const left = doc.page.margins.left;
540
+ const right = doc.page.width - doc.page.margins.right;
541
+ const usable = right - left;
542
+ doc.rect(left, doc.y, usable, 3).fill(ACCENT);
543
+ doc.moveDown(0.7);
544
+ doc.fillColor("black");
545
+ const headerY = doc.y;
546
+ const logoSize = opts.logoSize ?? DEFAULT_LOGO_SIZE;
547
+ if (opts.logoPath) {
548
+ try {
549
+ doc.image(opts.logoPath, left, headerY, { fit: [logoSize, logoSize] });
550
+ } catch {
551
+ }
552
+ }
553
+ doc.font("Helvetica-Bold").fontSize(20).fillColor("black").text(orgName, left + (opts.logoPath ? logoSize + 14 : 0), headerY + 4, {
554
+ width: usable
555
+ });
556
+ doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(`OFFICIAL DONATION RECEIPT \xB7 ${report.year}`, {
557
+ characterSpacing: 1.2
558
+ });
559
+ doc.moveDown(1.5);
560
+ doc.fillColor("black");
561
+ const blockTop = doc.y;
562
+ const colWidth = (usable - 20) / 2;
563
+ doc.font("Helvetica-Bold").fontSize(9).fillColor(MUTED).text("ISSUED TO", left, blockTop, {
564
+ width: colWidth,
565
+ characterSpacing: 1
566
+ });
567
+ doc.moveDown(0.3);
568
+ doc.font("Helvetica-Bold").fontSize(12).fillColor("black").text(donor.name ?? "(Anonymous donor)", {
569
+ width: colWidth
570
+ });
571
+ doc.font("Helvetica").fontSize(10).fillColor("#374151");
572
+ if (donor.email) doc.text(donor.email, { width: colWidth });
573
+ if (donor.address) {
574
+ const a = donor.address;
575
+ const addrLines = [
576
+ a.line1,
577
+ a.line2,
578
+ [a.city, a.state, a.postal_code].filter(Boolean).join(", "),
579
+ a.country
580
+ ].filter(Boolean);
581
+ for (const line of addrLines) doc.text(line, { width: colWidth });
582
+ }
583
+ const rightColX = left + colWidth + 20;
584
+ doc.font("Helvetica-Bold").fontSize(9).fillColor(MUTED).text("ISSUED", rightColX, blockTop, {
585
+ width: colWidth,
586
+ characterSpacing: 1
587
+ });
588
+ doc.moveDown(0.3);
589
+ doc.font("Helvetica").fontSize(10).fillColor("black").text(formatIssuedDate(report.generated_at), { width: colWidth });
590
+ doc.moveDown(0.5);
591
+ doc.font("Helvetica-Bold").fontSize(9).fillColor(MUTED).text("DONATIONS", {
592
+ width: colWidth,
593
+ characterSpacing: 1
594
+ });
595
+ doc.moveDown(0.3);
596
+ doc.font("Helvetica").fontSize(10).fillColor("black").text(`${donor.donation_count} gift${donor.donation_count === 1 ? "" : "s"} in ${report.year}`, {
597
+ width: colWidth
598
+ });
599
+ doc.x = left;
600
+ doc.y = Math.max(doc.y, blockTop + 110);
601
+ doc.moveDown(0.5);
602
+ const boxY = doc.y;
603
+ doc.roundedRect(left, boxY, usable, 64, 6).fill(ACCENT);
604
+ doc.font("Helvetica").fontSize(9).fillColor("#cbd5e1").text("TOTAL CONTRIBUTED", left + 20, boxY + 14, {
605
+ characterSpacing: 1.2,
606
+ width: usable - 40
607
+ });
608
+ doc.font("Helvetica-Bold").fontSize(28).fillColor("white").text(money2(donor.total_amount, report.currency), left + 20, boxY + 28, {
609
+ width: usable - 40
610
+ });
611
+ doc.y = boxY + 64;
612
+ doc.moveDown(1.2);
613
+ doc.fillColor("black");
614
+ doc.font("Helvetica-Bold").fontSize(10).text("Itemized gifts", left);
615
+ doc.moveDown(0.5);
616
+ const tableTop = doc.y;
617
+ const colDate = left;
618
+ const colCampaign = left + 90;
619
+ const colAmount = right;
620
+ const rowHeight = 22;
621
+ doc.rect(left, tableTop, usable, rowHeight).fill(ROW_ALT);
622
+ doc.font("Helvetica-Bold").fontSize(9).fillColor(MUTED).text("DATE", colDate + 10, tableTop + 7, { characterSpacing: 1 });
623
+ doc.text("CAMPAIGN", colCampaign, tableTop + 7, { characterSpacing: 1 });
624
+ doc.text("AMOUNT", left, tableTop + 7, {
625
+ width: usable - 10,
626
+ align: "right",
627
+ characterSpacing: 1
628
+ });
629
+ let rowY = tableTop + rowHeight;
630
+ donor.payments.forEach((p, i) => {
631
+ if (i % 2 === 1) {
632
+ doc.rect(left, rowY, usable, rowHeight).fill(ROW_ALT);
633
+ }
634
+ doc.font("Helvetica").fontSize(10).fillColor("black").text(p.date ? p.date.slice(0, 10) : "\u2014", colDate + 10, rowY + 7);
635
+ doc.text(p.campaign ?? "\u2014", colCampaign, rowY + 7, {
636
+ width: colAmount - colCampaign - 90,
637
+ ellipsis: true
638
+ });
639
+ doc.text(money2(p.amount, report.currency), left, rowY + 7, {
640
+ width: usable - 10,
641
+ align: "right"
642
+ });
643
+ rowY += rowHeight;
644
+ });
645
+ doc.moveTo(left, rowY).lineTo(right, rowY).strokeColor(RULE).lineWidth(0.5).stroke();
646
+ doc.y = rowY + 20;
647
+ doc.moveTo(left, doc.y).lineTo(right, doc.y).strokeColor(RULE).lineWidth(0.5).stroke();
648
+ doc.moveDown(0.8);
649
+ doc.font("Helvetica-Oblique").fontSize(8.5).fillColor(MUTED).text(opts.receiptText ?? DEFAULT_RECEIPT_TEXT, left, doc.y, {
650
+ width: usable,
651
+ align: "left",
652
+ lineGap: 2
653
+ });
654
+ doc.end();
655
+ });
656
+ }
657
+ function formatIssuedDate(iso) {
658
+ const d = new Date(iso);
659
+ if (Number.isNaN(d.getTime())) return iso;
660
+ return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
661
+ }
662
+ function money2(n, currency) {
663
+ try {
664
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
665
+ } catch {
666
+ return `${n.toFixed(2)} ${currency}`;
667
+ }
668
+ }
669
+ function receiptFilename(donor, year) {
670
+ const slug = (donor.name ?? donor.email ?? donor.contact_id ?? "anonymous").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 60);
671
+ return `${year}-${slug || "anonymous"}.pdf`;
672
+ }
673
+
674
+ // src/sdk/index.ts
675
+ var Zeffy = class {
676
+ client;
677
+ payments;
678
+ contacts;
679
+ campaigns;
680
+ constructor(opts) {
681
+ const options = typeof opts === "string" ? { apiKey: opts } : opts;
682
+ this.client = new ZeffyClient(options);
683
+ this.payments = new PaymentsResource(this.client);
684
+ this.contacts = new ContactsResource(this.client);
685
+ this.campaigns = new CampaignsResource(this.client);
686
+ }
687
+ };
688
+ export {
689
+ AddressSchema,
690
+ CampaignSchema,
691
+ CampaignsResource,
692
+ ContactSchema,
693
+ ContactsResource,
694
+ ListResponseSchema,
695
+ PaymentLineItemSchema,
696
+ PaymentSchema,
697
+ PaymentsResource,
698
+ Zeffy,
699
+ ZeffyApiError,
700
+ ZeffyClient,
701
+ buildEoyReport,
702
+ formatCsv,
703
+ formatJson,
704
+ formatMarkdown,
705
+ renderDonorPdf,
706
+ validateLogo,
707
+ writePdfReceipts
708
+ };