|
/** |
|
* Mass-cancel PayPal Automatic Payments (billing agreements). |
|
* |
|
* Cookies are attached by the browser — nothing here touches credentials. |
|
* |
|
* The endpoint, reverse-engineered from the page's own traffic: |
|
* POST /myaccount/autopay/api/connect/{agreementId}/cancel |
|
* x-requested-with: XMLHttpRequest |
|
* {"_csrf":"…","data":{"fundingId":"…","agreementStatus":"ACTIVE", |
|
* "agreementState":"ACTIVE","isWebRefreshOn":true}, |
|
* "headers":{"Accept":"application/json","Content-Type":"application/json"}} |
|
* → 200 {"status":"INACTIVE"} |
|
* |
|
* Note _csrf goes in the BODY, not a header. fundingId is per-agreement (it's the |
|
* card or balance backing that agreement), so it's read from each detail response |
|
* rather than reused — GET /api/connect/{id} returns status, state, and funding.id. |
|
* |
|
* Usage: |
|
* 1. Log in, go to https://www.paypal.com/myaccount/autopay/ |
|
* 2. Paste this file into the DevTools console. |
|
* 3. await cancelAutopay() // dry run: shows what it would do |
|
* 4. await cancelAutopay({ apply: true }) // fires, serially, 2s apart |
|
* |
|
* The list lazy-loads behind a "More activity" button and only renders a handful |
|
* at a time; both calls click through it first. Passing `ids` skips that. |
|
* |
|
* Only ACTIVE agreements are cancelled; the page lists inactive ones too and those |
|
* 400 with SERVICE_ERROR if you try. Re-running after a partial failure is safe. |
|
* |
|
* Cancelling does NOT cancel the underlying service — the merchant may bill you |
|
* another way or treat it as a missed payment. And PayPal will not reactivate a |
|
* cancelled agreement. |
|
*/ |
|
|
|
// Wrapped in an IIFE so re-pasting into the same console works. Top-level `const` |
|
// would throw "already declared" on the second paste and silently abort the whole |
|
// file, leaving the previous version running. |
|
(() => { |
|
const AUTOPAY_API = 'https://www.paypal.com/myaccount/autopay/api/connect'; |
|
|
|
/** The token is in the page somewhere; the capture proves the value exists client-side. */ |
|
function findCsrf() { |
|
const meta = document.querySelector('meta[name="csrf-token"], meta[name="_csrf"]'); |
|
if (meta?.content) return meta.content; |
|
|
|
const reactData = document.getElementById('__react_data__'); |
|
if (reactData) { |
|
try { |
|
const parsed = JSON.parse(atob(reactData.getAttribute('data'))); |
|
if (parsed._csrf) return parsed._csrf; |
|
} catch {} |
|
} |
|
|
|
// Server-rendered Node app: the token is usually inlined in the initial HTML. |
|
const inline = document.documentElement.innerHTML.match(/"_csrf"\s*:\s*"([^"]+)"/); |
|
if (inline) return inline[1]; |
|
|
|
return null; |
|
} |
|
|
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms)); |
|
|
|
async function waitUntil(predicate, timeoutMs = 8000, stepMs = 250) { |
|
for (let waited = 0; waited < timeoutMs; waited += stepMs) { |
|
await wait(stepMs); |
|
if (predicate()) return true; |
|
} |
|
return false; |
|
} |
|
|
|
/** |
|
* The list lazy-loads behind a "More activity" button; unexpanded it showed 8 of 100. |
|
* Click until the button is gone or the list stops growing. |
|
*/ |
|
async function expandList({ maxClicks = 200 } = {}) { |
|
let clicks = 0; |
|
for (;;) { |
|
const btn = [...document.querySelectorAll('button[name="moreActivity"]')].find( |
|
(b) => !b.disabled && b.offsetParent !== null |
|
); |
|
if (!btn) break; |
|
if (clicks >= maxClicks) { |
|
console.warn(`Stopped after ${maxClicks} clicks — "More activity" is still there. Raise maxClicks.`); |
|
break; |
|
} |
|
|
|
const before = agreementIdsOnPage().length; |
|
btn.click(); |
|
clicks++; |
|
|
|
if (!(await waitUntil(() => agreementIdsOnPage().length > before))) { |
|
console.warn(`Clicked "More activity" but the list stopped growing at ${before}. Stopping.`); |
|
break; |
|
} |
|
console.log(` expanded to ${agreementIdsOnPage().length}…`); |
|
} |
|
return { clicks, total: agreementIdsOnPage().length }; |
|
} |
|
|
|
function agreementIdsOnPage() { |
|
const ids = new Set(); |
|
for (const a of document.querySelectorAll('a[href*="/autopay/connect/"]')) { |
|
const m = a.getAttribute('href').match(/\/autopay\/connect\/((?:B|BA|I)-[A-Z0-9]+)/); |
|
if (m) ids.add(m[1]); |
|
} |
|
return [...ids]; |
|
} |
|
|
|
/** Errors come back as text, not JSON — .json() alone turns the diagnosis into null. */ |
|
async function readBody(res) { |
|
const text = await res.text().catch(() => ''); |
|
if (!text) return null; |
|
try { |
|
return JSON.parse(text); |
|
} catch { |
|
return text.slice(0, 2000); |
|
} |
|
} |
|
|
|
async function fetchDetail(agreementId) { |
|
const res = await fetch(`${AUTOPAY_API}/${agreementId}`, { |
|
credentials: 'same-origin', |
|
headers: { accept: 'application/json, text/plain, */*', 'x-requested-with': 'XMLHttpRequest' }, |
|
}); |
|
return { status: res.status, body: await readBody(res) }; |
|
} |
|
|
|
async function cancelOne({ agreementId, csrf, fundingId, status, state }) { |
|
const res = await fetch(`${AUTOPAY_API}/${agreementId}/cancel`, { |
|
method: 'POST', |
|
credentials: 'same-origin', |
|
headers: { |
|
accept: 'application/json, text/plain, */*', |
|
'content-type': 'application/json', |
|
'x-requested-with': 'XMLHttpRequest', |
|
}, |
|
body: JSON.stringify({ |
|
_csrf: csrf, |
|
data: { |
|
// Balance-backed agreements have a funding object with no id — nothing to send. |
|
// undefined drops the key entirely rather than sending null. |
|
fundingId: fundingId ?? undefined, |
|
agreementStatus: status, |
|
agreementState: state, |
|
isWebRefreshOn: true, |
|
}, |
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, |
|
}), |
|
}); |
|
return { ok: res.ok, status: res.status, body: await readBody(res) }; |
|
} |
|
|
|
/** Dump one agreement's raw detail payload, to find where the real field names live. */ |
|
async function inspectAgreement(agreementId) { |
|
const { status, body } = await fetchDetail(agreementId); |
|
console.log(`GET /api/connect/${agreementId} — http ${status}`); |
|
console.log(JSON.stringify(body, null, 2)); |
|
return body; |
|
} |
|
|
|
async function cancelAutopay({ apply = false, ids = null, delayMs = 2000, csrf: csrfOverride = null } = {}) { |
|
const csrf = csrfOverride || findCsrf(); |
|
if (!csrf) { |
|
console.error( |
|
'No _csrf token found on this page. It exists (the captured request carries one), ' + |
|
'but not anywhere findCsrf() looks. Grab it from a fresh cancel request and pass it in.' |
|
); |
|
return null; |
|
} |
|
|
|
if (!ids) { |
|
console.log('Expanding the list…'); |
|
const { clicks, total } = await expandList(); |
|
console.log(`Fully expanded: ${total} agreement(s) after ${clicks} click(s).`); |
|
} |
|
|
|
const agreementIds = ids || agreementIdsOnPage(); |
|
if (!agreementIds.length) { |
|
console.warn('No agreement links found. Are you on https://www.paypal.com/myaccount/autopay/ ?'); |
|
return null; |
|
} |
|
|
|
console.log(`Found ${agreementIds.length} agreement(s). Reading details…`); |
|
const plan = []; |
|
for (const agreementId of agreementIds) { |
|
const { status: detailHttp, body } = await fetchDetail(agreementId); |
|
plan.push({ |
|
agreementId, |
|
detailHttp, |
|
merchant: body?.merchantName ?? '?', |
|
status: body?.status ?? null, |
|
state: body?.state ?? null, |
|
totalPaid: body?.totalAmountPaid ?? null, |
|
fundingId: body?.funding?.id ?? null, |
|
}); |
|
await new Promise((r) => setTimeout(r, 300)); |
|
} |
|
|
|
console.table(plan); |
|
|
|
// The autopay page lists inactive agreements too; cancelling one 400s (SERVICE_ERROR). |
|
// A missing fundingId is normal, not an error: balance-backed agreements have no |
|
// funding instrument pinned, so funding.id is simply absent. |
|
const ready = plan.filter((p) => p.status === 'ACTIVE'); |
|
const inactive = plan.filter((p) => p.status && p.status !== 'ACTIVE'); |
|
const broken = plan.filter((p) => !p.status); |
|
|
|
if (inactive.length) { |
|
console.log( |
|
`${inactive.length} already inactive, skipping:`, |
|
inactive.map((p) => `${p.merchant} (${p.status})`) |
|
); |
|
} |
|
if (broken.length) { |
|
console.warn(`${broken.length} could not be read, skipping:`, broken); |
|
} |
|
|
|
if (!apply) { |
|
console.log( |
|
`DRY RUN — would cancel ${ready.length} ACTIVE of ${plan.length} listed:`, |
|
ready.map((p) => p.merchant) |
|
); |
|
console.log('To fire: await cancelAutopay({ apply: true })'); |
|
return plan; |
|
} |
|
|
|
const results = []; |
|
for (const item of ready) { |
|
const res = await cancelOne({ ...item, csrf }); |
|
// Keep the response body: a bare status code says nothing about WHY a 400 happened. |
|
results.push({ |
|
agreementId: item.agreementId, |
|
merchant: item.merchant, |
|
status: res.status, |
|
ok: res.ok, |
|
response: res.body, |
|
}); |
|
console.log(`${res.ok ? '✓' : '✗'} ${item.merchant} (${item.agreementId}) — http ${res.status}`, res.body ?? ''); |
|
await new Promise((r) => setTimeout(r, delayMs)); |
|
} |
|
|
|
console.table(results.map(({ response, ...row }) => row)); |
|
if (results.some((r) => !r.ok)) console.log('Failures with full response bodies:', results.filter((r) => !r.ok)); |
|
return results; |
|
} |
|
|
|
window.cancelAutopay = cancelAutopay; |
|
window.inspectAgreement = inspectAgreement; |
|
window.expandList = expandList; |
|
window.findCsrf = findCsrf; |
|
|
|
console.log('Ready. Dry run: await cancelAutopay() — inspect: await inspectAgreement("B-…")'); |
|
})(); |