forked from UKSOURCE/ipv6
paypal
This commit is contained in:
@@ -2,3 +2,10 @@ PORT=3000
|
||||
# MongoDB — summit request queue (registration + feedback)
|
||||
MONGODB_URI=mongodb://localhost:27017/ipv6_summit
|
||||
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[] };
|
||||
};
|
||||
|
||||
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({
|
||||
label,
|
||||
value,
|
||||
@@ -167,6 +198,8 @@ function buildLiveStats(data: AdminData, lang: string) {
|
||||
const approved = req.filter((r) => r.status === "approved").length;
|
||||
const rejected = req.filter((r) => r.status === "rejected").length;
|
||||
const conversionPct = total > 0 ? Math.round((approved / total) * 100) : 0;
|
||||
const paidCount = req.filter((r) => r.paymentStatus === "paid").length;
|
||||
const totalRevenue = paidCount * 120;
|
||||
|
||||
const isVi = lang === "vi";
|
||||
|
||||
@@ -209,6 +242,14 @@ function buildLiveStats(data: AdminData, lang: string) {
|
||||
: "",
|
||||
progressPercent: conversionPct,
|
||||
};
|
||||
case "revenue":
|
||||
return {
|
||||
...s,
|
||||
value: `$${formatCount(totalRevenue)}`,
|
||||
hint: isVi
|
||||
? `${formatCount(paidCount)} đăng ký đã thanh toán`
|
||||
: `${formatCount(paidCount)} paid registrations`,
|
||||
};
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
@@ -225,6 +266,7 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
||||
const [afterDate, setAfterDate] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [detail, setDetail] = useState<AdminRequest | null>(null);
|
||||
const [paymentFilter, setPaymentFilter] = useState("all");
|
||||
const { lang, setLang } = useLanguage();
|
||||
const { admin: t } = useTranslation();
|
||||
const [userDropdown, setUserDropdown] = useState(false);
|
||||
@@ -260,13 +302,13 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
||||
return data.requests.filter((r) => {
|
||||
if (statusFilter !== "all" && r.status !== statusFilter) return false;
|
||||
if (companyGroup !== "all" && r.segment !== companyGroup) return false;
|
||||
if (paymentFilter !== "all" && (r.paymentStatus ?? "not_required") !== paymentFilter) return false;
|
||||
if (afterDate && r.submittedAt < afterDate) return false;
|
||||
if (!q) return true;
|
||||
|
||||
const hay = `${r.id} ${r.fullName} ${r.company} ${r.email} ${r.phone} ${r.jobTitle} ${r.status}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}, [data.requests, search, statusFilter, companyGroup, afterDate]);
|
||||
}, [data.requests, search, statusFilter, companyGroup, paymentFilter, afterDate]);
|
||||
|
||||
async function commitStatus(publicId: string, next: SubmissionStatus) {
|
||||
try {
|
||||
@@ -477,6 +519,26 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
||||
))}
|
||||
</select>
|
||||
</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]">
|
||||
<label className="font-[var(--font-label-caps)] text-[10px] text-outline uppercase tracking-wider">
|
||||
{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]">
|
||||
{t.table.columns.status}
|
||||
</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]">
|
||||
{t.table.columns.actions}
|
||||
</th>
|
||||
@@ -542,9 +607,14 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
||||
{row.displayDate}
|
||||
</td>
|
||||
<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}>
|
||||
{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 className="text-[11px] text-outline truncate max-w-[200px]" title={row.jobTitle}>
|
||||
{row.jobTitle}
|
||||
</div>
|
||||
@@ -564,6 +634,16 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
||||
{sm.label}
|
||||
</span>
|
||||
</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">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<button
|
||||
@@ -838,6 +918,39 @@ export default function AdminConsole({ data, view = "all" }: { data: AdminData;
|
||||
</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>
|
||||
|
||||
{/* Modal Footer Actions */}
|
||||
|
||||
@@ -40,20 +40,26 @@ export default function RegistrationForm({
|
||||
setSubmitState("sending");
|
||||
const industryLabel = industries.find((o) => o.value === form.industry)?.label ?? "";
|
||||
try {
|
||||
const res = await fetch("/ipv6/api/submissions", {
|
||||
const res = await fetch("/ipv6/api/payments/create-order/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "registration",
|
||||
...form,
|
||||
fullName: form.fullName,
|
||||
phone: form.phone,
|
||||
email: form.email,
|
||||
company: form.company,
|
||||
jobTitle: form.jobTitle,
|
||||
industry: form.industry,
|
||||
industryLabel,
|
||||
notes: form.notes,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(typeof err?.error === "string" ? err.error : res.statusText);
|
||||
}
|
||||
setSubmitState("ok");
|
||||
const { approvalUrl } = await res.json();
|
||||
window.location.href = approvalUrl;
|
||||
} catch {
|
||||
setSubmitState("error");
|
||||
}
|
||||
@@ -69,6 +75,7 @@ export default function RegistrationForm({
|
||||
<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"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
value={form.fullName}
|
||||
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"
|
||||
placeholder="john.doe@company.com"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((s) => ({ ...s, email: e.target.value }))}
|
||||
/>
|
||||
@@ -177,17 +185,12 @@ export default function RegistrationForm({
|
||||
type="submit"
|
||||
disabled={submitState === "sending"}
|
||||
>
|
||||
{submitState === "sending" ? "Submitting…" : data.submit.label}
|
||||
{submitState === "sending" ? "Đang xử lý..." : data.submit.label}
|
||||
</button>
|
||||
|
||||
<SubmitToast
|
||||
state={submitState}
|
||||
successTitle="Registration received"
|
||||
successMessage="Thank you. Your registration was saved and will appear in the admin console under Pending."
|
||||
onDismiss={() => {
|
||||
setSubmitState("idle");
|
||||
setForm({ ...emptyRegistration });
|
||||
}}
|
||||
onDismiss={() => setSubmitState("idle")}
|
||||
/>
|
||||
|
||||
<p className="mt-[16px] text-center text-outline text-[11px] tracking-widest opacity-60 uppercase">
|
||||
|
||||
@@ -93,12 +93,12 @@ export default function SubmitToast({
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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 className="mt-1 text-[13px] leading-relaxed opacity-80">
|
||||
{isSuccess
|
||||
? successMessage
|
||||
: "Please check your connection and try again."}
|
||||
: "Vui lòng kiểm tra kết nối và thử lại."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
"icon": "trending_up",
|
||||
"accent": "progress",
|
||||
"progressPercent": 67
|
||||
},
|
||||
{
|
||||
"id": "revenue",
|
||||
"label": "Total Revenue",
|
||||
"value": "$0",
|
||||
"hint": "0 paid registrations",
|
||||
"icon": "payments",
|
||||
"accent": "confirmed"
|
||||
}
|
||||
],
|
||||
"filters": {
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
"icon": "trending_up",
|
||||
"accent": "progress",
|
||||
"progressPercent": 67
|
||||
},
|
||||
{
|
||||
"id": "revenue",
|
||||
"label": "Tổng Doanh Thu",
|
||||
"value": "$0",
|
||||
"hint": "0 đăng ký đã thanh toán",
|
||||
"icon": "payments",
|
||||
"accent": "confirmed"
|
||||
}
|
||||
],
|
||||
"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,
|
||||
notes: doc.notes,
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 PaymentStatus =
|
||||
| "not_required"
|
||||
| "payment_pending"
|
||||
| "paid"
|
||||
| "payment_failed"
|
||||
| "payment_cancelled";
|
||||
|
||||
export interface ISummitRequest {
|
||||
publicId: string;
|
||||
submittedAt: string;
|
||||
@@ -16,6 +23,12 @@ export interface ISummitRequest {
|
||||
status: SubmissionStatus;
|
||||
notes: string;
|
||||
source?: SummitRequestSource;
|
||||
paymentStatus: PaymentStatus;
|
||||
paypalOrderId?: string;
|
||||
paypalCaptureId?: string;
|
||||
paymentAmount?: number;
|
||||
paymentCurrency?: string;
|
||||
paymentCompletedAt?: Date;
|
||||
}
|
||||
|
||||
const summitRequestSchema = new Schema<ISummitRequest>(
|
||||
@@ -37,6 +50,17 @@ const summitRequestSchema = new Schema<ISummitRequest>(
|
||||
},
|
||||
notes: { type: String, default: "" },
|
||||
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 },
|
||||
);
|
||||
|
||||
Generated
+2207
-1
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -7,6 +7,8 @@
|
||||
"clean": "rm -rf .next",
|
||||
"build": "next build",
|
||||
"start": "dotenv -- next start",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint",
|
||||
"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",
|
||||
@@ -21,16 +23,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv-cli": "11.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"dotenv": "^17.4.2",
|
||||
"dotenv-cli": "11.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"fast-check": "^3.23.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"sass": "^1.98.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,11 @@ export type AdminRequestRow = {
|
||||
notes: string;
|
||||
/** Optional: where the row came from (not shown in table yet). */
|
||||
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