From 8001f3237438038f2f178273c43e4bd918bd7f19 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 13:35:53 +0200 Subject: [PATCH 1/3] fix(ladder): show timeline amounts in on-demand-equivalent dollars per row basis Timeline events keep the native amount_usd_hr and add amount_basis, commitment_rate and a derived od_usd_hr, rounded up to 6 digits so it never understates on-demand. The scope totals (native and on-demand) come from the one statement that pages the rows; the on-demand total sums the per-row rounded values. A malformed stored rate returns a fixed error instead of falling back to the native amount. The chart, table and selection total use od_usd_hr with a fallback to the native amount for older APIs. The editor labels each row by its basis and stays native. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/ladder-timeline-api.test.ts | 11 +++ .../src/__tests__/ladder-timeline.test.ts | 34 +++++++++ frontend/src/api/ladder.ts | 17 ++++- frontend/src/ladder-timeline-editor.ts | 2 +- frontend/src/ladder-timeline-filters.ts | 11 ++- frontend/src/ladder-timeline.ts | 6 +- internal/api/handler_ladder_timeline.go | 7 +- internal/api/handler_ladder_timeline_test.go | 20 +++++ internal/config/ladder_timeline.go | 38 +++++----- internal/config/ladder_timeline_od.go | 73 +++++++++++++++++++ .../config/store_postgres_ladder_timeline.go | 13 +++- ...stgres_ladder_timeline_integration_test.go | 36 +++++++++ .../store_postgres_ladder_timeline_test.go | 53 +++++++++++++- 13 files changed, 292 insertions(+), 29 deletions(-) create mode 100644 internal/config/ladder_timeline_od.go diff --git a/frontend/src/__tests__/ladder-timeline-api.test.ts b/frontend/src/__tests__/ladder-timeline-api.test.ts index 8793931cd..bc645984b 100644 --- a/frontend/src/__tests__/ladder-timeline-api.test.ts +++ b/frontend/src/__tests__/ladder-timeline-api.test.ts @@ -20,3 +20,14 @@ test('actual API loader rejects incomplete or changing pagination', async () => await expect(getLadderEvents('account', 'aws')).rejects.toThrow('changed'); } }); + +test('OD cross-check compares the displayed od_usd_hr values and tolerates a pre-upgrade API', async () => { + const base: LadderEvent = { id: 'one', config_id: 'config', run_id: 'run', layer_type: 'compute-sp', term: '1yr', payment_option: 'no-upfront', status: 'scheduled', run_status: 'planned', revision: 0, amount_usd_hr: '5.000000', amount_basis: 'commitment', commitment_rate: '1/2', od_usd_hr: '10.000000', scheduled_date: '2030-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z' }; + jest.mocked(apiRequest).mockResolvedValueOnce({ total_count: 1, total_usd_hr: '5.000000', total_od_usd_hr: '10.000000', events: [base] }); + expect(await getLadderEvents('account', 'aws')).toHaveLength(1); + jest.mocked(apiRequest).mockResolvedValueOnce({ total_count: 1, total_usd_hr: '5.000000', total_od_usd_hr: '5.000000', events: [base] }); + await expect(getLadderEvents('account', 'aws')).rejects.toThrow('changed'); + const { od_usd_hr: _od, amount_basis: _basis, commitment_rate: _rate, ...legacy } = base; + jest.mocked(apiRequest).mockResolvedValueOnce({ total_count: 1, total_usd_hr: '5.000000', events: [legacy] }); + expect(await getLadderEvents('account', 'aws')).toHaveLength(1); +}); diff --git a/frontend/src/__tests__/ladder-timeline.test.ts b/frontend/src/__tests__/ladder-timeline.test.ts index 4f6a98c73..87a060557 100644 --- a/frontend/src/__tests__/ladder-timeline.test.ts +++ b/frontend/src/__tests__/ladder-timeline.test.ts @@ -326,3 +326,37 @@ test('layers are told apart by point shape as well as color, and a selection wit expect(Chart).not.toHaveBeenCalled(); expect(document.querySelector('.ladder-timeline-chart')!.hasAttribute('hidden')).toBe(true); }); + +// Mixed run: commitment 5 @ 1/2 (OD 10), legacy OD 2, and a deploy-skew row without the new fields. +const mixed = [ + { ...event, id: 'm1', amount_usd_hr: '5.000000', amount_basis: 'commitment' as const, commitment_rate: '1/2', od_usd_hr: '10.000000', scheduled_date: '2030-01-01T00:00:00Z' }, + { ...event, id: 'm2', amount_usd_hr: '2.000000', amount_basis: 'on_demand_equivalent' as const, od_usd_hr: '2.000000', scheduled_date: '2030-01-02T00:00:00Z' }, + { ...event, id: 'm3', amount_usd_hr: '1.000000', scheduled_date: '2030-01-03T00:00:00Z' }, +]; + +test('chart, table and selection total use on-demand-equivalent values, falling back to the native amount', async () => { + jest.mocked(getLadderEvents).mockResolvedValue(mixed); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const config = jest.mocked(Chart).mock.calls[0]![1] as unknown as { data: { datasets: { data: { y: number }[] }[] } }; + expect(config.data.datasets[0]!.data.map(point => point.y)).toEqual([10, 12, 13]); + const cells = Array.from(document.querySelectorAll('tbody tr')).map(row => row.children[4]!.textContent); + expect(cells).toEqual(['$10.00/hr', '$2.00/hr', '$1.00/hr']); + expect(document.querySelector('[data-summary]')!.textContent).toContain('$13.00/hr'); + expect(document.querySelector('table caption, table')!.textContent).toContain('on-demand-equivalent $/hr'); +}); + +test('editor labels each row by its basis and keeps the native amount', async () => { + jest.mocked(getLadderEvents).mockResolvedValue(mixed); + jest.mocked(getLadderRuns).mockResolvedValue([{ ...run, total_hourly_commit: '8.000000' }]); + await initLadderTimeline(document.getElementById('timeline')!, [], false); + const open = (id: string): string => { + document.querySelector(`[data-event="${id}"]`)!.click(); + const label = document.querySelector('label[for="ladder-event-amount"]')!.textContent!; + const value = (document.getElementById('ladder-event-amount') as HTMLInputElement).value; + document.querySelector('.ladder-event-editor [data-cancel]')!.click(); + return `${label}|${value}`; + }; + expect(open('m1')).toBe('Planning budget (commitment $/hr)|5.000000'); + expect(open('m2')).toBe('Planning budget (on-demand-equivalent $/hr)|2.000000'); + expect(open('m3')).toBe('Planning budget (on-demand-equivalent $/hr)|1.000000'); +}); diff --git a/frontend/src/api/ladder.ts b/frontend/src/api/ladder.ts index 2d24ddf79..cd184b6d7 100644 --- a/frontend/src/api/ladder.ts +++ b/frontend/src/api/ladder.ts @@ -7,7 +7,7 @@ */ import { apiRequest } from './client'; -import { ladderTotal } from '../ladder-timeline-filters'; +import { ladderTotal, ladderTotalOd } from '../ladder-timeline-filters'; /** * A single ramp step within a ladder ramp schedule. @@ -90,6 +90,10 @@ export interface LadderEvent { run_status: string; execution_id?: string; amount_usd_hr: string; + /** Optional so a frontend newer than the API (deploy skew) falls back to the native amount. */ + amount_basis?: 'commitment' | 'on_demand_equivalent'; + commitment_rate?: string; + od_usd_hr?: string; scheduled_date: string; created_at: string; revision: number; @@ -114,6 +118,7 @@ interface TimelinePage { runs?: LadderRun[]; total_count: number; total_usd_hr?: string; + total_od_usd_hr?: string; next_cursor?: LadderCursor; } @@ -125,6 +130,7 @@ async function completeTimeline( const seen = new Set(); let count: number | undefined; let total: string | undefined; + let totalOd: string | undefined; while (true) { const page = await apiRequest(`/ladder/${endpoint}?${params}`); if (!page || !Number.isSafeInteger(page.total_count) || page.total_count < 0) { @@ -139,6 +145,12 @@ async function completeTimeline( throw new Error('The ladder timeline changed while loading. Reload to try again.'); } total = page.total_usd_hr; + if (page.total_od_usd_hr !== undefined) { + if (typeof page.total_od_usd_hr !== 'string' || (totalOd !== undefined && totalOd !== page.total_od_usd_hr)) { + throw new Error('The ladder timeline changed while loading. Reload to try again.'); + } + totalOd = page.total_od_usd_hr; + } } const rows = endpoint === 'tranches' ? page.events : page.runs; if (!Array.isArray(rows)) throw new Error('Ladder timeline rows are unavailable.'); @@ -156,6 +168,9 @@ async function completeTimeline( if (endpoint === 'tranches' && ladderTotal(items as LadderEvent[]) !== total) { throw new Error('The ladder timeline changed while loading. Reload to try again.'); } + if (endpoint === 'tranches' && totalOd !== undefined && ladderTotalOd(items as LadderEvent[]) !== totalOd) { + throw new Error('The ladder timeline changed while loading. Reload to try again.'); + } return items; } diff --git a/frontend/src/ladder-timeline-editor.ts b/frontend/src/ladder-timeline-editor.ts index 942166940..8c726a97e 100644 --- a/frontend/src/ladder-timeline-editor.ts +++ b/frontend/src/ladder-timeline-editor.ts @@ -18,7 +18,7 @@ export function openLadderEventEditor(event: LadderEvent, run: LadderRun, siblin

These are planning budgets. Purchases are not executed by this editor.

-
+

Original run ceiling: ${escapeHtml(formatLadderAmount(run.total_hourly_commit))}. diff --git a/frontend/src/ladder-timeline-filters.ts b/frontend/src/ladder-timeline-filters.ts index 642707df5..11c7912fd 100644 --- a/frontend/src/ladder-timeline-filters.ts +++ b/frontend/src/ladder-timeline-filters.ts @@ -34,6 +34,15 @@ export function formatLadderUnits(units: bigint): string { return `${units / 1000000n}.${(units % 1000000n).toString().padStart(6, '0')}`; } +/** On-demand-equivalent amount; absent on an older API, whose amounts were all on-demand-equivalent. */ +export function ladderOdAmount(event: LadderEvent): string { + return event.od_usd_hr ?? event.amount_usd_hr; +} + +export function ladderTotalOd(events: readonly LadderEvent[]): string { + return formatLadderUnits(events.reduce((total, event) => total + ladderAmountUnits(ladderOdAmount(event)), 0n)); +} + export function ladderTotal(events: readonly LadderEvent[]): string { return formatLadderUnits(events.reduce((total, event) => total + ladderAmountUnits(event.amount_usd_hr), 0n)); } @@ -120,7 +129,7 @@ export function ladderCumulative(sorted: readonly LadderEvent[], base: bigint): let running = base; for (const event of sorted) { if (ladderNotCountedNote(event) !== undefined) { rowTotals.set(event.id, null); continue; } - const amount = ladderAmountUnits(event.amount_usd_hr); + const amount = ladderAmountUnits(ladderOdAmount(event)); running += amount; rowTotals.set(event.id, running); const at = times.indexOf(Date.parse(event.scheduled_date)); diff --git a/frontend/src/ladder-timeline.ts b/frontend/src/ladder-timeline.ts index 885a6243e..85d6cb1d0 100644 --- a/frontend/src/ladder-timeline.ts +++ b/frontend/src/ladder-timeline.ts @@ -3,7 +3,7 @@ import { listAccountsMinimal, type AccountSummary } from './api/accounts'; import { getLadderEvents, getLadderRuns, type LadderConfig, type LadderEvent, type LadderRun } from './api/ladder'; import { canAccess } from './permissions'; import { escapeHtml } from './utils'; -import { emptyLadderFilters, filterLadderEvents, ladderDimensions, ladderFilterOptions, ladderEditReason, ladderTotal, ladderLabel, formatLadderAmount, formatLadderUnits, ladderNotCountedNote, LADDER_COUNTED_NOTE, formatLadderDateTime, type LadderFilters } from './ladder-timeline-filters'; +import { emptyLadderFilters, filterLadderEvents, ladderDimensions, ladderFilterOptions, ladderEditReason, ladderTotalOd, ladderOdAmount, ladderLabel, formatLadderAmount, formatLadderUnits, ladderNotCountedNote, LADDER_COUNTED_NOTE, formatLadderDateTime, type LadderFilters } from './ladder-timeline-filters'; import { openLadderEventEditor } from './ladder-timeline-editor'; import { ladderChartConfig, ladderChartModel, ladderChartSummary, LADDER_UNIT_LABEL } from './ladder-timeline-chart'; @@ -71,7 +71,7 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La select.innerHTML = `${options.map(value => ``).join('')}`; select.value = filters[key]; }); - summary.textContent = `${visible.length} of ${events.length} retained tranches; ${formatLadderAmount(ladderTotal(visible))} in this selection.`; + summary.textContent = `${visible.length} of ${events.length} retained tranches; ${formatLadderAmount(ladderTotalOd(visible))} in this selection.`; const snapshot = root.querySelector('[data-snapshot]')!; const run = runs.find(item => item.id === filters.run_id); snapshot.textContent = run ? `Original run ${formatLadderDateTime(run.started_at)}: baseline ${formatLadderAmount(run.baseline_usd_hr)}, existing ${formatLadderAmount(run.existing_usd_hr)}, target ${formatLadderAmount(run.target_usd_hr)}. These snapshots are not a coverage forecast. ${run.actions.map(action => `${action.rationale}${action.data_sources?.length ? ` Sources: ${action.data_sources.join(', ')}.` : ''}`).join(' ')}` : 'Select a run to inspect its original baseline, existing commitment and target snapshots.'; @@ -103,7 +103,7 @@ export async function initLadderTimeline(root: HTMLElement, configs: readonly La } table.innerHTML = `${visible.map(event => { const reason = ladderEditReason(event); - return ``; + return ``; }).join('')}
Planned purchases with running cumulative total. ${LADDER_COUNTED_NOTE} The base is the selected run's existing commitment, or 0 (planned additions only) when no run is selected.
Scheduled (UTC)TypeTermPaymentBudget (${LADDER_UNIT_LABEL.toLowerCase()})Cumulative (${LADDER_UNIT_LABEL.toLowerCase()})StatusSelect or edit
${escapeHtml(formatLadderDateTime(event.scheduled_date))}${escapeHtml(ladderLabel('layer_type', event.layer_type))}${escapeHtml(ladderLabel('term', event.term))}${escapeHtml(ladderLabel('payment_option', event.payment_option))}${escapeHtml(formatLadderAmount(event.amount_usd_hr))}${escapeHtml(cumulativeCell(event))}${escapeHtml(ladderLabel('status', event.status))}${reason ? `${escapeHtml(reason)}` : ''}
${escapeHtml(formatLadderDateTime(event.scheduled_date))}${escapeHtml(ladderLabel('layer_type', event.layer_type))}${escapeHtml(ladderLabel('term', event.term))}${escapeHtml(ladderLabel('payment_option', event.payment_option))}${escapeHtml(formatLadderAmount(ladderOdAmount(event)))}${escapeHtml(cumulativeCell(event))}${escapeHtml(ladderLabel('status', event.status))}${reason ? `${escapeHtml(reason)}` : ''}
`; table.querySelectorAll('[data-event]').forEach(button => button.addEventListener('click', () => { const event = visible.find(item => item.id === button.dataset['event']); diff --git a/internal/api/handler_ladder_timeline.go b/internal/api/handler_ladder_timeline.go index 49219f0dc..e5a2e7c38 100644 --- a/internal/api/handler_ladder_timeline.go +++ b/internal/api/handler_ladder_timeline.go @@ -2,6 +2,7 @@ package api import ( "context" + "errors" "time" "github.com/google/uuid" @@ -75,7 +76,11 @@ func (h *Handler) getLadderTimeline(ctx context.Context, req *events.LambdaFunct if err != nil { return nil, err } - return scope.store.ListLadderTimeline(ctx, scope.accountID, scope.provider, scope.cursor) + page, err := scope.store.ListLadderTimeline(ctx, scope.accountID, scope.provider, scope.cursor) + if errors.Is(err, config.ErrLadderTimelineRate) { + return nil, NewClientError(500, "Ladder timeline contains a tranche with an unreadable commitment rate") + } + return page, err } func (h *Handler) getLadderTimelineRuns(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) { diff --git a/internal/api/handler_ladder_timeline_test.go b/internal/api/handler_ladder_timeline_test.go index dc2a72c48..7f522ceb4 100644 --- a/internal/api/handler_ladder_timeline_test.go +++ b/internal/api/handler_ladder_timeline_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "fmt" "testing" "github.com/LeanerCloud/cloud-commitments-platform/internal/auth" @@ -109,3 +110,22 @@ func TestLadderTimelineCursorRejectsPartialAndInvalidInput(t *testing.T) { require.Nil(t, cursor) } } + +type failingTimelineStore struct{ timelineTestStore } + +func (s *failingTimelineStore) ListLadderTimeline(context.Context, string, string, *config.LadderTimelineCursor) (*config.LadderTimelinePage, error) { + return nil, fmt.Errorf("row 9: %w", config.ErrLadderTimelineRate) +} + +func TestLadderTimelineMalformedRateReturnsFixedError(t *testing.T) { + h, original := ladderScopedHandler(t) + h.config = &failingTimelineStore{timelineTestStore{MockConfigStore: original}} + req := ladderScopedReq("") + req.QueryStringParameters = map[string]string{"account_id": scopedInAccount, "provider": "aws"} + result, err := NewRouter(h).Route(context.Background(), "GET", "/api/ladder/tranches", req) + clientErr, ok := IsClientError(err) + require.True(t, ok) + require.Equal(t, 500, clientErr.code) + require.NotContains(t, clientErr.Error(), "row 9") + require.Nil(t, result) +} diff --git a/internal/config/ladder_timeline.go b/internal/config/ladder_timeline.go index f1e8877b0..55c04a2c5 100644 --- a/internal/config/ladder_timeline.go +++ b/internal/config/ladder_timeline.go @@ -15,26 +15,30 @@ type LadderTimelineCursor struct { } type LadderTimelineEvent struct { - CreatedAt time.Time `json:"created_at"` - ScheduledDate time.Time `json:"scheduled_date"` - ExecutionID *string `json:"execution_id,omitempty"` - ID string `json:"id"` - RunID string `json:"run_id"` - ConfigID string `json:"config_id"` - AmountUSDHr string `json:"amount_usd_hr"` - Revision int64 `json:"revision"` - Layer ladder.LayerType `json:"layer_type"` - Term ladder.Term `json:"term"` - Payment ladder.PaymentOption `json:"payment_option"` - Status ladder.TrancheStatus `json:"status"` - RunStatus ladder.RunStatus `json:"run_status"` + CreatedAt time.Time `json:"created_at"` + ScheduledDate time.Time `json:"scheduled_date"` + ExecutionID *string `json:"execution_id,omitempty"` + ID string `json:"id"` + RunID string `json:"run_id"` + ConfigID string `json:"config_id"` + AmountUSDHr string `json:"amount_usd_hr"` + AmountBasis *LadderAmountBasis `json:"amount_basis,omitempty"` + CommitmentRate *string `json:"commitment_rate,omitempty"` + ODUSDHr string `json:"od_usd_hr"` + Revision int64 `json:"revision"` + Layer ladder.LayerType `json:"layer_type"` + Term ladder.Term `json:"term"` + Payment ladder.PaymentOption `json:"payment_option"` + Status ladder.TrancheStatus `json:"status"` + RunStatus ladder.RunStatus `json:"run_status"` } type LadderTimelinePage struct { - Events []LadderTimelineEvent `json:"events"` - NextCursor *LadderTimelineCursor `json:"next_cursor,omitempty"` - TotalUSDHr string `json:"total_usd_hr"` - TotalCount int64 `json:"total_count"` + Events []LadderTimelineEvent `json:"events"` + NextCursor *LadderTimelineCursor `json:"next_cursor,omitempty"` + TotalUSDHr string `json:"total_usd_hr"` + TotalODUSDHr string `json:"total_od_usd_hr"` + TotalCount int64 `json:"total_count"` } type LadderTimelineRun struct { diff --git a/internal/config/ladder_timeline_od.go b/internal/config/ladder_timeline_od.go new file mode 100644 index 000000000..bf2aa309a --- /dev/null +++ b/internal/config/ladder_timeline_od.go @@ -0,0 +1,73 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" +) + +// ErrLadderTimelineRate marks a stored amount basis or rate that cannot be +// converted; callers must not fall back to the native amount. +var ErrLadderTimelineRate = errors.New("ladder timeline row has an unreadable amount basis or commitment rate") + +var ladderMicro = big.NewInt(1_000_000) + +// ladderTimelineODMicro returns the row's on-demand-equivalent amount in +// micro-dollars, rounded UP so a displayed value never understates OD. +func ladderTimelineODMicro(amount string, basis *LadderAmountBasis, rate *string) (*big.Int, error) { + // A NULL basis (rows from before 000121) is ASSUMED on-demand-equivalent, + // as on the amend path; it is not verified. + tr := assumeOnDemandIfUnknown(&LadderTrancheDB{AmountBasis: basis, CommitmentRate: rate}) + od, err := nettingOnDemand(amount, tr) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrLadderTimelineRate, err) + } + scaled := new(big.Rat).Mul(od, new(big.Rat).SetInt(ladderMicro)) + q, m := new(big.Int).QuoRem(scaled.Num(), scaled.Denom(), new(big.Int)) + if m.Sign() > 0 { + q.Add(q, big.NewInt(1)) + } + return q, nil +} + +func formatLadderMicro(v *big.Int) string { + q, r := new(big.Int).QuoRem(v, ladderMicro, new(big.Int)) + return fmt.Sprintf("%s.%06d", q, r.Int64()) +} + +// fillLadderTimelineOnDemand sets od_usd_hr on the page rows and the scope +// total. Both come from the one statement that paged the rows, and the total +// is the sum of the per-row rounded values so it equals what a client adds up. +func fillLadderTimelineOnDemand(page *LadderTimelinePage, scopeRaw []byte) error { + var scope [][3]*string + if err := json.Unmarshal(scopeRaw, &scope); err != nil { + return fmt.Errorf("decode ladder timeline scope: %w", err) + } + total := new(big.Int) + for _, row := range scope { + if row[0] == nil { + return fmt.Errorf("%w: missing amount", ErrLadderTimelineRate) + } + var basis *LadderAmountBasis + if row[1] != nil { + b := LadderAmountBasis(*row[1]) + basis = &b + } + micro, err := ladderTimelineODMicro(*row[0], basis, row[2]) + if err != nil { + return err + } + total.Add(total, micro) + } + page.TotalODUSDHr = formatLadderMicro(total) + for i := range page.Events { + e := &page.Events[i] + micro, err := ladderTimelineODMicro(e.AmountUSDHr, e.AmountBasis, e.CommitmentRate) + if err != nil { + return err + } + e.ODUSDHr = formatLadderMicro(micro) + } + return nil +} diff --git a/internal/config/store_postgres_ladder_timeline.go b/internal/config/store_postgres_ladder_timeline.go index 8be082365..a095d39c5 100644 --- a/internal/config/store_postgres_ladder_timeline.go +++ b/internal/config/store_postgres_ladder_timeline.go @@ -10,7 +10,8 @@ const ladderTimelineQuery = ` WITH scoped AS MATERIALIZED ( SELECT t.id, t.run_id, t.config_id, t.layer_type, t.term, t.payment_option, t.status, r.status AS run_status, t.execution_id, t.revision, - t.amount_usd_hr::text AS amount_usd_hr, t.scheduled_date, t.created_at + t.amount_usd_hr::text AS amount_usd_hr, t.amount_basis, t.commitment_rate, + t.scheduled_date, t.created_at FROM ladder_tranches t JOIN ladder_configs c ON c.id = t.config_id JOIN ladder_runs r ON r.id = t.run_id AND r.config_id = c.id @@ -22,7 +23,8 @@ WITH scoped AS MATERIALIZED ( ORDER BY created_at, id LIMIT $5 ) SELECT COUNT(*), COALESCE(SUM(amount_usd_hr::numeric), 0::numeric(20,6))::text, - COALESCE((SELECT jsonb_agg(to_jsonb(page) ORDER BY created_at, id) FROM page), '[]'::jsonb) + COALESCE((SELECT jsonb_agg(to_jsonb(page) ORDER BY created_at, id) FROM page), '[]'::jsonb), + COALESCE(jsonb_agg(jsonb_build_array(amount_usd_hr, amount_basis, commitment_rate)), '[]'::jsonb) FROM scoped` const ladderTimelineRunsQuery = ` @@ -58,14 +60,17 @@ func ladderTimelineArgs(accountID, provider string, cursor *LadderTimelineCursor func (s *PostgresStore) ListLadderTimeline(ctx context.Context, accountID, provider string, cursor *LadderTimelineCursor) (*LadderTimelinePage, error) { page := &LadderTimelinePage{} - var raw []byte + var raw, scopeRaw []byte if err := s.db.QueryRow(ctx, ladderTimelineQuery, ladderTimelineArgs(accountID, provider, cursor)...). - Scan(&page.TotalCount, &page.TotalUSDHr, &raw); err != nil { + Scan(&page.TotalCount, &page.TotalUSDHr, &raw, &scopeRaw); err != nil { return nil, fmt.Errorf("list ladder timeline: %w", err) } if err := json.Unmarshal(raw, &page.Events); err != nil { return nil, fmt.Errorf("decode ladder timeline: %w", err) } + if err := fillLadderTimelineOnDemand(page, scopeRaw); err != nil { + return nil, err + } if len(page.Events) > LadderTimelinePageSize { page.Events = page.Events[:LadderTimelinePageSize] last := page.Events[len(page.Events)-1] diff --git a/internal/config/store_postgres_ladder_timeline_integration_test.go b/internal/config/store_postgres_ladder_timeline_integration_test.go index b0dc3a334..7256c528e 100644 --- a/internal/config/store_postgres_ladder_timeline_integration_test.go +++ b/internal/config/store_postgres_ladder_timeline_integration_test.go @@ -103,3 +103,39 @@ func TestLadderTimelineScopedPagesAndSnapshots(t *testing.T) { require.Equal(t, int64(101), unchanged.TotalCount) require.Equal(t, "12.469056", unchanged.TotalUSDHr) } + +func TestLadderTimelineMixedBasisOnDemand(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + store := setupLadderStore(ctx, t) + cfgID := seedLadderConfig(ctx, t, store) + var accountID string + require.NoError(t, store.db.QueryRow(ctx, "SELECT cloud_account_id FROM ladder_configs WHERE id=$1", cfgID).Scan(&accountID)) + run, err := store.SaveLadderRun(ctx, &LadderRunDB{ConfigID: &cfgID, Status: ladder.RunStatusPlanned, StartedAt: time.Now().UTC(), Plan: []byte(`{}`)}) + require.NoError(t, err) + // No writer produces commitment rows yet, so seed them directly. + _, err = store.db.Exec(ctx, `INSERT INTO ladder_tranches(config_id,run_id,layer_type,amount_usd_hr,amount_basis,commitment_rate,term,payment_option,scheduled_date,status,created_at) + VALUES ($1,$2,'compute-sp',5,'commitment','1/2','1yr','no-upfront',NOW(),'scheduled',NOW()+interval '1 second'), + ($1,$2,'compute-sp',1,'commitment','1/3','1yr','no-upfront',NOW(),'scheduled',NOW()+interval '2 second'), + ($1,$2,'compute-sp',2.5,'on_demand_equivalent',NULL,'1yr','no-upfront',NOW(),'scheduled',NOW()+interval '3 second'), + ($1,$2,'compute-sp',4,NULL,NULL,'1yr','no-upfront',NOW(),'scheduled',NOW()+interval '4 second'), + ($1,$2,'compute-sp',1.000001,'commitment','2/3','1yr','no-upfront',NOW(),'scheduled',NOW()+interval '5 second'), + ($1,$2,'compute-sp',1.000001,'commitment','2/3','1yr','no-upfront',NOW(),'scheduled',NOW()+interval '6 second')`, cfgID, run.ID) + require.NoError(t, err) + page, err := store.ListLadderTimeline(ctx, accountID, "aws", nil) + require.NoError(t, err) + require.Equal(t, "14.500002", page.TotalUSDHr) + require.Equal(t, "22.500004", page.TotalODUSDHr) + var od, native []string + for _, e := range page.Events { + od, native = append(od, e.ODUSDHr), append(native, e.AmountUSDHr) + } + require.Equal(t, []string{"10.000000", "3.000000", "2.500000", "4.000000", "1.500002", "1.500002"}, od) + require.Equal(t, []string{"5.000000", "1.000000", "2.500000", "4.000000", "1.000001", "1.000001"}, native) + require.Nil(t, page.Events[3].AmountBasis, "NULL basis stays unknown on the wire") + + _, err = store.db.Exec(ctx, `UPDATE ladder_tranches SET commitment_rate='abc' WHERE id=$1`, page.Events[0].ID) + require.NoError(t, err) + _, err = store.ListLadderTimeline(ctx, accountID, "aws", nil) + require.ErrorIs(t, err, ErrLadderTimelineRate) +} diff --git a/internal/config/store_postgres_ladder_timeline_test.go b/internal/config/store_postgres_ladder_timeline_test.go index 5b32aa68e..cbb3dd722 100644 --- a/internal/config/store_postgres_ladder_timeline_test.go +++ b/internal/config/store_postgres_ladder_timeline_test.go @@ -25,9 +25,10 @@ func TestLadderTimelinePagination(t *testing.T) { } raw, err := json.Marshal(events) require.NoError(t, err) + scope := []byte(`[["0.123456",null,null]]`) pool.ExpectQuery(regexp.QuoteMeta(ladderTimelineQuery)). WithArgs("account", "aws", nil, nil, LadderTimelinePageSize+1). - WillReturnRows(pgxmock.NewRows([]string{"count", "sum", "page"}).AddRow(int64(101), "12.469056", raw)) + WillReturnRows(pgxmock.NewRows([]string{"count", "sum", "page", "scope"}).AddRow(int64(101), "12.469056", raw, scope)) page, err := store.ListLadderTimeline(context.Background(), "account", "aws", nil) require.NoError(t, err) require.Len(t, page.Events, LadderTimelinePageSize) @@ -80,3 +81,53 @@ func TestLadderTimelineRunsPaginationAndFailure(t *testing.T) { require.Nil(t, page) require.NoError(t, pool.ExpectationsWereMet()) } + +func TestLadderTimelineOnDemandFromOneStatement(t *testing.T) { + pool, err := pgxmock.NewPool() + require.NoError(t, err) + defer pool.Close() + store := &PostgresStore{db: pool} + commit := LadderAmountCommitment + rate := "2/3" + page := []LadderTimelineEvent{ + {ID: uuid.NewString(), AmountUSDHr: "1.000001", AmountBasis: &commit, CommitmentRate: &rate}, + {ID: uuid.NewString(), AmountUSDHr: "4.000000"}, + } + raw, err := json.Marshal(page) + require.NoError(t, err) + // Two rows of exactly 1.5000015 on-demand: per-row ceilings sum to 3.000004, not the rounded exact sum 3.000003. + scope := []byte(`[["1.000001","commitment","2/3"],["1.000001","commitment","2/3"],["4.000000",null,null]]`) + pool.ExpectQuery(regexp.QuoteMeta(ladderTimelineQuery)). + WithArgs("account", "aws", nil, nil, LadderTimelinePageSize+1). + WillReturnRows(pgxmock.NewRows([]string{"count", "sum", "page", "scope"}).AddRow(int64(3), "6.000002", raw, scope)) + got, err := store.ListLadderTimeline(context.Background(), "account", "aws", nil) + require.NoError(t, err) + require.Equal(t, "6.000002", got.TotalUSDHr) + require.Equal(t, "7.000004", got.TotalODUSDHr) + require.Equal(t, "1.500002", got.Events[0].ODUSDHr) + require.Equal(t, "1.000001", got.Events[0].AmountUSDHr, "native amount stays native") + require.Equal(t, "4.000000", got.Events[1].ODUSDHr, "NULL basis is assumed on-demand") + require.NoError(t, pool.ExpectationsWereMet(), "exactly one statement") +} + +func TestLadderTimelineMalformedRateFailsLoud(t *testing.T) { + for name, scope := range map[string]string{ + "unparseable": `[["1.000000","commitment","abc"]]`, + "zero": `[["1.000000","commitment","0"]]`, + "above one": `[["1.000000","commitment","3/2"]]`, + "unknown": `[["1.000000","mystery",null]]`, + } { + t.Run(name, func(t *testing.T) { + pool, err := pgxmock.NewPool() + require.NoError(t, err) + defer pool.Close() + store := &PostgresStore{db: pool} + pool.ExpectQuery(regexp.QuoteMeta(ladderTimelineQuery)). + WithArgs("account", "aws", nil, nil, LadderTimelinePageSize+1). + WillReturnRows(pgxmock.NewRows([]string{"count", "sum", "page", "scope"}).AddRow(int64(1), "1.000000", []byte(`[]`), []byte(scope))) + got, err := store.ListLadderTimeline(context.Background(), "account", "aws", nil) + require.ErrorIs(t, err, ErrLadderTimelineRate) + require.Nil(t, got) + }) + } +} From 690d1bec9ce8e881cff4798b7a814e2254155ec6 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 16:00:54 +0200 Subject: [PATCH 2/3] fix(ladder): wrap the timeline rate error cause with %w errorlint rejected %v on the cause; wrap both so ErrLadderTimelineRate stays matchable and the cause is unwrappable. Also preallocate the slices in the integration test (prealloc). Co-Authored-By: Claude Opus 5.5 --- internal/config/ladder_timeline_od.go | 2 +- .../config/store_postgres_ladder_timeline_integration_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/ladder_timeline_od.go b/internal/config/ladder_timeline_od.go index bf2aa309a..219bba3aa 100644 --- a/internal/config/ladder_timeline_od.go +++ b/internal/config/ladder_timeline_od.go @@ -21,7 +21,7 @@ func ladderTimelineODMicro(amount string, basis *LadderAmountBasis, rate *string tr := assumeOnDemandIfUnknown(&LadderTrancheDB{AmountBasis: basis, CommitmentRate: rate}) od, err := nettingOnDemand(amount, tr) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrLadderTimelineRate, err) + return nil, fmt.Errorf("%w: %w", ErrLadderTimelineRate, err) } scaled := new(big.Rat).Mul(od, new(big.Rat).SetInt(ladderMicro)) q, m := new(big.Int).QuoRem(scaled.Num(), scaled.Denom(), new(big.Int)) diff --git a/internal/config/store_postgres_ladder_timeline_integration_test.go b/internal/config/store_postgres_ladder_timeline_integration_test.go index 7256c528e..8f5b076e8 100644 --- a/internal/config/store_postgres_ladder_timeline_integration_test.go +++ b/internal/config/store_postgres_ladder_timeline_integration_test.go @@ -126,7 +126,7 @@ func TestLadderTimelineMixedBasisOnDemand(t *testing.T) { require.NoError(t, err) require.Equal(t, "14.500002", page.TotalUSDHr) require.Equal(t, "22.500004", page.TotalODUSDHr) - var od, native []string + od, native := make([]string, 0, len(page.Events)), make([]string, 0, len(page.Events)) for _, e := range page.Events { od, native = append(od, e.ODUSDHr), append(native, e.AmountUSDHr) } From 63709da3b5c3c0bc77fc5c9c8cb4d9e6a565bdca Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 16 Oct 2026 16:01:25 +0200 Subject: [PATCH 3/3] test(e2e): target the editor input by its basis-labelled name The PR renamed the editor label to "Planning budget (on-demand-equivalent $/hr)" (or commitment $/hr by basis); the spec still used "(USD/hour)" so the input was not found. The editor still shows and persists the native amount. Co-Authored-By: Claude Opus 5.5 --- frontend/tests-e2e/ladder-timeline.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/tests-e2e/ladder-timeline.spec.ts b/frontend/tests-e2e/ladder-timeline.spec.ts index bc4167e72..3b741dd09 100644 --- a/frontend/tests-e2e/ladder-timeline.spec.ts +++ b/frontend/tests-e2e/ladder-timeline.spec.ts @@ -53,17 +53,17 @@ for (const width of [1280, 390]) { } } await expect(modal).toBeVisible(); - await expect(modal.getByLabel('Planning budget (USD/hour)')).toHaveValue('0.500000'); - await modal.getByLabel('Planning budget (USD/hour)').fill('0.250000'); + await expect(modal.getByLabel('Planning budget (on-demand-equivalent $/hr)')).toHaveValue('0.500000'); + await modal.getByLabel('Planning budget (on-demand-equivalent $/hr)').fill('0.250000'); await expect(modal.locator('#ladder-event-preview')).toContainText('$2.25/hr'); await modal.getByRole('button', { name: 'Undo draft changes' }).click(); - await expect(modal.getByLabel('Planning budget (USD/hour)')).toHaveValue('0.500000'); + await expect(modal.getByLabel('Planning budget (on-demand-equivalent $/hr)')).toHaveValue('0.500000'); await modal.getByLabel('Scheduled date and time (UTC)').fill('2020-01-01T00:00'); await modal.getByRole('button', { name: 'Save planned purchase' }).click(); await expect(modal.locator('[role="alert"]')).toContainText('future'); expect(saves).toEqual([]); await modal.getByLabel('Scheduled date and time (UTC)').fill('2030-01-04T00:00'); - await modal.getByLabel('Planning budget (USD/hour)').fill('0.250000'); + await modal.getByLabel('Planning budget (on-demand-equivalent $/hr)').fill('0.250000'); await modal.getByRole('button', { name: 'Save planned purchase' }).click(); await expect(modal.getByRole('button', { name: 'Save in progress' })).toBeFocused(); await page.keyboard.press('Escape'); await page.keyboard.press('Tab'); await page.keyboard.press('Enter'); @@ -79,10 +79,10 @@ for (const width of [1280, 390]) { await timeline.locator('[data-event="event-2"]').focus(); await page.keyboard.press('Enter'); await expect(modal.getByLabel('Scheduled date and time (UTC)')).toBeFocused(); rejectNextSave = true; - await modal.getByLabel('Planning budget (USD/hour)').fill('0.200000'); + await modal.getByLabel('Planning budget (on-demand-equivalent $/hr)').fill('0.200000'); await modal.getByRole('button', { name: 'Save planned purchase' }).click(); await expect(modal.locator('[role="alert"]')).toContainText('draft is retained'); - await expect(modal.getByLabel('Planning budget (USD/hour)')).toHaveValue('0.200000'); + await expect(modal.getByLabel('Planning budget (on-demand-equivalent $/hr)')).toHaveValue('0.200000'); await page.keyboard.press('Escape'); await expect(modal).toHaveCount(0); await expect(timeline.locator('[data-event="event-2"]')).toBeFocused();