sizmo 0.4.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,119 @@
1
+ // commands/receivables.mjs — A/R: who owes, how much, how old.
2
+ // Trust-fix #1: LOC from ctx.cfg.loc.
3
+ // Trust-fix #2: invoices paginate to completion (was offset-capped at 2000).
4
+ // Trust-fix #3: per-currency totals (never cross-sum).
5
+ // READ-ONLY. Agent can send/void one at a time, L2 confirm. NEVER charges.
6
+ import { paginate } from '../lib/paginate.mjs';
7
+
8
+ export const meta = {
9
+ name: 'receivables',
10
+ summary: 'A/R — who owes, how much, how old',
11
+ flags: [
12
+ { name: '--top', type: 'int', default: 20, desc: 'max rows to display' },
13
+ ],
14
+ readOnly: true,
15
+ };
16
+
17
+ const SYM = { PHP: '₱', USD: '$', EUR: '€', GBP: '£' };
18
+ const money = (n, c = 'PHP') => (SYM[c] || c + ' ') + Number(n || 0).toLocaleString('en-PH', { maximumFractionDigits: 0 });
19
+ const UNPAID = new Set(['sent', 'overdue', 'partially_paid', 'partially paid', 'payment_processing', 'viewed', 'due']);
20
+
21
+ export async function collect(args, ctx) {
22
+ const TOP = args.top ?? 20;
23
+ const LOC = ctx.cfg.loc;
24
+ const NOW = ctx.now;
25
+ const ageDays = (t) => t ? Math.floor((NOW - t) / 86400000) : null;
26
+
27
+ let firstErr = null;
28
+ const inv = [];
29
+ for await (const item of paginate({
30
+ fetchPage: async (offset = 0) => {
31
+ const r = await ctx.http.get('/invoices/', {
32
+ query: { altId: LOC, altType: 'location', limit: 100, offset },
33
+ });
34
+ if (!r.ok) return { _err: r.code, invoices: [] };
35
+ return r.j;
36
+ },
37
+ getItems: (resp) => {
38
+ if (resp._err) { firstErr = resp._err; return []; }
39
+ return resp.invoices || resp.data || [];
40
+ },
41
+ nextCursor: (resp, items, offset = 0) => {
42
+ if (resp._err || items.length < 100) return null;
43
+ return offset + 100;
44
+ },
45
+ maxPages: 500,
46
+ startCursor: 0,
47
+ })) {
48
+ inv.push(item);
49
+ }
50
+
51
+ if (firstErr && inv.length === 0) {
52
+ ctx.out.warn(`can't see invoices → HTTP ${firstErr}`, { degraded: true });
53
+ return { location: LOC, scanned: 0, outstanding: 0, totalOwed: 0, currency: 'PHP', list: [] };
54
+ }
55
+
56
+ const owed = inv
57
+ .filter(i => UNPAID.has(String(i.status || '').toLowerCase()))
58
+ .map(i => {
59
+ const total = Number(i.total ?? i.amount ?? i.invoiceTotal ?? 0);
60
+ const paid = Number(i.amountPaid ?? i.totalPaid ?? 0);
61
+ const due = total - paid;
62
+ const dt = Date.parse(i.dueDate || i.issueDate || i.createdAt) || 0;
63
+ return {
64
+ name: i.contactDetails?.name || i.name || i.invoiceNumber || '(unknown)',
65
+ num: i.invoiceNumber || i._id || i.id,
66
+ due, total,
67
+ cur: (i.currency || 'PHP').toUpperCase(),
68
+ status: i.status,
69
+ age: ageDays(dt),
70
+ id: i._id || i.id,
71
+ };
72
+ })
73
+ .filter(x => x.due > 0.0001)
74
+ .sort((a, b) => (b.age || 0) - (a.age || 0));
75
+
76
+ // per-currency totals (trust-fix #3)
77
+ const byCur = {};
78
+ for (const x of owed) {
79
+ byCur[x.cur] = (byCur[x.cur] || 0) + x.due;
80
+ }
81
+ // keep backward-compat: single currency → flat totalOwed + currency; multi → byCurrency map
82
+ const currencies = Object.keys(byCur);
83
+ const totalOwed = currencies.length === 1 ? byCur[currencies[0]] : Object.values(byCur).reduce((s, v) => s + v, 0);
84
+ const currency = currencies.length === 1 ? currencies[0] : (owed[0]?.cur || 'PHP');
85
+
86
+ return {
87
+ location: LOC,
88
+ scanned: inv.length,
89
+ outstanding: owed.length,
90
+ totalOwed,
91
+ currency,
92
+ ...(currencies.length > 1 ? { byCurrency: byCur } : {}),
93
+ list: owed.slice(0, TOP),
94
+ };
95
+ }
96
+
97
+ export async function run(args, ctx) {
98
+ const data = await collect(args, ctx);
99
+ ctx.out.data(data);
100
+
101
+ const TOP = args.top ?? 20;
102
+ ctx.out.card(() => {
103
+ ctx.out.line(`\n RECEIVABLES — ${money(data.totalOwed, data.currency)} outstanding across ${data.outstanding} invoice(s) · ${data.scanned} scanned · loc ${data.location}`);
104
+ ctx.out.line(' ' + '─'.repeat(72));
105
+ if (!data.list.length) {
106
+ ctx.out.line(' Nothing outstanding. All settled. ✅\n');
107
+ return;
108
+ }
109
+ data.list.forEach((x, i) => {
110
+ const aged = x.age == null ? '—' : (x.age >= 30 ? `${x.age}d ⚠` : `${x.age}d`);
111
+ ctx.out.line(` ${String(i + 1).padStart(2)}. ${(x.name || '?').slice(0, 24).padEnd(24)} ${money(x.due, x.cur).padStart(11)} ${String(x.status).padEnd(14)} aged ${aged}`);
112
+ ctx.out.line(` invoice ${x.num} · id ${x.id}`);
113
+ });
114
+ if (data.outstanding > TOP) ctx.out.line(` … +${data.outstanding - TOP} more`);
115
+ ctx.out.line(' ' + '─'.repeat(72));
116
+ ctx.out.line(' → I can (re)send or void ONE at a time on your say-so (L2). I NEVER charge/collect — that stays you.\n');
117
+ });
118
+ return 0;
119
+ }
@@ -0,0 +1,205 @@
1
+ // commands/reconcile.mjs — Collected by source + status breakdown + flags.
2
+ // Trust-fix #1: LOC from ctx.cfg.loc.
3
+ // Trust-fix #2: transactions + subscriptions paginate to completion.
4
+ // Trust-fix #3: collected-by-source per currency (never cross-sums).
5
+ // READ-ONLY. NEVER charges, refunds, or collects.
6
+ import { paginate } from '../lib/paginate.mjs';
7
+
8
+ export const meta = {
9
+ name: 'reconcile',
10
+ summary: 'Money reconciliation — collected by source, flags, recurring',
11
+ flags: [
12
+ { name: '--days', type: 'int', default: 30, desc: 'window in days' },
13
+ { name: '--top', type: 'int', default: 20, desc: 'max source rows' },
14
+ ],
15
+ readOnly: true,
16
+ };
17
+
18
+ const SYM = { PHP: '₱', USD: '$', EUR: '€', GBP: '£' };
19
+ const m = (n, c = 'PHP') => !Number.isFinite(Number(n)) ? '—' : (SYM[c] || c + ' ') + Number(n || 0).toLocaleString('en-PH', { maximumFractionDigits: 0 });
20
+ const SUCCESS = new Set(['succeeded', 'success', 'paid', 'completed', 'captured']);
21
+ const srcOf = (t) =>
22
+ (t.paymentProviderType || t.providerType || t.source || t.chargeSnapshot?.provider || t.entitySourceType || 'unknown').toString();
23
+
24
+ export async function collect(args, ctx) {
25
+ const DAYS = args.days ?? 30;
26
+ const LOC = ctx.cfg.loc;
27
+ const NOW = ctx.now;
28
+ const START = NOW - DAYS * 24 * 60 * 60 * 1000;
29
+ // Mirror snapshot's inWindow normalization: numeric seconds (< 1e12) → ms.
30
+ // GHL currently returns ISO strings, but numeric-epoch fields are defensively handled.
31
+ const inWin = (v) => {
32
+ const t = typeof v === 'number' ? (v < 1e12 ? v * 1000 : v) : (Date.parse(v) || 0);
33
+ return t >= START && t <= NOW;
34
+ };
35
+
36
+ // transactions paginated to completion (trust-fix #2)
37
+ const txns = [];
38
+ let txnErr = null;
39
+ for await (const t of paginate({
40
+ fetchPage: async (offset = 0) => {
41
+ const r = await ctx.http.get('/payments/transactions', {
42
+ query: { altId: LOC, altType: 'location', limit: 100, offset },
43
+ });
44
+ if (!r.ok) return { _err: r.code, data: [] };
45
+ return r.j;
46
+ },
47
+ getItems: (resp) => {
48
+ if (resp._err) { txnErr = resp._err; return []; }
49
+ return resp.data || resp.transactions || [];
50
+ },
51
+ nextCursor: (resp, items, offset = 0) => {
52
+ if (resp._err || items.length < 100) return null;
53
+ return offset + 100;
54
+ },
55
+ maxPages: 500,
56
+ startCursor: 0,
57
+ })) {
58
+ txns.push(t);
59
+ }
60
+
61
+ if (txnErr && txns.length === 0) {
62
+ ctx.out.warn(`can't see transactions → HTTP ${txnErr}`, { degraded: true });
63
+ return {
64
+ location: LOC, days: DAYS, scanned: 0, inWindow: 0, collected: 0, currency: 'PHP',
65
+ bySource: {}, byStatus: {}, flags: { refunds: 0, failed: 0, orphans: 0 }, subscriptions: null,
66
+ };
67
+ }
68
+
69
+ const win = txns.filter(t => inWin(t.createdAt || t.created_at || t.dateAdded));
70
+
71
+ // per-source, per-currency (trust-fix #3 — real implementation)
72
+ const byStatus = {};
73
+ // byCur: { PHP: { bySource: { stripe: {c,v} }, total: n }, USD: { ... } }
74
+ const byCur = {};
75
+ const refunds = [], failed = [], orphans = [];
76
+
77
+ for (const t of win) {
78
+ const st = (t.status || t.paymentStatus || '').toLowerCase();
79
+ byStatus[st] = (byStatus[st] || 0) + 1;
80
+ const amt = Number(t.amount) || 0;
81
+ const cur = (t.currency || 'PHP').toUpperCase();
82
+ if (SUCCESS.has(st)) {
83
+ const s = srcOf(t);
84
+ byCur[cur] ??= { bySource: {}, total: 0 };
85
+ byCur[cur].bySource[s] ??= { c: 0, v: 0 };
86
+ byCur[cur].bySource[s].c++;
87
+ byCur[cur].bySource[s].v += amt;
88
+ byCur[cur].total += amt;
89
+ if (!(t.entityId || t.invoiceId || t.entitySourceType)) orphans.push(t);
90
+ } else if (/refund/.test(st)) {
91
+ refunds.push(t);
92
+ } else if (/fail|declin|error/.test(st)) {
93
+ failed.push(t);
94
+ }
95
+ }
96
+
97
+ // flatten for output — single currency → backward-compat flat shape; multi → byCurrency map
98
+ const currencies = Object.keys(byCur);
99
+ const isSingle = currencies.length <= 1;
100
+ const currency = isSingle ? (currencies[0] || 'PHP') : (currencies[0] || 'PHP');
101
+ const collected = isSingle ? (byCur[currency]?.total ?? 0) : null;
102
+ const byCurrency = isSingle ? null : Object.fromEntries(currencies.map(c => [c, byCur[c].total]));
103
+ // bySource: when single currency keep flat {src:{c,v}} for backward compat; multi-currency not surfaced at top level
104
+ const bySource = isSingle ? (byCur[currency]?.bySource ?? {}) : Object.fromEntries(
105
+ currencies.flatMap(c => Object.entries(byCur[c].bySource).map(([s, v]) => [`${s}(${c})`, v]))
106
+ );
107
+
108
+ // subscriptions paginated to completion (trust-fix #2)
109
+ let subs = null;
110
+ const subItems = [];
111
+ let subErr = null;
112
+ for await (const s of paginate({
113
+ fetchPage: async (offset = 0) => {
114
+ const r = await ctx.http.get('/payments/subscriptions', {
115
+ query: { altId: LOC, altType: 'location', limit: 100, offset },
116
+ });
117
+ if (!r.ok) return { _err: r.code, data: [] };
118
+ return r.j;
119
+ },
120
+ getItems: (resp) => {
121
+ if (resp._err) { subErr = resp._err; return []; }
122
+ return resp.data || resp.subscriptions || [];
123
+ },
124
+ nextCursor: (resp, items, offset = 0) => {
125
+ if (resp._err || items.length < 100) return null;
126
+ return offset + 100;
127
+ },
128
+ maxPages: 100,
129
+ startCursor: 0,
130
+ })) {
131
+ subItems.push(s);
132
+ }
133
+
134
+ if (!subErr) {
135
+ const active = subItems.filter(s => /active|trialing/i.test(s.status || ''));
136
+ // MRR per-currency — same treatment as transactions (never cross-sum currencies)
137
+ const mrrByCur = {};
138
+ for (const x of active) {
139
+ const cur = (x.currency || 'PHP').toUpperCase();
140
+ mrrByCur[cur] = (mrrByCur[cur] || 0) + (Number(x.amount) || 0);
141
+ }
142
+ const mrrCurrencies = Object.keys(mrrByCur);
143
+ const isSingleMrr = mrrCurrencies.length <= 1;
144
+ subs = {
145
+ active: active.length,
146
+ total: subItems.length,
147
+ // single-currency: flat mrr for backward compat; multi-currency: mrrByCurrency map
148
+ ...(isSingleMrr
149
+ ? { mrr: mrrByCur[mrrCurrencies[0]] ?? 0 }
150
+ : { mrrByCurrency: mrrByCur }),
151
+ };
152
+ }
153
+
154
+ return {
155
+ location: LOC,
156
+ days: DAYS,
157
+ scanned: txns.length,
158
+ inWindow: win.length,
159
+ // single-currency: flat `collected` + `currency`; multi-currency: `byCurrency` map (no cross-sum)
160
+ ...(isSingle
161
+ ? { collected, currency }
162
+ : { byCurrency }),
163
+ bySource,
164
+ byStatus,
165
+ flags: { refunds: refunds.length, failed: failed.length, orphans: orphans.length },
166
+ subscriptions: subs,
167
+ };
168
+ }
169
+
170
+ export async function run(args, ctx) {
171
+ const data = await collect(args, ctx);
172
+ ctx.out.data(data);
173
+
174
+ const isMulti = !!data.byCurrency;
175
+ const collectedLine = isMulti
176
+ ? Object.entries(data.byCurrency).map(([c, v]) => m(v, c)).join(' + ')
177
+ : m(data.collected, data.currency);
178
+ const cur = data.currency || 'PHP';
179
+
180
+ ctx.out.card(() => {
181
+ ctx.out.line(`\n RECONCILE — ${collectedLine} collected · last ${data.days}d · ${data.inWindow} txn in window · loc ${data.location}`);
182
+ ctx.out.line(' ' + '─'.repeat(64));
183
+ ctx.out.line(' BY SOURCE (succeeded)');
184
+ const srcs = Object.entries(data.bySource).sort((a, b) => b[1].v - a[1].v);
185
+ if (!srcs.length) ctx.out.line(' (none)');
186
+ else for (const [s, v] of srcs) ctx.out.line(` ${s.slice(0, 24).padEnd(24)} ${String(v.c).padStart(3)} txn ${m(v.v, isMulti ? (s.match(/\((\w+)\)$/)?.[1] || cur) : cur).padStart(12)}`);
187
+ ctx.out.line('\n BY STATUS');
188
+ for (const [s, c] of Object.entries(data.byStatus).sort((a, b) => b[1] - a[1]))
189
+ ctx.out.line(` ${s.slice(0, 24).padEnd(24)} ${String(c).padStart(3)}`);
190
+ ctx.out.line('\n FLAGS');
191
+ ctx.out.line(` refunds ${data.flags.refunds} · failed ${data.flags.failed} · orphan (no invoice/order) ${data.flags.orphans}`);
192
+ if (data.subscriptions) {
193
+ const sub = data.subscriptions;
194
+ const mrrLine = sub.mrrByCurrency
195
+ ? Object.entries(sub.mrrByCurrency).map(([c, v]) => m(v, c)).join(' + ')
196
+ : m(sub.mrr, cur);
197
+ ctx.out.line(`\n RECURRING ${sub.active} active / ${sub.total} subs · ${mrrLine} per cycle`);
198
+ } else {
199
+ ctx.out.line("\n RECURRING can't see (payments/subscriptions scope absent or none)");
200
+ }
201
+ ctx.out.line(' ' + '─'.repeat(64));
202
+ ctx.out.line(' Read-only. I reconcile + flag — I never charge, refund, or collect. That stays you.\n');
203
+ });
204
+ return 0;
205
+ }
@@ -0,0 +1,133 @@
1
+ // commands/segment.mjs — Multi-criteria contact segment finder.
2
+ // Trust-fix #1: LOC from ctx.cfg.loc.
3
+ // Trust-fix #2: contacts paginate to completion.
4
+ // READ-ONLY. Never writes a tag, never messages.
5
+ import { paginate } from '../lib/paginate.mjs';
6
+
7
+ export const meta = {
8
+ name: 'segment',
9
+ summary: 'Find contacts by criteria — tag, phone, age, etc.',
10
+ flags: [
11
+ { name: '--tag', type: 'str', desc: 'must have this tag' },
12
+ { name: '--without-tag', type: 'str', desc: 'must NOT have this tag' },
13
+ { name: '--no-tags', type: 'bool', desc: 'contacts with zero tags' },
14
+ { name: '--created-days', type: 'int', desc: 'created within N days' },
15
+ { name: '--has-phone', type: 'bool', desc: 'must have phone' },
16
+ { name: '--no-phone', type: 'bool', desc: 'must NOT have phone' },
17
+ { name: '--top', type: 'int', default: 20, desc: 'max rows to show in sample' },
18
+ ],
19
+ readOnly: true,
20
+ };
21
+
22
+ export async function collect(args, ctx) {
23
+ const TAG = args.tag?.toLowerCase() ?? null;
24
+ const WITHOUT = args['without-tag']?.toLowerCase() ?? null;
25
+ const NO_TAGS = !!args['no-tags'];
26
+ const CREATED_DAYS = args['created-days'] ?? null;
27
+ const HAS_PHONE = !!args['has-phone'];
28
+ const NO_PHONE = !!args['no-phone'];
29
+ const TOP = args.top ?? 20;
30
+ const LOC = ctx.cfg.loc;
31
+ const NOW = ctx.now;
32
+ const START = CREATED_DAYS ? NOW - Number(CREATED_DAYS) * 86400000 : null;
33
+
34
+ const crit = [
35
+ TAG && `tag=${TAG}`,
36
+ WITHOUT && `without=${WITHOUT}`,
37
+ NO_TAGS && 'no-tags',
38
+ CREATED_DAYS && `created≤${CREATED_DAYS}d`,
39
+ HAS_PHONE && 'has-phone',
40
+ NO_PHONE && 'no-phone',
41
+ ].filter(Boolean);
42
+
43
+ if (!crit.length) {
44
+ ctx.out.warn('No criteria given. Use e.g. --created-days 30 --no-tags', { degraded: false });
45
+ return null; // signal usage error
46
+ }
47
+
48
+ function matches(c) {
49
+ const tags = (c.tags || []).map(t => String(t).toLowerCase());
50
+ if (TAG && !tags.includes(TAG)) return false;
51
+ if (WITHOUT && tags.includes(WITHOUT)) return false;
52
+ if (NO_TAGS && tags.length > 0) return false;
53
+ if (START) { const t = Date.parse(c.dateAdded || c.createdAt) || 0; if (t < START) return false; }
54
+ if (HAS_PHONE && !c.phone) return false;
55
+ if (NO_PHONE && c.phone) return false;
56
+ return true;
57
+ }
58
+
59
+ let scanned = 0;
60
+ const hits = [];
61
+ let firstErr = null;
62
+
63
+ for await (const c of paginate({
64
+ fetchPage: async (cursor) => {
65
+ const q = { locationId: LOC, limit: 100 };
66
+ if (cursor) { q.startAfter = cursor.startAfter; q.startAfterId = cursor.startAfterId; }
67
+ const r = await ctx.http.get('/contacts/', { query: q });
68
+ if (!r.ok) return { _err: r.code, contacts: [] };
69
+ return r.j;
70
+ },
71
+ getItems: (resp) => {
72
+ if (resp._err) { firstErr = resp._err; return []; }
73
+ return resp.contacts || resp.data || [];
74
+ },
75
+ nextCursor: (resp, items) => {
76
+ if (resp._err || items.length < 100) return null;
77
+ const last = items[items.length - 1];
78
+ return {
79
+ startAfter: last.dateAdded ? Date.parse(last.dateAdded) : undefined,
80
+ startAfterId: last.id,
81
+ };
82
+ },
83
+ maxPages: 200,
84
+ })) {
85
+ scanned++;
86
+ if (matches(c)) hits.push(c);
87
+ }
88
+
89
+ if (firstErr && scanned === 0) {
90
+ ctx.out.warn(`can't see contacts → HTTP ${firstErr}`, { degraded: true });
91
+ return { location: LOC, criteria: crit, scanned: 0, matched: 0, contactIds: [], sample: [] };
92
+ }
93
+
94
+ return {
95
+ location: LOC,
96
+ criteria: crit,
97
+ scanned,
98
+ matched: hits.length,
99
+ contactIds: hits.map(c => c.id),
100
+ sample: hits.slice(0, TOP).map(c => ({
101
+ name: c.contactName || ((c.firstName || '') + ' ' + (c.lastName || '')).trim() || c.email,
102
+ email: c.email,
103
+ phone: c.phone || null,
104
+ tags: c.tags || [],
105
+ id: c.id,
106
+ })),
107
+ };
108
+ }
109
+
110
+ export async function run(args, ctx) {
111
+ const data = await collect(args, ctx);
112
+ if (data === null) return 2; // usage error — no criteria
113
+
114
+ ctx.out.data(data);
115
+ const TOP = args.top ?? 20;
116
+
117
+ ctx.out.card(() => {
118
+ ctx.out.line(`\n SEGMENT — ${data.matched} contact(s) match [${data.criteria.join(' AND ')}] · ${data.scanned} scanned · loc ${data.location}`);
119
+ ctx.out.line(' ' + '─'.repeat(70));
120
+ if (!data.sample.length) {
121
+ ctx.out.line(' No matches.\n');
122
+ return;
123
+ }
124
+ data.sample.forEach((c, i) => {
125
+ const name = (c.name || '(no name)').slice(0, 26);
126
+ ctx.out.line(` ${String(i + 1).padStart(3)}. ${name.padEnd(26)} ${(c.email || '—').slice(0, 28).padEnd(28)} [${(c.tags || []).join(', ').slice(0, 20)}]`);
127
+ });
128
+ if (data.matched > TOP) ctx.out.line(` … +${data.matched - TOP} more (--top to show, --json for all IDs)`);
129
+ ctx.out.line(' ' + '─'.repeat(70));
130
+ ctx.out.line(` → hand to ghl-contacts: bulk-tag these ${data.matched} (L2 — I echo the count + tag, you confirm, then apply). NEVER auto-tagged here.\n`);
131
+ });
132
+ return 0;
133
+ }