forked from UKSOURCE/ipv6
paypal
This commit is contained in:
+8
-1
@@ -1,4 +1,11 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
# MongoDB — summit request queue (registration + feedback)
|
# MongoDB — summit request queue (registration + feedback)
|
||||||
MONGODB_URI=mongodb://localhost:27017/ipv6_summit
|
MONGODB_URI=mongodb://localhost:27017/ipv6_summit
|
||||||
NEXT_PUBLIC_BASE_PATH=/ipv6
|
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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<main className="min-h-screen bg-[var(--color-background)] flex items-center justify-center px-[var(--spacing-gutter)] py-[80px]">
|
||||||
|
<div className="w-full max-w-[560px]">
|
||||||
|
<div className="glass-panel rounded-xl p-[48px] text-center relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<span className="material-symbols-outlined text-[64px] text-yellow-400">
|
||||||
|
cancel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-[var(--font-display-lg)] text-[clamp(1.5rem,3vw,2rem)] text-on-surface mb-3 leading-[1.2]">
|
||||||
|
Thanh toán đã bị hủy
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-on-surface-variant text-base mb-8">
|
||||||
|
Bạn đã hủy quá trình thanh toán. Đăng ký của bạn chưa được xác nhận.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/registration"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg gold-gradient-bg text-on-primary font-medium transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
||||||
|
Thử lại thanh toán
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">home</span>
|
||||||
|
Về trang chủ
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!cancelled && (
|
||||||
|
<p className="mt-6 text-on-surface-variant text-xs opacity-60">
|
||||||
|
Đang cập nhật trạng thái...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
|
||||||
|
<main className="min-h-screen bg-[var(--color-background)] flex items-center justify-center px-[var(--spacing-gutter)] py-[80px]">
|
||||||
|
<div className="w-full max-w-[560px]">
|
||||||
|
{state === "loading" && (
|
||||||
|
<div className="glass-panel rounded-xl p-[48px] text-center">
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<span className="material-symbols-outlined text-[48px] text-primary animate-spin">
|
||||||
|
progress_activity
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-on-surface-variant text-lg">
|
||||||
|
Đang xử lý thanh toán của bạn...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "success" && (
|
||||||
|
<div className="glass-panel rounded-xl p-[48px] text-center relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<span className="material-symbols-outlined text-[64px] text-green-400">
|
||||||
|
check_circle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-[var(--font-display-lg)] text-[clamp(1.5rem,3vw,2rem)] text-on-surface mb-3 leading-[1.2]">
|
||||||
|
Thanh toán thành công!
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-on-surface-variant text-base mb-8">
|
||||||
|
Cảm ơn bạn đã đăng ký tham dự IPv6 Summit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-[var(--color-surface-container-high)] rounded-lg p-6 mb-8 text-left space-y-3">
|
||||||
|
{registrationId && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-on-surface-variant text-sm">Mã đăng ký</span>
|
||||||
|
<span className="text-primary font-mono font-medium">
|
||||||
|
#{registrationId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-on-surface-variant text-sm">Số tiền đã thanh toán</span>
|
||||||
|
<span className="text-on-surface font-medium">$120 USD</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-on-surface-variant text-sm">
|
||||||
|
Chúng tôi sẽ liên hệ với bạn qua email để xác nhận.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-primary hover:text-on-surface transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">home</span>
|
||||||
|
Về trang chủ
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "error" && (
|
||||||
|
<div className="glass-panel rounded-xl p-[48px] text-center relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<span className="material-symbols-outlined text-[64px] text-red-400">
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-[var(--font-display-lg)] text-[clamp(1.5rem,3vw,2rem)] text-on-surface mb-3 leading-[1.2]">
|
||||||
|
Có lỗi xảy ra
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="text-on-surface-variant text-base mb-8">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/registration"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg gold-gradient-bg text-on-primary font-medium transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
||||||
|
Thử lại đăng ký
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<NextResponse> {
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {}): NextRequest {
|
||||||
|
const defaultHeaders: Record<string, string> = {
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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<NextResponse> {
|
||||||
|
// 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,37 @@ export type AdminData = {
|
|||||||
footer: { legal: string[] };
|
footer: { legal: string[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function paymentStatusLabel(status: string, lang: string): string {
|
||||||
|
const labels: Record<string, { en: string; vi: string }> = {
|
||||||
|
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({
|
function InfoItem({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -167,6 +198,8 @@ function buildLiveStats(data: AdminData, lang: string) {
|
|||||||
const approved = req.filter((r) => r.status === "approved").length;
|
const approved = req.filter((r) => r.status === "approved").length;
|
||||||
const rejected = req.filter((r) => r.status === "rejected").length;
|
const rejected = req.filter((r) => r.status === "rejected").length;
|
||||||
const conversionPct = total > 0 ? Math.round((approved / total) * 100) : 0;
|
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";
|
const isVi = lang === "vi";
|
||||||
|
|
||||||
@@ -209,6 +242,14 @@ function buildLiveStats(data: AdminData, lang: string) {
|
|||||||
: "",
|
: "",
|
||||||
progressPercent: conversionPct,
|
progressPercent: conversionPct,
|
||||||
};
|
};
|
||||||
|
case "revenue":
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
value: `$${formatCount(totalRevenue)}`,
|
||||||
|
hint: isVi
|
||||||
|
? `${formatCount(paidCount)} đăng ký đã thanh toán`
|
||||||
|
: `${formatCount(paidCount)} paid registrations`,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -225,6 +266,7 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
|||||||
const [afterDate, setAfterDate] = useState("");
|
const [afterDate, setAfterDate] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [detail, setDetail] = useState<AdminRequest | null>(null);
|
const [detail, setDetail] = useState<AdminRequest | null>(null);
|
||||||
|
const [paymentFilter, setPaymentFilter] = useState("all");
|
||||||
const { lang, setLang } = useLanguage();
|
const { lang, setLang } = useLanguage();
|
||||||
const { admin: t } = useTranslation();
|
const { admin: t } = useTranslation();
|
||||||
const [userDropdown, setUserDropdown] = useState(false);
|
const [userDropdown, setUserDropdown] = useState(false);
|
||||||
@@ -260,13 +302,13 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
|||||||
return data.requests.filter((r) => {
|
return data.requests.filter((r) => {
|
||||||
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
||||||
if (companyGroup !== "all" && r.segment !== companyGroup) 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 (afterDate && r.submittedAt < afterDate) return false;
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
|
|
||||||
const hay = `${r.id} ${r.fullName} ${r.company} ${r.email} ${r.phone} ${r.jobTitle} ${r.status}`.toLowerCase();
|
const hay = `${r.id} ${r.fullName} ${r.company} ${r.email} ${r.phone} ${r.jobTitle} ${r.status}`.toLowerCase();
|
||||||
return hay.includes(q);
|
return hay.includes(q);
|
||||||
});
|
});
|
||||||
}, [data.requests, search, statusFilter, companyGroup, afterDate]);
|
}, [data.requests, search, statusFilter, companyGroup, paymentFilter, afterDate]);
|
||||||
|
|
||||||
async function commitStatus(publicId: string, next: SubmissionStatus) {
|
async function commitStatus(publicId: string, next: SubmissionStatus) {
|
||||||
try {
|
try {
|
||||||
@@ -477,6 +519,26 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1 min-w-[160px]">
|
||||||
|
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
|
||||||
|
{lang === "vi" ? "Thanh toán" : "Payment"}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="bg-surface-container-high border border-outline/30 text-on-surface text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||||
|
value={paymentFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPaymentFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">{lang === "vi" ? "Tất cả" : "All"}</option>
|
||||||
|
<option value="not_required">{lang === "vi" ? "Chưa yêu cầu" : "Not required"}</option>
|
||||||
|
<option value="payment_pending">{lang === "vi" ? "Chờ thanh toán" : "Pending"}</option>
|
||||||
|
<option value="paid">{lang === "vi" ? "Đã thanh toán" : "Paid"}</option>
|
||||||
|
<option value="payment_failed">{lang === "vi" ? "Thất bại" : "Failed"}</option>
|
||||||
|
<option value="payment_cancelled">{lang === "vi" ? "Đã hủy" : "Cancelled"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1 min-w-[160px]">
|
<div className="flex flex-col gap-1 min-w-[160px]">
|
||||||
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
|
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
|
||||||
{lang === "vi" ? "Nộp sau ngày" : "Submitted after"}
|
{lang === "vi" ? "Nộp sau ngày" : "Submitted after"}
|
||||||
@@ -525,6 +587,9 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
|||||||
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest w-[110px]">
|
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest w-[110px]">
|
||||||
{t.table.columns.status}
|
{t.table.columns.status}
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest w-[120px]">
|
||||||
|
{lang === "vi" ? "Thanh toán" : "Payment"}
|
||||||
|
</th>
|
||||||
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest text-right w-[160px]">
|
<th className="px-4 py-3 font-[var(--font-label-caps)] text-outline text-[10px] uppercase tracking-widest text-right w-[160px]">
|
||||||
{t.table.columns.actions}
|
{t.table.columns.actions}
|
||||||
</th>
|
</th>
|
||||||
@@ -542,8 +607,13 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
|||||||
{row.displayDate}
|
{row.displayDate}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="font-[var(--font-body-base)] text-[13px] font-semibold text-on-surface truncate max-w-[200px]" title={row.fullName}>
|
<div className="flex items-center gap-1.5">
|
||||||
{row.fullName}
|
<div className="font-[var(--font-body-base)] text-[13px] font-semibold text-on-surface truncate max-w-[200px]" title={row.fullName}>
|
||||||
|
{row.fullName}
|
||||||
|
</div>
|
||||||
|
{row.paymentStatus === "paid" && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-green-400 shrink-0" title={lang === "vi" ? "Đã thanh toán" : "Paid"} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-outline truncate max-w-[200px]" title={row.jobTitle}>
|
<div className="text-[11px] text-outline truncate max-w-[200px]" title={row.jobTitle}>
|
||||||
{row.jobTitle}
|
{row.jobTitle}
|
||||||
@@ -564,6 +634,16 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
|||||||
{sm.label}
|
{sm.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{row.paymentStatus && row.paymentStatus !== "not_required" ? (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[9px] font-[var(--font-label-caps)] border uppercase tracking-wider flex items-center gap-1.5 w-fit ${paymentStatusStyle(row.paymentStatus)}`}>
|
||||||
|
<span className={`w-1 h-1 rounded-full status-led ${paymentStatusDot(row.paymentStatus)}`} />
|
||||||
|
{paymentStatusLabel(row.paymentStatus, lang)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-outline/40 text-[11px]">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
<button
|
<button
|
||||||
@@ -838,6 +918,39 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Payment Info Section */}
|
||||||
|
{detail.paymentStatus && detail.paymentStatus !== "not_required" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xs font-[var(--font-label-caps)] text-primary uppercase tracking-[0.2em] border-b border-primary/20 pb-2">
|
||||||
|
{lang === "vi" ? "Thông tin thanh toán" : "Payment Information"}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<InfoItem
|
||||||
|
label={lang === "vi" ? "Trạng thái thanh toán" : "Payment Status"}
|
||||||
|
value={paymentStatusLabel(detail.paymentStatus, lang)}
|
||||||
|
/>
|
||||||
|
{detail.paypalOrderId && (
|
||||||
|
<InfoItem label="PayPal Order ID" value={detail.paypalOrderId} />
|
||||||
|
)}
|
||||||
|
{detail.paypalCaptureId && (
|
||||||
|
<InfoItem label="PayPal Capture ID" value={detail.paypalCaptureId} />
|
||||||
|
)}
|
||||||
|
{detail.paymentAmount && (
|
||||||
|
<InfoItem
|
||||||
|
label={lang === "vi" ? "Số tiền" : "Amount"}
|
||||||
|
value={`${detail.paymentAmount} ${detail.paymentCurrency ?? "USD"}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detail.paymentCompletedAt && (
|
||||||
|
<InfoItem
|
||||||
|
label={lang === "vi" ? "Thời gian thanh toán" : "Payment Date"}
|
||||||
|
value={new Date(detail.paymentCompletedAt).toLocaleString(lang === "vi" ? "vi-VN" : "en-GB")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer Actions */}
|
{/* Modal Footer Actions */}
|
||||||
|
|||||||
@@ -40,20 +40,26 @@ export default function RegistrationForm({
|
|||||||
setSubmitState("sending");
|
setSubmitState("sending");
|
||||||
const industryLabel = industries.find((o) => o.value === form.industry)?.label ?? "";
|
const industryLabel = industries.find((o) => o.value === form.industry)?.label ?? "";
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/ipv6/api/submissions", {
|
const res = await fetch("/ipv6/api/payments/create-order/", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
type: "registration",
|
fullName: form.fullName,
|
||||||
...form,
|
phone: form.phone,
|
||||||
|
email: form.email,
|
||||||
|
company: form.company,
|
||||||
|
jobTitle: form.jobTitle,
|
||||||
|
industry: form.industry,
|
||||||
industryLabel,
|
industryLabel,
|
||||||
|
notes: form.notes,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(typeof err?.error === "string" ? err.error : res.statusText);
|
throw new Error(typeof err?.error === "string" ? err.error : res.statusText);
|
||||||
}
|
}
|
||||||
setSubmitState("ok");
|
const { approvalUrl } = await res.json();
|
||||||
|
window.location.href = approvalUrl;
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitState("error");
|
setSubmitState("error");
|
||||||
}
|
}
|
||||||
@@ -69,6 +75,7 @@ export default function RegistrationForm({
|
|||||||
<input
|
<input
|
||||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
|
required
|
||||||
value={form.fullName}
|
value={form.fullName}
|
||||||
onChange={(e) => setForm((s) => ({ ...s, fullName: e.target.value }))}
|
onChange={(e) => setForm((s) => ({ ...s, fullName: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
@@ -88,6 +95,7 @@ export default function RegistrationForm({
|
|||||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||||
placeholder="john.doe@company.com"
|
placeholder="john.doe@company.com"
|
||||||
type="email"
|
type="email"
|
||||||
|
required
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={(e) => setForm((s) => ({ ...s, email: e.target.value }))}
|
onChange={(e) => setForm((s) => ({ ...s, email: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
@@ -177,17 +185,12 @@ export default function RegistrationForm({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitState === "sending"}
|
disabled={submitState === "sending"}
|
||||||
>
|
>
|
||||||
{submitState === "sending" ? "Submitting…" : data.submit.label}
|
{submitState === "sending" ? "Đang xử lý..." : data.submit.label}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<SubmitToast
|
<SubmitToast
|
||||||
state={submitState}
|
state={submitState}
|
||||||
successTitle="Registration received"
|
onDismiss={() => setSubmitState("idle")}
|
||||||
successMessage="Thank you. Your registration was saved and will appear in the admin console under Pending."
|
|
||||||
onDismiss={() => {
|
|
||||||
setSubmitState("idle");
|
|
||||||
setForm({ ...emptyRegistration });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="mt-[16px] text-center text-outline text-[11px] tracking-widest opacity-60 uppercase">
|
<p className="mt-[16px] text-center text-outline text-[11px] tracking-widest opacity-60 uppercase">
|
||||||
|
|||||||
@@ -93,12 +93,12 @@ export default function SubmitToast({
|
|||||||
{/* Text */}
|
{/* Text */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-[var(--font-display-lg)] font-semibold text-sm leading-snug">
|
<p className="font-[var(--font-display-lg)] font-semibold text-sm leading-snug">
|
||||||
{isSuccess ? successTitle : "Submission failed"}
|
{isSuccess ? successTitle : "Gửi thông tin thất bại"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[13px] leading-relaxed opacity-80">
|
<p className="mt-1 text-[13px] leading-relaxed opacity-80">
|
||||||
{isSuccess
|
{isSuccess
|
||||||
? successMessage
|
? successMessage
|
||||||
: "Please check your connection and try again."}
|
: "Vui lòng kiểm tra kết nối và thử lại."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,14 @@
|
|||||||
"icon": "trending_up",
|
"icon": "trending_up",
|
||||||
"accent": "progress",
|
"accent": "progress",
|
||||||
"progressPercent": 67
|
"progressPercent": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "revenue",
|
||||||
|
"label": "Total Revenue",
|
||||||
|
"value": "$0",
|
||||||
|
"hint": "0 paid registrations",
|
||||||
|
"icon": "payments",
|
||||||
|
"accent": "confirmed"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filters": {
|
"filters": {
|
||||||
|
|||||||
@@ -42,6 +42,14 @@
|
|||||||
"icon": "trending_up",
|
"icon": "trending_up",
|
||||||
"accent": "progress",
|
"accent": "progress",
|
||||||
"progressPercent": 67
|
"progressPercent": 67
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "revenue",
|
||||||
|
"label": "Tổng Doanh Thu",
|
||||||
|
"value": "$0",
|
||||||
|
"hint": "0 đăng ký đã thanh toán",
|
||||||
|
"icon": "payments",
|
||||||
|
"accent": "confirmed"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"filters": {
|
"filters": {
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Property-based tests for lib/paypal.ts (PaymentService)
|
||||||
|
* Feature: paypal-payment-integration
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import * as fc from "fast-check";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 11: Base URL đúng theo PAYPAL_MODE
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("getPayPalBaseUrl", () => {
|
||||||
|
it(
|
||||||
|
"Feature: paypal-payment-integration, Property 11: Base URL đúng theo PAYPAL_MODE",
|
||||||
|
() => {
|
||||||
|
fc.assert(
|
||||||
|
fc.property(fc.constantFrom("sandbox", "live"), (mode) => {
|
||||||
|
// Test the logic directly without env dependency
|
||||||
|
const getUrl = (m: string) =>
|
||||||
|
m === "sandbox"
|
||||||
|
? "https://api-m.sandbox.paypal.com"
|
||||||
|
: "https://api-m.paypal.com";
|
||||||
|
|
||||||
|
const url = getUrl(mode);
|
||||||
|
if (mode === "sandbox") {
|
||||||
|
expect(url).toBe("https://api-m.sandbox.paypal.com");
|
||||||
|
} else {
|
||||||
|
expect(url).toBe("https://api-m.paypal.com");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("returns sandbox URL when PAYPAL_MODE=sandbox", async () => {
|
||||||
|
const originalMode = process.env.PAYPAL_MODE;
|
||||||
|
process.env.PAYPAL_MODE = "sandbox";
|
||||||
|
// Dynamic import to pick up env
|
||||||
|
const mod = await import("../paypal?sandbox=" + Date.now());
|
||||||
|
// Since module is cached, test the logic via the exported function
|
||||||
|
// The function reads process.env at call time
|
||||||
|
process.env.PAYPAL_MODE = "sandbox";
|
||||||
|
const { getPayPalBaseUrl } = await import("../paypal");
|
||||||
|
expect(getPayPalBaseUrl()).toBe("https://api-m.sandbox.paypal.com");
|
||||||
|
process.env.PAYPAL_MODE = originalMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns live URL when PAYPAL_MODE=live", async () => {
|
||||||
|
const originalMode = process.env.PAYPAL_MODE;
|
||||||
|
process.env.PAYPAL_MODE = "live";
|
||||||
|
const { getPayPalBaseUrl } = await import("../paypal");
|
||||||
|
expect(getPayPalBaseUrl()).toBe("https://api-m.paypal.com");
|
||||||
|
process.env.PAYPAL_MODE = originalMode;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns live URL when PAYPAL_MODE is not set", async () => {
|
||||||
|
const originalMode = process.env.PAYPAL_MODE;
|
||||||
|
delete process.env.PAYPAL_MODE;
|
||||||
|
const { getPayPalBaseUrl } = await import("../paypal");
|
||||||
|
expect(getPayPalBaseUrl()).toBe("https://api-m.paypal.com");
|
||||||
|
process.env.PAYPAL_MODE = originalMode;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 1: Số tiền order luôn là $120.00 USD
|
||||||
|
// Property 2: Approval URL luôn được trả về
|
||||||
|
// Property 3: Idempotency Key luôn duy nhất
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("createPayPalOrder", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.PAYPAL_MODE = "sandbox";
|
||||||
|
process.env.PAYPAL_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.PAYPAL_CLIENT_SECRET = "test-client-secret";
|
||||||
|
process.env.NEXT_PUBLIC_BASE_PATH = "/ipv6";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"Feature: paypal-payment-integration, Property 1: Số tiền order luôn là $120.00 USD",
|
||||||
|
async () => {
|
||||||
|
const capturedBodies: unknown[] = [];
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string, opts: RequestInit) => {
|
||||||
|
if (String(url).includes("/v1/oauth2/token")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ access_token: "mock-token", expires_in: 3600 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (String(url).includes("/v2/checkout/orders")) {
|
||||||
|
capturedBodies.push(JSON.parse(opts.body as string));
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: "ORDER-" + Math.random(),
|
||||||
|
links: [{ rel: "approve", href: "https://paypal.com/approve" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: false, text: () => Promise.resolve("unexpected") });
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createPayPalOrder } = await import("../paypal");
|
||||||
|
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.string({ minLength: 1, maxLength: 20 }),
|
||||||
|
async (registrationId) => {
|
||||||
|
capturedBodies.length = 0;
|
||||||
|
await createPayPalOrder(registrationId);
|
||||||
|
expect(capturedBodies.length).toBeGreaterThan(0);
|
||||||
|
const body = capturedBodies[capturedBodies.length - 1] as any;
|
||||||
|
expect(body.purchase_units[0].amount.value).toBe("120.00");
|
||||||
|
expect(body.purchase_units[0].amount.currency_code).toBe("USD");
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 20 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"Feature: paypal-payment-integration, Property 2: Approval URL luôn được trả về khi tạo order thành công",
|
||||||
|
async () => {
|
||||||
|
const expectedUrl = "https://paypal.com/approve?token=TEST";
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string) => {
|
||||||
|
if (String(url).includes("/v1/oauth2/token")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ access_token: "mock-token", expires_in: 3600 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: "ORDER-123",
|
||||||
|
links: [
|
||||||
|
{ rel: "self", href: "https://paypal.com/self" },
|
||||||
|
{ rel: "approve", href: expectedUrl },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createPayPalOrder } = await import("../paypal");
|
||||||
|
const result = await createPayPalOrder("reg-test");
|
||||||
|
expect(result.approvalUrl).toBe(expectedUrl);
|
||||||
|
expect(result.orderId).toBe("ORDER-123");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"Feature: paypal-payment-integration, Property 3: Idempotency Key luôn duy nhất",
|
||||||
|
async () => {
|
||||||
|
const capturedKeys: string[] = [];
|
||||||
|
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockImplementation((url: string, opts: RequestInit) => {
|
||||||
|
if (String(url).includes("/v1/oauth2/token")) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ access_token: "mock-token", expires_in: 3600 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (String(url).includes("/v2/checkout/orders")) {
|
||||||
|
const key = (opts.headers as Record<string, string>)["PayPal-Request-Id"];
|
||||||
|
capturedKeys.push(key);
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
id: "ORDER-" + Math.random(),
|
||||||
|
links: [{ rel: "approve", href: "https://paypal.com/approve" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: false, text: () => Promise.resolve("unexpected") });
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createPayPalOrder } = await import("../paypal");
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
await createPayPalOrder(`reg-${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueKeys = new Set(capturedKeys);
|
||||||
|
expect(uniqueKeys.size).toBe(capturedKeys.length);
|
||||||
|
expect(capturedKeys.length).toBe(50);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Property-based tests for payment functions in lib/summit-requests.ts
|
||||||
|
* Feature: paypal-payment-integration
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import * as fc from "fast-check";
|
||||||
|
import type { AdminRequestRow } from "@/types/admin-submission";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const mockCreate = vi.fn();
|
||||||
|
const mockFindOneAndUpdate = vi.fn();
|
||||||
|
const mockFindOne = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/lib/mongodb", () => ({ default: vi.fn().mockResolvedValue(undefined) }));
|
||||||
|
vi.mock("@/models/SummitRequest", () => ({
|
||||||
|
default: {
|
||||||
|
create: (...args: unknown[]) => mockCreate(...args),
|
||||||
|
findOneAndUpdate: (...args: unknown[]) => mockFindOneAndUpdate(...args),
|
||||||
|
findOne: (...args: unknown[]) => mockFindOne(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRow: AdminRequestRow = {
|
||||||
|
id: "V6-TEST",
|
||||||
|
submittedAt: "2026-01-01",
|
||||||
|
displayDate: "01 JAN 2026",
|
||||||
|
fullName: "Test User",
|
||||||
|
jobTitle: "Engineer",
|
||||||
|
company: "Test Co",
|
||||||
|
segment: "enterprise",
|
||||||
|
email: "test@example.com",
|
||||||
|
phone: "+84 123456789",
|
||||||
|
status: "pending",
|
||||||
|
notes: "",
|
||||||
|
source: "registration",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 4: PayPal Order ID được lưu với trạng thái payment_pending
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("createRegistrationWithPayment", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreate.mockReset();
|
||||||
|
mockCreate.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"Feature: paypal-payment-integration, Property 4: Order ID lưu với payment_pending",
|
||||||
|
async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.string({ minLength: 1, maxLength: 50 }),
|
||||||
|
async (orderId) => {
|
||||||
|
const { createRegistrationWithPayment } = await import("../summit-requests");
|
||||||
|
await createRegistrationWithPayment(mockRow, orderId);
|
||||||
|
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
paymentStatus: "payment_pending",
|
||||||
|
paypalOrderId: orderId,
|
||||||
|
paymentAmount: 120,
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 5: Capture thành công chuyển trạng thái sang paid
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("markPaymentPaid", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFindOneAndUpdate.mockReset();
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ paymentStatus: "paid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"Feature: paypal-payment-integration, Property 5: Capture thành công chuyển sang paid",
|
||||||
|
async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.string({ minLength: 1, maxLength: 50 }),
|
||||||
|
fc.string({ minLength: 1, maxLength: 50 }),
|
||||||
|
async (orderId, captureId) => {
|
||||||
|
const { markPaymentPaid } = await import("../summit-requests");
|
||||||
|
await markPaymentPaid(orderId, captureId);
|
||||||
|
|
||||||
|
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ paypalOrderId: orderId, paymentStatus: { $ne: "paid" } },
|
||||||
|
expect.objectContaining({
|
||||||
|
$set: expect.objectContaining({
|
||||||
|
paymentStatus: "paid",
|
||||||
|
paypalCaptureId: captureId,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 7: Cancel chuyển trạng thái sang payment_cancelled
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("markPaymentCancelled", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFindOneAndUpdate.mockReset();
|
||||||
|
mockFindOneAndUpdate.mockResolvedValue({ paymentStatus: "payment_cancelled" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"Feature: paypal-payment-integration, Property 7: Cancel chuyển trạng thái sang payment_cancelled",
|
||||||
|
async () => {
|
||||||
|
await fc.assert(
|
||||||
|
fc.asyncProperty(
|
||||||
|
fc.string({ minLength: 1, maxLength: 50 }),
|
||||||
|
async (orderId) => {
|
||||||
|
const { markPaymentCancelled } = await import("../summit-requests");
|
||||||
|
await markPaymentCancelled(orderId);
|
||||||
|
|
||||||
|
expect(mockFindOneAndUpdate).toHaveBeenCalledWith(
|
||||||
|
{ paypalOrderId: orderId },
|
||||||
|
{ $set: { paymentStatus: "payment_cancelled" } },
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
{ numRuns: 50 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Property 14: paymentStatus mặc định là not_required
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("createSummitRequest (default paymentStatus)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCreate.mockReset();
|
||||||
|
mockCreate.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"Feature: paypal-payment-integration, Property 14: paymentStatus mặc định là not_required",
|
||||||
|
async () => {
|
||||||
|
// The Mongoose schema sets default: "not_required" — verify the schema field is defined
|
||||||
|
// We test that createSummitRequest does NOT pass paymentStatus (so schema default applies)
|
||||||
|
const { createSummitRequest } = await import("../summit-requests");
|
||||||
|
await createSummitRequest(mockRow);
|
||||||
|
|
||||||
|
const callArg = mockCreate.mock.calls[0][0];
|
||||||
|
// createSummitRequest should not override paymentStatus — schema default handles it
|
||||||
|
expect(callArg).not.toHaveProperty("paymentStatus");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
+281
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* PaymentService — PayPal Orders API v2 integration
|
||||||
|
*
|
||||||
|
* Handles OAuth 2.0 token management, order creation, payment capture,
|
||||||
|
* and webhook signature verification.
|
||||||
|
*
|
||||||
|
* Security: PAYPAL_CLIENT_SECRET and access tokens are NEVER logged or stored.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Token cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface TokenCache {
|
||||||
|
accessToken: string;
|
||||||
|
expiresAt: number; // Unix timestamp in ms
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenCache: TokenCache | null = null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Base URL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the PayPal API base URL based on PAYPAL_MODE environment variable.
|
||||||
|
* Defaults to live if PAYPAL_MODE is not "sandbox".
|
||||||
|
*/
|
||||||
|
export function getPayPalBaseUrl(): string {
|
||||||
|
return process.env.PAYPAL_MODE === "sandbox"
|
||||||
|
? "https://api-m.sandbox.paypal.com"
|
||||||
|
: "https://api-m.paypal.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OAuth 2.0 — Client Credentials
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a valid PayPal access token, using the cached token when still valid.
|
||||||
|
* Automatically fetches a new token when the cached one has expired.
|
||||||
|
*
|
||||||
|
* SECURITY: The access token is held only in memory and is never logged.
|
||||||
|
*/
|
||||||
|
export async function getAccessToken(): Promise<string> {
|
||||||
|
// Return cached token if it is still valid
|
||||||
|
if (tokenCache !== null && Date.now() < tokenCache.expiresAt) {
|
||||||
|
return tokenCache.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.PAYPAL_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.PAYPAL_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
throw new Error(
|
||||||
|
"PayPal credentials are not configured. Set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getPayPalBaseUrl();
|
||||||
|
// Basic auth — secret is used only here and never logged
|
||||||
|
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/v1/oauth2/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: "grant_type=client_credentials",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`PayPal OAuth token request failed (HTTP ${response.status}): ${errorText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { access_token: string; expires_in: number };
|
||||||
|
|
||||||
|
// Cache with a 60-second buffer before actual expiry
|
||||||
|
tokenCache = {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
expiresAt: Date.now() + (data.expires_in - 60) * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
return tokenCache.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Create PayPal Order
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a PayPal Order for the given registration with a fixed amount of $120.00 USD.
|
||||||
|
*
|
||||||
|
* @param registrationId - The internal registration ID used as the purchase unit reference.
|
||||||
|
* @returns The PayPal Order ID and the approval URL to redirect the user to.
|
||||||
|
* @throws If the PayPal API returns a non-2xx response.
|
||||||
|
*/
|
||||||
|
export async function createPayPalOrder(
|
||||||
|
registrationId: string
|
||||||
|
): Promise<{ orderId: string; approvalUrl: string }> {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
const baseUrl = getPayPalBaseUrl();
|
||||||
|
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||||
|
// PayPal requires full absolute URLs for return/cancel
|
||||||
|
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/$/, "") + basePath;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
intent: "CAPTURE",
|
||||||
|
purchase_units: [
|
||||||
|
{
|
||||||
|
reference_id: registrationId,
|
||||||
|
amount: {
|
||||||
|
currency_code: "USD",
|
||||||
|
value: "120.00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
application_context: {
|
||||||
|
return_url: `${appUrl}/registration/success`,
|
||||||
|
cancel_url: `${appUrl}/registration/cancel`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/v2/checkout/orders`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// Idempotency key — unique per call to prevent duplicate orders on retry
|
||||||
|
"PayPal-Request-Id": crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`PayPal create order failed (HTTP ${response.status}): ${errorText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
id: string;
|
||||||
|
links: Array<{ rel: string; href: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderId = data.id;
|
||||||
|
const approveLink = data.links.find((l) => l.rel === "approve");
|
||||||
|
|
||||||
|
if (!approveLink) {
|
||||||
|
throw new Error(
|
||||||
|
"PayPal create order response did not include an approval URL."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { orderId, approvalUrl: approveLink.href };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Capture PayPal Order
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures a previously approved PayPal Order.
|
||||||
|
*
|
||||||
|
* @param orderId - The PayPal Order ID to capture.
|
||||||
|
* @returns Capture details: captureId, status, and amount.
|
||||||
|
* @throws If the PayPal API returns a non-2xx response or capture data is missing.
|
||||||
|
*/
|
||||||
|
export async function capturePayPalOrder(orderId: string): Promise<{
|
||||||
|
captureId: string;
|
||||||
|
status: string;
|
||||||
|
amount: { value: string; currency_code: string };
|
||||||
|
}> {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
const baseUrl = getPayPalBaseUrl();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/v2/checkout/orders/${orderId}/capture`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`PayPal capture order failed (HTTP ${response.status}): ${errorText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
purchase_units: Array<{
|
||||||
|
payments: {
|
||||||
|
captures: Array<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
amount: { value: string; currency_code: string };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const capture = data.purchase_units?.[0]?.payments?.captures?.[0];
|
||||||
|
|
||||||
|
if (!capture) {
|
||||||
|
throw new Error(
|
||||||
|
"PayPal capture response did not include capture details."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
captureId: capture.id,
|
||||||
|
status: capture.status,
|
||||||
|
amount: capture.amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Verify Webhook Signature
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a PayPal webhook signature using the PayPal Webhooks Verify Signature API.
|
||||||
|
*
|
||||||
|
* @returns `true` if the signature is valid, `false` otherwise (including on errors).
|
||||||
|
*/
|
||||||
|
export async function verifyWebhookSignature(params: {
|
||||||
|
authAlgo: string;
|
||||||
|
certUrl: string;
|
||||||
|
transmissionId: string;
|
||||||
|
transmissionSig: string;
|
||||||
|
transmissionTime: string;
|
||||||
|
webhookId: string;
|
||||||
|
webhookEvent: unknown;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
const baseUrl = getPayPalBaseUrl();
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
auth_algo: params.authAlgo,
|
||||||
|
cert_url: params.certUrl,
|
||||||
|
transmission_id: params.transmissionId,
|
||||||
|
transmission_sig: params.transmissionSig,
|
||||||
|
transmission_time: params.transmissionTime,
|
||||||
|
webhook_id: params.webhookId,
|
||||||
|
webhook_event: params.webhookEvent,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${baseUrl}/v1/notifications/verify-webhook-signature`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { verification_status: string };
|
||||||
|
return data.verification_status === "SUCCESS";
|
||||||
|
} catch {
|
||||||
|
// Never throw — return false on any error so the webhook handler can respond with 401
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,13 @@ function docToRow(doc: ISummitRequest): AdminRequestRow {
|
|||||||
status: doc.status,
|
status: doc.status,
|
||||||
notes: doc.notes,
|
notes: doc.notes,
|
||||||
source: doc.source,
|
source: doc.source,
|
||||||
|
// Payment fields
|
||||||
|
paymentStatus: doc.paymentStatus,
|
||||||
|
paypalOrderId: doc.paypalOrderId,
|
||||||
|
paypalCaptureId: doc.paypalCaptureId,
|
||||||
|
paymentAmount: doc.paymentAmount,
|
||||||
|
paymentCurrency: doc.paymentCurrency,
|
||||||
|
paymentCompletedAt: doc.paymentCompletedAt?.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,3 +59,66 @@ export async function updateSummitRequestStatus(publicId: string, status: Submis
|
|||||||
throw new Error(`Request not found: ${publicId}`);
|
throw new Error(`Request not found: ${publicId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createRegistrationWithPayment(
|
||||||
|
row: AdminRequestRow,
|
||||||
|
paypalOrderId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await connectDB();
|
||||||
|
await SummitRequest.create({
|
||||||
|
publicId: row.id,
|
||||||
|
submittedAt: row.submittedAt,
|
||||||
|
displayDate: row.displayDate,
|
||||||
|
fullName: row.fullName,
|
||||||
|
jobTitle: row.jobTitle,
|
||||||
|
company: row.company,
|
||||||
|
segment: row.segment,
|
||||||
|
email: row.email,
|
||||||
|
phone: row.phone,
|
||||||
|
status: row.status,
|
||||||
|
notes: row.notes,
|
||||||
|
source: row.source,
|
||||||
|
paymentStatus: "payment_pending",
|
||||||
|
paypalOrderId,
|
||||||
|
paymentAmount: 120,
|
||||||
|
paymentCurrency: "USD",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markPaymentPaid(
|
||||||
|
paypalOrderId: string,
|
||||||
|
captureId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await connectDB();
|
||||||
|
const updated = await SummitRequest.findOneAndUpdate(
|
||||||
|
{ paypalOrderId, paymentStatus: { $ne: "paid" } },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
paymentStatus: "paid",
|
||||||
|
paypalCaptureId: captureId,
|
||||||
|
paymentCompletedAt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
if (!updated) {
|
||||||
|
// Either not found or already paid — both are acceptable (idempotent)
|
||||||
|
console.log(`markPaymentPaid: no update for orderId=${paypalOrderId} (not found or already paid)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markPaymentCancelled(paypalOrderId: string): Promise<void> {
|
||||||
|
await connectDB();
|
||||||
|
await SummitRequest.findOneAndUpdate(
|
||||||
|
{ paypalOrderId },
|
||||||
|
{ $set: { paymentStatus: "payment_cancelled" } },
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findByPaypalOrderId(
|
||||||
|
paypalOrderId: string
|
||||||
|
): Promise<ISummitRequest | null> {
|
||||||
|
await connectDB();
|
||||||
|
return SummitRequest.findOne({ paypalOrderId }).lean<ISummitRequest>();
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { Schema, model, models } from "mongoose";
|
|||||||
|
|
||||||
export type SummitRequestSource = "registration" | "feedback";
|
export type SummitRequestSource = "registration" | "feedback";
|
||||||
|
|
||||||
|
export type PaymentStatus =
|
||||||
|
| "not_required"
|
||||||
|
| "payment_pending"
|
||||||
|
| "paid"
|
||||||
|
| "payment_failed"
|
||||||
|
| "payment_cancelled";
|
||||||
|
|
||||||
export interface ISummitRequest {
|
export interface ISummitRequest {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
submittedAt: string;
|
submittedAt: string;
|
||||||
@@ -16,6 +23,12 @@ export interface ISummitRequest {
|
|||||||
status: SubmissionStatus;
|
status: SubmissionStatus;
|
||||||
notes: string;
|
notes: string;
|
||||||
source?: SummitRequestSource;
|
source?: SummitRequestSource;
|
||||||
|
paymentStatus: PaymentStatus;
|
||||||
|
paypalOrderId?: string;
|
||||||
|
paypalCaptureId?: string;
|
||||||
|
paymentAmount?: number;
|
||||||
|
paymentCurrency?: string;
|
||||||
|
paymentCompletedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
const summitRequestSchema = new Schema<ISummitRequest>(
|
const summitRequestSchema = new Schema<ISummitRequest>(
|
||||||
@@ -37,6 +50,17 @@ const summitRequestSchema = new Schema<ISummitRequest>(
|
|||||||
},
|
},
|
||||||
notes: { type: String, default: "" },
|
notes: { type: String, default: "" },
|
||||||
source: { type: String, enum: ["registration", "feedback"] },
|
source: { type: String, enum: ["registration", "feedback"] },
|
||||||
|
paymentStatus: {
|
||||||
|
type: String,
|
||||||
|
enum: ["not_required", "payment_pending", "paid", "payment_failed", "payment_cancelled"],
|
||||||
|
default: "not_required",
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
paypalOrderId: { type: String, sparse: true, index: true },
|
||||||
|
paypalCaptureId: { type: String },
|
||||||
|
paymentAmount: { type: Number, default: 120 },
|
||||||
|
paymentCurrency: { type: String, default: "USD" },
|
||||||
|
paymentCompletedAt: { type: Date },
|
||||||
},
|
},
|
||||||
{ timestamps: true },
|
{ timestamps: true },
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+2207
-1
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -7,6 +7,8 @@
|
|||||||
"clean": "rm -rf .next",
|
"clean": "rm -rf .next",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "dotenv -- next start",
|
"start": "dotenv -- next start",
|
||||||
|
"test": "vitest --run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"db:seed": "tsx scripts/seed-summit-requests.ts",
|
"db:seed": "tsx scripts/seed-summit-requests.ts",
|
||||||
"scss:build": "sass public/assets/scss/main.scss public/assets/css/main.css --style=expanded --source-map",
|
"scss:build": "sass public/assets/scss/main.scss public/assets/css/main.css --style=expanded --source-map",
|
||||||
@@ -21,16 +23,22 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"dotenv-cli": "11.0.0",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"dotenv-cli": "11.0.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"fast-check": "^3.23.2",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"sass": "^1.98.0",
|
"sass": "^1.98.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,11 @@ export type AdminRequestRow = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
/** Optional: where the row came from (not shown in table yet). */
|
/** Optional: where the row came from (not shown in table yet). */
|
||||||
source?: "registration" | "feedback";
|
source?: "registration" | "feedback";
|
||||||
|
paymentStatus?: "not_required" | "payment_pending" | "paid" | "payment_failed" | "payment_cancelled";
|
||||||
|
paypalOrderId?: string;
|
||||||
|
paypalCaptureId?: string;
|
||||||
|
paymentAmount?: number;
|
||||||
|
paymentCurrency?: string;
|
||||||
|
/** ISO string representation of payment completion timestamp. */
|
||||||
|
paymentCompletedAt?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
include: ["**/__tests__/**/*.test.{ts,tsx}", "**/*.test.{ts,tsx}"],
|
||||||
|
exclude: ["node_modules", ".next"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
Reference in New Issue
Block a user