// ==UserScript== // @name AWS Billing Exporter // @namespace https://console.aws.amazon.com/billing // @version 4.4 // @description Export detailed billing CSV. Accounts auto-loaded from billing API on panel open and on month/year change. Shows USD total per account. Identifies management account. // @author — // @updateURL https://git.radmik.com.my/Syed/aws-bill-to-csv/raw/branch/main/bill-to-csv.user.js // @downloadURL https://git.radmik.com.my/Syed/aws-bill-to-csv/raw/branch/main/bill-to-csv.user.js // @match https://us-east-1.console.aws.amazon.com/* // @match https://*.us-east-1.console.aws.amazon.com/* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; // ─── CONSTANTS ──────────────────────────────────────────────────────────────── const VERSION = '4.4'; // Detect billing console base URL from current page — handles both standard // (us-east-1.console.aws.amazon.com) and account-specific subdomain URLs // (347220827321-6aoq6a4e.us-east-1.console.aws.amazon.com). const BILLING_BASE = window.location.origin; const UI_ID = 'abe-root'; const CSS_ID = 'abe-styles'; // Known AWS issuer IDs → company display names. // Add more here if your org has different issuers. const ISSUER_NAME_MAP = { A1SF0XEGC4WU7S: 'Amazon Web Services Malaysia Sdn. Bhd.', AG8OH9DC5PTT3: 'Amazon Web Services, Inc.', }; // ─── XSRF TOKEN CAPTURE ────────────────────────────────────────────────────── // // The AWS billing console sends a custom "x-awsbc-xsrf-token" header with every // API request. Without it, some accounts return 404 even though they exist. // The token is generated by the billing app's JS and held in memory — it is NOT // in any cookie or DOM element we can read directly. // // Solution: hook window.fetch() early to intercept the first billing API call // the page makes naturally (e.g. loading the charges table). We extract the // token from that call's headers, store it, and attach it to all our requests. let _xsrfToken = null; // captured from page's own fetch calls let _managementAccountId = null; // captured from DescribeOrganization response (function hookFetch() { const _origFetch = window.fetch.bind(window); window.fetch = function (input, init) { const url = (typeof input === 'string') ? input : (input?.url || ''); // ── Capture XSRF token from billing API calls ────────────────────────── if (!_xsrfToken && url.includes('/billing/rest/')) { const headers = init?.headers || {}; const token = (headers instanceof Headers) ? headers.get('x-awsbc-xsrf-token') : Array.isArray(headers) ? (headers.find(([k]) => k.toLowerCase() === 'x-awsbc-xsrf-token') || [])[1] : headers['x-awsbc-xsrf-token'] || headers['X-Awsbc-Xsrf-Token']; if (token) _xsrfToken = token; } // ── Passively capture DescribeOrganization responses ─────────────────── // If the console naturally calls the Organizations API, extract MasterAccountId. if (!_managementAccountId && url.includes('organizations')) { const h = init?.headers || {}; const target = (h instanceof Headers) ? h.get('X-Amz-Target') : Array.isArray(h) ? (h.find(([k]) => k.toLowerCase() === 'x-amz-target') || [])[1] : h['X-Amz-Target'] || h['x-amz-target'] || ''; if (String(target).includes('DescribeOrganization')) { const p = _origFetch(input, init); p.then(resp => resp.clone().json() .then(d => { if (d?.Organization?.MasterAccountId) _managementAccountId = d.Organization.MasterAccountId; }) .catch(() => {})).catch(() => {}); return p; } } return _origFetch(input, init); }; })(); // ─── XHR HOOK (AWS SDK v2 uses XHR, not fetch) ─────────────────────────────── // // The AWS SDK v2 sends requests via XMLHttpRequest. We hook open() to note the // URL and the target header, then hook the load event to read the response body. (function hookXHR() { const _open = XMLHttpRequest.prototype.open; const _setHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function (method, url) { this._abeUrl = String(url || ''); this._abeTarget = ''; return _open.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function (name, value) { if (name && name.toLowerCase() === 'x-amz-target') this._abeTarget = value || ''; return _setHeader.apply(this, arguments); }; const _origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (body) { if (!_managementAccountId && this._abeUrl.includes('organizations') && String(this._abeTarget).includes('DescribeOrganization')) { this.addEventListener('load', function () { try { const d = JSON.parse(this.responseText); if (d?.Organization?.MasterAccountId) _managementAccountId = d.Organization.MasterAccountId; } catch (_) {} }); } return _origSend.apply(this, arguments); }; })(); // ─── FORMATTING ─────────────────────────────────────────────────────────────── /** * Format USD amount preserving full raw precision from the API. * No rounding — strips only IEEE-754 floating-point noise beyond 10 sig figs. * Handles scientific notation (e.g. 6e-06 → "0.000006"). * Adds comma separators on the integer part. * e.g. 9.000004 → "9.000004", 2724.113 → "2,724.113", -74.6232 → "-74.6232" */ function fmtAmount(raw) { if (raw === null || raw === undefined) return '0'; const v = parseFloat(String(raw)); if (v === 0 || isNaN(v)) return '0'; const clean = parseFloat(v.toPrecision(10)); // strip IEEE-754 noise if (clean === 0) return '0'; const neg = clean < 0; const cleanAbs = Math.abs(clean); // toFixed(10) avoids scientific notation for tiny values, then strip trailing zeros const s = cleanAbs.toFixed(10).replace(/\.?0+$/, ''); const parts = s.split('.'); parts[0] = parseInt(parts[0], 10).toLocaleString('en-US'); return (neg ? '-' : '') + parts.join('.'); } /** * Format usage amount number only (no unit) — for the Usage Amount column. * Integers: no decimal (1488.0 → "1,488") * Decimals: full raw precision (5060.109 → "5,060.109") * Handles scientific notation (3.8e-7 → "0.00000038") * Returns empty string for null/undefined (credit rows with no quantity) */ function fmtUsageAmount(amount) { if (amount === null || amount === undefined) return ''; const v = parseFloat(String(amount)); if (isNaN(v)) return ''; if (v === Math.floor(v)) return Math.floor(v).toLocaleString('en-US'); const clean = parseFloat(v.toPrecision(10)); const s = Math.abs(clean).toFixed(10).replace(/\.?0+$/, ''); const parts = s.split('.'); parts[0] = parseInt(parts[0], 10).toLocaleString('en-US'); return (clean < 0 ? '-' : '') + parts.join('.'); } /** * Return the usage unit string for the Usage Unit column. * Returns empty string for rows that have no meaningful unit. */ function fmtUsageUnit(amount, unit) { if (amount === null || amount === undefined) return ''; return unit || ''; } /** Month label: "MAR 2026" (matches legacy DOM-scraper format) */ function fmtMonthLabel(year, month) { const M = ['JAN','FEB','MAR','APR','MAY','JUN', 'JUL','AUG','SEP','OCT','NOV','DEC']; return `${M[month - 1]} ${year}`; } /** Always-quoted CSV cell */ function csvQ(val) { if (val === null || val === undefined) return '""'; return '"' + String(val).replace(/"/g, '""') + '"'; } // ─── COMPANY NAME RESOLVER ──────────────────────────────────────────────────── function resolveCompanyName(issuerId) { if (ISSUER_NAME_MAP[issuerId]) return ISSUER_NAME_MAP[issuerId]; const hits = document.body.innerText.match(/Amazon Web Services[^\n,]*/gi); if (hits) return hits.sort((a, b) => b.length - a.length)[0].trim(); return 'Amazon Web Services'; } // ─── BILLING API ────────────────────────────────────────────────────────────── async function fetchBill(accountId, year, month) { const url = accountId ? `${BILLING_BASE}/billing/rest/v1.0/bill/linked/completebill` + `?linkedAccountId=${accountId}&year=${year}&month=${month}&groupBy=ServiceProvider` : `${BILLING_BASE}/billing/rest/v1.0/bill/completebill` + `?year=${year}&month=${month}&groupBy=ServiceProvider`; // Build headers — include XSRF token if we captured one from the page const headers = { 'Content-Type': 'application/json' }; if (_xsrfToken) headers['x-awsbc-xsrf-token'] = _xsrfToken; const resp = await fetch(url, { credentials: 'include', headers }); if (resp.ok) return resp.json(); // Build a meaningful error message from the response let detail = `HTTP ${resp.status}`; if (resp.status === 404) { detail = _xsrfToken ? 'HTTP 404 — account has no billing data for this period' : 'HTTP 404 — XSRF token not yet captured; try refreshing the page and waiting a moment'; } else if (resp.status === 403) { detail = 'HTTP 403 — insufficient permissions'; } else { try { const body = await resp.json(); if (body && (body.message || body.errorMessage)) detail = `HTTP ${resp.status}: ${body.message || body.errorMessage}`; } catch (_) {} } throw new Error(detail); } // ─── LINKED ACCOUNTS API ───────────────────────────────────────────────────── /** * Fetch the exact list of linked accounts for a given month/year * using the billing gateway API — same source the AWS console uses. * Returns [{id, label, total}] sorted by total descending (as AWS returns them). */ async function fetchLinkedAccounts(year, month) { const url = `${BILLING_BASE}/billing/rest/api-proxy/pbgs?query=getLinkedAccountData`; const headers = { 'Content-Type': 'application/json' }; if (_xsrfToken) headers['x-awsbc-xsrf-token'] = _xsrfToken; const payload = { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'amz-1.0', 'X-Amz-Target': 'AWSProformaBillingGatewayService.GetTotal', }, path: '/', region: 'us-east-1', contentString: JSON.stringify({ timePeriod: { startBillingPeriod: { year, month }, endBillingPeriod: { year, month }, }, usageType: 'FAMILY', caller: 'READONLY', sort: { order: 'DESC' }, groupBy: ['LINKED_ACCOUNT_ID', 'LINKED_ACCOUNT_NAME'], filter: { logicalOperator: 'AND', expressions: [ { logicalOperator: 'NOT', expressions: [{ comparison: { attribute: 'LINE_ITEM_TYPE', values: ['Refund'], matchOption: 'EQUAL' } }], }, { logicalOperator: 'NOT', expressions: [{ logicalOperator: 'AND', expressions: [ { comparison: { attribute: 'LINE_ITEM_TYPE', values: ['Tax'], matchOption: 'EQUAL' } }, { comparison: { attribute: 'AMOUNT', values: ['0'], matchOption: 'LOWER' } }, ], }], }, ], }, }), }; const resp = await fetch(url, { method: 'POST', credentials: 'include', headers, body: JSON.stringify(payload), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); return (data.results || []).map(result => { const groups = result.groups || []; const id = groups.find(g => g.key === 'LINKED_ACCOUNT_ID')?.value || ''; const label = groups.find(g => g.key === 'LINKED_ACCOUNT_NAME')?.value || ''; const total = result.totalByCurrencies?.find(c => c.currency === 'USD')?.sum ?? 0; return { id, label, total }; }).filter(a => a.id); } // ─── MANAGEMENT ACCOUNT DETECTION ──────────────────────────────────────────── /** * Returns the management (payer) account ID. * * Strategy 1: passive XHR/fetch hook already captured it this session. * Strategy 2: GET /billing/rest/v1.0/account — returns accountRole:"Payer" * and accountId. Called fresh every run, no caching needed. * Strategy 3: manual input fallback (user sets it in the panel). */ async function fetchManagementAccountId() { if (_managementAccountId) return _managementAccountId; // ── Strategy 2: billing REST /account endpoint ───────────────────────── try { const headers = { 'Content-Type': 'application/json' }; if (_xsrfToken) headers['x-awsbc-xsrf-token'] = _xsrfToken; const resp = await fetch(`${BILLING_BASE}/billing/rest/v1.0/account`, { credentials: 'include', headers, }); if (resp.ok) { const data = await resp.json(); if (data.accountRole === 'Payer' && data.accountId) { _managementAccountId = data.accountId; return data.accountId; } } } catch (_) {} return null; } // ─── CURRENT ACCOUNT (single-account mode) ──────────────────────────────────── function scrapeNavAccountName() { const navLabel = document.querySelector('[data-testid="account-label"]'); if (!navLabel) return null; const match = navLabel.textContent.match(/^(.+?)\s*\(\d+\)$/); return match ? match[1].trim() : null; } async function fetchCurrentAccount() { const headers = { 'Content-Type': 'application/json' }; if (_xsrfToken) headers['x-awsbc-xsrf-token'] = _xsrfToken; const resp = await fetch(`${BILLING_BASE}/billing/rest/v1.0/account`, { credentials: 'include', headers, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); const id = data.accountId || ''; const label = scrapeNavAccountName() || id; return { id, label, role: data.accountRole }; } // ─── ROW PARSER ─────────────────────────────────────────────────────────────── // // SAVINGS PLAN CHARGE TYPE LOGIC (v4.4 — FIXED) // ────────────────────────────────────────────── // Each SP_Discount line item from the AWS billing API is a SINGLE credit // (li.chargeAmount.amount is already negative, e.g. -0.658005). It represents // the actual discount AWS applies against the on-demand-equivalent cost of // usage that was covered by a Savings Plan. // // v4.3 bug: this single credit was split into two synthetic rows — // "Savings Plan Covered Usage" = +0.658005 (flipped positive) // "Savings Plan Negation" = -0.658005 (original, negative) // Summed together these net to exactly $0, which silently ERASES the real // discount instead of applying it — every SP_Discount line item overstated // the grand total by the size of its own discount (and if anyone filtered // by charge type and excluded "Savings Plan Negation" — as the old inline // filter guidance below suggested — the discount got added a second time). // // Fix: emit ONE row per SP_Discount line item, carrying the original signed // chargeAmount (negative) as-is. No splitting, no cancel-to-zero trap. // This matches AWS Cost Explorer / the invoice total exactly, whether you // sum the whole CSV or filter by any subset of charge types that includes // "Savings Plan Discount". // // ComputeSavingsPlans commitment rows → "Savings Plan Recurring Fee" // // For client billing filter: // "Usage" + "Savings Plan Discount" + "Savings Plan Recurring Fee" function parseRows(data, accountId, accountLabel, year, month) { const rows = []; const mo = fmtMonthLabel(year, month); const acctCol = `${accountId} (${accountLabel})`; const SPP = 'Solution Provider Program Discounts'; // bill/completebill (single-account) returns { payerMap: { "": { serviceProviderList } } } // bill/linked/completebill (management) returns { bill: { serviceProviderList } } let serviceProviderList; if (data.bill) { serviceProviderList = data.bill.serviceProviderList; } else if (data.payerMap) { const payerEntry = Object.values(data.payerMap)[0]; serviceProviderList = payerEntry?.serviceProviderList || []; } else { serviceProviderList = []; } for (const sp of serviceProviderList) { const companyName = resolveCompanyName(sp.issuerId); for (const product of sp.productInformationList) { const serviceName = product.name; for (const region of product.regionList) { const regionName = region.name || ''; for (const it of region.instanceTypeList) { const serviceDetail = it.instanceType || 'No Instance Type'; for (const li of it.lineItemList) { // ── Savings Plan credit → single row, true signed amount ──── if (li.lineItemSubType === 'SP_Discount') { rows.push([ mo, acctCol, companyName, serviceName, regionName, serviceDetail, li.description || '', 'Savings Plan Discount', fmtUsageAmount(li.discountUsageAmount), fmtUsageUnit(li.discountUsageAmount, li.usageUnitName), fmtAmount(li.chargeAmount?.amount ?? 0) ]); continue; // skip the normal row push below } // ── All other line items ───────────────────────────────────── // Charge Type let chargeType; if (product.code === 'ComputeSavingsPlans') { chargeType = 'Savings Plan Recurring Fee'; } else if (li.lineItemType === 'Credit' && li.isDiscount === true) { chargeType = 'Bundle Discount'; } else { chargeType = li.lineItemType || 'Usage'; } // Usage Amount + Unit let usageAmt, usageUnit; if (li.lineItemType === 'Credit' && (li.usageAmount === null || li.usageAmount === 0)) { usageAmt = ''; usageUnit = ''; } else { usageAmt = fmtUsageAmount(li.usageAmount); usageUnit = fmtUsageUnit(li.usageAmount, li.usageUnitName); } rows.push([ mo, acctCol, companyName, serviceName, regionName, serviceDetail, li.description || '', chargeType, usageAmt, usageUnit, fmtAmount(li.chargeAmount?.amount ?? 0) ]); } } } // ── SPP / EDP discount row per service ────────────────────────── const sppTotal = Object.values(product.discounts || {}).reduce((s, v) => s + v, 0); const edpTotal = parseFloat(String(product.edpDiscounts?.amount ?? 0)); const discTotal = sppTotal + edpTotal; if (discTotal !== 0) { rows.push([ mo, acctCol, companyName, serviceName, '', SPP, SPP, 'SPP Discount', '', '', fmtAmount(-discTotal) ]); } } } return rows; } // ─── CSV + DOWNLOAD ─────────────────────────────────────────────────────────── function buildCSV(allRows) { const header = [ 'Month', 'Account ID', 'Amazon Company Name', 'Service Name', 'Region', 'Service Detail', 'Charge Rate Detail', 'Charge Type', 'Usage Amount', 'Usage Unit', 'Amount in USD' ]; const lines = [header.map(csvQ).join(',')]; for (const row of allRows) lines.push(row.map(csvQ).join(',')); return lines.join('\r\n'); } function triggerDownload(csv, filename) { const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(blob), download: filename, style: 'display:none' }); document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500); } // ─── STYLES ─────────────────────────────────────────────────────────────────── function injectCSS() { if (document.getElementById(CSS_ID)) return; const el = document.createElement('style'); el.id = CSS_ID; el.textContent = ` #abe-root { position: fixed; bottom: 24px; right: 24px; z-index: 2147483647; font-family: 'Amazon Ember','Helvetica Neue',Arial,sans-serif; font-size: 13px; line-height: 1.4; } #abe-fab { width: 54px; height: 54px; border-radius: 50%; background: #232f3e; border: 2.5px solid #ff9900; color: #ff9900; font-size: 22px; display: flex; align-items: center; justify-content: center; cursor: pointer; margin-left: auto; box-shadow: 0 4px 20px rgba(0,0,0,.4); transition: transform .18s, background .18s; } #abe-fab:hover { background: #1a2533; transform: scale(1.08); } #abe-panel { display: none; margin-bottom: 12px; background: #fff; border-radius: 14px; box-shadow: 0 12px 48px rgba(0,0,0,.22); width: 360px; overflow: hidden; border: 1px solid #e2e5ea; } #abe-panel.open { display: block; animation: abe-in .18s ease; } @keyframes abe-in { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } } #abe-hdr { background: #232f3e; padding: 15px 18px; display: flex; align-items: center; gap: 10px; } #abe-hdr-icon { font-size: 20px; } #abe-hdr-text h3 { margin:0; font-size:14px; font-weight:700; color:#fff; } #abe-hdr-text small { font-size:11px; color:#8fa3bc; } #abe-hdr-close { margin-left:auto; background:none; border:none; color:#8fa3bc; font-size:18px; cursor:pointer; padding:0; line-height:1; } #abe-hdr-close:hover { color:#fff; } #abe-body { padding: 16px 18px; } .abe-row { display:flex; gap:10px; margin-bottom:14px; } .abe-field { flex:1; } .abe-label { display:block; font-size:10.5px; font-weight:700; color:#6b7280; text-transform:uppercase; letter-spacing:.06em; margin-bottom:5px; } .abe-field select, .abe-field input[type=number], .abe-field input[type=text] { width:100%; box-sizing:border-box; padding:7px 10px; border:1.5px solid #d1d5db; border-radius:7px; font-size:13px; color:#1f2937; background:#f9fafb; outline:none; transition:border-color .15s; } .abe-field select:focus, .abe-field input[type=number]:focus, .abe-field input[type=text]:focus { border-color:#ff9900; background:#fff; } #abe-acct-hdr { display:flex; align-items:center; margin-bottom:6px; } #abe-acct-hdr .abe-label { margin:0; } #abe-acct-actions { margin-left:auto; display:flex; gap:10px; align-items:center; } .abe-link-btn { font-size:11px; color:#ff9900; cursor:pointer; text-decoration:underline; background:none; border:none; padding:0; font-family:inherit; } #abe-refresh-btn { color:#6b7280; } #abe-refresh-btn:hover { color:#ff9900; } #abe-acct-box { border:1.5px solid #e5e7eb; border-radius:9px; max-height:160px; overflow-y:auto; background:#f9fafb; margin-bottom:14px; } #abe-acct-box::-webkit-scrollbar { width:5px; } #abe-acct-box::-webkit-scrollbar-track { background:transparent; } #abe-acct-box::-webkit-scrollbar-thumb { background:#d1d5db; border-radius:99px; } .abe-acct { display:flex; align-items:center; gap:9px; padding:8px 12px; border-bottom:1px solid #f0f0f0; cursor:pointer; transition:background .1s; } .abe-acct:last-child { border-bottom:none; } .abe-acct:hover { background:#f0f4ff; } .abe-acct input[type=checkbox] { accent-color:#ff9900; width:14px; height:14px; flex-shrink:0; } .abe-acct-id { font-size:12px; font-weight:700; color:#1f2937; } .abe-acct-lbl { font-size:11px; color:#9ca3af; } .abe-acct-total { margin-left:auto; font-size:11px; font-weight:600; color:#374151; white-space:nowrap; } .abe-mgmt-badge { display:inline-block; margin-left:6px; font-size:9.5px; font-weight:700; letter-spacing:.04em; color:#7c3aed; background:#ede9fe; border:1px solid #c4b5fd; border-radius:4px; padding:1px 5px; vertical-align:middle; white-space:nowrap; } .abe-no-acct { padding:14px 12px; font-size:12px; color:#9ca3af; text-align:center; line-height:1.6; } .abe-no-acct a { color:#ff9900; text-decoration:none; cursor:pointer; } /* Scanning state */ .abe-scanning { padding:12px; display:flex; align-items:center; gap:8px; font-size:12px; color:#6b7280; } .abe-spinner { width:14px; height:14px; border:2px solid #e5e7eb; border-top-color:#ff9900; border-radius:50%; animation:abe-spin .7s linear infinite; flex-shrink:0; } @keyframes abe-spin { to { transform:rotate(360deg); } } #abe-export { width:100%; padding:11px; border:none; border-radius:8px; background:#ff9900; color:#fff; font-size:14px; font-weight:700; cursor:pointer; transition:background .18s, opacity .18s; } #abe-export:hover:not(:disabled) { background:#e68900; } #abe-export:disabled { opacity:.55; cursor:not-allowed; } #abe-status { margin-top:10px; font-size:12px; color:#6b7280; min-height:16px; text-align:center; } #abe-status.err { color:#dc2626; } #abe-status.ok { color:#16a34a; font-weight:700; } #abe-bar-wrap { margin-top:8px; background:#e5e7eb; border-radius:99px; height:5px; overflow:hidden; display:none; } #abe-bar { height:100%; background:#ff9900; border-radius:99px; width:0; transition:width .25s ease; } #abe-errors { margin-top:8px; font-size:11px; color:#dc2626; max-height:60px; overflow-y:auto; display:none; } `; document.head.appendChild(el); } // ─── UI ─────────────────────────────────────────────────────────────────────── let _panel = null; // panel DOM element reference function buildUI() { document.getElementById(CSS_ID)?.remove(); document.getElementById(UI_ID)?.remove(); injectCSS(); const now = new Date(); const curY = now.getFullYear(); const curM = now.getMonth() + 1; const defM = curM === 1 ? 12 : curM - 1; const defY = curM === 1 ? curY - 1 : curY; const MONTH_NAMES = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December' ]; const root = document.createElement('div'); root.id = UI_ID; const panel = document.createElement('div'); panel.id = 'abe-panel'; panel.innerHTML = `
📊

AWS Billing Exporter v${VERSION}

Multi-account · Direct API · CSV
Project Name (prefix)
Month
Year
Accounts
Scanning for accounts…
`; const fab = document.createElement('button'); fab.id = 'abe-fab'; fab.title = 'AWS Billing Exporter'; fab.innerHTML = '📥'; root.appendChild(panel); root.appendChild(fab); document.body.appendChild(root); _panel = panel; // ── Events ─────────────────────────────────────────────────────────────── // FAB: if not on billing page, redirect there (XSRF token only exists on /billing/). // Pass ?abe-open=1 so the panel auto-opens after redirect. // On the billing page: toggle panel normally. const _onBillingPage = window.location.pathname.startsWith('/billing'); let _panelEverOpened = false; fab.addEventListener('click', () => { if (!_onBillingPage) { window.location.href = window.location.origin + '/billing/home?abe-open=1#/bills'; return; } const isOpen = panel.classList.toggle('open'); if (isOpen && !_panelEverOpened) { _panelEverOpened = true; loadAccounts(panel); } }); panel.querySelector('#abe-hdr-close').addEventListener('click', () => panel.classList.remove('open')); // Refresh button: reload accounts for current month/year panel.querySelector('#abe-refresh-btn').addEventListener('click', () => loadAccounts(panel)); // Month / Year change: reload accounts with debounce (year needs typing time) let _debounceTimer = null; const onPeriodChange = () => { clearTimeout(_debounceTimer); _debounceTimer = setTimeout(() => loadAccounts(panel), 600); }; panel.querySelector('#abe-month').addEventListener('change', onPeriodChange); panel.querySelector('#abe-year').addEventListener('input', onPeriodChange); // Select/deselect all toggle panel.querySelector('#abe-sel-all').addEventListener('click', () => { const chks = [...panel.querySelectorAll('.abe-chk')]; const allTicked = chks.every(c => c.checked); chks.forEach(c => (c.checked = !allTicked)); panel.querySelector('#abe-sel-all').textContent = allTicked ? 'Select all' : 'Deselect all'; }); panel.querySelector('#abe-export').addEventListener('click', () => runExport(panel)); return panel; } // ─── ACCOUNT BOX RENDERER ──────────────────────────────────────────────────── function renderAccounts(panel, accounts) { const box = panel.querySelector('#abe-acct-box'); box.innerHTML = ''; if (accounts.length === 0) { box.innerHTML = `
No accounts found for this period.
Try a different month or click ↺ Refresh.
`; box.querySelector('#abe-inline-refresh')?.addEventListener('click', () => { loadAccounts(panel); }); return; } accounts.forEach(acc => { const row = document.createElement('label'); row.className = 'abe-acct'; const isMgmt = _managementAccountId && acc.id === _managementAccountId; const totalStr = acc.total !== undefined ? 'USD ' + acc.total.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : ''; row.innerHTML = `
${acc.id} ${isMgmt ? '★ Management' : ''}
${acc.label}
${totalStr ? `
${totalStr}
` : ''}`; box.appendChild(row); }); panel.querySelector('#abe-sel-all').textContent = 'Deselect all'; } // ─── ACCOUNT LOADER ────────────────────────────────────────────────────────── /** * Fetch linked accounts from the billing API for the currently selected * month/year, then render them into the panel. * Called on: FAB click, month change, year change, manual refresh. */ async function loadAccounts(panel) { const year = parseInt(panel.querySelector('#abe-year').value, 10); const month = parseInt(panel.querySelector('#abe-month').value, 10); if (isNaN(year) || year < 2020 || isNaN(month)) return; // Show loading spinner const box = panel.querySelector('#abe-acct-box'); box.innerHTML = `
Loading accounts for ${new Date(year, month-1).toLocaleString('en-US',{month:'long'})} ${year}…
`; // Reset select-all label panel.querySelector('#abe-sel-all').textContent = 'Select all'; try { // Run both concurrently; management account detection may arrive first or after const [accounts, mgmtId] = await Promise.all([ fetchLinkedAccounts(year, month), fetchManagementAccountId(), ]); if (mgmtId) _managementAccountId = mgmtId; const subtitle = panel.querySelector('#abe-subtitle'); if (accounts.length === 0) { // Single-account mode: no linked accounts — fetch current account identity const current = await fetchCurrentAccount(); if (current.id) { panel.dataset.singleAccountMode = '1'; panel.dataset.singleAccountId = current.id; panel.dataset.singleAccountLabel = current.label; renderAccounts(panel, [{ id: current.id, label: current.label }]); if (current.label && current.label !== current.id) { panel.querySelector('#abe-project').value = current.label.toUpperCase(); } if (subtitle) { subtitle.textContent = 'Single account mode · Direct API · CSV · ✓ Auth ready'; subtitle.style.color = '#6fcf97'; } } else { renderAccounts(panel, []); } } else { delete panel.dataset.singleAccountMode; renderAccounts(panel, accounts); // Auto-fill Project Name — prefer nav bar name, fallback to linked accounts label if (_managementAccountId) { const navName = scrapeNavAccountName(); if (navName) { panel.querySelector('#abe-project').value = navName.toUpperCase(); } else { const mgmtAcc = accounts.find(a => a.id === _managementAccountId); if (mgmtAcc?.label) { panel.querySelector('#abe-project').value = mgmtAcc.label.toUpperCase(); } } } if (subtitle) { subtitle.textContent = 'Multi-account · Direct API · CSV · ✓ Auth ready'; subtitle.style.color = '#6fcf97'; } } } catch (err) { box.innerHTML = `
Failed to load accounts: ${err.message}
↺ Try again
`; box.querySelector('#abe-inline-refresh')?.addEventListener('click', () => loadAccounts(panel)); } } // ─── EXPORT RUNNER ──────────────────────────────────────────────────────────── async function runExport(panel) { const btn = panel.querySelector('#abe-export'); const status = panel.querySelector('#abe-status'); const barWrap = panel.querySelector('#abe-bar-wrap'); const bar = panel.querySelector('#abe-bar'); const errBox = panel.querySelector('#abe-errors'); const year = parseInt(panel.querySelector('#abe-year').value, 10); const month = parseInt(panel.querySelector('#abe-month').value, 10); const selected = [...panel.querySelectorAll('.abe-chk:checked')]; if (isNaN(year) || year < 2020) { setStatus(status, 'Enter a valid year (2020 or later).', 'err'); return; } if (selected.length === 0) { setStatus(status, 'Select at least one account.', 'err'); return; } btn.disabled = true; errBox.style.display = 'none'; errBox.innerHTML = ''; barWrap.style.display = 'block'; bar.style.width = '0%'; setStatus(status, 'Starting…', ''); const allRows = []; const errors = []; const total = selected.length; for (let i = 0; i < total; i++) { const chk = selected[i]; const id = chk.value; const label = chk.dataset.label; setStatus(status, `Fetching ${id} (${label})… [${i+1} / ${total}]`, ''); try { const fetchId = panel.dataset.singleAccountMode === '1' ? null : id; const json = await fetchBill(fetchId, year, month); allRows.push(...parseRows(json, id, label, year, month)); } catch (err) { errors.push(`${id} (${label}): ${err.message}`); } bar.style.width = Math.round(((i + 1) / total) * 100) + '%'; } btn.disabled = false; if (allRows.length === 0) { setStatus(status, errors.length ? 'All fetches failed — see details below.' : 'No data returned from API.', 'err'); showErrors(errBox, errors); return; } const M = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC']; const project = (panel.querySelector('#abe-project')?.value || '').trim().toUpperCase(); const prefix = project ? `${project}-` : ''; const filename = `${prefix}aws-billing-${M[month-1]}${year}-${total}accounts.csv`; triggerDownload(buildCSV(allRows), filename); const errNote = errors.length ? ` (${errors.length} account${errors.length > 1 ? 's' : ''} failed)` : ''; setStatus(status, `✓ ${filename} — ${allRows.length.toLocaleString()} rows${errNote}`, 'ok'); if (errors.length) showErrors(errBox, errors); } // ─── HELPERS ────────────────────────────────────────────────────────────────── function setStatus(el, msg, cls) { el.className = cls; el.textContent = msg; } function showErrors(el, errors) { if (!errors.length) return; el.style.display = 'block'; el.innerHTML = errors.map(e => `• ${e}`).join('
'); } // ─── INIT ───────────────────────────────────────────────────────────────────── // ── UI only on billing pages; hooks above already run on all pages ────────── const ON_BILLING_PAGE = window.location.pathname.startsWith('/billing') || window.location.pathname.startsWith('/costmanagement'); function init() { if (!ON_BILLING_PAGE) return; const panel = buildUI(); // Auto-open if redirected here from another console page via ?abe-open=1 if (new URLSearchParams(window.location.search).get('abe-open') === '1') { panel.classList.add('open'); // Clean the param from the URL without a page reload (keep fragment) const url = new URL(window.location.href); url.searchParams.delete('abe-open'); history.replaceState(null, '', url.toString()); // Retry loading accounts every 1 s, max 5 attempts — billing page needs // a moment to initialise and capture the XSRF token after redirect. (async () => { for (let attempt = 1; attempt <= 5; attempt++) { await loadAccounts(panel); if (panel.querySelector('.abe-chk')) break; // accounts loaded OK if (attempt < 5) await new Promise(r => setTimeout(r, 1000)); } })(); } } // document-start: DOM may not exist yet — defer until body is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();