398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
/**
|
|
* PayPal Payment Service
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Uses PayPal Orders API v2 (REST).
|
|
* Supports: Create Order → Capture Order → Webhook verification
|
|
*
|
|
* Configuration (set via environment variables — DO NOT hardcode):
|
|
* PAYPAL_CLIENT_ID — PayPal REST API client ID
|
|
* PAYPAL_CLIENT_SECRET — PayPal REST API client secret
|
|
* PAYPAL_WEBHOOK_ID — Webhook ID from PayPal Developer Dashboard
|
|
* PAYPAL_SANDBOX — "true" to use sandbox environment
|
|
*
|
|
* Integration point:
|
|
* After capturing the order (or receiving PAYMENT.CAPTURE.COMPLETED webhook),
|
|
* call tokenDistributionService.creditXic() to distribute XIC tokens.
|
|
*
|
|
* Docs: https://developer.paypal.com/docs/api/orders/v2/
|
|
*/
|
|
|
|
import { getDb } from "../db";
|
|
import { fiatOrders } from "../../drizzle/schema";
|
|
import { eq } from "drizzle-orm";
|
|
import { creditXic, calcXicAmount } from "../tokenDistributionService";
|
|
|
|
// ─── Configuration ────────────────────────────────────────────────────────────
|
|
// TODO: Replace placeholder values with real credentials from PayPal Developer Dashboard
|
|
// https://developer.paypal.com/dashboard/applications
|
|
const PAYPAL_CONFIG = {
|
|
clientId: process.env.PAYPAL_CLIENT_ID || "PLACEHOLDER_PAYPAL_CLIENT_ID",
|
|
clientSecret: process.env.PAYPAL_CLIENT_SECRET || "PLACEHOLDER_PAYPAL_CLIENT_SECRET",
|
|
webhookId: process.env.PAYPAL_WEBHOOK_ID || "PLACEHOLDER_PAYPAL_WEBHOOK_ID",
|
|
// Sandbox: https://api-m.sandbox.paypal.com
|
|
// Production: https://api-m.paypal.com
|
|
baseUrl: process.env.PAYPAL_SANDBOX === "true"
|
|
? "https://api-m.sandbox.paypal.com"
|
|
: "https://api-m.paypal.com",
|
|
sandbox: process.env.PAYPAL_SANDBOX === "true",
|
|
returnUrl: process.env.PAYPAL_RETURN_URL || "https://pre-sale.newassetchain.io/payment/success",
|
|
cancelUrl: process.env.PAYPAL_CANCEL_URL || "https://pre-sale.newassetchain.io/payment/cancel",
|
|
};
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
export interface PaypalOrderParams {
|
|
orderId: string;
|
|
usdAmount: string; // USD amount, e.g. "100.00"
|
|
xicReceiveAddress: string;
|
|
userId?: string;
|
|
description?: string;
|
|
}
|
|
|
|
export interface PaypalOrderResult {
|
|
success: boolean;
|
|
paypalOrderId?: string; // PayPal's order ID
|
|
approveUrl?: string; // URL to redirect user for approval
|
|
orderId?: string; // our internal order ID
|
|
error?: string;
|
|
}
|
|
|
|
// ─── OAuth Token Cache ────────────────────────────────────────────────────────
|
|
let _accessToken: string | null = null;
|
|
let _tokenExpiry = 0;
|
|
|
|
/**
|
|
* Get PayPal OAuth 2.0 access token.
|
|
* Tokens are cached until expiry.
|
|
*/
|
|
async function getAccessToken(): Promise<string> {
|
|
if (_accessToken && Date.now() < _tokenExpiry - 60_000) {
|
|
return _accessToken;
|
|
}
|
|
|
|
if (PAYPAL_CONFIG.clientId === "PLACEHOLDER_PAYPAL_CLIENT_ID") {
|
|
console.warn("[PayPal] Using placeholder credentials — API calls will fail");
|
|
return "PLACEHOLDER_ACCESS_TOKEN";
|
|
}
|
|
|
|
const credentials = Buffer.from(`${PAYPAL_CONFIG.clientId}:${PAYPAL_CONFIG.clientSecret}`).toString("base64");
|
|
const response = await fetch(`${PAYPAL_CONFIG.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) {
|
|
throw new Error(`[PayPal] Failed to get access token: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json() as any;
|
|
_accessToken = data.access_token;
|
|
_tokenExpiry = Date.now() + (data.expires_in * 1000);
|
|
return _accessToken!;
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Generate unique order ID with PAYPAL prefix */
|
|
export function generatePaypalOrderId(): string {
|
|
const ts = Date.now().toString();
|
|
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
return `PAYPAL-${ts}-${rand}`;
|
|
}
|
|
|
|
// ─── Core Functions ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create a PayPal order.
|
|
* Returns an approval URL to redirect the user to PayPal for payment.
|
|
*
|
|
* Flow:
|
|
* 1. Create order via PayPal Orders API v2
|
|
* 2. Save order to fiat_orders table
|
|
* 3. Return approveUrl for frontend redirect
|
|
* 4. After user approves, frontend calls capturePaypalOrder()
|
|
*/
|
|
export async function createPaypalOrder(params: PaypalOrderParams): Promise<PaypalOrderResult> {
|
|
const { orderId, usdAmount, xicReceiveAddress, userId, description } = params;
|
|
|
|
const usdValue = parseFloat(usdAmount);
|
|
const xicAmount = calcXicAmount(usdValue);
|
|
|
|
try {
|
|
const accessToken = await getAccessToken();
|
|
const db = await getDb();
|
|
if (!db) return { success: false, error: "Database not available" };
|
|
|
|
// Build PayPal order request
|
|
const orderPayload = {
|
|
intent: "CAPTURE",
|
|
purchase_units: [
|
|
{
|
|
reference_id: orderId,
|
|
description: description || `NAC XIC Token Purchase — ${xicAmount} XIC`,
|
|
custom_id: xicReceiveAddress, // store XIC receive address in custom_id
|
|
amount: {
|
|
currency_code: "USD",
|
|
value: usdAmount,
|
|
breakdown: {
|
|
item_total: { currency_code: "USD", value: usdAmount },
|
|
},
|
|
},
|
|
items: [
|
|
{
|
|
name: "XIC Token",
|
|
description: `${xicAmount} XIC tokens at $0.02/XIC`,
|
|
quantity: "1",
|
|
unit_amount: { currency_code: "USD", value: usdAmount },
|
|
category: "DIGITAL_GOODS",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
payment_source: {
|
|
paypal: {
|
|
experience_context: {
|
|
payment_method_preference: "IMMEDIATE_PAYMENT_REQUIRED",
|
|
brand_name: "NAC XIC Token Presale",
|
|
locale: "en-US",
|
|
landing_page: "LOGIN",
|
|
shipping_preference: "NO_SHIPPING",
|
|
user_action: "PAY_NOW",
|
|
return_url: `${PAYPAL_CONFIG.returnUrl}?orderId=${orderId}`,
|
|
cancel_url: `${PAYPAL_CONFIG.cancelUrl}?orderId=${orderId}`,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json",
|
|
"PayPal-Request-Id": orderId, // idempotency key
|
|
},
|
|
body: JSON.stringify(orderPayload),
|
|
});
|
|
|
|
const data = await response.json() as any;
|
|
|
|
if (!response.ok) {
|
|
console.error("[PayPal] Create order error:", data);
|
|
return { success: false, error: data.message || "PayPal API error" };
|
|
}
|
|
|
|
// Find approve URL
|
|
const approveLink = data.links?.find((l: any) => l.rel === "payer-action" || l.rel === "approve");
|
|
const approveUrl = approveLink?.href;
|
|
|
|
// Save to database
|
|
await db.insert(fiatOrders).values({
|
|
orderId,
|
|
gatewayOrderId: data.id,
|
|
channel: "paypal",
|
|
userId: userId || null,
|
|
xicReceiveAddress,
|
|
usdtEquivalent: usdAmount,
|
|
currency: "USD",
|
|
originalAmount: usdAmount,
|
|
xicAmount: xicAmount.toString(),
|
|
status: "pending",
|
|
paymentUrl: approveUrl || null,
|
|
expiredAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
|
});
|
|
|
|
console.log(`[PayPal] Order created: ${orderId} (PayPal: ${data.id}) | USD ${usdAmount} → ${xicAmount} XIC → ${xicReceiveAddress}`);
|
|
return { success: true, paypalOrderId: data.id, approveUrl, orderId };
|
|
} catch (err) {
|
|
console.error("[PayPal] Failed to create order:", err);
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Capture a PayPal order after user approval.
|
|
* Called by frontend after user returns from PayPal approval page.
|
|
* This is the final step that actually charges the user.
|
|
*/
|
|
export async function capturePaypalOrder(paypalOrderId: string, internalOrderId: string): Promise<{ success: boolean; captureId?: string; error?: string }> {
|
|
try {
|
|
const accessToken = await getAccessToken();
|
|
const db = await getDb();
|
|
if (!db) return { success: false, error: "Database not available" };
|
|
|
|
const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders/${paypalOrderId}/capture`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${accessToken}`,
|
|
"Content-Type": "application/json",
|
|
"PayPal-Request-Id": `capture-${internalOrderId}`,
|
|
},
|
|
});
|
|
|
|
const data = await response.json() as any;
|
|
|
|
if (!response.ok) {
|
|
console.error("[PayPal] Capture error:", data);
|
|
return { success: false, error: data.message || "Capture failed" };
|
|
}
|
|
|
|
if (data.status !== "COMPLETED") {
|
|
return { success: false, error: `Unexpected status: ${data.status}` };
|
|
}
|
|
|
|
const capture = data.purchase_units?.[0]?.payments?.captures?.[0];
|
|
const captureId = capture?.id;
|
|
const payerEmail = data.payer?.email_address;
|
|
const xicReceiveAddress = data.purchase_units?.[0]?.custom_id;
|
|
|
|
// Find order in database
|
|
const orders = await db.select().from(fiatOrders)
|
|
.where(eq(fiatOrders.orderId, internalOrderId))
|
|
.limit(1);
|
|
const order = orders[0];
|
|
if (!order) return { success: false, error: "Order not found" };
|
|
|
|
if (order.status === "distributed" || order.status === "paid") {
|
|
return { success: true, captureId };
|
|
}
|
|
|
|
// Update to paid
|
|
await db.update(fiatOrders)
|
|
.set({
|
|
status: "paid",
|
|
payerEmail: payerEmail || null,
|
|
callbackPayload: JSON.stringify(data),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(fiatOrders.orderId, internalOrderId));
|
|
|
|
// Distribute XIC
|
|
const creditResult = await creditXic({
|
|
txHash: `PAYPAL-${captureId}`,
|
|
chainType: "PAYPAL",
|
|
fromAddress: payerEmail || "paypal_payer",
|
|
toAddress: "paypal_merchant",
|
|
usdtAmount: parseFloat(order.usdtEquivalent),
|
|
xicAmount: parseFloat(order.xicAmount),
|
|
xicReceiveAddress: order.xicReceiveAddress || xicReceiveAddress || undefined,
|
|
remark: `PayPal order ${internalOrderId}, USD ${order.originalAmount}`,
|
|
});
|
|
|
|
if (creditResult.success) {
|
|
await db.update(fiatOrders)
|
|
.set({ status: "distributed", distributedAt: new Date() })
|
|
.where(eq(fiatOrders.orderId, internalOrderId));
|
|
console.log(`[PayPal] ✅ XIC distributed for order ${internalOrderId}`);
|
|
}
|
|
|
|
return { success: true, captureId };
|
|
} catch (err) {
|
|
console.error("[PayPal] Capture error:", err);
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle PayPal webhook events.
|
|
* PayPal sends PAYMENT.CAPTURE.COMPLETED when payment is confirmed.
|
|
* This is a backup to capturePaypalOrder() for cases where the user
|
|
* closes the browser before returning to our site.
|
|
*
|
|
* Docs: https://developer.paypal.com/api/rest/webhooks/
|
|
*/
|
|
export async function handlePaypalWebhook(
|
|
headers: Record<string, string>,
|
|
body: any
|
|
): Promise<{ ok: boolean; message: string }> {
|
|
// TODO: Implement PayPal webhook signature verification
|
|
// Reference: https://developer.paypal.com/api/rest/webhooks/rest/#link-eventtypelistforallapps
|
|
// For now, process the event directly (add signature verification before production)
|
|
|
|
const { event_type, resource } = body;
|
|
|
|
if (event_type !== "PAYMENT.CAPTURE.COMPLETED") {
|
|
return { ok: true, message: "ignored" };
|
|
}
|
|
|
|
const captureId = resource?.id;
|
|
const customId = resource?.custom_id; // our XIC receive address
|
|
const invoiceId = resource?.invoice_id; // our internal order ID (if set)
|
|
const payerEmail = resource?.payer?.email_address;
|
|
const amount = resource?.amount?.value;
|
|
|
|
if (!captureId) return { ok: false, message: "missing_capture_id" };
|
|
|
|
try {
|
|
const db = await getDb();
|
|
if (!db) return { ok: false, message: "db_unavailable" };
|
|
|
|
// Find order by gatewayOrderId (PayPal order ID)
|
|
// Note: resource.supplementary_data?.related_ids?.order_id contains the PayPal order ID
|
|
const paypalOrderId = resource?.supplementary_data?.related_ids?.order_id;
|
|
if (!paypalOrderId) return { ok: true, message: "no_order_id" };
|
|
|
|
const orders = await db.select().from(fiatOrders)
|
|
.where(eq(fiatOrders.gatewayOrderId, paypalOrderId))
|
|
.limit(1);
|
|
const order = orders[0];
|
|
if (!order) return { ok: true, message: "order_not_found" };
|
|
|
|
if (order.status === "distributed" || order.status === "paid") {
|
|
return { ok: true, message: "already_processed" };
|
|
}
|
|
|
|
await db.update(fiatOrders)
|
|
.set({
|
|
status: "paid",
|
|
payerEmail: payerEmail || null,
|
|
callbackPayload: JSON.stringify(body),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(fiatOrders.id, order.id));
|
|
|
|
const creditResult = await creditXic({
|
|
txHash: `PAYPAL-${captureId}`,
|
|
chainType: "PAYPAL",
|
|
fromAddress: payerEmail || "paypal_payer",
|
|
toAddress: "paypal_merchant",
|
|
usdtAmount: parseFloat(order.usdtEquivalent),
|
|
xicAmount: parseFloat(order.xicAmount),
|
|
xicReceiveAddress: order.xicReceiveAddress || customId || undefined,
|
|
remark: `PayPal webhook capture ${captureId}, USD ${amount}`,
|
|
});
|
|
|
|
if (creditResult.success) {
|
|
await db.update(fiatOrders)
|
|
.set({ status: "distributed", distributedAt: new Date() })
|
|
.where(eq(fiatOrders.id, order.id));
|
|
console.log(`[PayPal] ✅ XIC distributed via webhook for order ${order.orderId}`);
|
|
}
|
|
|
|
return { ok: true, message: "success" };
|
|
} catch (err) {
|
|
console.error("[PayPal] Webhook processing error:", err);
|
|
return { ok: false, message: String(err) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query PayPal order status.
|
|
*/
|
|
export async function queryPaypalOrder(paypalOrderId: string): Promise<{ success: boolean; status?: string; error?: string }> {
|
|
try {
|
|
const accessToken = await getAccessToken();
|
|
const response = await fetch(`${PAYPAL_CONFIG.baseUrl}/v2/checkout/orders/${paypalOrderId}`, {
|
|
headers: { "Authorization": `Bearer ${accessToken}` },
|
|
});
|
|
const data = await response.json() as any;
|
|
if (response.ok) return { success: true, status: data.status };
|
|
return { success: false, error: data.message };
|
|
} catch (err) {
|
|
return { success: false, error: String(err) };
|
|
}
|
|
}
|