From b99d76b05c44af05b073dc7958a5211a818083bf Mon Sep 17 00:00:00 2001 From: Parker Rex Date: Tue, 30 Sep 2025 17:12:54 -0400 Subject: [PATCH] Remove all payment and Stripe functionality ## Removed Components & Pages - Deleted components/payments/ directory (12 components) - Removed pricing, membership, reactivate pages - Removed billing settings page - Removed payment admin pages and modals ## Removed Backend - Deleted convex/stripe/ directory (all Stripe functions) - Removed convex/admin/payments.ts - Removed subscription/payment helper functions - Removed 4 payment-related migration files - Removed stripe webhook route ## Schema Changes - Removed subscriptions, payments, stripeWebhookEvents tables - Removed payment fields from members table: - tier, subscriptionStatus, subscriptionEndDate - billingInterval, stripeCustomerId, stripeSubscriptionId - lastPaymentDate, amountCents, lastPaymentFailure - Removed by_stripeCustomerId index - Removed payment_reminder notification type - Removed payment notification category - Updated member status (removed 'cancelled', 'churned') ## Dependencies - Removed stripe package - Removed react-number-format package 42 files changed, significant code reduction --- app/actions/checkout-session.ts | 45 - app/admin/payments/page.tsx | 349 ---- app/api/stripe/webhook/route.ts | 46 - app/membership/success/auto-signin.tsx | 111 - app/membership/success/page-client.tsx | 198 -- app/membership/success/page.tsx | 24 - app/pricing/page.tsx | 190 -- app/reactivate/page.tsx | 340 ---- app/settings/billing/page.tsx | 425 ---- components/admin/payment-details-modal.tsx | 363 ---- components/admin/payment-history.tsx | 317 --- .../payments/activate-subscription-banner.tsx | 239 --- .../payments/checkout-error-recovery.tsx | 214 -- components/payments/join-vai-pro.tsx | 308 --- .../payments/payment-reminder-banner.tsx | 108 - components/payments/payment-retry-modal.tsx | 247 --- components/payments/paywall.tsx | 168 -- .../payments/pricing-comparison-table.tsx | 204 -- .../payments/reactivate-banner-inline.tsx | 177 -- components/payments/reactivate-banner-top.tsx | 114 -- .../payments/subscription-expired-modal.tsx | 196 -- .../payments/subscription-status-skeleton.tsx | 30 - convex/admin/payments.ts | 181 -- convex/helpers/subscriptionAccess.ts | 204 -- convex/members/checkoutStatus.ts | 39 - .../migrations/checkSubscriptionStatuses.ts | 75 - convex/migrations/importMemberBilling.ts | 1811 ----------------- convex/migrations/populatePaymentFields.ts | 49 - convex/migrations/testReactivation.ts | 148 -- convex/schema.ts | 148 +- convex/stripe/checkout.ts | 143 -- convex/stripe/directCheckout.ts | 184 -- convex/stripe/getSubscriptionInfo.ts | 107 - convex/stripe/monitoring.ts | 402 ---- convex/stripe/portal.ts | 37 - convex/stripe/pricing.ts | 107 - convex/stripe/refund.ts | 277 --- convex/stripe/renewalReminders.ts | 47 - convex/stripe/retryFailedPayment.ts | 84 - convex/stripe/webhooks.ts | 606 ------ lib/payment-error-utils.ts | 253 --- package.json | 2 - 42 files changed, 3 insertions(+), 9314 deletions(-) delete mode 100644 app/actions/checkout-session.ts delete mode 100644 app/admin/payments/page.tsx delete mode 100644 app/api/stripe/webhook/route.ts delete mode 100644 app/membership/success/auto-signin.tsx delete mode 100644 app/membership/success/page-client.tsx delete mode 100644 app/membership/success/page.tsx delete mode 100644 app/pricing/page.tsx delete mode 100644 app/reactivate/page.tsx delete mode 100644 app/settings/billing/page.tsx delete mode 100644 components/admin/payment-details-modal.tsx delete mode 100644 components/admin/payment-history.tsx delete mode 100644 components/payments/activate-subscription-banner.tsx delete mode 100644 components/payments/checkout-error-recovery.tsx delete mode 100644 components/payments/join-vai-pro.tsx delete mode 100644 components/payments/payment-reminder-banner.tsx delete mode 100644 components/payments/payment-retry-modal.tsx delete mode 100644 components/payments/paywall.tsx delete mode 100644 components/payments/pricing-comparison-table.tsx delete mode 100644 components/payments/reactivate-banner-inline.tsx delete mode 100644 components/payments/reactivate-banner-top.tsx delete mode 100644 components/payments/subscription-expired-modal.tsx delete mode 100644 components/payments/subscription-status-skeleton.tsx delete mode 100644 convex/admin/payments.ts delete mode 100644 convex/helpers/subscriptionAccess.ts delete mode 100644 convex/members/checkoutStatus.ts delete mode 100644 convex/migrations/checkSubscriptionStatuses.ts delete mode 100644 convex/migrations/importMemberBilling.ts delete mode 100644 convex/migrations/populatePaymentFields.ts delete mode 100644 convex/migrations/testReactivation.ts delete mode 100644 convex/stripe/checkout.ts delete mode 100644 convex/stripe/directCheckout.ts delete mode 100644 convex/stripe/getSubscriptionInfo.ts delete mode 100644 convex/stripe/monitoring.ts delete mode 100644 convex/stripe/portal.ts delete mode 100644 convex/stripe/pricing.ts delete mode 100644 convex/stripe/refund.ts delete mode 100644 convex/stripe/renewalReminders.ts delete mode 100644 convex/stripe/retryFailedPayment.ts delete mode 100644 convex/stripe/webhooks.ts delete mode 100644 lib/payment-error-utils.ts diff --git a/app/actions/checkout-session.ts b/app/actions/checkout-session.ts deleted file mode 100644 index 286fb1c6..00000000 --- a/app/actions/checkout-session.ts +++ /dev/null @@ -1,45 +0,0 @@ -"use server"; - -import Stripe from "stripe"; - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); - -export interface CheckoutSessionData { - email: string; - customerId: string; - subscriptionId: string; - customerName?: string; - signInToken?: string; -} - -/** - * Retrieves checkout session data from Stripe - */ -export async function getCheckoutSessionData( - sessionId: string, -): Promise { - try { - const session = await stripe.checkout.sessions.retrieve(sessionId, { - expand: ["customer", "subscription"], - }); - - if (!session.customer_details?.email) { - return null; - } - - // Check if this session has an associated sign-in token in metadata - // This would be set if Clerk account was successfully created - const signInToken = session.metadata?.signInToken; - - return { - email: session.customer_details.email, - customerId: session.customer as string, - subscriptionId: session.subscription as string, - customerName: session.customer_details.name || undefined, - signInToken, - }; - } catch (error) { - console.error("Error retrieving checkout session:", error); - return null; - } -} diff --git a/app/admin/payments/page.tsx b/app/admin/payments/page.tsx deleted file mode 100644 index 1855e599..00000000 --- a/app/admin/payments/page.tsx +++ /dev/null @@ -1,349 +0,0 @@ -"use client"; - -import { useQuery } from "convex/react"; -import { endOfDay, format, startOfDay, subDays } from "date-fns"; -import { - Calendar, - CheckCircle, - Clock, - CreditCard, - DollarSign, - Download, - ExternalLink, - Filter, - RefreshCw, - TrendingUp, - XCircle, -} from "lucide-react"; -import { useState } from "react"; -import { PaymentDetailsModal } from "@/components/admin/payment-details-modal"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { api } from "@/convex/_generated/api"; -import type { Id } from "@/convex/_generated/dataModel"; -import { cn } from "@/lib/utils"; -import type { PaymentStats, PaymentWithMember } from "@/types/admin"; - -const paymentStatusConfig = { - succeeded: { - label: "Paid", - color: "bg-green-50 text-green-700", - icon: CheckCircle, - }, - pending: { - label: "Pending", - color: "bg-yellow-50 text-yellow-700", - icon: Clock, - }, - failed: { label: "Failed", color: "bg-red-50 text-red-700", icon: XCircle }, - refunded: { - label: "Refunded", - color: "bg-gray-50 text-gray-700", - icon: RefreshCw, - }, - partially_refunded: { - label: "Partial Refund", - color: "bg-orange-50 text-orange-700", - icon: RefreshCw, - }, -}; - -export default function AdminPaymentsPage() { - const [selectedPaymentId, setSelectedPaymentId] = useState | null>(null); - const [statusFilter, setStatusFilter] = useState< - "all" | "pending" | "succeeded" | "failed" | "refunded" | "partially_refunded" - >("all"); - const [dateRange, setDateRange] = useState("30"); - - // Calculate date range - const endDate = Date.now(); - const startDate = - dateRange === "all" - ? undefined - : startOfDay(subDays(endDate, parseInt(dateRange, 10))).getTime(); - - const payments = useQuery(api.admin.payments.getAllPayments, { - status: statusFilter === "all" ? undefined : statusFilter, - startDate, - endDate: endOfDay(endDate).getTime(), - limit: 100, - }) as PaymentWithMember[] | undefined; - - const stats = useQuery(api.admin.payments.getPaymentStats, { - startDate, - endDate: endOfDay(endDate).getTime(), - }) as PaymentStats | undefined; - - const exportPayments = () => { - if (!payments) return; - - const data = payments.map((p) => ({ - date: format(new Date(p.createdAt), "yyyy-MM-dd HH:mm:ss"), - customer: p.member?.email || "Unknown", - amount: (p.amount / 100).toFixed(2), - status: p.status, - method: `${p.paymentMethod.brand} ${p.paymentMethod.last4}`, - description: p.description, - refunded: p.refundedAmount ? (p.refundedAmount / 100).toFixed(2) : "0", - })); - - const headers = ["date", "customer", "amount", "status", "method", "description", "refunded"]; - const csv = [ - headers.join(","), - ...data.map((row) => headers.map((key) => `"${row[key as keyof typeof row]}"`).join(",")), - ].join("\n"); - - const blob = new Blob([csv], { type: "text/csv" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `payments-${format(new Date(), "yyyy-MM-dd")}.csv`; - a.click(); - }; - - if (!payments || !stats) { - return ( -
-
-
-

Loading payments...

-
-
- ); - } - - return ( -
- {/* Header */} -
-
-
-

Payments

-

View payment history and process refunds

-
- -
-
- - {/* Stats Cards */} -
-
-
-
-

Gross Revenue

-

- {stats.revenue.formattedGross} -

-

Before fees & refunds

-
-
- -
-
-
- -
-
-
-

Net Revenue

-

{stats.revenue.formattedNet}

-

After fees & refunds

-
-
- -
-
-
- -
-
-
-

Refunds

-

- {stats.revenue.formattedRefunds} -

-

{stats.refundedPayments} payments

-
-
- -
-
-
- -
-
-
-

Success Rate

-

- {stats.totalPayments > 0 - ? ((stats.successfulPayments / stats.totalPayments) * 100).toFixed(1) - : 0} - % -

-

- {stats.successfulPayments} of {stats.totalPayments} -

-
-
- -
-
-
-
- - {/* Filters */} -
-
- {/* Date Range */} - - - {/* Status Filter */} - -
-
- - {/* Payments Table */} -
- - - - Date - Customer - Amount - Status - Method - Description - Actions - - - - {payments.map((payment) => { - const statusInfo = - paymentStatusConfig[payment.status as keyof typeof paymentStatusConfig]; - const StatusIcon = statusInfo.icon; - - return ( - - - {format(new Date(payment.createdAt), "MMM d, yyyy")} -
- {format(new Date(payment.createdAt), "h:mm a")} -
-
- - {payment.member ? ( -
-
- {payment.member.firstName} {payment.member.lastName} -
-
{payment.member.email}
-
- ) : ( - Unknown - )} -
- -
${(payment.amount / 100).toFixed(2)}
- {(payment.refundedAmount ?? 0) > 0 && ( -
- -${((payment.refundedAmount ?? 0) / 100).toFixed(2)} refunded -
- )} -
- - - - {statusInfo.label} - - - -
- - {payment.paymentMethod.brand} •••• {payment.paymentMethod.last4} -
-
- - {payment.description} - - - - -
- ); - })} -
-
-
- - {/* Payment Details Modal */} - {selectedPaymentId && ( - setSelectedPaymentId(null)} - /> - )} -
- ); -} diff --git a/app/api/stripe/webhook/route.ts b/app/api/stripe/webhook/route.ts deleted file mode 100644 index 9a693e2d..00000000 --- a/app/api/stripe/webhook/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { ConvexHttpClient } from "convex/browser"; -import { headers } from "next/headers"; -import { NextResponse } from "next/server"; -import Stripe from "stripe"; -import { api } from "../../../../convex/_generated/api"; - -const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!); - -export async function POST(request: Request) { - if (!process.env.STRIPE_SECRET_KEY) { - console.error("STRIPE_SECRET_KEY is not configured"); - return NextResponse.json({ error: "Server configuration error" }, { status: 500 }); - } - - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); - const body = await request.text(); - const headersList = await headers(); - const signature = headersList.get("stripe-signature"); - - if (!signature) { - return NextResponse.json({ error: "Missing signature" }, { status: 400 }); - } - - let event: Stripe.Event; - - try { - event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!); - } catch (err) { - console.error("Webhook signature verification failed:", err); - return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); - } - - try { - // Process the event in Convex - await convex.mutation(api.stripe.webhooks.processWebhookEvent, { - stripeEventId: event.id, - type: event.type, - data: event.data.object, - }); - - return NextResponse.json({ received: true }); - } catch (error) { - console.error("Error processing webhook:", error); - return NextResponse.json({ error: "Webhook processing failed" }, { status: 500 }); - } -} diff --git a/app/membership/success/auto-signin.tsx b/app/membership/success/auto-signin.tsx deleted file mode 100644 index d1d70122..00000000 --- a/app/membership/success/auto-signin.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** - * @fileoverview Auto-sign-in helper used by the checkout success page. - * - * When a guest finishes a Stripe checkout the webhook stores a one-time - * Clerk `signInToken` on their new Convex member record. This component - * consumes that token, performs the Clerk ticket sign-in flow on the - * client, then redirects the user to the desired page. - * - * Existing members (who won’t have a token) bypass this component. - * - * NOTE: Once the success flow is migrated to an Edge route this component - * can be removed entirely. - */ -"use client"; - -import { useSignIn } from "@clerk/nextjs"; -import { Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { Card } from "@/components/ui/card"; - -interface AutoSignInProps { - signInToken?: string; - email: string; - sourcePostId?: string; -} - -export function AutoSignIn({ signInToken, email, sourcePostId }: AutoSignInProps) { - const { signIn, isLoaded } = useSignIn(); - const router = useRouter(); - const [status, setStatus] = useState<"loading" | "signing-in" | "error">("loading"); - const [error, setError] = useState(""); - - useEffect(() => { - const performAutoSignIn = async () => { - if (!isLoaded || !signIn) return; - - try { - setStatus("signing-in"); - - if (signInToken) { - // Use the sign-in token for passwordless authentication - const signInResult = await signIn.create({ - strategy: "ticket", - ticket: signInToken, - }); - - if (signInResult.status === "complete") { - // Sign-in successful, redirect to source post or home - if (sourcePostId) { - // TODO: Get the actual post URL from the post ID - router.push("/"); - } else { - router.push("/"); - } - } else { - throw new Error("Sign-in not complete"); - } - } else { - // No token available, create email link sign-in - await signIn.create({ - strategy: "email_link", - identifier: email, - }); - - // This will send an email, but we want immediate access - // So we'll redirect to a page explaining the email was sent - router.push(`/sign-in/verify-email?email=${encodeURIComponent(email)}`); - } - } catch (err) { - console.error("Auto sign-in error:", err); - setStatus("error"); - setError("Unable to sign in automatically. Please sign in manually."); - - // Redirect to sign-in page after a delay - setTimeout(() => { - const redirectUrl = sourcePostId ? `/?source_post_id=${sourcePostId}` : "/"; - router.push( - `/sign-in?redirect_url=${encodeURIComponent(redirectUrl)}&prefill_email=${encodeURIComponent(email)}`, - ); - }, 3000); - } - }; - - performAutoSignIn(); - }, [isLoaded, signIn, signInToken, email, router, sourcePostId]); - - if (status === "loading" || status === "signing-in") { - return ( - - -

Setting up your account...

-

- Please wait while we create your account and sign you in. -

-
- ); - } - - if (status === "error") { - return ( - -

Sign-in Issue

-

{error}

-

Redirecting to sign-in page...

-
- ); - } - - return null; -} diff --git a/app/membership/success/page-client.tsx b/app/membership/success/page-client.tsx deleted file mode 100644 index 93dd4336..00000000 --- a/app/membership/success/page-client.tsx +++ /dev/null @@ -1,198 +0,0 @@ -/** - * @fileoverview Membership checkout success page (client) - * - * This component handles **two** different post-checkout scenarios: - * 1. Existing Clerk users that just upgraded their subscription. - * – Webhook activates their Convex member immediately. - * – No `signInToken` is required, we simply wait for `memberStatus` to - * show the subscription is active then redirect. - * 2. Guest / password-less checkouts. - * – Convex creates a temporary member and stores a one-time Clerk - * `signInToken` when the webhook finishes. - * – When the query returns the token we render which - * exchanges the ticket with Clerk and redirects afterwards. - * - * NOTE: All of this logic should be replaced by a small **Edge/server route** - * that validates the `session_id`, sets a Clerk ticket cookie (if needed) - * and issues a 302 so users never get “stuck”. - * - * TODO(MVP-CLEANUP): Move success flow to an Edge handler once webhook - * latency is <1s. - */ -"use client"; - -import confetti from "canvas-confetti"; -import { useQuery } from "convex/react"; -import { CheckCircle, Loader2 } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import { getCheckoutSessionData } from "@/app/actions/checkout-session"; -import { Card } from "@/components/ui/card"; -import { api } from "@/convex/_generated/api"; -import type { Id } from "@/convex/_generated/dataModel"; -import { getPaywallVariant, type PaywallVariant, successCopy } from "@/lib/conversion-copy"; -import { AutoSignIn } from "./auto-signin"; - -interface SessionData { - email: string; - customerId: string; - subscriptionId: string; - customerName?: string; - signInToken?: string; -} - -export function SuccessPageClient() { - const searchParams = useSearchParams(); - const sessionId = searchParams.get("session_id"); - const sourcePostId = searchParams.get("source_post_id"); - const variantParam = searchParams.get("variant") as PaywallVariant | null; - const [hasTriggeredConfetti, setHasTriggeredConfetti] = useState(false); - const [sessionData, setSessionData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - // Get the messaging variant - const variant = getPaywallVariant(variantParam || undefined); - const copy = successCopy[variant]; - - // Added: router for redirects - const router = useRouter(); - - // Get post routing information if we have a source post - // TODO: Use this to redirect back to the original post after onboarding - useQuery( - api.posts.getPostRouting, - sourcePostId ? { postId: sourcePostId as Id<"posts"> } : "skip", - ); - - // Get member status from checkout - const memberStatus = useQuery( - api.members.checkoutStatus.getCheckoutMemberStatus, - sessionData?.email ? { email: sessionData.email } : "skip", - ); - - // Fetch checkout session data - useEffect(() => { - const fetchSessionData = async () => { - if (!sessionId) { - setIsLoading(false); - return; - } - - try { - const data = await getCheckoutSessionData(sessionId); - setSessionData(data); - } catch (error) { - console.error("Error fetching session data:", error); - } finally { - setIsLoading(false); - } - }; - - fetchSessionData(); - }, [sessionId]); - - // Trigger confetti animation on mount - useEffect(() => { - if (!hasTriggeredConfetti) { - setHasTriggeredConfetti(true); - - // Fire confetti - const duration = 3 * 1000; - const animationEnd = Date.now() + duration; - const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }; - - function randomInRange(min: number, max: number) { - return Math.random() * (max - min) + min; - } - - const interval: NodeJS.Timeout = setInterval(() => { - const timeLeft = animationEnd - Date.now(); - - if (timeLeft <= 0) { - return clearInterval(interval); - } - - const particleCount = 50 * (timeLeft / duration); - - confetti({ - ...defaults, - particleCount, - origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, - }); - confetti({ - ...defaults, - particleCount, - origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, - }); - }, 250); - } - }, [hasTriggeredConfetti]); - - // Redirect users who are already fully set up (have an active member record but no sign-in token required) - // TODO(MVP-CLEANUP): This redirect logic becomes obsolete once we move the entire flow to the Edge handler. - useEffect(() => { - if (memberStatus && !memberStatus.signInToken) { - // If we have the source post id, send the user back there, otherwise home - const target = sourcePostId ? "/" : "/"; // TODO: map postId to URL when routing util is available - router.replace(target); - } - }, [memberStatus, router, sourcePostId]); - - if (isLoading || !sessionData) { - return ( -
- - -

Processing your payment...

-

- Please wait while we set up your account. -

-
-
- ); - } - - return ( -
-
- {/* Success message */} -
-
-
- -
-
- -

{copy.headline}

-

- {copy.welcome(sessionData.customerName?.split(" ")[0])} -

-

{copy.celebration}

-
- - {/* Auto sign-in component */} - {memberStatus?.signInToken ? ( - - ) : ( - -

Setting up your account...

-

- Your account is being created. You'll be signed in automatically. -

- -
- )} - - {/* Additional info */} -
-

{copy.nextSteps}

-

Your subscription is now active and will renew automatically.

-
-
-
- ); -} diff --git a/app/membership/success/page.tsx b/app/membership/success/page.tsx deleted file mode 100644 index 6f846677..00000000 --- a/app/membership/success/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Suspense } from "react"; -import { SuccessPageClient } from "./page-client"; - -export const metadata = { - title: "Welcome to VAI Pro! - VAI", - description: "Thank you for joining VAI Pro. You now have access to all premium content.", -}; - -export default function SuccessPage() { - return ( - -
-
-
-
-
- } - > - - - ); -} diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx deleted file mode 100644 index c2799898..00000000 --- a/app/pricing/page.tsx +++ /dev/null @@ -1,190 +0,0 @@ -"use client"; - -import { useAuth } from "@clerk/nextjs"; -import { useAction, useQuery } from "convex/react"; -import { ArrowRight, Check, Code, Shield, Users, Zap } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { api } from "@/convex/_generated/api"; - -export default function PricingPage() { - const [isLoading, setIsLoading] = useState(false); - const router = useRouter(); - const { isSignedIn } = useAuth(); - const createDirectCheckout = useAction(api.stripe.directCheckout.createDirectCheckout); - const subscriptionInfo = useQuery(api.stripe.getSubscriptionInfo.getSubscriptionInfo); - - const handleCheckout = async () => { - // Check if already subscribed (no free tier - all subscriptions are paid) - if (isSignedIn && subscriptionInfo?.isActive) { - toast.info("You already have an active subscription"); - router.push("/settings/billing"); - return; - } - - setIsLoading(true); - try { - // Use direct checkout that doesn't require authentication - const { url } = await createDirectCheckout({}); - if (url) { - window.location.href = url; - } - } catch (error) { - console.error("Checkout error:", error); - toast.error("Failed to create checkout session"); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
- {/* Hero Section */} -
-

- Where AI Engineers Hangout. -

- -

- Join the private community where engineers from OpenAI, Anthropic, Google, and Meta - share their AI workflows and implementation details. -

- -
- -

Cancel anytime. No BS.

-
-
- - {/* Value Props Grid */} -
- - - - Real Implementation Details - - -

- Production code, architectures, and prompts from engineers building at scale. -

-
-
- - - - - Private & Unfiltered - - -

- Share freely without corporate PR. What works, what doesn't, and why. -

-
-
- - - - - Engineers Only - - -

- No recruiters, no spam. Just engineers who build AI systems. -

-
-
-
- - {/* What You Get */} - - -
- -

What you get

-
-
- -
- {[ - "Access to all AI workflows and implementations", - "Direct conversations with engineers from top AI labs", - "Production prompts and system architectures", - "Weekly implementation deep-dives", - "Private Discord with verified engineers", - "Early access to new AI tools and techniques", - "No ads, no tracking, no bs", - "Cancel anytime, data export on request", - ].map((feature, i) => ( -
- - {feature} -
- ))} -
-
-
- - {/* FAQ Section */} -
-

Quick answers

- -
-
-

Why $99/month?

-

- It keeps the community small and signal high. We're not trying to be Reddit. -

-
- -
-

Who's actually in here?

-

- Engineers from Google, Microsoft, and high growth startups. -

-
- -
-

Can I expense this?

-

- Yes. We provide invoices with all the right tax info. Most engineers expense it as - "professional development" or "technical resources". -

-
- -
-

What if it sucks?

-

- Cancel anytime from your account. No calls, no retention BS. If you cancel in the - first week, we'll refund you. -

-
-
- -
- -
-
-
-
- ); -} diff --git a/app/reactivate/page.tsx b/app/reactivate/page.tsx deleted file mode 100644 index c1ffa4fd..00000000 --- a/app/reactivate/page.tsx +++ /dev/null @@ -1,340 +0,0 @@ -"use client"; - -import { useAuth } from "@clerk/nextjs"; -import { useAction, useQuery } from "convex/react"; -import { Check, Sparkles } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { api } from "@/convex/_generated/api"; - -/** - * ReactivatePage Component - * - * A specialized page for users with cancelled or expired subscriptions to reactivate - * their membership at their original pricing tier. This page handles tier-specific - * pricing (founding member, early bird, regular) and maintains grandfathered rates - * for legacy users. - * - * Key Features: - * - Preserves original subscription tier and pricing for returning users - * - Supports monthly/yearly billing toggle with savings calculations - * - Handles authentication flow with proper redirects - * - Integrates with Stripe checkout for subscription reactivation - * - Shows tier-specific benefits and locked-in pricing guarantees - * - * Authentication Flow: - * - Redirects unauthenticated users to sign-in with return URL - * - Redirects users with active subscriptions back to pricing page - * - Only shows reactivation options for users with previous subscriptions - * - * @returns {JSX.Element | null} The reactivation page or null during loading/redirects - */ -export default function ReactivatePage() { - // Billing period state - defaults to yearly to encourage longer commitments - // and maximize savings for returning users - const [billingPeriod, setBillingPeriod] = useState<"yearly" | "monthly">("yearly"); - - // Loading state for the reactivation button to prevent double-clicks - // and provide user feedback during Stripe checkout session creation - const [isLoading, setIsLoading] = useState(false); - - // Next.js router for programmatic navigation during authentication flow - const router = useRouter(); - - // Clerk authentication hook to verify user is signed in before showing reactivation options - const { isSignedIn } = useAuth(); - - // Convex query to fetch user's subscription information including tier and status - // This determines pricing and eligibility for reactivation - const subscriptionInfo = useQuery(api.stripe.getSubscriptionInfo.getSubscriptionInfo); - - // Convex action to create Stripe checkout sessions for subscription reactivation - // Handles server-side Stripe integration with tier-specific pricing - const createCheckoutSession = useAction(api.stripe.checkout.createCheckoutSession); - - /** - * Authentication Effect - * - * Redirects unauthenticated users to the sign-in page with a return URL - * pointing back to this reactivation page. This ensures users complete - * authentication before accessing subscription management features. - */ - useEffect(() => { - // Redirect if not signed in - if (!isSignedIn) { - router.push("/sign-in?redirect_url=/reactivate"); - } - }, [isSignedIn, router]); - - /** - * Subscription Status Effect - * - * Handles routing logic based on user's current subscription status: - * - Active subscriptions: redirect to pricing page (no reactivation needed) - * - Free tier users: redirect to pricing page (no previous subscription to reactivate) - * - Cancelled/expired users: allow access to reactivation flow - */ - useEffect(() => { - // Redirect if user has active subscription or no previous subscription - if (subscriptionInfo) { - if (subscriptionInfo.isActive) { - router.push("/pricing"); - } - } - }, [subscriptionInfo, router]); - - // Early return during loading or for ineligible users - // Prevents flash of content before redirects complete - if (!subscriptionInfo) { - return null; - } - - /** - * Get Tier-Specific Information - * - * Returns pricing and display information based on the user's subscription tier. - * This function maintains grandfathered pricing for legacy users and provides - * appropriate messaging for each tier level. - * - * Tier Hierarchy: - * - founding_member: Lowest price, purple badge, "forever" messaging - * - early_bird: Mid-tier price, blue badge, "grandfathered" messaging - * - default (member): Current pricing, primary color, no special badge - * - * @returns {Object} Tier information including name, pricing, colors, and badges - */ - const getTierInfo = () => { - switch (subscriptionInfo.tier) { - case "founding_member": - return { - name: "Founding Member", - description: "Your exclusive early supporter pricing - locked forever", - monthlyPrice: 39, - yearlyPrice: 375, - color: "bg-purple-500", - badge: "Forever Price", - }; - case "early_bird": - return { - name: "Early Bird", - description: "Your grandfathered rate as an early member", - monthlyPrice: 50, - yearlyPrice: 480, - color: "bg-blue-500", - badge: "Grandfathered", - }; - default: - return { - name: "Pro Member", - description: "Full access to the VAI community", - monthlyPrice: 99, - yearlyPrice: 950, - color: "bg-primary", - badge: null, - }; - } - }; - - // Get tier-specific information for the current user - const tierInfo = getTierInfo(); - - // Calculate current price based on selected billing period - const currentPrice = billingPeriod === "yearly" ? tierInfo.yearlyPrice : tierInfo.monthlyPrice; - - // Calculate annual savings when choosing yearly billing - // This helps encourage users to select the yearly option - const savings = tierInfo.monthlyPrice * 12 - tierInfo.yearlyPrice; - - // Calculate savings percentage for display in the UI - // Rounded to nearest whole number for clean presentation - const savingsPercent = Math.round((savings / (tierInfo.monthlyPrice * 12)) * 100); - - /** - * Handle Subscription Reactivation - * - * Creates a Stripe checkout session for the user's specific tier and billing period. - * This function handles the complete reactivation flow including error handling - * and user feedback through loading states and toast notifications. - * - * Process: - * 1. Set loading state to prevent double-clicks - * 2. Construct environment variable key for tier-specific Stripe price ID - * 3. Create checkout session via Convex mutation - * 4. Redirect to Stripe checkout or show error message - * 5. Reset loading state regardless of outcome - * - * @returns {Promise} Async function that handles the reactivation process - */ - const handleReactivate = async () => { - setIsLoading(true); - - try { - // Ensure subscription info has a tier - if (!subscriptionInfo?.tier) { - throw new Error("No subscription tier found"); - } - - // Create Stripe checkout session with tier-specific parameters - const { checkoutUrl } = await createCheckoutSession({ - tier: subscriptionInfo.tier as "founding_member" | "early_bird" | "member", - billingInterval: billingPeriod, - }); - - // Redirect to Stripe checkout if session creation was successful - if (checkoutUrl) { - window.location.href = checkoutUrl; - } - } catch (error) { - // Log error for debugging and show user-friendly error message - console.error("Reactivation error:", error); - toast.error("Failed to create checkout session"); - } finally { - // Always reset loading state to re-enable the button - setIsLoading(false); - } - }; - - return ( -
-
- {/* Page Header Section */} - {/* Welcome message with sparkles icon to create positive, celebratory feeling */} -
-

- - Welcome Back! -

- {/* Personalized subtitle showing their specific tier name */} -

- Reactivate your {tierInfo.name} membership at your special rate -

-
- - {/* Main Reactivation Card */} - {/* Enhanced border to draw attention to the primary action area */} - - - {/* Card header with tier name and optional badge */} -
- {tierInfo.name} - {/* Conditional badge rendering for special tiers */} - {tierInfo.badge && ( - {tierInfo.badge} - )} -
- {/* Tier description explaining the value proposition */} -

{tierInfo.description}

-
- - {/* Billing Period Toggle Section */} - {/* Two-button toggle allowing users to compare monthly vs yearly pricing */} -
- {/* Monthly billing option button */} - - - {/* Yearly billing option button with savings badge */} - -
- - {/* Savings Information Section */} - {/* Highlighted box showing potential savings to encourage yearly billing */} -
-

- {billingPeriod === "yearly" ? ( - // Message for users who selected yearly billing - <> - You'll save ${savings}{" "} - per year with annual billing - - ) : ( - // Encouragement message for users on monthly billing - <> - Switch to yearly and save{" "} - ${savings} per year - - )} -

-
- - {/* Primary Reactivation Button */} - {/* Large, prominent button with dynamic pricing and loading state */} - - - {/* Benefits List Section */} - {/* Reassuring list of what users get when they reactivate */} -
- {/* Immediate access benefit */} -
- - Immediate access to all features -
- - {/* Price lock guarantee - key value proposition for returning users */} -
- - Your {tierInfo.name} pricing is locked forever -
- - {/* Cancellation flexibility to reduce commitment anxiety */} -
- - Cancel anytime from your account settings -
-
-
-
- - {/* Support Contact Information */} - {/* Provides users with a way to get help if they have questions */} -

- Questions? Contact us at support@vai.ai -

-
-
- ); -} diff --git a/app/settings/billing/page.tsx b/app/settings/billing/page.tsx deleted file mode 100644 index f4889542..00000000 --- a/app/settings/billing/page.tsx +++ /dev/null @@ -1,425 +0,0 @@ -"use client"; - -import { useMutation, useQuery } from "convex/react"; -import { - AlertCircle, - Calendar, - CreditCard, - DollarSign, - ExternalLink, - Receipt, - Shield, - Zap, -} from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { api } from "@/convex/_generated/api"; - -/** - * BillingPage Component - * - * A comprehensive billing and subscription management page that displays the user's - * current subscription status, payment history, and provides access to Stripe's - * customer portal for subscription management. - * - * Key Features: - * - Displays current subscription tier with appropriate badges and colors - * - Shows subscription status (Active, Past Due, Cancelled, Expired) - * - Provides billing details including interval, last payment, and renewal info - * - Handles special cases for scholarship and free tier users - * - Integrates with Stripe customer portal for subscription management - * - Displays recent payment history with status indicators - * - Responsive design that works on mobile and desktop - * - * User Flow: - * 1. Page loads and fetches subscription information from Convex - * 2. Displays current plan details with tier-specific styling - * 3. Shows relevant actions based on subscription status - * 4. Allows paid users to access Stripe portal for management - * 5. Displays payment history for transparency - * - * @returns {JSX.Element} The complete billing management interface - */ -export default function BillingPage() { - // Note: Router removed since no free tier upgrade functionality needed - - // Loading state for the Stripe portal button to prevent double-clicks - // This ensures users don't accidentally create multiple portal sessions - const [isLoadingPortal, setIsLoadingPortal] = useState(false); - - // Query to fetch comprehensive subscription information from Convex - // This includes tier, status, billing details, and payment history - // Returns null during loading, which we handle with a loading state - const subscriptionInfo = useQuery(api.stripe.getSubscriptionInfo.getSubscriptionInfo); - - // Mutation to create a Stripe customer portal session - // This allows users to manage their subscription, update payment methods, - // download invoices, and cancel their subscription through Stripe's UI - const createPortalSession = useMutation(api.stripe.portal.createPortalSession); - - /** - * Handle Subscription Management Portal Access - * - * Creates a Stripe customer portal session and redirects the user to it. - * The portal allows users to manage their subscription, update payment methods, - * download invoices, and cancel their subscription in a secure environment. - * - * Process: - * 1. Set loading state to prevent multiple clicks - * 2. Call Convex mutation to create portal session - * 3. Redirect user to the returned portal URL - * 4. Handle errors with user-friendly toast messages - * 5. Reset loading state regardless of outcome - * - * Error Handling: - * - Shows toast notification for any failures - * - Logs errors to console for debugging - * - Ensures loading state is always reset - * - * @returns {Promise} Async function that handles portal creation and redirect - */ - const handleManageSubscription = async () => { - // Prevent multiple portal session creations while one is in progress - setIsLoadingPortal(true); - - try { - // Create a new Stripe customer portal session via Convex mutation - // This returns a secure URL that expires after a short time - const { portalUrl } = await createPortalSession(); - - // Redirect to the portal if URL was successfully created - // Using window.location.href for full page navigation to external domain - if (portalUrl) { - window.location.href = portalUrl; - } - } catch (error) { - // Show user-friendly error message if portal creation fails - // This could happen due to network issues or Stripe API problems - toast.error("Failed to open billing portal"); - - // Log the actual error for debugging purposes - // This helps developers troubleshoot issues without exposing details to users - console.error(error); - } finally { - // Always reset loading state, whether success or failure - // This ensures the button becomes clickable again - setIsLoadingPortal(false); - } - }; - - // Show loading state while subscription information is being fetched - // This prevents rendering incomplete UI and provides user feedback - if (!subscriptionInfo) { - return ( -
-
-
-
-
-
-
-
-
-
-
-
- ); - } - - /** - * Get Tier-Specific Badge Colors - * - * Returns appropriate CSS classes for tier badges based on the subscription tier. - * Each tier has distinct colors to help users quickly identify their status - * and create visual hierarchy in the interface. - * - * Tier Color Mapping: - * - founding_member: Purple (premium, exclusive feel) - * - early_bird: Blue (trustworthy, established) - * - scholarship: Green (positive, beneficial) - * - member: Primary theme colors (consistent with brand) - * - default: Gray (neutral, fallback) - * - * @param {string} tier - The subscription tier identifier - * @returns {string} CSS classes for badge styling - */ - const getTierBadgeColor = (tier: string) => { - switch (tier) { - case "founding_member": - // Purple badge for founding members - conveys exclusivity and premium status - return "bg-purple-500 text-primary-foreground"; - case "early_bird": - // Blue badge for early bird members - trustworthy and established feeling - return "bg-blue-500 text-primary-foreground"; - case "scholarship": - // Green badge for scholarship members - positive and beneficial association - return "bg-green-500 text-primary-foreground"; - case "member": - // Use theme colors for regular members - maintains brand consistency - return "bg-primary text-primary-foreground"; - default: - // Gray fallback for unknown tiers - neutral and safe - return "bg-muted text-muted-foreground"; - } - }; - - return ( -
- {/* Page Header Section */} - {/* Provides clear context about what this page contains and its purpose */} -
-

Billing & Subscription

-

Manage your subscription and payment settings

-
- - {/* Current Plan Card */} - {/* Main card displaying the user's current subscription tier and status */} - {/* This is the primary information users need to see about their account */} - - - {/* Header with plan title and tier badge for quick identification */} -
-
- - Current Plan -
- {/* Tier badge with dynamic colors based on subscription level */} - - {subscriptionInfo.tierDisplay} - -
-
- -
- {/* Subscription Status Section */} - {/* Shows current status with color-coded badges for quick recognition */} -
-

Subscription Status

-
- {/* Active status badge - green to indicate positive state */} - {subscriptionInfo.isActive && ( - - Active - - )} - {/* Past due status badge - yellow to indicate warning state */} - {subscriptionInfo.isPastDue && ( - - Past Due - - )} - {/* Cancelled status badge - orange to indicate caution state */} - {subscriptionInfo.isCancelled && ( - - Cancelled - - )} - {/* Expired status badge - red to indicate critical state */} - {subscriptionInfo.isExpired && ( - - Expired - - )} -
-
- - {/* Billing Details Section */} - {/* Only shown for paid tiers that have actual billing information */} - {/* Note: No free tier - platform operates with zero free users */} - {/* Scholarships handled via Stripe coupons, still show billing details */} - {subscriptionInfo.tier && ( - <> - - {/* Two-column grid for billing interval and last payment info */} -
-
-
- - Billing Interval -
- {/* Shows whether user is billed monthly or yearly */} -

- {subscriptionInfo.billingIntervalDisplay || "N/A"} -

-
-
-
- - Last Payment -
- {/* Shows last payment amount and date for transparency */} -

- {subscriptionInfo.lastPaymentAmount || "N/A"} -

- {subscriptionInfo.lastPaymentDate && ( -

- {subscriptionInfo.lastPaymentDate} -

- )} -
-
- - {/* Renewal Information Section */} - {/* Only shown for active subscriptions that will auto-renew */} - {/* Helps users understand when their next payment will occur */} - {subscriptionInfo.renewalInfo && - subscriptionInfo.isActive && - !subscriptionInfo.cancelAtPeriodEnd && ( -
-
-
- -
-
-

Next Billing Date

- {/* Shows next billing date with descriptive renewal text */} -

- {subscriptionInfo.renewalInfo.nextBillingDate} -

-

- {subscriptionInfo.renewalInfo.renewalText} -

-
-
-
- )} - - {/* Cancellation Notice Section */} - {/* Warning banner for subscriptions set to cancel at period end */} - {/* Uses yellow color scheme to indicate important information */} - {subscriptionInfo.cancelAtPeriodEnd && ( -
-
- {/* Alert icon to draw attention to the cancellation notice */} -
- -
-
-

- Subscription set to cancel -

- {/* Clear information about when the subscription will end */} -

- Your subscription will end on{" "} - - {subscriptionInfo.renewalInfo?.nextBillingDate} - -

-
-
-
- )} - - )} - - {/* Note: No free tier - platform operates with zero free users */} - {/* Scholarships handled via Stripe coupons with early_bird tier */} -
-
-
- - {/* Subscription Management Actions Card */} - {/* Only shown for paid subscribers who can manage their subscription */} - {/* Note: No free tier - all users have paid subscriptions */} - {/* Scholarships handled via Stripe coupons, still show management options */} - {subscriptionInfo.tier && ( - - -
- -
- Subscription Management - - Update your payment method, download invoices, or cancel your subscription - -
-
-
- -
- {/* Main action button to access Stripe customer portal */} - {/* Responsive width - full on mobile, auto on larger screens */} - - {/* Informational text about the redirect to build user trust */} -

- - You'll be redirected to our secure billing portal powered by Stripe -

-
-
-
- )} - - {/* Recent Payments History Card */} - {/* Only shown if user has payment history to display */} - {/* Provides transparency about billing and payment status */} - {subscriptionInfo.recentPayments && subscriptionInfo.recentPayments.length > 0 && ( - - -
- - Recent Payments -
-
- -
- {/* Map through recent payments to display each transaction */} - {subscriptionInfo.recentPayments.map( - (payment: { - id: string; - date: string; - amount: string; - status: string; - description: string; - }) => ( -
- {/* Left side: Payment details with icon */} -
- {/* Dollar icon for visual consistency */} -
- -
-
- {/* Payment amount in bold for emphasis */} -

{payment.amount}

- {/* Payment date in muted color for hierarchy */} -

{payment.date}

-
-
- {/* Right side: Payment status badge with conditional styling */} - - {payment.status === "succeeded" ? "Paid" : payment.status} - -
- ), - )} -
-
-
- )} -
- ); -} diff --git a/components/admin/payment-details-modal.tsx b/components/admin/payment-details-modal.tsx deleted file mode 100644 index f3b39261..00000000 --- a/components/admin/payment-details-modal.tsx +++ /dev/null @@ -1,363 +0,0 @@ -"use client"; - -import { useAction, useQuery } from "convex/react"; -import { format } from "date-fns"; -import { AlertCircle, Copy, CreditCard, Loader2, RefreshCw } from "lucide-react"; -import { useState } from "react"; -import { toast } from "sonner"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Separator } from "@/components/ui/separator"; -import { Textarea } from "@/components/ui/textarea"; -import { api } from "@/convex/_generated/api"; -import type { Id } from "@/convex/_generated/dataModel"; -import { paymentStatusConfig } from "@/lib/admin-config"; -import { formatCentsAsCurrency } from "@/lib/format"; -import { cn } from "@/lib/utils"; -import type { PaymentDetailsResponse, RefundEligibility } from "@/types/admin"; - -interface PaymentDetailsModalProps { - paymentId: Id<"payments">; - onClose: () => void; -} - -export function PaymentDetailsModal({ paymentId, onClose }: PaymentDetailsModalProps) { - const [showRefundForm, setShowRefundForm] = useState(false); - const [refundAmount, setRefundAmount] = useState(""); - const [refundReason, setRefundReason] = useState("requested_by_customer"); - const [refundNotes, setRefundNotes] = useState(""); - const [isProcessing, setIsProcessing] = useState(false); - - const paymentDetails = useQuery(api.admin.payments.getPaymentDetails, { - paymentId, - }) as PaymentDetailsResponse | undefined; - const refundEligibility = useQuery(api.stripe.refund.getRefundEligibility, { - paymentId, - }) as RefundEligibility | undefined; - const refundPayment = useAction(api.stripe.refund.refundPayment); - - if (!paymentDetails) { - return ( - - -
-
-
-
-
- ); - } - - const { payment, member } = paymentDetails; - const statusInfo = paymentStatusConfig[payment.status as keyof typeof paymentStatusConfig]; - const StatusIcon = statusInfo.icon; - - const fullName = - member?.firstName && member?.lastName - ? `${member.firstName} ${member.lastName}` - : member?.email || "Unknown"; - - const handleCopyId = (id: string) => { - navigator.clipboard.writeText(id); - toast.success("ID copied to clipboard"); - }; - - const handleRefund = async () => { - if (!refundEligibility?.eligible) return; - - setIsProcessing(true); - try { - const amountInCents = refundAmount - ? Math.round(parseFloat(refundAmount) * 100) - : payment.amount; - - await refundPayment({ - paymentId, - amount: amountInCents, - reason: refundReason as "requested_by_customer" | "duplicate" | "fraudulent" | "other", - notes: refundNotes, - }); - - toast.success("Refund Successful", { - description: `Refunded $${(amountInCents / 100).toFixed(2)}`, - }); - - setShowRefundForm(false); - onClose(); - } catch (error) { - toast.error("Refund Failed", { - description: - error instanceof Error ? error.message : "An error occurred while processing the refund", - }); - } finally { - setIsProcessing(false); - } - }; - - const maxRefundAmount = - refundEligibility && "maxRefundAmount" in refundEligibility - ? refundEligibility.maxRefundAmount - : 0; - const canRefund = refundEligibility?.eligible && !showRefundForm; - - return ( - - - - Payment Details - Transaction information and refund options - - -
- {/* Payment Status */} -
-
- -
-

${(payment.amount / 100).toFixed(2)}

- {statusInfo.label} -
-
-
-

{format(new Date(payment.createdAt), "MMM d, yyyy")}

-

{format(new Date(payment.createdAt), "h:mm a")}

-
-
- - {/* Transaction Details */} -
-

Transaction Details

- -
-
-

Customer

-

{fullName}

-

{member?.email}

-
- -
-

Payment Method

-
- - - {payment.paymentMethod.brand} •••• {payment.paymentMethod.last4} - -
-
- -
-

Description

-

{payment.description}

-
- -
-

Invoice ID

-
- - {payment.stripeInvoiceId || "N/A"} - - {payment.stripeInvoiceId && ( - - )} -
-
-
-
- - {/* Financial Breakdown */} -
-

Financial Breakdown

- -
-
- Gross Amount - {formatCentsAsCurrency(payment.amount)} -
- - {payment.transactionFee ? ( -
- Transaction Fee - - -{formatCentsAsCurrency(payment.transactionFee)} - -
- ) : null} - - {payment.refundedAmount ? ( -
- Refunded - - -{formatCentsAsCurrency(payment.refundedAmount)} - -
- ) : null} - - - -
- Net Amount - - {formatCentsAsCurrency(payment.netAmount || payment.amount)} - -
-
-
- - {/* Stripe IDs */} -
-

Stripe Information

-
-
- Payment Intent ID -
- - {payment.stripePaymentIntentId} - - -
-
-
-
- - {/* Refund Section */} - {canRefund && ( - - - Refund Available - - You can refund up to ${(maxRefundAmount / 100).toFixed(2)} for this payment. - {"eligible" in refundEligibility && - refundEligibility.alreadyRefunded > 0 && - ` (Already refunded: $${(refundEligibility.alreadyRefunded / 100).toFixed(2)})`} - - - )} - - {!canRefund && refundEligibility && !refundEligibility.eligible && ( - - - Cannot Refund - {refundEligibility.reason} - - )} - - {/* Refund Form */} - {showRefundForm && refundEligibility?.eligible && ( -
-

Process Refund

- -
-
- -
- - $ - - setRefundAmount(e.target.value)} - className="pl-8" - /> -
-

- Leave empty to refund full amount ({formatCentsAsCurrency(maxRefundAmount)}) -

-
- -
- - -
- -
- -