Skip to content

Instantly share code, notes, and snippets.

@mmikhan
Last active February 22, 2025 13:53
Show Gist options
  • Select an option

  • Save mmikhan/5944bc50c6302efea0b87dcfd9b3a8c7 to your computer and use it in GitHub Desktop.

Select an option

Save mmikhan/5944bc50c6302efea0b87dcfd9b3a8c7 to your computer and use it in GitHub Desktop.

Revisions

  1. mmikhan revised this gist Feb 22, 2025. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion paypal.ts
    Original file line number Diff line number Diff line change
    @@ -191,7 +191,7 @@ export class PayPalPaymentProvider implements PaymentProvider {
    name: params.product_data.name,
    description: params.product_data.description,
    type: "DIGITAL", // Changed from SERVICE to DIGITAL
    category: "DIGITAL_GOODS", // Changed from SOFTWARE to DIGITAL_GOODS
    category: "SOFTWARE", // Changed from SOFTWARE to DIGITAL_GOODS
    }),
    }
    );
  2. mmikhan created this gist Feb 22, 2025.
    520 changes: 520 additions & 0 deletions paypal.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,520 @@
    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: "DIGITAL_GOODS", // 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.");
    }
    }