hookrecon 0.9.2__tar.gz
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.
- hookrecon-0.9.2/.github/workflows/ci.yml +36 -0
- hookrecon-0.9.2/.github/workflows/release.yml +24 -0
- hookrecon-0.9.2/.gitignore +6 -0
- hookrecon-0.9.2/LICENSE +26 -0
- hookrecon-0.9.2/PKG-INFO +339 -0
- hookrecon-0.9.2/PLAN.md +191 -0
- hookrecon-0.9.2/README.md +326 -0
- hookrecon-0.9.2/pyproject.toml +25 -0
- hookrecon-0.9.2/src/hookrecon/__init__.py +1 -0
- hookrecon-0.9.2/src/hookrecon/cli.py +385 -0
- hookrecon-0.9.2/src/hookrecon/config.py +214 -0
- hookrecon-0.9.2/src/hookrecon/db.py +151 -0
- hookrecon-0.9.2/src/hookrecon/drift.py +125 -0
- hookrecon-0.9.2/src/hookrecon/report.py +166 -0
- hookrecon-0.9.2/src/hookrecon/stripe_client.py +80 -0
- hookrecon-0.9.2/tests/test_cli_init.py +65 -0
- hookrecon-0.9.2/tests/test_config.py +51 -0
- hookrecon-0.9.2/tests/test_db.py +159 -0
- hookrecon-0.9.2/tests/test_drift.py +131 -0
- hookrecon-0.9.2/tests/test_report.py +186 -0
- hookrecon-0.9.2/tests/test_sql.py +51 -0
- hookrecon-0.9.2/tests/test_stripe_client.py +102 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
strategy:
|
|
11
|
+
fail-fast: false
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.10", "3.12", "3.13", "3.14"]
|
|
14
|
+
os: [ubuntu-latest, windows-latest]
|
|
15
|
+
runs-on: ${{ matrix.os }}
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python }}
|
|
21
|
+
- run: python -m pip install -e .
|
|
22
|
+
- run: python -m unittest discover tests -v
|
|
23
|
+
- name: Exit-code smoke (no Stripe/DB needed)
|
|
24
|
+
shell: bash
|
|
25
|
+
# GitHub's bash runs with -e -o pipefail: a bare `cmd; rc=$?` dies on the
|
|
26
|
+
# expected non-zero exit, so every probe must use the `|| rc=$?` pattern.
|
|
27
|
+
run: |
|
|
28
|
+
cd "$(mktemp -d)"
|
|
29
|
+
rc=0
|
|
30
|
+
hookrecon check >/dev/null 2>&1 || rc=$?
|
|
31
|
+
[ "$rc" -eq 2 ] || { echo "missing config: expected exit 2, got $rc"; exit 1; }
|
|
32
|
+
echo '{not json' > hookrecon.config.json
|
|
33
|
+
rc=0
|
|
34
|
+
hookrecon check >/dev/null 2>&1 || rc=$?
|
|
35
|
+
[ "$rc" -eq 2 ] || { echo "malformed config: expected exit 2, got $rc"; exit 1; }
|
|
36
|
+
echo "exit-code smoke ok"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
# One-time setup required before the first tag: on PyPI, open hookrecon ->
|
|
10
|
+
# Publishing -> add this GitHub repo as a "trusted publisher"
|
|
11
|
+
# (owner/repo: this repo, workflow: release.yml, environment: pypi).
|
|
12
|
+
# Trusted publishing uses OIDC — no API token stored in GitHub.
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
permissions:
|
|
16
|
+
id-token: write # PyPI trusted publishing (OIDC)
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
- run: python -m pip install build
|
|
23
|
+
- run: python -m build
|
|
24
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
hookrecon-0.9.2/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hookrecon contributors
|
|
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Note: hookrecon's Postgres dependency, psycopg, is LGPL-3.0-only. It is used
|
|
26
|
+
here as an unmodified pip-installed dependency; hookrecon itself is MIT.
|
hookrecon-0.9.2/PKG-INFO
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hookrecon
|
|
3
|
+
Version: 0.9.2
|
|
4
|
+
Summary: Find Stripe payments that never reached your database — read-only Stripe↔Postgres reconciliation CLI
|
|
5
|
+
Project-URL: Repository, https://github.com/Vasram/hookrecon
|
|
6
|
+
Project-URL: Issues, https://github.com/Vasram/hookrecon/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: psycopg[binary]<4,>=3.3
|
|
11
|
+
Requires-Dist: stripe<17,>=16
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# hookrecon — find Stripe payments that never reached your database
|
|
15
|
+
|
|
16
|
+
<!-- LAUNCH TODO — demo GIF (see instructions below, then delete this comment):
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="docs/hookrecon.gif"
|
|
19
|
+
alt="hookrecon finding Stripe payments that never reached the database — webhook failed, order missing"
|
|
20
|
+
width="720">
|
|
21
|
+
</p>
|
|
22
|
+
-->
|
|
23
|
+
|
|
24
|
+
[](https://github.com/Vasram/hookrecon/actions/workflows/ci.yml)
|
|
25
|
+
[](https://www.python.org/)
|
|
26
|
+
[](LICENSE)
|
|
27
|
+
|
|
28
|
+
<!-- LAUNCH TODO: pehli PyPI publish ke baad (tag v0.9.2 push → release.yml auto-publish) is
|
|
29
|
+
comment ko hata do — tab tak shields.io "package or version not found" (red) dikhata hai:
|
|
30
|
+
[](https://pypi.org/project/hookrecon/)
|
|
31
|
+
-->
|
|
32
|
+
|
|
33
|
+
**hookrecon is a read-only CLI that reconciles Stripe against your Postgres database.** It lists every completed payment whose webhook never made it into your app — with the amount, the customer's object id, and a deep link to the event in your Stripe dashboard. Zero install, zero signup, zero telemetry.
|
|
34
|
+
|
|
35
|
+
> **The problem:** your customer pays, Stripe shows the payment as **succeeded** — but the order, subscription, or payment row never appears in your database. The webhook failed silently. It happens more often than anyone admits: a CDN or WAF answered Stripe with `200 OK` before the request ever reached your app, a deploy broke your handler, or your handler threw an error *after* acknowledging the event. [Stripe retries failed webhooks for up to 3 days](https://docs.stripe.com/webhooks/process-undelivered-events), then gives up forever. Nobody notices until a customer emails support.
|
|
36
|
+
|
|
37
|
+
**If that's you right now** — payment succeeded but no order created, Stripe webhook not updating your database, webhooks silently failing in production, Stripe events missing after a deploy — **hookrecon finds every affected payment in one command**, along with the money involved.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Why Stripe webhooks fail silently
|
|
42
|
+
|
|
43
|
+
Your webhook handler *looks* healthy in the dashboard because most failures happen **after** something returns `200 OK`:
|
|
44
|
+
|
|
45
|
+
| Failure mode | What actually happens |
|
|
46
|
+
|---|---|
|
|
47
|
+
| **CDN / WAF / proxy acks early** | Cloudflare or your load balancer returns `200 OK` to Stripe; the request never reaches your handler. Stripe marks the delivery *successful*. |
|
|
48
|
+
| **Handler throws after acking** | Your code accepted the event, then crashed on the DB write (migration missing, RLS denied, connection pool exhausted). Stripe already got its `200`. |
|
|
49
|
+
| **Deploy breaks the handler** | New version renamed a column, dropped an event type, or the signing-secret env var didn't survive the deploy. Every payment since then is missing. |
|
|
50
|
+
| **Endpoint timeout** | Your handler does too much work before responding; Stripe times out and the retry chain starts — and gives up 3 days later. |
|
|
51
|
+
|
|
52
|
+
Stripe's dashboard shows you *delivery* status — it cannot tell you whether the **payment reached your database**. That gap is exactly what hookrecon closes: it diff's Stripe's records against your own tables and tells you which completed payments never landed, how much money is involved, and where to click to fix each one.
|
|
53
|
+
|
|
54
|
+
## Quick start (60 seconds)
|
|
55
|
+
|
|
56
|
+
**1. Run it** — no Python needed on your machine (uv installs it automatically):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uvx hookrecon init
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Prefer pipx or already have Python? `pipx install hookrecon` (needs Python 3.10+), then `hookrecon init`.
|
|
63
|
+
|
|
64
|
+
**2. Answer 3 questions.** The wizard writes `hookrecon.config.json` pre-filled with the 3 canonical checks — you just edit the table and column names to match your schema.
|
|
65
|
+
|
|
66
|
+
**3. Point it at your secrets** (env var *names* live in the config; the values never do):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export STRIPE_SECRET_KEY="sk_live_..." # or rk_live_... restricted key — see Trust below
|
|
70
|
+
export DATABASE_URL="postgresql://user:pass@host/db?sslmode=require"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**4. Reconcile:**
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
hookrecon check
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`hookrecon doctor` runs the same preflight first — config, env vars, Stripe API, database connection, and a write-privilege audit — if anything is off, it tells you exactly what.
|
|
80
|
+
|
|
81
|
+
## What you get
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
hookrecon — Stripe ↔ database reconciliation
|
|
85
|
+
Lookback: 7 days (Sep 24 – Oct 1) · 1,842 events scanned · live mode
|
|
86
|
+
|
|
87
|
+
✗ completed checkout without order — 3 drifts, ≈ $249.00
|
|
88
|
+
evt_1P9xK… cs_live…a1 $89.00 Sep 28 https://dashboard.stripe.com/events/evt_1P9…
|
|
89
|
+
evt_1P2mQ… cs_live…b7 $120.00 Sep 30 https://dashboard.stripe.com/events/evt_1P2…
|
|
90
|
+
evt_1P8zL… cs_live…c2 $40.00 Oct 1 https://dashboard.stripe.com/events/evt_1P8…
|
|
91
|
+
→ Fix: open the event URL → "Resend" after fixing the handler, or insert the row manually.
|
|
92
|
+
|
|
93
|
+
✓ paid invoice not in billing table — clean
|
|
94
|
+
✗ captured payment without payment record — 1 drift, ≈ $12.00
|
|
95
|
+
…
|
|
96
|
+
|
|
97
|
+
Summary: 4 drifts, ≈ $261.00 affected · run `hookrecon doctor` if any check errored.
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Every drift row deep-links to the event in your Stripe dashboard — open it, fix your handler, hit **Resend**, and your app processes the payment like nothing happened.
|
|
101
|
+
|
|
102
|
+
## How it works
|
|
103
|
+
|
|
104
|
+
1. **Fetch** — `hookrecon` pulls recent Stripe events (`checkout.session.completed`, `invoice.payment_succeeded`, `payment_intent.succeeded`, or any types you configure) over your lookback window.
|
|
105
|
+
2. **Diff** — for each event it runs *your* SQL (one read-only `SELECT` per check, bound as a parameterized query) against your database. **0 rows = the record the webhook should have created is missing = drift.**
|
|
106
|
+
3. **Report** — drifts with amounts per currency, deep links, and exit codes your CI can act on.
|
|
107
|
+
|
|
108
|
+
**What hookrecon is not:** it never writes to your database, never resends events itself, never hosts anything, and has no daemon. It's the diff tool — detection stays local and read-only, so you can trust it with production credentials.
|
|
109
|
+
|
|
110
|
+
<details>
|
|
111
|
+
<summary><strong>Under the hood — what happens when you run <code>check</code></strong></summary>
|
|
112
|
+
|
|
113
|
+
1. **Load + validate config** — `hookrecon.config.json` is read and validated: each `sql` must be a single read-only `SELECT` referencing `$1`, check names must be unique, at most 20 distinct event types. Errors name the offending check and exit 2.
|
|
114
|
+
2. **Resolve the key** — the secret comes from the env var *named* in your config; the key's prefix (`sk_test_`, `sk_live_`, `rk_…`) decides the mode shown in the report. Missing → exit 2 with a hint.
|
|
115
|
+
3. **Fetch Stripe events** — `GET /v1/events` with `created ≥ now − lookback` and your configured types; auto-paginates, dedupes by event id, stops at `--limit` (and says so if it did).
|
|
116
|
+
4. **Connect to Postgres** — your `DATABASE_URL`, one independent transaction per statement, nothing ever written.
|
|
117
|
+
5. **Diff** — for every event matching a check: the `param` path (default `data.object.id`) is pulled out of the event, your SQL runs with that id bound as a parameterized query. **0 rows = drift**; an SQL error marks only that check as errored and the others continue.
|
|
118
|
+
6. **Render** — the human table (or `--json`), money summed per currency, every drift deep-linked to its Stripe dashboard event.
|
|
119
|
+
7. **Exit code** — `0` clean · `1` drift found · `2` couldn't run reliably. Cron/CI acts on the code, not the output.
|
|
120
|
+
|
|
121
|
+
Nothing in these steps ever writes to your database or calls a Stripe write endpoint.
|
|
122
|
+
</details>
|
|
123
|
+
|
|
124
|
+
## The three commands
|
|
125
|
+
|
|
126
|
+
| Command | What it does |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `hookrecon init` | Interactive wizard: writes a starter `hookrecon.config.json` (3 canonical checks), prints the restricted-key recipe, ends with a `doctor` preflight |
|
|
129
|
+
| `hookrecon check` | The product: fetches Stripe events, runs your per-check SQL, reports drift |
|
|
130
|
+
| `hookrecon doctor` | Preflight only: env vars → Stripe events ping → DB connection → per-check SQL dry-run → write-privilege audit |
|
|
131
|
+
|
|
132
|
+
`check` flags:
|
|
133
|
+
|
|
134
|
+
| Flag | Meaning |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `--since <Nd>` | Override the configured lookback (e.g. `30d`) |
|
|
137
|
+
| `--json` | Machine-readable JSON report instead of the table (CI/cron-friendly) |
|
|
138
|
+
| `--show-sql` | Print every SQL statement with its param before running it |
|
|
139
|
+
| `--limit <n>` | Safety cap on Stripe events fetched (default 10000; warns if hit) |
|
|
140
|
+
| `--quiet` | Only drift lines + summary, no banner |
|
|
141
|
+
|
|
142
|
+
Exit codes — act on the code, not the output:
|
|
143
|
+
|
|
144
|
+
| Exit | Meaning |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `0` | Ran fully, no drift (also: `doctor` all green) |
|
|
147
|
+
| `1` | Ran fully, **drift found** |
|
|
148
|
+
| `2` | Could not run reliably — config/fatal error, or any check's SQL errored. Usage errors also exit 2 — same class. |
|
|
149
|
+
|
|
150
|
+
`init` exits 0 once the config is written — even if the preflight that follows finds issues; it exits 2 only if `hookrecon.config.json` already exists.
|
|
151
|
+
|
|
152
|
+
## Configuration reference
|
|
153
|
+
|
|
154
|
+
`hookrecon init` writes this starter config — edit the table/column names, not the structure:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"stripe": {
|
|
159
|
+
"apiKeyEnv": "STRIPE_SECRET_KEY",
|
|
160
|
+
"lookbackDays": 7
|
|
161
|
+
},
|
|
162
|
+
"database": {
|
|
163
|
+
"urlEnv": "DATABASE_URL"
|
|
164
|
+
},
|
|
165
|
+
"checks": [
|
|
166
|
+
{
|
|
167
|
+
"name": "completed checkout without order",
|
|
168
|
+
"event": "checkout.session.completed",
|
|
169
|
+
"sql": "SELECT 1 FROM orders WHERE stripe_session_id = $1",
|
|
170
|
+
"money": "data.object.amount_total",
|
|
171
|
+
"param": "data.object.id"
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"name": "paid invoice not in billing table",
|
|
175
|
+
"event": "invoice.payment_succeeded",
|
|
176
|
+
"sql": "SELECT 1 FROM subscriptions WHERE stripe_invoice_id = $1",
|
|
177
|
+
"money": "data.object.amount_paid",
|
|
178
|
+
"param": "data.object.id"
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"name": "captured payment without payment record",
|
|
182
|
+
"event": "payment_intent.succeeded",
|
|
183
|
+
"sql": "SELECT 1 FROM payments WHERE stripe_payment_intent_id = $1",
|
|
184
|
+
"money": "data.object.amount_received",
|
|
185
|
+
"param": "data.object.id"
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Field | Rules |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `event` | Exact Stripe event type. Unknown types are allowed with a warning — but they're sent to Stripe as-is, so a typo'd type fails the fetch with a Stripe error. Max 20 distinct types (Stripe's own limit). |
|
|
194
|
+
| `sql` | A single SELECT. **0 rows = drift** (the record is missing — the webhook never landed), **≥1 row = processed fine.** Your SQL must reference `$1` at least once — it is bound as a parameterized query, never string-interpolated. |
|
|
195
|
+
| `param` | JSON path into the Stripe event, default `data.object.id`. |
|
|
196
|
+
| `money` | Optional JSON path to an integer amount (minor units) for the "≈ $X affected" summary. Absent → that check contributes no money figure. |
|
|
197
|
+
| `name` | Free text shown in the report — must be unique across checks. |
|
|
198
|
+
|
|
199
|
+
**Suppress known false positives with your own `WHERE`** — imported customers, test orders, free-trial invoices:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"name": "completed checkout without order (real customers only)",
|
|
204
|
+
"event": "checkout.session.completed",
|
|
205
|
+
"sql": "SELECT 1 FROM orders WHERE stripe_session_id = $1 AND customer_id NOT IN (SELECT id FROM test_accounts)"
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The SQL is yours — anything from a simple existence check to joins against your own tables works, as long as it's one read-only `SELECT` and returns 0 rows when something went missing.
|
|
210
|
+
|
|
211
|
+
## Automation: cron, CI, and alerting
|
|
212
|
+
|
|
213
|
+
`check` is built for unattended runs — exit code `1` means drift, `--json` gives you structured output:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
# crontab — every hour, log the JSON report
|
|
217
|
+
0 * * * * cd /srv/yourapp && hookrecon check --quiet --json >> /var/log/hookrecon.json 2>&1
|
|
218
|
+
|
|
219
|
+
# Slack yourself only when there's drift
|
|
220
|
+
hookrecon check --quiet --json | jq -e '.checks[] | select(.driftCount > 0)' \
|
|
221
|
+
|| curl -s -X POST -d '{"text":"hookrecon found missing payments"}' $SLACK_WEBHOOK
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
GitHub Actions (secrets, not plaintext):
|
|
225
|
+
|
|
226
|
+
```yaml
|
|
227
|
+
- name: Stripe ↔ DB reconciliation
|
|
228
|
+
env:
|
|
229
|
+
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
|
230
|
+
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
231
|
+
run: uvx hookrecon check --quiet
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Trust & security — why you can paste your DB URL into this
|
|
235
|
+
|
|
236
|
+
The reason you'd hesitate is the reason this section exists:
|
|
237
|
+
|
|
238
|
+
- **Read-only end to end.** hookrecon only issues `SELECT` statements to your database and only reads from Stripe's API. It never writes to your DB, never calls a Stripe write endpoint.
|
|
239
|
+
- **The SELECT lint is a convenience, not a sandbox.** Postgres functions (`setval`, `dblink`, …) and sequence writes sit outside table privileges — a **read-only DB user is the real boundary**, and `doctor`'s audit tells you when you forgot one.
|
|
240
|
+
- **No telemetry. No outbound calls except the Stripe API and your own database.** The source is a handful of small Python files — read it in one sitting.
|
|
241
|
+
- **`--show-sql`.** Print every statement with its bound param before it runs. Verify exactly what happens.
|
|
242
|
+
- **Write-privilege audit.** `doctor` extracts every table your checks touch and asks Postgres itself — `has_table_privilege(current_user, table, ...)` for INSERT/UPDATE/DELETE/TRUNCATE — which catches PUBLIC grants, inherited roles, and superusers that `information_schema` views miss. If the connected user *could* write, you get: *"⚠ connected user can WRITE to \<table\> — use a read-only user; hookrecon never writes, but don't take our word for it."*
|
|
243
|
+
- **Secrets stay in env vars.** The config file stores env var *names* (`apiKeyEnv`, `urlEnv`), never values. Your key and DSN are never echoed. The config file is commit-safe.
|
|
244
|
+
|
|
245
|
+
Give hookrecon a Postgres role that can only read:
|
|
246
|
+
|
|
247
|
+
<details>
|
|
248
|
+
<summary>Read-only Postgres user recipe</summary>
|
|
249
|
+
|
|
250
|
+
```sql
|
|
251
|
+
CREATE ROLE hookrecon_ro LOGIN PASSWORD 'choose-a-long-password';
|
|
252
|
+
GRANT CONNECT ON DATABASE yourdb TO hookrecon_ro;
|
|
253
|
+
GRANT USAGE ON SCHEMA public TO hookrecon_ro;
|
|
254
|
+
GRANT SELECT ON ALL TABLES IN SCHEMA public TO hookrecon_ro;
|
|
255
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO hookrecon_ro;
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Caveat: `ALTER DEFAULT PRIVILEGES` only covers tables created by the role that runs it. Run it as (or once per) whichever role creates your app's tables, so tables added later are readable too.
|
|
259
|
+
</details>
|
|
260
|
+
|
|
261
|
+
On the Stripe side, create a **restricted key** (Dashboard → Developers → API keys) with `Events: Read` — the only API hookrecon calls — plus Read on Checkout Sessions, Invoices, and Payment Intents if you keep the default checks. `doctor` pings `GET /v1/events?limit=1` rather than `/v1/balance` on purpose: the balance endpoint returns 403 for exactly this Events:Read-only key, while the events ping validates both auth and the one permission the tool actually needs.
|
|
262
|
+
|
|
263
|
+
## Troubleshooting — every error, and its fix
|
|
264
|
+
|
|
265
|
+
| You see | Why | Fix |
|
|
266
|
+
|---|---|---|
|
|
267
|
+
| `config file not found: hookrecon.config.json` | You're running from a directory that has no config — hookrecon reads it from the current directory | `cd` into your project folder, or run `hookrecon init` there once to create it |
|
|
268
|
+
| `environment variable STRIPE_SECRET_KEY is not set` | The env var named in your config isn't set in this shell | Bash: `export STRIPE_SECRET_KEY="sk_..."` · PowerShell: `$env:STRIPE_SECRET_KEY = "sk_..."` · cmd: `set STRIPE_SECRET_KEY=sk_...` · persistent on Windows: `setx STRIPE_SECRET_KEY "sk_..."` then reopen the terminal |
|
|
269
|
+
| `Invalid API Key provided` | Key typo'd, or test key against live expectations | Copy it again from Dashboard → Developers → API keys; check test vs live mode |
|
|
270
|
+
| Stripe ping fails with a permissions/403 error | Restricted key is missing `Events: Read` (or the object reads your checks need) | Dashboard → Restricted keys → add `Events: Read` (+ Checkout Sessions / Invoices / Payment Intents: Read) |
|
|
271
|
+
| `relation "orders" does not exist` | The check's SQL names a table your DB doesn't have (starter config uses example names) | Edit `hookrecon.config.json` — change `FROM orders` etc. to your real tables/columns, or delete checks you don't need |
|
|
272
|
+
| `connection refused` / connection timeout | Wrong host/port, or SSL required | Append `?sslmode=require` to your `DATABASE_URL`; check firewall; for Neon/Supabase use their provided pooled connection string |
|
|
273
|
+
| `the query has 0 placeholders but 1 parameters were passed` | Your check SQL doesn't use `$1` | Add it: `... WHERE your_stripe_id_column = $1` |
|
|
274
|
+
| `SQL must start with SELECT` / `forbidden keyword 'UPDATE'` | The SQL lint rejected something that isn't one read-only SELECT | One `SELECT` statement only; filter with `WHERE`, no CTEs (`WITH`) in v1 |
|
|
275
|
+
| `duplicate check name(s)` | Two checks share a `name` | Give each check a unique name |
|
|
276
|
+
| `unknown config key 'urlenv' — ignored (typo?)` | A key in the config isn't recognized — likely a typo | Fix the spelling (e.g. `urlenv` → `urlEnv`); unknown keys are ignored, defaults apply |
|
|
277
|
+
| `⚠ fetch stopped at --limit N — results are incomplete` | More events matched than `--limit` | Raise `--limit` or narrow `--since` |
|
|
278
|
+
| `0 events in lookback — widen --since if this is unexpected` | No events matched — wrong mode? | Check the key: `sk_test_` sees only test-mode payments, `sk_live_` only live. Widen `--since` |
|
|
279
|
+
| `hookrecon: command not found` (after install) | The install dir isn't on `PATH` | uv: add `%USERPROFILE%\.local\bin` to `PATH` · pipx: run `pipx ensurepath`, reopen the terminal |
|
|
280
|
+
| `hookrecon.config.json already exists — not overwriting` | `init` never clobbers | Delete or rename the old file first |
|
|
281
|
+
|
|
282
|
+
## FAQ
|
|
283
|
+
|
|
284
|
+
**How long does Stripe retry failed webhooks?**
|
|
285
|
+
Up to 3 days with exponential backoff. After that the event is marked failed and *never retried* — the payment stays completed in Stripe and permanently missing from your app unless someone notices. hookrecon sees events up to **30 days back** (Stripe's API retention), so run it within that window — hourly cron is the point.
|
|
286
|
+
|
|
287
|
+
**My webhook returns 200 OK but my database isn't updated — how is that possible?**
|
|
288
|
+
Because `200 OK` was sent by something that isn't your handler: a CDN/WAF/ingress acking early, or your handler crashing *after* acknowledging. Stripe sees success; your DB never got the write. That's the exact failure hookrecon detects.
|
|
289
|
+
|
|
290
|
+
**How do I find Stripe payments missing from my database?**
|
|
291
|
+
That's this tool: `uvx hookrecon check` diffs Stripe's completed payments against your tables and lists every one that never landed, with amounts and dashboard deep links.
|
|
292
|
+
|
|
293
|
+
**Is it safe to give a CLI my production database URL?**
|
|
294
|
+
That's the design center: run it as a read-only Postgres user (recipe above), watch `doctor`'s write-privilege audit confirm your grants, use `--show-sql` to see every statement before it runs. No telemetry, no third-party calls, config file holds env-var names only. The whole codebase is a few small Python files.
|
|
295
|
+
|
|
296
|
+
**Does hookrecon support MySQL, MongoDB, or SQLite?**
|
|
297
|
+
Postgres only in v1 — deliberately one dialect, done well. Open an issue if you need another; demand decides the roadmap.
|
|
298
|
+
|
|
299
|
+
**Does it work with Shopify, Razorpay, or Paddle?**
|
|
300
|
+
Stripe only in v1, for the same reason. Star/watch the repo if you want those first.
|
|
301
|
+
|
|
302
|
+
**Why does it say 0 events?**
|
|
303
|
+
Three usual reasons: your lookback window is too short (`--since 30d`), you're using a test key but looking for live payments (or vice versa), or your handler is actually working — `0 events` with no drift is a good result.
|
|
304
|
+
|
|
305
|
+
**Why do $0 invoices show as drift?**
|
|
306
|
+
Free trials legitimately fire `invoice.payment_succeeded` with `amount_paid: 0`. If you don't track trials in your billing table, suppress them with a `WHERE amount_paid`-style filter in your own SQL.
|
|
307
|
+
|
|
308
|
+
**Can hookrecon fix the missing rows for me?**
|
|
309
|
+
No — it's read-only by design, which is what makes it safe to point at production. Each drift row links to the event in Stripe's dashboard where **Resend** replays it to your (now-fixed) handler, or you insert the row manually.
|
|
310
|
+
|
|
311
|
+
**Does it work with Stripe test mode?**
|
|
312
|
+
Yes — it detects test keys and says so loudly, since test-mode results cover test-mode payments only.
|
|
313
|
+
|
|
314
|
+
## Roadmap
|
|
315
|
+
|
|
316
|
+
v1 is deliberately small. What gets built next is decided by demand, not speculation — [open an issue](../../issues) or star/watch the repo to vote with your attention:
|
|
317
|
+
|
|
318
|
+
- MySQL / SQLite / MongoDB dialects
|
|
319
|
+
- Shopify, Razorpay, Paddle gateways
|
|
320
|
+
- A scheduled runner with alerting (the CLI stays free and dumb)
|
|
321
|
+
- A "delivery status" side-report (events Stripe never delivered at all)
|
|
322
|
+
|
|
323
|
+
## Contributing & development
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
git clone https://github.com/Vasram/hookrecon && cd hookrecon
|
|
327
|
+
python -m pip install -e .
|
|
328
|
+
python -m unittest discover tests
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
53 tests, no test frameworks beyond stdlib `unittest`, no network in the suite. Bug reports with your (redacted) config shape are the most useful contributions right now.
|
|
332
|
+
|
|
333
|
+
## License
|
|
334
|
+
|
|
335
|
+
[MIT](LICENSE) — the `psycopg` dependency is LGPL-3.0-only (unmodified pip dependency; see LICENSE).
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
*If hookrecon found money your database forgot, a ⭐ helps the next developer find it before their customers do.*
|
hookrecon-0.9.2/PLAN.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# hookrecon — Implementation Plan (Python)
|
|
2
|
+
|
|
3
|
+
**Status:** DRAFT for review · **Date:** 2026-10-01 · **Source:** SRS v1.0 + verified investigation (Stripe docs, psycopg docs, packaging docs, PyPI/npm landscape). All facts below carry sources in §10.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 0. SRS amendments — review these first
|
|
8
|
+
|
|
9
|
+
The SRS said TypeScript. The stack pivots to Python; everything else in the SRS (3 commands, Postgres-only, read-only wedge, config spec, exit codes) stands. Investigation also surfaced four places where the SRS itself should change:
|
|
10
|
+
|
|
11
|
+
| # | SRS says | Plan says | Why (verified) |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| A1 | TypeScript, Node ≥ 20, ESM; deps `stripe`, `pg`, `commander` | **Python ≥ 3.10**; deps `stripe>=16,<17`, `psycopg[binary]>=3.3,<4`, argparse (stdlib) | Owner decision. psycopg 3 is the recommended Postgres driver; argparse is stdlib (no commander equivalent needed). `uvx hookrecon` = npx parity: uv auto-downloads Python if missing. |
|
|
14
|
+
| A2 | `doctor` pings Stripe via `GET /v1/balance` | Ping via **`GET /v1/events?limit=1`** | `/v1/balance` returns **403** on exactly the Events:Read-only restricted key the SRS's own recipe (§9.1) tells users to create. Events ping validates auth *and* the one permission the tool needs. |
|
|
15
|
+
| A3 | Write-privilege audit via `information_schema.role_table_grants WHERE grantee = current_user` | Audit via **`has_table_privilege(current_user, tbl, priv)`** per table | `role_table_grants` misses grants to `PUBLIC` (the default on many managed Postgres setups) and inherited roles — i.e. it misses the most common real misconfiguration. `has_table_privilege` catches PUBLIC, inheritance, and superuser. |
|
|
16
|
+
| A4 | Check SQL uses `$1` placeholder | Keep `$1` in **config** (SRS contract + Postgres culture), translate `$n → %s` at bind time | psycopg 3 only accepts `%s`/`%(name)s`; its extended protocol rewrites to server-side `$n` anyway. Translation is one regex, marked with a `ponytail:` ceiling comment. |
|
|
17
|
+
| A5 | `node:test` + one test file | `unittest` (stdlib), same one-file spirit + one small SQL-lint file | Python mirror of §11; lint is a trust boundary so it gets its own asserts. |
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 1. Stack (final)
|
|
22
|
+
|
|
23
|
+
| Decision | Choice | Notes |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| Language | Python ≥ 3.10, sync only (no asyncio) | Floor bound by psycopg 3 (`>=3.10`); stripe-python needs only 3.9. uvx users don't care about the floor; pipx users do. |
|
|
26
|
+
| Distribution | **PyPI** + `uvx hookrecon` (primary), `pipx install hookrecon` (fallback) | uv's `python-downloads` is `automatic` — `uvx` works with **no Python installed**. Windows gets `.exe` shims (PATH note goes in README). |
|
|
27
|
+
| Runtime deps | `stripe>=16,<17` · `psycopg[binary]>=3.3,<4` | `psycopg[binary]` wheels bundle libpq — no system Postgres client. psycopg is LGPL-3.0-only: fine for an MIT CLI (unmodified pip dependency); one README line. |
|
|
28
|
+
| CLI parsing | `argparse` (stdlib) | 3 subcommands + flags fully covered. argparse exits **2** on usage errors — same class as SRS exit 2; document it. |
|
|
29
|
+
| Config | `hookrecon.config.json` (unchanged) | No parser dep needed; stdlib `json`. |
|
|
30
|
+
| Tests | `unittest` + `unittest.mock`-free fakes | SRS §11 mirrored: fake "run SQL" callable, no DB, no network, no framework. |
|
|
31
|
+
| Build backend | hatchling | 2026 packaging recommendation for a single pure-Python package. |
|
|
32
|
+
| Publish | PyPI **Trusted Publishing** (OIDC, `pypa/gh-action-pypi-publish`) via GitHub Actions on tag | No API tokens. |
|
|
33
|
+
| Name | `hookrecon` | **Free on PyPI** (registry 404s today). Also free of conflicts on npm. Register at M6 publish time. |
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 2. Repository layout
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
hookrecon/
|
|
41
|
+
pyproject.toml # hatchling, [project.scripts] hookrecon = "hookrecon.cli:main"
|
|
42
|
+
README.md # GIF at top (record at M4), restricted-key recipe, uvx line
|
|
43
|
+
LICENSE # MIT (+ one line noting psycopg dependency is LGPL)
|
|
44
|
+
.github/workflows/
|
|
45
|
+
ci.yml # unittest matrix: 3.10 / 3.12 / 3.13 × ubuntu + windows
|
|
46
|
+
release.yml # tag → build → PyPI trusted publish
|
|
47
|
+
src/hookrecon/
|
|
48
|
+
__init__.py # __version__
|
|
49
|
+
cli.py # argparse wiring + init/check/doctor orchestration
|
|
50
|
+
config.py # load + validate hookrecon.config.json
|
|
51
|
+
stripe_client.py # fetch events: pagination, dedupe, --limit, mode detect
|
|
52
|
+
db.py # connect, SELECT lint, $n→%s, run check SQL, dry-run, privilege audit
|
|
53
|
+
drift.py # pure diff logic — the only nontrivial module (no I/O)
|
|
54
|
+
report.py # ANSI table + JSON rendering, money formatting
|
|
55
|
+
tests/
|
|
56
|
+
drift_test.py # SRS §11's four cases (fake sql fn) → shipped as test_drift.py
|
|
57
|
+
sql_test.py # SELECT-lint + $n→%s translation (trust boundary) → test_sql.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 3. Module design
|
|
63
|
+
|
|
64
|
+
### `config.py` — load + validate
|
|
65
|
+
- Resolve env-var **names** from config (defaults: `STRIPE_SECRET_KEY`, `DATABASE_URL`); values come only from the environment (SRS §9.5 — config file stays commit-safe).
|
|
66
|
+
- Validate: `lookbackDays` positive int; `checks` non-empty; per check: `name`, `event`, `sql`, optional `param` (default `data.object.id`), optional `money`.
|
|
67
|
+
- `event`: validated against a small known-list (the 3 canonical types); unknown types allowed with a warning.
|
|
68
|
+
- Distinct event types across checks **> 20 → validation error** (Stripe `types` filter accepts max 20 per call; 3 canonical checks make this theoretical).
|
|
69
|
+
- **SQL lint (trust boundary):** strip `--` and `/* */` comments → first token must be `SELECT` → reject `;` (except one optional trailing) → reject word-boundary write/DDL keywords (`INSERT UPDATE DELETE TRUNCATE DROP ALTER CREATE GRANT REVOKE COPY CALL DO VACUUM ANALYZE WITH`). `WITH`/CTEs rejected in v1 — users suppress false positives with plain `WHERE`, per SRS §7.
|
|
70
|
+
- Any validation error → `exit 2` naming the offending check (SRS §7).
|
|
71
|
+
|
|
72
|
+
### `stripe_client.py` — fetch events
|
|
73
|
+
- Key from env (name from config). Mode detect by prefix (`rk_live_` / `sk_live_` / `rk_test_` / `rk_test_`) for the banner; **loud warning on test keys** (SRS §9.1).
|
|
74
|
+
- `stripe.api_key = key`, then `stripe.Event.list(created={"gte": now - lookback}, types=[...], limit=100).auto_paging_iter()`.
|
|
75
|
+
- Break at `--limit` (default 10000) → set `capped=True` → warn "results incomplete" in report.
|
|
76
|
+
- **Dedupe by `event.id`** (pages can overlap while objects update mid-scan).
|
|
77
|
+
- **Lookback > 30 days → warn**: "Stripe retains events for 30 days; older events are silently absent" — warn and continue (do not silently undercount).
|
|
78
|
+
- Events converted with `.to_dict()`; a tiny hand-rolled path getter `get_path(obj, "data.object.amount_total")` (split `.`, walk dict) serves both `param` and `money`.
|
|
79
|
+
|
|
80
|
+
### `db.py` — connection, binding, audit
|
|
81
|
+
- `psycopg.connect(dsn)` — full `postgresql://…?sslmode=require` URL straight from env (psycopg accepts libpq conninfo/URI). On connect failure: show `pg` error verbatim + SSL hint (SRS §10).
|
|
82
|
+
- **Binding:** lint (§ above) → `re.sub(r"\$\d+", "%s", sql)` → `conn.execute(sql, [param_value])`. `ponytail:` naive regex could match `$1` inside a string literal — acceptable ceiling; the read-only DB user is the real boundary.
|
|
83
|
+
- **Free safety net:** psycopg 3's extended protocol refuses multi-statement strings at the driver level, so even a lint miss can't append `; DELETE …`.
|
|
84
|
+
- **Table extraction for the audit:** `EXPLAIN (FORMAT JSON) <user SQL>` with the param bound — planner only, **never `ANALYZE`** (that executes) — walk the plan tree collecting `Relation Name`. Fallback: regex over `FROM|JOIN` if EXPLAIN errors. (Regex alone misses subqueries/quoted/schema-qualified names.)
|
|
85
|
+
- **Privilege audit:** per extracted table —
|
|
86
|
+
`SELECT has_table_privilege(current_user, %s, 'INSERT'), … 'UPDATE', … 'DELETE', … 'TRUNCATE'`
|
|
87
|
+
Any true → print the SRS §9.2 warning verbatim-style: "⚠ connected user can WRITE to <table> — use a read-only user; hookrecon never writes, but don't take our word for it."
|
|
88
|
+
- **Doctor dry-run:** run each check's SQL with dummy param `"hookrecon-doctor-probe"`; **no exception = pass** (row count is meaningless, ignore it). Exception = doctor fail with verbatim error, exit 2.
|
|
89
|
+
|
|
90
|
+
### `drift.py` — pure diff (no I/O)
|
|
91
|
+
- Inputs: deduped events (dicts), validated checks, a `run_sql(sql, param) -> rows` callable (injected — that's what makes it testable without DB/network).
|
|
92
|
+
- Per check: events where `event["type"] == check.event` → per event: `param = get_path(event, check.param)`.
|
|
93
|
+
- Param path missing (possible: old events render at their creation-time API version) → **soft skip**: count per check as `skipped`, don't crash, shown in report/JSON.
|
|
94
|
+
- 0 rows from `run_sql` → drift record `{event_id, object_id, amount, currency, created, url}` where `url = https://dashboard.stripe.com/events/<event_id>` (0 rows = the record the webhook should have created is missing — corrected from this plan's original inverted wording after live acceptance testing); amount from `money` path or `null` if path absent (SRS §7: that check then contributes no money figure).
|
|
95
|
+
- `run_sql` raises → that check `status: "error"` with the message, other checks continue (SRS §8 step 3).
|
|
96
|
+
- Money summary: sum per check per currency — currencies never merged (SRS §8 step 4).
|
|
97
|
+
- Output: report dict matching the §8.4 JSON schema exactly.
|
|
98
|
+
|
|
99
|
+
### `report.py` — rendering
|
|
100
|
+
- ANSI by hand (SRS forbids color libs): `\x1b[31m` red, `\x1b[32m` green, `\x1b[1m` bold, `\x1b[2m` dim. Respect `NO_COLOR` and non-tty → plain text.
|
|
101
|
+
- Table per SRS §8.3: banner (lookback, events scanned, mode) → per-check ✓/✗ blocks with drift rows (event id, object id, amount, date, dashboard URL) and the `→ Fix:` line → summary.
|
|
102
|
+
- **Money formatting:** minor units → human string with a **zero-decimal currency guard** (`JPY KRW VND CLP XOF XAF BIF DJF GNF KMF MGA PYG RWF UGX VUV` — never ÷100). `ponytail:` fixed set; extend on complaint.
|
|
103
|
+
- **$0 drifts are real:** free-trial invoices fire `invoice.payment_succeeded` with `amount_paid: 0` — render at $0.00; README tells users to suppress via WHERE filter.
|
|
104
|
+
- Dates: human table in local time (event `created` is Unix seconds UTC); `--json` always ISO-8601 UTC.
|
|
105
|
+
- `--json` → `json.dumps(report, indent=2)` per §8.4. `--quiet` → no banner, drift lines + summary only.
|
|
106
|
+
|
|
107
|
+
### `cli.py` — wiring
|
|
108
|
+
- argparse: subparsers `init` / `check` / `doctor`; `check` flags `--since <Nd> --json --show-sql --limit <n> --quiet` (flag `--since` overrides config `lookbackDays` — now defined).
|
|
109
|
+
- `init`: `input()` wizard (Stripe key env name → DB URL env name → lookback days) → writes `hookrecon.config.json` pre-filled with the 3 canonical checks → prints the restricted-key recipe → runs doctor preflight.
|
|
110
|
+
- `doctor`: env vars → Stripe events ping → DB `SELECT 1` → per-check dry-run → privilege audit. Exit 0 if all pass.
|
|
111
|
+
- Exit codes: **0** clean (including the explicit "0 events — widen --since" case), **1** drift, **2** fatal/config/any-check-errored (+ argparse usage errors). Single `sys.exit(main())` at the end — always finish rendering before exiting (SRS §10).
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 4. Command flows (refined, logic unchanged from SRS §5/§8)
|
|
116
|
+
|
|
117
|
+
- **init** = wizard → config file → key recipe → preflight.
|
|
118
|
+
- **check** = load/validate config → resolve env → fetch events (§stripe_client) → pure diff (§drift) → money summary → render (§report) → exit code.
|
|
119
|
+
- **doctor** = preflight only, ordered fail-soft with named failures, privilege audit last so it always gets shown.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## 5. Tests
|
|
124
|
+
|
|
125
|
+
`tests/test_drift.py` — SRS §11's four, verbatim logic, `unittest.TestCase`, fake `run_sql`:
|
|
126
|
+
1. 3 events, 1 has its DB row present → the other 2 are drifts, correct money sum, correct url shape.
|
|
127
|
+
2. Duplicate event id → deduped, counted once.
|
|
128
|
+
3. Check SQL raises → that check `status: "error"`, other checks still reconcile.
|
|
129
|
+
4. `money` path absent → amount `null`, excluded from totals.
|
|
130
|
+
|
|
131
|
+
`tests/test_sql.py` — the trust boundary:
|
|
132
|
+
5. Valid SELECT passes; `UPDATE …`, `SELECT 1; DROP TABLE x`, CTE (`WITH …`), leading comment + SELECT passes.
|
|
133
|
+
6. `$1`/`$2` → `%s`/`%s` translation; literal-heavy SQL untouched.
|
|
134
|
+
|
|
135
|
+
Run: `python -m unittest discover tests` (or `python -m unittest` with default discovery). No fixtures, no mocks library, no pytest.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 6. CI / release
|
|
140
|
+
|
|
141
|
+
- `ci.yml`: matrix 3.10/3.12/3.13 × ubuntu/windows → `pip install -e .` → unittest → assert exit codes via a tiny scripted run of `--help`/config-error paths (no Stripe/DB in CI — SRS: "no e2e in CI").
|
|
142
|
+
- `release.yml`: on tag → hatchling build → PyPI trusted publishing (OIDC). Trigger: manually, at M6.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 7. Milestones (evenings-sized, Python-adjusted SRS §12)
|
|
147
|
+
|
|
148
|
+
| Block | Deliverable |
|
|
149
|
+
|---|---|
|
|
150
|
+
| **M1** | Repo scaffold: pyproject (hatchling, entry point, deps), src-layout, `pip install -e .` runs; `config.py` + SQL lint; `drift.py` + both test files — all verifiable offline |
|
|
151
|
+
| **M2** | `stripe_client.py` (pagination, dedupe, limit, mode detect, 30-day warning) wired into `check --json` against a test-mode key |
|
|
152
|
+
| **M3** | `db.py`: connect, lint→translate→bind, per-check error isolation, `EXPLAIN` table extraction, privilege audit |
|
|
153
|
+
| **M4** | `report.py`: ANSI table, money summary (zero-decimal guard), JSON schema, exit codes, `--show-sql`, `--quiet`; **record the README GIF here** |
|
|
154
|
+
| **M5** | `doctor` (events ping, dry-run, audit) + `init` wizard |
|
|
155
|
+
| **M6** | README (GIF first, uvx line, restricted-key recipe, trust section, LGPL note), MIT, CI + release workflows, **PyPI publish**, GitHub topics (§9) |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 8. Acceptance checklist (definition of done)
|
|
160
|
+
|
|
161
|
+
- [ ] `uvx hookrecon init` (or local `pip install -e .`) produces a working config against Stripe test mode + a Postgres with an `orders` table.
|
|
162
|
+
- [ ] E2E demo: create a checkout session in test mode, "forget" the insert → `check` reports the drift with the correct amount.
|
|
163
|
+
- [ ] `doctor` flags a write-privileged DB user (tested with both an owner user and a true read-only user).
|
|
164
|
+
- [ ] Doctor passes against the SRS-prescribed restricted key (Events: Read only) — this is what A2 fixed.
|
|
165
|
+
- [ ] All tests pass; exit codes 0/1/2 verified in CI.
|
|
166
|
+
- [ ] `hookrecon` registered on PyPI; `uvx hookrecon --help` works on a machine without Python (uv downloads it).
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 9. SEO / distribution (per your note — ranking comes from repo description + README H1 + topics)
|
|
171
|
+
|
|
172
|
+
- **Repo description:** `Find Stripe payments that never reached your database — read-only CLI, zero install: uvx hookrecon check`
|
|
173
|
+
- **README H1 (exact):** `# hookrecon — find Stripe payments that never reached your database`
|
|
174
|
+
- **First paragraph carries the search phrases:** "missing Stripe payments", "failed webhooks", "Stripe database reconciliation", "silent webhook failures".
|
|
175
|
+
- **GitHub topics:** `stripe`, `webhook`, `reconciliation`, `postgres`, `cli`, `payments`, `drift-detection`, `python`, `stripe-webhooks`, `database`
|
|
176
|
+
- **PyPI description** mirrors the README (PyPI ranks in Google for the same phrases).
|
|
177
|
+
- GIF at top before launch (SRS §6 note).
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 10. Verified-fact sources
|
|
182
|
+
|
|
183
|
+
Stripe [events/list](https://docs.stripe.com/api/events/list) (30-day retention, `types` ≤ 20, `delivery_success`) · [rate limits](https://docs.stripe.com/rate-limits) · [restricted keys](https://docs.stripe.com/keys/restricted-api-keys) + [403 changelog](https://docs.stripe.com/changelog/2016-10-19/insufficient-permissions-throw-403-error) · [Checkout Session](https://docs.stripe.com/api/checkout/sessions/object) / [Invoice](https://docs.stripe.com/api/invoices/object) / [PaymentIntent](https://docs.stripe.com/api/payment_intents/object) (money paths, nullability, $0 invoices) · [auto-pagination](https://docs.stripe.com/api/pagination/auto?lang=python) · [stripe-python](https://github.com/stripe/stripe-python) / [PyPI stripe 16](https://pypi.org/pypi/stripe/json) · psycopg [3 docs](https://www.psycopg.org/psycopg3/docs/basic/from_pg2.html) (%s only, multi-statement refusal) / [conninfo](https://www.psycopg.org/psycopg3/docs/api/conninfo.html) / [PyPI psycopg 3.3](https://pypi.org/pypi/psycopg/json) · Postgres [role_table_grants](https://www.postgresql.org/docs/current/infoschema-role-table-grants.html) (PUBLIC gap → `has_table_privilege`) · [read-only user recipe](https://www.crunchydata.com/blog/creating-a-read-only-postgres-user) · packaging [recommendations](https://packaging.python.org/guides/tool-recommendations) · [uv python downloads](https://docs.astral.sh/uv/concepts/python-versions/) (uvx needs no Python) · [PyPI trusted publishers](https://docs.pypi.org/trusted-publishers/) · landscape: `stripe-reconcile`/`stripe-reconciler`/`stripe-drift` = zero results on npm+PyPI; `hookrecon` free on PyPI; closest existing thing = [Stripe's own hand-rolled recipe](https://docs.stripe.com/webhooks/process-undelivered-events).
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 11. Deliberately excluded (one line each, revisit after dogfooding)
|
|
188
|
+
|
|
189
|
+
- **`delivery_success=false` filter** (discovered in the events API): catches *never-acknowledged* webhooks — a different failure mode than hookrecon's target (handler acked 200 then failed, or WAF answered for it). The full-scan diff is the product; this could become a bonus section in the report later.
|
|
190
|
+
- MySQL/SQLite/Mongo, other gateways, daemon/scheduler, replay — SRS non-goals, unchanged.
|
|
191
|
+
- Concurrency/asyncio — 10k events × ~1 ms sequential queries is fine; add none.
|