hookrecon 0.9.2__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.9.2"
hookrecon/cli.py ADDED
@@ -0,0 +1,385 @@
1
+ """argparse wiring + the three commands: init, check, doctor.
2
+
3
+ All printing lives here; config/db/stripe_client/drift stay silent.
4
+ """
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ from datetime import datetime, timezone
12
+
13
+ import stripe
14
+
15
+ from hookrecon import config, db, drift, stripe_client
16
+ from hookrecon.report import BOLD, DIM, GREEN, RED, RESET, color_enabled, render_report
17
+
18
+ _CONFIG_FILE = "hookrecon.config.json"
19
+ _AUTH_HINT = "is this a restricted key with Events: Read?"
20
+ _TEST_KEY_WARNING = "using a TEST key — results cover test-mode events only"
21
+ _WRITE_WARNING = (
22
+ "connected user can WRITE to {table} — use a read-only user;"
23
+ " hookrecon never writes, but don't take our word for it."
24
+ )
25
+ _KEY_RECIPE = """\
26
+ Next: create a Stripe RESTRICTED key (Dashboard → Developers → API keys) with only:
27
+ - Events: Read
28
+ - Checkout Sessions: Read, Invoices: Read, Payment Intents: Read (for the default checks)
29
+ hookrecon only ever reads — it never calls a Stripe write endpoint.
30
+ Store the key in the {api_key_env} environment variable, never in the config file."""
31
+ _SINCE_RE = re.compile(r"(\d+)d")
32
+
33
+
34
+ def _positive_int(value: str) -> int:
35
+ try:
36
+ number = int(value)
37
+ except ValueError:
38
+ raise argparse.ArgumentTypeError(f"{value!r} is not an integer") from None
39
+ if number < 1:
40
+ raise argparse.ArgumentTypeError("must be >= 1")
41
+ return number
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ # Windows pipes can default to a legacy codepage (cp1252) on Python < 3.15
46
+ # — the report's ✓/✗/⚠ glyphs must degrade, never crash the run.
47
+ for stream in (sys.stdout, sys.stderr):
48
+ if hasattr(stream, "reconfigure"):
49
+ try:
50
+ stream.reconfigure(errors="replace")
51
+ except Exception:
52
+ pass
53
+ parser = argparse.ArgumentParser(
54
+ prog="hookrecon",
55
+ description="Find Stripe payments that never reached your database.",
56
+ )
57
+ subparsers = parser.add_subparsers(dest="command", required=True)
58
+
59
+ subparsers.add_parser("init", help="Write a starter hookrecon.config.json")
60
+
61
+ check = subparsers.add_parser("check", help="Reconcile Stripe events against your database")
62
+ check.add_argument("--since", metavar="Nd", help="Override the configured lookback, e.g. 30d")
63
+ check.add_argument("--json", action="store_true", help="Print the machine-readable JSON report")
64
+ check.add_argument(
65
+ "--show-sql", action="store_true", help="Print every SQL statement before running it"
66
+ )
67
+ check.add_argument(
68
+ "--limit",
69
+ type=_positive_int,
70
+ default=10000,
71
+ help="Safety cap on Stripe events fetched (default 10000)",
72
+ )
73
+ check.add_argument("--quiet", action="store_true", help="Only drift lines + summary, no banner")
74
+
75
+ subparsers.add_parser(
76
+ "doctor", help="Preflight: env vars, Stripe ping, DB connection, privileges"
77
+ )
78
+
79
+ args = parser.parse_args(argv)
80
+ try:
81
+ if args.command == "check":
82
+ return _check(args, parser)
83
+ if args.command == "init":
84
+ return _init()
85
+ return run_doctor(_CONFIG_FILE)
86
+ except KeyboardInterrupt:
87
+ print("\nhookrecon: interrupted", file=sys.stderr)
88
+ return 2
89
+
90
+
91
+ def run_doctor(cfg_path: str) -> int:
92
+ """Ordered preflight: config → env → Stripe ping → DB → dry runs → privilege audit.
93
+
94
+ Fail-soft: a failed step marks the run (exit 2) but independent later steps
95
+ still run; steps depending on a failed one print a skip line. The privilege
96
+ audit is advisory — ⚠ lines never fail the run. Never prints key/DSN values.
97
+ """
98
+ color = color_enabled()
99
+ failed = False
100
+ print(_c(BOLD, "hookrecon doctor — preflight", color))
101
+
102
+ try:
103
+ cfg, warnings = config.load(cfg_path)
104
+ except config.ConfigError as exc:
105
+ print(_step(False, f"config: {exc}", color))
106
+ if "not found" in str(exc):
107
+ print(f" hint: run `hookrecon init` in your project directory to create {cfg_path}")
108
+ return 2
109
+ print(_step(True, f"config: {cfg_path}", color))
110
+ for warning in warnings:
111
+ print(f" ⚠ {warning}")
112
+
113
+ key_env, url_env = cfg["stripe"]["apiKeyEnv"], cfg["database"]["urlEnv"]
114
+ key = os.environ.get(key_env)
115
+ dsn = os.environ.get(url_env)
116
+ missing = [name for name, value in ((key_env, key), (url_env, dsn)) if not value]
117
+ if missing:
118
+ failed = True
119
+ for name in missing:
120
+ print(_step(False, f"env var {name} is not set", color))
121
+ first = missing[0]
122
+ print(
123
+ f" hint: set it in this shell first, e.g. export {first}=\"...\""
124
+ f" (Windows: setx {first} \"...\") — hookrecon reads keys from the environment, not from files"
125
+ )
126
+ else:
127
+ print(_step(True, f"env vars {key_env}, {url_env} set", color))
128
+
129
+ if not key:
130
+ print(_skip(f"Stripe ping — {key_env} is not set", color))
131
+ elif not _doctor_stripe(key_env, color):
132
+ failed = True
133
+
134
+ conn = None
135
+ if not dsn:
136
+ print(_skip(f"database checks — {url_env} is not set", color))
137
+ else:
138
+ try:
139
+ conn = db.connect(url_env)
140
+ conn.execute("SELECT 1").fetchall()
141
+ except db.DbError as exc:
142
+ failed = True
143
+ print(_step(False, f"database: {exc}", color))
144
+ if exc.hint:
145
+ print(f" hint: {exc.hint}")
146
+ except Exception as exc: # connected, but SELECT 1 itself failed
147
+ failed = True
148
+ print(_step(False, f"database: SELECT 1: {exc}", color))
149
+ else:
150
+ print(_step(True, "database: connected (SELECT 1 ok)", color))
151
+ for check in cfg["checks"]:
152
+ if not _doctor_check(conn, check, color):
153
+ failed = True
154
+ _doctor_privileges(conn, cfg["checks"], color)
155
+ finally:
156
+ if conn is not None:
157
+ conn.close()
158
+
159
+ return 2 if failed else 0
160
+
161
+
162
+ def _doctor_stripe(key_env: str, color: bool) -> bool:
163
+ """Ping via the events list — /v1/balance 403s on Events:Read-only restricted keys."""
164
+ mode = "unknown"
165
+ try:
166
+ _, mode = stripe_client.resolve_key(key_env)
167
+ stripe.api_key = os.environ[key_env]
168
+ stripe.Event.list(limit=1)
169
+ except Exception as exc:
170
+ print(_step(False, f"Stripe ping ({mode} mode): {exc}", color))
171
+ if mode == "test":
172
+ print(f" ⚠ {_TEST_KEY_WARNING}")
173
+ print(f" hint: {_AUTH_HINT}")
174
+ return False
175
+ print(_step(True, f"Stripe: events API reachable ({mode} mode)", color))
176
+ if mode == "test":
177
+ print(f" ⚠ {_TEST_KEY_WARNING}")
178
+ return True
179
+
180
+
181
+ def _doctor_check(conn, check: dict, color: bool) -> bool:
182
+ try:
183
+ db.dry_run(conn, check["sql"])
184
+ except Exception as exc:
185
+ print(_step(False, f"check {check['name']!r}: {exc}", color))
186
+ return False
187
+ print(_step(True, f"check {check['name']!r}: SQL dry run ok", color))
188
+ return True
189
+
190
+
191
+ def _doctor_privileges(conn, checks: list[dict], color: bool) -> None:
192
+ """Advisory audit — ⚠ lines inform but never fail the run."""
193
+ print(_c(BOLD, "privilege audit", color))
194
+ tables = list(
195
+ dict.fromkeys(table for check in checks for table in db.extract_tables(conn, check["sql"]))
196
+ )
197
+ try:
198
+ writable = db.audit_privileges(conn, tables)
199
+ except Exception as exc:
200
+ print(f" ⚠ privilege audit could not run: {exc}")
201
+ return
202
+ for table, _privs in writable:
203
+ print(f" ⚠ {_WRITE_WARNING.format(table=table)}")
204
+ if not writable:
205
+ print(_step(True, "connected user cannot write to any audited table", color))
206
+
207
+
208
+ def _init() -> int:
209
+ """Wizard → config file → restricted-key recipe → doctor preflight.
210
+
211
+ Exit 0 once the config is written; preflight failures stay informational.
212
+ """
213
+ if os.path.exists(_CONFIG_FILE):
214
+ print(
215
+ f"hookrecon: {_CONFIG_FILE} already exists — not overwriting; delete it first",
216
+ file=sys.stderr,
217
+ )
218
+ return 2
219
+
220
+ print("hookrecon init — press Enter to accept the [default]")
221
+ api_key_env = _ask("Stripe secret key env var name", "STRIPE_SECRET_KEY")
222
+ url_env = _ask("Database URL env var name", "DATABASE_URL")
223
+ lookback_days = _ask_days()
224
+ write_config(_CONFIG_FILE, api_key_env, url_env, lookback_days)
225
+ print(f"hookrecon: wrote {_CONFIG_FILE} — edit each check's table/columns to match your schema")
226
+ print()
227
+ print(_KEY_RECIPE.format(api_key_env=api_key_env))
228
+ print()
229
+ if run_doctor(_CONFIG_FILE) != 0:
230
+ print()
231
+ print("hookrecon: preflight found issues — fix them, then run `hookrecon doctor`")
232
+ return 0
233
+
234
+
235
+ def write_config(path: str, api_key_env: str, url_env: str, lookback_days: int) -> None:
236
+ """Serialize build_default_config — answers in, file out; init's testable half."""
237
+ cfg = config.build_default_config(api_key_env, url_env, lookback_days)
238
+ with open(path, "w", encoding="utf-8") as f:
239
+ json.dump(cfg, f, indent=2)
240
+ f.write("\n")
241
+
242
+
243
+ def _ask(prompt: str, default: str) -> str:
244
+ try:
245
+ answer = input(f"{prompt} [{default}]: ").strip()
246
+ except EOFError: # piped/closed stdin — take the default rather than crash
247
+ return default
248
+ return answer or default
249
+
250
+
251
+ def _ask_days() -> int:
252
+ for _ in range(2): # one re-prompt on invalid input, then the default
253
+ try:
254
+ raw = input("Lookback days [7]: ").strip()
255
+ except EOFError:
256
+ return 7
257
+ if not raw:
258
+ return 7
259
+ try:
260
+ days = int(raw)
261
+ except ValueError:
262
+ days = 0
263
+ if days > 0:
264
+ return days
265
+ print(" please enter a positive number (or press Enter for 7)")
266
+ return 7
267
+
268
+
269
+ def _check(args, parser: argparse.ArgumentParser) -> int:
270
+ started_at = datetime.now(timezone.utc).isoformat()
271
+
272
+ try:
273
+ cfg, warnings = config.load(_CONFIG_FILE)
274
+ except config.ConfigError as exc:
275
+ print(f"hookrecon: {exc}", file=sys.stderr)
276
+ if "not found" in str(exc):
277
+ print(
278
+ f"hookrecon: run `hookrecon init` in your project directory to create {_CONFIG_FILE}",
279
+ file=sys.stderr,
280
+ )
281
+ return 2
282
+
283
+ try:
284
+ lookback_days = _parse_since(args.since, cfg["stripe"]["lookbackDays"])
285
+ except ValueError:
286
+ parser.error(f"invalid --since {args.since!r} (expected e.g. 7d)") # exits 2
287
+
288
+ try:
289
+ key, mode = stripe_client.resolve_key(cfg["stripe"]["apiKeyEnv"])
290
+ except stripe_client.StripeError as exc:
291
+ print(f"hookrecon: {exc}", file=sys.stderr)
292
+ if "not set" in str(exc):
293
+ var = cfg["stripe"]["apiKeyEnv"]
294
+ print(
295
+ f"hookrecon: set it in this shell first, e.g. export {var}=\"...\""
296
+ f" (Windows: setx {var} \"...\") — hookrecon reads keys from the environment, not from files",
297
+ file=sys.stderr,
298
+ )
299
+ else:
300
+ print(f"hookrecon: {_AUTH_HINT}", file=sys.stderr)
301
+ return 2
302
+
303
+ if mode == "test":
304
+ warnings.append(f"⚠ {_TEST_KEY_WARNING}")
305
+ if lookback_days > 30:
306
+ warnings.append("⚠ Stripe retains events for only 30 days — older events are silently absent")
307
+ for warning in warnings:
308
+ print(f"hookrecon: {warning}", file=sys.stderr)
309
+
310
+ try:
311
+ stripe.api_key = key
312
+ events, capped = stripe_client.fetch_events(
313
+ lambda **params: stripe.Event.list(**params),
314
+ [check["event"] for check in cfg["checks"]],
315
+ stripe_client.since_ts(lookback_days),
316
+ args.limit,
317
+ )
318
+ except Exception as exc: # Stripe auth/network or anything else — never a traceback
319
+ print(f"hookrecon: Stripe fetch failed: {exc}", file=sys.stderr)
320
+ if isinstance(exc, stripe.StripeError):
321
+ print(f"hookrecon: {_AUTH_HINT}", file=sys.stderr)
322
+ return 2
323
+
324
+ if capped:
325
+ print(
326
+ f"hookrecon: ⚠ fetch stopped at --limit {args.limit} — results are incomplete",
327
+ file=sys.stderr,
328
+ )
329
+ if not events:
330
+ print(
331
+ "hookrecon: 0 events in lookback — widen --since if this is unexpected",
332
+ file=sys.stderr,
333
+ )
334
+
335
+ try:
336
+ conn = db.connect(cfg["database"]["urlEnv"])
337
+ except db.DbError as exc:
338
+ print(f"hookrecon: {exc}", file=sys.stderr)
339
+ if exc.hint:
340
+ print(f"hookrecon: hint: {exc.hint}", file=sys.stderr)
341
+ return 2
342
+
343
+ try:
344
+ run_sql = db.make_run_sql(conn, show_sql=args.show_sql)
345
+ report = drift.reconcile(events, cfg["checks"], run_sql, started_at, lookback_days, mode)
346
+ finally:
347
+ conn.close()
348
+
349
+ if args.json:
350
+ print(json.dumps(report, indent=2))
351
+ else:
352
+ print(render_report(report, quiet=args.quiet, color=color_enabled()))
353
+
354
+ if any(check["status"] == "error" for check in report["checks"]):
355
+ return 2
356
+ if any(check["driftCount"] > 0 for check in report["checks"]):
357
+ return 1
358
+ return 0
359
+
360
+
361
+ def _parse_since(value: str | None, config_days: int) -> int:
362
+ """--since <Nd> in days; None -> config lookback. Raises ValueError if invalid."""
363
+ if value is None:
364
+ return config_days
365
+ match = _SINCE_RE.fullmatch(value.strip())
366
+ days = int(match.group(1)) if match else 0
367
+ if days <= 0:
368
+ raise ValueError(value)
369
+ return days
370
+
371
+
372
+ def _c(code: str, text: str, color: bool) -> str:
373
+ return f"{code}{text}{RESET}" if color else text
374
+
375
+
376
+ def _step(ok: bool, text: str, color: bool) -> str:
377
+ return f"{_c(GREEN if ok else RED, '✓' if ok else '✗', color)} {text}"
378
+
379
+
380
+ def _skip(text: str, color: bool) -> str:
381
+ return f" {_c(DIM, 'skip: ' + text, color)}"
382
+
383
+
384
+ if __name__ == "__main__":
385
+ sys.exit(main())
hookrecon/config.py ADDED
@@ -0,0 +1,214 @@
1
+ """Load + validate hookrecon.config.json.
2
+
3
+ Pure logic only — no DB, no network, no prints (cli.py and report.py own output).
4
+ """
5
+
6
+ import json
7
+ import re
8
+
9
+ KNOWN_EVENTS = (
10
+ "checkout.session.completed",
11
+ "invoice.payment_succeeded",
12
+ "payment_intent.succeeded",
13
+ )
14
+
15
+ _MAX_EVENT_TYPES = 20 # Stripe events.list `types` filter accepts at most 20
16
+
17
+ _DEFAULT_API_KEY_ENV = "STRIPE_SECRET_KEY"
18
+ _DEFAULT_LOOKBACK_DAYS = 7
19
+ _DEFAULT_URL_ENV = "DATABASE_URL"
20
+ _DEFAULT_PARAM = "data.object.id"
21
+
22
+ _KNOWN_KEYS = {
23
+ None: {"stripe", "database", "checks"},
24
+ "stripe": {"apiKeyEnv", "lookbackDays"},
25
+ "database": {"urlEnv"},
26
+ "check": {"name", "event", "sql", "param", "money"},
27
+ }
28
+
29
+ _FORBIDDEN_RE = re.compile(
30
+ r"\b(INSERT|UPDATE|DELETE|TRUNCATE|DROP|ALTER|CREATE|GRANT|REVOKE|COPY|CALL"
31
+ r"|DO|VACUUM|ANALYZE|WITH)\b",
32
+ re.IGNORECASE,
33
+ )
34
+ _BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
35
+ _LINE_COMMENT_RE = re.compile(r"--[^\n]*")
36
+
37
+
38
+ class ConfigError(Exception):
39
+ """Invalid config or SQL. The message names the offending check."""
40
+
41
+
42
+ def load(path: str) -> tuple[dict, list[str]]:
43
+ """Read + validate the config file. Returns (config, warnings).
44
+
45
+ Fills defaults for optional fields; raises ConfigError (message names the
46
+ offending check) on malformed JSON or wrong types. Unknown event types are
47
+ allowed and come back as warning strings.
48
+ """
49
+ try:
50
+ with open(path, encoding="utf-8") as f:
51
+ raw = json.load(f)
52
+ except FileNotFoundError:
53
+ raise ConfigError(f"config file not found: {path}") from None
54
+ except json.JSONDecodeError as exc:
55
+ raise ConfigError(f"config file is not valid JSON: {exc}") from None
56
+ if not isinstance(raw, dict):
57
+ raise ConfigError("config root must be a JSON object")
58
+
59
+ stripe_cfg = raw.get("stripe", {})
60
+ if not isinstance(stripe_cfg, dict):
61
+ raise ConfigError("'stripe' must be an object")
62
+ api_key_env = stripe_cfg.get("apiKeyEnv", _DEFAULT_API_KEY_ENV)
63
+ if not isinstance(api_key_env, str):
64
+ raise ConfigError("'stripe'.'apiKeyEnv' must be a string")
65
+ lookback_days = stripe_cfg.get("lookbackDays", _DEFAULT_LOOKBACK_DAYS)
66
+ if isinstance(lookback_days, bool) or not isinstance(lookback_days, int) or lookback_days <= 0:
67
+ raise ConfigError("'stripe'.'lookbackDays' must be a positive integer")
68
+
69
+ db_cfg = raw.get("database", {})
70
+ if not isinstance(db_cfg, dict):
71
+ raise ConfigError("'database' must be an object")
72
+ url_env = db_cfg.get("urlEnv", _DEFAULT_URL_ENV)
73
+ if not isinstance(url_env, str):
74
+ raise ConfigError("'database'.'urlEnv' must be a string")
75
+
76
+ checks_raw = raw.get("checks")
77
+ if not isinstance(checks_raw, list) or not checks_raw:
78
+ raise ConfigError("'checks' must be a non-empty list")
79
+ checks = [_validate_check(entry, i) for i, entry in enumerate(checks_raw)]
80
+
81
+ names = [c["name"] for c in checks]
82
+ dupes = sorted({n for n in names if names.count(n) > 1})
83
+ if dupes:
84
+ raise ConfigError(f"duplicate check name(s): {', '.join(repr(n) for n in dupes)}")
85
+
86
+ distinct = {c["event"] for c in checks}
87
+ if len(distinct) > _MAX_EVENT_TYPES:
88
+ raise ConfigError(
89
+ f"{len(distinct)} distinct event types across checks"
90
+ f" — Stripe's `types` filter accepts at most {_MAX_EVENT_TYPES}"
91
+ )
92
+
93
+ unknown = sorted(set(raw) - _KNOWN_KEYS[None])
94
+ unknown += sorted(set(stripe_cfg) - _KNOWN_KEYS["stripe"])
95
+ unknown += sorted(set(db_cfg) - _KNOWN_KEYS["database"])
96
+ for entry in checks_raw:
97
+ if isinstance(entry, dict):
98
+ unknown += sorted(set(entry) - _KNOWN_KEYS["check"])
99
+ warnings = [
100
+ f"unknown config key {key!r} — ignored (typo?)" for key in dict.fromkeys(unknown)
101
+ ] + [
102
+ f"check {c['name']!r}: event {c['event']!r} is not a known Stripe event type"
103
+ f" (known: {', '.join(KNOWN_EVENTS)})"
104
+ for c in checks
105
+ if c["event"] not in KNOWN_EVENTS
106
+ ]
107
+
108
+ return (
109
+ {
110
+ "stripe": {"apiKeyEnv": api_key_env, "lookbackDays": lookback_days},
111
+ "database": {"urlEnv": url_env},
112
+ "checks": checks,
113
+ },
114
+ warnings,
115
+ )
116
+
117
+
118
+ def _validate_check(entry, index: int) -> dict:
119
+ where = f"checks[{index}]"
120
+ if not isinstance(entry, dict):
121
+ raise ConfigError(f"{where}: each check must be an object")
122
+ name = entry.get("name")
123
+ if not isinstance(name, str):
124
+ raise ConfigError(f"{where}: 'name' must be a string")
125
+ where = f"check {name!r}"
126
+ event = entry.get("event")
127
+ if not isinstance(event, str):
128
+ raise ConfigError(f"{where}: 'event' must be a string")
129
+ sql = entry.get("sql")
130
+ if not isinstance(sql, str):
131
+ raise ConfigError(f"{where}: 'sql' must be a string")
132
+ try:
133
+ lint_select(sql)
134
+ except ConfigError as exc:
135
+ raise ConfigError(f"{where}: {exc}") from None
136
+ param = entry.get("param", _DEFAULT_PARAM)
137
+ if not isinstance(param, str):
138
+ raise ConfigError(f"{where}: 'param' must be a string")
139
+ money = entry.get("money")
140
+ if money is not None and not isinstance(money, str):
141
+ raise ConfigError(f"{where}: 'money' must be a string")
142
+ check = {"name": name, "event": event, "sql": sql, "param": param}
143
+ if money is not None:
144
+ check["money"] = money
145
+ return check
146
+
147
+
148
+ def lint_select(sql: str) -> str:
149
+ """Trust boundary: allow exactly one read-only SELECT, nothing else.
150
+
151
+ Strips `--` and `/* */` comments, then requires the first token to be
152
+ SELECT, rejects any `;` except one optional trailing one, and rejects
153
+ word-boundary write/DDL keywords. Returns the SQL whitespace-trimmed.
154
+ Naive about string literals (a literal containing '--' or a keyword is
155
+ misjudged) — the read-only DB user is the real boundary.
156
+ """
157
+ stripped = _LINE_COMMENT_RE.sub(" ", _BLOCK_COMMENT_RE.sub(" ", sql))
158
+ tokens = stripped.split()
159
+ if not tokens or tokens[0].upper() != "SELECT":
160
+ raise ConfigError("SQL must start with SELECT")
161
+ body = stripped.strip()
162
+ if body.endswith(";"):
163
+ body = body[:-1].strip()
164
+ if ";" in body:
165
+ raise ConfigError("SQL must be a single statement (unexpected ';')")
166
+ forbidden = _FORBIDDEN_RE.search(body)
167
+ if forbidden:
168
+ raise ConfigError(
169
+ f"SQL must be read-only: forbidden keyword {forbidden.group(0).upper()!r}"
170
+ )
171
+ return sql.strip()
172
+
173
+
174
+ def translate_placeholders(sql: str) -> str:
175
+ """Rewrite Postgres-style $n placeholders to psycopg 3's %s.
176
+
177
+ ponytail: naive regex can match $n inside a string literal — acceptable
178
+ ceiling; the read-only DB user is the real boundary.
179
+ """
180
+ return re.sub(r"\$\d+", "%s", sql)
181
+
182
+
183
+ def build_default_config(api_key_env: str, url_env: str, lookback_days: int) -> dict:
184
+ """The SRS §7 starter config: exactly the 3 canonical checks, nothing else.
185
+
186
+ Pure — init serializes this and the user edits table/column names.
187
+ """
188
+ return {
189
+ "stripe": {"apiKeyEnv": api_key_env, "lookbackDays": lookback_days},
190
+ "database": {"urlEnv": url_env},
191
+ "checks": [
192
+ {
193
+ "name": "completed checkout without order",
194
+ "event": "checkout.session.completed",
195
+ "sql": "SELECT 1 FROM orders WHERE stripe_session_id = $1",
196
+ "money": "data.object.amount_total",
197
+ "param": "data.object.id",
198
+ },
199
+ {
200
+ "name": "paid invoice not in billing table",
201
+ "event": "invoice.payment_succeeded",
202
+ "sql": "SELECT 1 FROM subscriptions WHERE stripe_invoice_id = $1",
203
+ "money": "data.object.amount_paid",
204
+ "param": "data.object.id",
205
+ },
206
+ {
207
+ "name": "captured payment without payment record",
208
+ "event": "payment_intent.succeeded",
209
+ "sql": "SELECT 1 FROM payments WHERE stripe_payment_intent_id = $1",
210
+ "money": "data.object.amount_received",
211
+ "param": "data.object.id",
212
+ },
213
+ ],
214
+ }