Skip to content

fix(billing,edge): money-path observability, Stripe idempotency, ledger index (#43)#44

Merged
ElbertePlinio merged 2 commits into
mainfrom
fix/money-path-observability
Jul 19, 2026
Merged

fix(billing,edge): money-path observability, Stripe idempotency, ledger index (#43)#44
ElbertePlinio merged 2 commits into
mainfrom
fix/money-path-observability

Conversation

@ElbertePlinio

Copy link
Copy Markdown
Member

Closes #43. Complementary swarm-review follow-ups on the credit money path (assumes #37 landed; no overlap with #37's lifecycle contract tests, LWW, claim-first router, or AppImage repair). Packages stay UI-free.

P1 — money-path observability

  • billing throw sites: refund_incomplete / refund_terminal_failure now emit a structured console.error before throwing, keyed by event_id, event_type, checkout_session_id, and error_code (threaded eventType through performDeletionRefund).
  • recordStripeEventBestEffort: previously swallowed every failure (and only via a catch that the {error}-returning client never triggered). Now captures the returned error, logs any swallowed failure, and treats only Postgres 23505 as silently ignorable.
  • edge-shared: structured console.error at the createStripeWebhookHandler catch (keyed by the Stripe event) and at the accountErrorResponse deletion_incomplete (503) / database_error (500) / unknown-500 branches, so a stuck refund never surfaces as an anonymous 500.
  • Logs emit ids and error codes only — never tokens, Stripe secrets, or request/response bodies.

P1 — Stripe idempotency

  • createCreditCheckoutSession now derives a deterministic idempotencyKey = checkout-session:<userId>:<requestId>, mirroring the refund pattern (checkout-deletion:<session>:attempt:<n>).
  • Retry semantics: retries of one purchase attempt reuse the key → Stripe returns the same Session; a genuinely new purchase supplies a fresh requestId (or omits it, in which case billing generates a fresh UUID) → distinct key → never collides with a prior purchase. create-credit-checkout forwards an optional x-idempotency-key header (already CORS-allowed).

P1 — ledger scaling

  • credit_balance_cents() and debit_credits() both compute sum(amount_cents) per user (twice per router request). Added additive-only migration (20260719000000, per chore: reconcile hosted Supabase migration history #35 — no renumbering/destructive change): covering index credit_ledger_user_id_amount_cents_idx (user_id) INCLUDE (amount_cents).
  • EXPLAIN proof against a local DB: the balance sum runs as Index Only Scan using credit_ledger_user_id_amount_cents_idx … Heap Fetches: 0.
  • Running/cached balance deferred (not attempted): it would alter the ledger write path and needs its own concurrency-correctness proof (interaction with the per-user advisory lock in debit_credits and with purchase/grant/refund inserts). The covering index is the safe additive minimum this policy allows.

P2 — simplify

  • delete-account trio: the settle → finalize → deleteCustomers block was not redundant — deleting a customer can terminalize a late refund/session, which the second pass cleans up (covered by the rechecks and deletes a customer terminalized after the first frozen snapshot contract test). Rather than delete it (which regresses that behaviour and touches Architecture: deepen platform lifecycle seams #37 territory), it's extracted into runDeletionSettlementPass and called twice with an explanatory comment — same behaviour, no duplication.
  • Deno adapter boilerplate: hoisted requiredEnv / createCallerSupabase / withCors from the 5 functions into edge-shared as runtime-agnostic, dependency-injected helpers (createRequiredEnv(env), createCallerSupabaseFactory({createClient,…}), withCors(response)) — no Deno globals, no @supabase/supabase-js import, so the package stays UI-free and Node/Bun-testable.

Versioning

Following the existing publish-then-pin convention (see #34): bumped @pickforge/* 0.9.0 → 0.10.0 and re-pinned the 5 functions (deno.json import maps for 4; inline npm: for stripe-webhook). bun install --frozen-lockfile passes unchanged.

Validation

  • bun install --frozen-lockfile ✅ · bun run typecheck (tsc) ✅ · bun run test (vitest) ✅ 354 passed (+9 new) · bun run test:coverage ✅ (gate met) · bun run build
  • bun test (Bun's native runner) — the repo's canonical command is bun run test (vitest); native bun test has pre-existing failures unrelated to this PR (auth's vi.hoisted, brand's CSS import). My new billing/edge-shared tests pass under both runners.
  • pgTAP: supabase db start + supabase test db --local140 tests pass including the new credit_ledger_balance_covering_index.test.sql (ran a parallel instance on a non-default port to avoid disturbing an existing local DB).
  • deno check on every touched function (verified against the locally-built 0.10.0 dist since the version isn't published yet — matching the publish-then-pin flow):
    • create-credit-checkout ✅ · delete-account ✅ · export-account-data ✅ (also cleared a trivial pre-existing unannotated-param error) · stripe-webhook
    • operator-router: 6 pre-existing strict-mode errors (supabase-js generics, unannotated arrow params, usage possibly undefined) that are identical on origin/main and untouched by this PR. deno check is not part of CI (bun run typecheck is); documenting rather than expanding scope into Architecture: deepen platform lifecycle seams #37 territory.

Worktree

Implemented in a dedicated worktree at /tmp/platform-money-path (branch fix/money-path-observability off origin/main), left in place. The fix/pi-kit-line-count working tree was untouched.

…er index (#43)

Complementary swarm-review follow-ups on the credit money path (no overlap
with #37; packages stay UI-free).

P1 observability
- billing: structured, secret-free console.error at the refund_incomplete /
  refund_terminal_failure throw sites (event id/type, checkout_session_id,
  error code); recordStripeEventBestEffort now logs every swallowed insert
  failure and only treats Postgres 23505 as silently ignorable.
- edge-shared: structured console.error at the createStripeWebhookHandler catch
  and the accountErrorResponse 503/500 branches so a stuck refund never surfaces
  as an anonymous 500. Logs emit ids and error codes only — never tokens,
  secrets, or request/response bodies.

P1 Stripe idempotency
- createCreditCheckoutSession derives a deterministic idempotencyKey
  (checkout-session:<user>:<requestId>) mirroring the refund pattern. Retries of
  one purchase reuse the key; a distinct purchase supplies/gets a fresh requestId
  so a legitimately new purchase never collides. create-credit-checkout forwards
  an optional x-idempotency-key header.

P1 ledger scaling
- Additive-only migration (per #35): covering index
  credit_ledger_user_id_amount_cents_idx (user_id) INCLUDE (amount_cents) so the
  balance sum runs as a heap-free index-only scan. Running/cached balance is
  documented as deferred. pgTAP proof added.

P2 simplify
- Deduplicate the load-bearing two-pass settle/finalize/deleteCustomers block in
  createDeleteAccountHandler into runDeletionSettlementPass (behaviour preserved;
  the second pass is required by the late-terminalized-customer contract test).
- Hoist requiredEnv/createCallerSupabase/withCors from the 5 edge functions into
  edge-shared as runtime-agnostic, dependency-injected helpers.

Bumps @pickforge/* 0.9.0 -> 0.10.0 and re-pins the functions (existing
publish-then-pin convention).
@ElbertePlinio

Copy link
Copy Markdown
Member Author

Implemented in a git worktree at /tmp/platform-money-path (branch fix/money-path-observability, based on origin/main). Per instructions the worktree is left in place for review/reproduction. The fix/pi-kit-line-count working tree and its uncommitted pi-kit changes were not touched.

@ElbertePlinio
ElbertePlinio merged commit c75b227 into main Jul 19, 2026
2 checks passed
@ElbertePlinio
ElbertePlinio deleted the fix/money-path-observability branch July 19, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Money-path observability, Stripe idempotency, ledger scaling

1 participant