From 93e2a196ddac11bad4ae91f1dfc3ff7cfe14ac96 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Thu, 9 Jul 2026 14:48:00 -0400 Subject: [PATCH 1/6] One time paid report support. --- app/api/report-access/route.ts | 106 +++++++++ .../stripe/create-report-checkout/route.ts | 112 ++++++++++ app/components/HealthspanReportModal.tsx | 27 ++- app/components/OverviewReportModal.tsx | 40 +++- app/components/TopTraitsReportModal.tsx | 27 ++- app/overview-report/page.tsx | 202 +++++++++++++++--- lib/report-access.ts | 15 ++ 7 files changed, 488 insertions(+), 41 deletions(-) create mode 100644 app/api/report-access/route.ts create mode 100644 app/api/stripe/create-report-checkout/route.ts create mode 100644 lib/report-access.ts diff --git a/app/api/report-access/route.ts b/app/api/report-access/route.ts new file mode 100644 index 0000000..8706dd1 --- /dev/null +++ b/app/api/report-access/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { isPaidReportType, PAID_REPORT_TYPES, PaidReportType } from '@/lib/report-access'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { + apiVersion: '2025-02-24.acacia', +}); + +function validateWalletAddress(walletAddress: unknown): string | null { + if (typeof walletAddress !== 'string') return null; + if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) return null; + return walletAddress.toLowerCase(); +} + +function stripeSearchValue(value: string) { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + +async function findUnusedReportPayments(walletAddress: string, reportType?: PaidReportType) { + const clauses = [ + `metadata['walletAddress']:'${stripeSearchValue(walletAddress)}'`, + `metadata['purpose']:'report_one_time'`, + `status:'succeeded'`, + ]; + + if (reportType) { + clauses.push(`metadata['reportType']:'${stripeSearchValue(reportType)}'`); + } + + const result = await stripe.paymentIntents.search({ + query: clauses.join(' AND '), + limit: 100, + }); + + return result.data.filter(payment => !payment.metadata.consumedAt); +} + +export async function POST(request: NextRequest) { + try { + const { walletAddress, reportType, action = 'check' } = await request.json(); + const normalizedWallet = validateWalletAddress(walletAddress); + + if (!normalizedWallet) { + return NextResponse.json({ error: 'Invalid wallet address format' }, { status: 400 }); + } + + if (!process.env.STRIPE_SECRET_KEY) { + return NextResponse.json({ error: 'Stripe is not configured' }, { status: 500 }); + } + + if (action === 'check') { + const payments = await findUnusedReportPayments(normalizedWallet); + const availableReports = Object.fromEntries( + PAID_REPORT_TYPES.map(type => [ + type, + payments.filter(payment => payment.metadata.reportType === type).length, + ]) + ); + + return NextResponse.json({ + success: true, + availableReports, + }); + } + + if (action === 'consume') { + if (!isPaidReportType(reportType)) { + return NextResponse.json({ error: 'Invalid report type' }, { status: 400 }); + } + + const payments = await findUnusedReportPayments(normalizedWallet, reportType); + const paymentToConsume = payments.sort((a, b) => a.created - b.created)[0]; + + if (!paymentToConsume) { + return NextResponse.json( + { success: false, error: 'No unused report pass found' }, + { status: 402 } + ); + } + + await stripe.paymentIntents.update(paymentToConsume.id, { + metadata: { + ...paymentToConsume.metadata, + consumedAt: new Date().toISOString(), + }, + }); + + return NextResponse.json({ + success: true, + consumedPaymentIntentId: paymentToConsume.id, + }); + } + + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } catch (error: any) { + console.error('Report access check failed:', error); + + return NextResponse.json( + { + error: 'Failed to check report access', + details: error.message || 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/api/stripe/create-report-checkout/route.ts b/app/api/stripe/create-report-checkout/route.ts new file mode 100644 index 0000000..428b556 --- /dev/null +++ b/app/api/stripe/create-report-checkout/route.ts @@ -0,0 +1,112 @@ +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import { + isPaidReportType, + ONE_TIME_REPORT_PRICE_CENTS, + PAID_REPORT_LABELS, +} from '@/lib/report-access'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { + apiVersion: '2025-02-24.acacia', +}); + +async function getOrCreateCustomer(walletAddress: string) { + const normalizedWallet = walletAddress.toLowerCase(); + const existingCustomers = await stripe.customers.search({ + query: `metadata['walletAddress']:'${normalizedWallet}'`, + limit: 1, + }); + + if (existingCustomers.data[0]) { + return existingCustomers.data[0]; + } + + return stripe.customers.create({ + metadata: { + walletAddress: normalizedWallet, + }, + }); +} + +export async function POST(request: NextRequest) { + try { + const { walletAddress, reportType } = await request.json(); + + if (!walletAddress) { + return NextResponse.json({ error: 'Wallet address required' }, { status: 400 }); + } + + if (!/^0x[a-fA-F0-9]{40}$/.test(walletAddress)) { + return NextResponse.json({ error: 'Invalid wallet address format' }, { status: 400 }); + } + + if (!isPaidReportType(reportType)) { + return NextResponse.json({ error: 'Invalid report type' }, { status: 400 }); + } + + if (!process.env.STRIPE_SECRET_KEY) { + return NextResponse.json({ error: 'Stripe is not configured' }, { status: 500 }); + } + + const requestOrigin = request.headers.get('origin'); + const forwardedProto = request.headers.get('x-forwarded-proto') || 'http'; + const forwardedHost = request.headers.get('x-forwarded-host') || request.headers.get('host'); + const origin = + requestOrigin || + process.env.NEXT_PUBLIC_APP_URL || + (forwardedHost ? `${forwardedProto}://${forwardedHost}` : 'http://localhost:3001'); + + const normalizedWallet = walletAddress.toLowerCase(); + const customer = await getOrCreateCustomer(walletAddress); + const reportLabel = PAID_REPORT_LABELS[reportType]; + + const metadata = { + walletAddress: normalizedWallet, + reportType, + purpose: 'report_one_time', + }; + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + mode: 'payment', + customer: customer.id, + line_items: [ + { + price_data: { + currency: 'usd', + unit_amount: ONE_TIME_REPORT_PRICE_CENTS, + product_data: { + name: `${reportLabel} run`, + description: 'One-time access to generate this premium report once.', + }, + }, + quantity: 1, + }, + ], + success_url: `${origin}/overview-report?report_purchase=success&report_type=${reportType}&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${origin}/overview-report?report_purchase=cancelled&report_type=${reportType}`, + metadata, + payment_intent_data: { + metadata, + }, + }); + + console.log(`[Stripe] Created one-time report checkout session: ${session.id} for ${reportType}`); + + return NextResponse.json({ + success: true, + sessionId: session.id, + checkoutUrl: session.url, + }); + } catch (error: any) { + console.error('Stripe report checkout creation error:', error); + + return NextResponse.json( + { + error: 'Failed to create report checkout session', + details: error.message || 'Unknown error', + }, + { status: 500 } + ); + } +} diff --git a/app/components/HealthspanReportModal.tsx b/app/components/HealthspanReportModal.tsx index 6c0a10d..7c68da9 100644 --- a/app/components/HealthspanReportModal.tsx +++ b/app/components/HealthspanReportModal.tsx @@ -15,9 +15,16 @@ type Phase = 'idle' | 'generating' | 'complete' | 'error'; interface HealthspanReportModalProps { isOpen: boolean; onClose: () => void; + hasPremiumAccess?: boolean; + onConsumeOneTimeAccess?: () => Promise; } -export default function HealthspanReportModal({ isOpen, onClose }: HealthspanReportModalProps) { +export default function HealthspanReportModal({ + isOpen, + onClose, + hasPremiumAccess = false, + onConsumeOneTimeAccess, +}: HealthspanReportModalProps) { const router = useRouter(); const { savedResults } = useResults(); const { customization } = useCustomization(); @@ -46,6 +53,24 @@ export default function HealthspanReportModal({ isOpen, onClose }: HealthspanRep const handleGenerate = async () => { if (inFlightRef.current) return; inFlightRef.current = true; + + if (!hasPremiumAccess && onConsumeOneTimeAccess) { + try { + const consumed = await onConsumeOneTimeAccess(); + if (!consumed) { + setError('No paid report run is available for this wallet.'); + setPhase('error'); + inFlightRef.current = false; + return; + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not use paid report run.'); + setPhase('error'); + inFlightRef.current = false; + return; + } + } + setPhase('generating'); setMessage('Organizing results by healthspan domain…'); setProgress(10); diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx index 25f424d..aeb686a 100644 --- a/app/components/OverviewReportModal.tsx +++ b/app/components/OverviewReportModal.tsx @@ -27,9 +27,16 @@ interface ProgressState { interface OverviewReportModalProps { isOpen: boolean; onClose: () => void; + hasOneTimeAccess?: boolean; + onConsumeOneTimeAccess?: () => Promise; } -export default function OverviewReportModal({ isOpen, onClose }: OverviewReportModalProps) { +export default function OverviewReportModal({ + isOpen, + onClose, + hasOneTimeAccess = false, + onConsumeOneTimeAccess, +}: OverviewReportModalProps) { const { savedResults } = useResults(); const { customization } = useCustomization(); const { hasActiveSubscription } = useAuth(); @@ -53,6 +60,31 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM return; } generationInFlightRef.current = true; + + const hasPromoAccess = hasValidPromoAccess(); + if (!hasActiveSubscription && !hasPromoAccess && onConsumeOneTimeAccess) { + try { + const consumed = await onConsumeOneTimeAccess(); + if (!consumed) { + setProgress(prev => ({ + ...prev, + phase: 'error', + error: 'No paid report run is available for this wallet.', + })); + generationInFlightRef.current = false; + return; + } + } catch (error) { + setProgress(prev => ({ + ...prev, + phase: 'error', + error: error instanceof Error ? error.message : 'Could not use paid report run.', + })); + generationInFlightRef.current = false; + return; + } + } + setIsGenerating(true); const start = Date.now(); setStartTime(start); @@ -526,7 +558,7 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM // Check subscription const hasPromoAccess = hasValidPromoAccess(); - const isBlocked = !hasActiveSubscription && !hasPromoAccess; + const isBlocked = !hasActiveSubscription && !hasPromoAccess && !hasOneTimeAccess; return (
@@ -560,10 +592,10 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM }}>

đź”’ Premium Feature

- Overview Report requires an active premium subscription. + Overview Report requires a premium subscription or a one-time report run.

- Try free for 7 days, then $4.99/month to unlock comprehensive analysis of all your genetic results. + Try free for 7 days, then $4.99/month, or run this report once for $4.99 from the Analyze page.

) : progress.phase === 'idle' ? ( diff --git a/app/components/TopTraitsReportModal.tsx b/app/components/TopTraitsReportModal.tsx index 1629ab8..cb57400 100644 --- a/app/components/TopTraitsReportModal.tsx +++ b/app/components/TopTraitsReportModal.tsx @@ -14,9 +14,16 @@ type Phase = 'idle' | 'generating' | 'complete' | 'error'; interface TopTraitsReportModalProps { isOpen: boolean; onClose: () => void; + hasPremiumAccess?: boolean; + onConsumeOneTimeAccess?: () => Promise; } -export default function TopTraitsReportModal({ isOpen, onClose }: TopTraitsReportModalProps) { +export default function TopTraitsReportModal({ + isOpen, + onClose, + hasPremiumAccess = false, + onConsumeOneTimeAccess, +}: TopTraitsReportModalProps) { const router = useRouter(); const { savedResults } = useResults(); const { customization } = useCustomization(); @@ -45,6 +52,24 @@ export default function TopTraitsReportModal({ isOpen, onClose }: TopTraitsRepor const handleGenerate = async () => { if (inFlightRef.current) return; inFlightRef.current = true; + + if (!hasPremiumAccess && onConsumeOneTimeAccess) { + try { + const consumed = await onConsumeOneTimeAccess(); + if (!consumed) { + setError('No paid report run is available for this wallet.'); + setPhase('error'); + inFlightRef.current = false; + return; + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not use paid report run.'); + setPhase('error'); + inFlightRef.current = false; + return; + } + } + setPhase('generating'); setMessage('Selecting top 50 signals by effect size…'); setProgress(10); diff --git a/app/overview-report/page.tsx b/app/overview-report/page.tsx index 337650a..07f4fa1 100644 --- a/app/overview-report/page.tsx +++ b/app/overview-report/page.tsx @@ -17,17 +17,31 @@ import { hasValidPromoAccess } from "@/lib/promo-access"; import GuidedTour from "../components/GuidedTour"; import { overviewReportTour } from "../components/tours/tourContent"; import { trackOverviewReportViewed } from "@/lib/analytics"; +import { PAID_REPORT_LABELS, PaidReportType } from "@/lib/report-access"; + +type ReportAccessCounts = Record; + +const emptyReportAccess: ReportAccessCounts = { + healthspan: 0, + top_traits: 0, + overview: 0, +}; export default function OverviewReportPage() { const router = useRouter(); const { savedResults } = useResults(); - const { isAuthenticated, hasActiveSubscription, openAuthModal } = useAuth(); + const { isAuthenticated, user, hasActiveSubscription, openAuthModal } = useAuth(); const [showOverviewReportModal, setShowOverviewReportModal] = useState(false); const [showHealthReportModal, setShowHealthReportModal] = useState(false); const [showHealthspanReportModal, setShowHealthspanReportModal] = useState(false); const [showTopTraitsReportModal, setShowTopTraitsReportModal] = useState(false); const [hasPromoAccess, setHasPromoAccess] = useState(false); + const [reportAccess, setReportAccess] = useState(emptyReportAccess); + const [checkingReportAccess, setCheckingReportAccess] = useState(false); + const [purchaseMessage, setPurchaseMessage] = useState(null); + const [purchasingReportType, setPurchasingReportType] = useState(null); const [tourOpen, setTourOpen] = useState(false); + const walletAddress = user?.verifiedCredentials?.find((c: any) => c.address)?.address; useEffect(() => { const refreshPromoAccess = () => { @@ -46,20 +60,115 @@ export default function OverviewReportPage() { }); }, [savedResults.length]); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const purchaseState = params.get('report_purchase'); + const reportType = params.get('report_type'); + + if (purchaseState === 'success' && reportType && reportType in PAID_REPORT_LABELS) { + setPurchaseMessage(`${PAID_REPORT_LABELS[reportType as PaidReportType]} is unlocked for one run.`); + } else if (purchaseState === 'cancelled') { + setPurchaseMessage('Report payment was cancelled. No charge was made.'); + } + }, []); + + useEffect(() => { + const refreshReportAccess = async () => { + if (!walletAddress) { + setReportAccess(emptyReportAccess); + return; + } + + setCheckingReportAccess(true); + try { + const response = await fetch('/api/report-access', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ walletAddress, action: 'check' }), + }); + const data = await response.json(); + if (response.ok && data.success) { + setReportAccess({ + ...emptyReportAccess, + ...data.availableReports, + }); + } + } catch (error) { + console.error('Failed to check report access:', error); + } finally { + setCheckingReportAccess(false); + } + }; + + refreshReportAccess(); + }, [walletAddress, purchaseMessage]); const hasPremiumAccess = hasActiveSubscription || hasPromoAccess; const hasResults = savedResults.length > 0; - const requirePremium = () => { - if (!hasPremiumAccess && !hasValidPromoAccess()) { + const hasReportAccess = (reportType: PaidReportType) => + hasPremiumAccess || hasValidPromoAccess() || reportAccess[reportType] > 0; + + const requireReportAccess = (reportType: PaidReportType) => { + if (!hasReportAccess(reportType)) { router.push('/subscribe'); return false; } return true; }; + const handleBuyReportRun = async (reportType: PaidReportType) => { + if (!isAuthenticated || !walletAddress) { + openAuthModal(); + return; + } + + setPurchasingReportType(reportType); + try { + const response = await fetch('/api/stripe/create-report-checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ walletAddress, reportType }), + }); + const data = await response.json(); + if (!response.ok || !data.checkoutUrl) { + throw new Error(data.error || 'Could not start checkout'); + } + window.location.href = data.checkoutUrl; + } catch (error) { + console.error('Failed to create report checkout:', error); + alert(error instanceof Error ? error.message : 'Could not start checkout'); + setPurchasingReportType(null); + } + }; + + const consumeReportPass = async (reportType: PaidReportType) => { + if (hasPremiumAccess || hasValidPromoAccess()) return true; + + if (!walletAddress || reportAccess[reportType] <= 0) { + return false; + } + + const response = await fetch('/api/report-access', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ walletAddress, reportType, action: 'consume' }), + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Could not use report pass'); + } + + setReportAccess(prev => ({ + ...prev, + [reportType]: Math.max(0, prev[reportType] - 1), + })); + + return true; + }; + const handleGenerateReport = () => { - if (!hasResults || !requirePremium()) return; + if (!hasResults || !requireReportAccess('overview')) return; setShowOverviewReportModal(true); }; @@ -69,30 +178,71 @@ export default function OverviewReportPage() { }; const handleGenerateHealthspanReport = () => { - if (!hasResults || !requirePremium()) return; + if (!hasResults || !requireReportAccess('healthspan')) return; setShowHealthspanReportModal(true); }; const handleGenerateTopTraitsReport = () => { - if (!hasResults || !requirePremium()) return; + if (!hasResults || !requireReportAccess('top_traits')) return; setShowTopTraitsReportModal(true); }; + const renderPaidReportActions = (reportType: PaidReportType, onGenerate: () => void, generateLabel: string) => { + const hasPass = reportAccess[reportType] > 0; + + return ( +
+ + {!hasPremiumAccess && !hasPass && ( + + )} + {!hasPremiumAccess && hasPass && ( +

+ One paid run available +

+ )} +
+ ); + }; + return (
{null} + {purchaseMessage && ( +
+ {purchaseMessage} +
+ )} +
@@ -147,15 +297,7 @@ export default function OverviewReportPage() { Organizes your associations by healthspan domain: cardiovascular, metabolic, neurological, immune, musculoskeletal, and cancer susceptibility. Synthesizes patterns within and across domains.

-
- -
+ {renderPaidReportActions('healthspan', handleGenerateHealthspanReport, 'Generate Healthspan Report')}
{/* Top Traits Report */} @@ -173,15 +315,7 @@ export default function OverviewReportPage() { Takes your 100 strongest genetic associations by effect size and synthesizes what they reveal about your biology. Good starting point if you have not added health history yet.

-
- -
+ {renderPaidReportActions('top_traits', handleGenerateTopTraitsReport, 'Generate Top Traits Report')} {/* Comprehensive Overview Report (experimental, at bottom) */} @@ -199,15 +333,7 @@ export default function OverviewReportPage() { Analyzes all your saved genetic results across categories: health, lifestyle, appearance, personality, and more. Works best after running broad analysis. Currently under development.

-
- -
+ {renderPaidReportActions('overview', handleGenerateReport, 'Generate Overview Report')} @@ -215,6 +341,8 @@ export default function OverviewReportPage() { setShowOverviewReportModal(false)} + hasOneTimeAccess={reportAccess.overview > 0} + onConsumeOneTimeAccess={() => consumeReportPass('overview')} /> setShowHealthspanReportModal(false)} + onConsumeOneTimeAccess={() => consumeReportPass('healthspan')} + hasPremiumAccess={hasPremiumAccess} /> setShowTopTraitsReportModal(false)} + onConsumeOneTimeAccess={() => consumeReportPass('top_traits')} + hasPremiumAccess={hasPremiumAccess} /> setTourOpen(false)} /> diff --git a/lib/report-access.ts b/lib/report-access.ts new file mode 100644 index 0000000..7a312a5 --- /dev/null +++ b/lib/report-access.ts @@ -0,0 +1,15 @@ +export const ONE_TIME_REPORT_PRICE_CENTS = 499; + +export const PAID_REPORT_TYPES = ['healthspan', 'top_traits', 'overview'] as const; + +export type PaidReportType = typeof PAID_REPORT_TYPES[number]; + +export const PAID_REPORT_LABELS: Record = { + healthspan: 'Healthspan Report', + top_traits: 'Top Traits Report', + overview: 'Comprehensive Overview Report', +}; + +export function isPaidReportType(value: unknown): value is PaidReportType { + return typeof value === 'string' && (PAID_REPORT_TYPES as readonly string[]).includes(value); +} From 48adad69f7dfebab296f31e12e9072e4f177023f Mon Sep 17 00:00:00 2001 From: Vishakh Date: Thu, 9 Jul 2026 15:19:56 -0400 Subject: [PATCH 2/6] Bug fixes. --- app/api/report-access/route.ts | 136 ++++++++++++++++++++--- app/components/HealthspanReportModal.tsx | 8 ++ app/components/OverviewReportModal.tsx | 25 +++-- app/components/TopTraitsReportModal.tsx | 8 ++ app/overview-report/page.tsx | 81 ++++++++++++-- lib/dynamic-auth.ts | 69 ++++++++++++ package-lock.json | 10 ++ package.json | 1 + 8 files changed, 309 insertions(+), 29 deletions(-) create mode 100644 lib/dynamic-auth.ts diff --git a/app/api/report-access/route.ts b/app/api/report-access/route.ts index 8706dd1..ad233fb 100644 --- a/app/api/report-access/route.ts +++ b/app/api/report-access/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; +import { randomUUID } from 'crypto'; import Stripe from 'stripe'; import { isPaidReportType, PAID_REPORT_TYPES, PaidReportType } from '@/lib/report-access'; +import { verifyWalletAuth } from '@/lib/dynamic-auth'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_test_placeholder', { apiVersion: '2025-02-24.acacia', @@ -16,6 +18,14 @@ function stripeSearchValue(value: string) { return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } +function isReportPayment(payment: Stripe.PaymentIntent, walletAddress: string) { + return ( + payment.status === 'succeeded' && + payment.metadata.purpose === 'report_one_time' && + payment.metadata.walletAddress === walletAddress + ); +} + async function findUnusedReportPayments(walletAddress: string, reportType?: PaidReportType) { const clauses = [ `metadata['walletAddress']:'${stripeSearchValue(walletAddress)}'`, @@ -35,9 +45,83 @@ async function findUnusedReportPayments(walletAddress: string, reportType?: Paid return result.data.filter(payment => !payment.metadata.consumedAt); } +// Stripe search is eventually consistent and can lag behind a checkout that +// just completed. When the client passes the checkout session id from the +// success redirect, resolve the payment intent directly so the new pass is +// visible immediately. +async function findPaymentFromCheckoutSession(sessionId: string, walletAddress: string) { + try { + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['payment_intent'], + }); + + if (session.payment_status !== 'paid') return null; + + const payment = session.payment_intent; + if (!payment || typeof payment === 'string') return null; + if (!isReportPayment(payment, walletAddress)) return null; + if (payment.metadata.consumedAt) return null; + + return payment; + } catch (error) { + console.error('Failed to resolve checkout session:', error); + return null; + } +} + +async function collectUnusedPayments( + walletAddress: string, + sessionId: string | undefined, + reportType?: PaidReportType +) { + const payments = await findUnusedReportPayments(walletAddress, reportType); + + if (typeof sessionId === 'string' && sessionId.startsWith('cs_')) { + const sessionPayment = await findPaymentFromCheckoutSession(sessionId, walletAddress); + if ( + sessionPayment && + (!reportType || sessionPayment.metadata.reportType === reportType) && + !payments.some(payment => payment.id === sessionPayment.id) + ) { + payments.push(sessionPayment); + } + } + + return payments; +} + +// Consumes one pass with an optimistic concurrency guard. Stripe metadata +// updates are last-writer-wins, so after stamping the payment intent we read +// it back and only treat the consumption as ours when our token survived. +async function consumeOnePayment(payments: Stripe.PaymentIntent[]) { + const candidates = [...payments].sort((a, b) => a.created - b.created); + + for (const candidate of candidates) { + const fresh = await stripe.paymentIntents.retrieve(candidate.id); + if (fresh.metadata.consumedAt) continue; + + const consumeToken = randomUUID(); + await stripe.paymentIntents.update(candidate.id, { + metadata: { + ...fresh.metadata, + consumedAt: new Date().toISOString(), + consumeToken, + }, + }); + + const verified = await stripe.paymentIntents.retrieve(candidate.id); + if (verified.metadata.consumeToken === consumeToken) { + return candidate.id; + } + } + + return null; +} + export async function POST(request: NextRequest) { try { - const { walletAddress, reportType, action = 'check' } = await request.json(); + const { walletAddress, reportType, action = 'check', sessionId, paymentIntentId } = + await request.json(); const normalizedWallet = validateWalletAddress(walletAddress); if (!normalizedWallet) { @@ -49,7 +133,7 @@ export async function POST(request: NextRequest) { } if (action === 'check') { - const payments = await findUnusedReportPayments(normalizedWallet); + const payments = await collectUnusedPayments(normalizedWallet, sessionId); const availableReports = Object.fromEntries( PAID_REPORT_TYPES.map(type => [ type, @@ -63,34 +147,60 @@ export async function POST(request: NextRequest) { }); } + // Consuming and releasing passes mutate paid state, so they require proof + // that the caller owns the wallet. + if (action === 'consume' || action === 'release') { + const auth = await verifyWalletAuth(request.headers.get('authorization'), normalizedWallet); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: auth.status || 401 }); + } + } + if (action === 'consume') { if (!isPaidReportType(reportType)) { return NextResponse.json({ error: 'Invalid report type' }, { status: 400 }); } - const payments = await findUnusedReportPayments(normalizedWallet, reportType); - const paymentToConsume = payments.sort((a, b) => a.created - b.created)[0]; + const payments = await collectUnusedPayments(normalizedWallet, sessionId, reportType); + const consumedPaymentIntentId = await consumeOnePayment(payments); - if (!paymentToConsume) { + if (!consumedPaymentIntentId) { return NextResponse.json( { success: false, error: 'No unused report pass found' }, { status: 402 } ); } - await stripe.paymentIntents.update(paymentToConsume.id, { - metadata: { - ...paymentToConsume.metadata, - consumedAt: new Date().toISOString(), - }, - }); - return NextResponse.json({ success: true, - consumedPaymentIntentId: paymentToConsume.id, + consumedPaymentIntentId, }); } + // Returns a pass to the wallet when report generation failed after the + // pass was consumed. + if (action === 'release') { + if (typeof paymentIntentId !== 'string' || !paymentIntentId.startsWith('pi_')) { + return NextResponse.json({ error: 'Invalid payment intent id' }, { status: 400 }); + } + + const payment = await stripe.paymentIntents.retrieve(paymentIntentId); + if (!isReportPayment(payment, normalizedWallet)) { + return NextResponse.json({ error: 'Payment not found for this wallet' }, { status: 404 }); + } + + if (payment.metadata.consumedAt) { + await stripe.paymentIntents.update(payment.id, { + metadata: { + consumedAt: '', + consumeToken: '', + }, + }); + } + + return NextResponse.json({ success: true }); + } + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); } catch (error: any) { console.error('Report access check failed:', error); diff --git a/app/components/HealthspanReportModal.tsx b/app/components/HealthspanReportModal.tsx index 7c68da9..6e806b6 100644 --- a/app/components/HealthspanReportModal.tsx +++ b/app/components/HealthspanReportModal.tsx @@ -17,6 +17,7 @@ interface HealthspanReportModalProps { onClose: () => void; hasPremiumAccess?: boolean; onConsumeOneTimeAccess?: () => Promise; + onReleaseOneTimeAccess?: () => Promise; } export default function HealthspanReportModal({ @@ -24,6 +25,7 @@ export default function HealthspanReportModal({ onClose, hasPremiumAccess = false, onConsumeOneTimeAccess, + onReleaseOneTimeAccess, }: HealthspanReportModalProps) { const router = useRouter(); const { savedResults } = useResults(); @@ -54,6 +56,7 @@ export default function HealthspanReportModal({ if (inFlightRef.current) return; inFlightRef.current = true; + let consumedPass = false; if (!hasPremiumAccess && onConsumeOneTimeAccess) { try { const consumed = await onConsumeOneTimeAccess(); @@ -69,6 +72,7 @@ export default function HealthspanReportModal({ inFlightRef.current = false; return; } + consumedPass = true; } setPhase('generating'); @@ -90,6 +94,10 @@ export default function HealthspanReportModal({ const domainCount = Object.values(res.domainCounts).filter(c => c.elevated + c.protective >= 2).length; trackHealthspanReportGenerated(res.selected.length, domainCount); } catch (err) { + // Return the pass so a failed generation does not burn a paid run. + if (consumedPass && onReleaseOneTimeAccess) { + onReleaseOneTimeAccess().catch(() => {}); + } const msg = err instanceof Error ? err.message : 'Generation failed.'; setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg); setPhase('error'); diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx index aeb686a..d8e4d00 100644 --- a/app/components/OverviewReportModal.tsx +++ b/app/components/OverviewReportModal.tsx @@ -3,11 +3,9 @@ import { useRef, useState } from "react"; import { useResults } from "./ResultsContext"; import { useCustomization } from "./CustomizationContext"; -import { useAuth } from "./AuthProvider"; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { trackOverviewReportGenerated } from "@/lib/analytics"; -import { hasValidPromoAccess } from "@/lib/promo-access"; type GenerationPhase = 'idle' | 'map' | 'reduce' | 'complete' | 'error'; @@ -27,19 +25,22 @@ interface ProgressState { interface OverviewReportModalProps { isOpen: boolean; onClose: () => void; + hasPremiumAccess?: boolean; hasOneTimeAccess?: boolean; onConsumeOneTimeAccess?: () => Promise; + onReleaseOneTimeAccess?: () => Promise; } export default function OverviewReportModal({ isOpen, onClose, + hasPremiumAccess = false, hasOneTimeAccess = false, onConsumeOneTimeAccess, + onReleaseOneTimeAccess, }: OverviewReportModalProps) { const { savedResults } = useResults(); const { customization } = useCustomization(); - const { hasActiveSubscription } = useAuth(); const [isGenerating, setIsGenerating] = useState(false); const generationInFlightRef = useRef(false); @@ -61,8 +62,16 @@ export default function OverviewReportModal({ } generationInFlightRef.current = true; - const hasPromoAccess = hasValidPromoAccess(); - if (!hasActiveSubscription && !hasPromoAccess && onConsumeOneTimeAccess) { + let consumedPass = false; + // Return the pass so a failed generation does not burn a paid run. + const releaseConsumedPass = () => { + if (consumedPass && onReleaseOneTimeAccess) { + consumedPass = false; + onReleaseOneTimeAccess().catch(() => {}); + } + }; + + if (!hasPremiumAccess && onConsumeOneTimeAccess) { try { const consumed = await onConsumeOneTimeAccess(); if (!consumed) { @@ -83,6 +92,7 @@ export default function OverviewReportModal({ generationInFlightRef.current = false; return; } + consumedPass = true; } setIsGenerating(true); @@ -136,6 +146,7 @@ export default function OverviewReportModal({ clearInterval(timerInterval); generationInFlightRef.current = false; setIsGenerating(false); + releaseConsumedPass(); } } ); @@ -151,6 +162,7 @@ export default function OverviewReportModal({ })); generationInFlightRef.current = false; setIsGenerating(false); + releaseConsumedPass(); } }; @@ -557,8 +569,7 @@ export default function OverviewReportModal({ if (!isOpen) return null; // Check subscription - const hasPromoAccess = hasValidPromoAccess(); - const isBlocked = !hasActiveSubscription && !hasPromoAccess && !hasOneTimeAccess; + const isBlocked = !hasPremiumAccess && !hasOneTimeAccess; return (
diff --git a/app/components/TopTraitsReportModal.tsx b/app/components/TopTraitsReportModal.tsx index cb57400..e385164 100644 --- a/app/components/TopTraitsReportModal.tsx +++ b/app/components/TopTraitsReportModal.tsx @@ -16,6 +16,7 @@ interface TopTraitsReportModalProps { onClose: () => void; hasPremiumAccess?: boolean; onConsumeOneTimeAccess?: () => Promise; + onReleaseOneTimeAccess?: () => Promise; } export default function TopTraitsReportModal({ @@ -23,6 +24,7 @@ export default function TopTraitsReportModal({ onClose, hasPremiumAccess = false, onConsumeOneTimeAccess, + onReleaseOneTimeAccess, }: TopTraitsReportModalProps) { const router = useRouter(); const { savedResults } = useResults(); @@ -53,6 +55,7 @@ export default function TopTraitsReportModal({ if (inFlightRef.current) return; inFlightRef.current = true; + let consumedPass = false; if (!hasPremiumAccess && onConsumeOneTimeAccess) { try { const consumed = await onConsumeOneTimeAccess(); @@ -68,6 +71,7 @@ export default function TopTraitsReportModal({ inFlightRef.current = false; return; } + consumedPass = true; } setPhase('generating'); @@ -88,6 +92,10 @@ export default function TopTraitsReportModal({ setPhase('complete'); trackTopTraitsReportGenerated(res.selected.length); } catch (err) { + // Return the pass so a failed generation does not burn a paid run. + if (consumedPass && onReleaseOneTimeAccess) { + onReleaseOneTimeAccess().catch(() => {}); + } const msg = err instanceof Error ? err.message : 'Generation failed.'; setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg); setPhase('error'); diff --git a/app/overview-report/page.tsx b/app/overview-report/page.tsx index 07f4fa1..4d0b34b 100644 --- a/app/overview-report/page.tsx +++ b/app/overview-report/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import MenuBar from "../components/MenuBar"; import Footer from "../components/Footer"; @@ -12,6 +12,7 @@ import HealthspanReportModal from "../components/HealthspanReportModal"; import TopTraitsReportModal from "../components/TopTraitsReportModal"; import { OverviewReportIcon } from "../components/Icons"; import { useAuth } from "../components/AuthProvider"; +import { getAuthToken } from "@dynamic-labs/sdk-react-core"; import { useResults } from "../components/ResultsContext"; import { hasValidPromoAccess } from "@/lib/promo-access"; import GuidedTour from "../components/GuidedTour"; @@ -39,7 +40,9 @@ export default function OverviewReportPage() { const [reportAccess, setReportAccess] = useState(emptyReportAccess); const [checkingReportAccess, setCheckingReportAccess] = useState(false); const [purchaseMessage, setPurchaseMessage] = useState(null); + const [checkoutSessionId, setCheckoutSessionId] = useState(null); const [purchasingReportType, setPurchasingReportType] = useState(null); + const consumedPassRef = useRef>>({}); const [tourOpen, setTourOpen] = useState(false); const walletAddress = user?.verifiedCredentials?.find((c: any) => c.address)?.address; @@ -64,13 +67,22 @@ export default function OverviewReportPage() { const params = new URLSearchParams(window.location.search); const purchaseState = params.get('report_purchase'); const reportType = params.get('report_type'); + const sessionId = params.get('session_id'); + + if (!purchaseState) return; if (purchaseState === 'success' && reportType && reportType in PAID_REPORT_LABELS) { setPurchaseMessage(`${PAID_REPORT_LABELS[reportType as PaidReportType]} is unlocked for one run.`); + if (sessionId) { + setCheckoutSessionId(sessionId); + } } else if (purchaseState === 'cancelled') { setPurchaseMessage('Report payment was cancelled. No charge was made.'); } - }, []); + + // Remove the purchase params so a refresh does not replay the banner. + router.replace(window.location.pathname, { scroll: false }); + }, [router]); useEffect(() => { const refreshReportAccess = async () => { @@ -84,7 +96,11 @@ export default function OverviewReportPage() { const response = await fetch('/api/report-access', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ walletAddress, action: 'check' }), + body: JSON.stringify({ + walletAddress, + action: 'check', + sessionId: checkoutSessionId || undefined, + }), }); const data = await response.json(); if (response.ok && data.success) { @@ -101,7 +117,7 @@ export default function OverviewReportPage() { }; refreshReportAccess(); - }, [walletAddress, purchaseMessage]); + }, [walletAddress, purchaseMessage, checkoutSessionId]); const hasPremiumAccess = hasActiveSubscription || hasPromoAccess; const hasResults = savedResults.length > 0; @@ -149,16 +165,30 @@ export default function OverviewReportPage() { return false; } + const authToken = getAuthToken(); + if (!authToken) { + throw new Error('Please sign in again to use your report pass.'); + } + const response = await fetch('/api/report-access', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ walletAddress, reportType, action: 'consume' }), + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ + walletAddress, + reportType, + action: 'consume', + sessionId: checkoutSessionId || undefined, + }), }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error(data.error || 'Could not use report pass'); } + consumedPassRef.current[reportType] = data.consumedPaymentIntentId; setReportAccess(prev => ({ ...prev, [reportType]: Math.max(0, prev[reportType] - 1), @@ -167,6 +197,35 @@ export default function OverviewReportPage() { return true; }; + const releaseReportPass = async (reportType: PaidReportType) => { + const paymentIntentId = consumedPassRef.current[reportType]; + if (!paymentIntentId || !walletAddress) return; + + try { + const authToken = getAuthToken(); + if (!authToken) return; + + const response = await fetch('/api/report-access', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ walletAddress, paymentIntentId, action: 'release' }), + }); + const data = await response.json(); + if (response.ok && data.success) { + delete consumedPassRef.current[reportType]; + setReportAccess(prev => ({ + ...prev, + [reportType]: prev[reportType] + 1, + })); + } + } catch (error) { + console.error('Failed to release report pass:', error); + } + }; + const handleGenerateReport = () => { if (!hasResults || !requireReportAccess('overview')) return; setShowOverviewReportModal(true); @@ -195,7 +254,7 @@ export default function OverviewReportPage() {
diff --git a/lib/dynamic-auth.ts b/lib/dynamic-auth.ts new file mode 100644 index 0000000..7deb6e3 --- /dev/null +++ b/lib/dynamic-auth.ts @@ -0,0 +1,69 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +// Server-side verification of Dynamic auth tokens. +// Dynamic signs a JWT for each logged-in user; the public keys are published +// at the environment's JWKS endpoint. + +const jwksCache = new Map>(); + +function getJwks(environmentId: string) { + let jwks = jwksCache.get(environmentId); + if (!jwks) { + jwks = createRemoteJWKSet( + new URL(`https://app.dynamic.xyz/api/v0/sdk/${environmentId}/.well-known/jwks`) + ); + jwksCache.set(environmentId, jwks); + } + return jwks; +} + +export interface WalletAuthResult { + ok: boolean; + error?: string; + status?: number; +} + +/** + * Verifies that the request carries a valid Dynamic auth token whose + * verified credentials include the given wallet address. + */ +export async function verifyWalletAuth( + authorizationHeader: string | null, + walletAddress: string +): Promise { + const environmentId = process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID; + if (!environmentId) { + return { ok: false, error: 'Authentication is not configured', status: 500 }; + } + + const token = authorizationHeader?.startsWith('Bearer ') + ? authorizationHeader.slice('Bearer '.length).trim() + : null; + + if (!token) { + return { ok: false, error: 'Authentication required', status: 401 }; + } + + try { + const { payload } = await jwtVerify(token, getJwks(environmentId)); + + const credentials = (payload as any).verified_credentials; + const normalizedWallet = walletAddress.toLowerCase(); + const ownsWallet = + Array.isArray(credentials) && + credentials.some( + (credential: any) => + typeof credential?.address === 'string' && + credential.address.toLowerCase() === normalizedWallet + ); + + if (!ownsWallet) { + return { ok: false, error: 'Wallet does not belong to the authenticated user', status: 403 }; + } + + return { ok: true }; + } catch (error) { + console.error('Dynamic token verification failed:', error); + return { ok: false, error: 'Invalid or expired authentication token', status: 401 }; + } +} diff --git a/package-lock.json b/package-lock.json index c6ea815..6cc505b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "crypto-browserify": "^3.12.1", "crypto-js": "^4.2.0", "ethers": "^6.13.4", + "jose": "^6.2.3", "next": "^16.2.1", "openai": "^6.7.0", "pako": "^2.1.0", @@ -13576,6 +13577,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", diff --git a/package.json b/package.json index dff3aad..01f6222 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "crypto-browserify": "^3.12.1", "crypto-js": "^4.2.0", "ethers": "^6.13.4", + "jose": "^6.2.3", "next": "^16.2.1", "openai": "^6.7.0", "pako": "^2.1.0", From 03954af7ba0b331035ea262df002c8786342ae2e Mon Sep 17 00:00:00 2001 From: Vishakh Date: Thu, 9 Jul 2026 15:31:41 -0400 Subject: [PATCH 3/6] Bug fixes. --- app/api/report-access/route.ts | 32 +++---------------- app/components/HealthspanReportModal.tsx | 8 ----- app/components/OverviewReportModal.tsx | 14 --------- app/components/TopTraitsReportModal.tsx | 8 ----- app/overview-report/page.tsx | 36 +--------------------- lib/dynamic-auth.ts | 39 +++++++++++++++++++----- 6 files changed, 36 insertions(+), 101 deletions(-) diff --git a/app/api/report-access/route.ts b/app/api/report-access/route.ts index ad233fb..398524c 100644 --- a/app/api/report-access/route.ts +++ b/app/api/report-access/route.ts @@ -120,7 +120,7 @@ async function consumeOnePayment(payments: Stripe.PaymentIntent[]) { export async function POST(request: NextRequest) { try { - const { walletAddress, reportType, action = 'check', sessionId, paymentIntentId } = + const { walletAddress, reportType, action = 'check', sessionId } = await request.json(); const normalizedWallet = validateWalletAddress(walletAddress); @@ -147,9 +147,9 @@ export async function POST(request: NextRequest) { }); } - // Consuming and releasing passes mutate paid state, so they require proof - // that the caller owns the wallet. - if (action === 'consume' || action === 'release') { + // Consuming passes mutates paid state, so it requires proof that the + // caller owns the wallet. + if (action === 'consume') { const auth = await verifyWalletAuth(request.headers.get('authorization'), normalizedWallet); if (!auth.ok) { return NextResponse.json({ error: auth.error }, { status: auth.status || 401 }); @@ -177,30 +177,6 @@ export async function POST(request: NextRequest) { }); } - // Returns a pass to the wallet when report generation failed after the - // pass was consumed. - if (action === 'release') { - if (typeof paymentIntentId !== 'string' || !paymentIntentId.startsWith('pi_')) { - return NextResponse.json({ error: 'Invalid payment intent id' }, { status: 400 }); - } - - const payment = await stripe.paymentIntents.retrieve(paymentIntentId); - if (!isReportPayment(payment, normalizedWallet)) { - return NextResponse.json({ error: 'Payment not found for this wallet' }, { status: 404 }); - } - - if (payment.metadata.consumedAt) { - await stripe.paymentIntents.update(payment.id, { - metadata: { - consumedAt: '', - consumeToken: '', - }, - }); - } - - return NextResponse.json({ success: true }); - } - return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); } catch (error: any) { console.error('Report access check failed:', error); diff --git a/app/components/HealthspanReportModal.tsx b/app/components/HealthspanReportModal.tsx index 6e806b6..7c68da9 100644 --- a/app/components/HealthspanReportModal.tsx +++ b/app/components/HealthspanReportModal.tsx @@ -17,7 +17,6 @@ interface HealthspanReportModalProps { onClose: () => void; hasPremiumAccess?: boolean; onConsumeOneTimeAccess?: () => Promise; - onReleaseOneTimeAccess?: () => Promise; } export default function HealthspanReportModal({ @@ -25,7 +24,6 @@ export default function HealthspanReportModal({ onClose, hasPremiumAccess = false, onConsumeOneTimeAccess, - onReleaseOneTimeAccess, }: HealthspanReportModalProps) { const router = useRouter(); const { savedResults } = useResults(); @@ -56,7 +54,6 @@ export default function HealthspanReportModal({ if (inFlightRef.current) return; inFlightRef.current = true; - let consumedPass = false; if (!hasPremiumAccess && onConsumeOneTimeAccess) { try { const consumed = await onConsumeOneTimeAccess(); @@ -72,7 +69,6 @@ export default function HealthspanReportModal({ inFlightRef.current = false; return; } - consumedPass = true; } setPhase('generating'); @@ -94,10 +90,6 @@ export default function HealthspanReportModal({ const domainCount = Object.values(res.domainCounts).filter(c => c.elevated + c.protective >= 2).length; trackHealthspanReportGenerated(res.selected.length, domainCount); } catch (err) { - // Return the pass so a failed generation does not burn a paid run. - if (consumedPass && onReleaseOneTimeAccess) { - onReleaseOneTimeAccess().catch(() => {}); - } const msg = err instanceof Error ? err.message : 'Generation failed.'; setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg); setPhase('error'); diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx index d8e4d00..9b8d22f 100644 --- a/app/components/OverviewReportModal.tsx +++ b/app/components/OverviewReportModal.tsx @@ -28,7 +28,6 @@ interface OverviewReportModalProps { hasPremiumAccess?: boolean; hasOneTimeAccess?: boolean; onConsumeOneTimeAccess?: () => Promise; - onReleaseOneTimeAccess?: () => Promise; } export default function OverviewReportModal({ @@ -37,7 +36,6 @@ export default function OverviewReportModal({ hasPremiumAccess = false, hasOneTimeAccess = false, onConsumeOneTimeAccess, - onReleaseOneTimeAccess, }: OverviewReportModalProps) { const { savedResults } = useResults(); const { customization } = useCustomization(); @@ -62,15 +60,6 @@ export default function OverviewReportModal({ } generationInFlightRef.current = true; - let consumedPass = false; - // Return the pass so a failed generation does not burn a paid run. - const releaseConsumedPass = () => { - if (consumedPass && onReleaseOneTimeAccess) { - consumedPass = false; - onReleaseOneTimeAccess().catch(() => {}); - } - }; - if (!hasPremiumAccess && onConsumeOneTimeAccess) { try { const consumed = await onConsumeOneTimeAccess(); @@ -92,7 +81,6 @@ export default function OverviewReportModal({ generationInFlightRef.current = false; return; } - consumedPass = true; } setIsGenerating(true); @@ -146,7 +134,6 @@ export default function OverviewReportModal({ clearInterval(timerInterval); generationInFlightRef.current = false; setIsGenerating(false); - releaseConsumedPass(); } } ); @@ -162,7 +149,6 @@ export default function OverviewReportModal({ })); generationInFlightRef.current = false; setIsGenerating(false); - releaseConsumedPass(); } }; diff --git a/app/components/TopTraitsReportModal.tsx b/app/components/TopTraitsReportModal.tsx index e385164..cb57400 100644 --- a/app/components/TopTraitsReportModal.tsx +++ b/app/components/TopTraitsReportModal.tsx @@ -16,7 +16,6 @@ interface TopTraitsReportModalProps { onClose: () => void; hasPremiumAccess?: boolean; onConsumeOneTimeAccess?: () => Promise; - onReleaseOneTimeAccess?: () => Promise; } export default function TopTraitsReportModal({ @@ -24,7 +23,6 @@ export default function TopTraitsReportModal({ onClose, hasPremiumAccess = false, onConsumeOneTimeAccess, - onReleaseOneTimeAccess, }: TopTraitsReportModalProps) { const router = useRouter(); const { savedResults } = useResults(); @@ -55,7 +53,6 @@ export default function TopTraitsReportModal({ if (inFlightRef.current) return; inFlightRef.current = true; - let consumedPass = false; if (!hasPremiumAccess && onConsumeOneTimeAccess) { try { const consumed = await onConsumeOneTimeAccess(); @@ -71,7 +68,6 @@ export default function TopTraitsReportModal({ inFlightRef.current = false; return; } - consumedPass = true; } setPhase('generating'); @@ -92,10 +88,6 @@ export default function TopTraitsReportModal({ setPhase('complete'); trackTopTraitsReportGenerated(res.selected.length); } catch (err) { - // Return the pass so a failed generation does not burn a paid run. - if (consumedPass && onReleaseOneTimeAccess) { - onReleaseOneTimeAccess().catch(() => {}); - } const msg = err instanceof Error ? err.message : 'Generation failed.'; setError(msg.includes('429') ? 'nilAI is rate-limited. The service retried automatically but is still overloaded. Wait 30-60 seconds and try again.' : msg); setPhase('error'); diff --git a/app/overview-report/page.tsx b/app/overview-report/page.tsx index 4d0b34b..ab33b85 100644 --- a/app/overview-report/page.tsx +++ b/app/overview-report/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import MenuBar from "../components/MenuBar"; import Footer from "../components/Footer"; @@ -42,7 +42,6 @@ export default function OverviewReportPage() { const [purchaseMessage, setPurchaseMessage] = useState(null); const [checkoutSessionId, setCheckoutSessionId] = useState(null); const [purchasingReportType, setPurchasingReportType] = useState(null); - const consumedPassRef = useRef>>({}); const [tourOpen, setTourOpen] = useState(false); const walletAddress = user?.verifiedCredentials?.find((c: any) => c.address)?.address; @@ -188,7 +187,6 @@ export default function OverviewReportPage() { throw new Error(data.error || 'Could not use report pass'); } - consumedPassRef.current[reportType] = data.consumedPaymentIntentId; setReportAccess(prev => ({ ...prev, [reportType]: Math.max(0, prev[reportType] - 1), @@ -197,35 +195,6 @@ export default function OverviewReportPage() { return true; }; - const releaseReportPass = async (reportType: PaidReportType) => { - const paymentIntentId = consumedPassRef.current[reportType]; - if (!paymentIntentId || !walletAddress) return; - - try { - const authToken = getAuthToken(); - if (!authToken) return; - - const response = await fetch('/api/report-access', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${authToken}`, - }, - body: JSON.stringify({ walletAddress, paymentIntentId, action: 'release' }), - }); - const data = await response.json(); - if (response.ok && data.success) { - delete consumedPassRef.current[reportType]; - setReportAccess(prev => ({ - ...prev, - [reportType]: prev[reportType] + 1, - })); - } - } catch (error) { - console.error('Failed to release report pass:', error); - } - }; - const handleGenerateReport = () => { if (!hasResults || !requireReportAccess('overview')) return; setShowOverviewReportModal(true); @@ -403,7 +372,6 @@ export default function OverviewReportPage() { hasPremiumAccess={hasPremiumAccess} hasOneTimeAccess={reportAccess.overview > 0} onConsumeOneTimeAccess={() => consumeReportPass('overview')} - onReleaseOneTimeAccess={() => releaseReportPass('overview')} /> setShowHealthspanReportModal(false)} hasPremiumAccess={hasPremiumAccess} onConsumeOneTimeAccess={() => consumeReportPass('healthspan')} - onReleaseOneTimeAccess={() => releaseReportPass('healthspan')} /> setShowTopTraitsReportModal(false)} hasPremiumAccess={hasPremiumAccess} onConsumeOneTimeAccess={() => consumeReportPass('top_traits')} - onReleaseOneTimeAccess={() => releaseReportPass('top_traits')} /> setTourOpen(false)} /> diff --git a/lib/dynamic-auth.ts b/lib/dynamic-auth.ts index 7deb6e3..641575c 100644 --- a/lib/dynamic-auth.ts +++ b/lib/dynamic-auth.ts @@ -23,6 +23,36 @@ export interface WalletAuthResult { status?: number; } +function credentialOwnsWallet(credential: any, normalizedWallet: string) { + return ( + typeof credential?.address === 'string' && + credential.address.toLowerCase() === normalizedWallet + ); +} + +function payloadOwnsWallet(payload: any, normalizedWallet: string) { + const credentialLists = [ + payload.verifiedCredentials, + payload.verified_credentials, + payload.blockchainAccounts, + payload.blockchain_accounts, + ]; + + if (credentialOwnsWallet(payload.verifiedAccount, normalizedWallet)) { + return true; + } + + if (credentialOwnsWallet(payload.verified_account, normalizedWallet)) { + return true; + } + + return credentialLists.some( + credentials => + Array.isArray(credentials) && + credentials.some((credential: any) => credentialOwnsWallet(credential, normalizedWallet)) + ); +} + /** * Verifies that the request carries a valid Dynamic auth token whose * verified credentials include the given wallet address. @@ -47,15 +77,8 @@ export async function verifyWalletAuth( try { const { payload } = await jwtVerify(token, getJwks(environmentId)); - const credentials = (payload as any).verified_credentials; const normalizedWallet = walletAddress.toLowerCase(); - const ownsWallet = - Array.isArray(credentials) && - credentials.some( - (credential: any) => - typeof credential?.address === 'string' && - credential.address.toLowerCase() === normalizedWallet - ); + const ownsWallet = payloadOwnsWallet(payload, normalizedWallet); if (!ownsWallet) { return { ok: false, error: 'Wallet does not belong to the authenticated user', status: 403 }; From 99d7bb60984f05dfedfce9a971cd67f97e0ca6f1 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Thu, 9 Jul 2026 15:43:26 -0400 Subject: [PATCH 4/6] Removed seven day trial. --- app/api/stripe/create-subscription/route.ts | 9 ++++----- app/components/OverviewReportModal.tsx | 2 +- app/components/PaymentModal.tsx | 2 +- app/components/PremiumFeatureHeader.tsx | 6 +++--- app/components/StripeSubscriptionForm.tsx | 16 ++++++++-------- app/dna-chat/page.tsx | 2 +- app/overview-report/page.tsx | 2 +- app/subscribe/layout.tsx | 6 +++--- app/subscribe/page.tsx | 2 +- 9 files changed, 23 insertions(+), 24 deletions(-) diff --git a/app/api/stripe/create-subscription/route.ts b/app/api/stripe/create-subscription/route.ts index 10cf3a6..106eaef 100644 --- a/app/api/stripe/create-subscription/route.ts +++ b/app/api/stripe/create-subscription/route.ts @@ -101,7 +101,6 @@ export async function POST(request: NextRequest) { price: process.env.STRIPE_PRICE_ID, }, ], - trial_period_days: 7, payment_behavior: 'default_incomplete', payment_settings: { save_default_payment_method: 'on_subscription', @@ -111,8 +110,8 @@ export async function POST(request: NextRequest) { metadata: { walletAddress: walletAddress.toLowerCase(), }, - // Always require payment method collection, even if first invoice is $0 - // This ensures we can charge after promotional period ends + // Always require payment method collection, even if the first invoice is $0. + // This ensures future subscription renewals can be charged. collection_method: 'charge_automatically', }; @@ -158,8 +157,8 @@ export async function POST(request: NextRequest) { const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent | null; - // If there's no payment intent (e.g., $0 invoice due to 100% discount) - // We need to create a SetupIntent to collect payment method for future charges + // If there is no payment intent, for example a $0 invoice from a 100% + // discount, collect a payment method for future charges. if (!paymentIntent) { console.log(`[Stripe] No payment intent for $0 invoice - creating SetupIntent to collect payment method`); diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx index 9b8d22f..895eed0 100644 --- a/app/components/OverviewReportModal.tsx +++ b/app/components/OverviewReportModal.tsx @@ -592,7 +592,7 @@ export default function OverviewReportModal({ Overview Report requires a premium subscription or a one-time report run.

- Try free for 7 days, then $4.99/month, or run this report once for $4.99 from the Analyze page. + Subscribe for $4.99/month, or run this report once for $4.99 from the Analyze page.

) : progress.phase === 'idle' ? ( diff --git a/app/components/PaymentModal.tsx b/app/components/PaymentModal.tsx index 58d65dd..65e22e7 100644 --- a/app/components/PaymentModal.tsx +++ b/app/components/PaymentModal.tsx @@ -431,7 +431,7 @@ export default function PaymentModal({ isOpen = true, onClose, onSuccess, displa
đź’ł
Pay with Card
-
7 days free, then $4.99/month (Stripe)
+
$4.99/month, billed by Stripe
diff --git a/app/components/PremiumFeatureHeader.tsx b/app/components/PremiumFeatureHeader.tsx index 69a0ca6..498c2f2 100644 --- a/app/components/PremiumFeatureHeader.tsx +++ b/app/components/PremiumFeatureHeader.tsx @@ -10,7 +10,7 @@ type PremiumFeatureHeaderProps = { featureName: string; description: string; gateTitle?: string; // overrides default "featureName is a premium tab" / "Premium subscription required" - gateDescription?: string; // overrides default "Try free for 7 days, then $4.99/month to access featureName." + gateDescription?: string; // overrides default "$4.99/month to access featureName." }; export default function PremiumFeatureHeader({ @@ -78,10 +78,10 @@ export default function PremiumFeatureHeader({
{gateTitle ?? 'Premium subscription required'} - {gateDescription ?? `Try free for 7 days, then $4.99/month to access ${featureName}.`} + {gateDescription ?? `$4.99/month to access ${featureName}.`}
- Try free + Subscribe
) : ( diff --git a/app/components/StripeSubscriptionForm.tsx b/app/components/StripeSubscriptionForm.tsx index 4c1a374..544747a 100644 --- a/app/components/StripeSubscriptionForm.tsx +++ b/app/components/StripeSubscriptionForm.tsx @@ -172,12 +172,12 @@ function SubscriptionForm({ clientSecret, walletAddress, couponCode, discount, i {/* Pricing Summary */}
-

7-Day Free Trial

+

Premium Subscription

- $0 - today + $4.99 + /month
-
then $4.99/month, cancel any time
+
billed today, cancel any time
@@ -211,7 +211,7 @@ function SubscriptionForm({ clientSecret, walletAddress, couponCode, discount, i
- Then $4.99/month after promotional period + Then $4.99/month after the promotional period
)} @@ -279,14 +279,14 @@ function SubscriptionForm({ clientSecret, walletAddress, couponCode, discount, i Processing... ) : ( - 'Start Free Trial' + 'Subscribe Now' )}
🔒 - Secured by Stripe • No charge for 7 days • Cancel anytime + Secured by Stripe • $4.99/month • Cancel anytime