From 3805b3e408e76996ab492c9bdd700f26fb107d9a Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Wed, 22 Jul 2026 22:09:56 -0400 Subject: [PATCH 1/6] feat: export current wealth account balances --- README.md | 5 ++ content.js | 154 +++++++++++++++++++++++++++++++++++++++++- manifest.json | 2 +- popup.css | 15 +++- popup.html | 7 ++ popup.js | 15 ++-- tests/content.test.js | 132 +++++++++++++++++++++++++++++++++++- tests/popup.test.js | 118 ++++++++++++++++++++++++++++++++ 8 files changed, 435 insertions(+), 13 deletions(-) create mode 100644 tests/popup.test.js diff --git a/README.md b/README.md index 6bea48f..185aa5e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The **Credit Karma Data Extractor** exports transaction history, net worth histo - **Instant Stop**: Cancel huge extractions immediately without losing data—what you've fetched is saved. - **Smart Export**: Automatically generates `All Data`, `Income Only`, and `Expenses Only` files. - **Wealth History**: Export the full Net Worth and Investment graph series as separate, date-filtered CSV files. +- **Current Wealth Accounts**: Export current cash and investment source balances to a separate `wealth_accounts_.csv` snapshot. This export is not filtered by the selected historical date range. ## Quick Start @@ -34,6 +35,7 @@ The **Credit Karma Data Extractor** exports transaction history, net worth histo - Click the extension icon. - Select your date range (or click "Last Year"). - Choose the transaction and/or wealth-history files to generate. + - Optionally choose **Current Cash & Investment Accounts** for an as-of snapshot of the source rows currently returned by Credit Karma. - Click **Export Selected Data**. - Watch the progress indicator and wait for your CSV files! @@ -47,6 +49,9 @@ Once you have your CSVs, you can use them with: ## Changelog +### Unreleased +- Added a separate current-balance snapshot export for individual cash and investment sources. Credit Karma's captured responses do not provide per-source history, so this file is intentionally independent of the date-range fields. + ### Version 2.1 (July 2026) - Added separate CSV exports for Net Worth and Investment graph values. - Wealth exports use the complete graph dataset and respect the selected date range. diff --git a/content.js b/content.js index 6625e95..8800e31 100644 --- a/content.js +++ b/content.js @@ -204,6 +204,107 @@ function formattedTextValue(formattedText) { return formattedText?.spans?.map(span => span.text || '').join('').trim() || ''; } +function viewTextValue(value) { + if (typeof value === 'string' || typeof value === 'number') { + return String(value).trim(); + } + return formattedTextValue(value); +} + +function parseFormattedBalance(value) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + + const text = viewTextValue(value); + if (!text || text.includes('%')) return null; + + const normalized = text + .replace(/[\u2212\u2012\u2013\u2014]/g, '-') + .replace(/\s/g, ''); + const isNegative = /^\(.*\)$/.test(normalized) || normalized.includes('-'); + const numericText = normalized.replace(/[(),+$¢£¥€-]/g, ''); + + if (!/^\d+(?:\.\d+)?$/.test(numericText)) return null; + + const parsed = Number(numericText); + if (!Number.isFinite(parsed)) return null; + return isNegative ? -parsed : parsed; +} + +function formatSnapshotDate(date) { + if (!date || typeof date.getTime !== 'function' || Number.isNaN(date.getTime())) { + throw new Error('Cannot export current account balances without a valid as-of date.'); + } + return date.toISOString(); +} + +/** + * Extract current account/source balances from KPL row views. A row can be a + * direct view or be repeated below an experimentation view's lookalikeViews, + * so traverse the response and deduplicate exact account snapshots. + */ +function extractWealthAccountRows(data, accountType, asOf = new Date()) { + const root = data?.data?.prime?.networthByAccountType; + if (!root || typeof root !== 'object') { + throw new Error(`Credit Karma did not return current ${accountType} account balances. The API may have changed.`); + } + + const rows = []; + const visited = new Set(); + const asOfValue = formatSnapshotDate(asOf); + + function visit(value) { + if (!value || typeof value !== 'object' || visited.has(value)) return; + visited.add(value); + + if (!Array.isArray(value) && value.rowTitle && value.rowValue) { + const sourceLabel = viewTextValue(value.rowTitle); + const balance = parseFormattedBalance(value.rowValue); + const descriptor = viewTextValue( + value.statusText + || value.rowStatusDot?.statusDotText + || value.rowSubtitle + || value.rowSubTitle + || value.descriptor + || value.subTitle + ); + + if (sourceLabel && balance !== null) { + rows.push({ + asOf: asOfValue, + accountType, + sourceLabel, + balance, + descriptor + }); + } + } + + for (const child of Object.values(value)) { + visit(child); + } + } + + visit(root); + + const uniqueRows = new Map(); + for (const row of rows) { + const key = [ + row.accountType.toLowerCase(), + row.sourceLabel.toLowerCase(), + row.balance, + row.descriptor.toLowerCase() + ].join('\u0000'); + if (!uniqueRows.has(key)) uniqueRows.set(key, row); + } + + return Array.from(uniqueRows.values()) + .sort((a, b) => a.accountType.localeCompare(b.accountType) + || a.sourceLabel.localeCompare(b.sourceLabel) + || a.balance - b.balance); +} + function graphDateToISO(dateLabel) { const parsedDate = new Date(dateLabel); if (Number.isNaN(parsedDate.getTime())) return null; @@ -274,6 +375,22 @@ async function fetchGraphHistory(type, startDate, endDate, signal) { ); } +async function fetchWealthAccountSnapshots(signal, asOf = new Date()) { + const accountTypes = ['cash', 'investments']; + const responses = await Promise.all(accountTypes.map(async accountType => ({ + accountType, + data: await fetchPersistedQuery( + 'getAccountL2Page', + CONFIG.ACCOUNT_L2_HASH, + { input: { accountType } }, + signal + ) + }))); + + return responses.flatMap(({ accountType, data }) => + extractWealthAccountRows(data, accountType, asOf)); +} + /** * Main API entry point. * @@ -1060,6 +1177,19 @@ function convertGraphHistoryToCSV(history, valueColumn) { return `Date,${valueColumn}\n${rows.join('')}`; } +function convertWealthAccountsToCSV(rows) { + const escape = value => String(value ?? '').replace(/"/g, '""'); + const csvRows = rows.map(row => [ + row.asOf, + row.accountType, + row.sourceLabel, + row.balance, + row.descriptor + ].map(value => `"${escape(value)}"`).join(',') + '\n'); + + return `As Of,Account Type,Source Label,Balance,Descriptor\n${csvRows.join('')}`; +} + function saveCSVToFile(csvData, fileName) { const blob = new Blob([csvData], { type: 'text/csv' }); const link = document.createElement('a'); @@ -1448,7 +1578,9 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { try { const { startDate, endDate, csvTypes, fetchAccountNames = false, useApi = true, columns } = request; const needsTransactions = csvTypes.allTransactions || csvTypes.income || csvTypes.expenses; - console.log(`Received export request from ${startDate} to ${endDate}`); + console.log(csvTypes.wealthAccounts + ? 'Received export request including current account balances' + : `Received export request from ${startDate} to ${endDate}`); // Create a visual indicator that extraction is in progress - moved to left side const indicator = document.createElement('div'); @@ -1532,16 +1664,32 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { ); } - return { transactionCount: filteredTransactions.length, graphResults }; + let wealthAccountCount = 0; + if (csvTypes.wealthAccounts) { + const asOf = new Date(); + const wealthAccounts = await fetchWealthAccountSnapshots(undefined, asOf); + wealthAccountCount = wealthAccounts.length; + if (wealthAccountCount === 0) { + console.warn('No current cash or investment account balances were found.'); + } else { + saveCSVToFile( + convertWealthAccountsToCSV(wealthAccounts), + `wealth_accounts_${asOf.toISOString().slice(0, 10)}.csv` + ); + } + } + + return { transactionCount: filteredTransactions.length, graphResults, wealthAccountCount }; }; - exportData().then(({ transactionCount, graphResults }) => { + exportData().then(({ transactionCount, graphResults, wealthAccountCount }) => { if (indicator.parentNode) indicator.parentNode.removeChild(indicator); const graphPointCount = graphResults.reduce((count, result) => count + result.history.length, 0); const summaryParts = []; if (needsTransactions) summaryParts.push(`${transactionCount} transactions`); if (graphResults.length) summaryParts.push(`${graphPointCount} graph values`); + if (csvTypes.wealthAccounts) summaryParts.push(`${wealthAccountCount} current account balances`); // Show completion notification - moved to left side const completionNotice = document.createElement('div'); diff --git a/manifest.json b/manifest.json index de623a6..2533968 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Credit Karma Data Exporter", "version": "2.1", - "description": "Export transactions, net worth history, and investment values from Credit Karma.", + "description": "Export transactions, wealth history, and current account balances from Credit Karma.", "permissions": [ "activeTab", "scripting" diff --git a/popup.css b/popup.css index 6827d51..a6149d7 100644 --- a/popup.css +++ b/popup.css @@ -182,6 +182,19 @@ input[type="date"] { margin: 0; } +.checkbox-label-wide { + grid-column: 1 / -1; + align-items: flex-start; +} + +.checkbox-label-wide small { + display: block; + margin-top: 2px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 400; +} + /* Toggle Switch */ .toggle-row { display: flex; @@ -308,4 +321,4 @@ footer { text-align: center; margin-top: 8px; line-height: 1.4; -} \ No newline at end of file +} diff --git a/popup.html b/popup.html index 904ae94..c971b2f 100644 --- a/popup.html +++ b/popup.html @@ -70,6 +70,13 @@

Files to Generate

+ diff --git a/popup.js b/popup.js index ab34898..a5c8a54 100644 --- a/popup.js +++ b/popup.js @@ -114,11 +114,6 @@ document.getElementById('export-btn').addEventListener('click', () => { const startDate = document.getElementById('start-date').value; const endDate = document.getElementById('end-date').value; - if (!startDate || !endDate) { - alert('Please select both start and end dates.'); - return; - } - // Get Settings const useApi = document.getElementById('useApiCheckbox').checked; @@ -128,7 +123,8 @@ document.getElementById('export-btn').addEventListener('click', () => { income: document.getElementById('incomeCheckbox').checked, expenses: document.getElementById('expensesCheckbox').checked, netWorth: document.getElementById('netWorthCheckbox').checked, - investments: document.getElementById('investmentsCheckbox').checked + investments: document.getElementById('investmentsCheckbox').checked, + wealthAccounts: document.getElementById('wealthAccountsCheckbox').checked }; if (!Object.values(csvTypes).some(Boolean)) { @@ -136,6 +132,13 @@ document.getElementById('export-btn').addEventListener('click', () => { return; } + const needsDateRange = Object.entries(csvTypes) + .some(([type, selected]) => type !== 'wealthAccounts' && selected); + if (needsDateRange && (!startDate || !endDate)) { + alert('Please select both start and end dates.'); + return; + } + // Get Column Preferences const columns = { date: document.getElementById('col-date').checked, diff --git a/tests/content.test.js b/tests/content.test.js index 2b62c49..467b515 100644 --- a/tests/content.test.js +++ b/tests/content.test.js @@ -16,10 +16,20 @@ vm.createContext(context); vm.runInContext(`${contentScript}\n;globalThis.testExports = { extractGraphHistory, convertGraphHistoryToCSV, - graphDateToISO + graphDateToISO, + parseFormattedBalance, + extractWealthAccountRows, + convertWealthAccountsToCSV };`, context); -const { extractGraphHistory, convertGraphHistoryToCSV, graphDateToISO } = context.testExports; +const { + extractGraphHistory, + convertGraphHistoryToCSV, + graphDateToISO, + parseFormattedBalance, + extractWealthAccountRows, + convertWealthAccountsToCSV +} = context.testExports; function point(date, value) { return { @@ -94,3 +104,121 @@ test('graph CSV has stable headers and ISO dates', () => { 'Date,Net Worth\n"2026-07-22","123.45"\n' ); }); + +function formattedText(text) { + return { spans: [{ text }] }; +} + +function accountResponse(views) { + return { + data: { + prime: { + networthByAccountType: { + cards: [{ item: { views } }] + } + } + } + }; +} + +function accountRow(label, balance, descriptor) { + return { + __typename: 'KPLRowView', + rowTitle: formattedText(label), + rowValue: formattedText(balance), + statusText: descriptor == null ? null : formattedText(descriptor) + }; +} + +test('parseFormattedBalance handles formatted, numeric, and negative values', () => { + assert.equal(parseFormattedBalance(formattedText('$1,234.56')), 1234.56); + assert.equal(parseFormattedBalance(formattedText('($45.67)')), -45.67); + assert.equal(parseFormattedBalance(formattedText('\u2212 $8.90')), -8.9); + assert.equal(parseFormattedBalance(-12.34), -12.34); + assert.equal(parseFormattedBalance(formattedText('$0.00')), 0); + assert.equal(parseFormattedBalance(formattedText('12%')), null); + assert.equal(parseFormattedBalance(null), null); +}); + +test('account snapshots include direct and nested lookalike rows and deduplicate copies', () => { + const direct = accountRow('Daily Cash', '$1,234.56', 'Connected'); + const nested = accountRow('Brokerage', '$8,765.44', 'Manual account'); + const duplicate = accountRow('Daily Cash', '$1,234.56', 'Connected'); + const data = accountResponse([ + nested, + { __typename: 'KPLExperimentationView', lookalikeViews: [duplicate] }, + { rowTitle: formattedText('Missing balance'), rowValue: null }, + direct + ]); + const asOf = new Date('2026-07-22T15:30:00.000Z'); + + const rows = extractWealthAccountRows(data, 'cash', asOf); + + assert.deepEqual(JSON.parse(JSON.stringify(rows)), [ + { + asOf: '2026-07-22T15:30:00.000Z', + accountType: 'cash', + sourceLabel: 'Brokerage', + balance: 8765.44, + descriptor: 'Manual account' + }, + { + asOf: '2026-07-22T15:30:00.000Z', + accountType: 'cash', + sourceLabel: 'Daily Cash', + balance: 1234.56, + descriptor: 'Connected' + } + ]); +}); + +test('account snapshots tolerate reordered fields, missing descriptors, and negative balances', () => { + const data = accountResponse([{ + rowValue: formattedText('-$25.00'), + presentationMetadata: null, + rowTitle: formattedText('Example Source') + }]); + + const rows = extractWealthAccountRows( + data, + 'investments', + new Date('2026-07-22T16:00:00.000Z') + ); + + assert.equal(rows.length, 1); + assert.equal(rows[0].sourceLabel, 'Example Source'); + assert.equal(rows[0].balance, -25); + assert.equal(rows[0].descriptor, ''); +}); + +test('account snapshots use KPL status-dot text as the descriptor', () => { + const data = accountResponse([{ + rowTitle: formattedText('Example Source'), + rowValue: formattedText('$50.00'), + rowStatusDot: { statusDotText: formattedText('Needs attention') } + }]); + + const rows = extractWealthAccountRows(data, 'cash', new Date('2026-07-22T16:00:00.000Z')); + assert.equal(rows[0].descriptor, 'Needs attention'); +}); + +test('account snapshot parser rejects responses without the expected account root', () => { + assert.throws( + () => extractWealthAccountRows({}, 'cash', new Date()), + /did not return current cash account balances/ + ); +}); + +test('account CSV uses stable columns and escapes text without changing numeric balances', () => { + assert.equal( + convertWealthAccountsToCSV([{ + asOf: '2026-07-22T15:30:00.000Z', + accountType: 'investments', + sourceLabel: 'Brokerage "A"', + balance: -25.5, + descriptor: 'Manual' + }]), + 'As Of,Account Type,Source Label,Balance,Descriptor\n' + + '"2026-07-22T15:30:00.000Z","investments","Brokerage ""A""","-25.5","Manual"\n' + ); +}); diff --git a/tests/popup.test.js b/tests/popup.test.js new file mode 100644 index 0000000..97b0917 --- /dev/null +++ b/tests/popup.test.js @@ -0,0 +1,118 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +const popupScript = fs.readFileSync(path.join(__dirname, '..', 'popup.js'), 'utf8'); + +function createPopupContext() { + const listeners = new Map(); + const alerts = []; + const sentMessages = []; + const checkboxIds = [ + 'useApiCheckbox', + 'allTransactionsCheckbox', + 'incomeCheckbox', + 'expensesCheckbox', + 'netWorthCheckbox', + 'investmentsCheckbox', + 'wealthAccountsCheckbox', + 'col-date', + 'col-desc', + 'col-amount', + 'col-category', + 'col-type', + 'col-account', + 'col-notes', + 'col-labels' + ]; + const elements = new Map(); + + function element(id) { + const result = { + id, + checked: false, + value: '', + textContent: id, + disabled: false, + addEventListener(type, callback) { + listeners.set(`${id}:${type}`, callback); + } + }; + elements.set(id, result); + return result; + } + + element('theme-toggle'); + element('start-date'); + element('end-date'); + element('export-btn'); + element('debug-raw-btn'); + for (const id of checkboxIds) element(id); + elements.get('useApiCheckbox').checked = true; + for (const id of checkboxIds.filter(id => id.startsWith('col-'))) { + elements.get(id).checked = true; + } + + const body = { + theme: 'light', + setAttribute(_name, value) { this.theme = value; }, + getAttribute() { return this.theme; } + }; + const context = { + URL, + console, + document: { + body, + getElementById: id => elements.get(id), + querySelectorAll: () => [] + }, + localStorage: { + getItem: () => null, + setItem() {} + }, + alert: message => alerts.push(message), + setTimeout: callback => callback(), + chrome: { + runtime: { lastError: null }, + scripting: { executeScript() {} }, + tabs: { + query(_options, callback) { + callback([{ id: 7, url: 'https://www.creditkarma.com/networth' }]); + }, + sendMessage(_tabId, message, callback) { + sentMessages.push(message); + callback({ status: 'started' }); + } + } + } + }; + + vm.createContext(context); + vm.runInContext(popupScript, context); + return { alerts, elements, listeners, sentMessages }; +} + +test('current wealth account snapshots can export without a historical date range', () => { + const { alerts, elements, listeners, sentMessages } = createPopupContext(); + elements.get('wealthAccountsCheckbox').checked = true; + + listeners.get('export-btn:click')(); + + assert.deepEqual(alerts, []); + assert.equal(sentMessages.length, 1); + assert.equal(sentMessages[0].csvTypes.wealthAccounts, true); + assert.equal(sentMessages[0].startDate, ''); + assert.equal(sentMessages[0].endDate, ''); +}); + +test('historical exports still require both dates', () => { + const { alerts, elements, listeners, sentMessages } = createPopupContext(); + elements.get('netWorthCheckbox').checked = true; + + listeners.get('export-btn:click')(); + + assert.deepEqual(alerts, ['Please select both start and end dates.']); + assert.deepEqual(sentMessages, []); +}); From f391cbe113dfcdecf3ff5392eb134355326f9233 Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Thu, 30 Jul 2026 00:06:39 -0400 Subject: [PATCH 2/6] feat: export full net worth snapshots --- README.md | 6 +- content.js | 198 +++++++++++++++++++++++++++++++++++++----- popup.html | 4 +- tests/content.test.js | 116 ++++++++++++++++++++++++- 4 files changed, 297 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 185aa5e..4e6f61c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The **Credit Karma Data Extractor** exports transaction history, net worth histo - **Instant Stop**: Cancel huge extractions immediately without losing data—what you've fetched is saved. - **Smart Export**: Automatically generates `All Data`, `Income Only`, and `Expenses Only` files. - **Wealth History**: Export the full Net Worth and Investment graph series as separate, date-filtered CSV files. -- **Current Wealth Accounts**: Export current cash and investment source balances to a separate `wealth_accounts_.csv` snapshot. This export is not filtered by the selected historical date range. +- **Current Net Worth Breakdown**: Export current Cash, Investments, Property, Credit cards, and Loans totals to `net_worth_breakdown_.csv`, plus available cash, investment, and property source balances to `wealth_accounts_.csv`. These snapshots are not filtered by the selected historical date range. ## Quick Start @@ -35,7 +35,7 @@ The **Credit Karma Data Extractor** exports transaction history, net worth histo - Click the extension icon. - Select your date range (or click "Last Year"). - Choose the transaction and/or wealth-history files to generate. - - Optionally choose **Current Cash & Investment Accounts** for an as-of snapshot of the source rows currently returned by Credit Karma. + - Optionally choose **Current Net Worth Breakdown & Accounts** for current asset/debt segment totals and the detailed source rows Credit Karma makes available. - Click **Export Selected Data**. - Watch the progress indicator and wait for your CSV files! @@ -50,7 +50,7 @@ Once you have your CSVs, you can use them with: ## Changelog ### Unreleased -- Added a separate current-balance snapshot export for individual cash and investment sources. Credit Karma's captured responses do not provide per-source history, so this file is intentionally independent of the date-range fields. +- Added current snapshot exports for Cash, Investments, Property, Credit cards, and Loans totals, plus individual cash, investment, and property sources. Credit Karma's captured responses do not provide per-source history, so these files are intentionally independent of the date-range fields. ### Version 2.1 (July 2026) - Added separate CSV exports for Net Worth and Investment graph values. diff --git a/content.js b/content.js index 8800e31..8430a1a 100644 --- a/content.js +++ b/content.js @@ -15,6 +15,15 @@ const CONFIG = { ACCOUNT_L2_HASH: 'ae3d3cc725b67ede7ec9216518daf4c06695c583301d6749881a1a55a9c061f2' }; +const WEALTH_ACCOUNT_TYPES = Object.freeze(['cash', 'investments', 'property']); +const NET_WORTH_SEGMENTS = Object.freeze({ + cash: { label: 'Cash', section: 'assets', order: 0 }, + investments: { label: 'Investments', section: 'assets', order: 1 }, + property: { label: 'Property', section: 'assets', order: 2 }, + creditCards: { label: 'Credit cards', section: 'debts', order: 3 }, + loans: { label: 'Loans', section: 'debts', order: 4 } +}); + // Cache for the access token let cachedAccessToken = null; @@ -234,11 +243,76 @@ function parseFormattedBalance(value) { function formatSnapshotDate(date) { if (!date || typeof date.getTime !== 'function' || Number.isNaN(date.getTime())) { - throw new Error('Cannot export current account balances without a valid as-of date.'); + throw new Error('Cannot export a current net worth snapshot without a valid as-of date.'); } return date.toISOString(); } +function extractNetWorthSegmentRows(data, asOf = new Date()) { + const root = data?.data?.prime?.networth; + if (!root || typeof root !== 'object') { + throw new Error('Credit Karma did not return the current net worth breakdown. The API may have changed.'); + } + + const segmentByLabel = new Map( + Object.entries(NET_WORTH_SEGMENTS) + .map(([segment, metadata]) => [metadata.label.toLowerCase(), { segment, ...metadata }]) + ); + const asOfValue = formatSnapshotDate(asOf); + const rows = new Map(); + + for (const card of root.cards || []) { + const views = card?.item?.composableRoot?.composableRootViews; + if (!Array.isArray(views)) continue; + + let currentRow = null; + const finishCurrentRow = () => { + if (currentRow && currentRow.balance !== null) { + rows.set(currentRow.segment, { + asOf: asOfValue, + section: currentRow.section, + segment: currentRow.segment, + balance: currentRow.balance, + descriptor: currentRow.descriptors.join(' · ') + }); + } + currentRow = null; + }; + + for (const view of views) { + if (view?.__typename !== 'FabricComposableFormattedText') continue; + + const text = formattedTextValue(view.composableFormattedTextModel); + if (!text) continue; + + const segment = segmentByLabel.get(text.toLowerCase()); + if (segment) { + finishCurrentRow(); + currentRow = { + ...segment, + balance: null, + descriptors: [] + }; + continue; + } + + if (!currentRow) continue; + + const balance = parseFormattedBalance(text); + if (balance !== null && currentRow.balance === null) { + currentRow.balance = balance; + } else if (balance === null) { + currentRow.descriptors.push(text); + } + } + + finishCurrentRow(); + } + + return Array.from(rows.values()) + .sort((a, b) => NET_WORTH_SEGMENTS[a.segment].order - NET_WORTH_SEGMENTS[b.segment].order); +} + /** * Extract current account/source balances from KPL row views. A row can be a * direct view or be repeated below an experimentation view's lookalikeViews, @@ -375,20 +449,63 @@ async function fetchGraphHistory(type, startDate, endDate, signal) { ); } +async function fetchNetWorthBreakdownSnapshot(signal, asOf = new Date()) { + const data = await fetchPersistedQuery( + 'getNetworthPage', + CONFIG.NET_WORTH_HASH, + { input: { queryStringParameters: '' } }, + signal + ); + return extractNetWorthSegmentRows(data, asOf); +} + async function fetchWealthAccountSnapshots(signal, asOf = new Date()) { - const accountTypes = ['cash', 'investments']; - const responses = await Promise.all(accountTypes.map(async accountType => ({ - accountType, - data: await fetchPersistedQuery( - 'getAccountL2Page', - CONFIG.ACCOUNT_L2_HASH, - { input: { accountType } }, - signal - ) - }))); - - return responses.flatMap(({ accountType, data }) => - extractWealthAccountRows(data, accountType, asOf)); + const results = await Promise.all(WEALTH_ACCOUNT_TYPES.map(async accountType => { + try { + const data = await fetchPersistedQuery( + 'getAccountL2Page', + CONFIG.ACCOUNT_L2_HASH, + { input: { accountType } }, + signal + ); + return { + accountType, + rows: extractWealthAccountRows(data, accountType, asOf) + }; + } catch (error) { + console.warn(`[API] Current ${accountType} balances were unavailable:`, error); + return { accountType, error }; + } + })); + + const successfulResults = results.filter(result => Array.isArray(result.rows)); + if (successfulResults.length === 0) { + throw new Error('Credit Karma did not return any current wealth account balances.'); + } + + return successfulResults.flatMap(result => result.rows); +} + +async function fetchCurrentWealthSnapshots(signal, asOf = new Date()) { + const [breakdownResult, accountsResult] = await Promise.allSettled([ + fetchNetWorthBreakdownSnapshot(signal, asOf), + fetchWealthAccountSnapshots(signal, asOf) + ]); + + if (breakdownResult.status === 'rejected') { + console.warn('[API] Current net worth breakdown was unavailable:', breakdownResult.reason); + } + if (accountsResult.status === 'rejected') { + console.warn('[API] Detailed current account balances were unavailable:', accountsResult.reason); + } + if (breakdownResult.status === 'rejected' && accountsResult.status === 'rejected') { + throw new Error('Credit Karma did not return any current net worth snapshot data.'); + } + + return { + breakdownRows: breakdownResult.status === 'fulfilled' ? breakdownResult.value : [], + accountRows: accountsResult.status === 'fulfilled' ? accountsResult.value : [] + }; } /** @@ -1190,6 +1307,19 @@ function convertWealthAccountsToCSV(rows) { return `As Of,Account Type,Source Label,Balance,Descriptor\n${csvRows.join('')}`; } +function convertNetWorthBreakdownToCSV(rows) { + const escape = value => String(value ?? '').replace(/"/g, '""'); + const csvRows = rows.map(row => [ + row.asOf, + row.section, + row.segment, + row.balance, + row.descriptor + ].map(value => `"${escape(value)}"`).join(',') + '\n'); + + return `As Of,Section,Segment,Balance,Descriptor\n${csvRows.join('')}`; +} + function saveCSVToFile(csvData, fileName) { const blob = new Blob([csvData], { type: 'text/csv' }); const link = document.createElement('a'); @@ -1665,31 +1795,57 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { } let wealthAccountCount = 0; + let netWorthSegmentCount = 0; if (csvTypes.wealthAccounts) { const asOf = new Date(); - const wealthAccounts = await fetchWealthAccountSnapshots(undefined, asOf); - wealthAccountCount = wealthAccounts.length; + const { breakdownRows, accountRows } = + await fetchCurrentWealthSnapshots(undefined, asOf); + netWorthSegmentCount = breakdownRows.length; + wealthAccountCount = accountRows.length; + + if (netWorthSegmentCount === 0) { + console.warn('No current net worth segment balances were found.'); + } else { + saveCSVToFile( + convertNetWorthBreakdownToCSV(breakdownRows), + `net_worth_breakdown_${asOf.toISOString().slice(0, 10)}.csv` + ); + } + if (wealthAccountCount === 0) { - console.warn('No current cash or investment account balances were found.'); + console.warn('No detailed current asset account balances were found.'); } else { saveCSVToFile( - convertWealthAccountsToCSV(wealthAccounts), + convertWealthAccountsToCSV(accountRows), `wealth_accounts_${asOf.toISOString().slice(0, 10)}.csv` ); } } - return { transactionCount: filteredTransactions.length, graphResults, wealthAccountCount }; + return { + transactionCount: filteredTransactions.length, + graphResults, + wealthAccountCount, + netWorthSegmentCount + }; }; - exportData().then(({ transactionCount, graphResults, wealthAccountCount }) => { + exportData().then(({ + transactionCount, + graphResults, + wealthAccountCount, + netWorthSegmentCount + }) => { if (indicator.parentNode) indicator.parentNode.removeChild(indicator); const graphPointCount = graphResults.reduce((count, result) => count + result.history.length, 0); const summaryParts = []; if (needsTransactions) summaryParts.push(`${transactionCount} transactions`); if (graphResults.length) summaryParts.push(`${graphPointCount} graph values`); - if (csvTypes.wealthAccounts) summaryParts.push(`${wealthAccountCount} current account balances`); + if (csvTypes.wealthAccounts) { + summaryParts.push(`${netWorthSegmentCount} net worth segments`); + summaryParts.push(`${wealthAccountCount} current account balances`); + } // Show completion notification - moved to left side const completionNotice = document.createElement('div'); diff --git a/popup.html b/popup.html index c971b2f..f00063d 100644 --- a/popup.html +++ b/popup.html @@ -73,8 +73,8 @@

Files to Generate

diff --git a/tests/content.test.js b/tests/content.test.js index 467b515..b878de8 100644 --- a/tests/content.test.js +++ b/tests/content.test.js @@ -17,8 +17,11 @@ vm.runInContext(`${contentScript}\n;globalThis.testExports = { extractGraphHistory, convertGraphHistoryToCSV, graphDateToISO, + WEALTH_ACCOUNT_TYPES, parseFormattedBalance, + extractNetWorthSegmentRows, extractWealthAccountRows, + convertNetWorthBreakdownToCSV, convertWealthAccountsToCSV };`, context); @@ -26,8 +29,11 @@ const { extractGraphHistory, convertGraphHistoryToCSV, graphDateToISO, + WEALTH_ACCOUNT_TYPES, parseFormattedBalance, + extractNetWorthSegmentRows, extractWealthAccountRows, + convertNetWorthBreakdownToCSV, convertWealthAccountsToCSV } = context.testExports; @@ -105,6 +111,10 @@ test('graph CSV has stable headers and ISO dates', () => { ); }); +test('current wealth snapshots request every account segment exposed by Credit Karma', () => { + assert.deepEqual(Array.from(WEALTH_ACCOUNT_TYPES), ['cash', 'investments', 'property']); +}); + function formattedText(text) { return { spans: [{ text }] }; } @@ -130,6 +140,110 @@ function accountRow(label, balance, descriptor) { }; } +function composableText(id, text) { + return { + __typename: 'FabricComposableFormattedText', + composableId: id, + composableFormattedTextModel: formattedText(text) + }; +} + +function netWorthBreakdownResponse(cards) { + return { + data: { + prime: { + networth: { + cards: cards.map(views => ({ + item: { composableRoot: { composableRootViews: views } } + })) + } + } + } + }; +} + +test('net worth snapshot extracts ordered asset and debt segment totals', () => { + const data = netWorthBreakdownResponse([ + [ + composableText('cash-title', 'Cash'), + composableText('cash-count', '2 accounts'), + composableText('cash-value', '$1,200.50'), + composableText('investments-title', 'Investments'), + composableText('investments-count', '1 account'), + composableText('investments-value', '$8,000') + ], + [ + composableText('property-title', 'Property'), + composableText('property-count', '1 asset'), + composableText('property-value', '$10,000') + ], + [ + composableText('credit-cards-title', 'Credit cards'), + composableText('credit-cards-count', '3 accounts'), + composableText('credit-cards-value', '$500.25') + ], + [ + composableText('loans-title', 'Loans'), + composableText('loans-count', '2 from your report'), + composableText('loans-value', '$2,500') + ] + ]); + + const rows = extractNetWorthSegmentRows(data, new Date('2026-07-29T12:00:00.000Z')); + + assert.deepEqual(JSON.parse(JSON.stringify(rows)), [ + { + asOf: '2026-07-29T12:00:00.000Z', + section: 'assets', + segment: 'cash', + balance: 1200.5, + descriptor: '2 accounts' + }, + { + asOf: '2026-07-29T12:00:00.000Z', + section: 'assets', + segment: 'investments', + balance: 8000, + descriptor: '1 account' + }, + { + asOf: '2026-07-29T12:00:00.000Z', + section: 'assets', + segment: 'property', + balance: 10000, + descriptor: '1 asset' + }, + { + asOf: '2026-07-29T12:00:00.000Z', + section: 'debts', + segment: 'creditCards', + balance: 500.25, + descriptor: '3 accounts' + }, + { + asOf: '2026-07-29T12:00:00.000Z', + section: 'debts', + segment: 'loans', + balance: 2500, + descriptor: '2 from your report' + } + ]); +}); + +test('net worth breakdown CSV keeps section and stable segment identifiers', () => { + assert.equal( + convertNetWorthBreakdownToCSV([{ + asOf: '2026-07-29T12:00:00.000Z', + section: 'debts', + segment: 'creditCards', + balance: 25.5, + descriptor: 'One "reported" account' + }]), + 'As Of,Section,Segment,Balance,Descriptor\n' + + '"2026-07-29T12:00:00.000Z","debts","creditCards","25.5","One ""reported"" account"\n' + ); +}); + test('parseFormattedBalance handles formatted, numeric, and negative values', () => { assert.equal(parseFormattedBalance(formattedText('$1,234.56')), 1234.56); assert.equal(parseFormattedBalance(formattedText('($45.67)')), -45.67); @@ -181,7 +295,7 @@ test('account snapshots tolerate reordered fields, missing descriptors, and nega const rows = extractWealthAccountRows( data, - 'investments', + 'property', new Date('2026-07-22T16:00:00.000Z') ); From d97f7b2ce0cc89dadfa12c307bc42d3fa9dd18bd Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Thu, 30 Jul 2026 00:29:04 -0400 Subject: [PATCH 3/6] chore: bump extension version to 2.2 --- README.md | 2 +- manifest.json | 2 +- popup.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4e6f61c..b4aecf3 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Once you have your CSVs, you can use them with: ## Changelog -### Unreleased +### Version 2.2 (July 2026) - Added current snapshot exports for Cash, Investments, Property, Credit cards, and Loans totals, plus individual cash, investment, and property sources. Credit Karma's captured responses do not provide per-source history, so these files are intentionally independent of the date-range fields. ### Version 2.1 (July 2026) diff --git a/manifest.json b/manifest.json index 2533968..ab858b5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Credit Karma Data Exporter", - "version": "2.1", + "version": "2.2", "description": "Export transactions, wealth history, and current account balances from Credit Karma.", "permissions": [ "activeTab", diff --git a/popup.html b/popup.html index f00063d..38b1071 100644 --- a/popup.html +++ b/popup.html @@ -126,7 +126,7 @@

Resources

From 026d889401fb5cb59f67013291a420d37eaee20c Mon Sep 17 00:00:00 2001 From: Chirag Bangera Date: Thu, 30 Jul 2026 00:33:30 -0400 Subject: [PATCH 4/6] fix: include details in net worth exports --- README.md | 4 ++-- content.js | 8 ++++++-- popup.html | 12 ++++++++---- tests/content.test.js | 12 ++++++++++-- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b4aecf3..bd12028 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ The **Credit Karma Data Extractor** exports transaction history, net worth histo - Go to [Credit Karma Transactions](https://www.creditkarma.com/networth/transactions). - Click the extension icon. - Select your date range (or click "Last Year"). - - Choose the transaction and/or wealth-history files to generate. - - Optionally choose **Current Net Worth Breakdown & Accounts** for current asset/debt segment totals and the detailed source rows Credit Karma makes available. + - Choose the transaction and/or wealth-history files to generate. **Net Worth History + Current Details** creates the total history plus current asset/debt segment totals and the detailed source rows Credit Karma makes available. + - Choose **Current Details Only** when you want the current breakdown and account balances without a historical date range. - Click **Export Selected Data**. - Watch the progress indicator and wait for your CSV files! diff --git a/content.js b/content.js index 8430a1a..3212771 100644 --- a/content.js +++ b/content.js @@ -508,6 +508,10 @@ async function fetchCurrentWealthSnapshots(signal, asOf = new Date()) { }; } +function shouldExportCurrentWealth(csvTypes) { + return Boolean(csvTypes?.netWorth || csvTypes?.wealthAccounts); +} + /** * Main API entry point. * @@ -1796,7 +1800,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { let wealthAccountCount = 0; let netWorthSegmentCount = 0; - if (csvTypes.wealthAccounts) { + if (shouldExportCurrentWealth(csvTypes)) { const asOf = new Date(); const { breakdownRows, accountRows } = await fetchCurrentWealthSnapshots(undefined, asOf); @@ -1842,7 +1846,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { const summaryParts = []; if (needsTransactions) summaryParts.push(`${transactionCount} transactions`); if (graphResults.length) summaryParts.push(`${graphPointCount} graph values`); - if (csvTypes.wealthAccounts) { + if (shouldExportCurrentWealth(csvTypes)) { summaryParts.push(`${netWorthSegmentCount} net worth segments`); summaryParts.push(`${wealthAccountCount} current account balances`); } diff --git a/popup.html b/popup.html index 38b1071..985a202 100644 --- a/popup.html +++ b/popup.html @@ -64,8 +64,12 @@

Files to Generate

-