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.
- package/INSTALL.md +127 -0
- package/LICENSE +21 -0
- package/README.md +152 -0
- package/SKILL.md +31 -0
- package/bin/sizmo.mjs +3 -0
- package/commands/booked-not-paid.mjs +242 -0
- package/commands/brief.mjs +213 -0
- package/commands/focus.mjs +163 -0
- package/commands/noshow.mjs +117 -0
- package/commands/pipeline.mjs +147 -0
- package/commands/receivables.mjs +119 -0
- package/commands/reconcile.mjs +205 -0
- package/commands/segment.mjs +133 -0
- package/commands/snapshot.mjs +256 -0
- package/commands/triage.mjs +142 -0
- package/docs/how-to/booked-not-paid.md +54 -0
- package/docs/how-to/brief.md +71 -0
- package/docs/how-to/configure-a-client-profile.md +73 -0
- package/docs/how-to/focus.md +46 -0
- package/docs/how-to/multi-client.md +86 -0
- package/docs/how-to/noshow.md +45 -0
- package/docs/how-to/pipeline.md +47 -0
- package/docs/how-to/receivables.md +45 -0
- package/docs/how-to/reconcile.md +52 -0
- package/docs/how-to/segment.md +55 -0
- package/docs/how-to/snapshot.md +52 -0
- package/docs/how-to/triage.md +44 -0
- package/lib/cache.mjs +36 -0
- package/lib/cli.mjs +311 -0
- package/lib/config.mjs +39 -0
- package/lib/context.mjs +33 -0
- package/lib/errors.mjs +11 -0
- package/lib/http.mjs +52 -0
- package/lib/output.mjs +39 -0
- package/lib/paginate.mjs +13 -0
- package/lib/pool.mjs +10 -0
- package/lib/prioritize.mjs +146 -0
- package/lib/registry.mjs +13 -0
- package/lib/schema.mjs +9 -0
- package/package.json +45 -0
package/INSTALL.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Installation
|
|
2
|
+
|
|
3
|
+
> Not affiliated with, endorsed by, or supported by HighLevel.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 20 or later (`node --version` to check)
|
|
8
|
+
- A GoHighLevel Private Integration Token (PIT) with at minimum `contacts.read`, `conversations.read`, `opportunities.read`, `calendars.read`, `invoices.read`, and `payments.read` scopes
|
|
9
|
+
|
|
10
|
+
## Step 1 — clone and link
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
git clone https://github.com/csalamida07-cyber/sizmo-ghl-cli
|
|
14
|
+
cd sizmo-ghl-cli
|
|
15
|
+
bash install.sh
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`install.sh` does exactly three things (verified against the actual script):
|
|
19
|
+
|
|
20
|
+
1. Creates `~/.local/bin` if it does not exist
|
|
21
|
+
2. Symlinks `<repo>/bin/sizmo.mjs` → `~/.local/bin/sizmo`
|
|
22
|
+
3. `chmod +x`s the bin entry point
|
|
23
|
+
4. Warns if `~/.local/bin` is not in `$PATH`
|
|
24
|
+
|
|
25
|
+
If the PATH warning appears, add to your shell profile:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # zsh
|
|
29
|
+
# or
|
|
30
|
+
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc # bash
|
|
31
|
+
source ~/.zshrc # reload
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Confirm the link works:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
sizmo --version
|
|
38
|
+
# 0.4.0
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Step 2 — get a PIT from GoHighLevel
|
|
42
|
+
|
|
43
|
+
1. Open GoHighLevel → Settings → Integrations → Private Integrations
|
|
44
|
+
2. Create a new integration — give it a name like "sizmo-read"
|
|
45
|
+
3. Grant read-only scopes: `contacts.read`, `conversations.read`, `opportunities.read`, `calendars.read`, `invoices.read`, `payments.read`, `transactions.read`
|
|
46
|
+
4. Copy the token (starts with `pit-`)
|
|
47
|
+
|
|
48
|
+
Never store the PIT in a shell command or history. Always pass via stdin.
|
|
49
|
+
|
|
50
|
+
## Step 3 — configure a profile
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
echo "pit-yourtoken..." | sizmo config set --profile myclient --loc YOUR_LOCATION_ID --pit-stdin
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `--profile` — a name for this credential set (e.g. the client's name)
|
|
57
|
+
- `--loc` — your GoHighLevel Location ID (found in Settings > Business Profile)
|
|
58
|
+
- `--pit-stdin` — reads the PIT from stdin; never from argv
|
|
59
|
+
|
|
60
|
+
The profile is saved to `~/.config/sizmo/profiles.json` with permissions `0600`.
|
|
61
|
+
|
|
62
|
+
## Step 4 — verify auth
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
sizmo auth status
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Expected output:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
auth source profile
|
|
72
|
+
location your-location-id
|
|
73
|
+
PIT pit-…XXXX (myclient)
|
|
74
|
+
PIT age day N of 90
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then do a live probe:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
sizmo auth check
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This makes one real API call (`GET /contacts/?limit=1`) to confirm the PIT is accepted for your location. You need a network connection and a valid PIT for this to pass.
|
|
84
|
+
|
|
85
|
+
## Multi-client setup
|
|
86
|
+
|
|
87
|
+
Add a second profile for each additional GoHighLevel location:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
echo "pit-secondtoken..." | sizmo config set --profile client2 --loc LOC_B --pit-stdin
|
|
91
|
+
sizmo config list # see all profiles; * marks the default
|
|
92
|
+
sizmo config use client2 # switch default
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Pass `--profile <name>` to any command to target a specific client without switching the default.
|
|
96
|
+
|
|
97
|
+
## Credential storage
|
|
98
|
+
|
|
99
|
+
Profiles are stored in `~/.config/sizmo/profiles.json`. The file is created with `chmod 0600` — readable only by your user. PITs are stored in plaintext in that file; protect your home directory accordingly.
|
|
100
|
+
|
|
101
|
+
To remove a profile:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
sizmo config rm myclient
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## PIT rotation
|
|
108
|
+
|
|
109
|
+
PITs expire after 90 days. `sizmo auth status` shows the age — warnings appear at day 80, the expired-zone at day 90.
|
|
110
|
+
|
|
111
|
+
To rotate:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
echo "pit-newtoken..." | sizmo config set --profile myclient --pit-stdin --created $(date +%Y-%m-%d)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`--created` resets the age counter. If omitted, the current date is used automatically when setting a PIT.
|
|
118
|
+
|
|
119
|
+
## Using environment variables instead of profiles
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
export GHL_PIT=pit-yourtoken...
|
|
123
|
+
export GHL_LOCATION_ID=your-location-id
|
|
124
|
+
sizmo brief
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Environment variables take precedence over saved profiles.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sizmo / CJ Salamida
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# sizmo
|
|
2
|
+
|
|
3
|
+
**Unofficial read-only GoHighLevel CLI for coaches and consultants.**
|
|
4
|
+
|
|
5
|
+
> Not affiliated with, endorsed by, or supported by HighLevel. This is an independent open-source tool.
|
|
6
|
+
|
|
7
|
+
`sizmo` reads your GoHighLevel location — leads, bookings, pipeline, A/R, money leaks — from the terminal. It never writes, never charges, never sends. Every outward action stays human-triggered.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Requires Node.js 20+.
|
|
12
|
+
|
|
13
|
+
**Option A — clone + install (puts `sizmo` on your PATH):**
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
git clone https://github.com/csalamida07-cyber/sizmo-ghl-cli
|
|
17
|
+
cd sizmo-ghl-cli
|
|
18
|
+
bash install.sh
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`install.sh` symlinks `bin/sizmo.mjs` into `~/.local/bin/sizmo`. Add `~/.local/bin` to `$PATH` if not already present (the script will warn you if needed).
|
|
22
|
+
|
|
23
|
+
**Option B — run with no install, straight from GitHub:**
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx github:csalamida07-cyber/sizmo-ghl-cli brief
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Option C — clone + run directly:**
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
git clone https://github.com/csalamida07-cyber/sizmo-ghl-cli && cd sizmo-ghl-cli
|
|
33
|
+
node bin/sizmo.mjs brief
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> `npx sizmo` (npm install) is coming once the package is published to npm.
|
|
37
|
+
|
|
38
|
+
Then configure a profile:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
echo "pit-yourtoken..." | sizmo config set --profile myclient --loc YOUR_LOCATION_ID --pit-stdin
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
PIT = Private Integration Token. Find it under GoHighLevel Settings > Integrations > Private Integrations. Never pass it as a command-line argument — always pipe it via stdin.
|
|
45
|
+
|
|
46
|
+
Verify auth:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
sizmo auth status
|
|
50
|
+
sizmo auth check
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
Command list generated from `sizmo schema` (authoritative — pulled directly from the code):
|
|
56
|
+
|
|
57
|
+
| Command | Summary | Key flags |
|
|
58
|
+
|---------|---------|-----------|
|
|
59
|
+
| `sizmo brief` | Morning brief — numbers + NEEDS YOU TODAY | `--days N` (default 7) |
|
|
60
|
+
| `sizmo snapshot` | Monday card — 6 metrics, one screen | `--days N` (default 7) |
|
|
61
|
+
| `sizmo triage` | Who is waiting on a reply, longest first | `--top N` (default 10), `--days N` (default 30) |
|
|
62
|
+
| `sizmo pipeline` | Pipeline health — value by stage + stuck deal sweep | `--stuck-days N` (default 7), `--top N` (default 100) |
|
|
63
|
+
| `sizmo noshow` | No-show recovery — who to re-book | `--days N` (default 30), `--top N` (default 15) |
|
|
64
|
+
| `sizmo receivables` | A/R — who owes, how much, how old | `--top N` (default 20) |
|
|
65
|
+
| `sizmo reconcile` | Money reconciliation — collected by source, flags, recurring | `--days N` (default 30), `--top N` (default 20) |
|
|
66
|
+
| `sizmo booked-not-paid` | Sessions with no invoice or payment — the money leak | `--days N` (default 30), `--top N` (default 15) |
|
|
67
|
+
| `sizmo focus` | One ranked to-do queue by money at stake | `--top N` (default 15), `--stuck-days N` (default 7) |
|
|
68
|
+
| `sizmo segment` | Find contacts by criteria — tag, phone, age, etc. | `--tag X`, `--without-tag X`, `--no-tags`, `--created-days N`, `--has-phone`, `--no-phone`, `--top N` (default 20) |
|
|
69
|
+
|
|
70
|
+
### Utility commands
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
sizmo schema # machine-readable command tree (JSON)
|
|
74
|
+
sizmo auth status # show credential source, location, masked PIT, rotation age
|
|
75
|
+
sizmo auth check # probe live API to verify PIT scopes
|
|
76
|
+
sizmo config list # list all saved profiles
|
|
77
|
+
sizmo config use <name> # switch default profile
|
|
78
|
+
sizmo config set --profile <name> --loc <id> --pit-stdin
|
|
79
|
+
sizmo config rm <name> # remove a profile
|
|
80
|
+
sizmo api /path # raw GET escape hatch (--paginate --max-pages N)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Global flags (work with every command)
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
--profile <name> use a named credential profile
|
|
87
|
+
--json machine-readable output (stable JSON envelope)
|
|
88
|
+
--fresh bypass 60-second read cache — re-fetches live data
|
|
89
|
+
--no-cache alias for --fresh
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## JSON envelope
|
|
93
|
+
|
|
94
|
+
Every command supports `--json`. The envelope shape is stable:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"schemaVersion": 1,
|
|
99
|
+
"command": "brief",
|
|
100
|
+
"location": "LOC_ID",
|
|
101
|
+
"data": { ... },
|
|
102
|
+
"degraded": false,
|
|
103
|
+
"warnings": [],
|
|
104
|
+
"cacheAgeMs": 0
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`degraded: true` means at least one data source was blocked (scope or auth). Read `warnings`. A blocked source is not zero — treat it as unknown.
|
|
109
|
+
|
|
110
|
+
## Read-only + safety promise
|
|
111
|
+
|
|
112
|
+
- **Never writes to GoHighLevel.** No contacts created, no messages sent, no invoices issued, no payments charged.
|
|
113
|
+
- **Money is always human-triggered.** The CLI reads; humans approve every action that has a dollar attached.
|
|
114
|
+
- **PIT never in argv.** Credentials are passed via stdin (`--pit-stdin`) or env var (`--pit-env VAR`). Never logged, never echoed raw.
|
|
115
|
+
- **60-second read cache.** Repeated calls within 60s return cached data. `cacheAgeMs` in the envelope tells you how old. Use `--fresh` to bypass.
|
|
116
|
+
|
|
117
|
+
## Honest limitations
|
|
118
|
+
|
|
119
|
+
- **Rate-limit cap: 5 concurrent requests.** The pool is capped at 5 to avoid hammering the GHL API.
|
|
120
|
+
- **Cache TTL: 60 seconds.** Stale data possible within that window. Use `--fresh` when you need live.
|
|
121
|
+
- **No-show / booked-not-paid calendar truncation.** GHL's `/calendars/events` endpoint has no pagination cursor. If a calendar returns >= 100 events the result may be silently truncated. A `degraded: true` warning is emitted in that case.
|
|
122
|
+
- **Pipeline currency.** GHL opportunity monetary values carry no currency field — they inherit pipeline config. The CLI renders them as-is; cross-currency totals are never summed.
|
|
123
|
+
- **No workflow writes.** This tool has no workflow-authoring capability. Workflow creation stays in GoHighLevel's UI.
|
|
124
|
+
|
|
125
|
+
## Exit codes
|
|
126
|
+
|
|
127
|
+
| Code | Meaning |
|
|
128
|
+
|------|---------|
|
|
129
|
+
| 0 | OK |
|
|
130
|
+
| 1 | API error |
|
|
131
|
+
| 2 | Usage error (bad flag / unknown command) |
|
|
132
|
+
| 3 | Auth error / no location resolved |
|
|
133
|
+
| 4 | Not found |
|
|
134
|
+
|
|
135
|
+
## Multi-client
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
sizmo config set --profile clientA --loc LOC_A --pit-stdin
|
|
139
|
+
sizmo config set --profile clientB --loc LOC_B --pit-stdin
|
|
140
|
+
sizmo brief --profile clientA
|
|
141
|
+
sizmo brief --profile clientB
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
See `docs/how-to/multi-client.md` for full workflow.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT. See LICENSE.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
Built by Sizmo — productized GHL systems for coaches & consultants. Unofficial; not affiliated with HighLevel.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sizmo
|
|
3
|
+
description: Use to read a GoHighLevel location's state — leads, bookings, who's waiting, pipeline, A/R, money-leaks — via the read-only CLI. Never writes; money is always human-triggered.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Driving the GoHighLevel Read-Only CLI
|
|
7
|
+
|
|
8
|
+
Read-only GoHighLevel ops. Every command takes `--json` (stable envelope: `{schemaVersion,command,location,data,degraded,warnings}`) and `--profile <name>` for multi-client. Run `sizmo schema` for the machine-readable command tree before composing.
|
|
9
|
+
|
|
10
|
+
## Recipes (the jobs)
|
|
11
|
+
- `sizmo brief` — the morning screen: numbers + prioritized "needs you today". Start here.
|
|
12
|
+
- `sizmo snapshot [days]` — 6-metric card (leads/bookings/show-rate/collected/reply-rate/pipeline).
|
|
13
|
+
- `sizmo triage --top N` — who's waiting on a reply, longest first.
|
|
14
|
+
- `sizmo pipeline --stuck-days N` — value by stage + stuck-deal sweep.
|
|
15
|
+
- `sizmo noshow --days N` — no-shows to re-book.
|
|
16
|
+
- `sizmo receivables` — who owes, how old, how much.
|
|
17
|
+
- `sizmo reconcile --days N` — money collected by source + flags.
|
|
18
|
+
- `sizmo booked-not-paid --days N` — sessions with no invoice/payment (the money leak).
|
|
19
|
+
- `sizmo segment --tag X --no-phone` — find contacts by criteria.
|
|
20
|
+
|
|
21
|
+
## Auth
|
|
22
|
+
`sizmo config set --profile <client> --loc <id> --pit-stdin` (paste PIT to stdin — never argv). `sizmo auth status` shows source + PIT age (rotate at 90d). `sizmo auth check` probes scopes.
|
|
23
|
+
|
|
24
|
+
## Gotchas
|
|
25
|
+
- READ-ONLY. The CLI never writes to GoHighLevel. To act (send, invoice, tag), the specialist agents draft; the human approves; money is always human-triggered.
|
|
26
|
+
- `degraded:true` in the envelope ≠ zero — a source was blocked (scope/auth). Read `warnings`. Never treat a blocked read as "0".
|
|
27
|
+
- Exit codes: 0 ok · 1 API · 2 usage · 3 auth/no-location · 4 not found. Branch on these.
|
|
28
|
+
- No location resolved → exit 3. Pass `--profile` or set `GHL_LOCATION_ID`; there is no default location.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
Built by Sizmo — productized GHL systems for coaches & consultants. Unofficial; not affiliated with HighLevel.
|
package/bin/sizmo.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// commands/booked-not-paid.mjs — Money-leak detector: sessions × invoices × payments.
|
|
2
|
+
// Trust-fix #1: LOC from ctx.cfg.loc.
|
|
3
|
+
// Trust-fix #2 (critical): transactions paginate to completion — fixes false-accusation bug
|
|
4
|
+
// where single-page limit:100 missed paid contacts → now exhausts all pages.
|
|
5
|
+
// READ-ONLY. Never messages, invoices, or charges.
|
|
6
|
+
import { paginate } from '../lib/paginate.mjs';
|
|
7
|
+
|
|
8
|
+
export const meta = {
|
|
9
|
+
name: 'booked-not-paid',
|
|
10
|
+
summary: 'Sessions with no invoice or payment — the money leak',
|
|
11
|
+
flags: [
|
|
12
|
+
{ name: '--days', type: 'int', default: 30, desc: 'session lookback window' },
|
|
13
|
+
{ name: '--top', type: 'int', default: 15, desc: 'max rows to show per bucket' },
|
|
14
|
+
],
|
|
15
|
+
readOnly: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const SYM = { PHP: '₱', USD: '$', EUR: '€', GBP: '£' };
|
|
19
|
+
const money = (n, c = 'PHP') => (SYM[c] || c + ' ') + Number(n || 0).toLocaleString('en-PH', { maximumFractionDigits: 0 });
|
|
20
|
+
const UNPAID = new Set(['sent', 'overdue', 'partially_paid', 'partially paid', 'payment_processing', 'viewed', 'due']);
|
|
21
|
+
// Must match reconcile.mjs SUCCESS set exactly — any status in this set means the contact paid.
|
|
22
|
+
const SUCCESS = new Set(['succeeded', 'success', 'paid', 'completed', 'captured']);
|
|
23
|
+
|
|
24
|
+
// I-2 truncation cap: GHL's /calendars/events has no pagination cursor.
|
|
25
|
+
// If a calendar returns >= CAP events it is likely truncated (silently under-reports).
|
|
26
|
+
// Full fix = date-window splitting; tracked as follow-up. Cheap mitigation: warn + degrade.
|
|
27
|
+
const EVENTS_CAP = 100;
|
|
28
|
+
|
|
29
|
+
export async function collect(args, ctx) {
|
|
30
|
+
const DAYS = args.days ?? 30;
|
|
31
|
+
const TOP = args.top ?? 15;
|
|
32
|
+
const LOC = ctx.cfg.loc;
|
|
33
|
+
const NOW = ctx.now;
|
|
34
|
+
const START = NOW - DAYS * 86400000;
|
|
35
|
+
const PAY_LOOKBACK = START - 60 * 86400000;
|
|
36
|
+
|
|
37
|
+
// ── 1. CALENDARS: who had a session in the window ──
|
|
38
|
+
const cr = await ctx.http.get('/calendars/', { query: { locationId: LOC }, version: '2021-04-15' });
|
|
39
|
+
if (!cr.ok) {
|
|
40
|
+
ctx.out.warn(`can't see calendars → HTTP ${cr.code}`, { degraded: true });
|
|
41
|
+
return { location: LOC, days: DAYS, calendars: 0, contactsWithSessions: 0, neverBilled: [], billedUnpaid: [], billedUnpaidTotal: 0, currency: 'PHP', settled: 0, caveat: 'calendars blocked' };
|
|
42
|
+
}
|
|
43
|
+
const cals = cr.j.calendars || [];
|
|
44
|
+
const byContact = new Map();
|
|
45
|
+
let skippedCalendars = 0;
|
|
46
|
+
for (const cal of cals) {
|
|
47
|
+
const ev = await ctx.http.get('/calendars/events', {
|
|
48
|
+
query: { locationId: LOC, calendarId: cal.id, startTime: String(START), endTime: String(NOW) },
|
|
49
|
+
version: '2021-04-15',
|
|
50
|
+
});
|
|
51
|
+
if (!ev.ok) {
|
|
52
|
+
skippedCalendars++;
|
|
53
|
+
ctx.out.warn(`calendar "${cal.name || cal.id}" events unreadable (HTTP ${ev.code})`, { degraded: true });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const evList = ev.j.events || ev.j.appointments || [];
|
|
57
|
+
// I-2: truncation mitigation — no cursor available; warn if at cap
|
|
58
|
+
if (evList.length >= EVENTS_CAP) {
|
|
59
|
+
ctx.out.warn(`calendar "${cal.name || cal.id}" returned ${evList.length} events — may be truncated (no pagination cursor available); counts for this calendar may under-report`, { degraded: true });
|
|
60
|
+
}
|
|
61
|
+
for (const e of evList) {
|
|
62
|
+
const s = (e.appointmentStatus || e.status || '').toLowerCase();
|
|
63
|
+
if (['noshow', 'no-show', 'no_show', 'cancelled', 'canceled', 'invalid'].includes(s)) continue;
|
|
64
|
+
const t = Date.parse(e.startTime || e.startTimeISO || e.appointmentStartTime) || 0;
|
|
65
|
+
if (t > NOW) continue;
|
|
66
|
+
if (!e.contactId) continue;
|
|
67
|
+
const rec = byContact.get(e.contactId) ?? { name: e.title || e.contactName || '(unknown)', sessions: [] };
|
|
68
|
+
rec.sessions.push({ when: t, status: s === 'showed' ? 'showed' : 'unmarked', cal: cal.name });
|
|
69
|
+
byContact.set(e.contactId, rec);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!byContact.size) {
|
|
74
|
+
return {
|
|
75
|
+
location: LOC, days: DAYS, calendars: cals.length,
|
|
76
|
+
...(skippedCalendars > 0 && { skippedCalendars }),
|
|
77
|
+
contactsWithSessions: 0, invoicesScanned: 0, neverBilled: [], billedUnpaid: [],
|
|
78
|
+
billedUnpaidTotal: 0, currency: 'PHP', settled: 0,
|
|
79
|
+
caveat: 'contact-level matching; payments lookback window+60d; prepaid clients older than that may flag falsely',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── 2. INVOICES: who got billed ──
|
|
84
|
+
const inv = [];
|
|
85
|
+
let invBlocked = null;
|
|
86
|
+
for await (const item of paginate({
|
|
87
|
+
fetchPage: async (offset = 0) => {
|
|
88
|
+
const r = await ctx.http.get('/invoices/', {
|
|
89
|
+
query: { altId: LOC, altType: 'location', limit: 100, offset },
|
|
90
|
+
});
|
|
91
|
+
if (!r.ok) return { _err: r.code, invoices: [] };
|
|
92
|
+
return r.j;
|
|
93
|
+
},
|
|
94
|
+
getItems: (resp) => {
|
|
95
|
+
if (resp._err) { invBlocked = `HTTP ${resp._err}`; return []; }
|
|
96
|
+
return resp.invoices || resp.data || [];
|
|
97
|
+
},
|
|
98
|
+
nextCursor: (resp, items, offset = 0) => {
|
|
99
|
+
if (resp._err || items.length < 100) return null;
|
|
100
|
+
return offset + 100;
|
|
101
|
+
},
|
|
102
|
+
maxPages: 500,
|
|
103
|
+
startCursor: 0,
|
|
104
|
+
})) {
|
|
105
|
+
inv.push(item);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const billing = new Map();
|
|
109
|
+
for (const i of inv) {
|
|
110
|
+
const cid = i.contactDetails?.id || i.contactDetails?._id || i.contactId;
|
|
111
|
+
if (!cid) continue;
|
|
112
|
+
const st = String(i.status || '').toLowerCase();
|
|
113
|
+
if (st === 'draft' || st === 'void' || st === 'cancelled' || st === 'canceled') continue;
|
|
114
|
+
const b = billing.get(cid) ?? { billed: false, due: 0, cur: (i.currency || 'PHP').toUpperCase() };
|
|
115
|
+
b.billed = true;
|
|
116
|
+
if (UNPAID.has(st)) {
|
|
117
|
+
const due = Number(i.total ?? i.amount ?? 0) - Number(i.amountPaid ?? i.totalPaid ?? 0);
|
|
118
|
+
if (due > 0.0001) b.due += due;
|
|
119
|
+
}
|
|
120
|
+
billing.set(cid, b);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── 3. PAYMENTS: paginate to completion (trust-fix #2 — the false-accusation fix) ──
|
|
124
|
+
const paidContacts = new Set();
|
|
125
|
+
let payBlocked = null;
|
|
126
|
+
for await (const t of paginate({
|
|
127
|
+
fetchPage: async (offset = 0) => {
|
|
128
|
+
const r = await ctx.http.get('/payments/transactions', {
|
|
129
|
+
query: {
|
|
130
|
+
altId: LOC, altType: 'location', limit: 100, offset,
|
|
131
|
+
startAt: new Date(PAY_LOOKBACK).toISOString().slice(0, 10),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
if (!r.ok) return { _err: r.code, data: [] };
|
|
135
|
+
return r.j;
|
|
136
|
+
},
|
|
137
|
+
getItems: (resp) => {
|
|
138
|
+
if (resp._err) { payBlocked = `HTTP ${resp._err}`; return []; }
|
|
139
|
+
return resp.data || resp.transactions || [];
|
|
140
|
+
},
|
|
141
|
+
nextCursor: (resp, items, offset = 0) => {
|
|
142
|
+
if (resp._err || items.length < 100) return null;
|
|
143
|
+
return offset + 100;
|
|
144
|
+
},
|
|
145
|
+
maxPages: 500,
|
|
146
|
+
startCursor: 0,
|
|
147
|
+
})) {
|
|
148
|
+
if (!SUCCESS.has(String(t.status || '').toLowerCase())) continue;
|
|
149
|
+
const cid = t.contactId || t.contactDetails?.id;
|
|
150
|
+
if (cid) paidContacts.add(cid);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Emit machine-readable degraded warnings for blocked sources (visible in --json, not just TTY).
|
|
154
|
+
// invBlocked: can't tell billed from not → neverBilled bucket unreliable.
|
|
155
|
+
// payBlocked: can't see outside-invoice payments → neverBilled bucket unreliable (may over-accuse).
|
|
156
|
+
if (invBlocked) ctx.out.warn(`can't see invoices (${invBlocked}) — NEVER-BILLED bucket suppressed, can't tell billed from not`, { degraded: true });
|
|
157
|
+
if (payBlocked) ctx.out.warn(`can't see payments (${payBlocked}) — outside-invoice payments invisible; NEVER-BILLED bucket suppressed to avoid false accusations`, { degraded: true });
|
|
158
|
+
|
|
159
|
+
// ── 4. Cross-check ──
|
|
160
|
+
const neverBilled = [], billedUnpaid = [];
|
|
161
|
+
let settled = 0;
|
|
162
|
+
for (const [cid, rec] of byContact) {
|
|
163
|
+
const b = billing.get(cid);
|
|
164
|
+
const paidAnyRoute = paidContacts.has(cid);
|
|
165
|
+
const last = Math.max(...rec.sessions.map(s => s.when));
|
|
166
|
+
const row = {
|
|
167
|
+
name: rec.name, contactId: cid, sessions: rec.sessions.length,
|
|
168
|
+
lastSession: new Date(last).toISOString(), lastSessionTs: last,
|
|
169
|
+
attended: rec.sessions.some(s => s.status === 'showed') ? 'showed' : 'unmarked',
|
|
170
|
+
};
|
|
171
|
+
if (b?.due > 0.0001) billedUnpaid.push({ ...row, due: b.due, cur: b.cur });
|
|
172
|
+
// neverBilled suppressed when invBlocked (can't see invoices) OR payBlocked (can't see all payments)
|
|
173
|
+
else if (!b?.billed && !paidAnyRoute && !invBlocked && !payBlocked) neverBilled.push(row);
|
|
174
|
+
else settled++;
|
|
175
|
+
}
|
|
176
|
+
neverBilled.sort((a, b) => b.lastSessionTs - a.lastSessionTs);
|
|
177
|
+
billedUnpaid.sort((a, b) => b.due - a.due);
|
|
178
|
+
const dueSum = billedUnpaid.reduce((s, x) => s + x.due, 0);
|
|
179
|
+
const cur = billedUnpaid[0]?.cur || 'PHP';
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
location: LOC,
|
|
183
|
+
days: DAYS,
|
|
184
|
+
calendars: cals.length,
|
|
185
|
+
...(skippedCalendars > 0 && { skippedCalendars }),
|
|
186
|
+
contactsWithSessions: byContact.size,
|
|
187
|
+
invoicesScanned: inv.length,
|
|
188
|
+
...(invBlocked && { invoicesBlocked: invBlocked }),
|
|
189
|
+
...(payBlocked && { paymentsBlocked: payBlocked }),
|
|
190
|
+
neverBilled: neverBilled.slice(0, TOP),
|
|
191
|
+
billedUnpaid: billedUnpaid.slice(0, TOP),
|
|
192
|
+
billedUnpaidTotal: dueSum,
|
|
193
|
+
currency: cur,
|
|
194
|
+
settled,
|
|
195
|
+
caveat: 'contact-level matching; payments lookback window+60d; prepaid clients older than that may flag falsely',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function run(args, ctx) {
|
|
200
|
+
const data = await collect(args, ctx);
|
|
201
|
+
ctx.out.data(data);
|
|
202
|
+
|
|
203
|
+
const DAYS = args.days ?? 30;
|
|
204
|
+
const TOP = args.top ?? 15;
|
|
205
|
+
const cur = data.currency;
|
|
206
|
+
const fmt = (t) =>
|
|
207
|
+
new Date(t).toLocaleString('en-US', { timeZone: 'Asia/Manila', month: 'short', day: 'numeric' });
|
|
208
|
+
|
|
209
|
+
ctx.out.card(() => {
|
|
210
|
+
ctx.out.line(`\n BOOKED-NOT-PAID — last ${DAYS}d · ${data.contactsWithSessions} contact(s) with sessions · loc ${data.location}`);
|
|
211
|
+
ctx.out.line(' ' + '─'.repeat(72));
|
|
212
|
+
if (data.invoicesBlocked) ctx.out.line(` ⚠ can't see invoices (${data.invoicesBlocked}) — NEVER-BILLED bucket suppressed, can't tell billed from not`);
|
|
213
|
+
if (data.paymentsBlocked) ctx.out.line(` ⚠ can't see payments (${data.paymentsBlocked}) — outside-invoice payments invisible, may over-flag`);
|
|
214
|
+
if (!data.neverBilled?.length && !data.billedUnpaid?.length) {
|
|
215
|
+
ctx.out.line(` No leaks — every session contact is billed or settled (${data.settled} clean). ✅\n`);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (data.neverBilled?.length) {
|
|
219
|
+
ctx.out.line(` NEVER BILLED — ${data.neverBilled.length} contact(s) had sessions, zero invoice, zero payment on file:`);
|
|
220
|
+
data.neverBilled.slice(0, TOP).forEach((x, i) => {
|
|
221
|
+
ctx.out.line(` ${String(i + 1).padStart(2)}. ${(x.name || '?').slice(0, 26).padEnd(26)} ${String(x.sessions) + ' session(s)'} · last ${fmt(x.lastSessionTs)} · ${x.attended}`);
|
|
222
|
+
ctx.out.line(` contact ${x.contactId}`);
|
|
223
|
+
});
|
|
224
|
+
if (data.neverBilled.length > TOP) ctx.out.line(` … +${data.neverBilled.length - TOP} more`);
|
|
225
|
+
ctx.out.line('');
|
|
226
|
+
}
|
|
227
|
+
if (data.billedUnpaid?.length) {
|
|
228
|
+
ctx.out.line(` BILLED, UNPAID — ${money(data.billedUnpaidTotal, cur)} due from ${data.billedUnpaid.length} session contact(s):`);
|
|
229
|
+
data.billedUnpaid.slice(0, TOP).forEach((x, i) => {
|
|
230
|
+
ctx.out.line(` ${String(i + 1).padStart(2)}. ${(x.name || '?').slice(0, 26).padEnd(26)} ${money(x.due, x.cur).padStart(11)} · ${x.sessions} session(s) · last ${fmt(x.lastSessionTs)}`);
|
|
231
|
+
ctx.out.line(` contact ${x.contactId}`);
|
|
232
|
+
});
|
|
233
|
+
if (data.billedUnpaid.length > TOP) ctx.out.line(` … +${data.billedUnpaid.length - TOP} more`);
|
|
234
|
+
ctx.out.line('');
|
|
235
|
+
}
|
|
236
|
+
ctx.out.line(' ' + '─'.repeat(72));
|
|
237
|
+
ctx.out.line(` ${data.settled} contact(s) billed/settled — skipped. Matching is contact-level (v1); prepaid >${DAYS + 60}d ago may flag.`);
|
|
238
|
+
ctx.out.line(' → never-billed: ghl-invoices drafts the invoice · unpaid: ghl-conversations drafts the nudge.');
|
|
239
|
+
ctx.out.line(' You approve every invoice and every message. Money stays you, always.\n');
|
|
240
|
+
});
|
|
241
|
+
return 0;
|
|
242
|
+
}
|