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.
- package/config/skills/agent/checkup-analyse/SKILL.md +99 -0
- package/config/skills/agent/checkup-analyse/analyse.mjs +603 -0
- package/config/skills/agent/checkup-analyse/execute.sh +34 -0
- package/config/skills/agent/checkup-analyse/execute.test.sh +126 -0
- package/config/skills/agent/ingest-exports/SKILL.md +97 -0
- package/config/skills/agent/ingest-exports/execute.sh +35 -0
- package/config/skills/agent/ingest-exports/execute.test.sh +82 -0
- package/config/skills/agent/ingest-exports/fixtures/jackrabbit-enrollments.csv +4 -0
- package/config/skills/agent/ingest-exports/fixtures/jotform-submissions.csv +4 -0
- package/config/skills/agent/ingest-exports/fixtures/mailchimp-audience.csv +3 -0
- package/config/skills/agent/ingest-exports/fixtures/quickbooks-invoices.csv +4 -0
- package/config/skills/agent/ingest-exports/fixtures/square-customers.csv +4 -0
- package/config/skills/agent/ingest-exports/fixtures/square-transactions.csv +5 -0
- package/config/skills/agent/ingest-exports/fixtures/unknown-report.csv +4 -0
- package/config/skills/agent/ingest-exports/ingest.mjs +368 -0
- package/dist/backend/build-info.json +2 -2
- package/package.json +1 -1
|
@@ -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[@]}"}
|