113 lines
3.7 KiB
TypeScript
113 lines
3.7 KiB
TypeScript
import type { BillingCheckoutStatus } from "../data/repos";
|
|
|
|
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
|
|
|
type ApiErrorLike = {
|
|
code?: unknown;
|
|
httpStatus?: unknown;
|
|
};
|
|
|
|
export type CheckoutPollResult =
|
|
| { outcome: "fulfilled"; checkout: BillingCheckoutStatus }
|
|
| { outcome: "failed"; checkout: BillingCheckoutStatus }
|
|
| { outcome: "timeout"; checkout: BillingCheckoutStatus };
|
|
|
|
export function isSafeBillingUrl(value: string, dev = import.meta.env.DEV): boolean {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol === "https:") return true;
|
|
return (
|
|
dev &&
|
|
url.protocol === "http:" &&
|
|
(url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]")
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function billingErrorMessage(
|
|
error: unknown,
|
|
t: Translate,
|
|
fallbackKey: string,
|
|
): string {
|
|
const apiError = error && typeof error === "object" ? (error as ApiErrorLike) : null;
|
|
const isApiError = apiError && ("code" in apiError || "httpStatus" in apiError);
|
|
if (!isApiError) return t(fallbackKey);
|
|
const code = typeof apiError?.code === "number" ? apiError.code : 0;
|
|
const status = typeof apiError?.httpStatus === "number" ? apiError.httpStatus : 0;
|
|
if (status === 0 && code === 0) return t("checkout.networkError");
|
|
if (status === 503 || status === 504 || code === 503010) return t("checkout.unavailable");
|
|
if (status === 401) return t("checkout.sessionExpired");
|
|
if (code === 409081) return t("checkout.portalUnavailable");
|
|
return t(fallbackKey);
|
|
}
|
|
|
|
export function startBillingRedirect(
|
|
assign: () => void,
|
|
onFailure: () => void,
|
|
timeoutMs = 3_000,
|
|
): () => void {
|
|
let settled = false;
|
|
let timer = 0;
|
|
const cleanup = () => {
|
|
settled = true;
|
|
window.clearTimeout(timer);
|
|
window.removeEventListener("pagehide", cleanup);
|
|
};
|
|
window.addEventListener("pagehide", cleanup, { once: true });
|
|
timer = window.setTimeout(() => {
|
|
if (settled) return;
|
|
cleanup();
|
|
onFailure();
|
|
}, timeoutMs);
|
|
try {
|
|
assign();
|
|
} catch {
|
|
cleanup();
|
|
onFailure();
|
|
}
|
|
return cleanup;
|
|
}
|
|
|
|
export function isTerminalCheckoutFailure(checkout: BillingCheckoutStatus): boolean {
|
|
const checkoutStatus = checkout.checkout_status.toLowerCase();
|
|
const paymentStatus = checkout.payment_status.toLowerCase();
|
|
const fulfillmentStatus = checkout.fulfillment_status.toLowerCase();
|
|
return (
|
|
["failed", "expired", "canceled", "cancelled"].includes(checkoutStatus) ||
|
|
["failed", "expired", "canceled", "cancelled"].includes(paymentStatus) ||
|
|
["failed", "expired", "canceled", "cancelled"].includes(fulfillmentStatus)
|
|
);
|
|
}
|
|
|
|
export async function pollCheckout(
|
|
getCheckout: () => Promise<BillingCheckoutStatus>,
|
|
options: {
|
|
intervalMs?: number;
|
|
timeoutMs?: number;
|
|
sleep?: (ms: number) => Promise<void>;
|
|
stopped?: () => boolean;
|
|
} = {},
|
|
): Promise<CheckoutPollResult | null> {
|
|
const intervalMs = options.intervalMs ?? 1_500;
|
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
let elapsed = 0;
|
|
let checkout = await getCheckout();
|
|
|
|
while (!options.stopped?.()) {
|
|
if (checkout.fulfillment_status.toLowerCase() === "fulfilled") {
|
|
return { outcome: "fulfilled", checkout };
|
|
}
|
|
if (isTerminalCheckoutFailure(checkout)) return { outcome: "failed", checkout };
|
|
if (elapsed >= timeoutMs) return { outcome: "timeout", checkout };
|
|
const wait = Math.min(intervalMs, timeoutMs - elapsed);
|
|
await sleep(wait);
|
|
elapsed += wait;
|
|
if (options.stopped?.()) return null;
|
|
checkout = await getCheckout();
|
|
}
|
|
return null;
|
|
}
|