forked from UKSOURCE/ipv6
paypal
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user