Merge pull request 'paypal' (#11) from feat/toan-19052026-paypal into develop

Reviewed-on: UKSOURCE/ipv6#11
This commit is contained in:
2026-05-19 06:24:01 +00:00
23 changed files with 3781 additions and 22 deletions
+7
View File
@@ -2,3 +2,10 @@ 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
+71
View File
@@ -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 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>
);
}
+142
View File
@@ -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ử 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 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"> đăng </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]">
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
</Link>
</div>
)}
</div>
</main>
);
}
+18
View File
@@ -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 });
}
}
+68
View File
@@ -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 }
);
}
+83
View File
@@ -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);
}
);
});
+85
View File
@@ -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 }
);
}
}
+115 -2
View File
@@ -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,9 +607,14 @@ 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="flex items-center gap-1.5">
<div className="font-[var(--font-body-base)] text-[13px] font-semibold text-on-surface truncate max-w-[200px]" title={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} {row.fullName}
</div> </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 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}
</div> </div>
@@ -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 */}
+14 -11
View File
@@ -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">
+2 -2
View File
@@ -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>
+8
View File
@@ -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": {
+8
View File
@@ -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": {
+197
View File
@@ -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
View File
@@ -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;
}
}
+70
View File
@@ -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>();
}
+24
View File
@@ -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 },
); );
+2207 -1
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -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"
} }
} }
+7
View File
@@ -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;
}; };
+19
View File
@@ -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, "."),
},
},
});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom";