From 55a0133ed477a37a0d59bf2e305c57b9d4188848 Mon Sep 17 00:00:00 2001 From: nthanhtoan61 Date: Tue, 19 May 2026 12:48:41 +0700 Subject: [PATCH] paypal --- .env.example | 9 +- app/(site)/registration/cancel/page.tsx | 71 + app/(site)/registration/success/page.tsx | 142 ++ app/api/payments/cancel/route.ts | 18 + app/api/payments/capture/route.ts | 68 + app/api/payments/create-order/route.ts | 83 + .../payments/webhook/__tests__/route.test.ts | 173 ++ app/api/payments/webhook/route.ts | 85 + app/components/admin/AdminConsole.tsx | 121 +- app/components/forms/RegistrationForm.tsx | 25 +- app/components/ui/SubmitToast.tsx | 4 +- app/data/admin.json | 8 + app/data/admin.vi.json | 8 + lib/__tests__/paypal.test.ts | 197 ++ lib/__tests__/summit-requests-payment.test.ts | 167 ++ lib/paypal.ts | 281 +++ lib/summit-requests.ts | 70 + models/SummitRequest.ts | 24 + package-lock.json | 2208 ++++++++++++++++- package.json | 14 +- types/admin-submission.ts | 7 + vitest.config.ts | 19 + vitest.setup.ts | 1 + 23 files changed, 3781 insertions(+), 22 deletions(-) create mode 100644 app/(site)/registration/cancel/page.tsx create mode 100644 app/(site)/registration/success/page.tsx create mode 100644 app/api/payments/cancel/route.ts create mode 100644 app/api/payments/capture/route.ts create mode 100644 app/api/payments/create-order/route.ts create mode 100644 app/api/payments/webhook/__tests__/route.test.ts create mode 100644 app/api/payments/webhook/route.ts create mode 100644 lib/__tests__/paypal.test.ts create mode 100644 lib/__tests__/summit-requests-payment.test.ts create mode 100644 lib/paypal.ts create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/.env.example b/.env.example index b845899..777c607 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,11 @@ PORT=3000 # MongoDB — summit request queue (registration + feedback) MONGODB_URI=mongodb://localhost:27017/ipv6_summit -NEXT_PUBLIC_BASE_PATH=/ipv6 \ No newline at end of file +NEXT_PUBLIC_BASE_PATH=/ipv6 +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# PayPal Integration +PAYPAL_CLIENT_ID=your_client_id_here +PAYPAL_CLIENT_SECRET=your_client_secret_here +PAYPAL_WEBHOOK_ID=your_webhook_id_here +PAYPAL_MODE=sandbox diff --git a/app/(site)/registration/cancel/page.tsx b/app/(site)/registration/cancel/page.tsx new file mode 100644 index 0000000..720f3b4 --- /dev/null +++ b/app/(site)/registration/cancel/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; + +export default function RegistrationCancelPage() { + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + const [cancelled, setCancelled] = useState(false); + + useEffect(() => { + if (token) { + fetch("/ipv6/api/payments/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }).finally(() => setCancelled(true)); + } else { + setCancelled(true); + } + }, [token]); + + return ( +
+
+
+
+ +
+ + cancel + +
+ +

+ Thanh toán đã bị hủy +

+ +

+ Bạn đã hủy quá trình thanh toán. Đăng ký của bạn chưa được xác nhận. +

+ +
+ + refresh + Thử lại thanh toán + + + + home + Về trang chủ + +
+ + {!cancelled && ( +

+ Đang cập nhật trạng thái... +

+ )} +
+
+
+ ); +} diff --git a/app/(site)/registration/success/page.tsx b/app/(site)/registration/success/page.tsx new file mode 100644 index 0000000..a02ac6b --- /dev/null +++ b/app/(site)/registration/success/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import Link from "next/link"; + +export default function RegistrationSuccessPage() { + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + const payerId = searchParams.get("PayerID"); + + const [state, setState] = useState<"loading" | "success" | "error">("loading"); + const [registrationId, setRegistrationId] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (!token || !payerId) { + setState("error"); + setErrorMessage("Thiếu thông tin thanh toán."); + return; + } + + fetch("/ipv6/api/payments/capture", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, PayerID: payerId }), + }) + .then(async (res) => { + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error ?? "Capture thất bại."); + } + return res.json(); + }) + .then((data) => { + setRegistrationId(data.registrationId); + setState("success"); + }) + .catch((err) => { + setErrorMessage(err.message); + setState("error"); + }); + }, [token, payerId]); + + return ( +
+
+ {state === "loading" && ( +
+
+ + progress_activity + +
+

+ Đang xử lý thanh toán của bạn... +

+
+ )} + + {state === "success" && ( +
+
+ +
+ + check_circle + +
+ +

+ Thanh toán thành công! +

+ +

+ Cảm ơn bạn đã đăng ký tham dự IPv6 Summit. +

+ +
+ {registrationId && ( +
+ Mã đăng ký + + #{registrationId} + +
+ )} +
+ Số tiền đã thanh toán + $120 USD +
+
+ +

+ Chúng tôi sẽ liên hệ với bạn qua email để xác nhận. +

+ +
+ + home + Về trang chủ + +
+
+ )} + + {state === "error" && ( +
+
+ +
+ + error + +
+ +

+ Có lỗi xảy ra +

+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + + + refresh + Thử lại đăng ký + +
+ )} +
+
+ ); +} diff --git a/app/api/payments/cancel/route.ts b/app/api/payments/cancel/route.ts new file mode 100644 index 0000000..edd6c35 --- /dev/null +++ b/app/api/payments/cancel/route.ts @@ -0,0 +1,18 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import { markPaymentCancelled } from "@/lib/summit-requests"; + +export async function POST(req: NextRequest): Promise { + try { + const { token } = (await req.json()) as { token: string }; + if (!token) { + return NextResponse.json({ error: "token is required." }, { status: 400 }); + } + await markPaymentCancelled(token); + return NextResponse.json({ ok: true }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to cancel payment."; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/app/api/payments/capture/route.ts b/app/api/payments/capture/route.ts new file mode 100644 index 0000000..5a48f22 --- /dev/null +++ b/app/api/payments/capture/route.ts @@ -0,0 +1,68 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import { capturePayPalOrder } from "@/lib/paypal"; +import { findByPaypalOrderId, markPaymentPaid } from "@/lib/summit-requests"; + +interface CaptureBody { + token: string; // PayPal Order ID (from query param after redirect) + PayerID: string; +} + +export async function POST(req: NextRequest): Promise { + let body: CaptureBody; + + try { + body = (await req.json()) as CaptureBody; + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + // Validate required fields + if (!body.token || !body.PayerID) { + return NextResponse.json( + { error: "token and PayerID are required." }, + { status: 400 } + ); + } + + const { token, PayerID } = body; + console.log(`[capture] Received capture request for orderId=${token} PayerID=${PayerID}`); + + // Look up the registration by PayPal Order ID + const registration = await findByPaypalOrderId(token); + + if (!registration || registration.paymentStatus !== "payment_pending") { + console.log( + `[capture] Order not found or not in payment_pending state: orderId=${token} paymentStatus=${registration?.paymentStatus ?? "not found"}` + ); + return NextResponse.json( + { error: "Order not found or not in payment_pending state." }, + { status: 409 } + ); + } + + console.log(`[capture] Found registration publicId=${registration.publicId}, proceeding to capture`); + + // Capture the PayPal order + let captureId: string; + + try { + const result = await capturePayPalOrder(token); + captureId = result.captureId; + console.log(`[capture] PayPal capture succeeded: captureId=${captureId} status=${result.status}`); + } catch (err) { + const message = err instanceof Error ? err.message : "PayPal capture failed."; + console.error(`[capture] PayPal capture failed for orderId=${token}: ${message}`); + return NextResponse.json({ error: message }, { status: 502 }); + } + + // Mark the registration as paid in the DB + await markPaymentPaid(token, captureId); + console.log(`[capture] Marked payment as paid for orderId=${token} captureId=${captureId}`); + + return NextResponse.json( + { ok: true, captureId, registrationId: registration.publicId }, + { status: 200 } + ); +} diff --git a/app/api/payments/create-order/route.ts b/app/api/payments/create-order/route.ts new file mode 100644 index 0000000..e75cf46 --- /dev/null +++ b/app/api/payments/create-order/route.ts @@ -0,0 +1,83 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import { createPayPalOrder } from "@/lib/paypal"; +import { createRegistrationWithPayment } from "@/lib/summit-requests"; +import type { AdminRequestRow } from "@/types/admin-submission"; + +interface CreateOrderBody { + fullName: string; + phone?: string; + email: string; + company?: string; + jobTitle?: string; + industry?: string; + industryLabel?: string; + notes?: string; +} + +export async function POST(req: NextRequest): Promise { + let body: CreateOrderBody; + + try { + body = (await req.json()) as CreateOrderBody; + } catch { + return NextResponse.json({ error: "Invalid JSON body." }, { status: 400 }); + } + + // Validate required fields + if (!body.fullName || !body.email) { + console.error("[create-order] Validation failed — fullName:", JSON.stringify(body.fullName), "email:", JSON.stringify(body.email)); + return NextResponse.json( + { error: "fullName and email are required." }, + { status: 400 } + ); + } + + // Generate a short unique public ID + const publicId = "V6-" + crypto.randomUUID().slice(0, 8).toUpperCase(); + + // Build the AdminRequestRow + const row: AdminRequestRow = { + id: publicId, + submittedAt: new Date().toISOString().slice(0, 10), + displayDate: new Date() + .toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }) + .toUpperCase(), + fullName: body.fullName, + jobTitle: body.jobTitle ?? "", + company: body.company ?? "", + segment: body.industry ?? "", + email: body.email, + phone: body.phone ?? "", + status: "pending", + notes: body.notes ?? "", + source: "registration", + }; + + // Create the PayPal order + let orderId: string; + let approvalUrl: string; + + try { + const result = await createPayPalOrder(publicId); + orderId = result.orderId; + approvalUrl = result.approvalUrl; + } catch (err) { + const message = err instanceof Error ? err.message : "PayPal order creation failed."; + console.error("[create-order] PayPal error:", message); + return NextResponse.json({ error: message }, { status: 502 }); + } + + // Persist the registration with the pending payment + await createRegistrationWithPayment(row, orderId); + + return NextResponse.json( + { approvalUrl, registrationId: publicId }, + { status: 200 } + ); +} diff --git a/app/api/payments/webhook/__tests__/route.test.ts b/app/api/payments/webhook/__tests__/route.test.ts new file mode 100644 index 0000000..ebdbab8 --- /dev/null +++ b/app/api/payments/webhook/__tests__/route.test.ts @@ -0,0 +1,173 @@ +/** + * Property-based tests for the webhook handler + * Feature: paypal-payment-integration + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as fc from "fast-check"; +import { NextRequest } from "next/server"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- +const mockVerifyWebhookSignature = vi.fn(); +const mockMarkPaymentPaid = vi.fn(); + +vi.mock("@/lib/paypal", () => ({ + verifyWebhookSignature: (...args: unknown[]) => mockVerifyWebhookSignature(...args), +})); + +vi.mock("@/lib/summit-requests", () => ({ + markPaymentPaid: (...args: unknown[]) => mockMarkPaymentPaid(...args), +})); + +function makeRequest(body: unknown, headers: Record = {}): NextRequest { + const defaultHeaders: Record = { + "paypal-transmission-id": "tx-123", + "paypal-transmission-time": "2026-01-01T00:00:00Z", + "paypal-cert-url": "https://api.paypal.com/cert", + "paypal-auth-algo": "SHA256withRSA", + "paypal-transmission-sig": "sig-abc", + "content-type": "application/json", + ...headers, + }; + return new NextRequest("http://localhost/api/payments/webhook", { + method: "POST", + headers: defaultHeaders, + body: JSON.stringify(body), + }); +} + +// --------------------------------------------------------------------------- +// Property 8: Webhook với signature không hợp lệ → HTTP 401 +// --------------------------------------------------------------------------- +describe("webhook handler — signature validation", () => { + beforeEach(() => { + mockVerifyWebhookSignature.mockReset(); + mockMarkPaymentPaid.mockReset(); + process.env.PAYPAL_WEBHOOK_ID = "test-webhook-id"; + }); + + it( + "Feature: paypal-payment-integration, Property 8: Webhook signature không hợp lệ → 401", + async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + event_type: fc.string(), + resource: fc.record({ id: fc.string() }), + }), + async (payload) => { + mockVerifyWebhookSignature.mockResolvedValue(false); + mockMarkPaymentPaid.mockResolvedValue(undefined); + + const { POST } = await import("../route"); + const req = makeRequest(payload); + const res = await POST(req); + + expect(res.status).toBe(401); + expect(mockMarkPaymentPaid).not.toHaveBeenCalled(); + } + ), + { numRuns: 30 } + ); + } + ); + + it( + "Feature: paypal-payment-integration, Property 8b: Webhook thiếu headers → 401", + async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + event_type: fc.string(), + resource: fc.record({ id: fc.string() }), + }), + async (payload) => { + // Missing all PayPal signature headers + const { POST } = await import("../route"); + const req = makeRequest(payload, { + "paypal-transmission-id": "", + "paypal-transmission-time": "", + "paypal-cert-url": "", + "paypal-auth-algo": "", + "paypal-transmission-sig": "", + }); + const res = await POST(req); + + expect(res.status).toBe(401); + } + ), + { numRuns: 20 } + ); + } + ); +}); + +// --------------------------------------------------------------------------- +// Property 9: Webhook idempotent — valid signature, already paid → 200 +// Property 10: Webhook hợp lệ cập nhật trạng thái sang paid +// --------------------------------------------------------------------------- +describe("webhook handler — PAYMENT.CAPTURE.COMPLETED", () => { + beforeEach(() => { + mockVerifyWebhookSignature.mockReset(); + mockMarkPaymentPaid.mockReset(); + process.env.PAYPAL_WEBHOOK_ID = "test-webhook-id"; + }); + + it( + "Feature: paypal-payment-integration, Property 10: Webhook hợp lệ cập nhật trạng thái sang paid", + async () => { + await fc.assert( + fc.asyncProperty( + fc.string({ minLength: 1, maxLength: 30 }), + fc.string({ minLength: 1, maxLength: 30 }), + async (orderId, captureId) => { + mockVerifyWebhookSignature.mockResolvedValue(true); + mockMarkPaymentPaid.mockResolvedValue(undefined); + + const payload = { + event_type: "PAYMENT.CAPTURE.COMPLETED", + resource: { + id: captureId, + supplementary_data: { + related_ids: { order_id: orderId }, + }, + }, + }; + + const { POST } = await import("../route"); + const req = makeRequest(payload); + const res = await POST(req); + + expect(res.status).toBe(200); + expect(mockMarkPaymentPaid).toHaveBeenCalledWith(orderId, captureId); + } + ), + { numRuns: 30 } + ); + } + ); + + it( + "Feature: paypal-payment-integration, Property 9: Webhook idempotent — trả về 200 kể cả khi đã paid", + async () => { + // markPaymentPaid is idempotent (no-op if already paid) — webhook should still return 200 + mockVerifyWebhookSignature.mockResolvedValue(true); + mockMarkPaymentPaid.mockResolvedValue(undefined); // no-op + + const payload = { + event_type: "PAYMENT.CAPTURE.COMPLETED", + resource: { + id: "capture-already-done", + supplementary_data: { related_ids: { order_id: "order-already-paid" } }, + }, + }; + + const { POST } = await import("../route"); + const req = makeRequest(payload); + const res = await POST(req); + + expect(res.status).toBe(200); + } + ); +}); diff --git a/app/api/payments/webhook/route.ts b/app/api/payments/webhook/route.ts new file mode 100644 index 0000000..97e6645 --- /dev/null +++ b/app/api/payments/webhook/route.ts @@ -0,0 +1,85 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import { verifyWebhookSignature } from "@/lib/paypal"; +import { markPaymentPaid } from "@/lib/summit-requests"; + +export async function POST(req: NextRequest): Promise { + // Read raw body as text for signature verification + const rawBody = await req.text(); + + // Parse the body as JSON + const event = JSON.parse(rawBody); + + // Read required PayPal signature headers + const transmissionId = req.headers.get("paypal-transmission-id"); + const transmissionTime = req.headers.get("paypal-transmission-time"); + const certUrl = req.headers.get("paypal-cert-url"); + const authAlgo = req.headers.get("paypal-auth-algo"); + const transmissionSig = req.headers.get("paypal-transmission-sig"); + + // Return 401 if any required header is missing + if (!transmissionId || !transmissionTime || !certUrl || !authAlgo || !transmissionSig) { + return NextResponse.json( + { error: "Missing PayPal signature headers." }, + { status: 401 } + ); + } + + // Verify webhook signature + const isValid = await verifyWebhookSignature({ + authAlgo, + certUrl, + transmissionId, + transmissionSig, + transmissionTime, + webhookId: process.env.PAYPAL_WEBHOOK_ID ?? "", + webhookEvent: event, + }); + + if (!isValid) { + return NextResponse.json( + { error: "Invalid webhook signature." }, + { status: 401 } + ); + } + + console.log( + "[webhook] Received event:", + event.event_type, + "resource_id:", + event.resource?.id, + "transmission_id:", + transmissionId + ); + + try { + if (event.event_type === "PAYMENT.CAPTURE.COMPLETED") { + const orderId = + event.resource?.supplementary_data?.related_ids?.order_id ?? + event.resource?.id; + + const result = await markPaymentPaid(orderId, event.resource.id); + console.log( + "[webhook] markPaymentPaid result for orderId:", + orderId, + "captureId:", + event.resource.id, + result + ); + + return NextResponse.json({ received: true }, { status: 200 }); + } + + // All other event types — acknowledge and ignore + console.log("[webhook] Ignoring unhandled event type:", event.event_type); + return NextResponse.json({ received: true }, { status: 200 }); + } catch (err) { + console.error("[webhook] Internal error processing event:", event.event_type, err); + // Return 500 so PayPal retries the webhook + return NextResponse.json( + { error: "Internal server error." }, + { status: 500 } + ); + } +} diff --git a/app/components/admin/AdminConsole.tsx b/app/components/admin/AdminConsole.tsx index e0eba5a..20d9731 100644 --- a/app/components/admin/AdminConsole.tsx +++ b/app/components/admin/AdminConsole.tsx @@ -52,6 +52,37 @@ export type AdminData = { footer: { legal: string[] }; }; +function paymentStatusLabel(status: string, lang: string): string { + const labels: Record = { + not_required: { en: "Not required", vi: "Chưa yêu cầu" }, + payment_pending: { en: "Pending", vi: "Chờ thanh toán" }, + paid: { en: "Paid", vi: "Đã thanh toán" }, + payment_failed: { en: "Failed", vi: "Thất bại" }, + payment_cancelled: { en: "Cancelled", vi: "Đã hủy" }, + }; + return lang === "vi" ? (labels[status]?.vi ?? status) : (labels[status]?.en ?? status); +} + +function paymentStatusStyle(status: string): string { + switch (status) { + case "paid": return "border-green-500/50 text-green-400 bg-green-500/10"; + case "payment_pending": return "border-yellow-500/50 text-yellow-400 bg-yellow-500/10"; + case "payment_failed": return "border-red-500/50 text-red-400 bg-red-500/10"; + case "payment_cancelled": return "border-gray-500/50 text-gray-400 bg-gray-500/10"; + default: return "border-outline/30 text-outline"; + } +} + +function paymentStatusDot(status: string): string { + switch (status) { + case "paid": return "bg-green-400"; + case "payment_pending": return "bg-yellow-400"; + case "payment_failed": return "bg-red-400"; + case "payment_cancelled": return "bg-gray-400"; + default: return "bg-outline"; + } +} + function InfoItem({ label, value, @@ -167,6 +198,8 @@ function buildLiveStats(data: AdminData, lang: string) { const approved = req.filter((r) => r.status === "approved").length; const rejected = req.filter((r) => r.status === "rejected").length; const conversionPct = total > 0 ? Math.round((approved / total) * 100) : 0; + const paidCount = req.filter((r) => r.paymentStatus === "paid").length; + const totalRevenue = paidCount * 120; const isVi = lang === "vi"; @@ -209,6 +242,14 @@ function buildLiveStats(data: AdminData, lang: string) { : "", progressPercent: conversionPct, }; + case "revenue": + return { + ...s, + value: `$${formatCount(totalRevenue)}`, + hint: isVi + ? `${formatCount(paidCount)} đăng ký đã thanh toán` + : `${formatCount(paidCount)} paid registrations`, + }; default: return s; } @@ -225,6 +266,7 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData; const [afterDate, setAfterDate] = useState(""); const [page, setPage] = useState(1); const [detail, setDetail] = useState(null); + const [paymentFilter, setPaymentFilter] = useState("all"); const { lang, setLang } = useLanguage(); const { admin: t } = useTranslation(); const [userDropdown, setUserDropdown] = useState(false); @@ -260,13 +302,13 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData; return data.requests.filter((r) => { if (statusFilter !== "all" && r.status !== statusFilter) return false; if (companyGroup !== "all" && r.segment !== companyGroup) return false; + if (paymentFilter !== "all" && (r.paymentStatus ?? "not_required") !== paymentFilter) return false; if (afterDate && r.submittedAt < afterDate) return false; if (!q) return true; - const hay = `${r.id} ${r.fullName} ${r.company} ${r.email} ${r.phone} ${r.jobTitle} ${r.status}`.toLowerCase(); return hay.includes(q); }); - }, [data.requests, search, statusFilter, companyGroup, afterDate]); + }, [data.requests, search, statusFilter, companyGroup, paymentFilter, afterDate]); async function commitStatus(publicId: string, next: SubmissionStatus) { try { @@ -477,6 +519,26 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData; ))} +
+ + +