This commit is contained in:
2026-05-19 12:48:41 +07:00
parent 208a91b971
commit 55a0133ed4
23 changed files with 3781 additions and 22 deletions
+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 }
);
}
}