fix(credits): atomic Stripe webhook fulfillment + idempotency + privilege guard#2
Open
kimhons wants to merge 106 commits into
Open
fix(credits): atomic Stripe webhook fulfillment + idempotency + privilege guard#2kimhons wants to merge 106 commits into
kimhons wants to merge 106 commits into
Conversation
Critical (3): - SEC C-1: pin axios>=1.15.0 via overrides (CVSS 10.0 SSRF chain) - SEC C-2: pin handlebars>=4.7.9 via overrides (CVSS 9.8 JS injection) - ARCH C-1: consolidate three Spinner implementations to one canonical in Loading.tsx; barrel re-exports from LoadingSkeleton High (15): - SEC H-1/H-2: tighten RLS on adverse_action_notices and consent_records to TO service_role; users keep SELECT-only on consent - SEC H-3: bump next to ^15.5.15 (resolves H-4 undici) - SEC H-5: replace document.write in BackupCodesManagement with DOM API - SEC H-6: pin lodash>=4.17.24 via overrides - CQ H1/H2/H3, SEC M-1/M-4, ARCH H2: rewrite GDPR deleteUserData as an atomic Postgres RPC (delete_user_data_cascade), nullable user_id anonymization, system-scoped audit log entries, error-checked auth user deletion, DbClient injection across GDPR/CCPA services - ARCH H3: convertToCSV / convertToXML throw NotImplementedError - CQ H4: remove function db(): any helper - ARCH H1: 4 email templates use shared EmailFooter (CAN-SPAM-compliant) - ARCH H4: 15 credit-builder loading.tsx use shared LoadingPage - ARCH H5: shared brand-colors.ts primitives; chart-colors.ts and email-colors.ts import from it Other: - Add minimal app/not-found.tsx so production build succeeds with NODE_ENV=production (build prereq for /verify proof-of-life) - Bump direct undici dep to ^7.24.0 - /verify breadcrumb at .claude/last-verification.json Verification: lint clean, types clean, build OK (438 routes, 538 kB shared JS), tests 13615/13634 passing, audit 0 critical / 0 high, proof-of-life GET / -> 200 in 62ms on :3100.
3-wave plan to generate the full Fynvita asset system using Google's Nano Banana (Gemini 2.5 Flash Image) as the generation engine, with existing brand guidelines as ground truth. - Pipeline: TOML spec -> @google/genai -> raster -> VTracer -> SVGO -> production registry -> per-platform destinations - Wave 1: brand mark, app icons (iOS/Android/web), splash, favicon/PWA - Wave 2: empty states (15), onboarding, success/celebration, 404 - Wave 3: hero, OG images, App Store + Play Store marketing - 4 user-review gates between waves - ~255 API calls, ~$20-30 total Also adds GEMINI_API_KEY placeholder to .env.local.example. Plan: docs/superpowers/plans/2026-04-16-fynvita-asset-system-regen.md
- Copy 7 approved PNGs from assets/raw/logo-system/ to assets/production/logo/ - Rename with standardized production naming (fynvita-horizontal, -vertical, -mark, etc.) - Create comprehensive README.md with usage guide, brand colors, and minimum sizes - Create MANIFEST.json with file metadata (dimensions, bytes, colors, usage) - Total: 8.7 MB of production logo assets ready for Wave 1.3 (app icons)
- Add assets/specs/splash-source.toml (Nano Banana Pro generation spec) - Add scripts/assets/derive-splash.ts (164 lines) using sharp - Produce assets/production/splash/splash-1284x2778.png + splash-1024x1024.png - Replace mobile-app/assets/splash.png (1284x2778, Vital Green bg + mark) - Replace mobile-app/assets/icon.png + adaptive-icon.png (1024x1024) - Replace mobile-app/assets/favicon.png (48x48) + notification-icon.png (96x96) - Update mobile-app/app.config.js: backgroundColor #3B82F6 → #10B981 (3 sites) - Add assets:derive-splash npm script to package.json
- Copy 7 logo PNGs to public/brand/ (horizontal, vertical, mark, mark-mono-navy, reversed, wordmark, app-icon) - Copy favicon.ico + apple-touch-icon.png to public root - Copy PWA icon set to public/brand/ (pwa-192/512, maskable variants, favicon-16/32/48) - Add public/manifest.webmanifest with corrected /brand/ icon paths - Add src/components/brand/registry.ts — central BrandAsset registry with precise aspect ratios from sharp metadata (horizontal: 2816/1536) - Add src/components/brand/BrandMark.tsx — Server Component compatible, height-driven sizing, decorative + priority props - Add src/components/brand/index.ts — barrel export - Add src/components/brand/__tests__/BrandMark.test.tsx — 14 passing
- src/components/ui/Header.tsx: replace F-box+text lockup in desktop nav and mobile menu panel with BrandMark (horizontal/36 + horizontal/32) - src/components/auth/LoginForm.tsx: replace F-box+text with BrandMark (vertical/80, priority) - src/components/auth/SignUpForm.tsx: replace F-box+text with BrandMark (vertical/80, priority) - src/components/auth/ResetPasswordForm.tsx: replace gradient text 'Fynvita' with BrandMark (vertical/80, priority) - src/app/layout.tsx: add title template, manifest link, and icons metadata (favicon-16/32/48, apple-touch-icon)
…, compliance - Add shared Button component with 4 variants, 4 sizes, loading state, focus rings, and TypeScript-enforced aria-label for icon buttons - Add 17 loading.tsx skeleton files for all route groups (100% coverage) - Extract chart colors to design tokens (chart-colors.ts) - Extract email colors to design tokens (email-colors.ts) - Add FCRA adverse action notice service with full test coverage - Add adverse action notices database migration Tests: 13,629 passed, 0 failures (20 new Button tests + 3 compliance tests)
Phase 1 — Animation Infrastructure:
- Animation variants library with fade, scale, slide, stagger presets
- FadeIn, StaggerList, ScrollReveal, AnimatedNumber components
- Page transition via template.tsx (fade+slideUp on route change)
- useReducedMotion hook for prefers-reduced-motion
Phase 2 — Micro-Interactions:
- AnimatedSwitch with spring-interpolated thumb
- AnimatedTabs with sliding layoutId underline
- InteractiveCard with hover lift and press feedback
- Button press feedback (whileTap scale 0.97)
- Toast spring physics enter/exit with AnimatePresence
Phase 3 — Trading Charts:
- Drawing tools engine (trendline, horizontal, fibonacci)
- DrawingToolbar and DrawingOverlay (SVG over lightweight-charts)
- Volume profile calculation and overlay component
- OrderBook ladder display with animated row updates
- DepthChart cumulative bid/ask visualization
- useRealtimeChart hook with 4/sec throttled updates
Phase 4 — Asset States:
- Confetti particles (40 framer-motion divs, auto-cleanup)
- CelebrationOverlay with self-drawing SVG checkmark
- useConfetti imperative hook
- AnimatedErrorState with 5 animated SVG variants
- ChartLoadingSkeleton with animated sine-wave
- 4 new EmptyState illustrations (investments, goals, alerts, getting-started)
Phase 5 — Page Polish:
- Dashboard, landing, credit, investments, trading, analytics,
notifications pages with ScrollReveal and StaggerList
- Onboarding step transitions via FadeIn key={pathname}
- DragReorder component using framer-motion Reorder API
Tests: 13,730 passed, 0 failures (+101 new tests)
Types: 0 errors | 45 files changed, 4,319 insertions
- Generate 8 PWA icon sizes (72-512px) from pwa-512.png source - Generate dashboard and dispute shortcut icons (96px) - Fix manifest.json: all /icons/ paths now resolve to real files - Add maskable icon entries from /brand/ to manifest.json - Align manifest.webmanifest with manifest.json Resolves: 13 missing asset references in web manifest
…backtest, chat, disputes
…ntegration - GET /api/credits/balance: returns credit balance and usage stats - GET /api/credits/history: paginated transaction history - POST /api/credits/purchase: creates Stripe PaymentIntent for credit packs - POST /api/addons/subscribe: subscribes to addon bundles via Stripe - POST /api/addons/cancel: cancels addon subscriptions - GET /api/addons/list: lists active addon subscriptions - credit-reset.ts: resets credits for tier + active addon credits - webhook: payment_intent.succeeded handles credit pack fulfillment - webhook: invoice.paid triggers monthly credit reset per tier
…, tests Web components: - CreditBalance: compact header + expanded settings display with progress bar - CreditPurchaseModal: 3 pack cards with purchase flow - LowCreditBanner: dismissible warning when credits < 20% - CreditUsageHistory: paginated transaction table with action icons - Settings credits page: full credit management dashboard Mobile: - credits.tsx settings screen with balance, purchase, history - creditBalanceStore.ts: Zustand store with fetchBalance, purchasePack, selectors - Updated store index and settings layout Pricing page: - Added 'X credits/mo' line with icon to each tier card Tests (41 passing): - credit-costs.test.ts: 19 cases covering packs, tiers, costs, estimates - credit-service.test.ts: 22 cases covering getBalance, deductCredits, addCredits, checkSufficientCredits, resetMonthlyAllowance, getTransactionHistory, getUsageThisPeriod
… enhanced typography - Add DeviceMockup component (LaptopFrame, PhoneFrame, DashboardMockup, MobileMockup) - Convert hero to 2-column grid with floating device composition on lg+ - Add 'See It In Action' product showcase section with web + mobile side-by-side - Increase hero heading to text-5xl/6xl/7xl responsive scale - Add float/float-slow keyframe animations to Tailwind config - Apply glassmorphism (backdrop-blur-sm, bg-white/60) to all badge labels - Add emerald glow hover effect (hover:shadow-emerald-500/25) to primary CTAs - Increase Product Grid section spacing from py-4 to py-24 - Increase Features heading from text-2xl to text-4xl/5xl - Add subtle gradient background overlay to hero section - All changes preserve existing dark mode, responsive design, and functionality
- Reduce hero h1 to text-3xl on mobile (was text-5xl, overflowed 375px viewport) - Fix build script: explicitly set NODE_ENV=production to resolve conflicting values warning that caused /404 prerender failure - Add .playwright-mcp/ to .gitignore
…st-name-only - Fix AI Technology stat cards: bg-white → bg-white/10 (white text was invisible on opaque white background) - Fix AI Capabilities cards: same bg-white → bg-white/10 fix - Fix mobile app section: bg-white → bg-white/15, add credit score card - Fix pricing cards: remove overflow-hidden, reduce xl font to text-2xl (prices were being clipped at card boundaries) - Update all 7 testimonials to first names only (Sarah, Michael, Emily, David, Lisa, James, Amanda)
…ders - Generate 3 photorealistic device mockups via Google Imagen 4.0: hero-devices.png (MacBook Pro + iPhone 15 Pro, 16:9), showcase-laptop.png (MacBook Pro dashboard, 16:9), mobile-phone.png (iPhone 15 Pro portrait, 9:16) - Replace hero CSS DeviceMockup components with hero-devices.png Image - Replace 'Beautiful on every device' section with showcase-laptop.png - Replace 'Fynvita in your pocket' CSS phone with mobile-phone.png - Remove unused DeviceMockup import (components kept for reference)
…endations Regenerated all 3 Imagen 4.0 mockups with detailed app previews: - Hero: Credit Score Trend area chart, ,250 net worth, ,840 savings, 23% debt ratio cards, AI Recommendations panel (dispute, credit limit, portfolio rebalance), mobile score ring + spending donut - Showcase: 6-Month Financial Health Trend chart, AI Agent Insights panel (3 recommendations), bureau scores with sparklines, spending donut - Mobile: 731 score ring, AI Recommendations (dispute 94%, savings +3%, rebalance +2.1%), spending category bars, tab navigation Users now see a realistic preview of account analysis and agentic AI features before signing up.
…cleaner text - Hero: all three key metrics now render correctly (731, $47,250, $1,840) with clean dark dashboard UI and green area chart - Mobile: 731 score with +18 indicator, clean recommendation cards with colored icons and badges, spending bars at bottom - Showcase: unchanged (already clean — 731, $47,250, $1,840, +12.4%) - Switched to Imagen 4.0 Ultra for sharper text and detail - Simplified prompts to focus on visual elements over body text
- Hero: full laptop + iPhone visible (no cropping), complete score ring, single smooth S-curve chart with green gradient fill - Showcase: full laptop visible, single ascending curve (replaces nonsensical overlapping multi-series chart), correct bureau scores 728/735/731 with donut chart - Both retain correct key numbers: 731, $47,250, $1,840, +12.4%
- iPhone screen now faces the viewer (was showing camera/back side) - Laptop sidebar branded 'Fynvita' (was hallucinating 'Spy Pro') - All key numbers retained: 731, $47,250, $1,840
Replace all 3 Imagen 4.0 PNG mockups with React component-based renders: DeviceMockup.tsx — complete rewrite: - LaptopFrame: perspective tilt, chin, glass glare, environmental shadow - PhoneFrame: iPhone 15 notch, side button, edge glare - LaptopScreenMockup: dark sidebar (Fynvita branding), KPI strip (Net Worth $47,250, Score 731, Savings $1,840), SVG chart (640→731), AI Recommendations (3 items), Spending vs Budget bars, bureau scores - MobileScreenMockup: gradient header, score ring 731, quick actions, AI insight card, upcoming bills, tab bar page.tsx: swap all 3 Image tags with component mockups. Delete public/mockups/*.png (AI-generated images). Zero typos. Perfect branding. Mathematically correct charts.
Phone was stretching to fit all content, making it unnaturally tall. Fixed by adding aspect-ratio: 9/19.5 on the screen container with overflow-hidden to clip content at the correct iPhone proportions.
Replace CSS component hero mockup with photorealistic composite showing MacBook Pro + iPhone on wooden desk with natural lighting. The device screens display the pixel-perfect Fynvita dashboard (credit score 731, $47,250 net worth, AI recommendations, spending chart). Showcase and mobile sections retain HTML/CSS component mockups.
…lege guard - New add_credits RPC: single transaction over credit_purchases insert, user_credits update, credit_transactions log. Idempotency check inside the function via stripe_payment_intent_id; duplicate webhook deliveries return already_fulfilled=true with no double-grant and no stranded row. - UNIQUE(stripe_payment_intent_id) NOT NULL on credit_purchases. - REVOKE EXECUTE on add_credits + deduct_credits FROM PUBLIC; GRANT to service_role only - closes PostgREST privilege-escalation vector. - credit_transactions INSERT policy scoped TO service_role. - Stripe webhook handler propagates errors so Stripe retries on transient failures; previously errors were silently swallowed and credits lost. - Switch credit-pack purchase from PaymentIntent to Checkout Session; modal redirects to Stripe-hosted checkout (the previous client-side PaymentIntent flow was never wired and rendered nothing on Buy). Closes 5 CRITICAL/HIGH issues from the 2026-05-01 team-review. Confidence: high Scope-risk: narrow Not-tested: live Stripe webhook end-to-end (covered at service layer)
Comprehensive 9-domain team review surfaced 33 CRITICAL + ~50 HIGH findings that the 13,585-test pass rate did not catch. Re-baselining canon docs to reflect actual security/compliance posture and add a Wave 7 plan to close the gaps before Wave 8+ work resumes. New: - docs/ssot/gap_analysis.md (FND-001..FND-071, 9-theme rollup) Updated: - SSOT.md (VERSION-013 banner, new section 19 Audit Findings) - MASTER-IMPLEMENTATION-PLAN.md (Wave 7: ~55 tasks across 7 phases) - health_metrics.md (per-domain scorecard, web flipped to RED) - version_history.md (VERSION-013 entry, re-baseline rationale) - build_order_blueprint.md (GATE-7 hard gate, Wave 7 sequencing) - system_blueprint.md (5-layer security intended-vs-actual) - traceability_matrix.md (audit overlay reopening 6 tasks) - CLAUDE.md (status banner, scorecard, Wave 7 references) Wave 7 phases: PRE (branch hygiene incl. TASK-PRE-06 for feat/asset-system-regen), AUTH (RBAC source of truth), WBH (Stripe webhook hardening incl. TASK-WBH-07 subscription tier backfill), MNY (money/idempotency), MOK (mocks-in-prod removal), CMP (compliance), MOB (mobile secrets), IDR (IDOR sweep). Pre-launch (no real users yet) means no GDPR Art. 33 / CCPA disclosure obligation triggered, but findings still block GA. Refs: team-review (security/architecture/code), team-planning synthesis
QA team review of VERSION-013 commit bc56ae8 found 5 plan-level CRITICALs. This commit closes them all without changing app code. Amendments: 1. Wave 7 Test-Class Requirements table (723 regression tests across 7 classes: negative-auth 568, idempotency 13, money-cents 13, DB-required 40, compliance corpus 47, mobile-prod-bundle 12, IDOR 30). Each phase exit gate now requires its named test class. 2. Map 9 unmapped CRITICALs: - FND-006/041..044/049..051 → explicit AUTH-03 sub-batches a-f - FND-031/032 → new TASK-INV-W7-01/02 (math + magic constants) 3. Reconcile HIGH count: '~50 HIGH' → '38 HIGH' across CLAUDE.md, SSOT.md, gap_analysis.md, health_metrics.md, traceability_matrix.md, build_order_blueprint.md. 4. TASK-PRE-07 — security-scoped re-review of 92 prior commits on feat/asset-system-regen (SEC sign-off per commit touching auth/payment/ commerce/middleware/migrations). 5. TASK-AUTH-04-staging — synthetic monitoring (webhooks + signup + login + OAuth) green for 24h before deny-by-default middleware ships to prod. 6. Strike 'DONE 125 100%' from CLAUDE.md §11, MASTER-IMPLEMENTATION-PLAN.md §11, health_metrics.md §8.5; replace with NEEDS_VERIFICATION + Wave 7 breakdown. Bonus fixes: - TASK-MOB-01..07 (Wave 7) renamed TASK-MOB-W7-01..07 to fix Wave 4 ID collision (Wave 4 same IDs are different tasks: Biometric, UX Polish, etc.) - Wave 7 task count corrected: '~55' → '59' (added 4 above) - Phase count corrected: '7' → '8' (Phase 0 through Phase 7) - Exit Criterion #1 rewritten as explicit FND list (not the false range 'FND-001..FND-068 critical-tagged') - AUTH-03 split into a-f sub-batches so it doesn't deadlock downstream phases - IDR-02..05 may begin against manual list without waiting on IDR-01 Refs: /team-qa verdict (plan critic FAIL, FND spot-check 8/8 confirmed, test-coverage analyst recommended 723-test regression spec)
Re-ran lint, type-check, tests, build, and npm audit on feat/asset-system-regen @ 2877317. Five gates regressed since VERSION-013; the prior commit message claim '14,568/14,587 baseline still valid' was incorrect. Re-baseline results (VERSION-014): - Tests: 35 fail / 14,533 pass / 19 skip / 14,587 total across 2 PCTT suites (pctt-trading-service.test.ts, pctt-mode-integration.test.ts) - Types: 0 errors (clean) - Build: PASS, 560 kB shared first-load JS, 294 API routes / 204 pages - Lint: 15 errors, 2,858 warnings (was 7 / 841 — REGRESSION) - npm audit (all): 14 vulns (1 high, 11 mod, 2 low) - npm audit (prod): 9 moderate (was 0 — REGRESSION) - nodemailer SMTP injection via next-auth (GHSA-vvjj-xcjg-gr5g) - postcss XSS via next (GHSA-qx2v-qp2m-jg93) - uuid via svix→resend (GHSA-w5hq-g745-h8pq) Files: - docs/ssot/health_metrics.md: VERSION-014 banner; §1–§5 re-baselined; regression log appended - .claude/last-verification.json: verdict FAIL with per-gate detail and explicit invalid-prior-claim entries Validated by critic agent (APPROVE WITH NOTES). One factual correction applied: 35 failures span 2 PCTT suites, not 1. Ship: BLOCKED until Wave 7 closes AND PCTT regression + production npm audit chain are resolved. Closes acceptance §1 of TASK-PRE-01 (npm test/tsc/build/audit re-run + commit). Remaining acceptance items deferred: - gap_analysis.md regeneration (adds new findings — QA review) - CI badge wiring for machine-generated health_metrics.md (DEVOPS) - SSOT.md banner update (separate edit, not in scope of this run)
The mode switch in POST /api/disputes/generate returned handler promises without awaiting them, so a rejection from the AI or strategy handler escaped the route's try/catch as an unhandled rejection instead of a 500 response. Await each switch arm so the outer catch handles failures. Adds integration coverage for the route: auth, credit, validation, all three modes, and the error path (route 100% lines / 87% branches). Confidence: high Scope-risk: narrow
Adds 13 unit/integration suites (~308 tests) for previously untested or thinly tested domains surfaced by a coverage audit: webauthn auth routes, payment webhook + billing, dispute service, credit-builder, credit-builder-loan, rent-reporting, goal services, spending analyzer, and the connector registry. Also loosens CreditBuilderService.analyzeUtilization to accept Omit<CardUtilization, "status">[] — the method computes status itself, so requiring it as input was a latent type error. Confidence: high Scope-risk: narrow
Adds scripts/check-changed-coverage.js — a line-level diff coverage gate enforcing >=85% on added/modified executable lines (not whole files, so one-line edits to legacy files are not penalized). Wired as `npm run test:coverage:changed` and documented in a new rule .claude/rules/04-coverage.md. Fixes .claude/hooks/post-edit-lint.sh: it ran bare `npx eslint`, which crashes on this project's legacy .eslintrc under ESLint 9. Next.js projects now lint via `next lint --file`, matching how `npm run lint` works. Confidence: high Scope-risk: narrow
Approved design for sequencing Wave 7 remediation into a bounded MVP via workflow vertical slices, with two milestones (closed beta → public launch). Defines MVP scope (Auth, Payments, Investments, Financial, Credit, Mobile, Ancillary), deferral discipline for Trading/Commerce/white-label (preserved, flag-gated, Wave 8), the foundation block, per-vertical CRITICAL/HIGH mapping, and gate criteria. Confidence: high Scope-risk: narrow
Fixes 4 blocking + 8 should-fix issues from spec review: - C1: Appendix B uses the master plan's explicit 32-item CRITICAL list; documents the SSOT 32-vs-33 off-by-one for TASK-PRE-01 - C2: identifies orphaned HIGHs (FND-035/039/040/045) with no Wave 7 task; mandates task creation - C3: removes invented TASK-TRD-W7-00; PCTT failures are an M1 blocker, not Wave 8 - C4: deferred-code compile-safety becomes an owned obligation - adds Money Correctness track (Phase 3) — FND-024-027 are mandatory CRITICALs even though Commerce workflow is deferred Confidence: high Scope-risk: narrow
Bite-sized TDD plan for the Wave 7 prerequisites and auth/RBAC rebuild — the unblocking foundation for all MVP verticals. Covers PRE-01..07 and AUTH-01..12 (incl. AUTH-03 sub-batches a-f and AUTH-04-staging), grounded in a map of the current auth code. Closes 13 CRITICAL + 7 HIGH findings. Confidence: high Scope-risk: narrow
kimhons
added a commit
that referenced
this pull request
May 17, 2026
The admin/auth GET handler ran an independent cookie-token auth
(sb-access-token -> supabase.auth.getUser) and re-derived role from
profiles AFTER the withRole("admin") guard had already verified identity
and resolved the role from the trusted profiles table. That double-auth
is exactly the inline auth AUTH-03a Step A removes.
Collapse the handler to use the guard's AuthedUser param directly:
return { isAdmin: role is admin/super_admin, user }. Drops the cookies()
read, the inner getUser call, and the profile re-query.
Adds positive-path coverage (authenticated admin -> { isAdmin: true,
user }) which the old cookies()-dependent handler left untested, plus a
super_admin case. Rewires the two admin/auth test suites off the removed
supabase/next-headers machinery.
Addresses review MEDIUM #2 and NIT #5.
Constraint: behavior-preserving except removal of the redundant auth pass
Confidence: high
Scope-risk: narrow
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.
Summary
Resolves 5 CRITICAL/HIGH findings from the 2026-05-01 team-review of the credit fulfillment path.
What changed
add_creditsRPC (supabase/migrations/20260501000000_credit_purchase_idempotency.sql): single Postgres transaction over thecredit_purchasesinsert,user_creditsUPDATE, andcredit_transactionslog. Idempotency check inside the function viastripe_payment_intent_id— duplicate Stripe webhook deliveries returnalready_fulfilled=truewith no double-grant and no stranded sentinel row.REVOKE EXECUTE ... FROM PUBLIC; GRANT EXECUTE ... TO service_roleon bothadd_creditsanddeduct_credits. Previously, any authenticated PostgREST caller could credit/debit any user.UNIQUE NOT NULLoncredit_purchases.stripe_payment_intent_id: hard backstop for the idempotency check; backfill DELETE for any pre-existing NULLs.credit_transactionsINSERT policy scopedTO service_role(was open to all authenticated clients viaWITH CHECK (true)).stripe-service.ts:handlePaymentIntentSucceedednow logs then rethrows so Stripe retries on transient failures (was silently swallowing → permanent credit loss).fulfillCreditPurchasecollapses to a single RPC call.credit-service.ts:addCreditsrewritten to call the atomic RPC; returns{newBalance, alreadyFulfilled}. New optionalfulfillmentparam wires the Stripe metadata into the same transaction.api/credits/purchase/route.ts: switched fromPaymentIntenttoCheckout Session(mode='payment') and returnscheckoutUrl.CreditPurchaseModal.tsx: removed deaddata.newBalance/onPurchaseCompletelogic; now redirects to Stripe-hosted Checkout. The previous PaymentIntent flow was never wired (modal read fields the route never returned).Issues closed (all from team-review #d64e8d5 ancestor)
amount_cents→amount_paid_cents, dropped phantomstatus)addCreditsnon-atomic read-modify-writeCreditPurchaseModalreads fields route never returns → unwiredcredit_transactionsINSERT RLS missing role scopeauthenticatedTest plan
tsc --noEmitexit 0next buildexit 0 (539 kB first load)alreadyFulfilled=trueshort-circuit, non-array RPC response, non-positive amount guard, RPC error propagation, empty-result guard./.Out-of-scope follow-ups (do NOT block merge)
stripe-service.ts:644handleInvoicePaidswallows errors on the subscription-renewal credit-reset path. Same pattern as the bug fixed here for credit purchases. Pre-existing; not in original team-review scope.stripe-service.ts:685handleInvoicePaymentFailedmissing log on catch.add_creditsidempotencySELECTruns beforeFOR UPDATElock. UNIQUE constraint is the hard backstop; a true concurrent race surfaces as one extra Stripe retry rather than silent skip. Could tighten withINSERT ... ON CONFLICT DO NOTHING RETURNING id.resetMonthlyAllowancewrites tocredit_transactionsvia the admin client, relying on caller-side privilege rather than schema. Architectural note.Breaking changes
creditService.addCreditsreturn type changed fromPromise<number>toPromise<{ newBalance: number; alreadyFulfilled: boolean }>. Sole caller (stripe-service.ts) updated in this PR.CreditPurchaseModalonPurchaseCompleteprop removed (unused; the modal now always redirects to Stripe-hosted Checkout).