From 4e910704bcf74e054474ec81232da57be445c94a Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Sat, 23 May 2026 22:55:06 +0100 Subject: [PATCH] fix: billing idempotency atomic, logo in navbar, dark bg on app pages, Home btn, resend-verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - billing: atomic upsert-based idempotency (fixes TOCTOU + crash-between-ops race) - billing: payment_id uses `or` to handle explicit null payment_intent - Header: logo h-[44px] contained within navbar frame, remove md overflow - Header: Home button scrolls to top when already on / - Header: useMemo limelightItems to prevent useLayoutEffect thrash on scroll - Header: remove dead scrolled ternary (py-2 : py-2) - Hero: remove md:pt-[176px] gap (logo no longer overflows) - LimelightNav: clearTimeout cleanup, remove items from effect deps - SyntheticUsers/FocusGroups: bg-slate-50 → bg-background (dark theme fix) - api.ts + Dashboard: resendVerification passes user email (fixes 400 error) Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routes/billing.py | 37 +++++++++++++++++--------- src/components/dashboard/Dashboard.tsx | 2 +- src/components/landing/Hero.tsx | 2 +- src/components/layout/Header.tsx | 26 +++++++++++------- src/components/ui/LimelightNav.tsx | 7 +++-- src/lib/api.ts | 4 +-- src/pages/FocusGroups.tsx | 2 +- src/pages/SyntheticUsers.tsx | 2 +- 8 files changed, 53 insertions(+), 29 deletions(-) diff --git a/backend/app/routes/billing.py b/backend/app/routes/billing.py index a64e3a9f..86772fab 100644 --- a/backend/app/routes/billing.py +++ b/backend/app/routes/billing.py @@ -112,27 +112,40 @@ async def stripe_webhook(): user_id = meta.get("user_id") credits = int(meta.get("credits", 0)) pack_id = meta.get("pack_id", "") - payment_id = session.get("payment_intent", session.get("id", "")) + # `or` so explicit null payment_intent falls through to session id + payment_id = session.get("payment_intent") or session.get("id", "") - if not user_id or credits <= 0: + if not user_id or credits <= 0 or not payment_id: return jsonify({"status": "ok"}), 200 - # Idempotency — skip if this payment_intent was already processed + from datetime import datetime, timezone from app.db import get_db as _get_db db = await _get_db() - already = await db.credit_transactions.find_one({"ref.stripe_payment_id": payment_id}) - if already: + + # Atomic idempotency: insert the ledger slot first so concurrent Stripe + # retries both see the record — only the request that inserts it proceeds + slot = await db.credit_transactions.update_one( + {"ref.stripe_payment_id": payment_id}, + {"$setOnInsert": { + "user_id": user_id, + "type": "purchase", + "amount": credits, + "balance_after": None, + "description": f"Purchased {pack_id} pack ({credits} credits)", + "ref": {"stripe_payment_id": payment_id}, + "ts": datetime.now(timezone.utc), + }}, + upsert=True + ) + if slot.upserted_id is None: logger.info("Duplicate webhook for payment %s — skipping", payment_id) return jsonify({"status": "ok"}), 200 + # Slot claimed — grant credits and record final balance balance = await User.grant_credits(user_id, credits) - await CreditTransaction.record( - user_id=user_id, - tx_type="purchase", - amount=credits, - balance_after=balance, - description=f"Purchased {pack_id} pack ({credits} credits)", - ref={"stripe_payment_id": payment_id}, + await db.credit_transactions.update_one( + {"_id": slot.upserted_id}, + {"$set": {"balance_after": balance}} ) logger.info("Granted %d credits to user %s via Stripe payment %s", credits, user_id, payment_id) diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx index 4464fa6e..d84b9945 100755 --- a/src/components/dashboard/Dashboard.tsx +++ b/src/components/dashboard/Dashboard.tsx @@ -212,7 +212,7 @@ const Dashboard = () => { const handleResendEmail = async () => { setResendingEmail(true); try { - await authApi.resendVerification(); + await authApi.resendVerification((user as any)?.email); toastService.success('Verification email sent', { description: 'Check your inbox' }); } catch { toastService.error('Failed to send email'); diff --git a/src/components/landing/Hero.tsx b/src/components/landing/Hero.tsx index a7a17fd5..82874b24 100644 --- a/src/components/landing/Hero.tsx +++ b/src/components/landing/Hero.tsx @@ -119,7 +119,7 @@ export default function Hero() { const shouldAnimate = !shouldReduce; return ( -
+
{/* Background glow */}
{ if (external) return; - if (!anchor) { navigate(to); return; } + if (!anchor) { + if (to === '/' && location.pathname === '/') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + navigate(to); + } + return; + } if (location.pathname === '/') { document.getElementById(anchor)?.scrollIntoView({ behavior: 'smooth' }); } else { @@ -58,7 +65,8 @@ export default function Header() { } }; - const limelightItems = navLinks.map(({ label, to, anchor, external }, i) => ({ + // Memoise icons (static JSX) to prevent LimelightNav useLayoutEffect thrash on scroll + const limelightItems = useMemo(() => navLinks.map(({ label, to, anchor, external }) => ({ id: label, label, onClick: external ? () => { window.location.href = to; } : () => handleNavClick(anchor, to), @@ -67,13 +75,13 @@ export default function Header() { {label} ), - })); + // eslint-disable-next-line react-hooks/exhaustive-deps + })), [location.pathname]); return (
{!shouldReduce && ( @@ -92,13 +100,13 @@ export default function Header() { : 'bg-transparent' )} > - {/* Left: Logo banner */} - + {/* Left: Logo banner — contained within the 64px navbar frame */} + Cohorta diff --git a/src/components/ui/LimelightNav.tsx b/src/components/ui/LimelightNav.tsx index e022189a..846230be 100644 --- a/src/components/ui/LimelightNav.tsx +++ b/src/components/ui/LimelightNav.tsx @@ -41,9 +41,12 @@ export function LimelightNav({ if (limelight && activeItem) { const newLeft = activeItem.offsetLeft + activeItem.offsetWidth / 2 - limelight.offsetWidth / 2; limelight.style.left = `${newLeft}px`; - if (!isReady) setTimeout(() => setIsReady(true), 50); + if (!isReady) { + const id = setTimeout(() => setIsReady(true), 50); + return () => clearTimeout(id); + } } - }, [current, isReady, items]); + }, [current, isReady]); if (items.length === 0) return null; diff --git a/src/lib/api.ts b/src/lib/api.ts index c888eac5..ccb8b2e9 100755 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -126,8 +126,8 @@ export const authApi = { getProfile: () => api.get('/auth/me'), - resendVerification: () => - api.post('/auth/resend-verification'), + resendVerification: (email?: string) => + api.post('/auth/resend-verification', email ? { email } : {}), }; // Billing endpoints diff --git a/src/pages/FocusGroups.tsx b/src/pages/FocusGroups.tsx index 198fef31..9890fd78 100755 --- a/src/pages/FocusGroups.tsx +++ b/src/pages/FocusGroups.tsx @@ -284,7 +284,7 @@ const FocusGroups = () => { }; return ( -
+
diff --git a/src/pages/SyntheticUsers.tsx b/src/pages/SyntheticUsers.tsx index 7071c27f..0e356e4f 100755 --- a/src/pages/SyntheticUsers.tsx +++ b/src/pages/SyntheticUsers.tsx @@ -1150,7 +1150,7 @@ const SyntheticUsers = () => { // Removed separate download function - now using direct file response return ( -
+