Split PayPal hosted checkout flow
This commit is contained in:
@@ -10616,6 +10616,12 @@ const AUTO_RUN_BACKGROUND_COMPLETED_STEP_KEYS = new Set([
|
||||
'fetch-signup-code',
|
||||
'wait-registration-success',
|
||||
'plus-checkout-create',
|
||||
'paypal-hosted-openai-checkout',
|
||||
'paypal-hosted-email',
|
||||
'paypal-hosted-verification',
|
||||
'paypal-hosted-card',
|
||||
'paypal-hosted-create-account',
|
||||
'paypal-hosted-review',
|
||||
'plus-checkout-billing',
|
||||
'paypal-approve',
|
||||
'plus-checkout-return',
|
||||
@@ -11715,6 +11721,12 @@ const AUTO_RUN_NODE_DELAYS = Object.freeze({
|
||||
'fill-profile': 0,
|
||||
'wait-registration-success': 3000,
|
||||
'plus-checkout-create': 3000,
|
||||
'paypal-hosted-openai-checkout': 2000,
|
||||
'paypal-hosted-email': 2000,
|
||||
'paypal-hosted-verification': 2000,
|
||||
'paypal-hosted-card': 2000,
|
||||
'paypal-hosted-create-account': 2000,
|
||||
'paypal-hosted-review': 2000,
|
||||
'plus-checkout-billing': 2000,
|
||||
'gopay-subscription-confirm': 2000,
|
||||
'paypal-approve': 2000,
|
||||
@@ -13443,8 +13455,11 @@ const plusCheckoutCreateExecutor = self.MultiPageBackgroundPlusCheckoutCreate?.c
|
||||
createAutomationTab,
|
||||
ensureContentScriptReadyOnTabUntilStopped,
|
||||
fetch: typeof fetch === 'function' ? fetch.bind(globalThis) : null,
|
||||
getTabId,
|
||||
getState,
|
||||
isTabAlive,
|
||||
markCurrentRegistrationAccountUsed,
|
||||
queryTabsInAutomationWindow,
|
||||
registerTab,
|
||||
sendTabMessageUntilStopped,
|
||||
setState,
|
||||
@@ -13727,6 +13742,12 @@ const stepExecutorsByKey = {
|
||||
'fill-profile': (state) => step5Executor.executeStep5(state),
|
||||
'wait-registration-success': (state) => step6Executor.executeStep6(state),
|
||||
'plus-checkout-create': (state) => plusCheckoutCreateExecutor.executePlusCheckoutCreate(state),
|
||||
'paypal-hosted-openai-checkout': (state) => plusCheckoutCreateExecutor.executePayPalHostedOpenAiCheckout(state),
|
||||
'paypal-hosted-email': (state) => plusCheckoutCreateExecutor.executePayPalHostedEmail(state),
|
||||
'paypal-hosted-verification': (state) => plusCheckoutCreateExecutor.executePayPalHostedVerification(state),
|
||||
'paypal-hosted-card': (state) => plusCheckoutCreateExecutor.executePayPalHostedCard(state),
|
||||
'paypal-hosted-create-account': (state) => plusCheckoutCreateExecutor.executePayPalHostedCreateAccount(state),
|
||||
'paypal-hosted-review': (state) => plusCheckoutCreateExecutor.executePayPalHostedReview(state),
|
||||
'plus-checkout-billing': (state) => plusCheckoutBillingExecutor.executePlusCheckoutBilling(state),
|
||||
'gopay-subscription-confirm': (state) => goPayManualConfirmExecutor.executeGoPayManualConfirm(state),
|
||||
'paypal-approve': (state) => normalizePlusPaymentMethod(state?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GOPAY
|
||||
|
||||
@@ -20,6 +20,28 @@
|
||||
const HOSTED_CHECKOUT_VERIFICATION_POLL_ATTEMPTS = 12;
|
||||
const HOSTED_CHECKOUT_VERIFICATION_POLL_INTERVAL_MS = 5000;
|
||||
const HOSTED_CHECKOUT_DEFAULT_PHONE = '1234567890';
|
||||
const PAYPAL_HOSTED_STAGE_OUTSIDE = 'outside_paypal';
|
||||
const PAYPAL_HOSTED_STAGE_LOGIN = 'pay_login';
|
||||
const PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT = 'guest_checkout';
|
||||
const PAYPAL_HOSTED_STAGE_VERIFICATION = 'verification';
|
||||
const PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT = 'create_account';
|
||||
const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent';
|
||||
const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval';
|
||||
const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown';
|
||||
const PAYPAL_HOSTED_STEP_OPENAI_CHECKOUT = 'paypal-hosted-openai-checkout';
|
||||
const PAYPAL_HOSTED_STEP_EMAIL = 'paypal-hosted-email';
|
||||
const PAYPAL_HOSTED_STEP_VERIFICATION = 'paypal-hosted-verification';
|
||||
const PAYPAL_HOSTED_STEP_CARD = 'paypal-hosted-card';
|
||||
const PAYPAL_HOSTED_STEP_CREATE_ACCOUNT = 'paypal-hosted-create-account';
|
||||
const PAYPAL_HOSTED_STEP_REVIEW = 'paypal-hosted-review';
|
||||
const PAYPAL_HOSTED_STEP_META = Object.freeze({
|
||||
[PAYPAL_HOSTED_STEP_OPENAI_CHECKOUT]: { step: 7, label: '无卡直绑 OpenAI Checkout' },
|
||||
[PAYPAL_HOSTED_STEP_EMAIL]: { step: 8, label: '无卡直绑 PayPal 邮箱页' },
|
||||
[PAYPAL_HOSTED_STEP_VERIFICATION]: { step: 9, label: '无卡直绑 PayPal 验证码页' },
|
||||
[PAYPAL_HOSTED_STEP_CARD]: { step: 10, label: '无卡直绑 PayPal 资料页' },
|
||||
[PAYPAL_HOSTED_STEP_CREATE_ACCOUNT]: { step: 11, label: '无卡直绑 PayPal 创建确认页' },
|
||||
[PAYPAL_HOSTED_STEP_REVIEW]: { step: 12, label: '无卡直绑 PayPal 授权复核页' },
|
||||
});
|
||||
|
||||
function createPlusCheckoutCreateExecutor(deps = {}) {
|
||||
const {
|
||||
@@ -29,7 +51,10 @@
|
||||
createAutomationTab = null,
|
||||
ensureContentScriptReadyOnTabUntilStopped,
|
||||
fetch: fetchImpl = null,
|
||||
getTabId = null,
|
||||
getState = null,
|
||||
isTabAlive = null,
|
||||
queryTabsInAutomationWindow = null,
|
||||
registerTab,
|
||||
sendTabMessageUntilStopped,
|
||||
setState,
|
||||
@@ -47,6 +72,15 @@
|
||||
});
|
||||
}
|
||||
|
||||
function addHostedStepLog(stepKey, message, level = 'info', options = {}) {
|
||||
const meta = PAYPAL_HOSTED_STEP_META[stepKey] || {};
|
||||
return rawAddLog(message, level, {
|
||||
step: meta.step || 6,
|
||||
stepKey,
|
||||
...(options && typeof options === 'object' ? options : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePlusPaymentMethod(value = '') {
|
||||
const rootScope = typeof self !== 'undefined' ? self : globalThis;
|
||||
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
|
||||
@@ -110,6 +144,178 @@
|
||||
return /^https:\/\/(?:pay\.openai\.com|checkout\.stripe\.com)\/c\/pay(?:\/|$)/i.test(String(url || ''));
|
||||
}
|
||||
|
||||
function isHostedCheckoutRuntimeUrl(url = '') {
|
||||
return isHostedOpenAiCheckoutUrl(url) || isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url);
|
||||
}
|
||||
|
||||
function getHostedStepNumber(stepKey = '') {
|
||||
return PAYPAL_HOSTED_STEP_META[stepKey]?.step || 6;
|
||||
}
|
||||
|
||||
function normalizeHostedPhoneForPayload(phone = '') {
|
||||
const digits = String(phone || '').replace(/\D/g, '');
|
||||
if (!digits) {
|
||||
return HOSTED_CHECKOUT_DEFAULT_PHONE;
|
||||
}
|
||||
if (digits.length > 10 && digits.startsWith('1')) {
|
||||
return digits.slice(-10);
|
||||
}
|
||||
return digits.length > 10 ? digits.slice(-10) : digits;
|
||||
}
|
||||
|
||||
function getHostedProfileFromState(state = {}) {
|
||||
const profile = state?.plusHostedCheckoutGuestProfile || state?.hostedCheckoutGuestProfile || null;
|
||||
return profile && typeof profile === 'object' && !Array.isArray(profile) ? profile : null;
|
||||
}
|
||||
|
||||
async function getLatestHostedState(state = {}) {
|
||||
const latestState = typeof getState === 'function' ? await getState().catch(() => ({})) : {};
|
||||
return {
|
||||
...(latestState && typeof latestState === 'object' ? latestState : {}),
|
||||
...(state && typeof state === 'object' ? state : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureHostedGuestProfile(state = {}) {
|
||||
const mergedState = await getLatestHostedState(state);
|
||||
const existingProfile = getHostedProfileFromState(mergedState) || {};
|
||||
const config = await getHostedCheckoutRuntimeConfig(mergedState);
|
||||
const address = existingProfile.address && typeof existingProfile.address === 'object'
|
||||
? existingProfile.address
|
||||
: await fetchHostedCheckoutAddress();
|
||||
const generatedProfile = buildHostedGuestProfile(address, {
|
||||
phone: normalizeHostedPhoneForPayload(config.phone),
|
||||
});
|
||||
const nextProfile = {
|
||||
...generatedProfile,
|
||||
...existingProfile,
|
||||
address,
|
||||
phone: normalizeHostedPhoneForPayload(config.phone || existingProfile.phone),
|
||||
};
|
||||
await setState({
|
||||
plusHostedCheckoutGuestProfile: nextProfile,
|
||||
plusHostedCheckoutPhoneDigits: nextProfile.phone,
|
||||
});
|
||||
return {
|
||||
profile: nextProfile,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
async function getTabById(tabId) {
|
||||
const normalizedTabId = Number(tabId) || 0;
|
||||
if (!normalizedTabId || !chrome?.tabs?.get) {
|
||||
return null;
|
||||
}
|
||||
return chrome.tabs.get(normalizedTabId).catch(() => null);
|
||||
}
|
||||
|
||||
async function registerHostedCheckoutTab(tabId, url = '') {
|
||||
if (typeof registerTab !== 'function' || !Number.isInteger(Number(tabId))) {
|
||||
return;
|
||||
}
|
||||
await registerTab(isPayPalUrl(url) ? PAYPAL_SOURCE : PLUS_CHECKOUT_SOURCE, Number(tabId));
|
||||
}
|
||||
|
||||
async function findOpenHostedCheckoutTabId() {
|
||||
const queryTabs = typeof queryTabsInAutomationWindow === 'function'
|
||||
? queryTabsInAutomationWindow
|
||||
: (chrome?.tabs?.query ? (queryInfo) => chrome.tabs.query(queryInfo) : null);
|
||||
if (typeof queryTabs !== 'function') {
|
||||
return 0;
|
||||
}
|
||||
const tabs = await queryTabs({}).catch(() => []);
|
||||
const candidates = (Array.isArray(tabs) ? tabs : [])
|
||||
.filter((tab) => Number.isInteger(tab?.id) && isHostedCheckoutRuntimeUrl(tab.url || ''));
|
||||
if (!candidates.length) {
|
||||
return 0;
|
||||
}
|
||||
const match = candidates.find((tab) => tab.active && tab.currentWindow)
|
||||
|| candidates.find((tab) => tab.active)
|
||||
|| candidates[0];
|
||||
if (match?.id && chrome?.tabs?.update) {
|
||||
await chrome.tabs.update(match.id, { active: true }).catch(() => {});
|
||||
}
|
||||
await registerHostedCheckoutTab(match.id, match.url || '');
|
||||
return match?.id || 0;
|
||||
}
|
||||
|
||||
async function resolveHostedCheckoutTabId(state = {}, stepKey = '') {
|
||||
const storedTabId = Number(state?.plusCheckoutTabId) || 0;
|
||||
const storedTab = await getTabById(storedTabId);
|
||||
if (storedTab?.id && isHostedCheckoutRuntimeUrl(storedTab.url || '')) {
|
||||
await registerHostedCheckoutTab(storedTab.id, storedTab.url || '');
|
||||
return storedTab.id;
|
||||
}
|
||||
|
||||
if (typeof getTabId === 'function') {
|
||||
const paypalTabId = await Promise.resolve(getTabId(PAYPAL_SOURCE)).catch(() => 0);
|
||||
const paypalAlive = typeof isTabAlive !== 'function'
|
||||
? Boolean(paypalTabId)
|
||||
: await Promise.resolve(isTabAlive(PAYPAL_SOURCE)).catch(() => false);
|
||||
if (paypalTabId && paypalAlive) {
|
||||
return paypalTabId;
|
||||
}
|
||||
const checkoutTabId = await Promise.resolve(getTabId(PLUS_CHECKOUT_SOURCE)).catch(() => 0);
|
||||
const checkoutAlive = typeof isTabAlive !== 'function'
|
||||
? Boolean(checkoutTabId)
|
||||
: await Promise.resolve(isTabAlive(PLUS_CHECKOUT_SOURCE)).catch(() => false);
|
||||
if (checkoutTabId && checkoutAlive) {
|
||||
return checkoutTabId;
|
||||
}
|
||||
}
|
||||
|
||||
const discoveredTabId = await findOpenHostedCheckoutTabId();
|
||||
if (discoveredTabId) {
|
||||
await addHostedStepLog(stepKey, `步骤 ${getHostedStepNumber(stepKey)}:已从当前浏览器标签中发现 PayPal 无卡直绑页面,正在接管继续执行。`, 'info');
|
||||
return discoveredTabId;
|
||||
}
|
||||
|
||||
throw new Error(`步骤 ${getHostedStepNumber(stepKey)}:未找到 PayPal 无卡直绑标签页,请先完成创建 checkout 节点。`);
|
||||
}
|
||||
|
||||
async function getHostedCurrentUrl(tabId) {
|
||||
const tab = await getTabById(tabId);
|
||||
return String(tab?.url || '').trim();
|
||||
}
|
||||
|
||||
async function updateHostedCheckoutTabState(tabId, payload = {}) {
|
||||
const currentUrl = await getHostedCurrentUrl(tabId);
|
||||
await setState({
|
||||
plusCheckoutTabId: tabId,
|
||||
plusCheckoutUrl: currentUrl,
|
||||
...(payload && typeof payload === 'object' ? payload : {}),
|
||||
});
|
||||
return currentUrl;
|
||||
}
|
||||
|
||||
async function completeHostedStep(stepKey, tabId, payload = {}) {
|
||||
const currentUrl = await updateHostedCheckoutTabState(tabId, payload);
|
||||
await completeNodeFromBackground(stepKey, {
|
||||
plusCheckoutUrl: currentUrl,
|
||||
...(payload && typeof payload === 'object' ? payload : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function completeHostedStepIfSuccessful(stepKey, tabId, state = {}, options = {}) {
|
||||
const currentUrl = await getHostedCurrentUrl(tabId);
|
||||
if (!isHostedCheckoutSuccessUrl(currentUrl)) {
|
||||
return false;
|
||||
}
|
||||
const config = await getHostedCheckoutRuntimeConfig(state);
|
||||
const shouldWait = Boolean(options.waitBeforeComplete);
|
||||
if (shouldWait && config.oauthDelaySeconds > 0) {
|
||||
await addHostedStepLog(stepKey, `步骤 ${getHostedStepNumber(stepKey)}:支付成功后等待 ${config.oauthDelaySeconds} 秒,再继续账号接入。`, 'info');
|
||||
await sleepWithStop(config.oauthDelaySeconds * 1000);
|
||||
}
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusReturnUrl: currentUrl,
|
||||
plusHostedCheckoutCompleted: true,
|
||||
plusHostedCheckoutOauthDelaySeconds: config.oauthDelaySeconds,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function waitForUrlMatch(tabId, matcher, timeoutMs = 30000, retryDelayMs = 500) {
|
||||
const deadline = Date.now() + Math.max(1000, Number(timeoutMs) || 30000);
|
||||
while (Date.now() < deadline) {
|
||||
@@ -130,18 +336,18 @@
|
||||
const latestState = typeof getState === 'function' ? await getState().catch(() => ({})) : {};
|
||||
return {
|
||||
verificationUrl: String(
|
||||
state?.hostedCheckoutVerificationUrl
|
||||
|| latestState?.hostedCheckoutVerificationUrl
|
||||
latestState?.hostedCheckoutVerificationUrl
|
||||
|| state?.hostedCheckoutVerificationUrl
|
||||
|| ''
|
||||
).trim(),
|
||||
phone: String(
|
||||
state?.hostedCheckoutPhoneNumber
|
||||
|| latestState?.hostedCheckoutPhoneNumber
|
||||
latestState?.hostedCheckoutPhoneNumber
|
||||
|| state?.hostedCheckoutPhoneNumber
|
||||
|| HOSTED_CHECKOUT_DEFAULT_PHONE
|
||||
).trim(),
|
||||
oauthDelaySeconds: normalizeHostedCheckoutDelaySeconds(
|
||||
state?.plusHostedCheckoutOauthDelaySeconds
|
||||
?? latestState?.plusHostedCheckoutOauthDelaySeconds
|
||||
latestState?.plusHostedCheckoutOauthDelaySeconds
|
||||
?? state?.plusHostedCheckoutOauthDelaySeconds
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -386,72 +592,80 @@
|
||||
return result || {};
|
||||
}
|
||||
|
||||
async function runHostedPayPalCheckout(tabId, profile, config) {
|
||||
const deadline = Date.now() + HOSTED_CHECKOUT_PAYPAL_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
throwIfStopped();
|
||||
const tab = await chrome?.tabs?.get?.(tabId).catch(() => null);
|
||||
if (!tab) {
|
||||
throw new Error('步骤 6:PayPal 无卡直绑标签页已关闭。');
|
||||
}
|
||||
const currentUrl = String(tab.url || '').trim();
|
||||
if (isHostedCheckoutSuccessUrl(currentUrl)) {
|
||||
await addLog('步骤 6:PayPal 无卡直绑已回到 ChatGPT 支付成功页。', 'ok');
|
||||
return currentUrl;
|
||||
}
|
||||
if (!isPayPalUrl(currentUrl)) {
|
||||
await addLog(`步骤 6:无卡直绑已离开 PayPal(${currentUrl || '空 URL'}),继续等待支付成功页。`, 'info');
|
||||
await sleepWithStop(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageState = await getHostedPayPalState(tabId);
|
||||
if (pageState.hostedStage === 'verification' && pageState.verificationInputsVisible) {
|
||||
const verificationCode = await pollHostedVerificationCode(config.verificationUrl);
|
||||
await runHostedPayPalStep(tabId, { verificationCode });
|
||||
await sleepWithStop(1000);
|
||||
continue;
|
||||
}
|
||||
if (pageState.hostedStage === 'pay_login') {
|
||||
await addLog('步骤 6:正在填写 PayPal 无卡直绑登录邮箱。', 'info');
|
||||
await runHostedPayPalStep(tabId, { email: profile.email });
|
||||
await sleepWithStop(1000);
|
||||
continue;
|
||||
}
|
||||
if (pageState.hostedStage === 'guest_checkout') {
|
||||
await addLog('步骤 6:正在填写 PayPal 无卡直绑卡支付资料。', 'info');
|
||||
await runHostedPayPalStep(tabId, {
|
||||
...profile,
|
||||
phone: config.phone || profile.phone,
|
||||
});
|
||||
await sleepWithStop(1500);
|
||||
continue;
|
||||
}
|
||||
if (pageState.hostedStage === 'review_consent') {
|
||||
await addLog('步骤 6:正在确认 PayPal 无卡直绑账单复核页。', 'info');
|
||||
await runHostedPayPalStep(tabId, {});
|
||||
await sleepWithStop(1000);
|
||||
continue;
|
||||
}
|
||||
if (pageState.hostedStage === 'approval') {
|
||||
throw new Error('步骤 6:无卡直绑流程进入普通 PayPal 授权页,请检查支付方式是否选择正确。');
|
||||
}
|
||||
await sleepWithStop(1000);
|
||||
function getHostedStageOrder(stage = '') {
|
||||
switch (stage) {
|
||||
case PAYPAL_HOSTED_STAGE_LOGIN:
|
||||
return 1;
|
||||
case PAYPAL_HOSTED_STAGE_VERIFICATION:
|
||||
return 2;
|
||||
case PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT:
|
||||
return 3;
|
||||
case PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT:
|
||||
return 4;
|
||||
case PAYPAL_HOSTED_STAGE_REVIEW:
|
||||
return 5;
|
||||
case PAYPAL_HOSTED_STAGE_OUTSIDE:
|
||||
return 6;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
throw new Error('步骤 6:PayPal 无卡直绑自动化超时,未等到支付成功页。');
|
||||
}
|
||||
|
||||
async function waitHostedSuccessDelayAndComplete(tabId, state = {}, successUrl = '') {
|
||||
const config = await getHostedCheckoutRuntimeConfig(state);
|
||||
if (config.oauthDelaySeconds > 0) {
|
||||
await addLog(`步骤 6:支付成功后等待 ${config.oauthDelaySeconds} 秒,再继续账号接入。`, 'info');
|
||||
await sleepWithStop(config.oauthDelaySeconds * 1000);
|
||||
function isHostedStageAtOrAfter(stage = '', expectedStage = '') {
|
||||
const currentOrder = getHostedStageOrder(stage);
|
||||
const expectedOrder = getHostedStageOrder(expectedStage);
|
||||
return currentOrder > 0 && expectedOrder > 0 && currentOrder >= expectedOrder;
|
||||
}
|
||||
|
||||
async function waitForHostedPayPalStage(tabId, predicate, options = {}) {
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || HOSTED_CHECKOUT_TRANSITION_TIMEOUT_MS);
|
||||
const intervalMs = Math.max(100, Number(options.intervalMs) || 500);
|
||||
const label = String(options.label || 'PayPal 无卡直绑页面').trim();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastStage = '';
|
||||
while (Date.now() < deadline) {
|
||||
throwIfStopped();
|
||||
const currentUrl = await getHostedCurrentUrl(tabId);
|
||||
if (isHostedCheckoutSuccessUrl(currentUrl)) {
|
||||
return {
|
||||
successUrl: currentUrl,
|
||||
hostedStage: PAYPAL_HOSTED_STAGE_OUTSIDE,
|
||||
};
|
||||
}
|
||||
if (!isPayPalUrl(currentUrl)) {
|
||||
await sleepWithStop(intervalMs);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const pageState = await getHostedPayPalState(tabId);
|
||||
lastStage = pageState?.hostedStage || lastStage;
|
||||
if (predicate(pageState)) {
|
||||
return pageState;
|
||||
}
|
||||
} catch (error) {
|
||||
lastStage = error?.message || lastStage;
|
||||
}
|
||||
await sleepWithStop(intervalMs);
|
||||
}
|
||||
await completeNodeFromBackground('plus-checkout-create', {
|
||||
plusReturnUrl: successUrl,
|
||||
plusHostedCheckoutCompleted: true,
|
||||
plusHostedCheckoutOauthDelaySeconds: config.oauthDelaySeconds,
|
||||
});
|
||||
throw new Error(`${label}等待超时${lastStage ? `(最后状态:${lastStage})` : ''}。`);
|
||||
}
|
||||
|
||||
async function waitForHostedUrlAfterAction(tabId, matcher, options = {}) {
|
||||
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || HOSTED_CHECKOUT_TRANSITION_TIMEOUT_MS);
|
||||
const intervalMs = Math.max(100, Number(options.intervalMs) || 500);
|
||||
const label = String(options.label || 'PayPal 无卡直绑跳转').trim();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
throwIfStopped();
|
||||
const currentTab = await getTabById(tabId);
|
||||
const currentUrl = String(currentTab?.url || '').trim();
|
||||
if (matcher(currentUrl, currentTab)) {
|
||||
await waitForTabCompleteUntilStopped(tabId).catch(() => {});
|
||||
return currentUrl;
|
||||
}
|
||||
await sleepWithStop(intervalMs);
|
||||
}
|
||||
throw new Error(`${label}等待超时。`);
|
||||
}
|
||||
|
||||
function resolveCheckoutTargetUrl(result = {}, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
|
||||
@@ -484,56 +698,298 @@
|
||||
);
|
||||
const landedUrl = String(landedTab?.url || targetCheckoutUrl || '').trim();
|
||||
|
||||
const isAlreadySuccessful = isHostedCheckoutSuccessUrl(landedUrl);
|
||||
await setState({
|
||||
plusCheckoutTabId: tabId,
|
||||
plusCheckoutUrl: landedUrl,
|
||||
plusCheckoutCountry: result.country || 'US',
|
||||
plusCheckoutCurrency: result.currency || 'USD',
|
||||
plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED,
|
||||
plusReturnUrl: '',
|
||||
plusReturnUrl: isAlreadySuccessful ? landedUrl : '',
|
||||
plusHostedCheckoutCompleted: isAlreadySuccessful,
|
||||
});
|
||||
|
||||
await addLog(`步骤 6:PayPal 无卡直绑页面已就绪(${result.country || 'US'} ${result.currency || 'USD'}),开始自动完成直绑支付链路。`, 'info');
|
||||
await addLog(`步骤 6:PayPal 无卡直绑页面已就绪(${result.country || 'US'} ${result.currency || 'USD'}),准备进入页面级直绑节点。`, 'info');
|
||||
|
||||
if (isHostedCheckoutSuccessUrl(landedUrl)) {
|
||||
await waitHostedSuccessDelayAndComplete(tabId, state, landedUrl);
|
||||
await completeNodeFromBackground('plus-checkout-create', {
|
||||
plusCheckoutCountry: result.country || 'US',
|
||||
plusCheckoutCurrency: result.currency || 'USD',
|
||||
plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED,
|
||||
plusCheckoutUrl: landedUrl,
|
||||
plusReturnUrl: isAlreadySuccessful ? landedUrl : '',
|
||||
plusHostedCheckoutCompleted: isAlreadySuccessful,
|
||||
});
|
||||
}
|
||||
|
||||
async function executePayPalHostedOpenAiCheckout(state = {}) {
|
||||
const stepKey = PAYPAL_HOSTED_STEP_OPENAI_CHECKOUT;
|
||||
const stepNumber = getHostedStepNumber(stepKey);
|
||||
const tabId = await resolveHostedCheckoutTabId(state, stepKey);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentUrl = await getHostedCurrentUrl(tabId);
|
||||
if (isPayPalUrl(currentUrl)) {
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前已在 PayPal 页面,OpenAI Checkout 节点直接完成。`, 'info');
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isHostedOpenAiCheckoutUrl(currentUrl)) {
|
||||
currentUrl = await waitForHostedUrlAfterAction(
|
||||
tabId,
|
||||
(url) => isHostedOpenAiCheckoutUrl(url) || isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url),
|
||||
{ label: `步骤 ${stepNumber}:等待 OpenAI hosted checkout 页面` }
|
||||
);
|
||||
}
|
||||
if (isHostedCheckoutSuccessUrl(currentUrl)) {
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusReturnUrl: currentUrl,
|
||||
plusHostedCheckoutCompleted: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isPayPalUrl(currentUrl)) {
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { profile, config } = await ensureHostedGuestProfile(state);
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在选择 PayPal 并提交 OpenAI hosted checkout(电话使用本地号码 ${profile.phone})。`, 'info');
|
||||
const transitionUrl = await runHostedOpenAiCheckout(tabId, profile, config);
|
||||
const completedUrl = String(transitionUrl || await getHostedCurrentUrl(tabId) || '').trim();
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusCheckoutSource: PLUS_PAYMENT_METHOD_PAYPAL_HOSTED,
|
||||
plusCheckoutUrl: completedUrl,
|
||||
plusReturnUrl: isHostedCheckoutSuccessUrl(completedUrl) ? completedUrl : '',
|
||||
plusHostedCheckoutCompleted: isHostedCheckoutSuccessUrl(completedUrl),
|
||||
});
|
||||
}
|
||||
|
||||
async function executePayPalHostedEmail(state = {}) {
|
||||
const stepKey = PAYPAL_HOSTED_STEP_EMAIL;
|
||||
const stepNumber = getHostedStepNumber(stepKey);
|
||||
const tabId = await resolveHostedCheckoutTabId(state, stepKey);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
const { profile } = await ensureHostedGuestProfile(state);
|
||||
await waitForHostedUrlAfterAction(
|
||||
tabId,
|
||||
(url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url),
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 邮箱页` }
|
||||
);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageState = await getHostedPayPalState(tabId);
|
||||
if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_VERIFICATION)
|
||||
&& pageState.hostedStage !== PAYPAL_HOSTED_STAGE_LOGIN) {
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),邮箱节点直接完成。`, 'info');
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusHostedCheckoutLastStage: pageState.hostedStage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_LOGIN) {
|
||||
throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 邮箱页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`);
|
||||
}
|
||||
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在填写 PayPal 无卡直绑邮箱。`, 'info');
|
||||
await runHostedPayPalStep(tabId, {
|
||||
expectedStage: PAYPAL_HOSTED_STAGE_LOGIN,
|
||||
email: profile.email,
|
||||
});
|
||||
const nextState = await waitForHostedPayPalStage(
|
||||
tabId,
|
||||
(stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_LOGIN,
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 邮箱页跳转` }
|
||||
);
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusHostedCheckoutLastStage: nextState.hostedStage || '',
|
||||
});
|
||||
}
|
||||
|
||||
async function executePayPalHostedVerification(state = {}) {
|
||||
const stepKey = PAYPAL_HOSTED_STEP_VERIFICATION;
|
||||
const stepNumber = getHostedStepNumber(stepKey);
|
||||
const tabId = await resolveHostedCheckoutTabId(state, stepKey);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
await waitForHostedUrlAfterAction(
|
||||
tabId,
|
||||
(url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url),
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 验证码页` }
|
||||
);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageState = await getHostedPayPalState(tabId);
|
||||
if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT)
|
||||
&& pageState.hostedStage !== PAYPAL_HOSTED_STAGE_VERIFICATION) {
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),验证码节点直接完成。`, 'info');
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusHostedCheckoutLastStage: pageState.hostedStage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_VERIFICATION) {
|
||||
throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 验证码页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`);
|
||||
}
|
||||
|
||||
const config = await getHostedCheckoutRuntimeConfig(state);
|
||||
const address = await fetchHostedCheckoutAddress();
|
||||
const guestProfile = buildHostedGuestProfile(address, config);
|
||||
let transitionUrl = landedUrl;
|
||||
const verificationCode = await pollHostedVerificationCode(config.verificationUrl);
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在填写 PayPal 无卡直绑验证码。`, 'info');
|
||||
await runHostedPayPalStep(tabId, {
|
||||
expectedStage: PAYPAL_HOSTED_STAGE_VERIFICATION,
|
||||
verificationCode,
|
||||
});
|
||||
const nextState = await waitForHostedPayPalStage(
|
||||
tabId,
|
||||
(stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_VERIFICATION,
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 验证码页跳转` }
|
||||
);
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusHostedCheckoutLastStage: nextState.hostedStage || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (isHostedOpenAiCheckoutUrl(transitionUrl)) {
|
||||
transitionUrl = await runHostedOpenAiCheckout(tabId, guestProfile, config);
|
||||
async function executePayPalHostedCard(state = {}) {
|
||||
const stepKey = PAYPAL_HOSTED_STEP_CARD;
|
||||
const stepNumber = getHostedStepNumber(stepKey);
|
||||
const tabId = await resolveHostedCheckoutTabId(state, stepKey);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHostedCheckoutSuccessUrl(transitionUrl)) {
|
||||
await waitHostedSuccessDelayAndComplete(tabId, state, transitionUrl);
|
||||
await waitForHostedUrlAfterAction(
|
||||
tabId,
|
||||
(url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url),
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 资料页` }
|
||||
);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPayPalUrl(transitionUrl)) {
|
||||
const transitionTab = await waitForUrlMatch(
|
||||
tabId,
|
||||
(url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url),
|
||||
HOSTED_CHECKOUT_TRANSITION_TIMEOUT_MS,
|
||||
500
|
||||
const pageState = await getHostedPayPalState(tabId);
|
||||
if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT)
|
||||
&& pageState.hostedStage !== PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) {
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),资料节点直接完成。`, 'info');
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusHostedCheckoutLastStage: pageState.hostedStage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) {
|
||||
throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 资料页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`);
|
||||
}
|
||||
|
||||
const { profile } = await ensureHostedGuestProfile(state);
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在填写 PayPal 无卡直绑资料,提交前会复查电话是否为 ${profile.phone}。`, 'info');
|
||||
const cardResult = await runHostedPayPalStep(tabId, {
|
||||
...profile,
|
||||
expectedStage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT,
|
||||
phone: profile.phone,
|
||||
});
|
||||
if (cardResult?.phoneMatched) {
|
||||
await addHostedStepLog(
|
||||
stepKey,
|
||||
`步骤 ${stepNumber}:PayPal 页面电话复查通过(配置 ${cardResult.payloadPhoneDigits},页面 ${cardResult.renderedPhoneDigits})。`,
|
||||
'info'
|
||||
);
|
||||
transitionUrl = String(transitionTab?.url || '').trim();
|
||||
}
|
||||
const nextState = await waitForHostedPayPalStage(
|
||||
tabId,
|
||||
(stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT,
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 资料页跳转` }
|
||||
);
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusHostedCheckoutLastStage: nextState.hostedStage || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (isHostedCheckoutSuccessUrl(transitionUrl)) {
|
||||
await waitHostedSuccessDelayAndComplete(tabId, state, transitionUrl);
|
||||
async function executePayPalHostedCreateAccount(state = {}) {
|
||||
const stepKey = PAYPAL_HOSTED_STEP_CREATE_ACCOUNT;
|
||||
const stepNumber = getHostedStepNumber(stepKey);
|
||||
const tabId = await resolveHostedCheckoutTabId(state, stepKey);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
if (!isPayPalUrl(transitionUrl)) {
|
||||
throw new Error('步骤 6:PayPal 无卡直绑提交后未进入 PayPal 或支付成功页。');
|
||||
await waitForHostedUrlAfterAction(
|
||||
tabId,
|
||||
(url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url),
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 创建确认页` }
|
||||
);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const successUrl = await runHostedPayPalCheckout(tabId, guestProfile, config);
|
||||
await waitHostedSuccessDelayAndComplete(tabId, state, successUrl);
|
||||
const pageState = await getHostedPayPalState(tabId);
|
||||
if (isHostedStageAtOrAfter(pageState.hostedStage, PAYPAL_HOSTED_STAGE_REVIEW)
|
||||
&& pageState.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) {
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:当前 PayPal 已进入后续页面(${pageState.hostedStage}),创建确认节点直接完成。`, 'info');
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusHostedCheckoutLastStage: pageState.hostedStage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) {
|
||||
throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 创建确认页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`);
|
||||
}
|
||||
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在确认创建 PayPal 账号。`, 'info');
|
||||
await runHostedPayPalStep(tabId, {
|
||||
expectedStage: PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT,
|
||||
});
|
||||
const nextState = await waitForHostedPayPalStage(
|
||||
tabId,
|
||||
(stateInfo) => stateInfo?.hostedStage && stateInfo.hostedStage !== PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT,
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 创建确认页跳转` }
|
||||
);
|
||||
await completeHostedStep(stepKey, tabId, {
|
||||
plusHostedCheckoutLastStage: nextState.hostedStage || '',
|
||||
});
|
||||
}
|
||||
|
||||
async function executePayPalHostedReview(state = {}) {
|
||||
const stepKey = PAYPAL_HOSTED_STEP_REVIEW;
|
||||
const stepNumber = getHostedStepNumber(stepKey);
|
||||
const tabId = await resolveHostedCheckoutTabId(state, stepKey);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state, { waitBeforeComplete: true })) {
|
||||
return;
|
||||
}
|
||||
await waitForHostedUrlAfterAction(
|
||||
tabId,
|
||||
(url) => isPayPalUrl(url) || isHostedCheckoutSuccessUrl(url),
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 授权复核页` }
|
||||
);
|
||||
if (await completeHostedStepIfSuccessful(stepKey, tabId, state, { waitBeforeComplete: true })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageState = await getHostedPayPalState(tabId);
|
||||
if (pageState.hostedStage !== PAYPAL_HOSTED_STAGE_REVIEW) {
|
||||
throw new Error(`步骤 ${stepNumber}:当前不是 PayPal 授权复核页(当前状态:${pageState.hostedStage || PAYPAL_HOSTED_STAGE_UNKNOWN})。`);
|
||||
}
|
||||
|
||||
await addHostedStepLog(stepKey, `步骤 ${stepNumber}:正在确认 PayPal 授权复核页。`, 'info');
|
||||
await runHostedPayPalStep(tabId, {
|
||||
expectedStage: PAYPAL_HOSTED_STAGE_REVIEW,
|
||||
});
|
||||
await waitForHostedUrlAfterAction(
|
||||
tabId,
|
||||
(url) => isHostedCheckoutSuccessUrl(url),
|
||||
{ label: `步骤 ${stepNumber}:等待 PayPal 回到 ChatGPT 支付成功页`, timeoutMs: HOSTED_CHECKOUT_PAYPAL_TIMEOUT_MS }
|
||||
);
|
||||
if (!await completeHostedStepIfSuccessful(stepKey, tabId, state, { waitBeforeComplete: true })) {
|
||||
throw new Error(`步骤 ${stepNumber}:PayPal 授权后未检测到 ChatGPT 支付成功页。`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHelperCountryCode(countryCode = '86') {
|
||||
@@ -990,6 +1446,12 @@
|
||||
|
||||
return {
|
||||
executePlusCheckoutCreate,
|
||||
executePayPalHostedOpenAiCheckout,
|
||||
executePayPalHostedEmail,
|
||||
executePayPalHostedVerification,
|
||||
executePayPalHostedCard,
|
||||
executePayPalHostedCreateAccount,
|
||||
executePayPalHostedReview,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+168
-13
@@ -8,9 +8,17 @@ const PAYPAL_HOSTED_STAGE_OUTSIDE = 'outside_paypal';
|
||||
const PAYPAL_HOSTED_STAGE_LOGIN = 'pay_login';
|
||||
const PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT = 'guest_checkout';
|
||||
const PAYPAL_HOSTED_STAGE_VERIFICATION = 'verification';
|
||||
const PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT = 'create_account';
|
||||
const PAYPAL_HOSTED_STAGE_REVIEW = 'review_consent';
|
||||
const PAYPAL_HOSTED_STAGE_APPROVAL = 'approval';
|
||||
const PAYPAL_HOSTED_STAGE_UNKNOWN = 'unknown';
|
||||
const PAYPAL_HOSTED_STEP_KEYS = {
|
||||
[PAYPAL_HOSTED_STAGE_LOGIN]: 'paypal-hosted-email',
|
||||
[PAYPAL_HOSTED_STAGE_VERIFICATION]: 'paypal-hosted-verification',
|
||||
[PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT]: 'paypal-hosted-card',
|
||||
[PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT]: 'paypal-hosted-create-account',
|
||||
[PAYPAL_HOSTED_STAGE_REVIEW]: 'paypal-hosted-review',
|
||||
};
|
||||
|
||||
if (document.documentElement.getAttribute(PAYPAL_FLOW_LISTENER_SENTINEL) !== '1') {
|
||||
document.documentElement.setAttribute(PAYPAL_FLOW_LISTENER_SENTINEL, '1');
|
||||
@@ -255,15 +263,47 @@ function isHostedLoginPage() {
|
||||
}
|
||||
|
||||
function isHostedGuestCheckoutPage() {
|
||||
if (document.getElementById('cardNumber') || document.getElementById('billingLine1')) {
|
||||
return true;
|
||||
}
|
||||
const pageText = normalizeText(document.body?.innerText || document.body?.textContent || '');
|
||||
if (/create\s*(?:paypal\s*)?account|agree\s*(?:&|and)?\s*create|创建.*(?:账户|账号)/i.test(pageText)
|
||||
&& findHostedCreateAccountButton()) {
|
||||
return false;
|
||||
}
|
||||
return /\/checkoutweb\//i.test(getPayPalPathname())
|
||||
|| Boolean(document.getElementById('cardNumber'))
|
||||
|| Boolean(document.getElementById('billingLine1'));
|
||||
&& Boolean(document.getElementById('phone') || document.getElementById('email'));
|
||||
}
|
||||
|
||||
function isHostedReviewPage() {
|
||||
return /\/webapps\/hermes/i.test(getPayPalPathname());
|
||||
}
|
||||
|
||||
function findHostedCreateAccountButton() {
|
||||
return document.getElementById('createAccount')
|
||||
|| document.getElementById('createAccountButton')
|
||||
|| document.querySelector('button[data-testid="createAccountButton"]')
|
||||
|| document.querySelector('button[data-testid="create-account-button"]')
|
||||
|| findClickableByText([
|
||||
/agree\s*(?:&|and)?\s*create\s*(?:paypal\s*)?account/i,
|
||||
/create\s*(?:paypal\s*)?account/i,
|
||||
/同意.*创建|创建.*账户|创建.*账号/i,
|
||||
]);
|
||||
}
|
||||
|
||||
function isHostedCreateAccountPage() {
|
||||
if (document.getElementById('cardNumber') || document.getElementById('billingLine1')) {
|
||||
return false;
|
||||
}
|
||||
const button = findHostedCreateAccountButton();
|
||||
if (!button || !isVisibleElement(button) || !isEnabledControl(button)) {
|
||||
return false;
|
||||
}
|
||||
const pageText = normalizeText(document.body?.innerText || document.body?.textContent || '');
|
||||
return /create\s*(?:paypal\s*)?account|agree\s*(?:&|and)?\s*create|创建.*(?:账户|账号)/i.test(pageText)
|
||||
|| /create/i.test(getActionText(button));
|
||||
}
|
||||
|
||||
function findHostedReviewConsentButton() {
|
||||
const direct = document.getElementById('consentButton')
|
||||
|| document.querySelector('button[data-testid="consentButton"]');
|
||||
@@ -289,6 +329,9 @@ function detectPayPalHostedStage() {
|
||||
if (isHostedReviewPage() && findHostedReviewConsentButton()) {
|
||||
return PAYPAL_HOSTED_STAGE_REVIEW;
|
||||
}
|
||||
if (isHostedCreateAccountPage()) {
|
||||
return PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT;
|
||||
}
|
||||
if (isHostedLoginPage()) {
|
||||
return PAYPAL_HOSTED_STAGE_LOGIN;
|
||||
}
|
||||
@@ -337,23 +380,102 @@ function findHostedSubmitButton() {
|
||||
]);
|
||||
}
|
||||
|
||||
async function clickHostedSubmitButton() {
|
||||
function getHostedStepKey(stage = '', fallback = 'plus-checkout-create') {
|
||||
return PAYPAL_HOSTED_STEP_KEYS[stage] || fallback;
|
||||
}
|
||||
|
||||
async function clickHostedSubmitButton(options = {}) {
|
||||
const stepKey = String(options.stepKey || getHostedStepKey(options.stage)).trim();
|
||||
const label = String(options.label || 'hosted-paypal-submit').trim();
|
||||
const maxAttempts = Math.max(1, Math.floor(Number(options.maxAttempts) || 3));
|
||||
let lastButtonText = '';
|
||||
let lastDisabled = false;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const button = await waitUntil(() => {
|
||||
const candidate = findHostedSubmitButton();
|
||||
return candidate && isVisibleElement(candidate) ? candidate : null;
|
||||
}, {
|
||||
intervalMs: 500,
|
||||
timeoutMs: 15000,
|
||||
timeoutMessage: 'PayPal hosted checkout 未找到可点击的继续/提交按钮。',
|
||||
});
|
||||
lastButtonText = getActionText(button);
|
||||
lastDisabled = !isEnabledControl(button);
|
||||
if (lastDisabled) {
|
||||
if (attempt >= maxAttempts) {
|
||||
throw new Error('PayPal hosted checkout 继续/提交按钮长时间不可用。');
|
||||
}
|
||||
await sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
await performPayPalOperationWithDelay({ stepKey, kind: 'click', label }, async () => {
|
||||
simulateClick(button);
|
||||
});
|
||||
await sleep(1000);
|
||||
return {
|
||||
clicked: true,
|
||||
buttonText: lastButtonText,
|
||||
verificationRequired: hasHostedVerificationInputs(),
|
||||
attempt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
clicked: false,
|
||||
buttonText: lastButtonText,
|
||||
disabled: lastDisabled,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostedPhoneDigits(value = '') {
|
||||
return String(value || '').replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function verifyHostedPhoneBeforeSubmit(expectedPhone = '') {
|
||||
const phoneInput = document.getElementById('phone');
|
||||
if (!phoneInput || !isVisibleElement(phoneInput)) {
|
||||
throw new Error('PayPal hosted checkout 未找到电话输入框。');
|
||||
}
|
||||
const expectedDigits = normalizeHostedPhoneDigits(expectedPhone || PAYPAL_HOSTED_DEFAULT_PHONE);
|
||||
const renderedDigits = normalizeHostedPhoneDigits(phoneInput.value || '');
|
||||
if (!expectedDigits) {
|
||||
throw new Error('PayPal hosted checkout 电话配置为空。');
|
||||
}
|
||||
const comparableRenderedDigits = renderedDigits.length > expectedDigits.length
|
||||
? renderedDigits.slice(-expectedDigits.length)
|
||||
: renderedDigits;
|
||||
if (comparableRenderedDigits !== expectedDigits) {
|
||||
throw new Error(`PayPal hosted checkout 电话不一致:配置 ${expectedDigits},页面 ${renderedDigits || '(空)'}。`);
|
||||
}
|
||||
return {
|
||||
payloadPhoneDigits: expectedDigits,
|
||||
renderedPhoneDigits: renderedDigits,
|
||||
phoneMatched: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function clickHostedCreateAccount(payload = {}) {
|
||||
await waitForDocumentComplete();
|
||||
const button = await waitUntil(() => {
|
||||
const candidate = findHostedSubmitButton();
|
||||
const candidate = findHostedCreateAccountButton();
|
||||
return candidate && isVisibleElement(candidate) && isEnabledControl(candidate) ? candidate : null;
|
||||
}, {
|
||||
intervalMs: 500,
|
||||
timeoutMs: 15000,
|
||||
timeoutMessage: 'PayPal hosted checkout 未找到可点击的继续/提交按钮。',
|
||||
timeoutMs: 30000,
|
||||
timeoutMessage: 'PayPal hosted checkout 未找到创建账号确认按钮。',
|
||||
});
|
||||
await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'click', label: 'hosted-paypal-submit' }, async () => {
|
||||
await performPayPalOperationWithDelay({
|
||||
stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT),
|
||||
kind: 'click',
|
||||
label: 'hosted-paypal-create-account',
|
||||
}, async () => {
|
||||
simulateClick(button);
|
||||
});
|
||||
await sleep(1000);
|
||||
return {
|
||||
stage: PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT,
|
||||
clicked: true,
|
||||
submitted: true,
|
||||
buttonText: getActionText(button),
|
||||
verificationRequired: hasHostedVerificationInputs(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -408,7 +530,10 @@ async function submitHostedLogin(payload = {}) {
|
||||
throw new Error('PayPal hosted checkout 未找到邮箱输入框。');
|
||||
}
|
||||
refillPayPalEmailInput(emailInput, email);
|
||||
const clickResult = await clickHostedSubmitButton();
|
||||
const clickResult = await clickHostedSubmitButton({
|
||||
stage: PAYPAL_HOSTED_STAGE_LOGIN,
|
||||
label: 'hosted-paypal-email-submit',
|
||||
});
|
||||
return {
|
||||
stage: PAYPAL_HOSTED_STAGE_LOGIN,
|
||||
submitted: true,
|
||||
@@ -427,7 +552,11 @@ async function fillHostedVerificationCode(payload = {}) {
|
||||
if (inputs.length < 6) {
|
||||
throw new Error('PayPal hosted checkout 当前页面未显示验证码输入框。');
|
||||
}
|
||||
await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'fill', label: 'hosted-paypal-verification-code' }, async () => {
|
||||
await performPayPalOperationWithDelay({
|
||||
stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_VERIFICATION),
|
||||
kind: 'fill',
|
||||
label: 'hosted-paypal-verification-code',
|
||||
}, async () => {
|
||||
inputs.forEach((input, index) => fillInput(input, code[index] || ''));
|
||||
});
|
||||
return {
|
||||
@@ -468,11 +597,18 @@ async function fillHostedGuestCheckout(payload = {}) {
|
||||
fillHostedInputById('billingCity', address.city || '');
|
||||
fillHostedInputById('billingPostalCode', address.zip || address.postalCode || '');
|
||||
selectHostedOptionByIdText('billingState', address.state || address.region || '');
|
||||
const clickResult = await clickHostedSubmitButton();
|
||||
const phoneCheck = verifyHostedPhoneBeforeSubmit(values.phone);
|
||||
const clickResult = await clickHostedSubmitButton({
|
||||
stage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT,
|
||||
label: 'hosted-paypal-card-submit',
|
||||
maxAttempts: 4,
|
||||
});
|
||||
return {
|
||||
stage: PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT,
|
||||
submitted: true,
|
||||
verificationRequired: Boolean(clickResult.verificationRequired),
|
||||
payloadPhone: values.phone,
|
||||
...phoneCheck,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -486,7 +622,11 @@ async function clickHostedReviewConsent() {
|
||||
timeoutMs: 30000,
|
||||
timeoutMessage: 'PayPal hosted checkout 未找到账单确认按钮。',
|
||||
});
|
||||
await performPayPalOperationWithDelay({ stepKey: 'plus-checkout-create', kind: 'click', label: 'hosted-paypal-review-consent' }, async () => {
|
||||
await performPayPalOperationWithDelay({
|
||||
stepKey: getHostedStepKey(PAYPAL_HOSTED_STAGE_REVIEW),
|
||||
kind: 'click',
|
||||
label: 'hosted-paypal-review-consent',
|
||||
}, async () => {
|
||||
simulateClick(button);
|
||||
});
|
||||
return {
|
||||
@@ -497,6 +637,16 @@ async function clickHostedReviewConsent() {
|
||||
|
||||
async function runPayPalHostedCheckoutStep(payload = {}) {
|
||||
const stage = detectPayPalHostedStage();
|
||||
const expectedStage = String(payload.expectedStage || '').trim();
|
||||
if (expectedStage && stage !== expectedStage) {
|
||||
return {
|
||||
stage,
|
||||
expectedStage,
|
||||
submitted: false,
|
||||
skipped: true,
|
||||
approveReady: Boolean(findApproveButton()),
|
||||
};
|
||||
}
|
||||
if (stage === PAYPAL_HOSTED_STAGE_VERIFICATION) {
|
||||
return payload.verificationCode || payload.code
|
||||
? fillHostedVerificationCode(payload)
|
||||
@@ -508,6 +658,9 @@ async function runPayPalHostedCheckoutStep(payload = {}) {
|
||||
if (stage === PAYPAL_HOSTED_STAGE_GUEST_CHECKOUT) {
|
||||
return fillHostedGuestCheckout(payload);
|
||||
}
|
||||
if (stage === PAYPAL_HOSTED_STAGE_CREATE_ACCOUNT) {
|
||||
return clickHostedCreateAccount(payload);
|
||||
}
|
||||
if (stage === PAYPAL_HOSTED_STAGE_REVIEW) {
|
||||
return clickHostedReviewConsent();
|
||||
}
|
||||
@@ -520,6 +673,7 @@ async function runPayPalHostedCheckoutStep(payload = {}) {
|
||||
|
||||
function inspectPayPalHostedState() {
|
||||
const stage = detectPayPalHostedStage();
|
||||
const createAccountButton = findHostedCreateAccountButton();
|
||||
return {
|
||||
url: location.href,
|
||||
readyState: document.readyState,
|
||||
@@ -527,6 +681,7 @@ function inspectPayPalHostedState() {
|
||||
verificationInputsVisible: hasHostedVerificationInputs(),
|
||||
hasGuestCardFields: Boolean(document.getElementById('cardNumber')),
|
||||
hasHostedEmailInput: Boolean(document.getElementById('email') || findEmailInput()),
|
||||
createAccountReady: Boolean(createAccountButton && isVisibleElement(createAccountButton) && isEnabledControl(createAccountButton)),
|
||||
reviewConsentReady: Boolean(findHostedReviewConsentButton()),
|
||||
approveReady: Boolean(findApproveButton()),
|
||||
bodyTextPreview: normalizeText(document.body?.innerText || '').slice(0, 240),
|
||||
|
||||
@@ -33,7 +33,15 @@
|
||||
{ id: 8, order: 80, key: 'paypal-approve', title: 'PayPal 登录与授权', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-approve' },
|
||||
{ id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'plus-checkout-return' },
|
||||
];
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS = PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS.slice(0, 6);
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS = [
|
||||
...PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS.slice(0, 6),
|
||||
{ id: 7, order: 70, key: 'paypal-hosted-openai-checkout', title: '无卡直绑提交 OpenAI Checkout', sourceId: 'plus-checkout', driverId: 'content/plus-checkout', command: 'paypal-hosted-openai-checkout' },
|
||||
{ id: 8, order: 80, key: 'paypal-hosted-email', title: '无卡直绑填写 PayPal 邮箱', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-email' },
|
||||
{ id: 9, order: 90, key: 'paypal-hosted-verification', title: '无卡直绑填写 PayPal 验证码', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-verification' },
|
||||
{ id: 10, order: 100, key: 'paypal-hosted-card', title: '无卡直绑填写 PayPal 资料', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-card' },
|
||||
{ id: 11, order: 110, key: 'paypal-hosted-create-account', title: '无卡直绑确认创建 PayPal', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-create-account' },
|
||||
{ id: 12, order: 120, key: 'paypal-hosted-review', title: '无卡直绑完成 PayPal 授权', sourceId: 'paypal-flow', driverId: 'content/paypal-flow', command: 'paypal-hosted-review' },
|
||||
];
|
||||
|
||||
const PLUS_GOPAY_PREFIX_STEP_DEFINITIONS = [
|
||||
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网', sourceId: 'chatgpt', driverId: null, command: 'open-chatgpt' },
|
||||
@@ -232,23 +240,23 @@
|
||||
);
|
||||
const PLUS_PAYPAL_PHONE_STEP_DEFINITIONS = createOpenAiSteps(PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE);
|
||||
const PLUS_PAYPAL_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createOpenAiSteps(PLUS_PAYPAL_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true });
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 7, 70, SIGNUP_METHOD_EMAIL);
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 13, 130, SIGNUP_METHOD_EMAIL);
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_SUB2API_SESSION_STEP_DEFINITIONS = createHostedCheckoutSteps(
|
||||
PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS,
|
||||
7,
|
||||
70,
|
||||
13,
|
||||
130,
|
||||
SIGNUP_METHOD_EMAIL,
|
||||
{ plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_SUB2API_CODEX_SESSION }
|
||||
);
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_CPA_SESSION_STEP_DEFINITIONS = createHostedCheckoutSteps(
|
||||
PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS,
|
||||
7,
|
||||
70,
|
||||
13,
|
||||
130,
|
||||
SIGNUP_METHOD_EMAIL,
|
||||
{ plusAccountAccessStrategy: PLUS_ACCOUNT_ACCESS_STRATEGY_CPA_CODEX_SESSION }
|
||||
);
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 7, 70, SIGNUP_METHOD_PHONE);
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 7, 70, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true });
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 13, 130, SIGNUP_METHOD_PHONE);
|
||||
const PLUS_PAYPAL_HOSTED_CHECKOUT_PHONE_BOUND_EMAIL_RELOGIN_STEP_DEFINITIONS = createHostedCheckoutSteps(PLUS_PAYPAL_HOSTED_CHECKOUT_PREFIX_STEP_DEFINITIONS, 13, 130, SIGNUP_METHOD_PHONE, { phoneSignupReloginAfterBindEmailEnabled: true });
|
||||
const PLUS_GOPAY_STEP_DEFINITIONS = createOpenAiSteps(PLUS_GOPAY_PREFIX_STEP_DEFINITIONS, 10, 100, SIGNUP_METHOD_EMAIL);
|
||||
const PLUS_GOPAY_SUB2API_SESSION_STEP_DEFINITIONS = createOpenAiSteps(
|
||||
PLUS_GOPAY_PREFIX_STEP_DEFINITIONS,
|
||||
|
||||
+14
-2
@@ -191,11 +191,23 @@
|
||||
},
|
||||
'content/plus-checkout': {
|
||||
sourceId: 'plus-checkout',
|
||||
commands: ['plus-checkout-create', 'plus-checkout-billing', 'plus-checkout-return'],
|
||||
commands: [
|
||||
'plus-checkout-create',
|
||||
'paypal-hosted-openai-checkout',
|
||||
'plus-checkout-billing',
|
||||
'plus-checkout-return',
|
||||
],
|
||||
},
|
||||
'content/paypal-flow': {
|
||||
sourceId: 'paypal-flow',
|
||||
commands: ['paypal-approve'],
|
||||
commands: [
|
||||
'paypal-approve',
|
||||
'paypal-hosted-email',
|
||||
'paypal-hosted-verification',
|
||||
'paypal-hosted-card',
|
||||
'paypal-hosted-create-account',
|
||||
'paypal-hosted-review',
|
||||
],
|
||||
},
|
||||
'content/gopay-flow': {
|
||||
sourceId: 'gopay-flow',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const source = fs.readFileSync('content/paypal-flow.js', 'utf8');
|
||||
|
||||
@@ -308,3 +309,332 @@ test('PayPal email submit refills a prefilled email before clicking next', async
|
||||
awaiting: 'password_page',
|
||||
});
|
||||
});
|
||||
|
||||
function createHostedPayPalHarness(options = {}) {
|
||||
const events = [];
|
||||
const attrs = new Map();
|
||||
const elementsById = new Map();
|
||||
let elements = [];
|
||||
let listener = null;
|
||||
const body = { innerText: '', textContent: '' };
|
||||
const location = {
|
||||
href: 'https://www.paypal.com/checkoutweb/signup',
|
||||
host: 'www.paypal.com',
|
||||
pathname: '/checkoutweb/signup',
|
||||
};
|
||||
|
||||
function createDomElement({
|
||||
tagName = 'DIV',
|
||||
id = '',
|
||||
type = '',
|
||||
name = '',
|
||||
text = '',
|
||||
value = '',
|
||||
attrs: initialAttrs = {},
|
||||
options: selectOptions = [],
|
||||
} = {}) {
|
||||
const attrMap = new Map(Object.entries(initialAttrs));
|
||||
const element = {
|
||||
nodeType: 1,
|
||||
tagName,
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
textContent: text,
|
||||
innerText: text,
|
||||
value,
|
||||
checked: false,
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
options: selectOptions,
|
||||
parentElement: null,
|
||||
style: { display: 'block', visibility: 'visible', opacity: '1' },
|
||||
getAttribute(key) {
|
||||
if (key === 'id') return this.id;
|
||||
if (key === 'type') return this.type;
|
||||
if (key === 'name') return this.name;
|
||||
if (key === 'placeholder') return attrMap.get('placeholder') || '';
|
||||
return attrMap.has(key) ? attrMap.get(key) : null;
|
||||
},
|
||||
setAttribute(key, nextValue) {
|
||||
attrMap.set(key, String(nextValue));
|
||||
},
|
||||
dispatchEvent() {
|
||||
return true;
|
||||
},
|
||||
focus() {},
|
||||
blur() {},
|
||||
click() {
|
||||
events.push({ type: 'native-click', id: this.id, text: this.textContent });
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { left: 10, top: 10, width: 180, height: 44 };
|
||||
},
|
||||
};
|
||||
if (id) elementsById.set(id, element);
|
||||
return element;
|
||||
}
|
||||
|
||||
const countrySelect = createDomElement({ tagName: 'SELECT', id: 'country', value: 'US' });
|
||||
const emailInput = createDomElement({ tagName: 'INPUT', id: 'email', type: 'email', name: 'email' });
|
||||
const phoneInput = createDomElement({ tagName: 'INPUT', id: 'phone', type: 'tel', name: 'phone' });
|
||||
const cardNumberInput = createDomElement({ tagName: 'INPUT', id: 'cardNumber', type: 'text' });
|
||||
const cardExpiryInput = createDomElement({ tagName: 'INPUT', id: 'cardExpiry', type: 'text' });
|
||||
const cardCvvInput = createDomElement({ tagName: 'INPUT', id: 'cardCvv', type: 'text' });
|
||||
const passwordInput = createDomElement({ tagName: 'INPUT', id: 'password', type: 'password' });
|
||||
const firstNameInput = createDomElement({ tagName: 'INPUT', id: 'firstName', type: 'text' });
|
||||
const lastNameInput = createDomElement({ tagName: 'INPUT', id: 'lastName', type: 'text' });
|
||||
const billingLine1Input = createDomElement({ tagName: 'INPUT', id: 'billingLine1', type: 'text' });
|
||||
const billingCityInput = createDomElement({ tagName: 'INPUT', id: 'billingCity', type: 'text' });
|
||||
const billingPostalCodeInput = createDomElement({ tagName: 'INPUT', id: 'billingPostalCode', type: 'text' });
|
||||
const billingStateSelect = createDomElement({
|
||||
tagName: 'SELECT',
|
||||
id: 'billingState',
|
||||
value: '',
|
||||
options: [
|
||||
{ textContent: 'New York', label: 'New York', value: 'NY' },
|
||||
{ textContent: 'California', label: 'California', value: 'CA' },
|
||||
],
|
||||
});
|
||||
const submitButton = createDomElement({
|
||||
tagName: 'BUTTON',
|
||||
id: 'hostedSubmit',
|
||||
text: 'Agree & Create Account',
|
||||
attrs: { 'data-testid': 'submit-button' },
|
||||
});
|
||||
const createAccountButton = createDomElement({
|
||||
tagName: 'BUTTON',
|
||||
id: 'createAccountButton',
|
||||
text: 'Agree & Create Account',
|
||||
attrs: { 'data-testid': 'createAccountButton' },
|
||||
});
|
||||
|
||||
function setElements(nextElements) {
|
||||
elements = nextElements;
|
||||
elementsById.clear();
|
||||
for (const element of nextElements) {
|
||||
if (element.id) elementsById.set(element.id, element);
|
||||
}
|
||||
}
|
||||
|
||||
function showGuestCheckout() {
|
||||
location.href = 'https://www.paypal.com/checkoutweb/signup';
|
||||
location.host = 'www.paypal.com';
|
||||
location.pathname = '/checkoutweb/signup';
|
||||
body.innerText = 'Pay with debit or credit card';
|
||||
body.textContent = body.innerText;
|
||||
setElements([
|
||||
countrySelect,
|
||||
emailInput,
|
||||
phoneInput,
|
||||
cardNumberInput,
|
||||
cardExpiryInput,
|
||||
cardCvvInput,
|
||||
passwordInput,
|
||||
firstNameInput,
|
||||
lastNameInput,
|
||||
billingLine1Input,
|
||||
billingCityInput,
|
||||
billingPostalCodeInput,
|
||||
billingStateSelect,
|
||||
submitButton,
|
||||
]);
|
||||
}
|
||||
|
||||
function showCreateAccount() {
|
||||
location.href = 'https://www.paypal.com/checkoutweb/create-account';
|
||||
location.host = 'www.paypal.com';
|
||||
location.pathname = '/checkoutweb/create-account';
|
||||
body.innerText = 'Create your PayPal account. Agree & Create Account';
|
||||
body.textContent = body.innerText;
|
||||
setElements([createAccountButton]);
|
||||
}
|
||||
|
||||
const context = {
|
||||
console: { log() {}, warn() {}, error() {}, info() {} },
|
||||
location,
|
||||
window: {},
|
||||
Event: class TestEvent { constructor(type) { this.type = type; } },
|
||||
MouseEvent: class TestMouseEvent { constructor(type) { this.type = type; } },
|
||||
PointerEvent: class TestPointerEvent { constructor(type) { this.type = type; } },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
body,
|
||||
documentElement: {
|
||||
getAttribute(name) {
|
||||
return attrs.get(name) || null;
|
||||
},
|
||||
setAttribute(name, nextValue) {
|
||||
attrs.set(name, String(nextValue));
|
||||
},
|
||||
},
|
||||
getElementById(id) {
|
||||
return elementsById.get(id) || null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
const text = String(selector || '');
|
||||
if (text.includes('createAccountButton') || text.includes('create-account-button')) {
|
||||
return elements.includes(createAccountButton) ? createAccountButton : null;
|
||||
}
|
||||
if (text.includes('submit-button') || text.includes('hosted-payment-submit-button')) {
|
||||
return elements.includes(submitButton) ? submitButton : null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
const text = String(selector || '');
|
||||
if (text === 'input') return elements.filter((element) => element.tagName === 'INPUT');
|
||||
if (text === 'input[type="email"]') return elements.filter((element) => element.type === 'email');
|
||||
if (text === 'input[type="password"]') return elements.filter((element) => element.type === 'password');
|
||||
if (text.includes('button') || text.includes('[role="button"]')) {
|
||||
return elements.filter((element) => element.tagName === 'BUTTON');
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
chrome: {
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(fn) {
|
||||
listener = fn;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
events.push({ type: 'operation', metadata });
|
||||
const result = await operation();
|
||||
events.push({ type: 'delay', metadata });
|
||||
return result;
|
||||
},
|
||||
},
|
||||
resetStopState() {},
|
||||
isStopError() { return false; },
|
||||
throwIfStopped() {},
|
||||
sleep() { return Promise.resolve(); },
|
||||
fillInput(element, value) {
|
||||
if (element === phoneInput && typeof options.renderPhone === 'function') {
|
||||
element.value = options.renderPhone(value);
|
||||
} else {
|
||||
element.value = value;
|
||||
}
|
||||
events.push({ type: 'fill', id: element.id, value: element.value });
|
||||
},
|
||||
simulateClick(element) {
|
||||
events.push({ type: 'click', id: element.id, text: element.textContent });
|
||||
},
|
||||
};
|
||||
context.window = context;
|
||||
context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible', opacity: '1' };
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context);
|
||||
assert.equal(typeof listener, 'function');
|
||||
|
||||
async function send(message) {
|
||||
return await new Promise((resolve) => {
|
||||
listener(message, {}, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
send,
|
||||
showCreateAccount,
|
||||
showGuestCheckout,
|
||||
};
|
||||
}
|
||||
|
||||
test('PayPal hosted guest checkout verifies configured local phone before submit', async () => {
|
||||
const harness = createHostedPayPalHarness({
|
||||
renderPhone: (value) => `+1 ${value}`,
|
||||
});
|
||||
harness.showGuestCheckout();
|
||||
|
||||
const result = await harness.send({
|
||||
type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP',
|
||||
source: 'test',
|
||||
payload: {
|
||||
expectedStage: 'guest_checkout',
|
||||
email: 'guest@example.com',
|
||||
phone: '4155551234',
|
||||
cardNumber: '4147200000000000',
|
||||
cardExpiry: '12 / 29',
|
||||
cardCvv: '123',
|
||||
password: 'Aa1!example',
|
||||
firstName: 'James',
|
||||
lastName: 'Smith',
|
||||
address: {
|
||||
street: '1 Main St',
|
||||
city: 'New York',
|
||||
state: 'New York',
|
||||
zip: '10001',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.submitted, true);
|
||||
assert.equal(result.phoneMatched, true);
|
||||
assert.equal(result.payloadPhoneDigits, '4155551234');
|
||||
assert.equal(result.renderedPhoneDigits, '14155551234');
|
||||
assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'hostedSubmit'), true);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(harness.events.filter((event) => event.type === 'operation').map((event) => event.metadata))),
|
||||
[{ stepKey: 'paypal-hosted-card', kind: 'click', label: 'hosted-paypal-card-submit' }]
|
||||
);
|
||||
});
|
||||
|
||||
test('PayPal hosted guest checkout blocks submit when rendered phone differs from config', async () => {
|
||||
const harness = createHostedPayPalHarness({
|
||||
renderPhone: () => '9999999999',
|
||||
});
|
||||
harness.showGuestCheckout();
|
||||
|
||||
const result = await harness.send({
|
||||
type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP',
|
||||
source: 'test',
|
||||
payload: {
|
||||
expectedStage: 'guest_checkout',
|
||||
phone: '4155551234',
|
||||
cardNumber: '4147200000000000',
|
||||
cardExpiry: '12 / 29',
|
||||
cardCvv: '123',
|
||||
password: 'Aa1!example',
|
||||
address: { street: '1 Main St', city: 'New York', state: 'New York', zip: '10001' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(result.error, /电话不一致/);
|
||||
assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'hostedSubmit'), false);
|
||||
});
|
||||
|
||||
test('PayPal hosted create account page is detected and handled as its own step', async () => {
|
||||
const harness = createHostedPayPalHarness();
|
||||
harness.showCreateAccount();
|
||||
|
||||
const state = await harness.send({
|
||||
type: 'PAYPAL_HOSTED_GET_STATE',
|
||||
source: 'test',
|
||||
payload: {},
|
||||
});
|
||||
assert.equal(state.ok, true);
|
||||
assert.equal(state.hostedStage, 'create_account');
|
||||
assert.equal(state.createAccountReady, true);
|
||||
|
||||
const result = await harness.send({
|
||||
type: 'PAYPAL_RUN_HOSTED_CHECKOUT_STEP',
|
||||
source: 'test',
|
||||
payload: { expectedStage: 'create_account' },
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.stage, 'create_account');
|
||||
assert.equal(result.submitted, true);
|
||||
assert.equal(harness.events.some((event) => event.type === 'click' && event.id === 'createAccountButton'), true);
|
||||
assert.deepEqual(
|
||||
JSON.parse(JSON.stringify(harness.events.filter((event) => event.type === 'operation').map((event) => event.metadata))),
|
||||
[{ stepKey: 'paypal-hosted-create-account', kind: 'click', label: 'hosted-paypal-create-account' }]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -288,7 +288,7 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c
|
||||
assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' });
|
||||
});
|
||||
|
||||
test('PayPal no-card binding create waits for hosted success and does not use the old PayPal tail', async () => {
|
||||
test('PayPal no-card binding create only opens hosted checkout and leaves page steps to later nodes', async () => {
|
||||
const events = [];
|
||||
let currentUrl = 'https://chatgpt.com/';
|
||||
const executor = api.createPlusCheckoutCreateExecutor({
|
||||
@@ -315,21 +315,8 @@ test('PayPal no-card binding create waits for hosted success and does not use th
|
||||
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => {
|
||||
events.push({ type: 'ready', source, tabId, options });
|
||||
},
|
||||
fetch: async (url) => {
|
||||
events.push({ type: 'fetch', url });
|
||||
assert.equal(url, 'https://www.meiguodizhi.com/api/v1/dz');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
address: {
|
||||
Address: '1 Main St',
|
||||
City: 'New York',
|
||||
State: 'New York',
|
||||
Zip_Code: '10001',
|
||||
},
|
||||
}),
|
||||
};
|
||||
fetch: async () => {
|
||||
throw new Error('create node should not fetch address or run PayPal page automation');
|
||||
},
|
||||
registerTab: async (source, tabId) => {
|
||||
events.push({ type: 'register', source, tabId });
|
||||
@@ -345,13 +332,6 @@ test('PayPal no-card binding create waits for hosted success and does not use th
|
||||
currency: 'USD',
|
||||
};
|
||||
}
|
||||
if (message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') {
|
||||
currentUrl = 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted';
|
||||
return { clicked: true };
|
||||
}
|
||||
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
|
||||
return { hostedVerificationVisible: false };
|
||||
}
|
||||
throw new Error(`unexpected message type ${message.type}`);
|
||||
},
|
||||
setState: async (payload) => {
|
||||
@@ -384,13 +364,105 @@ test('PayPal no-card binding create waits for hosted success and does not use th
|
||||
assert.equal(statePayload.plusCheckoutCurrency, 'USD');
|
||||
assert.equal(statePayload.plusReturnUrl, '');
|
||||
assert.equal(events.some((event) => event.type === 'tab-message' && event.message.type === 'FILL_PLUS_BILLING_AND_SUBMIT'), false);
|
||||
assert.equal(events.some((event) => event.type === 'tab-message' && event.message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP'), false);
|
||||
assert.deepStrictEqual(events.find((event) => event.type === 'complete'), {
|
||||
type: 'complete',
|
||||
step: 'plus-checkout-create',
|
||||
payload: {
|
||||
plusCheckoutCountry: 'US',
|
||||
plusCheckoutCurrency: 'USD',
|
||||
plusCheckoutSource: 'paypal-hosted',
|
||||
plusCheckoutUrl: 'https://pay.openai.com/c/pay/cs_hosted',
|
||||
plusReturnUrl: '',
|
||||
plusHostedCheckoutCompleted: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('PayPal no-card binding OpenAI checkout node submits hosted page and completes after success transition', async () => {
|
||||
const events = [];
|
||||
let currentUrl = 'https://pay.openai.com/c/pay/cs_hosted';
|
||||
const executor = api.createPlusCheckoutCreateExecutor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.push({ type: 'log', message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async (tabId) => ({ id: tabId, url: currentUrl, status: 'complete' }),
|
||||
},
|
||||
},
|
||||
completeNodeFromBackground: async (step, payload) => {
|
||||
events.push({ type: 'complete', step, payload });
|
||||
},
|
||||
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => {
|
||||
events.push({ type: 'ready', source, tabId, options });
|
||||
},
|
||||
fetch: async (url) => {
|
||||
events.push({ type: 'fetch', url });
|
||||
assert.equal(url, 'https://www.meiguodizhi.com/api/v1/dz');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
address: {
|
||||
Address: '1 Main St',
|
||||
City: 'New York',
|
||||
State: 'New York',
|
||||
Zip_Code: '10001',
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
getState: async () => ({
|
||||
hostedCheckoutPhoneNumber: '(415) 555-1234',
|
||||
}),
|
||||
registerTab: async (source, tabId) => {
|
||||
events.push({ type: 'register', source, tabId });
|
||||
},
|
||||
sendTabMessageUntilStopped: async (tabId, source, message) => {
|
||||
events.push({ type: 'tab-message', tabId, source, message });
|
||||
if (message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP') {
|
||||
currentUrl = 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted';
|
||||
return { clicked: true };
|
||||
}
|
||||
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
|
||||
return { hostedVerificationVisible: false };
|
||||
}
|
||||
throw new Error(`unexpected message type ${message.type}`);
|
||||
},
|
||||
setState: async (payload) => {
|
||||
events.push({ type: 'set-state', payload });
|
||||
},
|
||||
sleepWithStop: async (ms) => {
|
||||
events.push({ type: 'sleep', ms });
|
||||
},
|
||||
waitForTabCompleteUntilStopped: async () => {
|
||||
events.push({ type: 'tab-complete' });
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePayPalHostedOpenAiCheckout({
|
||||
plusCheckoutTabId: 55,
|
||||
plusPaymentMethod: 'paypal-hosted',
|
||||
hostedCheckoutPhoneNumber: '2125550000',
|
||||
plusHostedCheckoutOauthDelaySeconds: 0,
|
||||
});
|
||||
|
||||
const profileState = events.find((event) => event.type === 'set-state' && event.payload.plusHostedCheckoutGuestProfile)?.payload || {};
|
||||
assert.equal(profileState.plusHostedCheckoutGuestProfile.phone, '4155551234');
|
||||
assert.equal(profileState.plusHostedCheckoutPhoneDigits, '4155551234');
|
||||
assert.equal(
|
||||
events.find((event) => event.type === 'tab-message' && event.message.type === 'RUN_PAYPAL_HOSTED_OPENAI_CHECKOUT_STEP')?.message?.payload?.address?.street,
|
||||
'1 Main St'
|
||||
);
|
||||
assert.deepStrictEqual(events.find((event) => event.type === 'complete'), {
|
||||
type: 'complete',
|
||||
step: 'paypal-hosted-openai-checkout',
|
||||
payload: {
|
||||
plusCheckoutUrl: 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted',
|
||||
plusCheckoutSource: 'paypal-hosted',
|
||||
plusReturnUrl: 'https://chatgpt.com/backend-api/payments/success?session_id=cs_hosted',
|
||||
plusHostedCheckoutCompleted: true,
|
||||
plusHostedCheckoutOauthDelaySeconds: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,6 +221,12 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
|
||||
'fetch-signup-code',
|
||||
'fill-profile',
|
||||
'plus-checkout-create',
|
||||
'paypal-hosted-openai-checkout',
|
||||
'paypal-hosted-email',
|
||||
'paypal-hosted-verification',
|
||||
'paypal-hosted-card',
|
||||
'paypal-hosted-create-account',
|
||||
'paypal-hosted-review',
|
||||
'oauth-login',
|
||||
'fetch-login-code',
|
||||
'confirm-oauth',
|
||||
@@ -230,8 +236,10 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
|
||||
assert.equal(hostedSteps.some((step) => step.key === 'plus-checkout-billing'), false);
|
||||
assert.equal(hostedSteps.some((step) => step.key === 'paypal-approve'), false);
|
||||
assert.equal(hostedSteps.some((step) => step.key === 'plus-checkout-return'), false);
|
||||
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
assert.equal(api.getLastStepId({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), 10);
|
||||
assert.equal(hostedSteps.find((step) => step.key === 'paypal-hosted-openai-checkout')?.title, '无卡直绑提交 OpenAI Checkout');
|
||||
assert.equal(hostedSteps.find((step) => step.key === 'paypal-hosted-card')?.title, '无卡直绑填写 PayPal 资料');
|
||||
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
|
||||
assert.equal(api.getLastStepId({ plusModeEnabled: true, plusPaymentMethod: 'paypal-hosted' }), 16);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
goPaySteps.map((step) => step.key),
|
||||
@@ -308,8 +316,8 @@ test('Plus session strategy swaps the OAuth tail for a single SUB2API import nod
|
||||
plusPaymentMethod: 'paypal-hosted',
|
||||
plusAccountAccessStrategy: 'sub2api_codex_session',
|
||||
},
|
||||
previousNodeId: 'plus-checkout-create',
|
||||
expectedStepIds: [1, 2, 3, 4, 5, 6, 7],
|
||||
previousNodeId: 'paypal-hosted-review',
|
||||
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
|
||||
},
|
||||
{
|
||||
label: 'gopay',
|
||||
@@ -399,8 +407,8 @@ test('Plus session strategy swaps the OAuth tail for a single CPA import node',
|
||||
plusPaymentMethod: 'paypal-hosted',
|
||||
plusAccountAccessStrategy: 'cpa_codex_session',
|
||||
},
|
||||
previousNodeId: 'plus-checkout-create',
|
||||
expectedStepIds: [1, 2, 3, 4, 5, 6, 7],
|
||||
previousNodeId: 'paypal-hosted-review',
|
||||
expectedStepIds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
|
||||
},
|
||||
{
|
||||
label: 'gopay',
|
||||
|
||||
Reference in New Issue
Block a user