crewly 1.20.447 → 1.20.449

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,603 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // checkup-analyse — the analysis step of the small-business check-up
4
+ // (crewly#1062). Reads the common-schema records that `ingest-exports` wrote,
5
+ // links the same person across every file, follows inquiry → sign-up →
6
+ // payment → repeat, and lists where that chain breaks:
7
+ //
8
+ // inquiry_no_follow_through a form submission with no sign-up or payment after it
9
+ // signed_up_unpaid a sign-up with no payment from that person
10
+ // paid_not_listed a payment from someone on no roster
11
+ // dropped a sign-up marked dropped / cancelled
12
+ // lapsed a customer with no activity for N days
13
+ // overdue_invoices an invoice past due with a balance
14
+ // double_entry the same person keyed into two or more systems
15
+ // not_on_mailing_list a customer the mailing list does not have
16
+ //
17
+ // Every break carries an hours/week and $/month estimate with the formula
18
+ // written out and marked as an estimate. No model calls, no dependencies.
19
+ //
20
+ // node analyse.mjs --in <ingest out folder> [--out <folder>] [--as-of YYYY-MM-DD]
21
+ // [--assume hourlyValue=40] [--assume lapsedDays=60] ...
22
+ // =============================================================================
23
+ import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
24
+ import { join, basename, resolve } from 'node:path';
25
+
26
+ const SCHEMA_VERSION = '1.0';
27
+ const DAY_MS = 86_400_000;
28
+
29
+ /** Sources whose "booking" rows are inquiries (someone asked), not sign-ups. */
30
+ const INQUIRY_SOURCES = new Set(['jotform']);
31
+ /** Sources that are a mailing list rather than a customer record. */
32
+ const MAILING_SOURCES = new Set(['mailchimp']);
33
+ const DROPPED_STATUSES = new Set(['dropped', 'cancelled', 'canceled', 'withdrawn']);
34
+ const FAILED_PAYMENT_STATUSES = new Set(['failed', 'declined', 'voided', 'void', 'pending']);
35
+
36
+ /** Every number in an estimate comes from one of these; the owner can change any of them. */
37
+ export const DEFAULT_ASSUMPTIONS = {
38
+ hourlyValue: 30, // $ an hour of the owner's or staff's time is worth
39
+ minutesPerManualEntry: 4, // minutes to key one person into one more system
40
+ minutesPerFollowUp: 10, // minutes to chase one inquiry, unpaid sign-up or invoice
41
+ inquiryConversionRate: 0.3, // share of followed-up inquiries that become customers
42
+ winBackRate: 0.2, // share of dropped or lapsed customers a nudge brings back
43
+ listReachRate: 0.1, // share of customers a mailing list brings back for another purchase
44
+ lapsedDays: 90, // days without activity before a customer counts as lapsed
45
+ avgCustomerValue: null, // $ per customer; null = mean paid amount / sign-up fee in the data
46
+ };
47
+
48
+ const BREAK_DEFS = {
49
+ inquiry_no_follow_through: {
50
+ label: 'Inquiries with no sign-up or payment after them',
51
+ agent: 'Reply to each open inquiry within the hour, offer the next free slot, and remind once after three days.',
52
+ },
53
+ signed_up_unpaid: {
54
+ label: 'Sign-ups with no payment',
55
+ agent: 'Send a payment link the day of sign-up and a reminder before the first session; flag anyone still unpaid to the owner.',
56
+ },
57
+ paid_not_listed: {
58
+ label: 'Payments from people on no roster',
59
+ agent: 'Match each payment to a booking or add the person to the roster, and ask the owner about the ones that match nothing.',
60
+ },
61
+ dropped: {
62
+ label: 'Sign-ups that were dropped or cancelled',
63
+ agent: 'Ask each one why in one short message, offer a make-up or the next session, and report the reasons back.',
64
+ },
65
+ lapsed: {
66
+ label: 'Customers with no activity for a while',
67
+ agent: 'Send a personal "we have a spot for you" note with the next dates, in batches, and track who comes back.',
68
+ },
69
+ overdue_invoices: {
70
+ label: 'Invoices past due with a balance',
71
+ agent: 'Send one friendly reminder per overdue invoice with the amount and a payment link, then a second after a week.',
72
+ },
73
+ double_entry: {
74
+ label: 'The same person keyed into two or more systems',
75
+ agent: 'Keep one system as the source of truth and copy new people into the others each day, so nothing is typed twice.',
76
+ },
77
+ not_on_mailing_list: {
78
+ label: 'Customers the mailing list does not have',
79
+ agent: 'Add each paying customer to the list (with consent) so the next class or offer reaches them.',
80
+ },
81
+ };
82
+
83
+ // --- helpers ----------------------------------------------------------------
84
+
85
+ function isHash(v) {
86
+ return typeof v === 'string' && v.startsWith('h:');
87
+ }
88
+ function normEmail(v) {
89
+ if (!v) return '';
90
+ const s = String(v).trim();
91
+ return isHash(s) ? s : s.toLowerCase();
92
+ }
93
+ function normPhone(v) {
94
+ if (!v) return '';
95
+ const s = String(v).trim();
96
+ if (isHash(s)) return s;
97
+ const d = s.replace(/\D/g, '');
98
+ if (d.length < 7) return '';
99
+ return d.length === 11 && d.startsWith('1') ? d.slice(1) : d;
100
+ }
101
+ function normName(v) {
102
+ if (!v) return '';
103
+ const s = String(v).trim();
104
+ return isHash(s) ? s : s.toLowerCase().replace(/\s+/g, ' ');
105
+ }
106
+ function dayOf(iso) {
107
+ return typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : '';
108
+ }
109
+ function ms(iso) {
110
+ const d = dayOf(iso);
111
+ return d ? Date.parse(`${d}T00:00:00Z`) : NaN;
112
+ }
113
+ function round1(n) {
114
+ return Math.round(n * 10) / 10;
115
+ }
116
+ function money(n) {
117
+ return Math.round(n);
118
+ }
119
+
120
+ // --- people -----------------------------------------------------------------
121
+
122
+ class People {
123
+ constructor() {
124
+ this.persons = [];
125
+ this.keyToPerson = new Map();
126
+ }
127
+ /** Register a record under every identity it carries; merge persons the record joins. */
128
+ add(rec) {
129
+ const keys = [];
130
+ const email = normEmail(rec.email);
131
+ const phone = normPhone(rec.phone);
132
+ const name = normName(rec.name);
133
+ if (email) keys.push(`e:${email}`);
134
+ if (phone) keys.push(`p:${phone}`);
135
+ if (!email && !phone && name) keys.push(`n:${name}`);
136
+ if (keys.length === 0) return null;
137
+ const found = [...new Set(keys.map((k) => this.keyToPerson.get(k)).filter(Boolean))];
138
+ let person = found[0];
139
+ if (!person) {
140
+ person = { id: this.persons.length + 1, names: new Set(), emails: new Set(), phones: new Set(), sources: new Set(), records: [] };
141
+ this.persons.push(person);
142
+ }
143
+ for (const other of found.slice(1)) this.merge(person, other);
144
+ for (const k of keys) this.keyToPerson.set(k, person);
145
+ if (name) person.names.add(String(rec.name).trim());
146
+ if (email) person.emails.add(email);
147
+ if (phone) person.phones.add(phone);
148
+ person.sources.add(rec.source || 'unknown');
149
+ person.records.push(rec);
150
+ return person;
151
+ }
152
+ merge(into, from) {
153
+ for (const n of from.names) into.names.add(n);
154
+ for (const e of from.emails) into.emails.add(e);
155
+ for (const p of from.phones) into.phones.add(p);
156
+ for (const s of from.sources) into.sources.add(s);
157
+ into.records.push(...from.records);
158
+ for (const [k, p] of this.keyToPerson) if (p === from) this.keyToPerson.set(k, into);
159
+ from.merged = true;
160
+ }
161
+ live() {
162
+ return this.persons.filter((p) => !p.merged);
163
+ }
164
+ }
165
+
166
+ /** The name to show for a person: the adult on a contact or payment row before a child's name on a booking row. */
167
+ function label(person) {
168
+ const adult = person.records.find((r) => (r.kind === 'contact' || r.kind === 'payment') && r.name);
169
+ return (adult && String(adult.name).trim()) || [...person.names][0] || [...person.emails][0] || [...person.phones][0] || `person-${person.id}`;
170
+ }
171
+
172
+ // --- events -----------------------------------------------------------------
173
+
174
+ /** Turn a common-schema record into what happened: inquiry, signup, dropped, payment, refund, invoice, listed, contact. */
175
+ export function classify(rec) {
176
+ const status = String(rec.status || '').toLowerCase();
177
+ switch (rec.kind) {
178
+ case 'payment':
179
+ if (status === 'refunded') return 'refund';
180
+ if (FAILED_PAYMENT_STATUSES.has(status)) return 'failed_payment';
181
+ return 'payment';
182
+ case 'invoice':
183
+ return 'invoice';
184
+ case 'booking':
185
+ if (INQUIRY_SOURCES.has(rec.source)) return 'inquiry';
186
+ return DROPPED_STATUSES.has(status) ? 'dropped' : 'signup';
187
+ case 'contact':
188
+ return MAILING_SOURCES.has(rec.source) ? 'listed' : 'contact';
189
+ default:
190
+ return 'other';
191
+ }
192
+ }
193
+
194
+ // --- loading ----------------------------------------------------------------
195
+
196
+ function loadRecords(inputs) {
197
+ const files = [];
198
+ const problems = [];
199
+ for (const input of inputs) {
200
+ const p = resolve(input);
201
+ if (!existsSync(p)) {
202
+ problems.push(`${input}: not found`);
203
+ continue;
204
+ }
205
+ if (statSync(p).isDirectory()) {
206
+ for (const f of readdirSync(p).sort()) {
207
+ if (f.endsWith('.json') && f !== 'summary.json') files.push(join(p, f));
208
+ }
209
+ } else if (p.endsWith('.json')) {
210
+ files.push(p);
211
+ } else {
212
+ problems.push(`${input}: not an ingest-exports JSON file (run ingest-exports first)`);
213
+ }
214
+ }
215
+ const records = [];
216
+ const sources = [];
217
+ for (const f of files) {
218
+ try {
219
+ const data = JSON.parse(readFileSync(f, 'utf8'));
220
+ if (!Array.isArray(data.records)) {
221
+ problems.push(`${basename(f)}: no records array`);
222
+ continue;
223
+ }
224
+ sources.push({ file: data.file || basename(f), source: data.source || 'unknown', kind: data.kind || 'other', rows: data.records.length });
225
+ records.push(...data.records.map((r) => ({ ...r, event: classify(r) })));
226
+ } catch (e) {
227
+ problems.push(`${basename(f)}: ${e.message}`);
228
+ }
229
+ }
230
+ return { records, sources, problems };
231
+ }
232
+
233
+ // --- analysis ---------------------------------------------------------------
234
+
235
+ function parseAssumptions(pairs) {
236
+ const a = { ...DEFAULT_ASSUMPTIONS };
237
+ for (const pair of pairs) {
238
+ const [k, v] = String(pair).split('=');
239
+ if (!(k in DEFAULT_ASSUMPTIONS)) throw new Error(`unknown assumption "${k}" (known: ${Object.keys(DEFAULT_ASSUMPTIONS).join(', ')})`);
240
+ const n = Number(v);
241
+ if (!Number.isFinite(n)) throw new Error(`assumption ${k} needs a number, got "${v}"`);
242
+ a[k] = n;
243
+ }
244
+ return a;
245
+ }
246
+
247
+ export function analyse(records, opts = {}) {
248
+ const a = { ...DEFAULT_ASSUMPTIONS, ...(opts.assumptions || {}) };
249
+ const notes = [];
250
+ const people = new People();
251
+ const anonymous = {};
252
+ let ignored = 0;
253
+
254
+ for (const rec of records) {
255
+ if (rec.event === 'other') {
256
+ ignored += 1;
257
+ continue;
258
+ }
259
+ if (!people.add(rec)) anonymous[rec.event] = (anonymous[rec.event] || 0) + 1;
260
+ }
261
+ const persons = people.live();
262
+ const used = records.filter((r) => r.event !== 'other');
263
+ const systems = [...new Set(used.map((r) => r.source || 'unknown'))].sort();
264
+ const hasSignupSystem = records.some((r) => r.event === 'signup' || r.event === 'dropped');
265
+ const hasMailingList = records.some((r) => r.event === 'listed');
266
+
267
+ // Period: first activity → as-of (default: the last activity in the data). Activity means
268
+ // something happened (an inquiry, sign-up, payment, invoice), not when a contact was created.
269
+ const ACTIVITY = new Set(['inquiry', 'signup', 'dropped', 'payment', 'refund', 'failed_payment', 'invoice']);
270
+ const ats = used.filter((r) => ACTIVITY.has(r.event)).map((r) => ms(r.at)).filter(Number.isFinite);
271
+ const firstMs = ats.length ? Math.min(...ats) : NaN;
272
+ const lastMs = ats.length ? Math.max(...ats) : NaN;
273
+ const asOfMs = opts.asOf ? ms(opts.asOf) : lastMs;
274
+ if (!Number.isFinite(asOfMs)) throw new Error('no dated records and no --as-of; nothing to analyse');
275
+ const asOf = new Date(asOfMs).toISOString().slice(0, 10);
276
+ const days = Number.isFinite(firstMs) ? Math.max(1, Math.round((asOfMs - firstMs) / DAY_MS)) : 1;
277
+ const weeks = Math.max(1, days / 7);
278
+ const months = Math.max(1, days / 30.44);
279
+ const period = { from: Number.isFinite(firstMs) ? new Date(firstMs).toISOString().slice(0, 10) : asOf, to: asOf, days, weeks: round1(weeks), months: round1(months) };
280
+
281
+ // Average customer value: mean of completed payments and sign-up fees unless assumed.
282
+ let avgCustomerValue = a.avgCustomerValue;
283
+ let avgSource = 'assumed';
284
+ if (avgCustomerValue == null) {
285
+ const amounts = records.filter((r) => (r.event === 'payment' || r.event === 'signup') && Number(r.amount) > 0).map((r) => Number(r.amount));
286
+ if (amounts.length) {
287
+ avgCustomerValue = Math.round(amounts.reduce((s, n) => s + n, 0) / amounts.length);
288
+ avgSource = `mean of ${amounts.length} paid amounts and sign-up fees in the data`;
289
+ } else {
290
+ avgCustomerValue = 50;
291
+ avgSource = 'default (no paid amounts in the data)';
292
+ notes.push('No paid amounts or sign-up fees in the data; $ estimates use a $50 customer value.');
293
+ }
294
+ }
295
+ const hoursWeek = (count, minutes) => round1((count * minutes) / 60 / weeks);
296
+ const dollarsMonthFromHours = (h) => money(h * 4.345 * a.hourlyValue);
297
+
298
+ const evs = (p, ...types) => p.records.filter((r) => types.includes(r.event));
299
+ const breaks = [];
300
+
301
+ // 1. Inquiry with no follow-through
302
+ {
303
+ const items = [];
304
+ for (const p of persons) {
305
+ const inquiries = evs(p, 'inquiry');
306
+ if (!inquiries.length) continue;
307
+ if (evs(p, 'signup', 'dropped', 'payment').length) continue;
308
+ const first = inquiries.sort((x, y) => String(x.at).localeCompare(String(y.at)))[0];
309
+ items.push({ person: label(p), inquiredAt: dayOf(first.at), item: first.item || null, source: first.source });
310
+ }
311
+ const n = items.length;
312
+ const h = hoursWeek(n, a.minutesPerFollowUp);
313
+ const d = money((n * a.inquiryConversionRate * avgCustomerValue) / months);
314
+ breaks.push(mkBreak('inquiry_no_follow_through', items, h, d, `${n} inquiries × ${a.inquiryConversionRate} conversion × $${avgCustomerValue} customer value ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min follow-up ÷ ${round1(weeks)} weeks = ${h} h/week`));
315
+ }
316
+
317
+ // 2. Signed up but unpaid
318
+ {
319
+ const items = [];
320
+ for (const p of persons) {
321
+ const payments = evs(p, 'payment');
322
+ for (const s of evs(p, 'signup')) {
323
+ const since = ms(s.at) - 7 * DAY_MS;
324
+ const paid = payments.some((pay) => !Number.isFinite(since) || !Number.isFinite(ms(pay.at)) || ms(pay.at) >= since);
325
+ if (paid) continue;
326
+ items.push({ person: label(p), item: s.item || null, signedUpAt: dayOf(s.at), fee: Number(s.amount) || 0, source: s.source });
327
+ }
328
+ }
329
+ const n = items.length;
330
+ const outstanding = money(items.reduce((s, i) => s + i.fee, 0));
331
+ const h = hoursWeek(n, a.minutesPerFollowUp);
332
+ const d = money(outstanding / months);
333
+ breaks.push(mkBreak('signed_up_unpaid', items, h, d, `$${outstanding} in unpaid fees ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min ÷ ${round1(weeks)} weeks = ${h} h/week`, { outstanding }));
334
+ }
335
+
336
+ // 3. Paid but on no roster (only meaningful when a roster system is in the data)
337
+ {
338
+ const items = [];
339
+ if (hasSignupSystem) {
340
+ for (const p of persons) {
341
+ const payments = evs(p, 'payment');
342
+ if (!payments.length || evs(p, 'signup', 'dropped').length) continue;
343
+ for (const pay of payments) items.push({ person: label(p), paidAt: dayOf(pay.at), amount: Number(pay.amount) || 0, item: pay.item || null, source: pay.source });
344
+ }
345
+ } else {
346
+ notes.push('No roster or booking export in the data, so "paid but not listed" could not be checked.');
347
+ }
348
+ const n = items.length;
349
+ const h = hoursWeek(n, a.minutesPerFollowUp);
350
+ breaks.push(mkBreak('paid_not_listed', items, h, 0, `${n} payments × ${a.minutesPerFollowUp} min to reconcile ÷ ${round1(weeks)} weeks = ${h} h/week; no $ figure (the money arrived; the risk is a person who shows up unlisted)`));
351
+ }
352
+
353
+ // 4. Dropped sign-ups
354
+ {
355
+ const items = [];
356
+ for (const p of persons) {
357
+ for (const s of evs(p, 'dropped')) items.push({ person: label(p), item: s.item || null, signedUpAt: dayOf(s.at), droppedAt: dayOf(s.endAt) || null, fee: Number(s.amount) || 0, source: s.source });
358
+ }
359
+ const n = items.length;
360
+ const fees = money(items.reduce((s, i) => s + i.fee, 0));
361
+ const h = hoursWeek(n, a.minutesPerFollowUp);
362
+ const d = money((fees * a.winBackRate) / months);
363
+ breaks.push(mkBreak('dropped', items, h, d, `$${fees} in dropped fees × ${a.winBackRate} win-back ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min ÷ ${round1(weeks)} weeks = ${h} h/week`));
364
+ }
365
+
366
+ // 5. Lapsed customers
367
+ {
368
+ const items = [];
369
+ const cutoff = asOfMs - a.lapsedDays * DAY_MS;
370
+ for (const p of persons) {
371
+ const customerEvents = evs(p, 'payment', 'signup', 'dropped');
372
+ if (!customerEvents.length) continue;
373
+ const stamps = p.records.flatMap((r) => [ms(r.at), ms(r.endAt)]).filter(Number.isFinite);
374
+ if (!stamps.length) continue;
375
+ const last = Math.max(...stamps);
376
+ if (last >= cutoff) continue;
377
+ const totalPaid = money(evs(p, 'payment').reduce((s, r) => s + (Number(r.amount) || 0), 0));
378
+ items.push({ person: label(p), lastActivity: new Date(last).toISOString().slice(0, 10), daysSince: Math.round((asOfMs - last) / DAY_MS), totalPaid });
379
+ }
380
+ const n = items.length;
381
+ const h = hoursWeek(n, a.minutesPerFollowUp);
382
+ const d = money((n * a.winBackRate * avgCustomerValue) / months);
383
+ breaks.push(mkBreak('lapsed', items, h, d, `${n} lapsed (no activity for ${a.lapsedDays} days) × ${a.winBackRate} win-back × $${avgCustomerValue} ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min ÷ ${round1(weeks)} weeks = ${h} h/week`));
384
+ }
385
+
386
+ // 6. Overdue invoices
387
+ {
388
+ const items = [];
389
+ let openNotDue = 0;
390
+ for (const r of records) {
391
+ if (r.event !== 'invoice' || !(Number(r.balance) > 0)) continue;
392
+ const due = ms(r.dueAt);
393
+ if (Number.isFinite(due) && due < asOfMs) {
394
+ items.push({ customer: r.name || null, number: r.id || null, dueAt: dayOf(r.dueAt), daysOverdue: Math.round((asOfMs - due) / DAY_MS), balance: Number(r.balance), source: r.source });
395
+ } else {
396
+ openNotDue += 1;
397
+ }
398
+ }
399
+ const n = items.length;
400
+ const outstanding = money(items.reduce((s, i) => s + i.balance, 0));
401
+ const h = hoursWeek(n, a.minutesPerFollowUp);
402
+ const d = money(outstanding / months);
403
+ breaks.push(mkBreak('overdue_invoices', items, h, d, `$${outstanding} overdue ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min ÷ ${round1(weeks)} weeks = ${h} h/week`, { outstanding, openNotYetDue: openNotDue }));
404
+ }
405
+
406
+ // 7. Double entry
407
+ {
408
+ const items = [];
409
+ let extraEntries = 0;
410
+ for (const p of persons) {
411
+ if (p.sources.size < 2) continue;
412
+ const conflicts = [];
413
+ const withPhone = new Set(p.records.filter((r) => normPhone(r.phone)).map((r) => r.source));
414
+ const withoutPhone = [...p.sources].filter((s) => !withPhone.has(s) && p.records.some((r) => r.source === s && r.kind === 'contact'));
415
+ if (withPhone.size && withoutPhone.length) conflicts.push(`phone missing in ${withoutPhone.join(', ')}`);
416
+ // Only names on contact rows are compared: a booking row carries the child's name, which is not a conflict.
417
+ const contactNames = [...new Set(p.records.filter((r) => r.kind === 'contact' && r.name).map((r) => String(r.name).trim()))];
418
+ if (new Set(contactNames.map(normName)).size > 1) conflicts.push(`names differ: ${contactNames.join(' / ')}`);
419
+ extraEntries += p.sources.size - 1;
420
+ items.push({ person: label(p), systems: [...p.sources].sort(), conflicts });
421
+ }
422
+ const n = items.length;
423
+ const h = round1((extraEntries * a.minutesPerManualEntry) / 60 / weeks);
424
+ const d = dollarsMonthFromHours(h);
425
+ breaks.push(mkBreak('double_entry', items, h, d, `${extraEntries} extra entries × ${a.minutesPerManualEntry} min ÷ ${round1(weeks)} weeks = ${h} h/week; × 4.345 weeks × $${a.hourlyValue}/h = $${d}/month`, { extraEntries, people: n }));
426
+ }
427
+
428
+ // 8. Customers not on the mailing list (only when a mailing list is in the data)
429
+ {
430
+ const items = [];
431
+ if (hasMailingList) {
432
+ for (const p of persons) {
433
+ if (!evs(p, 'payment', 'signup', 'dropped').length || evs(p, 'listed').length) continue;
434
+ items.push({ person: label(p) });
435
+ }
436
+ } else {
437
+ notes.push('No mailing-list export in the data, so "not on the mailing list" could not be checked.');
438
+ }
439
+ const n = items.length;
440
+ const d = money((n * a.listReachRate * avgCustomerValue) / months);
441
+ breaks.push(mkBreak('not_on_mailing_list', items, 0, d, `${n} customers × ${a.listReachRate} reach × $${avgCustomerValue} ÷ ${round1(months)} months = $${d}/month; adding them is a one-off`));
442
+ }
443
+
444
+ const ranked = breaks.filter((b) => b.count > 0).sort((x, y) => y.estimate.dollarsPerMonth - x.estimate.dollarsPerMonth || y.estimate.hoursPerWeek - x.estimate.hoursPerWeek);
445
+ const refunds = records.filter((r) => r.event === 'refund');
446
+ const stats = {
447
+ records: records.length,
448
+ ignoredOtherRows: ignored,
449
+ people: persons.length,
450
+ systems,
451
+ byEvent: countBy(records, (r) => r.event),
452
+ anonymousRows: anonymous,
453
+ refunds: { count: refunds.length, amount: money(refunds.reduce((s, r) => s + (Number(r.amount) || 0), 0)) },
454
+ avgCustomerValue,
455
+ avgCustomerValueFrom: avgSource,
456
+ };
457
+ const totals = {
458
+ hoursPerWeek: round1(breaks.reduce((s, b) => s + b.estimate.hoursPerWeek, 0)),
459
+ dollarsPerMonth: money(breaks.reduce((s, b) => s + b.estimate.dollarsPerMonth, 0)),
460
+ };
461
+ return { schemaVersion: SCHEMA_VERSION, asOf, period, assumptions: { ...a, avgCustomerValue }, stats, breaks, top3: ranked.slice(0, 3).map((b) => b.type), totals, notes };
462
+ }
463
+
464
+ function mkBreak(type, items, hoursPerWeek, dollarsPerMonth, formula, extra = {}) {
465
+ return {
466
+ type,
467
+ label: BREAK_DEFS[type].label,
468
+ count: items.length,
469
+ items,
470
+ estimate: { hoursPerWeek, dollarsPerMonth, ...extra, formula, isEstimate: true },
471
+ whatAnAgentCouldDo: BREAK_DEFS[type].agent,
472
+ };
473
+ }
474
+
475
+ function countBy(list, fn) {
476
+ const out = {};
477
+ for (const x of list) {
478
+ const k = fn(x);
479
+ out[k] = (out[k] || 0) + 1;
480
+ }
481
+ return out;
482
+ }
483
+
484
+ // --- report -----------------------------------------------------------------
485
+
486
+ export function renderReport(result, sources) {
487
+ const L = [];
488
+ L.push('# Check-up: where the apps disagree');
489
+ L.push('');
490
+ L.push(`As of ${result.asOf}. Data covers ${result.period.from} to ${result.period.to} (${result.period.days} days) across ${result.stats.systems.join(', ')}: ${result.stats.records} rows, ${result.stats.people} people.`);
491
+ L.push('');
492
+ L.push(`Every hour and dollar figure below is an **estimate** from the assumptions at the end; change an assumption and the numbers follow. Together the breaks come to about **${result.totals.hoursPerWeek} h/week** and **$${result.totals.dollarsPerMonth}/month**.`);
493
+ L.push('');
494
+ L.push('## Top 3');
495
+ L.push('');
496
+ const byType = Object.fromEntries(result.breaks.map((b) => [b.type, b]));
497
+ if (!result.top3.length) L.push('No breaks found in this data.');
498
+ result.top3.forEach((t, i) => {
499
+ const b = byType[t];
500
+ L.push(`### ${i + 1}. ${b.label} — ${b.count}`);
501
+ L.push('');
502
+ L.push(`Estimate: ${b.estimate.hoursPerWeek} h/week, $${b.estimate.dollarsPerMonth}/month.`);
503
+ L.push(`How: ${b.estimate.formula}.`);
504
+ L.push(`What an agent could do: ${b.whatAnAgentCouldDo}`);
505
+ L.push('');
506
+ });
507
+ L.push('## Every break');
508
+ L.push('');
509
+ L.push('| Break | Count | h/week | $/month |');
510
+ L.push('|---|---:|---:|---:|');
511
+ for (const b of result.breaks) L.push(`| ${b.label} | ${b.count} | ${b.estimate.hoursPerWeek} | ${b.estimate.dollarsPerMonth} |`);
512
+ L.push('');
513
+ if (result.notes.length) {
514
+ L.push('## Could not check');
515
+ L.push('');
516
+ for (const n of result.notes) L.push(`- ${n}`);
517
+ L.push('');
518
+ }
519
+ L.push('## Files');
520
+ L.push('');
521
+ for (const s of sources) L.push(`- ${s.file}: ${s.source} ${s.kind}, ${s.rows} rows`);
522
+ L.push('');
523
+ L.push('## Assumptions');
524
+ L.push('');
525
+ for (const [k, v] of Object.entries(result.assumptions)) L.push(`- ${k}: ${v}${k === 'avgCustomerValue' ? ` (${result.stats.avgCustomerValueFrom})` : ''}`);
526
+ L.push('');
527
+ return L.join('\n');
528
+ }
529
+
530
+ // --- CLI --------------------------------------------------------------------
531
+
532
+ function usage() {
533
+ process.stdout.write(
534
+ JSON.stringify({ success: false, error: 'usage: analyse.mjs --in <ingest-exports out folder or <file>.json> [--out <folder>] [--as-of YYYY-MM-DD] [--assume key=value ...]' }) + '\n',
535
+ );
536
+ process.exit(1);
537
+ }
538
+
539
+ function main(argv) {
540
+ const inputs = [];
541
+ const assume = [];
542
+ let out = null;
543
+ let asOf = null;
544
+ for (let i = 0; i < argv.length; i += 1) {
545
+ const arg = argv[i];
546
+ if (arg === '--in') inputs.push(argv[++i]);
547
+ else if (arg === '--out') out = argv[++i];
548
+ else if (arg === '--as-of') asOf = argv[++i];
549
+ else if (arg === '--assume') assume.push(argv[++i]);
550
+ else if (arg === '--help' || arg === '-h') usage();
551
+ else usage();
552
+ }
553
+ if (!inputs.length || inputs.some((x) => !x)) usage();
554
+ if (asOf && !/^\d{4}-\d{2}-\d{2}$/.test(asOf)) {
555
+ process.stdout.write(JSON.stringify({ success: false, error: `--as-of must be YYYY-MM-DD, got "${asOf}"` }) + '\n');
556
+ process.exit(1);
557
+ }
558
+ let assumptions;
559
+ try {
560
+ assumptions = parseAssumptions(assume);
561
+ } catch (e) {
562
+ process.stdout.write(JSON.stringify({ success: false, error: e.message }) + '\n');
563
+ process.exit(1);
564
+ }
565
+
566
+ const { records, sources, problems } = loadRecords(inputs);
567
+ if (!records.length) {
568
+ process.stdout.write(JSON.stringify({ success: false, error: 'no records found', problems }) + '\n');
569
+ process.exit(2);
570
+ }
571
+ let result;
572
+ try {
573
+ result = analyse(records, { asOf, assumptions });
574
+ } catch (e) {
575
+ process.stdout.write(JSON.stringify({ success: false, error: e.message, problems }) + '\n');
576
+ process.exit(2);
577
+ }
578
+ result.files = sources;
579
+ result.problems = problems;
580
+
581
+ if (out) {
582
+ mkdirSync(out, { recursive: true });
583
+ writeFileSync(join(out, 'breaks.json'), JSON.stringify(result, null, 2));
584
+ writeFileSync(join(out, 'report.md'), renderReport(result, sources));
585
+ }
586
+ const summary = {
587
+ success: problems.length === 0,
588
+ asOf: result.asOf,
589
+ period: result.period,
590
+ people: result.stats.people,
591
+ systems: result.stats.systems,
592
+ breaks: result.breaks.map((b) => ({ type: b.type, count: b.count, hoursPerWeek: b.estimate.hoursPerWeek, dollarsPerMonth: b.estimate.dollarsPerMonth })),
593
+ top3: result.top3,
594
+ totals: result.totals,
595
+ notes: result.notes,
596
+ problems,
597
+ out: out ? resolve(out) : null,
598
+ };
599
+ process.stdout.write(JSON.stringify(summary) + '\n');
600
+ process.exit(problems.length ? 2 : 0);
601
+ }
602
+
603
+ if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2));
@@ -0,0 +1,34 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # checkup-analyse — the analysis step of the small-business check-up
4
+ # (crewly#1062): read what ingest-exports wrote, link people across files,
5
+ # follow inquiry → sign-up → payment → repeat and list where it breaks, with
6
+ # an hours/week and $/month estimate (formula shown) per break.
7
+ #
8
+ # Usage:
9
+ # bash execute.sh --in /tmp/checkup/ingest --out /tmp/checkup/analysis
10
+ # bash execute.sh --in /tmp/checkup/ingest --as-of 2026-10-31 --assume hourlyValue=40
11
+ # bash execute.sh '{"in":"/tmp/checkup/ingest","out":"/tmp/checkup/analysis","asOf":"2026-10-31","assume":{"lapsedDays":60}}'
12
+ #
13
+ # Output: one JSON line (per-break counts and estimates, the top 3, totals,
14
+ # notes) and, with --out, breaks.json (every item) + report.md.
15
+ # Exit 0 = analysed, 2 = nothing to analyse or a file could not be read, 1 = usage.
16
+ # =============================================================================
17
+ set -euo pipefail
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ ANALYSE="${SCRIPT_DIR}/analyse.mjs"
20
+
21
+ command -v node >/dev/null 2>&1 || { printf '{"success":false,"error":"node is required but not on PATH"}\n'; exit 1; }
22
+
23
+ ARGS=()
24
+ if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
25
+ command -v jq >/dev/null 2>&1 || { printf '{"success":false,"error":"jq is required to read JSON input"}\n'; exit 1; }
26
+ while IFS= read -r p; do [ -n "$p" ] && ARGS+=(--in "$p"); done < <(printf '%s' "$1" | jq -r '(.in // .inputs // []) | if type=="string" then [.] else . end | .[]')
27
+ out=$(printf '%s' "$1" | jq -r '.out // empty'); [ -n "$out" ] && ARGS+=(--out "$out")
28
+ asof=$(printf '%s' "$1" | jq -r '.asOf // .as_of // empty'); [ -n "$asof" ] && ARGS+=(--as-of "$asof")
29
+ while IFS= read -r kv; do [ -n "$kv" ] && ARGS+=(--assume "$kv"); done < <(printf '%s' "$1" | jq -r '(.assume // .assumptions // {}) | to_entries[] | "\(.key)=\(.value)"')
30
+ shift
31
+ fi
32
+ ARGS+=("$@")
33
+
34
+ exec node "$ANALYSE" ${ARGS[@]+"${ARGS[@]}"}