Last active
February 22, 2025 13:53
-
-
Save mmikhan/5944bc50c6302efea0b87dcfd9b3a8c7 to your computer and use it in GitHub Desktop.
PayPal TypeScript SDK oAuth fetch token implementation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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<string> { | |
// 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<ApiResponse<PayPalError>>) | |
.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<ApiResponse<PayPalError>>) | |
.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."); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment