diff --git a/src/helpers/purchase-operation-helper.ts b/src/helpers/purchase-operation-helper.ts index b9852f0dd..bc3838503 100644 --- a/src/helpers/purchase-operation-helper.ts +++ b/src/helpers/purchase-operation-helper.ts @@ -132,6 +132,14 @@ export class PurchaseOperationHelper { this.maxNumberAttempts = maxNumberAttempts; } + get currentOperationSessionId(): string | null { + return this.operationSessionId; + } + + getBackend(): Backend { + return this.backend; + } + async prepareCheckout( productId: string, purchaseOption: PurchaseOption, diff --git a/src/main.ts b/src/main.ts index 1c4889857..7db415021 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import type { } from "./entities/offerings"; import PurchasesUi from "./ui/purchases-ui.svelte"; import PaddlePurchasesUi from "./ui/paddle-purchases-ui.svelte"; + import StripeCheckoutPurchasesUi from "./ui/stripe-checkout-purchases-ui.svelte"; import { type CustomerInfo, toCustomerInfo } from "./entities/customer-info"; @@ -35,6 +36,7 @@ import { PurchaseOperationHelper, } from "./helpers/purchase-operation-helper"; import { PaddleService } from "./paddle/paddle-service"; + import { type LogHandler, type LogLevel } from "./entities/logging"; import { Logger } from "./helpers/logger"; import { diff --git a/src/networking/responses/checkout-prepare-response.ts b/src/networking/responses/checkout-prepare-response.ts index 95db9f1ce..f13151e8c 100644 --- a/src/networking/responses/checkout-prepare-response.ts +++ b/src/networking/responses/checkout-prepare-response.ts @@ -3,7 +3,7 @@ import type { GatewayParams } from "./stripe-elements"; export type CheckoutPrepareStripeGatewayParams = GatewayParams; export interface CheckoutPreparePayPalGatewayParams { - client_access_token: string; + is_sandbox: boolean; } export interface CheckoutPreparePaddleBillingParams { diff --git a/src/networking/responses/checkout-start-response.ts b/src/networking/responses/checkout-start-response.ts index 383b4a2e5..ea4d8f72b 100644 --- a/src/networking/responses/checkout-start-response.ts +++ b/src/networking/responses/checkout-start-response.ts @@ -18,12 +18,19 @@ export interface StripeBillingParams { appearance?: Partial | null; } +export interface PayPalGatewayParams { + order_id: string; + approval_url: string; + is_sandbox: boolean; +} + export interface WebBillingCheckoutStartResponse { operation_session_id: string; gateway_params: GatewayParams; stripe_billing_params: StripeBillingParams | null; management_url: string; paddle_billing_params: null; + paypal_gateway_params: PayPalGatewayParams | null; } export interface PaddleCheckoutStartResponse { diff --git a/src/paypal/paypal-service.ts b/src/paypal/paypal-service.ts new file mode 100644 index 000000000..8766a5ffc --- /dev/null +++ b/src/paypal/paypal-service.ts @@ -0,0 +1,173 @@ +import { PurchasesError } from "../entities/errors"; +import { getWindow } from "../helpers/browser-globals"; +import { + PurchaseFlowError, + PurchaseFlowErrorCode, +} from "../helpers/purchase-operation-helper"; +import type { Backend } from "../networking/backend"; +import type { CheckoutStatusResponse } from "../networking/responses/checkout-status-response"; +import { CheckoutSessionStatus } from "../networking/responses/checkout-status-response"; +import { toRedemptionInfo } from "../entities/redemption-info"; +import type { OperationSessionSuccessfulResult } from "../helpers/purchase-operation-helper"; +import { handleCheckoutSessionFailed } from "../helpers/checkout-error-handler"; + +interface PayPalPurchaseParams { + operationSessionId: string; + approvalUrl: string; + onCheckoutLoaded: () => void; + onClose: () => void; +} + +export class PayPalService { + private readonly backend: Backend; + private readonly maxNumberAttempts: number; + private readonly waitMSBetweenAttempts = 1000; + + constructor(backend: Backend, maxNumberAttempts: number = 10) { + this.backend = backend; + this.maxNumberAttempts = maxNumberAttempts; + } + + async purchase({ + operationSessionId, + approvalUrl, + onCheckoutLoaded, + onClose, + }: PayPalPurchaseParams): Promise { + return new Promise((resolve, reject) => { + onCheckoutLoaded(); + + // Open PayPal approval URL in a popup window + const win = getWindow(); + const popupWidth = 450; + const popupHeight = 600; + const left = (win.screen.width - popupWidth) / 2; + const top = (win.screen.height - popupHeight) / 2; + const popup = win.open( + approvalUrl, + "paypal-checkout", + `width=${popupWidth},height=${popupHeight},left=${left},top=${top},scrollbars=yes`, + ); + + if (!popup) { + reject( + new PurchaseFlowError( + PurchaseFlowErrorCode.UnknownError, + "Failed to open PayPal checkout window. Please allow popups for this site.", + ), + ); + return; + } + + // Poll for popup closure (user cancelled or completed on PayPal side) + const popupPollInterval = setInterval(() => { + if (popup.closed) { + clearInterval(popupPollInterval); + // After the popup closes, poll the backend for status. + // PayPal redirects back to the backend which captures the approval, + // so the operation status will reflect the outcome. + this.pollOperationStatus(operationSessionId) + .then((result) => resolve(result)) + .catch((error) => { + // If polling fails with a non-terminal status, the user likely + // closed the popup before completing payment + if ( + error instanceof PurchaseFlowError && + error.errorCode === PurchaseFlowErrorCode.UnknownError && + error.message?.includes("Max attempts reached") + ) { + onClose(); + } else { + reject(error); + } + }); + } + }, 500); + }); + } + + private async pollOperationStatus( + operationSessionId: string, + ): Promise { + return new Promise((resolve, reject) => { + const checkForOperationStatus = (checkCount = 1) => { + if (checkCount > this.maxNumberAttempts) { + reject( + new PurchaseFlowError( + PurchaseFlowErrorCode.UnknownError, + "Max attempts reached trying to get successful purchase status", + ), + ); + return; + } + this.backend + .getCheckoutStatus(operationSessionId) + .then((operationResponse: CheckoutStatusResponse) => { + const storeTransactionIdentifier = + operationResponse.operation.store_transaction_identifier; + const productIdentifier = + operationResponse.operation.product_identifier; + const purchaseDate = operationResponse.operation.purchase_date + ? isNaN(Date.parse(operationResponse.operation.purchase_date)) + ? null + : new Date(operationResponse.operation.purchase_date) + : null; + switch (operationResponse.operation.status) { + case CheckoutSessionStatus.Started: + case CheckoutSessionStatus.InProgress: + setTimeout( + () => checkForOperationStatus(checkCount + 1), + this.waitMSBetweenAttempts, + ); + break; + case CheckoutSessionStatus.Succeeded: + if ( + !storeTransactionIdentifier || + !productIdentifier || + !purchaseDate + ) { + reject( + new PurchaseFlowError( + PurchaseFlowErrorCode.UnknownError, + "Missing required fields in operation response.", + ), + ); + return; + } + resolve({ + redemptionInfo: toRedemptionInfo(operationResponse), + operationSessionId: operationSessionId, + storeTransactionIdentifier: storeTransactionIdentifier ?? "", + productIdentifier: productIdentifier, + purchaseDate: purchaseDate ?? new Date(), + }); + return; + case CheckoutSessionStatus.Failed: + handleCheckoutSessionFailed( + operationResponse.operation.error, + reject, + ); + } + }) + .catch((error) => { + if (error instanceof PurchasesError) { + const purchasesError = PurchaseFlowError.fromPurchasesError( + error, + PurchaseFlowErrorCode.NetworkError, + ); + reject(purchasesError); + } else { + reject( + new PurchaseFlowError( + PurchaseFlowErrorCode.NetworkError, + `Failed to get checkout status: ${error}`, + ), + ); + } + }); + }; + + checkForOperationStatus(); + }); + } +} diff --git a/src/stories/fixtures.ts b/src/stories/fixtures.ts index de1e973a8..75ea67b4f 100644 --- a/src/stories/fixtures.ts +++ b/src/stories/fixtures.ts @@ -563,6 +563,7 @@ export const checkoutStartResponse: WebBillingCheckoutStartResponse = { }, management_url: "https://manage.revenuecat.com/test_test_test", paddle_billing_params: null, + paypal_gateway_params: null, }; export const checkoutCalculateTaxResponse: CheckoutCalculateTaxResponse = { diff --git a/src/tests/paypal/paypal-service.test.ts b/src/tests/paypal/paypal-service.test.ts new file mode 100644 index 000000000..b18a28752 --- /dev/null +++ b/src/tests/paypal/paypal-service.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { PayPalService } from "../../paypal/paypal-service"; +import { Backend } from "../../networking/backend"; +import { HttpResponse } from "msw"; +import { http } from "msw"; +import { StatusCodes } from "http-status-codes"; +import { + CheckoutSessionStatus, + CheckoutStatusErrorCodes, + type CheckoutStatusResponse, +} from "../../networking/responses/checkout-status-response"; +import { + PurchaseFlowError, + PurchaseFlowErrorCode, +} from "../../helpers/purchase-operation-helper"; +import { setupMswServer } from "../utils/setup-msw-server"; + +const operationSessionId = "test-operation-session-id"; +const approvalUrl = "https://www.sandbox.paypal.com/checkoutnow?token=test"; + +const operationStatusEndpoint = `http://localhost:8000/rcbilling/v1/checkout/${operationSessionId}`; + +const server = setupMswServer(); + +describe("PayPalService", () => { + let backend: Backend; + let paypalService: PayPalService; + let mockPopup: { closed: boolean; close: () => void }; + + beforeEach(() => { + backend = new Backend("test_api_key"); + paypalService = new PayPalService(backend); + + mockPopup = { + closed: false, + close: vi.fn(), + }; + + vi.stubGlobal("open", vi.fn().mockReturnValue(mockPopup)); + + vi.clearAllMocks(); + }); + + describe("purchase", () => { + test("opens popup window with approval URL", async () => { + const onCheckoutLoaded = vi.fn(); + const onClose = vi.fn(); + + const purchasePromise = paypalService.purchase({ + operationSessionId, + approvalUrl, + onCheckoutLoaded, + onClose, + }); + + expect(onCheckoutLoaded).toHaveBeenCalled(); + expect(window.open).toHaveBeenCalledWith( + approvalUrl, + "paypal-checkout", + expect.stringContaining("width="), + ); + + // Clean up + purchasePromise.catch(() => {}); + }); + + test("rejects if popup is blocked", async () => { + vi.stubGlobal("open", vi.fn().mockReturnValue(null)); + + await expect( + paypalService.purchase({ + operationSessionId, + approvalUrl, + onCheckoutLoaded: vi.fn(), + onClose: vi.fn(), + }), + ).rejects.toThrow( + new PurchaseFlowError( + PurchaseFlowErrorCode.UnknownError, + "Failed to open PayPal checkout window. Please allow popups for this site.", + ), + ); + }); + + test("polls operation status after popup closes and resolves on success", async () => { + vi.useFakeTimers(); + + const getOperationStatusResponse: CheckoutStatusResponse = { + operation: { + status: CheckoutSessionStatus.Succeeded, + is_expired: false, + error: null, + redemption_info: { + redeem_url: "test-url://redeem_my_rcb?token=1234", + }, + store_transaction_identifier: "test-store-transaction-id", + product_identifier: "test-product-id", + purchase_date: "2025-01-15T04:21:11Z", + }, + }; + server.use( + http.get(operationStatusEndpoint, () => + HttpResponse.json(getOperationStatusResponse, { + status: StatusCodes.OK, + }), + ), + ); + + const purchasePromise = paypalService.purchase({ + operationSessionId, + approvalUrl, + onCheckoutLoaded: vi.fn(), + onClose: vi.fn(), + }); + + // Simulate popup closing (user completed payment on PayPal) + mockPopup.closed = true; + await vi.advanceTimersByTimeAsync(500); + + const result = await purchasePromise; + + expect(result).toEqual({ + redemptionInfo: { + redeemUrl: "test-url://redeem_my_rcb?token=1234", + }, + operationSessionId: operationSessionId, + storeTransactionIdentifier: "test-store-transaction-id", + productIdentifier: "test-product-id", + purchaseDate: new Date("2025-01-15T04:21:11Z"), + }); + + vi.useRealTimers(); + }); + + test("calls onClose when user closes popup and polling times out", async () => { + vi.useFakeTimers(); + + const onClose = vi.fn(); + + server.use( + http.get(operationStatusEndpoint, () => + HttpResponse.json( + { + operation: { + status: CheckoutSessionStatus.Started, + is_expired: false, + error: null, + }, + }, + { status: StatusCodes.OK }, + ), + ), + ); + + const purchasePromise = paypalService.purchase({ + operationSessionId, + approvalUrl, + onCheckoutLoaded: vi.fn(), + onClose, + }); + + // Simulate popup closing (user cancelled) + mockPopup.closed = true; + await vi.advanceTimersByTimeAsync(500); + + // Advance past all polling attempts + for (let i = 0; i < 10; i++) { + await vi.advanceTimersByTimeAsync(1000); + } + await vi.runAllTimersAsync(); + + expect(onClose).toHaveBeenCalled(); + + // Clean up - the promise won't reject since onClose was called instead + purchasePromise.catch(() => {}); + + vi.useRealTimers(); + }); + + test("rejects if operation status is failed", async () => { + vi.useFakeTimers(); + + server.use( + http.get(operationStatusEndpoint, () => + HttpResponse.json( + { + operation: { + status: CheckoutSessionStatus.Failed, + is_expired: false, + error: { + code: CheckoutStatusErrorCodes.PaymentChargeFailed, + message: "test-error-message", + }, + }, + }, + { status: StatusCodes.OK }, + ), + ), + ); + + const purchasePromise = paypalService.purchase({ + operationSessionId, + approvalUrl, + onCheckoutLoaded: vi.fn(), + onClose: vi.fn(), + }); + + // Attach rejection handler before advancing timers to avoid unhandled rejection + const expectation = expect(purchasePromise).rejects.toThrow( + new PurchaseFlowError( + PurchaseFlowErrorCode.ErrorChargingPayment, + "Payment charge failed", + ), + ); + + // Simulate popup closing + mockPopup.closed = true; + await vi.advanceTimersByTimeAsync(500); + + await expectation; + + vi.useRealTimers(); + }); + + test("rejects if operation status response is missing required fields", async () => { + vi.useFakeTimers(); + + server.use( + http.get(operationStatusEndpoint, () => + HttpResponse.json( + { + operation: { + status: CheckoutSessionStatus.Succeeded, + is_expired: false, + error: null, + // missing store_transaction_identifier, product_identifier, purchase_date + }, + }, + { status: StatusCodes.OK }, + ), + ), + ); + + const purchasePromise = paypalService.purchase({ + operationSessionId, + approvalUrl, + onCheckoutLoaded: vi.fn(), + onClose: vi.fn(), + }); + + // Attach rejection handler before advancing timers to avoid unhandled rejection + const expectation = expect(purchasePromise).rejects.toThrow( + new PurchaseFlowError( + PurchaseFlowErrorCode.UnknownError, + "Missing required fields in operation response.", + ), + ); + + // Simulate popup closing + mockPopup.closed = true; + await vi.advanceTimersByTimeAsync(500); + + await expectation; + + vi.useRealTimers(); + }); + + test("rejects if getCheckoutStatus fails with network error", async () => { + vi.useFakeTimers(); + + server.use( + http.get(operationStatusEndpoint, () => + HttpResponse.json(null, { + status: StatusCodes.INTERNAL_SERVER_ERROR, + }), + ), + ); + + const purchasePromise = paypalService.purchase({ + operationSessionId, + approvalUrl, + onCheckoutLoaded: vi.fn(), + onClose: vi.fn(), + }); + + // Attach rejection handler before advancing timers to avoid unhandled rejection + const expectation = + expect(purchasePromise).rejects.toThrow(PurchaseFlowError); + + // Simulate popup closing + mockPopup.closed = true; + await vi.advanceTimersByTimeAsync(500); + await vi.runAllTimersAsync(); + + await expectation; + + vi.useRealTimers(); + }); + }); +}); diff --git a/src/tests/test-responses.ts b/src/tests/test-responses.ts index df471237a..c14e48063 100644 --- a/src/tests/test-responses.ts +++ b/src/tests/test-responses.ts @@ -612,7 +612,7 @@ export const checkoutPrepareResponse: CheckoutPrepareResponse = { }, }, paypal_gateway_params: { - client_access_token: "test_paypal_access_token", + is_sandbox: true, }, paddle_billing_params: { client_side_token: "test_client_side_token", @@ -857,6 +857,7 @@ export const checkoutStartResponse: CheckoutStartResponse = { stripe_billing_params: null, management_url: "https://test-management-url.revenuecat.com", paddle_billing_params: null, + paypal_gateway_params: null, }; export const checkoutCompleteResponse: CheckoutCompleteResponse = { diff --git a/src/tests/ui/stripe-checkout-purchases-ui.test.ts b/src/tests/ui/stripe-checkout-purchases-ui.test.ts index 0bf5f0935..98d1fa3da 100644 --- a/src/tests/ui/stripe-checkout-purchases-ui.test.ts +++ b/src/tests/ui/stripe-checkout-purchases-ui.test.ts @@ -33,6 +33,7 @@ const checkoutStartResponseWithoutStripeParams: WebBillingCheckoutStartResponse }, management_url: "https://test-management-url.revenuecat.com", paddle_billing_params: null, + paypal_gateway_params: null, }; const createCheckoutStartResponseWithStripeParams = ( @@ -56,6 +57,7 @@ const createCheckoutStartResponseWithStripeParams = ( }, management_url: "https://test-management-url.revenuecat.com", paddle_billing_params: null, + paypal_gateway_params: null, }); const purchaseOperationHelperMock: PurchaseOperationHelper = { diff --git a/src/ui/pages/payment-entry-page.svelte b/src/ui/pages/payment-entry-page.svelte index 0999cc6e8..8498fe492 100644 --- a/src/ui/pages/payment-entry-page.svelte +++ b/src/ui/pages/payment-entry-page.svelte @@ -46,12 +46,15 @@ import StripeElementsComponent from "../molecules/stripe-elements.svelte"; import PriceUpdateInfo from "../molecules/price-update-info.svelte"; import { getInitialPriceFromPurchaseOption } from "../../helpers/purchase-option-price-helper"; + import type { PayPalGatewayParams } from "../../networking/responses/checkout-start-response"; + import { PayPalService } from "../../paypal/paypal-service"; type View = "loading" | "form" | "error"; interface Props { gatewayParams: GatewayParams; managementUrl: string | null; + paypalGatewayParams?: PayPalGatewayParams | null; productDetails: Product; purchaseOption: PurchaseOption; brandingInfo: BrandingInfoResponse | null; @@ -74,6 +77,7 @@ const { gatewayParams, managementUrl, + paypalGatewayParams, productDetails, purchaseOption, brandingInfo, @@ -529,6 +533,34 @@ } } + let paypalProcessing = $state(false); + + async function handlePayPalClick(): Promise { + if (paypalProcessing || !paypalGatewayParams) return; + + paypalProcessing = true; + const paypalService = new PayPalService( + purchaseOperationHelper.getBackend(), + ); + + try { + await paypalService.purchase({ + operationSessionId: purchaseOperationHelper.currentOperationSessionId!, + approvalUrl: paypalGatewayParams.approval_url, + onCheckoutLoaded: () => {}, + onClose: () => { + paypalProcessing = false; + }, + }); + onContinue(); + } catch (error) { + paypalProcessing = false; + if (error instanceof PurchaseFlowError) { + onError(error); + } + } + } + const handleErrorTryAgain = () => { modalErrorMessage = undefined; }; @@ -585,6 +617,27 @@ /> + {#if paypalGatewayParams} +
+ + or + +
+ + {/if} +
{ if (e.errorCode === PurchaseFlowErrorCode.MissingEmailError) { @@ -259,6 +262,7 @@ {lastError} {gatewayParams} {managementUrl} + {paypalGatewayParams} {purchaseOperationHelper} {isInElement} {termsAndConditionsUrl}