Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/__tests__/history-column-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ const rows: HistoryPurchase[] = [
];

describe('applyPurchaseHistoryColumnFilters', () => {
test('unknown upfront cost does not match a known-zero filter', () => {
const out = applyPurchaseHistoryColumnFilters([
mkRow({ purchase_id: 'unknown', upfront_cost: null }),
mkRow({ purchase_id: 'zero', upfront_cost: 0 }),
], { upfront_cost: { kind: 'expr', expr: '0' } });
expect(out.map(row => row.purchase_id)).toEqual(['zero']);
});
test('numeric expr: savings > 100 narrows to high-saving rows', () => {
const filters: PurchaseHistoryColumnFilters = {
savings: { kind: 'expr', expr: '>100' },
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/__tests__/history-marketplace-sell-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ function sellIds(): (string | undefined)[] {
}

describe('History inline Sell on Marketplace button (issue #292)', () => {
test('unknown upfront disables listing without showing a fabricated price', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({ summary: {}, purchases: [makeRow({ purchase_id: 'ri-unknown-cost', upfront_cost: null })] });
await loadHistory();
const button = document.querySelector<HTMLButtonElement>('.history-marketplace-sell-btn');
expect(button).not.toBeNull();
button?.click();
expect(button?.disabled).toBe(true);
expect(api.createMarketplaceListing).not.toHaveBeenCalled();
expect(confirmDialog).not.toHaveBeenCalled();
});
beforeEach(() => {
setupDOM();
jest.clearAllMocks();
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/__tests__/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jest.mock('../state', () => ({
}));

import * as api from '../api';
import { getAmortizeUpfront } from '../state';
import { switchTab } from '../navigation';
import { canAccess } from '../permissions';

Expand All @@ -82,6 +83,26 @@ describe('History Module', () => {
jest.clearAllMocks();
mockGetCurrentProvider.mockReturnValue('all');
mockGetCurrentAccountIDs.mockReturnValue([]);
(getAmortizeUpfront as jest.Mock).mockReturnValue(false);
});

test.each([false, true])('unknown upfront with amortization %s preserves recurring versus effective cost', async (amortize) => {
(getAmortizeUpfront as jest.Mock).mockReturnValue(amortize);
const purchase = {
timestamp: '2026-09-29T00:00:00Z', provider: 'aws', service: 'ec2', resource_type: 'm5.large',
region: 'us-east-1', count: 1, term: 1, upfront_cost: null, monthly_cost: 50, estimated_savings: 10,
};
(api.getHistory as jest.Mock).mockResolvedValue({ summary: { total_upfront: null }, purchases: [
{ ...purchase, purchase_id: 'complete', status: 'completed' },
{ ...purchase, purchase_id: 'pending', status: 'pending' },
] });
await loadHistory();
const history = document.querySelector('#history-list tr[data-execution-id="complete"]');
const queue = document.querySelector('#purchases-approval-queue tr[data-execution-id="pending"]');
expect(history?.querySelectorAll('td')[8]?.textContent).toBe('--');
expect(history?.querySelectorAll('td')[9]?.textContent).toBe(amortize ? '-' : '$50');
expect(queue?.querySelectorAll('td')[7]?.textContent).toBe(amortize ? '-' : '$50');
expect(queue?.querySelectorAll('td')[8]?.textContent).toBe('--');
});

describe('initHistoryDateRange', () => {
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/__tests__/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,21 @@ function clearDOM(): void {
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
}

test.each([false, true])('inventory unknown upfront with amortization %s', async (amortize) => {
clearDOM();
buildInventoryDOM();
(state.getAmortizeUpfront as jest.Mock).mockReturnValue(amortize);
(api.listActiveCommitments as jest.Mock).mockResolvedValue([makeCommitment({ upfront_cost: null, monthly_cost: 50 })]);
try {
await loadActiveCommitments();
const cell = document.querySelector('#active-commitments-list tbody tr')?.querySelectorAll('td')[8];
expect(cell?.textContent).toBe(amortize ? '\u2014' : '$50');
} finally {
(state.getAmortizeUpfront as jest.Mock).mockReturnValue(false);
clearDOM();
}
});

describe('Inventory & Coverage sub-section switching', () => {
beforeEach(() => {
buildInventoryDOM();
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export interface PurchaseHistory {
count: number;
term: number;
payment: string;
upfront_cost: number;
upfront_cost: number | null;
monthly_cost: number | null;
estimated_savings: number;
plan_id?: string;
Expand Down Expand Up @@ -663,7 +663,7 @@ export interface SavingsAnalyticsSummary {
export interface SavingsDataPoint {
timestamp: string;
total_savings: number;
total_upfront: number;
total_upfront: number | null;
purchase_count: number;
cumulative_savings: number;
by_service?: Record<string, number>;
Expand All @@ -679,7 +679,7 @@ export interface SavingsBreakdownResponse {

export interface SavingsBreakdownValue {
total_savings: number;
total_upfront: number;
total_upfront: number | null;
purchase_count: number;
percentage: number;
}
Expand Down Expand Up @@ -876,7 +876,7 @@ export interface InventoryCommitment {
payment_option?: string;
start_date: string;
end_date: string;
upfront_cost: number;
upfront_cost: number | null;
/** null when the provider API did not return a monthly recurring breakdown; "$X.XX" when populated, "—" when null. */
monthly_cost: number | null;
estimated_savings: number;
Expand All @@ -897,7 +897,7 @@ export interface InventoryCommitment {
*/
export interface CoverageServiceRow {
service: string;
covered_monthly: number;
covered_monthly: number | null;
on_demand_monthly: number;
coverage_pct: number | null;
}
Expand Down
16 changes: 11 additions & 5 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,7 @@ function purchaseHistoryNumericCellValue(
): number {
switch (col) {
case 'count': return p.count ?? 0;
case 'upfront_cost': return p.upfront_cost ?? 0;
case 'upfront_cost': return p.upfront_cost ?? Number.NaN;
case 'savings': return p.estimated_savings ?? 0;
case 'provider':
case 'service':
Expand Down Expand Up @@ -1149,7 +1149,7 @@ function renderHistoryList(purchases: HistoryPurchase[]): void {
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)
? (p.upfront_cost == null ? null : amortizedMonthly(rawMonthly, p.upfront_cost, p.term))
: rawMonthly;
const monthlyCostCell = displayMonthly != null
? formatCurrency(displayMonthly)
Expand Down Expand Up @@ -1471,6 +1471,12 @@ function wireRowActionHandlers(container: HTMLElement): void {
// Look up the purchase record so we can show a meaningful price summary.
const purchase = lastPurchases.find(p => p.purchase_id === id);

if (!purchase || purchase.upfront_cost == null) {
btn.disabled = true;
showToast({ message: 'Upfront cost is unknown; marketplace pricing is unavailable.', kind: 'error' });
return;
}

// Build a pricing modal body with RI summary and fee breakdown.
const bodyEl = document.createElement('div');
bodyEl.className = 'marketplace-pricing-modal-body';
Expand All @@ -1491,7 +1497,7 @@ function wireRowActionHandlers(container: HTMLElement): void {
// EXACTLY: floor (not round), floored at 1 (not 0), so the number
// shown here is the same number the backend actually lists at.
const remainingMonths = Math.max(1, Math.floor(termMonths - elapsedMonths));
const upfront = purchase.upfront_cost ?? 0;
const upfront = purchase.upfront_cost;
const count = purchase.count > 0 ? purchase.count : 1;
// Mirror marketplaceResidualPerUnit + resolveMarketplacePriceSchedule's
// default branch in internal/api/handler_marketplace.go EXACTLY, so
Expand Down Expand Up @@ -1689,7 +1695,7 @@ function approvalQueueNumericCellValue(
// Return NaN for null monthly_cost so numeric predicates (e.g. "= 0")
// don't match rows where the provider didn't report a monthly cost.
case 'monthly_cost': return p.monthly_cost == null ? Number.NaN : p.monthly_cost;
case 'upfront_cost': return p.upfront_cost ?? 0;
case 'upfront_cost': return p.upfront_cost ?? Number.NaN;
case 'savings': return p.estimated_savings ?? 0;
case 'provider':
case 'account':
Expand Down Expand Up @@ -1814,7 +1820,7 @@ export function renderApprovalQueue(purchases: HistoryPurchase[]): void {
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)
? (p.upfront_cost == null ? null : amortizedMonthly(rawMonthly, p.upfront_cost, p.term))
: rawMonthly;
const monthlyCostCell = displayMonthly != null
? formatCurrency(displayMonthly)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ function buildCommitmentRow(c: api.InventoryCommitment): HTMLTableRowElement {
const amortize = state.getAmortizeUpfront();
let displayMonthly: number | null = c.monthly_cost;
if (displayMonthly != null && amortize) {
displayMonthly = amortizedMonthly(displayMonthly, c.upfront_cost, c.term_years);
displayMonthly = c.upfront_cost == null ? null : amortizedMonthly(displayMonthly, c.upfront_cost, c.term_years);
}
appendCell(tr, displayMonthly != null ? formatCurrency(displayMonthly) : '—');

Expand Down
8 changes: 4 additions & 4 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export interface HistorySummary {
total_pending?: number;
total_failed?: number;
total_expired?: number;
total_upfront?: number;
total_upfront?: number | null;
total_monthly_savings?: number;
total_annual_savings?: number;
}
Expand All @@ -216,7 +216,7 @@ export interface HistoryPurchase {
count: number;
term: number;
payment?: string;
upfront_cost: number;
upfront_cost: number | null;
monthly_cost?: number | null;
estimated_savings: number;
account_id?: string;
Expand Down Expand Up @@ -321,7 +321,7 @@ export interface SavingsAnalyticsSummary {
export interface SavingsDataPoint {
timestamp: string;
total_savings: number;
total_upfront: number;
total_upfront: number | null;
purchase_count: number;
cumulative_savings: number;
by_service?: Record<string, number>;
Expand All @@ -337,7 +337,7 @@ export interface SavingsBreakdownResponse {

export interface SavingsBreakdownValue {
total_savings: number;
total_upfront: number;
total_upfront: number | null;
purchase_count: number;
percentage: number;
}
Expand Down
36 changes: 36 additions & 0 deletions frontend/tests-e2e/nullable-purchase-cost.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
import { mockApi, seedAuth } from './fixtures/recs';

test('unknown upfront remains unknown through history and inventory amortization toggles', async ({ page }) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await seedAuth(page);
await mockApi(page);
const purchase = {
purchase_id: '11111111-1111-1111-1111-111111111111', timestamp: new Date().toISOString(),
provider: 'aws', service: 'ec2', resource_type: 'm5.large', region: 'us-east-1',
count: 1, term: 1, upfront_cost: null, monthly_cost: 50, estimated_savings: 10,
offering_class: 'standard', status: 'completed', account_id: 'account-1',
};
await page.route('**/api/history**', route => route.fulfill({ json: { summary: { total_upfront: null }, purchases: [purchase] } }));
await page.route('**/api/inventory/commitments**', route => route.fulfill({ json: { commitments: [{
...purchase, id: 'account-1:purchase', term_years: 1, start_date: purchase.timestamp,
end_date: '2027-09-29T00:00:00Z', status: 'active',
}] } }));

await page.goto('/purchases/history');
const history = page.locator('#history-list tbody tr').first();
await expect(history.locator('td').nth(8)).toHaveText('--');
await expect(history.locator('td').nth(9)).toHaveText('$50');
await page.locator('#history-amortize-checkbox').check();
await expect(history.locator('td').nth(9)).toHaveText('-');
await page.locator('#history-amortize-checkbox').uncheck();
await expect(history.locator('td').nth(9)).toHaveText('$50');

await page.goto('/inventory/active-commitments');
const inventory = page.locator('#active-commitments-list tbody tr').first();
await expect(inventory.locator('td').nth(8)).toHaveText('$50');
await page.locator('#inventory-amortize-checkbox').check();
await expect(inventory.locator('td').nth(8)).toHaveText('\u2014');
expect(errors).toEqual([]);
});
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928205306-c5cf5c7fbad8
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928205306-c5cf5c7fbad8
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928205306-c5cf5c7fbad8
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928205306-c5cf5c7fbad8
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928214714-ce9513612901
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928214714-ce9513612901
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928214714-ce9513612901
github.com/aws/aws-lambda-go v1.47.0
github.com/aws/aws-sdk-go-v2/service/kms v1.50.4
github.com/aws/aws-sdk-go-v2/service/lambda v1.89.0
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928205306-c5cf5c7fbad8 h1:/4MDV2fttTEnjt37Iq49v5SGMvYuBH68q99r9E+juOE=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928205306-c5cf5c7fbad8/go.mod h1:ApWBliDXe099f3oDXBz41K/I9v4bHvn1dG/BGoRmHlw=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928205306-c5cf5c7fbad8 h1:qUmZE0uvsWKU6Gtglo3h61jVEvqoihFjig6e1r/IvDk=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928205306-c5cf5c7fbad8/go.mod h1:tIUCLVX6utdBPiP6dAjY7GUfwIQngeSMK5A9aRqe6gA=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928205306-c5cf5c7fbad8 h1:eyAXwyDwIvEI9vdHb2TqkNPRuhbMYWDXN4751LOltKA=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928205306-c5cf5c7fbad8/go.mod h1:zgCL/ozOkcZUDbEC7a2UwW+6MPcLoBlEU2WUZ0TORUs=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928205306-c5cf5c7fbad8 h1:qs2P8PsqKcFl6eGMGvi7EfMXDuni3pqkc8vDRESppKA=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928205306-c5cf5c7fbad8/go.mod h1:UnMqDe2mBUHHF6gnKAgJn9/JeXa7WZqQxfWPs4unGrg=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928214714-ce9513612901 h1:JWQwkwshjBoEuoIuFK1KHh70yZDYQTrm5GRloD6ojjU=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20260928214714-ce9513612901/go.mod h1:ApWBliDXe099f3oDXBz41K/I9v4bHvn1dG/BGoRmHlw=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928214714-ce9513612901 h1:18MxQd7+ZQ+Z2uNbZfpIJQ00JWdSdTVSmVWBzg4R0DA=
github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20260928214714-ce9513612901/go.mod h1:tIUCLVX6utdBPiP6dAjY7GUfwIQngeSMK5A9aRqe6gA=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901 h1:iSdHYdmGUjcjtSmgGZxptBzDuLrJ9KR2mh1/x7FNvSY=
github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20260928214714-ce9513612901/go.mod h1:zgCL/ozOkcZUDbEC7a2UwW+6MPcLoBlEU2WUZ0TORUs=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928214714-ce9513612901 h1:i6OwXLUheudN3GfwnYXdKuEq8vPdE9qkNJ+r71LRLKA=
github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20260928214714-ce9513612901/go.mod h1:UnMqDe2mBUHHF6gnKAgJn9/JeXa7WZqQxfWPs4unGrg=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aws/aws-lambda-go v1.47.0 h1:0H8s0vumYx/YKs4sE7YM0ktwL2eWse+kfopsRI1sXVI=
Expand Down
27 changes: 18 additions & 9 deletions internal/analytics/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,14 @@ func NewCollector(cfg CollectorConfig, configStore config.StoreInterface) (*Coll
// aggregateData holds aggregated savings data for one
// (cloud_account_id|account_id|service|provider|region|commitment_type) bucket.
type aggregateData struct {
accountID string
cloudAccountID *string
service string
provider string
region string
commitmentType string
commitment float64
accountID string
cloudAccountID *string
service string
provider string
region string
commitmentType string
commitment float64
commitmentUnknown bool
// usage accumulates the recurring (monthly) cost of the commitments in this
// bucket as the covered-usage proxy. usageKnown stays false until at least
// one contributing row carried a non-nil MonthlyCost, so a bucket made up
Expand Down Expand Up @@ -179,7 +180,11 @@ func aggregatePurchases(ctx context.Context, purchases []config.PurchaseHistoryR
agg.savings += p.EstimatedSavings
// Upfront commitment amortized to a monthly run-rate over the term.
// Term > 0 guaranteed above.
agg.commitment += p.UpfrontCost / (float64(p.Term) * MonthsPerYear)
if p.UpfrontCost == nil {
agg.commitmentUnknown = true
} else {
agg.commitment += *p.UpfrontCost / (float64(p.Term) * MonthsPerYear)
}
// H2: real covered usage from the recurring monthly cost when present.
// Nil MonthlyCost (e.g. AWS all-upfront) contributes nothing and leaves
// usage unknown rather than implicitly $0.
Expand Down Expand Up @@ -209,6 +214,10 @@ func commitmentTypeFor(service string) string {
func buildSnapshots(serviceMap map[string]*aggregateData, now time.Time) []SavingsSnapshot {
snapshots := make([]SavingsSnapshot, 0, len(serviceMap))
for _, agg := range serviceMap {
var commitment *float64
if !agg.commitmentUnknown {
commitment = &agg.commitment
}
var usage *float64
if agg.usageKnown {
u := agg.usage
Expand All @@ -223,7 +232,7 @@ func buildSnapshots(serviceMap map[string]*aggregateData, now time.Time) []Savin
Service: agg.service,
Region: agg.region,
CommitmentType: agg.commitmentType,
TotalCommitment: agg.commitment,
TotalCommitment: commitment,
TotalUsage: usage,
TotalSavings: agg.savings,
CoveragePercentage: nil,
Expand Down
Loading
Loading