@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.
- package/LICENSE +21 -0
- package/README.md +212 -0
- package/dist/cli/index.js +979 -0
- package/dist/mcp/server.js +593 -0
- package/dist/sdk/index.d.ts +1380 -0
- package/dist/sdk/index.js +708 -0
- package/package.json +70 -0
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { Command as Command6 } from "commander";
|
|
5
|
+
import pc4 from "picocolors";
|
|
6
|
+
|
|
7
|
+
// src/cli/auth.ts
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import pc2 from "picocolors";
|
|
10
|
+
|
|
11
|
+
// src/cli/config.ts
|
|
12
|
+
import { mkdir, readFile, writeFile, chmod, unlink } from "fs/promises";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import os from "os";
|
|
15
|
+
function configDir() {
|
|
16
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
17
|
+
if (xdg) return path.join(xdg, "zfy");
|
|
18
|
+
return path.join(os.homedir(), ".config", "zfy");
|
|
19
|
+
}
|
|
20
|
+
function configPath() {
|
|
21
|
+
return path.join(configDir(), "config.json");
|
|
22
|
+
}
|
|
23
|
+
async function readConfig() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(configPath(), "utf8");
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
return typeof parsed === "object" && parsed ? parsed : {};
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === "ENOENT") return {};
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function writeConfig(cfg) {
|
|
34
|
+
const dir = configDir();
|
|
35
|
+
await mkdir(dir, { recursive: true, mode: 448 });
|
|
36
|
+
const p = configPath();
|
|
37
|
+
await writeFile(p, JSON.stringify(cfg, null, 2) + "\n", { mode: 384 });
|
|
38
|
+
await chmod(p, 384).catch(() => {
|
|
39
|
+
});
|
|
40
|
+
return p;
|
|
41
|
+
}
|
|
42
|
+
async function clearConfig() {
|
|
43
|
+
try {
|
|
44
|
+
await unlink(configPath());
|
|
45
|
+
return true;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.code === "ENOENT") return false;
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function resolveApiKey() {
|
|
52
|
+
const env = process.env["ZEFFY_API_KEY"];
|
|
53
|
+
if (env && env.trim()) return env.trim();
|
|
54
|
+
const cfg = await readConfig();
|
|
55
|
+
return cfg.api_key;
|
|
56
|
+
}
|
|
57
|
+
function getConfigPath() {
|
|
58
|
+
return configPath();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/cli/util.ts
|
|
62
|
+
import { createInterface } from "readline";
|
|
63
|
+
import pc from "picocolors";
|
|
64
|
+
|
|
65
|
+
// src/sdk/schemas.ts
|
|
66
|
+
import { z } from "zod";
|
|
67
|
+
var dateOrUnix = z.union([z.string(), z.number()]);
|
|
68
|
+
var AddressSchema = z.object({
|
|
69
|
+
line1: z.string().nullish(),
|
|
70
|
+
line2: z.string().nullish(),
|
|
71
|
+
city: z.string().nullish(),
|
|
72
|
+
state: z.string().nullish(),
|
|
73
|
+
postal_code: z.string().nullish(),
|
|
74
|
+
country: z.string().nullish()
|
|
75
|
+
}).partial().passthrough();
|
|
76
|
+
var ContactSchema = z.object({
|
|
77
|
+
id: z.string(),
|
|
78
|
+
email: z.string().nullish(),
|
|
79
|
+
first_name: z.string().nullish(),
|
|
80
|
+
last_name: z.string().nullish(),
|
|
81
|
+
name: z.string().nullish(),
|
|
82
|
+
phone: z.string().nullish(),
|
|
83
|
+
address: AddressSchema.nullish(),
|
|
84
|
+
created: dateOrUnix.nullish(),
|
|
85
|
+
updated: dateOrUnix.nullish()
|
|
86
|
+
}).passthrough();
|
|
87
|
+
var CampaignSchema = z.object({
|
|
88
|
+
id: z.string(),
|
|
89
|
+
name: z.string().nullish(),
|
|
90
|
+
type: z.string().nullish(),
|
|
91
|
+
status: z.string().nullish(),
|
|
92
|
+
goal: z.number().nullish(),
|
|
93
|
+
raised: z.number().nullish(),
|
|
94
|
+
currency: z.string().nullish(),
|
|
95
|
+
created: dateOrUnix.nullish()
|
|
96
|
+
}).passthrough();
|
|
97
|
+
var PaymentLineItemSchema = z.object({
|
|
98
|
+
id: z.string().nullish(),
|
|
99
|
+
description: z.string().nullish(),
|
|
100
|
+
amount: z.number().nullish(),
|
|
101
|
+
quantity: z.number().nullish()
|
|
102
|
+
}).passthrough();
|
|
103
|
+
var PaymentSchema = z.object({
|
|
104
|
+
id: z.string(),
|
|
105
|
+
amount: z.number(),
|
|
106
|
+
currency: z.string().nullish(),
|
|
107
|
+
status: z.string().nullish(),
|
|
108
|
+
type: z.string().nullish(),
|
|
109
|
+
contact_id: z.string().nullish(),
|
|
110
|
+
contact: ContactSchema.nullish(),
|
|
111
|
+
campaign_id: z.string().nullish(),
|
|
112
|
+
campaign: CampaignSchema.nullish(),
|
|
113
|
+
line_items: z.array(PaymentLineItemSchema).nullish(),
|
|
114
|
+
tax_receipt_eligible_amount: z.number().nullish(),
|
|
115
|
+
refunded: z.boolean().nullish(),
|
|
116
|
+
refunded_amount: z.number().nullish(),
|
|
117
|
+
created: dateOrUnix
|
|
118
|
+
}).passthrough();
|
|
119
|
+
var ListResponseSchema = (item) => z.object({
|
|
120
|
+
data: z.array(item),
|
|
121
|
+
has_more: z.boolean(),
|
|
122
|
+
next_cursor: z.string().nullish()
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// src/sdk/client.ts
|
|
126
|
+
var ZeffyApiError = class extends Error {
|
|
127
|
+
constructor(status, message, body) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.status = status;
|
|
130
|
+
this.body = body;
|
|
131
|
+
this.name = "ZeffyApiError";
|
|
132
|
+
}
|
|
133
|
+
status;
|
|
134
|
+
body;
|
|
135
|
+
};
|
|
136
|
+
var ZeffyClient = class {
|
|
137
|
+
apiKey;
|
|
138
|
+
baseUrl;
|
|
139
|
+
maxRetries;
|
|
140
|
+
fetchFn;
|
|
141
|
+
requestTimestamps = [];
|
|
142
|
+
rateLimit;
|
|
143
|
+
constructor(opts) {
|
|
144
|
+
if (!opts.apiKey) throw new Error("ZeffyClient: apiKey is required");
|
|
145
|
+
this.apiKey = opts.apiKey;
|
|
146
|
+
this.baseUrl = (opts.baseUrl ?? "https://api.zeffy.com/api/v1").replace(/\/+$/, "");
|
|
147
|
+
this.maxRetries = opts.maxRetries ?? 5;
|
|
148
|
+
this.fetchFn = opts.fetch ?? globalThis.fetch;
|
|
149
|
+
this.rateLimit = opts.rateLimitPerMinute ?? 90;
|
|
150
|
+
}
|
|
151
|
+
async throttle() {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
const windowStart = now - 6e4;
|
|
154
|
+
this.requestTimestamps = this.requestTimestamps.filter((t) => t > windowStart);
|
|
155
|
+
if (this.requestTimestamps.length >= this.rateLimit) {
|
|
156
|
+
const oldest = this.requestTimestamps[0];
|
|
157
|
+
if (oldest !== void 0) {
|
|
158
|
+
const waitMs = 6e4 - (now - oldest) + 50;
|
|
159
|
+
await sleep(waitMs);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.requestTimestamps.push(Date.now());
|
|
163
|
+
}
|
|
164
|
+
async request(path4, params) {
|
|
165
|
+
const url = new URL(this.baseUrl + path4);
|
|
166
|
+
if (params) {
|
|
167
|
+
for (const [k, v] of Object.entries(params)) {
|
|
168
|
+
if (v === void 0 || v === null) continue;
|
|
169
|
+
url.searchParams.set(k, String(v));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let attempt = 0;
|
|
173
|
+
while (true) {
|
|
174
|
+
await this.throttle();
|
|
175
|
+
const res = await this.fetchFn(url.toString(), {
|
|
176
|
+
headers: {
|
|
177
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
178
|
+
Accept: "application/json",
|
|
179
|
+
"User-Agent": "zfy-cli/0.1.0 (+https://github.com/EssentialsDev/zfy-cli)"
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
if (res.ok) {
|
|
183
|
+
return await res.json();
|
|
184
|
+
}
|
|
185
|
+
const retryable = res.status === 429 || res.status >= 500 && res.status < 600;
|
|
186
|
+
if (retryable && attempt < this.maxRetries) {
|
|
187
|
+
const retryAfter = parseRetryAfter(res.headers.get("retry-after"));
|
|
188
|
+
const backoff = retryAfter ?? Math.min(2 ** attempt * 500, 3e4);
|
|
189
|
+
await sleep(backoff);
|
|
190
|
+
attempt++;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
let body;
|
|
194
|
+
try {
|
|
195
|
+
body = await res.json();
|
|
196
|
+
} catch {
|
|
197
|
+
body = await res.text().catch(() => void 0);
|
|
198
|
+
}
|
|
199
|
+
const msg = typeof body === "object" && body && "message" in body ? String(body.message) : res.statusText || `HTTP ${res.status}`;
|
|
200
|
+
throw new ZeffyApiError(res.status, msg, body);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async list(path4, schema, params = {}) {
|
|
204
|
+
const raw = await this.request(path4, params);
|
|
205
|
+
const parsed = ListResponseSchema(schema).safeParse(raw);
|
|
206
|
+
if (!parsed.success) {
|
|
207
|
+
throw new ZeffyApiError(
|
|
208
|
+
500,
|
|
209
|
+
`Failed to parse response from ${path4}: ${parsed.error.message}`,
|
|
210
|
+
raw
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return parsed.data;
|
|
214
|
+
}
|
|
215
|
+
async *iterate(path4, schema, params = {}) {
|
|
216
|
+
let cursor;
|
|
217
|
+
const pageLimit = typeof params["limit"] === "number" ? params["limit"] : 100;
|
|
218
|
+
while (true) {
|
|
219
|
+
const page = await this.list(path4, schema, {
|
|
220
|
+
...params,
|
|
221
|
+
limit: pageLimit,
|
|
222
|
+
starting_after: cursor
|
|
223
|
+
});
|
|
224
|
+
for (const item of page.data) yield item;
|
|
225
|
+
if (!page.has_more || !page.next_cursor) return;
|
|
226
|
+
cursor = page.next_cursor;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
async collect(path4, schema, params = {}, max) {
|
|
230
|
+
const out = [];
|
|
231
|
+
for await (const item of this.iterate(path4, schema, params)) {
|
|
232
|
+
out.push(item);
|
|
233
|
+
if (max !== void 0 && out.length >= max) break;
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
function sleep(ms) {
|
|
239
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
240
|
+
}
|
|
241
|
+
function parseRetryAfter(header) {
|
|
242
|
+
if (!header) return void 0;
|
|
243
|
+
const secs = Number(header);
|
|
244
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1e3);
|
|
245
|
+
const dateMs = Date.parse(header);
|
|
246
|
+
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
247
|
+
return void 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// src/sdk/payments.ts
|
|
251
|
+
function toParams(f) {
|
|
252
|
+
const out = {
|
|
253
|
+
limit: f.limit,
|
|
254
|
+
starting_after: f.starting_after,
|
|
255
|
+
currency: f.currency,
|
|
256
|
+
status: f.status,
|
|
257
|
+
type: f.type,
|
|
258
|
+
contact_id: f.contact_id,
|
|
259
|
+
campaign_id: f.campaign_id
|
|
260
|
+
};
|
|
261
|
+
if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
|
|
262
|
+
if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
var PaymentsResource = class {
|
|
266
|
+
constructor(client) {
|
|
267
|
+
this.client = client;
|
|
268
|
+
}
|
|
269
|
+
client;
|
|
270
|
+
list(filters = {}) {
|
|
271
|
+
return this.client.list("/payments", PaymentSchema, toParams(filters));
|
|
272
|
+
}
|
|
273
|
+
iterate(filters = {}) {
|
|
274
|
+
return this.client.iterate("/payments", PaymentSchema, toParams(filters));
|
|
275
|
+
}
|
|
276
|
+
collect(filters = {}, max) {
|
|
277
|
+
return this.client.collect("/payments", PaymentSchema, toParams(filters), max);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// src/sdk/contacts.ts
|
|
282
|
+
function toParams2(f) {
|
|
283
|
+
const out = {
|
|
284
|
+
limit: f.limit,
|
|
285
|
+
starting_after: f.starting_after,
|
|
286
|
+
email: f.email
|
|
287
|
+
};
|
|
288
|
+
if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
|
|
289
|
+
if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
|
|
290
|
+
if (f.updated_gte !== void 0) out["updated[gte]"] = f.updated_gte;
|
|
291
|
+
if (f.updated_lte !== void 0) out["updated[lte]"] = f.updated_lte;
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
var ContactsResource = class {
|
|
295
|
+
constructor(client) {
|
|
296
|
+
this.client = client;
|
|
297
|
+
}
|
|
298
|
+
client;
|
|
299
|
+
list(filters = {}) {
|
|
300
|
+
return this.client.list("/contacts", ContactSchema, toParams2(filters));
|
|
301
|
+
}
|
|
302
|
+
iterate(filters = {}) {
|
|
303
|
+
return this.client.iterate("/contacts", ContactSchema, toParams2(filters));
|
|
304
|
+
}
|
|
305
|
+
collect(filters = {}, max) {
|
|
306
|
+
return this.client.collect("/contacts", ContactSchema, toParams2(filters), max);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// src/sdk/campaigns.ts
|
|
311
|
+
function toParams3(f) {
|
|
312
|
+
const out = {
|
|
313
|
+
limit: f.limit,
|
|
314
|
+
starting_after: f.starting_after
|
|
315
|
+
};
|
|
316
|
+
if (f.created_gte !== void 0) out["created[gte]"] = f.created_gte;
|
|
317
|
+
if (f.created_lte !== void 0) out["created[lte]"] = f.created_lte;
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
var CampaignsResource = class {
|
|
321
|
+
constructor(client) {
|
|
322
|
+
this.client = client;
|
|
323
|
+
}
|
|
324
|
+
client;
|
|
325
|
+
list(filters = {}) {
|
|
326
|
+
return this.client.list("/campaigns", CampaignSchema, toParams3(filters));
|
|
327
|
+
}
|
|
328
|
+
iterate(filters = {}) {
|
|
329
|
+
return this.client.iterate("/campaigns", CampaignSchema, toParams3(filters));
|
|
330
|
+
}
|
|
331
|
+
collect(filters = {}, max) {
|
|
332
|
+
return this.client.collect("/campaigns", CampaignSchema, toParams3(filters), max);
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// src/report/eoy.ts
|
|
337
|
+
import { fromZonedTime } from "date-fns-tz";
|
|
338
|
+
async function buildEoyReport(zeffy, opts) {
|
|
339
|
+
const tz = opts.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
|
|
340
|
+
const start = fromZonedTime(`${opts.year}-01-01T00:00:00`, tz);
|
|
341
|
+
const end = fromZonedTime(`${opts.year + 1}-01-01T00:00:00`, tz);
|
|
342
|
+
const created_gte = Math.floor(start.getTime() / 1e3);
|
|
343
|
+
const created_lte = Math.floor(end.getTime() / 1e3) - 1;
|
|
344
|
+
const excludeRefunded = opts.excludeRefunded ?? true;
|
|
345
|
+
const payments = [];
|
|
346
|
+
for await (const p of zeffy.payments.iterate({
|
|
347
|
+
created_gte,
|
|
348
|
+
created_lte,
|
|
349
|
+
currency: opts.currency,
|
|
350
|
+
status: opts.status
|
|
351
|
+
})) {
|
|
352
|
+
if (excludeRefunded && p.refunded) continue;
|
|
353
|
+
payments.push(p);
|
|
354
|
+
}
|
|
355
|
+
const donorMap = /* @__PURE__ */ new Map();
|
|
356
|
+
let currency = opts.currency ?? "USD";
|
|
357
|
+
for (const p of payments) {
|
|
358
|
+
if (p.currency) currency = p.currency;
|
|
359
|
+
const contactId = p.contact_id ?? p.contact?.id ?? null;
|
|
360
|
+
const key = contactId ?? `__anon__:${p.id}`;
|
|
361
|
+
const dateIso = toIsoString(p.created);
|
|
362
|
+
let donor = donorMap.get(key);
|
|
363
|
+
if (!donor) {
|
|
364
|
+
const c = p.contact;
|
|
365
|
+
donor = {
|
|
366
|
+
contact_id: contactId,
|
|
367
|
+
name: contactName(c),
|
|
368
|
+
email: c?.email ?? null,
|
|
369
|
+
address: c?.address ?? null,
|
|
370
|
+
total_amount: 0,
|
|
371
|
+
donation_count: 0,
|
|
372
|
+
payments: []
|
|
373
|
+
};
|
|
374
|
+
donorMap.set(key, donor);
|
|
375
|
+
}
|
|
376
|
+
donor.total_amount += p.amount;
|
|
377
|
+
donor.donation_count += 1;
|
|
378
|
+
donor.payments.push({
|
|
379
|
+
id: p.id,
|
|
380
|
+
date: dateIso,
|
|
381
|
+
amount: p.amount,
|
|
382
|
+
campaign: p.campaign?.name ?? null,
|
|
383
|
+
campaign_id: p.campaign_id ?? p.campaign?.id ?? null,
|
|
384
|
+
status: p.status ?? null
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const donors = [...donorMap.values()].sort((a, b) => b.total_amount - a.total_amount);
|
|
388
|
+
const totals = donors.reduce(
|
|
389
|
+
(acc, d) => {
|
|
390
|
+
acc.total_amount += d.total_amount;
|
|
391
|
+
acc.donation_count += d.donation_count;
|
|
392
|
+
acc.donor_count += 1;
|
|
393
|
+
return acc;
|
|
394
|
+
},
|
|
395
|
+
{ total_amount: 0, donor_count: 0, donation_count: 0 }
|
|
396
|
+
);
|
|
397
|
+
return {
|
|
398
|
+
year: opts.year,
|
|
399
|
+
timezone: tz,
|
|
400
|
+
currency,
|
|
401
|
+
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
402
|
+
totals,
|
|
403
|
+
donors
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function contactName(c) {
|
|
407
|
+
if (!c) return null;
|
|
408
|
+
if (c.name) return c.name;
|
|
409
|
+
const parts = [c.first_name, c.last_name].filter(Boolean);
|
|
410
|
+
return parts.length ? parts.join(" ") : null;
|
|
411
|
+
}
|
|
412
|
+
function toIsoString(v) {
|
|
413
|
+
if (v == null) return "";
|
|
414
|
+
if (typeof v === "number") return new Date(v * 1e3).toISOString();
|
|
415
|
+
const n = Number(v);
|
|
416
|
+
if (Number.isFinite(n) && String(n) === v) return new Date(n * 1e3).toISOString();
|
|
417
|
+
return new Date(v).toISOString();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/report/formats/json.ts
|
|
421
|
+
function formatJson(report, pretty = true) {
|
|
422
|
+
return pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/report/formats/csv.ts
|
|
426
|
+
import { stringify } from "csv-stringify/sync";
|
|
427
|
+
function formatCsv(report) {
|
|
428
|
+
const rows = report.donors.map((d) => ({
|
|
429
|
+
contact_id: d.contact_id ?? "",
|
|
430
|
+
name: d.name ?? "",
|
|
431
|
+
email: d.email ?? "",
|
|
432
|
+
address_line1: d.address?.line1 ?? "",
|
|
433
|
+
address_line2: d.address?.line2 ?? "",
|
|
434
|
+
city: d.address?.city ?? "",
|
|
435
|
+
state: d.address?.state ?? "",
|
|
436
|
+
postal_code: d.address?.postal_code ?? "",
|
|
437
|
+
country: d.address?.country ?? "",
|
|
438
|
+
donation_count: d.donation_count,
|
|
439
|
+
total_amount: d.total_amount.toFixed(2),
|
|
440
|
+
currency: report.currency,
|
|
441
|
+
year: report.year
|
|
442
|
+
}));
|
|
443
|
+
return stringify(rows, { header: true });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/report/formats/md.ts
|
|
447
|
+
function formatMarkdown(report, topN = 25) {
|
|
448
|
+
const lines = [];
|
|
449
|
+
const cur = report.currency;
|
|
450
|
+
lines.push(`# Donation report \u2014 ${report.year}`);
|
|
451
|
+
lines.push("");
|
|
452
|
+
lines.push(`Generated ${report.generated_at} \xB7 Timezone ${report.timezone}`);
|
|
453
|
+
lines.push("");
|
|
454
|
+
lines.push(`## Totals`);
|
|
455
|
+
lines.push(`- Total raised: ${money(report.totals.total_amount, cur)}`);
|
|
456
|
+
lines.push(`- Unique donors: ${report.totals.donor_count}`);
|
|
457
|
+
lines.push(`- Total donations: ${report.totals.donation_count}`);
|
|
458
|
+
if (report.totals.donor_count > 0) {
|
|
459
|
+
const avg = report.totals.total_amount / report.totals.donor_count;
|
|
460
|
+
lines.push(`- Average per donor: ${money(avg, cur)}`);
|
|
461
|
+
}
|
|
462
|
+
lines.push("");
|
|
463
|
+
lines.push(`## Top ${Math.min(topN, report.donors.length)} donors`);
|
|
464
|
+
lines.push("");
|
|
465
|
+
lines.push("| # | Donor | Email | Gifts | Total |");
|
|
466
|
+
lines.push("| --- | --- | --- | ---: | ---: |");
|
|
467
|
+
report.donors.slice(0, topN).forEach((d, i) => {
|
|
468
|
+
lines.push(
|
|
469
|
+
`| ${i + 1} | ${escape(d.name ?? "(anonymous)")} | ${escape(d.email ?? "")} | ${d.donation_count} | ${money(d.total_amount, cur)} |`
|
|
470
|
+
);
|
|
471
|
+
});
|
|
472
|
+
if (report.donors.length > topN) {
|
|
473
|
+
lines.push("");
|
|
474
|
+
lines.push(`_\u2026and ${report.donors.length - topN} more donors._`);
|
|
475
|
+
}
|
|
476
|
+
return lines.join("\n") + "\n";
|
|
477
|
+
}
|
|
478
|
+
function money(n, currency) {
|
|
479
|
+
try {
|
|
480
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
|
|
481
|
+
} catch {
|
|
482
|
+
return `${n.toFixed(2)} ${currency}`;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function escape(s) {
|
|
486
|
+
return s.replace(/\|/g, "\\|");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/report/formats/pdf.ts
|
|
490
|
+
import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile2, stat } from "fs/promises";
|
|
491
|
+
import path2 from "path";
|
|
492
|
+
import PDFDocument from "pdfkit";
|
|
493
|
+
var LOGO_MAX_BYTES = 2 * 1024 * 1024;
|
|
494
|
+
var LOGO_MIN_PIXELS = 64;
|
|
495
|
+
var LOGO_ASPECT_TOLERANCE = 0.1;
|
|
496
|
+
var DEFAULT_LOGO_SIZE = 64;
|
|
497
|
+
async function validateLogo(logoPath) {
|
|
498
|
+
let stats;
|
|
499
|
+
try {
|
|
500
|
+
stats = await stat(logoPath);
|
|
501
|
+
} catch {
|
|
502
|
+
return { ok: false, reason: `file not found: ${logoPath}` };
|
|
503
|
+
}
|
|
504
|
+
if (!stats.isFile()) return { ok: false, reason: `not a regular file: ${logoPath}` };
|
|
505
|
+
if (stats.size > LOGO_MAX_BYTES) {
|
|
506
|
+
return {
|
|
507
|
+
ok: false,
|
|
508
|
+
reason: `file too large (${formatBytes(stats.size)}, max ${formatBytes(LOGO_MAX_BYTES)})`
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const head = await readFile2(logoPath);
|
|
512
|
+
const dims = readImageDimensions(head);
|
|
513
|
+
if (!dims) {
|
|
514
|
+
return { ok: false, reason: "unsupported format (only PNG and JPEG are accepted)" };
|
|
515
|
+
}
|
|
516
|
+
if (dims.width < LOGO_MIN_PIXELS || dims.height < LOGO_MIN_PIXELS) {
|
|
517
|
+
return {
|
|
518
|
+
ok: false,
|
|
519
|
+
reason: `image too small (${dims.width}\xD7${dims.height}, minimum ${LOGO_MIN_PIXELS}\xD7${LOGO_MIN_PIXELS})`
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const ratio = dims.width / dims.height;
|
|
523
|
+
if (Math.abs(ratio - 1) > LOGO_ASPECT_TOLERANCE) {
|
|
524
|
+
return {
|
|
525
|
+
ok: false,
|
|
526
|
+
reason: `image is not square (${dims.width}\xD7${dims.height}); supply a square PNG/JPEG (e.g. 512\xD7512)`
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
return { ok: true, width: dims.width, height: dims.height, format: dims.format };
|
|
530
|
+
}
|
|
531
|
+
function readImageDimensions(buf) {
|
|
532
|
+
if (buf.length >= 24 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) {
|
|
533
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20), format: "png" };
|
|
534
|
+
}
|
|
535
|
+
if (buf.length >= 4 && buf[0] === 255 && buf[1] === 216) {
|
|
536
|
+
let off = 2;
|
|
537
|
+
while (off < buf.length - 9) {
|
|
538
|
+
if (buf[off] !== 255) {
|
|
539
|
+
off++;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
while (off < buf.length && buf[off] === 255) off++;
|
|
543
|
+
const marker = buf[off];
|
|
544
|
+
if (marker === void 0) return null;
|
|
545
|
+
off++;
|
|
546
|
+
if (marker === 217 || marker === 218) return null;
|
|
547
|
+
const segLen = buf.readUInt16BE(off);
|
|
548
|
+
const isSof = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
|
|
549
|
+
if (isSof && off + 7 <= buf.length) {
|
|
550
|
+
const height = buf.readUInt16BE(off + 3);
|
|
551
|
+
const width = buf.readUInt16BE(off + 5);
|
|
552
|
+
return { width, height, format: "jpeg" };
|
|
553
|
+
}
|
|
554
|
+
off += segLen;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
function formatBytes(n) {
|
|
560
|
+
if (n < 1024) return `${n} B`;
|
|
561
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
562
|
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
563
|
+
}
|
|
564
|
+
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.";
|
|
565
|
+
async function writePdfReceipts(report, outDir, opts = {}) {
|
|
566
|
+
await mkdir2(outDir, { recursive: true });
|
|
567
|
+
const effective = await resolveLogoOptions(opts);
|
|
568
|
+
const written = [];
|
|
569
|
+
for (const donor of report.donors) {
|
|
570
|
+
const filename = receiptFilename(donor, report.year);
|
|
571
|
+
const fullPath = path2.join(outDir, filename);
|
|
572
|
+
const buf = await renderDonorPdf(donor, report, effective);
|
|
573
|
+
await writeFile2(fullPath, buf);
|
|
574
|
+
written.push(fullPath);
|
|
575
|
+
}
|
|
576
|
+
return written;
|
|
577
|
+
}
|
|
578
|
+
async function resolveLogoOptions(opts) {
|
|
579
|
+
if (!opts.logoPath) return opts;
|
|
580
|
+
const result = await validateLogo(opts.logoPath);
|
|
581
|
+
if (result.ok) return opts;
|
|
582
|
+
const out = opts.warnStream ?? process.stderr;
|
|
583
|
+
out.write(`zfy: skipping logo \u2014 ${result.reason}
|
|
584
|
+
`);
|
|
585
|
+
const next = { ...opts };
|
|
586
|
+
delete next.logoPath;
|
|
587
|
+
return next;
|
|
588
|
+
}
|
|
589
|
+
var ACCENT = "#1f3a5f";
|
|
590
|
+
var MUTED = "#6b7280";
|
|
591
|
+
var RULE = "#e5e7eb";
|
|
592
|
+
var ROW_ALT = "#f9fafb";
|
|
593
|
+
function renderDonorPdf(donor, report, opts = {}) {
|
|
594
|
+
return new Promise((resolve, reject) => {
|
|
595
|
+
const doc = new PDFDocument({ size: "LETTER", margin: 54 });
|
|
596
|
+
const chunks = [];
|
|
597
|
+
doc.on("data", (c) => chunks.push(c));
|
|
598
|
+
doc.on("end", () => resolve(Buffer.concat(chunks)));
|
|
599
|
+
doc.on("error", reject);
|
|
600
|
+
const orgName = opts.orgName ?? "Your Organization";
|
|
601
|
+
const left = doc.page.margins.left;
|
|
602
|
+
const right = doc.page.width - doc.page.margins.right;
|
|
603
|
+
const usable = right - left;
|
|
604
|
+
doc.rect(left, doc.y, usable, 3).fill(ACCENT);
|
|
605
|
+
doc.moveDown(0.7);
|
|
606
|
+
doc.fillColor("black");
|
|
607
|
+
const headerY = doc.y;
|
|
608
|
+
const logoSize = opts.logoSize ?? DEFAULT_LOGO_SIZE;
|
|
609
|
+
if (opts.logoPath) {
|
|
610
|
+
try {
|
|
611
|
+
doc.image(opts.logoPath, left, headerY, { fit: [logoSize, logoSize] });
|
|
612
|
+
} catch {
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
doc.font("Helvetica-Bold").fontSize(20).fillColor("black").text(orgName, left + (opts.logoPath ? logoSize + 14 : 0), headerY + 4, {
|
|
616
|
+
width: usable
|
|
617
|
+
});
|
|
618
|
+
doc.font("Helvetica").fontSize(9).fillColor(MUTED).text(`OFFICIAL DONATION RECEIPT \xB7 ${report.year}`, {
|
|
619
|
+
characterSpacing: 1.2
|
|
620
|
+
});
|
|
621
|
+
doc.moveDown(1.5);
|
|
622
|
+
doc.fillColor("black");
|
|
623
|
+
const blockTop = doc.y;
|
|
624
|
+
const colWidth = (usable - 20) / 2;
|
|
625
|
+
doc.font("Helvetica-Bold").fontSize(9).fillColor(MUTED).text("ISSUED TO", left, blockTop, {
|
|
626
|
+
width: colWidth,
|
|
627
|
+
characterSpacing: 1
|
|
628
|
+
});
|
|
629
|
+
doc.moveDown(0.3);
|
|
630
|
+
doc.font("Helvetica-Bold").fontSize(12).fillColor("black").text(donor.name ?? "(Anonymous donor)", {
|
|
631
|
+
width: colWidth
|
|
632
|
+
});
|
|
633
|
+
doc.font("Helvetica").fontSize(10).fillColor("#374151");
|
|
634
|
+
if (donor.email) doc.text(donor.email, { width: colWidth });
|
|
635
|
+
if (donor.address) {
|
|
636
|
+
const a = donor.address;
|
|
637
|
+
const addrLines = [
|
|
638
|
+
a.line1,
|
|
639
|
+
a.line2,
|
|
640
|
+
[a.city, a.state, a.postal_code].filter(Boolean).join(", "),
|
|
641
|
+
a.country
|
|
642
|
+
].filter(Boolean);
|
|
643
|
+
for (const line of addrLines) doc.text(line, { width: colWidth });
|
|
644
|
+
}
|
|
645
|
+
const rightColX = left + colWidth + 20;
|
|
646
|
+
doc.font("Helvetica-Bold").fontSize(9).fillColor(MUTED).text("ISSUED", rightColX, blockTop, {
|
|
647
|
+
width: colWidth,
|
|
648
|
+
characterSpacing: 1
|
|
649
|
+
});
|
|
650
|
+
doc.moveDown(0.3);
|
|
651
|
+
doc.font("Helvetica").fontSize(10).fillColor("black").text(formatIssuedDate(report.generated_at), { width: colWidth });
|
|
652
|
+
doc.moveDown(0.5);
|
|
653
|
+
doc.font("Helvetica-Bold").fontSize(9).fillColor(MUTED).text("DONATIONS", {
|
|
654
|
+
width: colWidth,
|
|
655
|
+
characterSpacing: 1
|
|
656
|
+
});
|
|
657
|
+
doc.moveDown(0.3);
|
|
658
|
+
doc.font("Helvetica").fontSize(10).fillColor("black").text(`${donor.donation_count} gift${donor.donation_count === 1 ? "" : "s"} in ${report.year}`, {
|
|
659
|
+
width: colWidth
|
|
660
|
+
});
|
|
661
|
+
doc.x = left;
|
|
662
|
+
doc.y = Math.max(doc.y, blockTop + 110);
|
|
663
|
+
doc.moveDown(0.5);
|
|
664
|
+
const boxY = doc.y;
|
|
665
|
+
doc.roundedRect(left, boxY, usable, 64, 6).fill(ACCENT);
|
|
666
|
+
doc.font("Helvetica").fontSize(9).fillColor("#cbd5e1").text("TOTAL CONTRIBUTED", left + 20, boxY + 14, {
|
|
667
|
+
characterSpacing: 1.2,
|
|
668
|
+
width: usable - 40
|
|
669
|
+
});
|
|
670
|
+
doc.font("Helvetica-Bold").fontSize(28).fillColor("white").text(money2(donor.total_amount, report.currency), left + 20, boxY + 28, {
|
|
671
|
+
width: usable - 40
|
|
672
|
+
});
|
|
673
|
+
doc.y = boxY + 64;
|
|
674
|
+
doc.moveDown(1.2);
|
|
675
|
+
doc.fillColor("black");
|
|
676
|
+
doc.font("Helvetica-Bold").fontSize(10).text("Itemized gifts", left);
|
|
677
|
+
doc.moveDown(0.5);
|
|
678
|
+
const tableTop = doc.y;
|
|
679
|
+
const colDate = left;
|
|
680
|
+
const colCampaign = left + 90;
|
|
681
|
+
const colAmount = right;
|
|
682
|
+
const rowHeight = 22;
|
|
683
|
+
doc.rect(left, tableTop, usable, rowHeight).fill(ROW_ALT);
|
|
684
|
+
doc.font("Helvetica-Bold").fontSize(9).fillColor(MUTED).text("DATE", colDate + 10, tableTop + 7, { characterSpacing: 1 });
|
|
685
|
+
doc.text("CAMPAIGN", colCampaign, tableTop + 7, { characterSpacing: 1 });
|
|
686
|
+
doc.text("AMOUNT", left, tableTop + 7, {
|
|
687
|
+
width: usable - 10,
|
|
688
|
+
align: "right",
|
|
689
|
+
characterSpacing: 1
|
|
690
|
+
});
|
|
691
|
+
let rowY = tableTop + rowHeight;
|
|
692
|
+
donor.payments.forEach((p, i) => {
|
|
693
|
+
if (i % 2 === 1) {
|
|
694
|
+
doc.rect(left, rowY, usable, rowHeight).fill(ROW_ALT);
|
|
695
|
+
}
|
|
696
|
+
doc.font("Helvetica").fontSize(10).fillColor("black").text(p.date ? p.date.slice(0, 10) : "\u2014", colDate + 10, rowY + 7);
|
|
697
|
+
doc.text(p.campaign ?? "\u2014", colCampaign, rowY + 7, {
|
|
698
|
+
width: colAmount - colCampaign - 90,
|
|
699
|
+
ellipsis: true
|
|
700
|
+
});
|
|
701
|
+
doc.text(money2(p.amount, report.currency), left, rowY + 7, {
|
|
702
|
+
width: usable - 10,
|
|
703
|
+
align: "right"
|
|
704
|
+
});
|
|
705
|
+
rowY += rowHeight;
|
|
706
|
+
});
|
|
707
|
+
doc.moveTo(left, rowY).lineTo(right, rowY).strokeColor(RULE).lineWidth(0.5).stroke();
|
|
708
|
+
doc.y = rowY + 20;
|
|
709
|
+
doc.moveTo(left, doc.y).lineTo(right, doc.y).strokeColor(RULE).lineWidth(0.5).stroke();
|
|
710
|
+
doc.moveDown(0.8);
|
|
711
|
+
doc.font("Helvetica-Oblique").fontSize(8.5).fillColor(MUTED).text(opts.receiptText ?? DEFAULT_RECEIPT_TEXT, left, doc.y, {
|
|
712
|
+
width: usable,
|
|
713
|
+
align: "left",
|
|
714
|
+
lineGap: 2
|
|
715
|
+
});
|
|
716
|
+
doc.end();
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
function formatIssuedDate(iso) {
|
|
720
|
+
const d = new Date(iso);
|
|
721
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
722
|
+
return d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
|
723
|
+
}
|
|
724
|
+
function money2(n, currency) {
|
|
725
|
+
try {
|
|
726
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(n);
|
|
727
|
+
} catch {
|
|
728
|
+
return `${n.toFixed(2)} ${currency}`;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function receiptFilename(donor, year) {
|
|
732
|
+
const slug = (donor.name ?? donor.email ?? donor.contact_id ?? "anonymous").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 60);
|
|
733
|
+
return `${year}-${slug || "anonymous"}.pdf`;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/sdk/index.ts
|
|
737
|
+
var Zeffy = class {
|
|
738
|
+
client;
|
|
739
|
+
payments;
|
|
740
|
+
contacts;
|
|
741
|
+
campaigns;
|
|
742
|
+
constructor(opts) {
|
|
743
|
+
const options = typeof opts === "string" ? { apiKey: opts } : opts;
|
|
744
|
+
this.client = new ZeffyClient(options);
|
|
745
|
+
this.payments = new PaymentsResource(this.client);
|
|
746
|
+
this.contacts = new ContactsResource(this.client);
|
|
747
|
+
this.campaigns = new CampaignsResource(this.client);
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
// src/cli/util.ts
|
|
752
|
+
async function loadClientOrExit() {
|
|
753
|
+
const key = await resolveApiKey();
|
|
754
|
+
if (!key) {
|
|
755
|
+
console.error(
|
|
756
|
+
pc.red("No Zeffy API key configured.") + "\nRun " + pc.bold("zfy auth set") + " or export " + pc.bold("ZEFFY_API_KEY") + "."
|
|
757
|
+
);
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
return new Zeffy({ apiKey: key });
|
|
761
|
+
}
|
|
762
|
+
function parseDateToUnix(input, endOfDay = false) {
|
|
763
|
+
if (!input) return void 0;
|
|
764
|
+
const trimmed = input.trim();
|
|
765
|
+
if (/^\d{10}$/.test(trimmed)) return Number(trimmed);
|
|
766
|
+
if (/^\d{13}$/.test(trimmed)) return Math.floor(Number(trimmed) / 1e3);
|
|
767
|
+
const iso = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? endOfDay ? `${trimmed}T23:59:59` : `${trimmed}T00:00:00` : trimmed;
|
|
768
|
+
const ms = Date.parse(iso);
|
|
769
|
+
if (Number.isNaN(ms)) throw new Error(`Invalid date: ${input}`);
|
|
770
|
+
return Math.floor(ms / 1e3);
|
|
771
|
+
}
|
|
772
|
+
async function promptHidden(question) {
|
|
773
|
+
process.stdout.write(question);
|
|
774
|
+
return new Promise((resolve) => {
|
|
775
|
+
const stdin = process.stdin;
|
|
776
|
+
const rl = createInterface({ input: stdin, output: process.stdout });
|
|
777
|
+
const onData = (char) => {
|
|
778
|
+
const s = char.toString();
|
|
779
|
+
if (s === "\n" || s === "\r" || s === "\r\n") return;
|
|
780
|
+
process.stdout.write("*");
|
|
781
|
+
};
|
|
782
|
+
if (typeof stdin.setRawMode === "function") {
|
|
783
|
+
try {
|
|
784
|
+
stdin.setRawMode(false);
|
|
785
|
+
} catch {
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
stdin.on("data", onData);
|
|
789
|
+
rl.question("", (answer) => {
|
|
790
|
+
stdin.removeListener("data", onData);
|
|
791
|
+
rl.close();
|
|
792
|
+
process.stdout.write("\n");
|
|
793
|
+
resolve(answer.trim());
|
|
794
|
+
});
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
function printJson(value) {
|
|
798
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/cli/auth.ts
|
|
802
|
+
function authCommand() {
|
|
803
|
+
const cmd = new Command("auth").description("Manage Zeffy API credentials");
|
|
804
|
+
cmd.command("set").description("Store a Zeffy API key in your local config").option("--key <key>", "API key (skips the prompt)").action(async (opts) => {
|
|
805
|
+
const key = opts.key ?? await promptHidden("Zeffy API key: ");
|
|
806
|
+
if (!key) {
|
|
807
|
+
console.error(pc2.red("No key provided."));
|
|
808
|
+
process.exit(1);
|
|
809
|
+
}
|
|
810
|
+
const path4 = await writeConfig({ api_key: key });
|
|
811
|
+
console.log(pc2.green("Saved.") + ` ${pc2.dim(path4)}`);
|
|
812
|
+
});
|
|
813
|
+
cmd.command("status").description("Verify the current API key by calling the Zeffy API").action(async () => {
|
|
814
|
+
const key = await resolveApiKey();
|
|
815
|
+
if (!key) {
|
|
816
|
+
console.log(pc2.yellow("Not authenticated. Run `zfy auth set` or set ZEFFY_API_KEY."));
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
const zeffy = new Zeffy({ apiKey: key });
|
|
820
|
+
try {
|
|
821
|
+
const res = await zeffy.campaigns.list({ limit: 1 });
|
|
822
|
+
const sample = res.data[0];
|
|
823
|
+
console.log(pc2.green("Authenticated."));
|
|
824
|
+
if (sample) {
|
|
825
|
+
console.log(pc2.dim(`Sample campaign: ${sample.name ?? sample.id}`));
|
|
826
|
+
} else {
|
|
827
|
+
console.log(pc2.dim("(No campaigns yet on this account.)"));
|
|
828
|
+
}
|
|
829
|
+
} catch (err) {
|
|
830
|
+
if (err instanceof ZeffyApiError) {
|
|
831
|
+
console.error(pc2.red(`API error ${err.status}: ${err.message}`));
|
|
832
|
+
} else {
|
|
833
|
+
console.error(pc2.red(String(err)));
|
|
834
|
+
}
|
|
835
|
+
process.exit(1);
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
cmd.command("clear").description("Remove the stored API key from disk").action(async () => {
|
|
839
|
+
const removed = await clearConfig();
|
|
840
|
+
console.log(removed ? pc2.green("Cleared.") : pc2.dim("No stored credential to clear."));
|
|
841
|
+
});
|
|
842
|
+
cmd.command("path").description("Print the path to the config file").action(async () => {
|
|
843
|
+
const cfg = await readConfig();
|
|
844
|
+
console.log(getConfigPath() + (cfg.api_key ? "" : pc2.dim(" (empty)")));
|
|
845
|
+
});
|
|
846
|
+
return cmd;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/cli/payments.ts
|
|
850
|
+
import { Command as Command2 } from "commander";
|
|
851
|
+
function paymentsCommand() {
|
|
852
|
+
const cmd = new Command2("payments").description("List Zeffy payments");
|
|
853
|
+
cmd.command("list").description("List payments with optional filters (JSON output)").option("--from <date>", "Start date (YYYY-MM-DD or Unix seconds)").option("--to <date>", "End date (YYYY-MM-DD or Unix seconds)").option("--status <status>", "Filter by payment status").option("--currency <code>", "Filter by 3-letter currency code").option("--type <type>", "Filter by payment type").option("--campaign <id>", "Filter by campaign id").option("--contact <id>", "Filter by contact id").option("--limit <n>", "Stop after N records (default: all)", (v) => Number(v)).action(async (opts) => {
|
|
854
|
+
const zeffy = await loadClientOrExit();
|
|
855
|
+
const filters = {
|
|
856
|
+
created_gte: parseDateToUnix(opts["from"]),
|
|
857
|
+
created_lte: parseDateToUnix(opts["to"], true),
|
|
858
|
+
status: opts["status"],
|
|
859
|
+
currency: opts["currency"],
|
|
860
|
+
type: opts["type"],
|
|
861
|
+
campaign_id: opts["campaign"],
|
|
862
|
+
contact_id: opts["contact"]
|
|
863
|
+
};
|
|
864
|
+
const max = typeof opts["limit"] === "number" ? opts["limit"] : void 0;
|
|
865
|
+
const data = await zeffy.payments.collect(filters, max);
|
|
866
|
+
printJson(data);
|
|
867
|
+
});
|
|
868
|
+
return cmd;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// src/cli/contacts.ts
|
|
872
|
+
import { Command as Command3 } from "commander";
|
|
873
|
+
function contactsCommand() {
|
|
874
|
+
const cmd = new Command3("contacts").description("List Zeffy contacts (donors)");
|
|
875
|
+
cmd.command("list").description("List contacts with optional filters (JSON output)").option("--email <email>", "Filter by email").option("--created-from <date>", "Created at or after (YYYY-MM-DD)").option("--created-to <date>", "Created at or before (YYYY-MM-DD)").option("--updated-from <date>", "Updated at or after (YYYY-MM-DD)").option("--updated-to <date>", "Updated at or before (YYYY-MM-DD)").option("--limit <n>", "Stop after N records (default: all)", (v) => Number(v)).action(async (opts) => {
|
|
876
|
+
const zeffy = await loadClientOrExit();
|
|
877
|
+
const filters = {
|
|
878
|
+
email: opts["email"],
|
|
879
|
+
created_gte: parseDateToUnix(opts["createdFrom"]),
|
|
880
|
+
created_lte: parseDateToUnix(opts["createdTo"], true),
|
|
881
|
+
updated_gte: parseDateToUnix(opts["updatedFrom"]),
|
|
882
|
+
updated_lte: parseDateToUnix(opts["updatedTo"], true)
|
|
883
|
+
};
|
|
884
|
+
const max = typeof opts["limit"] === "number" ? opts["limit"] : void 0;
|
|
885
|
+
const data = await zeffy.contacts.collect(filters, max);
|
|
886
|
+
printJson(data);
|
|
887
|
+
});
|
|
888
|
+
return cmd;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/cli/campaigns.ts
|
|
892
|
+
import { Command as Command4 } from "commander";
|
|
893
|
+
function campaignsCommand() {
|
|
894
|
+
const cmd = new Command4("campaigns").description("List Zeffy campaigns");
|
|
895
|
+
cmd.command("list").description("List campaigns (JSON output)").option("--created-from <date>", "Created at or after (YYYY-MM-DD)").option("--created-to <date>", "Created at or before (YYYY-MM-DD)").option("--limit <n>", "Stop after N records", (v) => Number(v)).action(async (opts) => {
|
|
896
|
+
const zeffy = await loadClientOrExit();
|
|
897
|
+
const filters = {
|
|
898
|
+
created_gte: parseDateToUnix(opts["createdFrom"]),
|
|
899
|
+
created_lte: parseDateToUnix(opts["createdTo"], true)
|
|
900
|
+
};
|
|
901
|
+
const max = typeof opts["limit"] === "number" ? opts["limit"] : void 0;
|
|
902
|
+
const data = await zeffy.campaigns.collect(filters, max);
|
|
903
|
+
printJson(data);
|
|
904
|
+
});
|
|
905
|
+
return cmd;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/cli/report.ts
|
|
909
|
+
import { Command as Command5 } from "commander";
|
|
910
|
+
import { writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
|
|
911
|
+
import path3 from "path";
|
|
912
|
+
import pc3 from "picocolors";
|
|
913
|
+
function reportCommand() {
|
|
914
|
+
const cmd = new Command5("report").description("Generate donation reports");
|
|
915
|
+
cmd.command("eoy").description("End-of-year donation report aggregated per donor").requiredOption("--year <year>", "Calendar year, e.g. 2025", (v) => Number(v)).option("--format <format>", "json | csv | md | pdf", "json").option("--out <path>", "Output file (or directory, for pdf). Defaults to stdout.").option("--timezone <tz>", "IANA timezone (e.g. America/Los_Angeles). Defaults to system tz.").option("--currency <code>", "Filter to a single currency code").option("--status <status>", "Filter to a single payment status (e.g. succeeded)").option("--include-refunded", "Include refunded payments (excluded by default)").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\xD7512, max 2 MB)").option("--logo-size <pt>", "PDF: edge length of the square logo slot in points (default 64)", (v) => Number(v)).option("--receipt-text <text>", "PDF: override receipt boilerplate").action(async (opts) => {
|
|
916
|
+
const year = opts["year"];
|
|
917
|
+
if (!Number.isInteger(year) || year < 1900 || year > 9999) {
|
|
918
|
+
console.error(pc3.red(`Invalid --year: ${opts["year"]}`));
|
|
919
|
+
process.exit(1);
|
|
920
|
+
}
|
|
921
|
+
const format = String(opts["format"] ?? "json").toLowerCase();
|
|
922
|
+
const zeffy = await loadClientOrExit();
|
|
923
|
+
const report = await buildEoyReport(zeffy, {
|
|
924
|
+
year,
|
|
925
|
+
timezone: opts["timezone"],
|
|
926
|
+
currency: opts["currency"],
|
|
927
|
+
status: opts["status"],
|
|
928
|
+
excludeRefunded: !opts["includeRefunded"]
|
|
929
|
+
});
|
|
930
|
+
const outPath = opts["out"];
|
|
931
|
+
if (format === "pdf") {
|
|
932
|
+
const dir = outPath ?? path3.join(process.cwd(), `eoy-${year}-receipts`);
|
|
933
|
+
const written = await writePdfReceipts(report, dir, {
|
|
934
|
+
orgName: opts["org"],
|
|
935
|
+
receiptText: opts["receiptText"],
|
|
936
|
+
logoPath: opts["logo"],
|
|
937
|
+
logoSize: typeof opts["logoSize"] === "number" ? opts["logoSize"] : void 0
|
|
938
|
+
});
|
|
939
|
+
console.error(pc3.green(`Wrote ${written.length} receipts to ${dir}`));
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
let body;
|
|
943
|
+
if (format === "csv") body = formatCsv(report);
|
|
944
|
+
else if (format === "md" || format === "markdown") {
|
|
945
|
+
const topN = typeof opts["top"] === "number" ? opts["top"] : 25;
|
|
946
|
+
body = formatMarkdown(report, topN);
|
|
947
|
+
} else if (format === "json") body = formatJson(report);
|
|
948
|
+
else {
|
|
949
|
+
console.error(pc3.red(`Unknown --format: ${format}`));
|
|
950
|
+
process.exit(1);
|
|
951
|
+
}
|
|
952
|
+
if (outPath) {
|
|
953
|
+
await mkdir3(path3.dirname(path3.resolve(outPath)), { recursive: true });
|
|
954
|
+
await writeFile3(outPath, body);
|
|
955
|
+
console.error(pc3.green(`Wrote ${outPath}`));
|
|
956
|
+
} else {
|
|
957
|
+
process.stdout.write(body.endsWith("\n") ? body : body + "\n");
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
return cmd;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/cli/index.ts
|
|
964
|
+
var program = new Command6();
|
|
965
|
+
program.name("zfy").description("Third-party CLI for the Zeffy API \u2014 donations data for nonprofits and AI agents.").version("0.1.0");
|
|
966
|
+
program.addCommand(authCommand());
|
|
967
|
+
program.addCommand(paymentsCommand());
|
|
968
|
+
program.addCommand(contactsCommand());
|
|
969
|
+
program.addCommand(campaignsCommand());
|
|
970
|
+
program.addCommand(reportCommand());
|
|
971
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
972
|
+
if (err instanceof ZeffyApiError) {
|
|
973
|
+
console.error(pc4.red(`Zeffy API error ${err.status}: ${err.message}`));
|
|
974
|
+
if (err.body && process.env["DEBUG"]) console.error(err.body);
|
|
975
|
+
} else {
|
|
976
|
+
console.error(pc4.red(err instanceof Error ? err.message : String(err)));
|
|
977
|
+
}
|
|
978
|
+
process.exit(1);
|
|
979
|
+
});
|