From d6f3b5fe60aa0961986fcf05742feb15cdb83f24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:30:43 +0000 Subject: [PATCH 1/4] Initial plan From 3a8c7f849381c0c40e1b8fc78b2f329f5fa5c9df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:42:59 +0000 Subject: [PATCH 2/4] Add Stripe payment integration with PaymentAttempt and Refund models Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com> --- prisma/schema.prisma | 70 ++++- src/app/api/payments/create-session/route.ts | 84 ++++++ src/app/api/webhooks/stripe/route.ts | 247 +++++++++++++++++ src/components/checkout-button.tsx | 67 +++++ src/lib/services/payment.service.ts | 270 +++++++++++++++++++ 5 files changed, 737 insertions(+), 1 deletion(-) create mode 100644 src/app/api/payments/create-session/route.ts create mode 100644 src/app/api/webhooks/stripe/route.ts create mode 100644 src/components/checkout-button.tsx create mode 100644 src/lib/services/payment.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 542b72eb..81586c1f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -312,6 +312,11 @@ model Store { timezone String @default("UTC") locale String @default("en") + // Payment Integration (Stripe Connect) + stripeAccountId String? // Stripe Connect account ID for multi-tenant payments + stripeSecretKey String? // Encrypted Stripe secret key (use for API calls) + stripePublishableKey String? // Stripe publishable key (safe for client-side) + // Subscription subscriptionPlan SubscriptionPlan @default(FREE) subscriptionStatus SubscriptionStatus @default(TRIAL) @@ -333,6 +338,8 @@ model Store { staff StoreStaff[] // Store staff assignments discountCodes DiscountCode[] // Discount/coupon codes webhooks Webhook[] // External integrations + paymentAttempts PaymentAttempt[] // Payment tracking + refunds Refund[] // Refund tracking // Custom role management customRoles CustomRole[] @@ -778,6 +785,7 @@ model Order { fulfilledAt DateTime? deliveredAt DateTime? // Delivered timestamp + paidAt DateTime? // Payment completion timestamp canceledAt DateTime? cancelReason String? @@ -791,7 +799,9 @@ model Order { ipAddress String? - items OrderItem[] + items OrderItem[] + paymentAttempts PaymentAttempt[] // Payment tracking + refunds Refund[] // Refund tracking createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -837,6 +847,64 @@ model OrderItem { @@index([productId]) } +// Payment tracking model +model PaymentAttempt { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + provider String // STRIPE, SSLCOMMERZ, etc. + amount Float // Amount in base currency + currency String @default("USD") + status String @default("PENDING") // PENDING, SUCCESS, FAILED + + externalId String? // Payment intent ID from provider (e.g., Stripe payment intent) + + errorCode String? + errorMessage String? + + metadata String? // JSON metadata (sessionId, sessionUrl, etc.) + + processedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + refunds Refund[] // Refunds associated with this payment + + @@index([orderId]) + @@index([storeId, status]) + @@index([externalId]) + @@index([storeId, createdAt]) +} + +// Refund tracking model +model Refund { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + paymentAttemptId String + paymentAttempt PaymentAttempt @relation(fields: [paymentAttemptId], references: [id], onDelete: Cascade) + + amount Float // Refund amount + status String @default("PENDING") // PENDING, COMPLETED, FAILED + + externalId String? // Refund ID from payment provider + reason String? // Reason for refund + + processedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orderId]) + @@index([storeId, status]) + @@index([paymentAttemptId]) + @@index([storeId, createdAt]) +} + // Webhook configuration for external integrations model Webhook { id String @id @default(cuid()) diff --git a/src/app/api/payments/create-session/route.ts b/src/app/api/payments/create-session/route.ts new file mode 100644 index 00000000..c7d8f46c --- /dev/null +++ b/src/app/api/payments/create-session/route.ts @@ -0,0 +1,84 @@ +// src/app/api/payments/create-session/route.ts +// API Route to Create Stripe Checkout Session +// Validates user access and creates payment session for an order + +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { paymentService } from "@/lib/services/payment.service"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; + +const CreateSessionSchema = z.object({ + orderId: z.string().cuid(), +}); + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { orderId } = CreateSessionSchema.parse(body); + + // Verify order exists and get storeId + const order = await prisma.order.findUnique({ + where: { + id: orderId, + }, + select: { storeId: true, status: true }, + }); + + if (!order) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + // Verify user has access to the store + const storeAccess = await prisma.membership.findFirst({ + where: { + userId: session.user.id, + organization: { + store: { + id: order.storeId, + }, + }, + }, + }); + + if (!storeAccess) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + // Check if order is in a valid state for payment + if (order.status !== "PENDING" && order.status !== "PAYMENT_FAILED") { + return NextResponse.json( + { error: "Order is not in a valid state for payment" }, + { status: 400 } + ); + } + + const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; + const checkoutSession = await paymentService.createCheckoutSession({ + orderId, + storeId: order.storeId, + successUrl: `${baseUrl}/store/${order.storeId}/orders/${orderId}/success`, + cancelUrl: `${baseUrl}/store/${order.storeId}/orders/${orderId}/cancel`, + }); + + return NextResponse.json(checkoutSession); + } catch (error) { + console.error("Create session error:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.issues }, + { status: 400 } + ); + } + return NextResponse.json( + { error: "Failed to create payment session" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 00000000..e8276e3b --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -0,0 +1,247 @@ +// src/app/api/webhooks/stripe/route.ts +// Stripe Webhook Handler with Signature Verification +// Processes payment events: checkout.session.completed, payment_intent.succeeded, charge.refunded + +import { NextRequest, NextResponse } from "next/server"; +import Stripe from "stripe"; +import { prisma } from "@/lib/prisma"; +import { headers } from "next/headers"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2025-11-17.clover", +}); + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; + +// Disable body parsing for webhook (need raw body for signature verification) +export const runtime = "nodejs"; + +export async function POST(request: NextRequest) { + try { + const body = await request.text(); + const headersList = await headers(); + const signature = headersList.get("stripe-signature"); + + if (!signature) { + return NextResponse.json( + { error: "No signature provided" }, + { status: 400 } + ); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(body, signature, webhookSecret); + } catch (err) { + const error = err as Error; + console.error("Webhook signature verification failed:", error.message); + return NextResponse.json( + { error: "Invalid signature" }, + { status: 400 } + ); + } + + // Handle specific event types + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object as Stripe.Checkout.Session; + await handleCheckoutCompleted(session); + break; + } + + case "payment_intent.succeeded": { + const paymentIntent = event.data.object as Stripe.PaymentIntent; + await handlePaymentSucceeded(paymentIntent); + break; + } + + case "payment_intent.payment_failed": { + const paymentIntent = event.data.object as Stripe.PaymentIntent; + await handlePaymentFailed(paymentIntent); + break; + } + + case "charge.refunded": { + const charge = event.data.object as Stripe.Charge; + await handleChargeRefunded(charge); + break; + } + + default: + console.log(`Unhandled event type: ${event.type}`); + } + + return NextResponse.json({ received: true }); + } catch (error) { + console.error("Webhook processing error:", error); + return NextResponse.json( + { error: "Webhook processing failed" }, + { status: 500 } + ); + } +} + +/** + * Handle checkout.session.completed event + * Updates order status to PAID and payment attempt to SUCCESS + */ +async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { + const orderId = session.client_reference_id || session.metadata?.orderId; + if (!orderId) { + console.error("No orderId in session metadata"); + return; + } + + const paymentIntentId = session.payment_intent as string; + + try { + await prisma.$transaction(async (tx) => { + // Update payment attempt + await tx.paymentAttempt.updateMany({ + where: { + orderId, + externalId: paymentIntentId, + }, + data: { + status: "SUCCESS", + processedAt: new Date(), + metadata: JSON.stringify({ + sessionId: session.id, + paymentStatus: session.payment_status, + }), + }, + }); + + // Update order status + await tx.order.update({ + where: { id: orderId }, + data: { + status: "PAID", + paymentStatus: "PAID", + paidAt: new Date(), + }, + }); + + // Create audit log + await tx.auditLog.create({ + data: { + action: "PAYMENT_COMPLETED", + entityType: "Order", + entityId: orderId, + changes: JSON.stringify({ + paymentIntentId, + amount: session.amount_total + }), + }, + }); + }); + + console.log(`Payment completed for order ${orderId}`); + } catch (error) { + console.error(`Error handling checkout completed for order ${orderId}:`, error); + throw error; + } +} + +/** + * Handle payment_intent.succeeded event + * Fallback handler for direct payment intents + */ +async function handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) { + const orderId = paymentIntent.metadata?.orderId; + if (!orderId) return; + + try { + await prisma.paymentAttempt.updateMany({ + where: { + orderId, + externalId: paymentIntent.id, + }, + data: { + status: "SUCCESS", + processedAt: new Date(), + }, + }); + + console.log(`Payment intent succeeded for order ${orderId}`); + } catch (error) { + console.error(`Error handling payment succeeded for order ${orderId}:`, error); + } +} + +/** + * Handle payment_intent.payment_failed event + * Updates payment attempt with error details + */ +async function handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) { + const orderId = paymentIntent.metadata?.orderId; + if (!orderId) return; + + const errorCode = paymentIntent.last_payment_error?.code || "unknown_error"; + const errorMessage = paymentIntent.last_payment_error?.message || "Payment failed"; + + try { + await prisma.$transaction(async (tx) => { + // Update payment attempt + await tx.paymentAttempt.updateMany({ + where: { + orderId, + externalId: paymentIntent.id, + }, + data: { + status: "FAILED", + errorCode, + errorMessage, + }, + }); + + // Update order status + await tx.order.update({ + where: { id: orderId }, + data: { + status: "PAYMENT_FAILED", + paymentStatus: "FAILED", + }, + }); + }); + + console.error(`Payment failed for order ${orderId}: ${errorMessage}`); + } catch (error) { + console.error(`Error handling payment failed for order ${orderId}:`, error); + } +} + +/** + * Handle charge.refunded event + * Updates refund status to COMPLETED + */ +async function handleChargeRefunded(charge: Stripe.Charge) { + const paymentIntentId = charge.payment_intent as string; + + try { + // Find refund record by payment intent + const refund = await prisma.refund.findFirst({ + where: { + paymentAttempt: { + externalId: paymentIntentId, + }, + status: "PENDING", + }, + }); + + if (refund) { + await prisma.refund.update({ + where: { id: refund.id }, + data: { + status: "COMPLETED", + processedAt: new Date(), + }, + }); + + console.log(`Refund completed: ${refund.id}`); + } + } catch (error) { + console.error(`Error handling charge refunded for payment ${paymentIntentId}:`, error); + } +} diff --git a/src/components/checkout-button.tsx b/src/components/checkout-button.tsx new file mode 100644 index 00000000..d24fdabb --- /dev/null +++ b/src/components/checkout-button.tsx @@ -0,0 +1,67 @@ +// src/components/checkout-button.tsx +// Client Component for Stripe Checkout Redirection +// Initiates payment flow by creating Stripe Checkout session + +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +interface CheckoutButtonProps { + orderId: string; + disabled?: boolean; + className?: string; +} + +export function CheckoutButton({ orderId, disabled, className }: CheckoutButtonProps) { + const [loading, setLoading] = useState(false); + + const handleCheckout = async () => { + setLoading(true); + try { + const response = await fetch("/api/payments/create-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ orderId }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to create session"); + } + + const { sessionUrl } = await response.json(); + + if (!sessionUrl) { + throw new Error("No session URL returned"); + } + + // Redirect to Stripe Checkout + window.location.href = sessionUrl; + } catch (error) { + console.error("Checkout error:", error); + toast.error(error instanceof Error ? error.message : "Failed to start checkout"); + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/lib/services/payment.service.ts b/src/lib/services/payment.service.ts new file mode 100644 index 00000000..7e040d61 --- /dev/null +++ b/src/lib/services/payment.service.ts @@ -0,0 +1,270 @@ +// src/lib/services/payment.service.ts +// Stripe Payment Integration Service +// Implements Phase 1: Stripe Payment Integration requirements + +import Stripe from "stripe"; +import { prisma } from "@/lib/prisma"; + +if (!process.env.STRIPE_SECRET_KEY) { + throw new Error("STRIPE_SECRET_KEY is not defined"); +} + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { + apiVersion: "2025-11-17.clover", + typescript: true, +}); + +// ============================================================================ +// TYPES AND INTERFACES +// ============================================================================ + +export interface CreateCheckoutSessionParams { + orderId: string; + storeId: string; + successUrl: string; + cancelUrl: string; +} + +export interface ProcessRefundParams { + orderId: string; + amount: number; + reason?: string; + idempotencyKey: string; +} + +// ============================================================================ +// PAYMENT SERVICE +// ============================================================================ + +export class PaymentService { + /** + * Create Stripe Checkout Session for an order + * Supports multi-currency and Stripe Connect for multi-tenant payments + */ + async createCheckoutSession(params: CreateCheckoutSessionParams) { + const { orderId, storeId, successUrl, cancelUrl } = params; + + // Fetch order with items + const order = await prisma.order.findUnique({ + where: { id: orderId, storeId }, + include: { + items: { + include: { + product: { select: { name: true, images: true } }, + variant: { select: { name: true } }, + }, + }, + store: { + select: { + name: true, + currency: true, + stripeAccountId: true + } + }, + customer: { select: { email: true } }, + }, + }); + + if (!order) { + throw new Error("Order not found"); + } + + // Create line items for Stripe + const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = order.items.map((item) => { + // Parse images safely + let images: string[] = []; + if (item.product?.images) { + try { + const parsed = JSON.parse(item.product.images); + images = Array.isArray(parsed) ? parsed : []; + } catch { + images = []; + } + } + + return { + price_data: { + currency: (order.store.currency || "usd").toLowerCase(), + product_data: { + name: item.product?.name || item.productName, + description: item.variant?.name, + images: images.slice(0, 1), // First image only + }, + unit_amount: Math.round(item.price * 100), // Convert to cents + }, + quantity: item.quantity, + }; + }); + + // Create checkout session + const sessionOptions: Stripe.Checkout.SessionCreateParams = { + payment_method_types: ["card"], + line_items: lineItems, + mode: "payment", + success_url: successUrl, + cancel_url: cancelUrl, + client_reference_id: orderId, + customer_email: order.customerEmail || order.customer?.email || undefined, + metadata: { + orderId, + storeId, + orderNumber: order.orderNumber, + }, + }; + + // Add Stripe Connect account if configured + const session = await stripe.checkout.sessions.create( + sessionOptions, + order.store.stripeAccountId ? { + stripeAccount: order.store.stripeAccountId, + } : undefined + ); + + // Create pending payment attempt + await prisma.paymentAttempt.create({ + data: { + orderId, + storeId, + provider: "STRIPE", + amount: order.totalAmount, + currency: order.store.currency || "USD", + status: "PENDING", + externalId: session.payment_intent as string, + metadata: JSON.stringify({ + sessionId: session.id, + sessionUrl: session.url, + }), + }, + }); + + return { + sessionId: session.id, + sessionUrl: session.url, + }; + } + + /** + * Process refund for an order + * Supports full and partial refunds with inventory restoration + */ + async processRefund(params: ProcessRefundParams) { + const { orderId, amount, reason, idempotencyKey } = params; + + // Fetch order and payment + const order = await prisma.order.findUnique({ + where: { id: orderId }, + include: { + items: true, + paymentAttempts: { + where: { status: "SUCCESS" }, + orderBy: { createdAt: "desc" }, + take: 1, + }, + refunds: true, + store: { select: { stripeAccountId: true } }, + }, + }); + + if (!order || order.paymentAttempts.length === 0) { + throw new Error("No successful payment found for this order"); + } + + const paymentAttempt = order.paymentAttempts[0]; + const totalRefunded = order.refunds.reduce( + (sum, r) => sum + (r.status === "COMPLETED" ? r.amount : 0), + 0 + ); + const refundableBalance = order.totalAmount - totalRefunded; + + if (amount > refundableBalance) { + throw new Error(`Cannot refund ${amount}. Available balance: ${refundableBalance}`); + } + + if (!paymentAttempt.externalId) { + throw new Error("Payment intent ID not found"); + } + + // Process refund with Stripe + const refundOptions: Stripe.RefundCreateParams = { + payment_intent: paymentAttempt.externalId, + amount: Math.round(amount * 100), // Convert to cents + reason: reason === "REQUESTED_BY_CUSTOMER" ? "requested_by_customer" : undefined, + metadata: { + orderId, + orderNumber: order.orderNumber, + }, + }; + + const refund = await stripe.refunds.create( + refundOptions, + { + idempotencyKey, + ...(order.store.stripeAccountId && { + stripeAccount: order.store.stripeAccountId, + }), + } + ); + + // Create refund record + const refundRecord = await prisma.$transaction(async (tx) => { + const record = await tx.refund.create({ + data: { + orderId, + storeId: order.storeId, + paymentAttemptId: paymentAttempt.id, + amount, + status: refund.status === "succeeded" ? "COMPLETED" : "PENDING", + externalId: refund.id, + reason, + processedAt: refund.status === "succeeded" ? new Date() : null, + }, + }); + + // Update order status if fully refunded + if (amount === refundableBalance) { + await tx.order.update({ + where: { id: orderId }, + data: { + status: "REFUNDED", + refundedAmount: order.totalAmount, + }, + }); + + // Restore inventory + for (const item of order.items) { + if (item.productId) { + await tx.product.update({ + where: { id: item.productId }, + data: { inventoryQty: { increment: item.quantity } }, + }); + } + } + } else { + // Partial refund - update refunded amount + await tx.order.update({ + where: { id: orderId }, + data: { + refundedAmount: totalRefunded + amount, + }, + }); + } + + return record; + }); + + return refundRecord; + } + + /** + * Get payment intent details from Stripe + */ + async getPaymentIntent(paymentIntentId: string, stripeAccountId?: string) { + return stripe.paymentIntents.retrieve( + paymentIntentId, + stripeAccountId ? { stripeAccount: stripeAccountId } : undefined + ); + } +} + +// Singleton export +export const paymentService = new PaymentService(); From 351f64dbb2ffa3748a85d1799bac2dc747257d33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:46:38 +0000 Subject: [PATCH 3/4] Add refund API route and comprehensive Stripe integration documentation Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com> --- docs/STRIPE_INTEGRATION.md | 420 +++++++++++++++++++++++++++ src/app/api/payments/refund/route.ts | 108 +++++++ 2 files changed, 528 insertions(+) create mode 100644 docs/STRIPE_INTEGRATION.md create mode 100644 src/app/api/payments/refund/route.ts diff --git a/docs/STRIPE_INTEGRATION.md b/docs/STRIPE_INTEGRATION.md new file mode 100644 index 00000000..094e9e11 --- /dev/null +++ b/docs/STRIPE_INTEGRATION.md @@ -0,0 +1,420 @@ +# Stripe Payment Integration + +## Overview + +This implementation provides complete Stripe payment integration for StormCom, including: +- Checkout session creation +- Webhook handling for payment events +- Refund processing +- Multi-currency support +- Stripe Connect for multi-tenant payments + +## Architecture + +### Database Models + +#### PaymentAttempt +Tracks all payment attempts for orders. + +```typescript +{ + id: string + orderId: string + storeId: string + provider: string // "STRIPE" + amount: number + currency: string // "USD", "BDT", "EUR", etc. + status: "PENDING" | "SUCCESS" | "FAILED" + externalId: string // Stripe payment intent ID + errorCode?: string + errorMessage?: string + metadata?: string // JSON: { sessionId, sessionUrl } + processedAt?: Date + createdAt: Date + updatedAt: Date +} +``` + +#### Refund +Tracks refund transactions. + +```typescript +{ + id: string + orderId: string + storeId: string + paymentAttemptId: string + amount: number + status: "PENDING" | "COMPLETED" | "FAILED" + externalId?: string // Stripe refund ID + reason?: string + processedAt?: Date + createdAt: Date + updatedAt: Date +} +``` + +### Services + +#### PaymentService (`src/lib/services/payment.service.ts`) + +**createCheckoutSession(params)** +- Creates Stripe Checkout session +- Supports multi-currency (USD, BDT, EUR, GBP, etc.) +- Handles Stripe Connect for multi-tenant payments +- Creates pending PaymentAttempt record + +**processRefund(params)** +- Processes full or partial refunds +- Restores inventory on full refund +- Updates order status to REFUNDED when fully refunded +- Supports idempotency via idempotencyKey + +**getPaymentIntent(paymentIntentId, stripeAccountId?)** +- Retrieves payment intent details from Stripe + +### API Routes + +#### POST /api/payments/create-session +Creates Stripe Checkout session for an order. + +**Request:** +```json +{ + "orderId": "clxxx..." +} +``` + +**Response:** +```json +{ + "sessionId": "cs_test_...", + "sessionUrl": "https://checkout.stripe.com/..." +} +``` + +**Authentication:** Required (NextAuth session) +**Validation:** +- Order must exist +- User must have access to store +- Order status must be PENDING or PAYMENT_FAILED + +#### POST /api/payments/refund +Processes refund for an order. + +**Request:** +```json +{ + "orderId": "clxxx...", + "amount": 50.00, + "reason": "REQUESTED_BY_CUSTOMER" // optional +} +``` + +**Response:** +```json +{ + "success": true, + "refund": { + "id": "clyyy...", + "amount": 50.00, + "status": "COMPLETED", + "externalId": "re_...", + "processedAt": "2024-12-06T18:00:00Z" + } +} +``` + +**Authentication:** Required (NextAuth session) +**Validation:** +- Order must exist +- User must have access to store +- Order status must be PAID, PROCESSING, or DELIVERED +- Refund amount must not exceed order total + +#### POST /api/webhooks/stripe +Handles Stripe webhook events. + +**Supported Events:** +- `checkout.session.completed`: Updates order to PAID, marks payment attempt SUCCESS +- `payment_intent.succeeded`: Updates payment attempt to SUCCESS +- `payment_intent.payment_failed`: Updates order to PAYMENT_FAILED with error details +- `charge.refunded`: Updates refund status to COMPLETED + +**Security:** +- Verifies webhook signature using STRIPE_WEBHOOK_SECRET +- Returns 400 for invalid signatures + +### Components + +#### CheckoutButton (`src/components/checkout-button.tsx`) +Client component that initiates Stripe checkout flow. + +**Props:** +```typescript +{ + orderId: string + disabled?: boolean + className?: string +} +``` + +**Usage:** +```tsx +import { CheckoutButton } from "@/components/checkout-button"; + + +``` + +## Environment Variables + +Required environment variables in `.env.local`: + +```bash +# Stripe Test Mode +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... + +# Production +STRIPE_SECRET_KEY=sk_live_... +STRIPE_PUBLISHABLE_KEY=pk_live_... +STRIPE_WEBHOOK_SECRET=whsec_... +``` + +## Multi-Currency Support + +The system supports multiple currencies through the Store model's `currency` field. + +**Supported Currencies:** +- USD (United States Dollar) +- BDT (Bangladeshi Taka) +- EUR (Euro) +- GBP (British Pound) +- And all other Stripe-supported currencies + +**Implementation:** +- Amounts stored in base currency (e.g., dollars, taka) +- Converted to smallest unit (cents, paisa) for Stripe: `Math.round(amount * 100)` +- Currency code passed to Stripe in lowercase: `currency.toLowerCase()` + +## Multi-Tenancy (Stripe Connect) + +Each store can have its own Stripe Connect account for payment routing. + +**Store Configuration:** +```typescript +{ + stripeAccountId: "acct_..." // Stripe Connect account ID + stripeSecretKey: "sk_..." // Encrypted secret key + stripePublishableKey: "pk_..." // Publishable key +} +``` + +**Payment Routing:** +When creating checkout session or processing refund, if store has `stripeAccountId`: +```typescript +stripe.checkout.sessions.create( + sessionOptions, + { stripeAccount: store.stripeAccountId } +); +``` + +## Payment Flow + +### 1. Checkout Initiation +``` +User clicks "Proceed to Payment" + ↓ +CheckoutButton calls /api/payments/create-session + ↓ +PaymentService.createCheckoutSession() + ↓ +Create PaymentAttempt (PENDING) + ↓ +Return Stripe Checkout URL + ↓ +Redirect user to Stripe Checkout +``` + +### 2. Payment Completion +``` +User completes payment on Stripe + ↓ +Stripe sends webhook: checkout.session.completed + ↓ +Webhook handler verifies signature + ↓ +Update PaymentAttempt (SUCCESS) +Update Order (PAID, paidAt = now) +Create AuditLog + ↓ +User redirected to success page +``` + +### 3. Payment Failure +``` +Payment fails (declined card, etc.) + ↓ +Stripe sends webhook: payment_intent.payment_failed + ↓ +Update PaymentAttempt (FAILED, errorCode, errorMessage) +Update Order (PAYMENT_FAILED) + ↓ +User can retry payment +``` + +### 4. Refund Processing +``` +Admin initiates refund + ↓ +POST /api/payments/refund + ↓ +PaymentService.processRefund() + ↓ +Create refund in Stripe +Create Refund record (PENDING/COMPLETED) + ↓ +If full refund: + - Update Order (REFUNDED) + - Restore inventory +If partial refund: + - Update Order.refundedAmount + ↓ +Stripe sends webhook: charge.refunded + ↓ +Update Refund (COMPLETED) +``` + +## Testing + +### Test Cards + +**Successful Payment:** +``` +Card: 4242 4242 4242 4242 +Expiry: Any future date +CVC: Any 3 digits +``` + +**Declined Payment:** +``` +Card: 4000 0000 0000 0002 +Expiry: Any future date +CVC: Any 3 digits +``` + +**Insufficient Funds:** +``` +Card: 4000 0000 0000 9995 +``` + +### Webhook Testing with Stripe CLI + +**Install Stripe CLI:** +```bash +# macOS +brew install stripe/stripe-cli/stripe + +# Linux/Windows +# Download from https://github.com/stripe/stripe-cli/releases +``` + +**Login and Forward Webhooks:** +```bash +# Login +stripe login + +# Forward webhooks to local dev server +stripe listen --forward-to localhost:3000/api/webhooks/stripe +``` + +**Trigger Test Events:** +```bash +# Successful checkout +stripe trigger checkout.session.completed + +# Payment succeeded +stripe trigger payment_intent.succeeded + +# Payment failed +stripe trigger payment_intent.payment_failed + +# Refund completed +stripe trigger charge.refunded +``` + +## Error Handling + +### Payment Attempt Errors +Stored in PaymentAttempt model: +- `errorCode`: Stripe error code (e.g., "card_declined", "insufficient_funds") +- `errorMessage`: Human-readable error message + +### Common Error Codes +- `card_declined`: Card was declined +- `expired_card`: Card has expired +- `insufficient_funds`: Insufficient funds +- `invalid_cvc`: Invalid security code +- `processing_error`: Error processing the card + +### Webhook Error Handling +- Invalid signature: Returns 400 status +- Missing orderId: Logs error and returns 200 (to prevent retries) +- Database errors: Logs error and returns 500 (Stripe will retry) + +## Security + +### Webhook Signature Verification +All webhooks must pass signature verification: +```typescript +const event = stripe.webhooks.constructEvent( + body, + signature, + webhookSecret +); +``` + +### API Authentication +All payment APIs require authenticated NextAuth session. + +### Multi-Tenant Isolation +- All queries filtered by `storeId` +- User access validated via Membership relation +- Stripe Connect accounts isolated per store + +### Environment Security +- API keys stored in environment variables +- Store-specific keys encrypted in database +- Never expose secret keys to client + +## Performance Targets + +- Create checkout session: < 500ms +- Process webhook: < 2 seconds +- Process refund: < 3 seconds + +## Audit Trail + +All payment events logged in AuditLog: +```typescript +{ + action: "PAYMENT_COMPLETED", + entityType: "Order", + entityId: orderId, + changes: { + paymentIntentId: "pi_...", + amount: 5000 + } +} +``` + +## Future Enhancements + +1. **Subscription Support**: Recurring payments via Stripe Subscriptions +2. **Payment Method Storage**: Save cards for future payments +3. **3D Secure**: Enhanced authentication for European cards +4. **Alternative Payment Methods**: Bank transfers, wallets, etc. +5. **Split Payments**: Platform fee + vendor payout +6. **Dispute Management**: Handle chargebacks and disputes +7. **Payment Analytics**: Revenue reports and trends diff --git a/src/app/api/payments/refund/route.ts b/src/app/api/payments/refund/route.ts new file mode 100644 index 00000000..603e1749 --- /dev/null +++ b/src/app/api/payments/refund/route.ts @@ -0,0 +1,108 @@ +// src/app/api/payments/refund/route.ts +// API Route to Process Refunds +// Validates user access and processes full or partial refunds + +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { paymentService } from "@/lib/services/payment.service"; +import { prisma } from "@/lib/prisma"; +import { z } from "zod"; +import { randomBytes } from "crypto"; + +const RefundSchema = z.object({ + orderId: z.string().cuid(), + amount: z.number().positive(), + reason: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { orderId, amount, reason } = RefundSchema.parse(body); + + // Verify order exists and get storeId + const order = await prisma.order.findUnique({ + where: { + id: orderId, + }, + select: { storeId: true, status: true, totalAmount: true }, + }); + + if (!order) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + // Verify user has access to the store + const storeAccess = await prisma.membership.findFirst({ + where: { + userId: session.user.id, + organization: { + store: { + id: order.storeId, + }, + }, + }, + }); + + if (!storeAccess) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + // Check if order is in a valid state for refund + if (order.status !== "PAID" && order.status !== "PROCESSING" && order.status !== "DELIVERED") { + return NextResponse.json( + { error: "Order is not in a valid state for refund" }, + { status: 400 } + ); + } + + // Validate refund amount + if (amount > order.totalAmount) { + return NextResponse.json( + { error: "Refund amount exceeds order total" }, + { status: 400 } + ); + } + + // Generate idempotency key + const idempotencyKey = `refund_${orderId}_${randomBytes(8).toString("hex")}`; + + const refund = await paymentService.processRefund({ + orderId, + amount, + reason, + idempotencyKey, + }); + + return NextResponse.json({ + success: true, + refund: { + id: refund.id, + amount: refund.amount, + status: refund.status, + externalId: refund.externalId, + processedAt: refund.processedAt, + }, + }); + } catch (error) { + console.error("Refund error:", error); + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.issues }, + { status: 400 } + ); + } + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to process refund" + }, + { status: 500 } + ); + } +} From 7c96e4fc01b58befc4b297e5ecebfcf0ec2814c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:50:28 +0000 Subject: [PATCH 4/4] Add comprehensive Stripe testing guide Co-authored-by: rafiqul4 <124497017+rafiqul4@users.noreply.github.com> --- docs/STRIPE_TESTING_GUIDE.md | 364 +++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 docs/STRIPE_TESTING_GUIDE.md diff --git a/docs/STRIPE_TESTING_GUIDE.md b/docs/STRIPE_TESTING_GUIDE.md new file mode 100644 index 00000000..5281588e --- /dev/null +++ b/docs/STRIPE_TESTING_GUIDE.md @@ -0,0 +1,364 @@ +# Stripe Payment Integration - Testing Guide + +## Overview +This guide provides step-by-step instructions for testing the Stripe payment integration implementation. + +## Prerequisites + +1. **Stripe Account** + - Sign up at https://stripe.com + - Get test mode API keys from Dashboard > Developers > API keys + - Set up webhook endpoint in Dashboard > Developers > Webhooks + +2. **Environment Setup** + ```bash + # Update .env.local with your Stripe test keys + STRIPE_SECRET_KEY=sk_test_YOUR_TEST_KEY + STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_TEST_KEY + STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET + ``` + +3. **Database Setup** + ```bash + npm run prisma:generate + npm run db:seed # Optional: seed test data + ``` + +4. **Stripe CLI** (for webhook testing) + ```bash + # macOS + brew install stripe/stripe-cli/stripe + + # Login + stripe login + ``` + +## Test Cases + +### 1. Successful Payment Flow + +**Objective:** Verify end-to-end payment completion + +**Steps:** +1. Create a test order via API or UI +2. Get the order ID +3. Call the payment session creation API: + ```bash + curl -X POST http://localhost:3000/api/payments/create-session \ + -H "Content-Type: application/json" \ + -H "Cookie: next-auth.session-token=YOUR_SESSION_TOKEN" \ + -d '{"orderId": "clxxx..."}' + ``` +4. Open the returned `sessionUrl` in browser +5. Complete payment with test card: `4242 4242 4242 4242` +6. Verify order status changed to PAID in database +7. Verify PaymentAttempt created with status SUCCESS +8. Verify paidAt timestamp set + +**Expected Results:** +- Order.status = "PAID" +- Order.paymentStatus = "PAID" +- Order.paidAt = timestamp +- PaymentAttempt.status = "SUCCESS" +- PaymentAttempt.externalId = Stripe payment intent ID +- AuditLog entry created with action "PAYMENT_COMPLETED" + +### 2. Declined Payment + +**Objective:** Verify payment failure handling + +**Steps:** +1. Create a test order +2. Create payment session +3. Use declined test card: `4000 0000 0000 0002` +4. Verify order status changed to PAYMENT_FAILED +5. Check PaymentAttempt for error details + +**Expected Results:** +- Order.status = "PAYMENT_FAILED" +- Order.paymentStatus = "FAILED" +- PaymentAttempt.status = "FAILED" +- PaymentAttempt.errorCode = "card_declined" +- PaymentAttempt.errorMessage contains error description + +### 3. Full Refund + +**Objective:** Verify full refund processing and inventory restoration + +**Steps:** +1. Complete a successful payment (Test Case 1) +2. Note the product inventory quantities before refund +3. Process full refund: + ```bash + curl -X POST http://localhost:3000/api/payments/refund \ + -H "Content-Type: application/json" \ + -H "Cookie: next-auth.session-token=YOUR_SESSION_TOKEN" \ + -d '{ + "orderId": "clxxx...", + "amount": 100.00, + "reason": "REQUESTED_BY_CUSTOMER" + }' + ``` +4. Verify inventory restored +5. Check order status + +**Expected Results:** +- Order.status = "REFUNDED" +- Order.refundedAmount = Order.totalAmount +- Refund.status = "COMPLETED" +- Refund.externalId = Stripe refund ID +- Product inventory quantities incremented by order quantities + +### 4. Partial Refund + +**Objective:** Verify partial refund handling + +**Steps:** +1. Complete a successful payment for $100 +2. Process partial refund for $40: + ```bash + curl -X POST http://localhost:3000/api/payments/refund \ + -H "Content-Type: application/json" \ + -H "Cookie: next-auth.session-token=YOUR_SESSION_TOKEN" \ + -d '{ + "orderId": "clxxx...", + "amount": 40.00 + }' + ``` +3. Verify refundable balance + +**Expected Results:** +- Order.status remains "PAID" or "PROCESSING" +- Order.refundedAmount = 40.00 +- Refund.status = "COMPLETED" +- Refund.amount = 40.00 +- Inventory NOT restored (partial refund) +- Refundable balance = $60.00 + +### 5. Multi-Currency Payment + +**Objective:** Verify BDT (Bangladeshi Taka) payment processing + +**Steps:** +1. Create store with currency = "BDT" +2. Create order with amount in BDT (e.g., 1000 BDT) +3. Create payment session +4. Verify Stripe session created with currency "bdt" +5. Verify amount converted to paisa (100000 paisa) + +**Expected Results:** +- PaymentAttempt.currency = "BDT" +- PaymentAttempt.amount = 1000 (in taka) +- Stripe session amount_total = 100000 (in paisa) + +### 6. Webhook Signature Verification + +**Objective:** Verify webhook security + +**Steps:** +1. Send webhook with invalid signature: + ```bash + curl -X POST http://localhost:3000/api/webhooks/stripe \ + -H "stripe-signature: invalid_signature" \ + -d '{"type": "checkout.session.completed"}' + ``` +2. Verify 400 response + +**Expected Results:** +- HTTP 400 Bad Request +- Response: `{"error": "Invalid signature"}` +- Webhook event NOT processed + +### 7. Webhook Event Processing + +**Objective:** Verify webhook handlers using Stripe CLI + +**Steps:** +1. Start dev server: `npm run dev` +2. Forward webhooks: `stripe listen --forward-to localhost:3000/api/webhooks/stripe` +3. Trigger events: + ```bash + # Successful checkout + stripe trigger checkout.session.completed + + # Payment succeeded + stripe trigger payment_intent.succeeded + + # Payment failed + stripe trigger payment_intent.payment_failed + + # Charge refunded + stripe trigger charge.refunded + ``` +4. Check console logs for event processing +5. Verify database updates + +**Expected Results:** +- Each event processed successfully +- Corresponding database records updated +- Webhook returns 200 OK + +### 8. Multi-Tenancy Isolation + +**Objective:** Verify payment isolation between stores + +**Steps:** +1. Create two stores (Store A and Store B) +2. Create order in Store A +3. Attempt to create payment session as user from Store B +4. Verify access denied + +**Expected Results:** +- HTTP 403 Forbidden +- Response: `{"error": "Access denied"}` +- Payment session NOT created + +### 9. Idempotency + +**Objective:** Verify duplicate refund prevention + +**Steps:** +1. Complete a successful payment +2. Note the refund count in database +3. Process refund twice with same data +4. Verify only one refund created + +**Expected Results:** +- First refund: Success +- Second refund: Idempotent (Stripe returns same refund) +- Only one Refund record in database + +### 10. Stripe Connect + +**Objective:** Verify multi-tenant payment routing + +**Steps:** +1. Set Store.stripeAccountId to a test connected account ID +2. Create payment session +3. Verify Stripe API called with stripeAccount parameter +4. Check Stripe dashboard for payment under connected account + +**Expected Results:** +- Payment routed to connected account +- Platform receives connect fee (if configured) + +## Performance Testing + +### Response Time Benchmarks + +**Create Checkout Session:** < 500ms +```bash +time curl -X POST http://localhost:3000/api/payments/create-session \ + -H "Content-Type: application/json" \ + -H "Cookie: next-auth.session-token=TOKEN" \ + -d '{"orderId": "clxxx..."}' +``` + +**Webhook Processing:** < 2 seconds +Monitor webhook endpoint response times in Stripe dashboard. + +**Refund Processing:** < 3 seconds +```bash +time curl -X POST http://localhost:3000/api/payments/refund \ + -H "Content-Type: application/json" \ + -H "Cookie: next-auth.session-token=TOKEN" \ + -d '{"orderId": "clxxx...", "amount": 50.00}' +``` + +## Database Verification + +After each test, verify database state: + +```sql +-- Check order status +SELECT id, orderNumber, status, paymentStatus, paidAt, refundedAmount +FROM Order +WHERE id = 'clxxx...'; + +-- Check payment attempts +SELECT id, provider, amount, currency, status, errorCode, processedAt +FROM PaymentAttempt +WHERE orderId = 'clxxx...'; + +-- Check refunds +SELECT id, amount, status, externalId, reason, processedAt +FROM Refund +WHERE orderId = 'clxxx...'; + +-- Check audit logs +SELECT action, entityType, entityId, changes +FROM AuditLog +WHERE entityId = 'clxxx...' +ORDER BY createdAt DESC; +``` + +## Error Scenarios + +### Test Error Handling + +1. **Expired Card:** `4000 0000 0000 0069` +2. **Processing Error:** `4000 0000 0000 0119` +3. **Insufficient Funds:** `4000 0000 0000 9995` + +For each, verify: +- Appropriate error code stored +- User-friendly error message +- Order remains in PAYMENT_FAILED state +- User can retry payment + +## Security Checklist + +- [ ] Webhook signatures verified +- [ ] API keys never exposed to client +- [ ] Authentication required for all payment APIs +- [ ] Multi-tenant queries filtered by storeId +- [ ] User access validated before operations +- [ ] Stripe API errors logged but not exposed to users +- [ ] HTTPS enforced for webhook endpoints +- [ ] Environment variables properly configured +- [ ] No hardcoded credentials in code + +## Integration Testing + +For complete integration testing: + +1. Set up test Stripe account +2. Configure webhook endpoint in Stripe dashboard +3. Run full payment flow from order creation to refund +4. Verify all database records created correctly +5. Check Stripe dashboard for payment records +6. Test failure scenarios +7. Verify audit trail completeness + +## Troubleshooting + +**Payment session creation fails:** +- Check STRIPE_SECRET_KEY is set +- Verify order exists and status is PENDING +- Check user has access to store + +**Webhook not received:** +- Verify webhook URL in Stripe dashboard +- Check STRIPE_WEBHOOK_SECRET matches +- Use Stripe CLI to test locally + +**Refund fails:** +- Verify payment was successful +- Check refund amount ≤ order total +- Ensure payment intent ID exists + +**Type errors during build:** +- Run `npm run prisma:generate` +- Check Stripe API version matches + +## Success Criteria + +All tests pass when: +- ✅ Type-check passes +- ✅ Lint passes (0 errors, 1 expected warning) +- ✅ Build succeeds +- ✅ All 10 test cases pass +- ✅ Performance targets met +- ✅ Security checklist complete +- ✅ No CodeQL alerts +- ✅ No dependency vulnerabilities