@profullstack/coinpay 0.9.0 → 0.11.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,458 @@
1
+ /**
2
+ * A print-ready financial report, for handing to somebody who bills by the hour.
3
+ *
4
+ * The point is not that it is pretty. The point is that a bookkeeper opening it
5
+ * does not have to ask for anything: the period is stated, business and personal
6
+ * are separated, every figure says where it came from, and the transaction list
7
+ * is complete enough to tie out against. Every question this document answers in
8
+ * advance is a question nobody pays to have asked.
9
+ *
10
+ * Self-contained by design. No web fonts, no CDN, no script: it has to render
11
+ * identically on a machine that has never seen it, offline, and print the same
12
+ * way. The styling follows the shadcn palette because it is neutral and prints
13
+ * well in greyscale, not because anything here is a React component.
14
+ *
15
+ * It is a data export and says so. It is not a filing, not advice, and not
16
+ * reconciled against anything.
17
+ */
18
+
19
+ const esc = (value) =>
20
+ String(value ?? '')
21
+ .replace(/&/g, '&')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;')
24
+ .replace(/"/g, '&quot;');
25
+
26
+ const num = (value) => {
27
+ const x = Number(value);
28
+ return Number.isFinite(x) ? x : 0;
29
+ };
30
+
31
+ const money = (value, currency = 'USD') => {
32
+ const v = num(value);
33
+ const sign = v < 0 ? '-' : '';
34
+ return `${sign}${currency === 'USD' ? '$' : ''}${Math.abs(v).toLocaleString('en-US', {
35
+ minimumFractionDigits: 2,
36
+ maximumFractionDigits: 2,
37
+ })}`;
38
+ };
39
+
40
+ const day = (value) => String(value ?? '').slice(0, 10);
41
+
42
+ /** A figure that is negative reads red; everything else stays plain. */
43
+ const signed = (value, currency) =>
44
+ `<span class="${num(value) < 0 ? 'neg' : 'pos'}">${esc(money(value, currency))}</span>`;
45
+
46
+ const CSS = `
47
+ :root{
48
+ --bg:#ffffff; --fg:#09090b; --muted:#71717a; --line:#e4e4e7;
49
+ --card:#ffffff; --accent:#18181b; --neg:#b91c1c; --pos:#15803d; --soft:#fafafa;
50
+ }
51
+ *{box-sizing:border-box}
52
+ body{
53
+ margin:0; background:var(--bg); color:var(--fg);
54
+ font:13px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
55
+ -webkit-print-color-adjust:exact; print-color-adjust:exact;
56
+ }
57
+ .page{max-width:860px;margin:0 auto;padding:32px 28px 56px}
58
+ h1{font-size:22px;letter-spacing:-.01em;margin:0 0 2px}
59
+ h2{font-size:14px;letter-spacing:-.005em;margin:0 0 10px;text-transform:none}
60
+ .sub{color:var(--muted);font-size:12px;margin:0}
61
+ header.doc{border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:22px}
62
+ .meta{display:flex;flex-wrap:wrap;gap:18px;margin-top:12px}
63
+ .meta div{min-width:120px}
64
+ .meta dt{color:var(--muted);font-size:11px;margin:0 0 2px}
65
+ .meta dd{margin:0;font-size:12px;font-weight:500}
66
+ section{margin:0 0 22px;break-inside:avoid}
67
+ .cards{display:grid;grid-template-columns:repeat(4,1fr);gap:10px}
68
+ .cards.three{grid-template-columns:repeat(3,1fr)}
69
+ .card{border:1px solid var(--line);border-radius:8px;padding:11px 12px;background:var(--card)}
70
+ .card .label{color:var(--muted);font-size:11px;margin-bottom:3px}
71
+ .card .value{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
72
+ .card .note{color:var(--muted);font-size:10px;margin-top:2px}
73
+ table{width:100%;border-collapse:collapse;font-size:12px}
74
+ th{
75
+ text-align:left;color:var(--muted);font-weight:500;font-size:11px;
76
+ border-bottom:1px solid var(--line);padding:6px 8px;background:var(--soft);
77
+ }
78
+ td{padding:6px 8px;border-bottom:1px solid var(--line);font-variant-numeric:tabular-nums}
79
+ td.r,th.r{text-align:right}
80
+ tbody tr:last-child td{border-bottom:none}
81
+ .neg{color:var(--neg)}
82
+ .pos{color:var(--pos)}
83
+ .tag{
84
+ display:inline-block;font-size:10px;padding:1px 6px;border:1px solid var(--line);
85
+ border-radius:999px;color:var(--muted);background:var(--soft)
86
+ }
87
+ .notes{border:1px solid var(--line);border-radius:8px;padding:12px 14px;background:var(--soft);font-size:11px;color:var(--muted)}
88
+ .notes p{margin:0 0 6px}
89
+ .notes p:last-child{margin:0}
90
+ /* A truncated ledger has to be visible on a printed page, not just present. */
91
+ .notes p.warn{color:var(--neg);border-left:3px solid var(--neg);padding-left:8px}
92
+ .month{margin-top:16px;break-inside:avoid}
93
+ .month h3{font-size:12px;margin:0 0 6px;color:var(--muted);font-weight:600}
94
+ @page{size:letter;margin:14mm 12mm}
95
+ @media print{
96
+ .page{max-width:none;padding:0}
97
+ thead{display:table-header-group}
98
+ tr{break-inside:avoid}
99
+ section{break-inside:avoid}
100
+ .txns section{break-inside:auto}
101
+ }
102
+ `;
103
+
104
+ function card(label, value, note) {
105
+ return `<div class="card"><div class="label">${esc(label)}</div><div class="value">${value}</div>${
106
+ note ? `<div class="note">${esc(note)}</div>` : ''
107
+ }</div>`;
108
+ }
109
+
110
+ function table(headers, rows) {
111
+ if (!rows.length) return '<p class="sub">Nothing in this period.</p>';
112
+ const head = headers.map((h) => `<th class="${h.align === 'right' ? 'r' : ''}">${esc(h.title)}</th>`).join('');
113
+ const body = rows
114
+ .map(
115
+ (cells) =>
116
+ `<tr>${cells
117
+ .map((c) => `<td class="${c.align === 'right' ? 'r' : ''}">${c.html ?? esc(c.text)}</td>`)
118
+ .join('')}</tr>`,
119
+ )
120
+ .join('');
121
+ return `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
122
+ }
123
+
124
+ /** Business and personal, side by side. The separation an S-corp return needs. */
125
+ function scopeSection(position, currency) {
126
+ const scopes = position?.scopes ?? [];
127
+ if (!scopes.length) return '';
128
+ const months = num(position?.monthsObserved) || 1;
129
+ const rows = scopes.map((s) => [
130
+ { text: s.scope === 'business' ? 'Business' : 'Personal' },
131
+ { text: String(num(s.accounts)) , align: 'right' },
132
+ { html: signed(s.income, currency), align: 'right' },
133
+ { html: signed(-Math.abs(num(s.spending)), currency), align: 'right' },
134
+ { html: signed(num(s.income) - num(s.spending), currency), align: 'right' },
135
+ { html: esc(money(num(s.spending) / months, currency)), align: 'right' },
136
+ ]);
137
+ return `<section>
138
+ <h2>Business and personal</h2>
139
+ ${table(
140
+ [
141
+ { title: 'Set of books' },
142
+ { title: 'Accounts', align: 'right' },
143
+ { title: 'Money in', align: 'right' },
144
+ { title: 'Money out', align: 'right' },
145
+ { title: 'Net', align: 'right' },
146
+ { title: 'Spend / month', align: 'right' },
147
+ ],
148
+ rows,
149
+ )}
150
+ <p class="sub" style="margin-top:8px">Scope comes from how each account is marked in the feed, not from
151
+ how a transaction looks. An account marked wrong moves every one of its transactions to the wrong side.</p>
152
+ </section>`;
153
+ }
154
+
155
+ function accountsSection(bank, currency) {
156
+ const accounts = bank?.accounts ?? [];
157
+ if (!accounts.length) return '';
158
+ const rows = accounts.map((a) => [
159
+ { text: a.org_name ?? '' },
160
+ { text: a.name ?? '' },
161
+ { html: `<span class="tag">${esc(a.effective_kind ?? a.kind ?? '')}</span>` },
162
+ { html: `<span class="tag">${esc(a.effective_scope ?? 'unmarked')}</span>` },
163
+ { html: signed(a.display_balance ?? a.balance, a.currency ?? currency), align: 'right' },
164
+ { text: day(a.balance_date), align: 'right' },
165
+ ]);
166
+ return `<section>
167
+ <h2>Accounts</h2>
168
+ ${table(
169
+ [
170
+ { title: 'Institution' },
171
+ { title: 'Account' },
172
+ { title: 'Type' },
173
+ { title: 'Books' },
174
+ { title: 'Balance', align: 'right' },
175
+ { title: 'As of', align: 'right' },
176
+ ],
177
+ rows,
178
+ )}
179
+ </section>`;
180
+ }
181
+
182
+ function categoriesSection(bank, currency) {
183
+ const cats = (bank?.topCategories ?? []).filter((c) => num(c.spent) > 0);
184
+ if (!cats.length) return '';
185
+ const total = cats.reduce((t, c) => t + num(c.spent), 0);
186
+ const rows = cats.map((c) => [
187
+ { text: c.category ?? 'uncategorised' },
188
+ { text: String(num(c.count)), align: 'right' },
189
+ { html: esc(money(c.spent, currency)), align: 'right' },
190
+ { text: `${((num(c.spent) / total) * 100).toFixed(1)}%`, align: 'right' },
191
+ ]);
192
+ return `<section>
193
+ <h2>Spending by category</h2>
194
+ ${table(
195
+ [
196
+ { title: 'Category' },
197
+ { title: 'Charges', align: 'right' },
198
+ { title: 'Spent', align: 'right' },
199
+ { title: 'Share', align: 'right' },
200
+ ],
201
+ rows,
202
+ )}
203
+ </section>`;
204
+ }
205
+
206
+ function debtSection(position, currency) {
207
+ const debt = position?.debt;
208
+ if (!debt || !num(debt.total)) return '';
209
+ const rows = (debt.accounts ?? []).map((a) => [
210
+ { text: a.org ?? '' },
211
+ { text: a.name ?? '' },
212
+ { html: `<span class="tag">${esc(a.scope ?? 'unmarked')}</span>` },
213
+ { html: esc(money(a.owed, currency)), align: 'right' },
214
+ { html: esc(money(a.paid, currency)), align: 'right' },
215
+ { text: a.payoffMonths ? `${a.payoffMonths} mo` : '-', align: 'right' },
216
+ ]);
217
+ return `<section>
218
+ <h2>Debt</h2>
219
+ <div class="cards three" style="margin-bottom:10px">
220
+ ${card('Owed', esc(money(debt.total, currency)))}
221
+ ${card('Service / month', esc(money(position?.income?.perMonth ? debt.total / 12 : 0, currency)), 'estimate')}
222
+ ${card('Revolving', esc(money(debt.revolving, currency)))}
223
+ </div>
224
+ ${table(
225
+ [
226
+ { title: 'Institution' },
227
+ { title: 'Account' },
228
+ { title: 'Books' },
229
+ { title: 'Owed', align: 'right' },
230
+ { title: 'Paid in period', align: 'right' },
231
+ { title: 'Payoff', align: 'right' },
232
+ ],
233
+ rows,
234
+ )}
235
+ </section>`;
236
+ }
237
+
238
+ function recurringSection(position, currency) {
239
+ const charges = position?.recurring?.charges ?? [];
240
+ if (!charges.length) return '';
241
+ const rows = charges.map((c) => [
242
+ { text: c.payee ?? '' },
243
+ { html: `<span class="tag">${esc(c.scope ?? 'unmarked')}</span>` },
244
+ { text: c.cadence ?? '' },
245
+ { html: esc(money(c.amount, currency)), align: 'right' },
246
+ { html: esc(money(c.monthlyEquivalent, currency)), align: 'right' },
247
+ { text: day(c.lastSeen), align: 'right' },
248
+ ]);
249
+ return `<section>
250
+ <h2>Recurring obligations</h2>
251
+ ${table(
252
+ [
253
+ { title: 'Payee' },
254
+ { title: 'Books' },
255
+ { title: 'Every' },
256
+ { title: 'Amount', align: 'right' },
257
+ { title: 'Per month', align: 'right' },
258
+ { title: 'Last seen', align: 'right' },
259
+ ],
260
+ rows,
261
+ )}
262
+ </section>`;
263
+ }
264
+
265
+ /**
266
+ * Every transaction, grouped by month.
267
+ *
268
+ * Grouped rather than one flat run because a bookkeeper reconciles a month at a
269
+ * time, and because a 1,200 row table with no landmarks is unusable on paper.
270
+ */
271
+ function transactionsSection(bank, currency, limit) {
272
+ const ledger = bank?.ledger ?? [];
273
+ if (!ledger.length) return '';
274
+ const scopeById = new Map((bank?.accounts ?? []).map((a) => [a.id, a.effective_scope ?? 'unmarked']));
275
+
276
+ const rows = [...ledger].sort((a, b) => String(b.posted ?? '').localeCompare(String(a.posted ?? '')));
277
+ const shown = limit > 0 ? rows.slice(0, limit) : rows;
278
+
279
+ const byMonth = new Map();
280
+ for (const row of shown) {
281
+ const key = day(row.posted).slice(0, 7) || 'undated';
282
+ if (!byMonth.has(key)) byMonth.set(key, []);
283
+ byMonth.get(key).push(row);
284
+ }
285
+
286
+ const blocks = [...byMonth.entries()]
287
+ .map(([month, entries]) => {
288
+ const out = entries.reduce((t, r) => t + (num(r.amount) < 0 ? Math.abs(num(r.amount)) : 0), 0);
289
+ const inn = entries.reduce((t, r) => t + (num(r.amount) > 0 ? num(r.amount) : 0), 0);
290
+ const body = table(
291
+ [
292
+ { title: 'Date' },
293
+ { title: 'Account' },
294
+ { title: 'Payee' },
295
+ { title: 'Category' },
296
+ { title: 'Books' },
297
+ { title: 'Amount', align: 'right' },
298
+ ],
299
+ entries.map((r) => [
300
+ { text: day(r.posted) },
301
+ { text: r.account_name ?? '' },
302
+ { text: r.payee || r.description || '' },
303
+ { text: r.category ?? '' },
304
+ { html: `<span class="tag">${esc(scopeById.get(r.account_id) ?? 'unmarked')}</span>` },
305
+ { html: signed(r.amount, r.currency ?? currency), align: 'right' },
306
+ ]),
307
+ );
308
+ return `<div class="month">
309
+ <h3>${esc(month)} &middot; ${entries.length} transactions &middot; in ${esc(money(inn, currency))} &middot; out ${esc(money(out, currency))}</h3>
310
+ ${body}
311
+ </div>`;
312
+ })
313
+ .join('');
314
+
315
+ const omitted = rows.length - shown.length;
316
+ return `<section class="txns" style="break-before:page">
317
+ <h2>Transactions</h2>
318
+ <p class="sub">${shown.length} of ${num(bank?.ledgerTotal) || rows.length} in the period, newest first.${
319
+ omitted > 0 ? ` ${omitted} not shown.` : ''
320
+ }</p>
321
+ ${blocks}
322
+ </section>`;
323
+ }
324
+
325
+ /**
326
+ * Build the report.
327
+ *
328
+ * `snapshot` is what collectFinanceSnapshot returns.
329
+ */
330
+ export function buildFinanceReportHtml(snapshot, options = {}) {
331
+ const {
332
+ preparedFor = '',
333
+ preparedBy = '',
334
+ entity = '',
335
+ generatedAt = new Date().toISOString(),
336
+ includeTransactions = true,
337
+ transactionLimit = 0,
338
+ title = 'Financial summary',
339
+ } = options;
340
+
341
+ const bank = snapshot?.bank ?? {};
342
+ const position = snapshot?.position ?? {};
343
+ const currency = bank.currency || 'USD';
344
+ const days = num(snapshot?.windowDays) || 30;
345
+ const cash = bank.cashflow ?? {};
346
+
347
+ const requestedEnd = day(generatedAt);
348
+ const requestedStart = day(new Date(Date.parse(generatedAt) - days * 86400000).toISOString());
349
+
350
+ // What the feed actually holds, which is usually less than what was asked
351
+ // for. Printing the requested window alone tells a reader that the missing
352
+ // months had no transactions in them, which is a much stronger claim than
353
+ // "the bank did not give us those months".
354
+ const dates = (bank.ledger ?? []).map((r) => day(r.posted)).filter(Boolean).sort();
355
+ const coveredStart = dates[0] ?? '';
356
+ const coveredEnd = dates[dates.length - 1] ?? '';
357
+ const short = coveredStart && coveredStart > requestedStart;
358
+
359
+ const glance = `<section>
360
+ <h2>At a glance</h2>
361
+ <div class="cards">
362
+ ${card('Money in', signed(cash.moneyIn, currency), `${days} days`)}
363
+ ${card('Money out', signed(-Math.abs(num(cash.moneyOut)), currency), `${days} days`)}
364
+ ${card('Net', signed(cash.net, currency), `${days} days`)}
365
+ ${card('Transactions', esc(String(num(cash.transactions))), `${days} days`)}
366
+ </div>
367
+ <div class="cards" style="margin-top:10px">
368
+ ${card('Cash and assets', signed(bank.assets, currency), 'as of today')}
369
+ ${card('Owed', signed(-Math.abs(num(bank.liabilities)), currency), 'as of today')}
370
+ ${card('Net position', signed(bank.net, currency), 'assets minus debts')}
371
+ ${card('Accounts', esc(String(num(bank.accountCount))), `${(bank.connections ?? []).length} institutions`)}
372
+ </div>
373
+ </section>`;
374
+
375
+ const rates = num(position.monthsObserved)
376
+ ? `<section>
377
+ <h2>Run rate</h2>
378
+ <div class="cards three">
379
+ ${card('Income / month', signed(position.income?.perMonth, currency), `over ${num(position.lookbackDays)} days`)}
380
+ ${card('Spending / month', signed(-Math.abs(num(position.spending?.perMonth)), currency), `over ${num(position.lookbackDays)} days`)}
381
+ ${card('Net / month', signed(position.net?.perMonth, currency), `${num(position.monthsObserved).toFixed(1)} months observed`)}
382
+ </div>
383
+ <p class="sub" style="margin-top:8px">Transfers between accounts and card payments are netted out of both
384
+ sides, so paying a card off does not read as income on one side and spending on the other.</p>
385
+ </section>`
386
+ : '';
387
+
388
+ const uncategorised = num(position.confidence?.uncategorisedShare);
389
+
390
+ return `<!doctype html>
391
+ <html lang="en"><head><meta charset="utf-8">
392
+ <title>${esc(title)}${entity ? ` - ${esc(entity)}` : ''}</title>
393
+ <style>${CSS}</style>
394
+ </head><body><div class="page">
395
+ <header class="doc">
396
+ <h1>${esc(title)}</h1>
397
+ <p class="sub">${
398
+ coveredStart ? `Data covers ${esc(coveredStart)} to ${esc(coveredEnd)}` : `${esc(requestedStart)} to ${esc(requestedEnd)}`
399
+ }</p>
400
+ ${
401
+ short
402
+ ? `<p class="sub" style="color:var(--neg)">Requested ${esc(requestedStart)} to ${esc(requestedEnd)}, but the
403
+ feed holds nothing before ${esc(coveredStart)}. The earlier months are missing, not empty.</p>`
404
+ : ''
405
+ }
406
+ <dl class="meta">
407
+ ${entity ? `<div><dt>Entity</dt><dd>${esc(entity)}</dd></div>` : ''}
408
+ ${preparedFor ? `<div><dt>Prepared for</dt><dd>${esc(preparedFor)}</dd></div>` : ''}
409
+ ${preparedBy ? `<div><dt>Prepared by</dt><dd>${esc(preparedBy)}</dd></div>` : ''}
410
+ <div><dt>Generated</dt><dd>${esc(generatedAt.replace('T', ' ').slice(0, 16))}</dd></div>
411
+ <div><dt>Source</dt><dd>Bank feed via CoinPay</dd></div>
412
+ </dl>
413
+ </header>
414
+
415
+ ${glance}
416
+ ${rates}
417
+ ${scopeSection(position, currency)}
418
+ ${accountsSection(bank, currency)}
419
+ ${categoriesSection(bank, currency)}
420
+ ${debtSection(position, currency)}
421
+ ${recurringSection(position, currency)}
422
+
423
+ <section>
424
+ <div class="notes">
425
+ <p><strong>What this is.</strong> A direct export of a bank and card feed for the period above. It is not a
426
+ filing, not reconciled against statements, and not advice.</p>
427
+ <p><strong>Coverage.</strong> ${esc(String((bank.ledger ?? []).length))} transactions across
428
+ ${esc(String(num(bank.accountCount)))} accounts${
429
+ coveredStart ? `, ${esc(coveredStart)} to ${esc(coveredEnd)}` : ''
430
+ }. Run-rate figures use a fixed
431
+ ${esc(String(num(position.lookbackDays)))} day lookback and do not move with the period above.</p>
432
+ ${
433
+ // A coverage line that names a period must not imply it covered it. If
434
+ // the ledger is one page of a longer window, the document says so where
435
+ // the claim is made, not in a log nobody keeps.
436
+ bank.ledgerComplete === false
437
+ ? `<p class="warn"><strong>Incomplete.</strong> This is ${esc(String((bank.ledger ?? []).length))}
438
+ of ${esc(String(num(bank.ledgerTotal)))} transactions the API reports for this period. The totals
439
+ below are computed from what was returned and understate the period. Do not reconcile against
440
+ this document.</p>`
441
+ : ''
442
+ }
443
+ ${
444
+ uncategorised > 0
445
+ ? `<p><strong>Uncategorised.</strong> ${(uncategorised * 100).toFixed(0)}% of transactions carry no
446
+ category, so any category total is a floor rather than a complete figure.</p>`
447
+ : ''
448
+ }
449
+ <p><strong>Books split.</strong> Business and personal come from how each account is marked. Anything marked
450
+ "unmarked" has not been assigned and is counted in totals but in neither set of books.</p>
451
+ </div>
452
+ </section>
453
+
454
+ ${includeTransactions ? transactionsSection(bank, currency, transactionLimit) : ''}
455
+ </div></body></html>`;
456
+ }
457
+
458
+ export default buildFinanceReportHtml;