diff --git a/README.md b/README.md index 6bea48f..3d350eb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ The **Credit Karma Data Extractor** exports transaction history, net worth histo - **Modern UI**: Beautiful interface with Dark Mode support and Quick Date presets (YTD, Last Year). - **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. +- **One-File BudgetLens Export**: The default BudgetLens Bundle combines transactions, both wealth histories, the current five-category breakdown, and available account sources in one versioned JSON file. - **Wealth History**: Export the full Net Worth and Investment graph series as separate, date-filtered CSV files. +- **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 @@ -33,7 +35,9 @@ 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. + - Leave **BudgetLens Bundle** selected for one JSON file containing transactions and all supported wealth data. + - Choose the individual CSV exports when you need separate transaction or wealth files. **Complete Net Worth** creates the total history plus current asset/debt segment totals and the detailed source rows Credit Karma makes available. + - Choose **Current Snapshot 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! @@ -47,6 +51,10 @@ Once you have your CSVs, you can use them with: ## Changelog +### Version 2.2 (July 2026) +- Added a one-file, versioned BudgetLens JSON bundle containing transactions and all supported wealth data. +- 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. - Wealth exports use the complete graph dataset and respect the selected date range. diff --git a/content.js b/content.js index 6625e95..164fd2b 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; @@ -204,6 +213,172 @@ 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 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, + * 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 +449,69 @@ 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 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 : [] + }; +} + +function shouldExportCurrentWealth(csvTypes) { + return Boolean(csvTypes?.budgetLensBundle || csvTypes?.netWorth || csvTypes?.wealthAccounts); +} + /** * Main API entry point. * @@ -1060,6 +1298,66 @@ 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 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 buildBudgetLensBundle({ + startDate, + endDate, + exportedAt, + transactions, + netWorthHistory, + investmentHistory, + netWorthBreakdown, + wealthAccounts +}) { + return { + format: 'budgetlens', + version: 1, + exportedAt: exportedAt.toISOString(), + dateRange: { start: startDate, end: endDate }, + transactions: transactions.map(transaction => ({ + date: transaction.date, + description: transaction.description ?? '', + amount: transaction.amount, + category: transaction.category ?? null, + transactionType: transaction.transactionType ?? null, + accountName: transaction.accountName ?? null, + accountType: transaction.accountType ?? null, + provider: transaction.provider ?? null, + labels: Array.isArray(transaction.labels) ? transaction.labels : [], + notes: transaction.notes ?? null + })), + netWorthHistory, + investmentHistory, + netWorthBreakdown, + wealthAccounts + }; +} + function saveCSVToFile(csvData, fileName) { const blob = new Blob([csvData], { type: 'text/csv' }); const link = document.createElement('a'); @@ -1070,6 +1368,16 @@ function saveCSVToFile(csvData, fileName) { setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0); } +function saveJSONToFile(value, fileName) { + const blob = new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }); + const link = document.createElement('a'); + const objectUrl = window.URL.createObjectURL(blob); + link.href = objectUrl; + link.download = fileName; + link.click(); + setTimeout(() => window.URL.revokeObjectURL(objectUrl), 0); +} + function logResults(allTransactions, filteredTransactions, csvData) { console.log('Filtered Transactions:', filteredTransactions); console.log('CSV Data:', csvData); @@ -1447,8 +1755,14 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'captureTransactions') { 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}`); + const needsTransactions = + csvTypes.budgetLensBundle || + csvTypes.allTransactions || + csvTypes.income || + csvTypes.expenses; + 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'); @@ -1501,18 +1815,20 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { } const graphExports = []; - if (csvTypes.netWorth) { + if (csvTypes.netWorth || csvTypes.budgetLensBundle) { graphExports.push({ type: 'netWorth', fileName: `net_worth_${startDate}_to_${endDate}.csv`, - valueColumn: 'Net Worth' + valueColumn: 'Net Worth', + saveCSV: csvTypes.netWorth }); } - if (csvTypes.investments) { + if (csvTypes.investments || csvTypes.budgetLensBundle) { graphExports.push({ type: 'investments', fileName: `investments_${startDate}_to_${endDate}.csv`, - valueColumn: 'Investment Value' + valueColumn: 'Investment Value', + saveCSV: csvTypes.investments }); } @@ -1526,22 +1842,90 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { console.warn(`No ${result.type} graph values found in the selected date range.`); continue; } - saveCSVToFile( - convertGraphHistoryToCSV(result.history, result.valueColumn), - result.fileName + if (result.saveCSV) { + saveCSVToFile( + convertGraphHistoryToCSV(result.history, result.valueColumn), + result.fileName + ); + } + } + + let wealthAccountCount = 0; + let netWorthSegmentCount = 0; + let breakdownRows = []; + let accountRows = []; + if (shouldExportCurrentWealth(csvTypes)) { + const asOf = new Date(); + ({ breakdownRows, accountRows } = + await fetchCurrentWealthSnapshots(undefined, asOf)); + netWorthSegmentCount = breakdownRows.length; + wealthAccountCount = accountRows.length; + const saveSnapshotCSVs = csvTypes.netWorth || csvTypes.wealthAccounts; + + if (netWorthSegmentCount === 0) { + console.warn('No current net worth segment balances were found.'); + } else if (saveSnapshotCSVs) { + saveCSVToFile( + convertNetWorthBreakdownToCSV(breakdownRows), + `net_worth_breakdown_${asOf.toISOString().slice(0, 10)}.csv` + ); + } + + if (wealthAccountCount === 0) { + console.warn('No detailed current asset account balances were found.'); + } else if (saveSnapshotCSVs) { + saveCSVToFile( + convertWealthAccountsToCSV(accountRows), + `wealth_accounts_${asOf.toISOString().slice(0, 10)}.csv` + ); + } + } + + if (csvTypes.budgetLensBundle) { + const histories = new Map( + graphResults.map(result => [result.type, result.history]) + ); + const bundle = buildBudgetLensBundle({ + startDate, + endDate, + exportedAt: new Date(), + transactions: filteredTransactions, + netWorthHistory: histories.get('netWorth') || [], + investmentHistory: histories.get('investments') || [], + netWorthBreakdown: breakdownRows, + wealthAccounts: accountRows + }); + saveJSONToFile( + bundle, + `budgetlens_${startDate}_to_${endDate}.json` ); } - return { transactionCount: filteredTransactions.length, graphResults }; + return { + transactionCount: filteredTransactions.length, + graphResults, + wealthAccountCount, + netWorthSegmentCount + }; }; - exportData().then(({ transactionCount, graphResults }) => { + 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 (shouldExportCurrentWealth(csvTypes)) { + summaryParts.push(`${netWorthSegmentCount} net worth segments`); + summaryParts.push(`${wealthAccountCount} current account balances`); + } + if (csvTypes.budgetLensBundle) summaryParts.push('1 BudgetLens bundle'); // Show completion notification - moved to left side const completionNotice = document.createElement('div'); diff --git a/manifest.json b/manifest.json index de623a6..ab858b5 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "Credit Karma Data Exporter", - "version": "2.1", - "description": "Export transactions, net worth history, and investment values from Credit Karma.", + "version": "2.2", + "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..f6c0829 100644 --- a/popup.css +++ b/popup.css @@ -26,8 +26,8 @@ body { color: var(--text); margin: 0; width: 380px; - padding: 12px; - /* Reduced padding */ + min-height: 520px; + padding: 12px 12px 76px; transition: background-color 0.3s, color 0.3s; box-sizing: border-box; } @@ -36,8 +36,7 @@ header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 12px; - /* Reduced margin */ + margin-bottom: 10px; } h1 { @@ -45,7 +44,12 @@ h1 { font-weight: 700; margin: 0; color: var(--primary); - /* Solid color instead of gradient for cleaner look */ +} + +.header-subtitle { + margin: 2px 0 0; + color: var(--text-secondary); + font-size: 10px; } .theme-toggle { @@ -69,10 +73,8 @@ h1 { .card { background: var(--surface); border-radius: var(--radius); - padding: 12px; - /* Reduced padding */ - margin-bottom: 12px; - /* Reduced margin */ + padding: 10px; + margin-bottom: 8px; box-shadow: var(--shadow); border: 1px solid var(--border); } @@ -82,7 +84,7 @@ h1 { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary); - margin: 0 0 8px 0; + margin: 0 0 7px; font-weight: 600; } @@ -125,7 +127,7 @@ input[type="date"]:focus { .quick-dates { display: flex; gap: 8px; - margin-top: 8px; + margin-top: 6px; flex-wrap: wrap; } @@ -165,6 +167,49 @@ input[type="date"] { gap: 8px; } +.transaction-options { + grid-template-columns: repeat(3, 1fr); +} + +.bundle-option { + padding: 8px; + border: 1px solid color-mix(in srgb, var(--primary) 45%, var(--border)); + border-radius: 8px; + background: color-mix(in srgb, var(--primary) 8%, var(--surface)); +} + +.bundle-option small { + display: block; + margin-top: 2px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 400; +} + +.wealth-options { + display: grid; + gap: 8px; +} + +.group-label { + margin: 0 0 6px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 600; +} + +.section-divider { + height: 1px; + margin: 9px 0; + background: var(--border); +} + +.column-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 7px 4px; +} + .checkbox-label { display: flex; align-items: center; @@ -182,6 +227,28 @@ 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; +} + +.wealth-options small, +.advanced small { + display: block; + margin-top: 2px; + color: var(--text-secondary); + font-size: 10px; + font-weight: 400; +} + /* Toggle Switch */ .toggle-row { display: flex; @@ -235,10 +302,22 @@ input:checked+.slider:before { transform: translateX(16px); } -/* Button */ +/* Actions */ +.action-bar { + position: fixed; + z-index: 10; + right: 0; + bottom: 0; + left: 0; + padding: 10px 12px 12px; + border-top: 1px solid var(--border); + background: var(--bg); + box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.08); +} + .primary-btn { width: 100%; - padding: 12px; + padding: 11px; background: var(--primary); color: white; border: none; @@ -264,38 +343,57 @@ input:checked+.slider:before { box-shadow: none; } -/* Footer */ -footer { - margin-top: 16px; - border-top: 1px solid var(--border); - padding-top: 12px; +.secondary-btn { + width: 100%; + margin-top: 10px; + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 11px; + cursor: pointer; } -.footer-section { - margin-bottom: 12px; +.advanced { + padding: 0; } -.footer-section h4 { - font-size: 11px; - text-transform: uppercase; +.advanced summary { + padding: 10px; color: var(--text-secondary); - margin: 0 0 6px 0; + font-size: 11px; font-weight: 600; + cursor: pointer; +} + +.advanced[open] { + padding: 0 10px 10px; +} + +.advanced[open] summary { + margin: 0 -10px 8px; +} + +/* Footer */ +footer { + margin-top: 10px; + border-top: 1px solid var(--border); + padding-top: 9px; } .footer-links { display: flex; - flex-direction: column; - gap: 6px; + flex-direction: row; + justify-content: center; + gap: 12px; } .footer-links a { font-size: 11px; color: var(--primary); text-decoration: none; - display: flex; - align-items: center; - gap: 4px; } .footer-links a:hover { @@ -306,6 +404,10 @@ footer { font-size: 10px; color: var(--text-secondary); text-align: center; - margin-top: 8px; + margin-top: 7px; line-height: 1.4; -} \ No newline at end of file +} + +.footer-meta p { + margin: 0; +} diff --git a/popup.html b/popup.html index 904ae94..07e1831 100644 --- a/popup.html +++ b/popup.html @@ -10,7 +10,10 @@
-

Credit Karma Extractor

+
+

Credit Karma Extractor

+

Export transactions and wealth data to CSV

+
@@ -19,7 +22,7 @@

Credit Karma Extractor

-
+

Date Range

@@ -39,43 +42,60 @@

Date Range

-

Settings

-
-
-

Files to Generate

-
+
+

Transactions

+
-
+ +
+

Net Worth

+
+ +
-

Columns

-
+

Transaction Columns

+

CSV exports only

+
@@ -87,42 +107,40 @@

Columns

- - - +
+ Advanced + + +
+
+ +
+ diff --git a/popup.js b/popup.js index ab34898..c95393a 100644 --- a/popup.js +++ b/popup.js @@ -114,21 +114,18 @@ 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; // Get File Types const csvTypes = { + budgetLensBundle: document.getElementById('budgetLensBundleCheckbox').checked, allTransactions: document.getElementById('allTransactionsCheckbox').checked, 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 +133,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..b762e58 100644 --- a/tests/content.test.js +++ b/tests/content.test.js @@ -16,10 +16,30 @@ vm.createContext(context); vm.runInContext(`${contentScript}\n;globalThis.testExports = { extractGraphHistory, convertGraphHistoryToCSV, - graphDateToISO + graphDateToISO, + WEALTH_ACCOUNT_TYPES, + parseFormattedBalance, + extractNetWorthSegmentRows, + extractWealthAccountRows, + convertNetWorthBreakdownToCSV, + convertWealthAccountsToCSV, + shouldExportCurrentWealth, + buildBudgetLensBundle };`, context); -const { extractGraphHistory, convertGraphHistoryToCSV, graphDateToISO } = context.testExports; +const { + extractGraphHistory, + convertGraphHistoryToCSV, + graphDateToISO, + WEALTH_ACCOUNT_TYPES, + parseFormattedBalance, + extractNetWorthSegmentRows, + extractWealthAccountRows, + convertNetWorthBreakdownToCSV, + convertWealthAccountsToCSV, + shouldExportCurrentWealth, + buildBudgetLensBundle +} = context.testExports; function point(date, value) { return { @@ -94,3 +114,286 @@ test('graph CSV has stable headers and ISO dates', () => { 'Date,Net Worth\n"2026-07-22","123.45"\n' ); }); + +test('current wealth snapshots request every account segment exposed by Credit Karma', () => { + assert.deepEqual(Array.from(WEALTH_ACCOUNT_TYPES), ['cash', 'investments', 'property']); +}); + +test('net worth history automatically includes current detailed snapshots', () => { + assert.equal(shouldExportCurrentWealth({ netWorth: true, wealthAccounts: false }), true); + assert.equal(shouldExportCurrentWealth({ netWorth: false, wealthAccounts: true }), true); + assert.equal(shouldExportCurrentWealth({ budgetLensBundle: true }), true); + assert.equal(shouldExportCurrentWealth({ netWorth: false, wealthAccounts: false }), false); +}); + +test('BudgetLens bundle uses a stable versioned contract and full transaction fields', () => { + const bundle = buildBudgetLensBundle({ + startDate: '2026-01-01', + endDate: '2026-07-30', + exportedAt: new Date('2026-07-30T12:00:00.000Z'), + transactions: [{ + id: 'not-exported', + date: '2026-07-01', + description: 'Synthetic Market', + amount: -12.34, + category: 'Groceries', + transactionType: 'debit', + accountName: 'Example Checking', + accountType: 'CHECKING', + provider: 'Example Bank', + labels: ['weekly'], + notes: 'Synthetic note' + }], + netWorthHistory: [{ date: '2026-07-01', value: 1000 }], + investmentHistory: [{ date: '2026-07-01', value: 500 }], + netWorthBreakdown: [{ + asOf: '2026-07-30T12:00:00.000Z', + section: 'assets', + segment: 'cash', + balance: 500, + descriptor: '1 account' + }], + wealthAccounts: [{ + asOf: '2026-07-30T12:00:00.000Z', + accountType: 'cash', + sourceLabel: 'Example Checking', + balance: 500, + descriptor: 'Connected' + }] + }); + + assert.equal(bundle.format, 'budgetlens'); + assert.equal(bundle.version, 1); + assert.deepEqual(JSON.parse(JSON.stringify(bundle.dateRange)), { + start: '2026-01-01', + end: '2026-07-30' + }); + assert.equal(bundle.transactions[0].id, undefined); + assert.equal(bundle.transactions[0].accountName, 'Example Checking'); + assert.equal(bundle.netWorthHistory.length, 1); + assert.equal(bundle.investmentHistory.length, 1); + assert.equal(bundle.netWorthBreakdown.length, 1); + assert.equal(bundle.wealthAccounts.length, 1); +}); + +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) + }; +} + +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); + 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, + 'property', + 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..1a22033 --- /dev/null +++ b/tests/popup.test.js @@ -0,0 +1,133 @@ +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', + 'budgetLensBundleCheckbox', + '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, []); +}); + +test('BudgetLens bundle is sent as a distinct export type', () => { + const { alerts, elements, listeners, sentMessages } = createPopupContext(); + elements.get('budgetLensBundleCheckbox').checked = true; + elements.get('start-date').value = '2026-01-01'; + elements.get('end-date').value = '2026-07-30'; + + listeners.get('export-btn:click')(); + + assert.deepEqual(alerts, []); + assert.equal(sentMessages.length, 1); + assert.equal(sentMessages[0].csvTypes.budgetLensBundle, true); + assert.equal(sentMessages[0].csvTypes.allTransactions, false); +});