diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index d3579f0..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": [ - "next/core-web-vitals", - "next/typescript" - ], - "rules": { - "@typescript-eslint/no-explicit-any": "warn", - "react/no-unescaped-entities": "warn" - } -} diff --git a/app/api/report-access/route.ts b/app/api/report-access/route.ts new file mode 100644 index 0000000..398524c --- /dev/null +++ b/app/api/report-access/route.ts @@ -0,0 +1,192 @@ +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', +}); + +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, "\\'"); +} + +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)}'`, + `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); +} + +// 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', sessionId } = + 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 collectUnusedPayments(normalizedWallet, sessionId); + const availableReports = Object.fromEntries( + PAID_REPORT_TYPES.map(type => [ + type, + payments.filter(payment => payment.metadata.reportType === type).length, + ]) + ); + + return NextResponse.json({ + success: true, + availableReports, + }); + } + + // 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 }); + } + } + + if (action === 'consume') { + if (!isPaidReportType(reportType)) { + return NextResponse.json({ error: 'Invalid report type' }, { status: 400 }); + } + + const payments = await collectUnusedPayments(normalizedWallet, sessionId, reportType); + const consumedPaymentIntentId = await consumeOnePayment(payments); + + if (!consumedPaymentIntentId) { + return NextResponse.json( + { success: false, error: 'No unused report pass found' }, + { status: 402 } + ); + } + + return NextResponse.json({ + success: true, + consumedPaymentIntentId, + }); + } + + 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/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/HealthspanReportModal.tsx b/app/components/HealthspanReportModal.tsx index 6c0a10d..e986fef 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); @@ -113,6 +138,13 @@ export default function HealthspanReportModal({ isOpen, onClose }: HealthspanRep const handleClose = () => { if (phase === 'generating') return; + // A completed report exists only in memory, so confirm before discarding it. + if (phase === 'complete' && result) { + const confirmed = window.confirm( + 'Close this report? It cannot be recovered. Use Copy or Print to save it first.' + ); + if (!confirmed) return; + } setPhase('idle'); setResult(null); setQuestions([]); diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx index 25f424d..cb50663 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,12 +25,20 @@ interface ProgressState { interface OverviewReportModalProps { isOpen: boolean; onClose: () => void; + hasPremiumAccess?: boolean; + hasOneTimeAccess?: boolean; + onConsumeOneTimeAccess?: () => Promise; } -export default function OverviewReportModal({ isOpen, onClose }: OverviewReportModalProps) { +export default function OverviewReportModal({ + isOpen, + onClose, + hasPremiumAccess = false, + hasOneTimeAccess = false, + onConsumeOneTimeAccess, +}: OverviewReportModalProps) { const { savedResults } = useResults(); const { customization } = useCustomization(); - const { hasActiveSubscription } = useAuth(); const [isGenerating, setIsGenerating] = useState(false); const generationInFlightRef = useRef(false); @@ -53,6 +59,30 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM return; } generationInFlightRef.current = true; + + if (!hasPremiumAccess && 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); @@ -506,6 +536,13 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM }; const handleClose = () => { + // A completed report exists only in memory, so confirm before discarding it. + if (progress.phase === 'complete' && progress.finalReport) { + const confirmed = window.confirm( + 'Close this report? It cannot be recovered. Use Copy to Clipboard or Print Report to save it first.' + ); + if (!confirmed) return; + } // Reset state when closing modal to avoid showing stale data if (!isGenerating) { setProgress({ @@ -525,8 +562,7 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM if (!isOpen) return null; // Check subscription - const hasPromoAccess = hasValidPromoAccess(); - const isBlocked = !hasActiveSubscription && !hasPromoAccess; + const isBlocked = !hasPremiumAccess && !hasOneTimeAccess; return (
@@ -560,10 +596,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. + 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