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 {
| Count |
Term |
Upfront Cost |
- Monthly Cost |
+ ${escapeHtml(monthlyColHeader)} |
Monthly Savings |
Plan |
@@ -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 = `
@@ -1012,7 +1073,7 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void {
| Count |
Term |
Payment |
- Monthly Cost |
+ ${monthlyColHeader} |
Upfront Cost |
Monthly Savings |
Created by |
@@ -1025,5 +1086,8 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void {
`;
+ // 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);
+}