Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/helpers/purchase-operation-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/networking/responses/checkout-prepare-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/networking/responses/checkout-start-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ export interface StripeBillingParams {
appearance?: Partial<BrandingAppearance> | 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 {
Expand Down
173 changes: 173 additions & 0 deletions src/paypal/paypal-service.ts
Original file line number Diff line number Diff line change
@@ -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<OperationSessionSuccessfulResult> {
return new Promise<OperationSessionSuccessfulResult>((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<OperationSessionSuccessfulResult> {
return new Promise<OperationSessionSuccessfulResult>((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();
});
}
}
1 change: 1 addition & 0 deletions src/stories/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading