From 53cb1593fcd512d1727e32ce7fd16cac5916a9e7 Mon Sep 17 00:00:00 2001 From: Syed Date: Thu, 13 Aug 2026 15:03:47 +0800 Subject: [PATCH] ver 4.4 ver 4.4 --- bill-to-csv.user.js | 1053 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1053 insertions(+) create mode 100644 bill-to-csv.user.js diff --git a/bill-to-csv.user.js b/bill-to-csv.user.js new file mode 100644 index 0000000..63aaebc --- /dev/null +++ b/bill-to-csv.user.js @@ -0,0 +1,1053 @@ +// ==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(); + } + +})(); \ No newline at end of file