Skip to content

Instantly share code, notes, and snippets.

@StephenBrown2
Last active July 9, 2026 18:52
Show Gist options
  • Select an option

  • Save StephenBrown2/011cecaa68ceef76b8cba4a541d739a5 to your computer and use it in GitHub Desktop.

Select an option

Save StephenBrown2/011cecaa68ceef76b8cba4a541d739a5 to your computer and use it in GitHub Desktop.
Synchrony → YNAB CSV Exporter
// ==UserScript==
// @name Synchrony → YNAB CSV Exporter
// @namespace https://github.com/you/synchrony-ynab
// @version 4.1.0
// @description Exports transactions from the Synchrony activity page to a YNAB CSV file. Amazon item details appear in the memo field.
// @author you
// @match https://www.synchrony.com/accounts/activity*
// @grant none
// @run-at document-idle
// ==/UserScript==
;(function () {
'use strict'
// ─── SELECTORS ────────────────────────────────────────────────────────────
//
// Transactions are <tr data-type="transaction" data-reason="expand"> rows.
// All column selectors use td position or stable data/aria attributes —
// NOT hashed class names, which change on every Synchrony deployment.
//
// Column layout:
// td:nth-child(1) type icon + label span "Purchase" / "Payment" / "Refund"
// td:nth-child(2) date span "May 26 2026"
// td:nth-child(3) description button aria-label = merchant name
// └─ p:nth-child(1) merchant text (fallback)
// └─ p:nth-child(2) Amazon item detail (if present)
// td:nth-child(4) status span.Tag__content "Pending" / "Completed" / "Scheduled"
// td:nth-child(5) amount p "$21.19" or "-$501.56"
//
// Amount sign convention:
// Synchrony shows charges as "$21.19" → YNAB outflow → "-21.19"
// Synchrony shows credits as "-$501.56" → YNAB inflow → "501.56"
// Sign is confirmed by type label, not by CSS class.
const SEL_ROW = 'tr[data-type="transaction"][data-reason="expand"]'
const SEL_STATUS = 'span.Tag__content' // BEM class; stable across deploys
const SEL_TABLIST = 'div[role="tablist"]'
const SEL_CARD = 'div[data-test="card-bank-details"] p'
// Column cell helpers — positional, class-independent
const td = (row, n) => row.querySelector(`td:nth-child(${n})`)
const BTN_ID = 'ynab-export-btn'
// ─── UTILITIES ────────────────────────────────────────────────────────────
function text(el) {
return (el?.innerText ?? el?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
// "May 26 2026" → "05/26/2026"
function normalizeDate(raw) {
if (!raw) return ''
const d = new Date(raw)
if (!isNaN(d.getTime())) {
return [
String(d.getMonth() + 1).padStart(2, '0'),
String(d.getDate()).padStart(2, '0'),
d.getFullYear(),
].join('/')
}
return raw
}
// Derive YNAB amount from the raw string and the type label.
// Type "Purchase" → charge → negative
// Type "Payment"/"Autopay"/"Refund" → credit → positive
// The raw string sign is a cross-check, not the sole authority,
// because Synchrony sometimes omits it on certain transaction types.
function toYNABAmount(raw, typeLabel) {
if (!raw) return null
const n = parseFloat(raw.replace(/[^0-9.]/g, ''))
if (isNaN(n) || n === 0) return null
const isCredit = /payment|autopay|refund|return|credit/i.test(typeLabel)
|| raw.includes('-')
return isCredit ? n : -n
}
function csvField(val) {
const s = String(val ?? '').replace(/"/g, '""')
return /[",\n\r]/.test(s) ? `"${s}"` : s
}
// ─── SCRAPING ─────────────────────────────────────────────────────────────
function scrape() {
const rows = Array.from(document.querySelectorAll(SEL_ROW))
if (!rows.length) return []
const transactions = []
for (const row of rows) {
const status = text(row.querySelector(SEL_STATUS))
// Only export posted transactions
if (status !== 'Completed') continue
const typeLabel = text(td(row, 1)?.querySelector('span[color]')) // "Purchase" / "Payment" etc. — skips h4 label
const rawDate = text(td(row, 2)?.querySelector(':scope > span')) // direct child span — skips h4 label
const descBtn = td(row, 3)?.querySelector('button[aria-label]')
const merchant = descBtn?.getAttribute('aria-label')
|| text(td(row, 3)?.querySelector('p'))
const amzDetail = text(td(row, 3)?.querySelectorAll('p')?.[1]) // second <p> = Amazon item detail
const rawAmount = text(td(row, 5)?.querySelector('p')) // p inside Amount div — skips h4 label
transactions.push({
date: normalizeDate(rawDate),
payee: merchant,
memo: amzDetail,
amount: toYNABAmount(rawAmount, typeLabel),
})
}
return transactions
}
// ─── CSV & DOWNLOAD ───────────────────────────────────────────────────────
function buildCSV(rows) {
const header = 'Date,Payee,Memo,Amount'
const lines = rows.map(r =>
[r.date, r.payee, r.memo, r.amount ?? ''].map(csvField).join(',')
)
return [header, ...lines].join('\n')
}
function download(csv) {
const cardName = text(document.querySelector(SEL_CARD))
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase() || 'card'
const date = new Date().toISOString().slice(0, 10)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = Object.assign(document.createElement('a'), {
href: url, download: `synchrony-ynab-${cardName}-${date}.csv`, style: 'display:none'
})
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 10_000)
}
// ─── EXPORT BUTTON ────────────────────────────────────────────────────────
function exportNow() {
const rows = scrape()
if (!rows.length) {
alert(
'No completed transactions found.\n\n' +
'Make sure the Activity page has loaded, then try again.\n\n' +
'Run window.__syncYNABDebug() in the console for diagnostics.'
)
return
}
download(buildCSV(rows))
console.info(`[Synchrony→YNAB] Exported ${rows.length} rows.`)
}
function makeButton() {
const btn = document.createElement('button')
btn.id = BTN_ID
btn.type = 'button'
btn.textContent = '⬇ Export to YNAB'
btn.title = 'Download completed transactions as a YNAB-format CSV'
Object.assign(btn.style, {
background: '#fff',
color: '#34657f',
border: '2px solid #34657f',
borderRadius: '4px',
padding: '8px 18px',
fontSize: '13px',
fontWeight: '700',
letterSpacing: '0.05em',
textTransform: 'uppercase',
cursor: 'pointer',
marginLeft: '12px',
fontFamily: 'inherit',
verticalAlign: 'middle',
lineHeight: '1.4',
})
btn.addEventListener('mouseenter', () => btn.style.background = 'rgba(52,101,127,0.06)')
btn.addEventListener('mouseleave', () => btn.style.background = '#fff')
btn.addEventListener('click', exportNow)
return btn
}
function inject() {
if (document.getElementById(BTN_ID)) return
const tablist = document.querySelector(SEL_TABLIST)
if (tablist) { tablist.appendChild(makeButton()); return }
const firstTable = document.querySelector('table')
if (firstTable?.parentNode) firstTable.parentNode.insertBefore(makeButton(), firstTable)
}
// ─── OBSERVE & INJECT ─────────────────────────────────────────────────────
const observer = new MutationObserver(() => {
if (document.querySelector(SEL_ROW) && !document.getElementById(BTN_ID)) inject()
})
observer.observe(document.body, { childList: true, subtree: true })
if (document.querySelector(SEL_ROW)) inject()
// ─── DEBUG HELPER ─────────────────────────────────────────────────────────
// Run window.__syncYNABDebug() in the browser console.
window.__syncYNABDebug = function () {
console.group('[Synchrony→YNAB] Debug')
const rows = document.querySelectorAll(SEL_ROW)
console.log(`Transaction rows: ${rows.length}`)
if (rows[0]) {
const r = rows[0]
console.log(' type :', text(td(r, 1)?.querySelector('span[color]')))
console.log(' date :', text(td(r, 2)?.querySelector(':scope > span')))
console.log(' merchant :', td(r, 3)?.querySelector('button[aria-label]')?.getAttribute('aria-label'))
console.log(' amzDetail :', text(td(r, 3)?.querySelectorAll('p')?.[1]))
console.log(' status :', text(r.querySelector(SEL_STATUS)))
console.log(' amount :', text(td(r, 5)?.querySelector('p')))
}
console.table(scrape().slice(0, 10))
console.groupEnd()
}
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment