Files
aws-bill-to-csv/bill-to-csv.user.js
Syed 53cb1593fc ver 4.4
ver 4.4
2026-08-13 15:03:47 +08:00

1053 lines
43 KiB
JavaScript

// ==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: { "<payerId>": { 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 = `
<div id="abe-hdr">
<span id="abe-hdr-icon">📊</span>
<div id="abe-hdr-text">
<h3>AWS Billing Exporter
<span style="font-size:10px;opacity:.55;font-weight:400;margin-left:4px">v${VERSION}</span>
</h3>
<small id="abe-subtitle">Multi-account · Direct API · CSV</small>
</div>
<button id="abe-hdr-close" title="Close">✕</button>
</div>
<div id="abe-body">
<div class="abe-row">
<div class="abe-field" style="flex:2">
<span class="abe-label">Project Name <span style="font-weight:400;opacity:.6">(prefix)</span></span>
<input type="text" id="abe-project" placeholder="Auto-filled from management account…"
style="text-transform:uppercase">
</div>
</div>
<div class="abe-row">
<div class="abe-field">
<span class="abe-label">Month</span>
<select id="abe-month">
${MONTH_NAMES.map((n, i) =>
`<option value="${i+1}"${i+1 === defM ? ' selected':''}>${n}</option>`
).join('')}
</select>
</div>
<div class="abe-field">
<span class="abe-label">Year</span>
<input type="number" id="abe-year" value="${defY}" min="2020" max="${curY}">
</div>
</div>
<div id="abe-acct-hdr">
<span class="abe-label">Accounts</span>
<div id="abe-acct-actions">
<button class="abe-link-btn" id="abe-refresh-btn" title="Re-scan page for accounts">↺ Refresh</button>
<button class="abe-link-btn" id="abe-sel-all">Select all</button>
</div>
</div>
<div id="abe-acct-box">
<div class="abe-scanning">
<div class="abe-spinner"></div>
<span>Scanning for accounts…</span>
</div>
</div>
<button id="abe-export">⬇&nbsp;&nbsp;Export CSV</button>
<div id="abe-status"></div>
<div id="abe-bar-wrap"><div id="abe-bar"></div></div>
<div id="abe-errors"></div>
</div>
`;
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 = `
<div class="abe-no-acct">
No accounts found for this period.<br>
Try a different month or click <a id="abe-inline-refresh">↺ Refresh</a>.
</div>`;
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 = `
<input type="checkbox" class="abe-chk"
value="${acc.id}" data-label="${acc.label}" checked>
<div>
<div class="abe-acct-id">
${acc.id}
${isMgmt ? '<span class="abe-mgmt-badge">★ Management</span>' : ''}
</div>
<div class="abe-acct-lbl">${acc.label}</div>
</div>
${totalStr ? `<div class="abe-acct-total">${totalStr}</div>` : ''}`;
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 = `
<div class="abe-scanning">
<div class="abe-spinner"></div>
<span>Loading accounts for ${new Date(year, month-1).toLocaleString('en-US',{month:'long'})} ${year}…</span>
</div>`;
// 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 = `
<div class="abe-no-acct">
Failed to load accounts: ${err.message}<br>
<a id="abe-inline-refresh">↺ Try again</a>
</div>`;
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('<br>');
}
// ─── 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();
}
})();