From 12c64f8c89c9bd69a41a822b76ddd36d021a4b30 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 25 Apr 2026 14:43:15 +0200 Subject: [PATCH] ux(settings): add payment option selector to AWS account overrides modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Settings → Accounts → Overrides panel previously listed each per-service override as a static row showing Term / Payment / Coverage with no way to edit the payment option. AWS teams running mixed payment strategies across accounts (No Upfront in dev, All Upfront in prod) had no UI path to set this per account — they had to call the API directly. This change replaces the read-only Payment cell with a ` with Inherit + 3 AWS options for AWS overrides', async () => { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'ec2' }, + ]); + + const panel = await openOverridesPanel('acc-1'); + const select = panel.querySelector('select.override-payment-select') as HTMLSelectElement | null; + expect(select).not.toBeNull(); + const values = Array.from(select!.options).map(o => o.value); + expect(values).toEqual(['', 'no-upfront', 'partial-upfront', 'all-upfront']); + expect(select!.value).toBe(''); // Inherit by default when no payment set + expect(select!.options[0]!.disabled).toBe(false); + }); + + test('changing payment from Inherit calls saveAccountServiceOverride with only {payment}', async () => { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'rds', term: 1 }, + ]); + (api.saveAccountServiceOverride as jest.Mock).mockResolvedValue({ + id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'rds', term: 1, payment: 'all-upfront', + }); + + const panel = await openOverridesPanel('acc-1'); + const select = panel.querySelector('select.override-payment-select') as HTMLSelectElement; + select.value = 'all-upfront'; + select.dispatchEvent(new Event('change')); + await new Promise(r => setTimeout(r, 0)); + + expect(api.saveAccountServiceOverride).toHaveBeenCalledTimes(1); + expect(api.saveAccountServiceOverride).toHaveBeenCalledWith( + 'acc-1', 'aws', 'rds', { payment: 'all-upfront' }, + ); + }); + + test('Inherit is disabled when override already has a payment set (no clear-field channel)', async () => { + (api.listAccountServiceOverrides as jest.Mock).mockResolvedValue([ + { id: 'o1', account_id: 'acc-1', provider: 'aws', service: 'ec2', payment: 'partial-upfront' }, + ]); + + const panel = await openOverridesPanel('acc-1'); + const select = panel.querySelector('select.override-payment-select') as HTMLSelectElement; + expect(select.value).toBe('partial-upfront'); + expect(select.options[0]!.disabled).toBe(true); + // The selector still does NOT call save on initial render (no change yet). + expect(api.saveAccountServiceOverride).not.toHaveBeenCalled(); + }); + + test('non-AWS rows render the existing read-only payment cell, no for the overrides panel. Issue #23. + * + * - "Inherit (default)" keeps the override silent on payment so the global + * default applies. We only emit it as the chosen value when the override + * currently has no payment set; once a payment is set, switching back + * would require deleting/recreating the override (out of scope for #23). + * - The change handler PUTs only `{ payment }` — the backend's + * applyOverrideScalars (handler_accounts.go) treats unset request fields + * as "leave alone", so other override fields (term, coverage, etc.) are + * preserved. + */ +function buildPaymentOverrideSelect( + accountId: string, + override: api.AccountServiceOverride, + panel: HTMLElement, +): HTMLSelectElement { + const select = document.createElement('select'); + select.className = 'override-payment-select'; + select.setAttribute( + 'aria-label', + `Payment option for ${override.provider}/${override.service} override`, + ); + + const inheritOpt = document.createElement('option'); + inheritOpt.value = ''; + inheritOpt.textContent = 'Inherit (default)'; + select.appendChild(inheritOpt); + + for (const { value, label } of AWS_PAYMENT_OPTIONS) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + select.appendChild(opt); + } + + const initial = override.payment ?? ''; + select.value = initial; + select.dataset['previous'] = initial; + + // If a payment is already set, "Inherit" is not a clean transition (the + // backend has no clear-field channel — it would require resetting the + // whole override). Disable the option so the user can still pick another + // payment value but can't accidentally pick a no-op state. + if (initial !== '') { + inheritOpt.disabled = true; + inheritOpt.title = 'Use Reset to clear all override fields including payment'; + } + + select.addEventListener('change', () => { + void handlePaymentOverrideChange(accountId, override, select, panel); + }); + + return select; +} + +async function handlePaymentOverrideChange( + accountId: string, + override: api.AccountServiceOverride, + select: HTMLSelectElement, + panel: HTMLElement, +): Promise { + const previous = select.dataset['previous'] ?? ''; + const next = select.value; + if (next === previous) return; + if (next === '') { + // Reverting to Inherit while a payment is set is blocked above; defensive + // no-op here keeps types narrow if the option becomes selectable later. + select.value = previous; + return; + } + select.disabled = true; + try { + await api.saveAccountServiceOverride(accountId, override.provider, override.service, { + payment: next, + }); + select.dataset['previous'] = next; + showToast({ + message: `Payment override updated for ${override.provider}/${override.service}.`, + kind: 'success', + }); + await loadOverridesPanel(accountId, panel); + } catch (err) { + select.value = previous; + showToast({ + message: `Failed to update payment override: ${(err as Error).message}`, + kind: 'error', + }); + } finally { + select.disabled = false; + } +} + /** * Load and render the service overrides panel for an account */ @@ -451,12 +553,24 @@ async function loadOverridesPanel(accountId: string, panel: HTMLElement): Promis [ `${o.provider}/${o.service}`, o.term !== undefined ? `${o.term}yr` : '\u2014', - o.payment ?? '\u2014', - o.coverage !== undefined ? `${o.coverage}%` : '\u2014', ].forEach(text => { const td = tr.insertCell(); td.textContent = text; }); + + // Payment cell: editable