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