@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,593 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp/server.ts
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z as z2 } from "zod";
7
+
8
+ // src/sdk/schemas.ts
9
+ import { z } from "zod";
10
+ var dateOrUnix = z.union([z.string(), z.number()]);
11
+ var AddressSchema = z.object({
12
+ line1: z.string().nullish(),
13
+ line2: z.string().nullish(),
14
+ city: z.string().nullish(),
15
+ state: z.string().nullish(),
16
+ postal_code: z.string().nullish(),
17
+ country: z.string().nullish()
18
+ }).partial().passthrough();
19
+ var ContactSchema = z.object({
20
+ id: z.string(),
21
+ email: z.string().nullish(),
22
+ first_name: z.string().nullish(),
23
+ last_name: z.string().nullish(),
24
+ name: z.string().nullish(),
25
+ phone: z.string().nullish(),
26
+ address: AddressSchema.nullish(),
27
+ created: dateOrUnix.nullish(),
28
+ updated: dateOrUnix.nullish()
29
+ }).passthrough();
30
+ var CampaignSchema = z.object({
31
+ id: z.string(),
32
+ name: z.string().nullish(),
33
+ type: z.string().nullish(),
34
+ status: z.string().nullish(),
35
+ goal: z.number().nullish(),
36
+ raised: z.number().nullish(),
37
+ currency: z.string().nullish(),
38
+ created: dateOrUnix.nullish()
39
+ }).passthrough();
40
+ var PaymentLineItemSchema = z.object({
41
+ id: z.string().nullish(),
42
+ description: z.string().nullish(),
43
+ amount: z.number().nullish(),
44
+ quantity: z.number().nullish()
45
+ }).passthrough();
46
+ var PaymentSchema = z.object({
47
+ id: z.string(),
48
+ amount: z.number(),
49
+ currency: z.string().nullish(),
50
+ status: z.string().nullish(),
51
+ type: z.string().nullish(),
52
+ contact_id: z.string().nullish(),
53
+ contact: ContactSchema.nullish(),
54
+ campaign_id: z.string().nullish(),
55
+ campaign: CampaignSchema.nullish(),
56
+ line_items: z.array(PaymentLineItemSchema).nullish(),
57
+ tax_receipt_eligible_amount: z.number().nullish(),
58
+ refunded: z.boolean().nullish(),
59
+ refunded_amount: z.number().nullish(),
60
+ created: dateOrUnix
61
+ }).passthrough();
62
+ var ListResponseSchema = (item) => z.object({
63
+ data: z.array(item),
64
+ has_more: z.boolean(),
65
+ next_cursor: z.string().nullish()
66
+ });
67
+
68
+ // src/sdk/client.ts
69
+ var ZeffyApiError = class extends Error {
70
+ constructor(status, message, body) {
71
+ super(message);
72
+ this.status = status;
73
+ this.body = body;
74
+ this.name = "ZeffyApiError";
75
+ }
76
+ status;
77
+ body;
78
+ };
79
+ var ZeffyClient = class {
80
+ apiKey;
81
+ baseUrl;
82
+ maxRetries;
83
+ fetchFn;
84
+ requestTimestamps = [];
85
+ rateLimit;
86
+ constructor(opts) {
87
+ if (!opts.apiKey) throw new Error("ZeffyClient: apiKey is required");
88
+ this.apiKey = opts.apiKey;
89
+ this.baseUrl = (opts.baseUrl ?? "https://api.zeffy.com/api/v1").replace(/\/+$/, "");
90
+ this.maxRetries = opts.maxRetries ?? 5;
91
+ this.fetchFn = opts.fetch ?? globalThis.fetch;
92
+ this.rateLimit = opts.rateLimitPerMinute ?? 90;
93
+ }
94
+ async throttle() {
95
+ const now = Date.now();
96
+ const windowStart = now - 6e4;
97
+ this.requestTimestamps = this.requestTimestamps.filter((t) => t > windowStart);
98
+ if (this.requestTimestamps.length >= this.rateLimit) {
99
+ const oldest = this.requestTimestamps[0];
100
+ if (oldest !== void 0) {
101
+ const waitMs = 6e4 - (now - oldest) + 50;
102
+ await sleep(waitMs);
103
+ }
104
+ }
105
+ this.requestTimestamps.push(Date.now());
106
+ }
107
+ async request(path3, params) {
108
+ const url = new URL(this.baseUrl + path3);
109
+ if (params) {
110
+ for (const [k, v] of Object.entries(params)) {
111
+ if (v === void 0 || v === null) continue;
112
+ url.searchParams.set(k, String(v));
113
+ }
114
+ }
115
+ let attempt = 0;
116
+ while (true) {
117
+ await this.throttle();
118
+ const res = await this.fetchFn(url.toString(), {
119
+ headers: {
120
+ Authorization: `Bearer ${this.apiKey}`,
121
+ Accept: "application/json",
122
+ "User-Agent": "zfy-cli/0.1.0 (+https://github.com/EssentialsDev/zfy-cli)"
123
+ }
124
+ });
125
+ if (res.ok) {
126
+ return await res.json();
127
+ }
128
+ const retryable = res.status === 429 || res.status >= 500 && res.status < 600;
129
+ if (retryable && attempt < this.maxRetries) {
130
+ const retryAfter = parseRetryAfter(res.headers.get("retry-after"));
131
+ const backoff = retryAfter ?? Math.min(2 ** attempt * 500, 3e4);
132
+ await sleep(backoff);
133
+ attempt++;
134
+ continue;
135
+ }
136
+ let body;
137
+ try {
138
+ body = await res.json();
139
+ } catch {
140
+ body = await res.text().catch(() => void 0);
141
+ }
142
+ const msg = typeof body === "object" && body && "message" in body ? String(body.message) : res.statusText || `HTTP ${res.status}`;
143
+ throw new ZeffyApiError(res.status, msg, body);
144
+ }
145
+ }
146
+ async list(path3, schema, params = {}) {
147
+ const raw = await this.request(path3, params);
148
+ const parsed = ListResponseSchema(schema).safeParse(raw);
149
+ if (!parsed.success) {
150
+ throw new ZeffyApiError(
151
+ 500,
152
+ `Failed to parse response from ${path3}: ${parsed.error.message}`,
153
+ raw
154
+ );
155
+ }
156
+ return parsed.data;
157
+ }
158
+ async *iterate(path3, schema, params = {}) {
159
+ let cursor;
160
+ const pageLimit = typeof params["limit"] === "number" ? params["limit"] : 100;
161
+ while (true) {
162
+ const page = await this.list(path3, schema, {
163
+ ...params,
164
+ limit: pageLimit,
165
+ starting_after: cursor
166
+ });
167
+ for (const item of page.data) yield item;
168
+ if (!page.has_more || !page.next_cursor) return;
169
+ cursor = page.next_cursor;
170
+ }
171
+ }
172
+ async collect(path3, schema, params = {}, max) {
173
+ const out = [];
174
+ for await (const item of this.iterate(path3, schema, params)) {
175
+ out.push(item);
176
+ if (max !== void 0 && out.length >= max) break;
177
+ }
178
+ return out;
179
+ }
180
+ };
181
+ function sleep(ms) {
182
+ return new Promise((r) => setTimeout(r, ms));
183
+ }
184
+ function parseRetryAfter(header) {
185
+ if (!header) return void 0;
186
+ const secs = Number(header);
187
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
188
+ const dateMs = Date.parse(header);
189
+ if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
190
+ return void 0;
191
+ }
192
+
193
+ // src/sdk/payments.ts
194
+ function toParams(f) {
195
+ const out = {
196
+ limit: f.limit,
197
+ starting_after: f.starting_after,
198
+ currency: f.currency,
199
+ status: f.status,
200
+ type: f.type,
201
+ contact_id: f.contact_id,
202
+ campaign_id: f.campaign_id
203
+ };
204
+ if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
205
+ if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
206
+ return out;
207
+ }
208
+ var PaymentsResource = class {
209
+ constructor(client) {
210
+ this.client = client;
211
+ }
212
+ client;
213
+ list(filters = {}) {
214
+ return this.client.list("/payments", PaymentSchema, toParams(filters));
215
+ }
216
+ iterate(filters = {}) {
217
+ return this.client.iterate("/payments", PaymentSchema, toParams(filters));
218
+ }
219
+ collect(filters = {}, max) {
220
+ return this.client.collect("/payments", PaymentSchema, toParams(filters), max);
221
+ }
222
+ };
223
+
224
+ // src/sdk/contacts.ts
225
+ function toParams2(f) {
226
+ const out = {
227
+ limit: f.limit,
228
+ starting_after: f.starting_after,
229
+ email: f.email
230
+ };
231
+ if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
232
+ if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
233
+ if (f.updated_gte !== void 0) out["updated[gte]"] = f.updated_gte;
234
+ if (f.updated_lte !== void 0) out["updated[lte]"] = f.updated_lte;
235
+ return out;
236
+ }
237
+ var ContactsResource = class {
238
+ constructor(client) {
239
+ this.client = client;
240
+ }
241
+ client;
242
+ list(filters = {}) {
243
+ return this.client.list("/contacts", ContactSchema, toParams2(filters));
244
+ }
245
+ iterate(filters = {}) {
246
+ return this.client.iterate("/contacts", ContactSchema, toParams2(filters));
247
+ }
248
+ collect(filters = {}, max) {
249
+ return this.client.collect("/contacts", ContactSchema, toParams2(filters), max);
250
+ }
251
+ };
252
+
253
+ // src/sdk/campaigns.ts
254
+ function toParams3(f) {
255
+ const out = {
256
+ limit: f.limit,
257
+ starting_after: f.starting_after
258
+ };
259
+ if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
260
+ if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
261
+ return out;
262
+ }
263
+ var CampaignsResource = class {
264
+ constructor(client) {
265
+ this.client = client;
266
+ }
267
+ client;
268
+ list(filters = {}) {
269
+ return this.client.list("/campaigns", CampaignSchema, toParams3(filters));
270
+ }
271
+ iterate(filters = {}) {
272
+ return this.client.iterate("/campaigns", CampaignSchema, toParams3(filters));
273
+ }
274
+ collect(filters = {}, max) {
275
+ return this.client.collect("/campaigns", CampaignSchema, toParams3(filters), max);
276
+ }
277
+ };
278
+
279
+ // src/report/eoy.ts
280
+ import { fromZonedTime } from "date-fns-tz";
281
+ async function buildEoyReport(zeffy, opts) {
282
+ const tz = opts.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
283
+ const start = fromZonedTime(`${opts.year}-01-01T00:00:00`, tz);
284
+ const end = fromZonedTime(`${opts.year + 1}-01-01T00:00:00`, tz);
285
+ const created_gte = Math.floor(start.getTime() / 1e3);
286
+ const created_lte = Math.floor(end.getTime() / 1e3) - 1;
287
+ const excludeRefunded = opts.excludeRefunded ?? true;
288
+ const payments = [];
289
+ for await (const p of zeffy.payments.iterate({
290
+ created_gte,
291
+ created_lte,
292
+ currency: opts.currency,
293
+ status: opts.status
294
+ })) {
295
+ if (excludeRefunded && p.refunded) continue;
296
+ payments.push(p);
297
+ }
298
+ const donorMap = /* @__PURE__ */ new Map();
299
+ let currency = opts.currency ?? "USD";
300
+ for (const p of payments) {
301
+ if (p.currency) currency = p.currency;
302
+ const contactId = p.contact_id ?? p.contact?.id ?? null;
303
+ const key = contactId ?? `__anon__:${p.id}`;
304
+ const dateIso = toIsoString(p.created);
305
+ let donor = donorMap.get(key);
306
+ if (!donor) {
307
+ const c = p.contact;
308
+ donor = {
309
+ contact_id: contactId,
310
+ name: contactName(c),
311
+ email: c?.email ?? null,
312
+ address: c?.address ?? null,
313
+ total_amount: 0,
314
+ donation_count: 0,
315
+ payments: []
316
+ };
317
+ donorMap.set(key, donor);
318
+ }
319
+ donor.total_amount += p.amount;
320
+ donor.donation_count += 1;
321
+ donor.payments.push({
322
+ id: p.id,
323
+ date: dateIso,
324
+ amount: p.amount,
325
+ campaign: p.campaign?.name ?? null,
326
+ campaign_id: p.campaign_id ?? p.campaign?.id ?? null,
327
+ status: p.status ?? null
328
+ });
329
+ }
330
+ const donors = [...donorMap.values()].sort((a, b) => b.total_amount - a.total_amount);
331
+ const totals = donors.reduce(
332
+ (acc, d) => {
333
+ acc.total_amount += d.total_amount;
334
+ acc.donation_count += d.donation_count;
335
+ acc.donor_count += 1;
336
+ return acc;
337
+ },
338
+ { total_amount: 0, donor_count: 0, donation_count: 0 }
339
+ );
340
+ return {
341
+ year: opts.year,
342
+ timezone: tz,
343
+ currency,
344
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
345
+ totals,
346
+ donors
347
+ };
348
+ }
349
+ function contactName(c) {
350
+ if (!c) return null;
351
+ if (c.name) return c.name;
352
+ const parts = [c.first_name, c.last_name].filter(Boolean);
353
+ return parts.length ? parts.join(" ") : null;
354
+ }
355
+ function toIsoString(v) {
356
+ if (v == null) return "";
357
+ if (typeof v === "number") return new Date(v * 1e3).toISOString();
358
+ const n = Number(v);
359
+ if (Number.isFinite(n) && String(n) === v) return new Date(n * 1e3).toISOString();
360
+ return new Date(v).toISOString();
361
+ }
362
+
363
+ // src/report/formats/csv.ts
364
+ import { stringify } from "csv-stringify/sync";
365
+
366
+ // src/report/formats/md.ts
367
+ function formatMarkdown(report, topN = 25) {
368
+ const lines = [];
369
+ const cur = report.currency;
370
+ lines.push(`# Donation report \u2014 ${report.year}`);
371
+ lines.push("");
372
+ lines.push(`Generated ${report.generated_at} \xB7 Timezone ${report.timezone}`);
373
+ lines.push("");
374
+ lines.push(`## Totals`);
375
+ lines.push(`- Total raised: ${money(report.totals.total_amount, cur)}`);
376
+ lines.push(`- Unique donors: ${report.totals.donor_count}`);
377
+ lines.push(`- Total donations: ${report.totals.donation_count}`);
378
+ if (report.totals.donor_count > 0) {
379
+ const avg = report.totals.total_amount / report.totals.donor_count;
380
+ lines.push(`- Average per donor: ${money(avg, cur)}`);
381
+ }
382
+ lines.push("");
383
+ lines.push(`## Top ${Math.min(topN, report.donors.length)} donors`);
384
+ lines.push("");
385
+ lines.push("| # | Donor | Email | Gifts | Total |");
386
+ lines.push("| --- | --- | --- | ---: | ---: |");
387
+ report.donors.slice(0, topN).forEach((d, i) => {
388
+ lines.push(
389
+ `| ${i + 1} | ${escape(d.name ?? "(anonymous)")} | ${escape(d.email ?? "")} | ${d.donation_count} | ${money(d.total_amount, cur)} |`
390
+ );
391
+ });
392
+ if (report.donors.length > topN) {
393
+ lines.push("");
394
+ lines.push(`_\u2026and ${report.donors.length - topN} more donors._`);
395
+ }
396
+ return lines.join("\n") + "\n";
397
+ }
398
+ function money(n, currency) {
399
+ try {
400
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
401
+ } catch {
402
+ return `${n.toFixed(2)} ${currency}`;
403
+ }
404
+ }
405
+ function escape(s) {
406
+ return s.replace(/\|/g, "\\|");
407
+ }
408
+
409
+ // src/report/formats/pdf.ts
410
+ import { mkdir, writeFile, readFile, stat } from "fs/promises";
411
+ import path from "path";
412
+ import PDFDocument from "pdfkit";
413
+ var LOGO_MAX_BYTES = 2 * 1024 * 1024;
414
+
415
+ // src/sdk/index.ts
416
+ var Zeffy = class {
417
+ client;
418
+ payments;
419
+ contacts;
420
+ campaigns;
421
+ constructor(opts) {
422
+ const options = typeof opts === "string" ? { apiKey: opts } : opts;
423
+ this.client = new ZeffyClient(options);
424
+ this.payments = new PaymentsResource(this.client);
425
+ this.contacts = new ContactsResource(this.client);
426
+ this.campaigns = new CampaignsResource(this.client);
427
+ }
428
+ };
429
+
430
+ // src/cli/config.ts
431
+ import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, chmod, unlink } from "fs/promises";
432
+ import path2 from "path";
433
+ import os from "os";
434
+ function configDir() {
435
+ const xdg = process.env["XDG_CONFIG_HOME"];
436
+ if (xdg) return path2.join(xdg, "zfy");
437
+ return path2.join(os.homedir(), ".config", "zfy");
438
+ }
439
+ function configPath() {
440
+ return path2.join(configDir(), "config.json");
441
+ }
442
+ async function readConfig() {
443
+ try {
444
+ const raw = await readFile2(configPath(), "utf8");
445
+ const parsed = JSON.parse(raw);
446
+ return typeof parsed === "object" && parsed ? parsed : {};
447
+ } catch (err) {
448
+ if (err.code === "ENOENT") return {};
449
+ throw err;
450
+ }
451
+ }
452
+ async function resolveApiKey() {
453
+ const env = process.env["ZEFFY_API_KEY"];
454
+ if (env && env.trim()) return env.trim();
455
+ const cfg = await readConfig();
456
+ return cfg.api_key;
457
+ }
458
+
459
+ // src/mcp/server.ts
460
+ var DEFAULT_MAX = 1e3;
461
+ async function getClient() {
462
+ const key = await resolveApiKey();
463
+ if (!key) {
464
+ throw new Error(
465
+ "Zeffy API key not configured. Set ZEFFY_API_KEY in the MCP server env, or run `zfy auth set` on this machine."
466
+ );
467
+ }
468
+ return new Zeffy({ apiKey: key });
469
+ }
470
+ function dateToUnix(d) {
471
+ if (!d) return void 0;
472
+ const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(d) ? `${d}T00:00:00Z` : d);
473
+ if (Number.isNaN(ms)) throw new Error(`Invalid date: ${d}`);
474
+ return Math.floor(ms / 1e3);
475
+ }
476
+ function asText(value) {
477
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
478
+ }
479
+ var server = new McpServer({ name: "zfy", version: "0.1.0" });
480
+ server.registerTool(
481
+ "zeffy_list_payments",
482
+ {
483
+ description: "List Zeffy donations (payments). Cursor pagination is handled automatically \u2014 use `limit` to cap result size.",
484
+ inputSchema: {
485
+ from: z2.string().optional().describe("Earliest date (YYYY-MM-DD or ISO datetime)"),
486
+ to: z2.string().optional().describe("Latest date (YYYY-MM-DD or ISO datetime)"),
487
+ status: z2.string().optional(),
488
+ currency: z2.string().optional(),
489
+ type: z2.string().optional(),
490
+ campaign_id: z2.string().optional(),
491
+ contact_id: z2.string().optional(),
492
+ limit: z2.number().int().positive().max(5e3).default(DEFAULT_MAX)
493
+ }
494
+ },
495
+ async (args) => {
496
+ const zeffy = await getClient();
497
+ const data = await zeffy.payments.collect(
498
+ {
499
+ created_gte: dateToUnix(args.from),
500
+ created_lte: dateToUnix(args.to),
501
+ status: args.status,
502
+ currency: args.currency,
503
+ type: args.type,
504
+ campaign_id: args.campaign_id,
505
+ contact_id: args.contact_id
506
+ },
507
+ args.limit
508
+ );
509
+ return asText(data);
510
+ }
511
+ );
512
+ server.registerTool(
513
+ "zeffy_list_contacts",
514
+ {
515
+ description: "List Zeffy contacts (donors). Use `email` to look up a single donor.",
516
+ inputSchema: {
517
+ email: z2.string().optional(),
518
+ created_from: z2.string().optional(),
519
+ created_to: z2.string().optional(),
520
+ updated_from: z2.string().optional(),
521
+ updated_to: z2.string().optional(),
522
+ limit: z2.number().int().positive().max(5e3).default(DEFAULT_MAX)
523
+ }
524
+ },
525
+ async (args) => {
526
+ const zeffy = await getClient();
527
+ const data = await zeffy.contacts.collect(
528
+ {
529
+ email: args.email,
530
+ created_gte: dateToUnix(args.created_from),
531
+ created_lte: dateToUnix(args.created_to),
532
+ updated_gte: dateToUnix(args.updated_from),
533
+ updated_lte: dateToUnix(args.updated_to)
534
+ },
535
+ args.limit
536
+ );
537
+ return asText(data);
538
+ }
539
+ );
540
+ server.registerTool(
541
+ "zeffy_list_campaigns",
542
+ {
543
+ description: "List Zeffy campaigns (donation forms, events, etc.).",
544
+ inputSchema: {
545
+ created_from: z2.string().optional(),
546
+ created_to: z2.string().optional(),
547
+ limit: z2.number().int().positive().max(5e3).default(DEFAULT_MAX)
548
+ }
549
+ },
550
+ async (args) => {
551
+ const zeffy = await getClient();
552
+ const data = await zeffy.campaigns.collect(
553
+ {
554
+ created_gte: dateToUnix(args.created_from),
555
+ created_lte: dateToUnix(args.created_to)
556
+ },
557
+ args.limit
558
+ );
559
+ return asText(data);
560
+ }
561
+ );
562
+ server.registerTool(
563
+ "zeffy_eoy_report",
564
+ {
565
+ description: "Generate an end-of-year donation report aggregated per donor. Returns JSON, or a Markdown summary if format='markdown'.",
566
+ inputSchema: {
567
+ year: z2.number().int().min(1900).max(9999),
568
+ format: z2.enum(["json", "markdown"]).default("json"),
569
+ timezone: z2.string().optional().describe("IANA timezone (default: UTC)"),
570
+ currency: z2.string().optional(),
571
+ status: z2.string().optional(),
572
+ include_refunded: z2.boolean().default(false),
573
+ top: z2.number().int().positive().max(500).default(25)
574
+ }
575
+ },
576
+ async (args) => {
577
+ const zeffy = await getClient();
578
+ const report = await buildEoyReport(zeffy, {
579
+ year: args.year,
580
+ timezone: args.timezone ?? "UTC",
581
+ currency: args.currency,
582
+ status: args.status,
583
+ excludeRefunded: !args.include_refunded
584
+ });
585
+ const text = args.format === "markdown" ? formatMarkdown(report, args.top) : JSON.stringify(report, null, 2);
586
+ return { content: [{ type: "text", text }] };
587
+ }
588
+ );
589
+ var transport = new StdioServerTransport();
590
+ server.connect(transport).catch((err) => {
591
+ console.error(`zfy-mcp failed to start: ${err instanceof Error ? err.message : String(err)}`);
592
+ process.exit(1);
593
+ });