import { type User } from "@/server/auth/types"; import { type SelectProduct } from "@/server/db/schema/products-schema"; import { ApiError, CheckoutPaymentIntent, Client, ClientCredentialsAuthManager, Environment, LogLevel, OrderApplicationContextShippingPreference, OrderApplicationContextUserAction, OrdersController, PaymentsController, type ApiResponse, type OAuthToken, type OrderRequest, } from "@paypal/paypal-server-sdk"; import { type PayPalSubscriptionPlan, type PayPalSubscriptionResponse, } from "./paypal-types"; import { type PaymentProvider } from "./types"; // PayPal error response type type PayPalError = { name: string; message: string; debug_id?: string; details?: Array<{ issue: string; description?: string; }>; }; export class PayPalPaymentProvider implements PaymentProvider { private client: Client; private ordersController: OrdersController; private paymentsController: PaymentsController; private authManager: ClientCredentialsAuthManager; private baseUrl: string; constructor(private apiKey: string, private clientSecret: string) { this.baseUrl = process.env.NODE_ENV === "production" ? "https://api.paypal.com" : "https://api.sandbox.paypal.com"; // Initialize PayPal client with OAuth credentials this.client = new Client({ clientCredentialsAuthCredentials: { oAuthClientId: apiKey, oAuthClientSecret: clientSecret, // Add OAuth token update callback to handle token refresh oAuthOnTokenUpdate: (token: OAuthToken) => { this.currentToken = token; }, }, timeout: 30000, environment: process.env.NODE_ENV === "production" ? Environment.Production : Environment.Sandbox, logging: process.env.NODE_ENV === "development" ? { logLevel: LogLevel.Info, logRequest: { logBody: true }, logResponse: { logHeaders: true }, } : undefined, }); // Initialize controllers this.ordersController = new OrdersController(this.client); this.paymentsController = new PaymentsController(this.client); // Initialize auth manager with both required arguments this.authManager = new ClientCredentialsAuthManager( { oAuthClientId: this.apiKey, oAuthClientSecret: this.clientSecret, }, this.client // Pass the client instance as second argument ); } private currentToken?: OAuthToken; private async getAccessToken(): Promise { // Use the SDK's auth manager to get/refresh token if (!this.currentToken || this.authManager.isExpired(this.currentToken)) { this.currentToken = await this.authManager.fetchToken(); } return this.currentToken.accessToken; } // Helper for making authenticated REST API calls private async fetchWithAuth(path: string, init?: RequestInit) { const token = await this.getAccessToken(); return fetch(`${this.baseUrl}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json", "PayPal-Request-Id": `${Date.now()}-${Math.random() .toString(36) .substring(7)}`, // Generate unique request ID Prefer: "return=representation", ...init?.headers, }, }); } // Update subscription-related methods to use fetchWithAuth async createSubscription({ customerId, priceId, }: { customerId: string; priceId: string; }) { try { const response = await this.fetchWithAuth("/v1/billing/subscriptions", { method: "POST", body: JSON.stringify({ plan_id: priceId, subscriber: { email_address: customerId, }, application_context: { brand_name: "Your Brand", locale: "en-US", shipping_preference: "NO_SHIPPING", user_action: "SUBSCRIBE_NOW", payment_method: { payer_selected: "PAYPAL", payee_preferred: "IMMEDIATE_PAYMENT_REQUIRED", }, }, }), }); if (!response.ok) { const error = (await response.json()) as PayPalError; throw new Error( `PayPal subscription creation failed: ${JSON.stringify(error)}` ); } const subscription = (await response.json()) as PayPalSubscriptionResponse; return { id: subscription.id, status: subscription.status.toLowerCase(), }; } catch (error) { if (error instanceof Error) { throw error; } throw new Error("Unknown error during subscription creation"); } } // Update other REST API methods to use fetchWithAuth async createPrice(params: { unit_amount: number; currency: string; product_data: { name: string; description?: string }; recurring?: { interval: "day" | "week" | "month" | "year" }; }): Promise<{ id: string }> { try { console.log("access token", await this.getAccessToken()); // Create product with proper authentication and explicit method const productResponse = await fetch( `${this.baseUrl}/v1/catalogs/products`, { method: "POST", headers: { Authorization: `Bearer ${await this.getAccessToken()}`, "Content-Type": "application/json", Accept: "application/json", "PayPal-Request-Id": `PRD-${Date.now()}-${Math.random() .toString(36) .slice(2)}`, Prefer: "return=representation", }, body: JSON.stringify({ name: params.product_data.name, description: params.product_data.description, type: "DIGITAL", // Changed from SERVICE to DIGITAL category: "SOFTWARE", // Changed from SOFTWARE to DIGITAL_GOODS }), } ); // For debugging const responseText = await productResponse.text(); console.log("Product Response Status:", productResponse.status); console.log( "Product Response Headers:", Object.fromEntries(productResponse.headers.entries()) ); console.log("Product Response Body:", responseText); if (!productResponse.ok) { const error = JSON.parse(responseText) as PayPalError; throw new Error(`Failed to create product: ${JSON.stringify(error)}`); } const product = JSON.parse(responseText) as { id: string }; // Create billing plan with the same token const planResponse = await fetch(`${this.baseUrl}/v1/billing/plans`, { method: "POST", headers: { Authorization: `Bearer ${await this.getAccessToken()}`, "Content-Type": "application/json", Accept: "application/json", "PayPal-Request-Id": `PLN-${Date.now()}-${Math.random() .toString(36) .slice(2)}`, Prefer: "return=representation", }, body: JSON.stringify({ product_id: product.id, name: params.product_data.name, status: "ACTIVE", billing_cycles: [ { frequency: { interval_unit: params.recurring?.interval.toUpperCase() ?? "MONTH", interval_count: 1, }, tenure_type: "REGULAR", sequence: 1, total_cycles: 0, pricing_scheme: { fixed_price: { value: (params.unit_amount / 100).toString(), currency_code: params.currency.toUpperCase(), }, }, }, ], payment_preferences: { auto_bill_outstanding: true, payment_failure_threshold: 3, }, }), }); // For debugging const planResponseText = await planResponse.text(); console.log("Plan Response Status:", planResponse.status); console.log( "Plan Response Headers:", Object.fromEntries(planResponse.headers.entries()) ); console.log("Plan Response Body:", planResponseText); if (!planResponse.ok) { const error = JSON.parse(planResponseText) as PayPalError; throw new Error(`Failed to create plan: ${JSON.stringify(error)}`); } const plan = JSON.parse(planResponseText) as PayPalSubscriptionPlan; return { id: plan.id }; } catch (error) { console.error("PayPal Error:", error); throw error; } } async createCustomer({ email, name }: { email: string; name?: string }) { // PayPal doesn't have a direct customer creation API // We'll store customer info in our database instead return { id: email }; // Use email as customer ID } async createCheckoutSession({ currency, product, user, successUrl, cancelUrl, }: { currency: string; product: SelectProduct; user: User; successUrl: string; cancelUrl: string; }) { // Handle subscription mode if (product.mode === "subscription" && product.priceId) { const { id: subscriptionId } = await this.createSubscription({ customerId: user.email, priceId: product.priceId, }); // Get subscription details to get approval URL const response = await this.fetchWithAuth( `/v1/billing/subscriptions/${subscriptionId}` ); if (!response.ok) { const error = (await response.json()) as PayPalError; throw new Error( `PayPal subscription fetch failed: ${JSON.stringify(error)}` ); } const subscription = (await response.json()) as PayPalSubscriptionResponse; const approvalLink = subscription.links.find( (link) => link.rel === "approve" )?.href; if (!approvalLink) { throw new Error( "No approval URL found in PayPal subscription response" ); } return { url: approvalLink }; } // Handle one-time payment mode const orderRequest: OrderRequest = { intent: CheckoutPaymentIntent.Capture, purchaseUnits: [ { amount: { currencyCode: currency.toUpperCase(), value: product.price.toString(), }, description: product.description ?? undefined, referenceId: product.id, customId: JSON.stringify({ userId: user.id, productId: product.id, }), }, ], applicationContext: { returnUrl: successUrl.replace("{CHECKOUT_SESSION_ID}", "@{order.id}"), cancelUrl: cancelUrl, userAction: OrderApplicationContextUserAction.PayNow, shippingPreference: OrderApplicationContextShippingPreference.NoShipping, }, }; try { const { result } = await this.ordersController.ordersCreate({ body: orderRequest, prefer: "return=representation", }); const approvalUrl = result.links?.find( (link) => link.rel === "approve" )?.href; if (!approvalUrl) { throw new Error("No approval URL found in PayPal response"); } return { url: approvalUrl }; } catch (error) { if (error instanceof ApiError) { const paypalError = (error as ApiError>) .result; throw new Error( `PayPal checkout creation failed: ${JSON.stringify(paypalError)}` ); } throw error; } } async getSession(sessionId: string) { try { const { result } = await this.ordersController.ordersGet({ id: sessionId, }); if (!result.id) { throw new Error("Invalid PayPal order response - missing ID"); } return { id: result.id, // Now guaranteed to be string customer: { id: result.payer?.payerId ?? result.id, // Fallback to order ID if no payer ID email: result.payer?.emailAddress ?? "", name: result.payer?.name ? `${result.payer.name.givenName ?? ""} ${ result.payer.name.surname ?? "" }`.trim() : undefined, }, payment: result.status ? { id: result.id, status: result.status, amount: parseFloat( result.purchaseUnits?.[0]?.amount?.value ?? "0" ), currency: result.purchaseUnits?.[0]?.amount?.currencyCode?.toLowerCase() ?? "usd", priceId: result.purchaseUnits?.[0]?.referenceId ?? "", } : undefined, metadata: result.purchaseUnits?.[0]?.customId ? (JSON.parse(result.purchaseUnits?.[0].customId) as { userId: string; productId: string; }) : {}, }; } catch (error) { if (error instanceof ApiError) { const paypalError = (error as ApiError>) .result; throw new Error( `PayPal session retrieval failed: ${JSON.stringify(paypalError)}` ); } throw error; } } updateSubscription(params: { subscriptionId: string; priceId: string; }): Promise<{ id: string; status: string }> { throw new Error("Method not implemented."); } updatePrice( priceId: string, data: { active: boolean } ): Promise<{ id: string; active: boolean }> { throw new Error("Method not implemented."); } async cancelSubscription(subscriptionId: string) { try { const response = await this.fetchWithAuth( `/v1/billing/subscriptions/${subscriptionId}/cancel`, { method: "POST", body: JSON.stringify({ reason: "Canceled by customer", }), } ); if (!response.ok) { const error = (await response.json()) as PayPalError; throw new Error( `Failed to cancel subscription: ${JSON.stringify(error)}` ); } return { status: "cancelled" }; } catch (error) { if (error instanceof Error) { throw error; } throw new Error("Unknown error while canceling subscription"); } } createWebhook(params: { endpoint: string; events: string[]; }): Promise<{ id: string; status: string; secret: string; url: string }> { throw new Error("Method not implemented."); } async getSubscription(subscriptionId: string) { try { const response = await this.fetchWithAuth( `/v1/billing/subscriptions/${subscriptionId}` ); if (!response.ok) { const error = (await response.json()) as PayPalError; throw new Error(`Failed to get subscription: ${JSON.stringify(error)}`); } const subscription = (await response.json()) as PayPalSubscriptionResponse; return { id: subscription.id, status: subscription.status.toLowerCase(), currentPeriodEnd: new Date(subscription.billing_info.next_billing_time), }; } catch (error) { if (error instanceof Error) { throw error; } throw new Error("Unknown error while fetching subscription"); } } async getBalance() { throw new Error("PayPal getBalance not implemented yet"); return { available: 0, pending: 0, currency: "usd" }; // TypeScript needs this even though it's unreachable } manageBillingPortal(customerId: string): Promise<{ url: string }> { throw new Error("Method not implemented."); } }