fix(billing,edge): money-path observability, Stripe idempotency, ledger index (#43)#44
Merged
Merged
Conversation
…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).
Member
Author
|
Implemented in a git worktree at |
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
refund_incomplete/refund_terminal_failurenow emit a structuredconsole.errorbefore throwing, keyed byevent_id,event_type,checkout_session_id, anderror_code(threadedeventTypethroughperformDeletionRefund).recordStripeEventBestEffort: previously swallowed every failure (and only via acatchthat the{error}-returning client never triggered). Now captures the returned error, logs any swallowed failure, and treats only Postgres23505as silently ignorable.console.errorat thecreateStripeWebhookHandlercatch (keyed by the Stripe event) and at theaccountErrorResponsedeletion_incomplete(503) /database_error(500) / unknown-500 branches, so a stuck refund never surfaces as an anonymous 500.P1 — Stripe idempotency
createCreditCheckoutSessionnow derives a deterministicidempotencyKey=checkout-session:<userId>:<requestId>, mirroring the refund pattern (checkout-deletion:<session>:attempt:<n>).requestId(or omits it, in which case billing generates a fresh UUID) → distinct key → never collides with a prior purchase.create-credit-checkoutforwards an optionalx-idempotency-keyheader (already CORS-allowed).P1 — ledger scaling
credit_balance_cents()anddebit_credits()both computesum(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 indexcredit_ledger_user_id_amount_cents_idx (user_id) INCLUDE (amount_cents).Index Only Scan using credit_ledger_user_id_amount_cents_idx … Heap Fetches: 0.debit_creditsand with purchase/grant/refund inserts). The covering index is the safe additive minimum this policy allows.P2 — simplify
settle → finalize → deleteCustomersblock was not redundant — deleting a customer can terminalize a late refund/session, which the second pass cleans up (covered by therechecks and deletes a customer terminalized after the first frozen snapshotcontract test). Rather than delete it (which regresses that behaviour and touches Architecture: deepen platform lifecycle seams #37 territory), it's extracted intorunDeletionSettlementPassand called twice with an explanatory comment — same behaviour, no duplication.requiredEnv/createCallerSupabase/withCorsfrom the 5 functions into edge-shared as runtime-agnostic, dependency-injected helpers (createRequiredEnv(env),createCallerSupabaseFactory({createClient,…}),withCors(response)) — noDenoglobals, no@supabase/supabase-jsimport, 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.0and re-pinned the 5 functions (deno.json import maps for 4; inlinenpm:for stripe-webhook).bun install --frozen-lockfilepasses 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 isbun run test(vitest); nativebun testhas pre-existing failures unrelated to this PR (auth'svi.hoisted,brand's CSS import). My new billing/edge-shared tests pass under both runners.supabase db start+supabase test db --local✅ 140 tests pass including the newcredit_ledger_balance_covering_index.test.sql(ran a parallel instance on a non-default port to avoid disturbing an existing local DB).deno checkon every touched function (verified against the locally-built0.10.0dist 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,usagepossibly undefined) that are identical onorigin/mainand untouched by this PR.deno checkis not part of CI (bun run typecheckis); documenting rather than expanding scope into Architecture: deepen platform lifecycle seams #37 territory.Worktree
Implemented in a dedicated worktree at
/tmp/platform-money-path(branchfix/money-path-observabilityofforigin/main), left in place. Thefix/pi-kit-line-countworking tree was untouched.