thread-master/apps/web/src/lib/billingCheckout.ts

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;
}