diff --git a/frontend/src/__tests__/allowed-accounts.test.ts b/frontend/src/__tests__/allowed-accounts.test.ts index 8592f77cc..20cddb667 100644 --- a/frontend/src/__tests__/allowed-accounts.test.ts +++ b/frontend/src/__tests__/allowed-accounts.test.ts @@ -70,6 +70,9 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); // --------------------------------------------------------------------------- diff --git a/frontend/src/__tests__/app.test.ts b/frontend/src/__tests__/app.test.ts index b1b63d41c..d7b9bca71 100644 --- a/frontend/src/__tests__/app.test.ts +++ b/frontend/src/__tests__/app.test.ts @@ -17,7 +17,10 @@ jest.mock('../state', () => ({ subscribeProvider: jest.fn(), subscribeAccount: jest.fn(), getCurrentProvider: jest.fn(() => ''), - getCurrentAccountIDs: jest.fn(() => []) + getCurrentAccountIDs: jest.fn(() => []), + getAmortizeUpfront: jest.fn(() => false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); jest.mock('../auth', () => ({ diff --git a/frontend/src/__tests__/approval-details.test.ts b/frontend/src/__tests__/approval-details.test.ts index b2e10f945..ca4d19e8f 100644 --- a/frontend/src/__tests__/approval-details.test.ts +++ b/frontend/src/__tests__/approval-details.test.ts @@ -7,6 +7,13 @@ * network helpers (those are covered by api-* tests). */ +// approval-details.ts now reads getAmortizeUpfront() from state at render +// time. Mock it to return false (default: non-amortized) so existing tests +// continue to verify the base column layout. +jest.mock('../state', () => ({ + getAmortizeUpfront: jest.fn(() => false), +})); + import { computeEffectiveSavingsPct, formatAccountLabel, @@ -82,13 +89,13 @@ describe('renderApprovalDetailsBody', () => { expect(text).toContain('Accounts'); }); - it('renders the per-rec table with all 12 columns', () => { + it('renders the per-rec table with all 13 columns (Monthly cost added by issue #1112)', () => { const rec = makeRec({}); const body = renderApprovalDetailsBody(makeDetails([rec]), new Map()); const headers = Array.from(body.querySelectorAll('.approval-details-table thead th')).map(th => th.textContent); expect(headers).toEqual([ 'Account', 'Provider', 'Service', 'Resource', 'Engine', 'Region', - 'Count', 'Term', 'Payment', 'Upfront', 'Monthly savings', 'Eff. savings %', + 'Count', 'Term', 'Payment', 'Upfront', 'Monthly cost', 'Monthly savings', 'Eff. savings %', ]); }); @@ -149,8 +156,9 @@ describe('renderApprovalDetailsBody', () => { const rec = makeRec({ upfront_cost: 4567.89, savings: 12.5 }); const body = renderApprovalDetailsBody(makeDetails([rec]), new Map()); const cells = body.querySelectorAll('.approval-details-table tbody td'); + // col 9 = Upfront, col 10 = Monthly cost (issue #1112), col 11 = Monthly savings expect(cells[9]?.textContent).toBe('$4,568'); - expect(cells[10]?.textContent).toBe('$13'); + expect(cells[11]?.textContent).toBe('$13'); }); it('computes effective savings % when on_demand_cost is set, "—" otherwise', () => { @@ -158,8 +166,9 @@ describe('renderApprovalDetailsBody', () => { const withoutBaseline = makeRec({ id: 'rec-2', savings: 30, on_demand_cost: null, monthly_cost: null }); const body = renderApprovalDetailsBody(makeDetails([withBaseline, withoutBaseline]), new Map()); const rows = body.querySelectorAll('.approval-details-table tbody tr'); - expect(rows[0]?.querySelectorAll('td')[11]?.textContent).toBe('30.0%'); - expect(rows[1]?.querySelectorAll('td')[11]?.textContent).toBe('—'); + // col 12 = Eff. savings % (shifted by +1 due to the new Monthly cost col at index 10) + expect(rows[0]?.querySelectorAll('td')[12]?.textContent).toBe('30.0%'); + expect(rows[1]?.querySelectorAll('td')[12]?.textContent).toBe('—'); }); it('annual-savings tooltip does NOT fire on floating-point rounding noise', () => { diff --git a/frontend/src/__tests__/history-approval-queue.test.ts b/frontend/src/__tests__/history-approval-queue.test.ts index a929062bf..3bc03f3ba 100644 --- a/frontend/src/__tests__/history-approval-queue.test.ts +++ b/frontend/src/__tests__/history-approval-queue.test.ts @@ -60,6 +60,9 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); jest.mock('../recommendations', () => ({ diff --git a/frontend/src/__tests__/history-approve-button.test.ts b/frontend/src/__tests__/history-approve-button.test.ts index 224becfeb..e0016c8b8 100644 --- a/frontend/src/__tests__/history-approve-button.test.ts +++ b/frontend/src/__tests__/history-approve-button.test.ts @@ -61,6 +61,9 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); import * as api from '../api'; diff --git a/frontend/src/__tests__/history-cancel-button.test.ts b/frontend/src/__tests__/history-cancel-button.test.ts index 0ff3bbefb..492bf47dd 100644 --- a/frontend/src/__tests__/history-cancel-button.test.ts +++ b/frontend/src/__tests__/history-cancel-button.test.ts @@ -58,6 +58,9 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); import * as api from '../api'; diff --git a/frontend/src/__tests__/history-cancel-permissions.test.ts b/frontend/src/__tests__/history-cancel-permissions.test.ts index b8dee68da..515f355d3 100644 --- a/frontend/src/__tests__/history-cancel-permissions.test.ts +++ b/frontend/src/__tests__/history-cancel-permissions.test.ts @@ -57,6 +57,9 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); // Mock permissions so we can inject arbitrary permission sets, including diff --git a/frontend/src/__tests__/history-retry-button.test.ts b/frontend/src/__tests__/history-retry-button.test.ts index d44506f77..3e113e7af 100644 --- a/frontend/src/__tests__/history-retry-button.test.ts +++ b/frontend/src/__tests__/history-retry-button.test.ts @@ -67,6 +67,9 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); import * as api from '../api'; diff --git a/frontend/src/__tests__/history.test.ts b/frontend/src/__tests__/history.test.ts index 6a6cbc0f5..f0e98a7b0 100644 --- a/frontend/src/__tests__/history.test.ts +++ b/frontend/src/__tests__/history.test.ts @@ -41,6 +41,9 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); import * as api from '../api'; diff --git a/frontend/src/__tests__/inventory.test.ts b/frontend/src/__tests__/inventory.test.ts index 532b69f25..756f4a364 100644 --- a/frontend/src/__tests__/inventory.test.ts +++ b/frontend/src/__tests__/inventory.test.ts @@ -32,6 +32,9 @@ jest.mock('../state', () => ({ subscribeAccount: jest.fn(() => jest.fn()), getCurrentProvider: jest.fn(() => ''), getCurrentAccountIDs: jest.fn(() => []), + getAmortizeUpfront: jest.fn(() => false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn(() => jest.fn()), })); // inventory.ts routes sub-nav clicks through navigation.switchInventorySubTab diff --git a/frontend/src/__tests__/utils.test.ts b/frontend/src/__tests__/utils.test.ts index f2042cee3..ed58dec6f 100644 --- a/frontend/src/__tests__/utils.test.ts +++ b/frontend/src/__tests__/utils.test.ts @@ -20,7 +20,8 @@ import { getStatusBadge, calculatePaybackMonths, providerBadgeClass, - providerBadgeHtml + providerBadgeHtml, + amortizedMonthly, } from '../utils'; describe('formatCurrency', () => { @@ -539,3 +540,48 @@ describe('formatCurrency (11-N2: absent vs real zero)', () => { expect(formatCurrency(0)).toBe('$0'); }); }); + +describe('amortizedMonthly', () => { + test('All Upfront: zero recurring cost produces positive amortized value', () => { + // $0/mo recurring + $1200 upfront over 1 year = $100/mo amortized + expect(amortizedMonthly(0, 1200, 1)).toBeCloseTo(100, 5); + }); + + test('All Upfront: 3-year term spreads upfront over 36 months', () => { + // $0/mo recurring + $3600 upfront over 3 years = $100/mo amortized + expect(amortizedMonthly(0, 3600, 3)).toBeCloseTo(100, 5); + }); + + test('Partial Upfront: recurring + amortized-upfront slice', () => { + // $50/mo recurring + $600 upfront over 1 year = $50 + $50 = $100/mo + expect(amortizedMonthly(50, 600, 1)).toBeCloseTo(100, 5); + }); + + test('No Upfront (upfront === 0): result equals monthlyCost unchanged', () => { + expect(amortizedMonthly(80, 0, 1)).toBeCloseTo(80, 5); + expect(amortizedMonthly(80, 0, 3)).toBeCloseTo(80, 5); + }); + + test('term <= 0: returns monthlyCost unchanged (guard against divide-by-zero)', () => { + expect(amortizedMonthly(50, 600, 0)).toBe(50); + expect(amortizedMonthly(50, 600, -1)).toBe(50); + }); + + test('non-finite term: returns monthlyCost unchanged', () => { + expect(amortizedMonthly(50, 600, Infinity)).toBe(50); + expect(amortizedMonthly(50, 600, NaN)).toBe(50); + }); + + test('null upfrontCost: returns monthlyCost unchanged', () => { + expect(amortizedMonthly(80, null, 1)).toBe(80); + }); + + test('undefined upfrontCost: returns monthlyCost unchanged', () => { + expect(amortizedMonthly(80, undefined, 1)).toBe(80); + }); + + test('non-finite upfrontCost: returns monthlyCost unchanged', () => { + expect(amortizedMonthly(80, Infinity, 1)).toBe(80); + expect(amortizedMonthly(80, NaN, 1)).toBe(80); + }); +}); diff --git a/frontend/src/__tests__/xss-provider-class.test.ts b/frontend/src/__tests__/xss-provider-class.test.ts index ee6455114..3dad85b7b 100644 --- a/frontend/src/__tests__/xss-provider-class.test.ts +++ b/frontend/src/__tests__/xss-provider-class.test.ts @@ -43,6 +43,9 @@ jest.mock('../state', () => ({ setCurrentAccountIDs: jest.fn(), subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + getAmortizeUpfront: jest.fn().mockReturnValue(false), + setAmortizeUpfront: jest.fn(), + subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}), })); import * as api from '../api'; diff --git a/frontend/src/approval-details.ts b/frontend/src/approval-details.ts index ec87c97a3..b64010e53 100644 --- a/frontend/src/approval-details.ts +++ b/frontend/src/approval-details.ts @@ -29,7 +29,8 @@ import * as api from './api'; import type { CloudAccount } from './api/accounts'; import type { PurchaseDetails, Recommendation } from './api/types'; -import { escapeHtml, formatCurrency, formatTerm } from './utils'; +import { escapeHtml, formatCurrency, formatTerm, amortizedMonthly } from './utils'; +import { getAmortizeUpfront } from './state'; /** * accountsById maps the internal CloudAccount UUID (the value carried @@ -169,22 +170,31 @@ function renderApprovalDetailsTable(recs: Recommendation[], accountsById: Accoun const table = document.createElement('table'); table.className = 'approval-details-table'; + const amortize = getAmortizeUpfront(); const thead = document.createElement('thead'); - thead.innerHTML = ` - - Account - Provider - Service - Resource - Engine - Region - Count - Term - Payment - Upfront - Monthly savings - Eff. savings % - `; + const headerRow = document.createElement('tr'); + const headerCols: Array<{ label: string; numeric?: true }> = [ + { label: 'Account' }, + { label: 'Provider' }, + { label: 'Service' }, + { label: 'Resource' }, + { label: 'Engine' }, + { label: 'Region' }, + { label: 'Count', numeric: true }, + { label: 'Term' }, + { label: 'Payment' }, + { label: 'Upfront', numeric: true }, + { label: amortize ? 'Monthly cost (amortized)' : 'Monthly cost', numeric: true }, + { label: 'Monthly savings', numeric: true }, + { label: 'Eff. savings %', numeric: true }, + ]; + for (const col of headerCols) { + const th = document.createElement('th'); + th.textContent = col.label; + if (col.numeric) th.className = 'num'; + headerRow.appendChild(th); + } + thead.appendChild(headerRow); table.appendChild(thead); const tbody = document.createElement('tbody'); @@ -214,9 +224,24 @@ function renderRecRow(rec: Recommendation, accountsById: AccountsById, hostAWSAc // both `undefined` and "" are falsy so the single check is enough. const engineLabel = rec.engine ? rec.engine : '—'; const effSavings = computeEffectiveSavingsPct(rec); + + // Compute the monthly cost cell value (issue #1112). When monthly_cost + // is null the provider API did not return a breakdown; render "—". + // When amortize is on, fold the upfront slice in. + const amortize = getAmortizeUpfront(); + let monthlyCostDisplay: string; + if (rec.monthly_cost == null) { + monthlyCostDisplay = '—'; + } else { + const displayVal = amortize + ? amortizedMonthly(rec.monthly_cost, rec.upfront_cost ?? 0, rec.term) + : rec.monthly_cost; + monthlyCostDisplay = formatCurrency(displayVal); + } + // innerHTML is safe here because every interpolated value goes // through escapeHtml or is a numeric/preformatted constant. Using - // innerHTML rather than 12 createElement calls per row keeps the + // innerHTML rather than 13 createElement calls per row keeps the // render code readable for the table layout, mirroring the // recommendations.ts pattern. row.innerHTML = ` @@ -230,6 +255,7 @@ function renderRecRow(rec: Recommendation, accountsById: AccountsById, hostAWSAc ${escapeHtml(formatTerm(rec.term))} ${escapeHtml(rec.payment ?? '')} ${escapeHtml(formatCurrency(rec.upfront_cost ?? null))} + ${escapeHtml(monthlyCostDisplay)} ${escapeHtml(formatCurrency(rec.savings ?? null))} ${effSavings === null ? '—' : escapeHtml(`${effSavings.toFixed(1)}%`)}`; return row; diff --git a/frontend/src/history.ts b/frontend/src/history.ts index c4990c050..1ecd062dd 100644 --- a/frontend/src/history.ts +++ b/frontend/src/history.ts @@ -4,7 +4,7 @@ import * as api from './api'; import * as state from './state'; -import { formatCurrency, formatDate, formatTerm, escapeHtml, escapeHtmlAttr } from './utils'; +import { formatCurrency, formatDate, formatTerm, escapeHtml, escapeHtmlAttr, amortizedMonthly } from './utils'; import type { HistoryResponse, HistorySummary, HistoryPurchase } from './types'; import { switchTab } from './navigation'; import { confirmDialog } from './confirmDialog'; @@ -113,6 +113,48 @@ export function applyExecutionDeepLink(): boolean { export function setupHistoryHandlers(): void { state.subscribeProvider(() => void loadHistory()); state.subscribeAccount(() => void loadHistory()); + // Re-render both tables when the amortize toggle flips (issue #1112). + state.subscribeAmortizeUpfront(() => { + renderHistoryList(lastPurchases); + renderApprovalQueue(lastPurchases); + syncAmortizeCheckbox('history-amortize-checkbox'); + syncAmortizeCheckbox('approval-queue-amortize-checkbox'); + }); +} + +/** + * Mount the "Amortize upfront over term" checkbox into a container element + * (idempotent -- safe to call on every loadHistory). + * + * The checkbox is wired to setAmortizeUpfront so a change here is reflected + * in all other views via the shared localStorage key + subscriber pattern. + */ +function mountAmortizeCheckbox(containerId: string, checkboxId: string): void { + const container = document.getElementById(containerId); + if (!container) return; + if (document.getElementById(checkboxId)) return; // already mounted + + const wrapper = document.createElement('label'); + wrapper.className = 'amortize-toggle-label'; + wrapper.htmlFor = checkboxId; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.id = checkboxId; + cb.checked = state.getAmortizeUpfront(); + cb.addEventListener('change', () => { + state.setAmortizeUpfront(cb.checked); + }); + + wrapper.appendChild(cb); + wrapper.appendChild(document.createTextNode(' Amortize upfront over term')); + container.appendChild(wrapper); +} + +/** Keep an already-mounted checkbox in sync when state changes externally. */ +function syncAmortizeCheckbox(checkboxId: string): void { + const cb = document.getElementById(checkboxId) as HTMLInputElement | null; + if (cb) cb.checked = state.getAmortizeUpfront(); } /** @@ -683,8 +725,13 @@ function renderHistoryList(purchases: HistoryPurchase[]): void { })(); const execIdAttr = p.purchase_id ? ` data-execution-id="${escapeHtmlAttr(p.purchase_id)}"` : ''; const planCellContent = renderActionCell(p); - const monthlyCostCell = p.monthly_cost != null - ? formatCurrency(p.monthly_cost) + const amortize = state.getAmortizeUpfront(); + const rawMonthly = p.monthly_cost != null ? p.monthly_cost : null; + const displayMonthly = (rawMonthly != null && amortize) + ? amortizedMonthly(rawMonthly, p.upfront_cost, p.term) + : rawMonthly; + const monthlyCostCell = displayMonthly != null + ? formatCurrency(displayMonthly) : '-'; return ` @@ -704,6 +751,8 @@ function renderHistoryList(purchases: HistoryPurchase[]): void { `; }).join(''); + const amortize = state.getAmortizeUpfront(); + const monthlyColHeader = amortize ? 'Monthly Cost (amortized)' : 'Monthly Cost'; const markup = ` ${buildStatusChipRowHTML(purchases, activeStatusFilter)} @@ -718,7 +767,7 @@ function renderHistoryList(purchases: HistoryPurchase[]): void { - + @@ -730,6 +779,9 @@ function renderHistoryList(purchases: HistoryPurchase[]): void { `; container.innerHTML = markup; + // Mount the amortize checkbox into the controls area (idempotent). + mountAmortizeCheckbox('history-controls', 'history-amortize-checkbox'); + container.querySelectorAll('.status-chip[data-history-status]').forEach(btn => { btn.addEventListener('click', () => { const next = btn.dataset['historyStatus'] as StatusFilter | undefined; @@ -979,8 +1031,13 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void { : '-'; const termCell = p.term ? escapeHtml(formatTerm(p.term)) : '-'; const paymentCell = p.payment ? escapeHtml(p.payment) : '-'; - const monthlyCostCell = p.monthly_cost != null - ? formatCurrency(p.monthly_cost) + const amortize = state.getAmortizeUpfront(); + const rawMonthly = p.monthly_cost != null ? p.monthly_cost : null; + const displayMonthly = (rawMonthly != null && amortize) + ? amortizedMonthly(rawMonthly, p.upfront_cost, p.term) + : rawMonthly; + const monthlyCostCell = displayMonthly != null + ? formatCurrency(displayMonthly) : '-'; const execIdAttr = p.purchase_id ? ` data-execution-id="${escapeHtmlAttr(p.purchase_id)}"` : ''; return ` @@ -1001,6 +1058,10 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void { `; }).join(''); + const amortize = state.getAmortizeUpfront(); + const monthlyColHeader = amortize ? 'Monthly Cost (amortized)' : 'Monthly Cost'; + // monthlyColHeader is a hardcoded constant string (no user data), so + // interpolating it directly into the template is safe. container.innerHTML = `
Count Term Upfront CostMonthly Cost${escapeHtml(monthlyColHeader)} Monthly Savings Plan
@@ -1012,7 +1073,7 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void { - + @@ -1025,5 +1086,8 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void {
Count Term PaymentMonthly Cost${monthlyColHeader} Upfront Cost Monthly Savings Created by
`; + // Mount the amortize checkbox into the approval queue section (idempotent). + mountAmortizeCheckbox('purchases-approval-queue-section', 'approval-queue-amortize-checkbox'); + wireRowActionHandlers(container); } diff --git a/frontend/src/inventory.ts b/frontend/src/inventory.ts index 516896d47..061dae1bf 100644 --- a/frontend/src/inventory.ts +++ b/frontend/src/inventory.ts @@ -14,7 +14,7 @@ import * as api from './api'; import type { ProviderCoverageSection, CoverageServiceRow } from './api'; import { loadRIExchange } from './riexchange'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; -import { formatCurrency, formatDate } from './utils'; +import { formatCurrency, formatDate, amortizedMonthly } from './utils'; import * as state from './state'; import { switchInventorySubTab } from './navigation'; @@ -104,6 +104,7 @@ export async function loadActiveCommitments(): Promise { if (!container) return; wireRefreshButton(); + wireAmortizeSubscription(); const provider = state.getCurrentProvider(); const accountIDs = state.getCurrentAccountIDs(); @@ -117,6 +118,10 @@ export async function loadActiveCommitments(): Promise { try { const commitments = await api.listActiveCommitments({ provider: provider || undefined, accountID }); + // Cache for amortize-toggle re-renders (issue #1112). + lastCommitments = commitments; + lastCommitmentsProvider = provider || undefined; + lastCommitmentsAccountID = accountID; renderActiveCommitmentsTable(container, commitments, provider, accountID); } catch (error) { teardownSkeleton(container); @@ -227,7 +232,9 @@ function renderActiveCommitmentsTable( const thead = document.createElement('thead'); const headerRow = document.createElement('tr'); - const headers = ['Provider', 'Account', 'Service', 'Resource type', 'Region', 'Count', 'Term', 'Payment', 'Monthly cost', 'Monthly savings', 'Expires']; + const amortize = state.getAmortizeUpfront(); + const monthlyLabel = amortize ? 'Monthly cost (amortized)' : 'Monthly cost'; + const headers = ['Provider', 'Account', 'Service', 'Resource type', 'Region', 'Count', 'Term', 'Payment', monthlyLabel, 'Monthly savings', 'Expires']; for (const label of headers) { const th = document.createElement('th'); th.textContent = label; @@ -243,6 +250,9 @@ function renderActiveCommitmentsTable( table.appendChild(tbody); container.appendChild(table); + + // Mount the amortize toggle into the section-header-actions area (idempotent). + mountInventoryAmortizeCheckbox(); } function buildCommitmentRow(c: api.InventoryCommitment): HTMLTableRowElement { @@ -256,7 +266,15 @@ function buildCommitmentRow(c: api.InventoryCommitment): HTMLTableRowElement { appendCell(tr, String(c.count)); appendCell(tr, `${c.term_years}y`); appendCell(tr, c.payment_option ?? ''); - appendCell(tr, c.monthly_cost != null ? formatCurrency(c.monthly_cost) : '—'); + + // When amortize is on, fold the upfront cost over the term years. + const amortize = state.getAmortizeUpfront(); + let displayMonthly: number | null = c.monthly_cost; + if (displayMonthly != null && amortize) { + displayMonthly = amortizedMonthly(displayMonthly, c.upfront_cost, c.term_years); + } + appendCell(tr, displayMonthly != null ? formatCurrency(displayMonthly) : '—'); + appendCell(tr, formatCurrency(c.estimated_savings)); appendCell(tr, formatDate(c.end_date)); @@ -269,6 +287,39 @@ function appendCell(tr: HTMLTableRowElement, text: string): void { tr.appendChild(td); } +/** + * Mount the "Amortize upfront over term" checkbox into the active-commitments + * section-header-actions area (idempotent). Wires to setAmortizeUpfront so + * the same localStorage key is shared with all other views (issue #1112). + */ +function mountInventoryAmortizeCheckbox(): void { + const actions = document.querySelector('#inventory-active-commitments .section-header-actions'); + if (!actions) return; + const checkboxId = 'inventory-amortize-checkbox'; + if (document.getElementById(checkboxId)) { + // Already mounted -- sync checked state in case another view changed it. + const cb = document.getElementById(checkboxId) as HTMLInputElement; + cb.checked = state.getAmortizeUpfront(); + return; + } + + const wrapper = document.createElement('label'); + wrapper.className = 'amortize-toggle-label'; + wrapper.htmlFor = checkboxId; + + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.id = checkboxId; + cb.checked = state.getAmortizeUpfront(); + cb.addEventListener('change', () => { + state.setAmortizeUpfront(cb.checked); + }); + + wrapper.appendChild(cb); + wrapper.appendChild(document.createTextNode(' Amortize upfront over term')); + actions.appendChild(wrapper); +} + function buildAccountCell(c: api.InventoryCommitment): HTMLTableCellElement { const td = document.createElement('td'); if (c.account_name) { @@ -489,10 +540,35 @@ function isInventoryTabActive(): boolean { // Unsubscribe handles for the chip subscriptions. Re-assigned each time // loadInventory() wires them so repeated tab-switches don't stack duplicate -// listeners — the old pair is torn down before a new pair is registered. +// listeners -- the old pair is torn down before a new pair is registered. let unsubscribeProvider: (() => void) | null = null; let unsubscribeAccount: (() => void) | null = null; +// Cache of the last-fetched commitments so the amortize toggle can +// re-render without a round-trip to the API (issue #1112). Also caches +// the provider/accountID context so the empty-state message stays accurate. +let lastCommitments: api.InventoryCommitment[] | null = null; +let lastCommitmentsProvider: string | undefined; +let lastCommitmentsAccountID: string | undefined; + +// Wired once; tracks whether the amortize subscriber has been registered +// for this module so repeated loadInventory() calls don't stack listeners. +let amortizeUnsubscribe: (() => void) | null = null; + +function wireAmortizeSubscription(): void { + if (amortizeUnsubscribe) return; // already wired + amortizeUnsubscribe = state.subscribeAmortizeUpfront(() => { + const container = document.getElementById(ACTIVE_COMMITMENTS_LIST_ID); + if (!container || lastCommitments === null) return; + renderActiveCommitmentsTable( + container, + lastCommitments, + lastCommitmentsProvider, + lastCommitmentsAccountID, + ); + }); +} + /** * Wire provider + account chip subscriptions (issue #866). * diff --git a/frontend/src/state.ts b/frontend/src/state.ts index b7575a86d..6e91feb95 100644 --- a/frontend/src/state.ts +++ b/frontend/src/state.ts @@ -358,3 +358,45 @@ export function setPlansColumnFilter( export function clearAllPlansColumnFilters(): void { plansColumnFilters = {}; } + +// --------------------------------------------------------------------------- +// Amortize-upfront toggle (issue #1112). +// Persisted in localStorage('cudly.amortizeUpfront'). In-memory fallback +// when localStorage is unavailable (private browsing, quota-exceeded). +// Subscribers are notified on every change so all views re-render in sync. +// --------------------------------------------------------------------------- + +const AMORTIZE_UPFRONT_LS_KEY = 'cudly.amortizeUpfront'; + +let amortizeUpfrontMemory = false; + +export function getAmortizeUpfront(): boolean { + try { + const raw = localStorage.getItem(AMORTIZE_UPFRONT_LS_KEY); + if (raw === null) return amortizeUpfrontMemory; + amortizeUpfrontMemory = raw === 'true'; + return amortizeUpfrontMemory; + } catch { + // localStorage unavailable (private browsing, iframe sandbox) -- use memory. + } + return amortizeUpfrontMemory; +} + +export function setAmortizeUpfront(value: boolean): void { + amortizeUpfrontMemory = value; + try { + localStorage.setItem(AMORTIZE_UPFRONT_LS_KEY, String(value)); + } catch { + // Non-fatal; in-memory fallback remains correct for the session. + } + amortizeListeners.forEach((cb) => { + try { cb(); } catch (err) { console.warn('subscribeAmortizeUpfront listener error:', err); } + }); +} + +const amortizeListeners: Set<() => void> = new Set(); + +export function subscribeAmortizeUpfront(cb: () => void): () => void { + amortizeListeners.add(cb); + return () => amortizeListeners.delete(cb); +} diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 766c8fb45..4870da2bf 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -341,3 +341,29 @@ export function providerBadgeHtml(provider: string | null | undefined): string { const label = escapeHtml((provider || '').toUpperCase()); return `${label}`; } + +/** + * Compute the amortized monthly cost: the recurring monthly cost plus the + * upfront cost spread evenly over the term. + * + * Used when the "Amortize upfront over term" toggle is enabled, so every + * commitment type (No Upfront, Partial Upfront, All Upfront) is compared + * on an apples-to-apples total-cost-per-month basis. + * + * Guard rules (return monthlyCost unchanged): + * - termYears <= 0 or not finite: cannot divide by zero / infinity. + * - upfrontCost is null, undefined, or not a finite number: no upfront data. + * + * No Upfront (upfrontCost === 0): amortized term is 0, result equals monthlyCost. + * Partial Upfront: result is monthlyCost + upfrontCost / (termYears * 12). + * All Upfront (monthlyCost === 0): result is just the amortized upfront slice. + */ +export function amortizedMonthly( + monthlyCost: number, + upfrontCost: number | null | undefined, + termYears: number, +): number { + if (!termYears || !isFinite(termYears) || termYears <= 0) return monthlyCost; + if (upfrontCost == null || !isFinite(upfrontCost)) return monthlyCost; + return monthlyCost + upfrontCost / (termYears * 12); +}