Last active
July 9, 2026 18:52
-
-
Save StephenBrown2/011cecaa68ceef76b8cba4a541d739a5 to your computer and use it in GitHub Desktop.
Synchrony → YNAB CSV Exporter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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