Skip to content
Merged
70 changes: 68 additions & 2 deletions frontend/src/__tests__/commitmentOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import {
populatePaymentSelect,
getPaymentLabel,
normalizePaymentValue,
formatPaymentAdjustmentNotice,
CommitmentConfig,
PaymentOption,
TermOption
} from '../commitmentOptions';
import type { PaymentAdjustment } from '../api/types';

describe('commitmentOptions', () => {
describe('getCommitmentConfig', () => {
Expand Down Expand Up @@ -633,8 +635,14 @@ describe('commitmentOptions', () => {
expect(normalizePaymentValue('all-upfront', 'azure')).toBe('upfront');
});

it('should convert partial-upfront to upfront for Azure', () => {
expect(normalizePaymentValue('partial-upfront', 'azure')).toBe('upfront');
// Regression test for #1503: partial-upfront has no Azure equivalent
// and must land on 'monthly', never 'upfront'. Azure's billing plan is
// immutable after purchase and both plans cost the same total, so
// pre-selecting 'upfront' would hand the user an irreversible full
// upfront charge they never chose. Mirrors the Go-side assertion in
// internal/config/validation_test.go:TestNormalizePaymentOption.
it('should convert partial-upfront to monthly (not upfront) for Azure', () => {
expect(normalizePaymentValue('partial-upfront', 'azure')).toBe('monthly');
});

it('should convert no-upfront to monthly for Azure', () => {
Expand Down Expand Up @@ -815,4 +823,62 @@ describe('commitmentOptions', () => {
expect(isValidCombination('aws', 'rds', 1, 'partial-upfront')).toBe(true);
});
});

// #1503: the backend coerces a payment option the target provider cannot
// express (Azure has only Upfront/Monthly) instead of rejecting it. That is
// only acceptable if the change is disclosed, so this formatter is the copy
// every purchase-submit path renders. A null return means "nothing to say".
describe('formatPaymentAdjustmentNotice', () => {
const adj = (over: Partial<PaymentAdjustment> = {}): PaymentAdjustment => ({
rec_index: 0,
provider: 'azure',
service: 'vm',
requested_payment_option: 'partial-upfront',
applied_payment_option: 'monthly',
reason: 'no azure equivalent',
...over,
});

it('returns null when there is nothing to disclose', () => {
expect(formatPaymentAdjustmentNotice(undefined)).toBeNull();
expect(formatPaymentAdjustmentNotice([])).toBeNull();
});

it('names both the requested and the applied schedule for a single rec', () => {
const msg = formatPaymentAdjustmentNotice([adj()]);
// Human labels, not raw API tokens -- the user picked "Partial Upfront"
// in the UI, so that is the wording they will recognise.
expect(msg).toContain('Partial Upfront');
expect(msg).toContain('Pay Monthly');
expect(msg).toContain('AZURE');
expect(msg).toContain('vm');
// The raw tokens must not leak into user-facing copy.
expect(msg).not.toContain('partial-upfront');
});

it('collapses a batch to the distinct requested -> applied pairs', () => {
const msg = formatPaymentAdjustmentNotice([
adj({ rec_index: 0 }),
adj({ rec_index: 1 }),
adj({ rec_index: 2 }),
]);
// Count reflects every affected rec...
expect(msg).toContain('3 recommendations');
// ...but the identical mapping is stated once, not three times.
expect(msg?.match(/Partial Upfront/g)).toHaveLength(1);
});

it('lists every distinct mapping when a batch was coerced differently', () => {
const msg = formatPaymentAdjustmentNotice([
adj({ requested_payment_option: 'partial-upfront', applied_payment_option: 'monthly' }),
adj({ requested_payment_option: 'all-upfront', applied_payment_option: 'upfront' }),
]);
expect(msg).toContain('2 recommendations');
expect(msg).toContain('Partial Upfront');
expect(msg).toContain('All Upfront');
expect(msg).toContain('Pay Monthly');
expect(msg).toContain('Pay Upfront');
});
});

});
148 changes: 148 additions & 0 deletions frontend/src/__tests__/purchase-execution-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,15 @@ function lastToastMessage(): string | null {
return last?.querySelector('.toast-message')?.textContent ?? last?.textContent ?? null;
}

/** Return the text of every rendered toast, oldest first. */
function allToastMessages(): string[] {
const container = document.getElementById('toast-container');
if (!container) return [];
return Array.from(container.querySelectorAll('.toast')).map(
(t) => t.querySelector('.toast-message')?.textContent ?? t.textContent ?? '',
);
}

/** Return the kind class of the most recently rendered toast (success/error/warning). */
function lastToastKind(): string | null {
const container = document.getElementById('toast-container');
Expand Down Expand Up @@ -842,3 +851,142 @@ describe('handleExecutePurchase — double-submit guard (#644)', () => {
expect(btn.textContent).toBe('Send for Approval');
});
});

// ── #1503: payment-option coercion must be disclosed to the user ─────────────
//
// The backend does not reject a payment option the target provider cannot
// express (Azure has exactly two billing plans, so an inherited AWS-style
// 'partial-upfront' token has nowhere to land). It coerces to the closest
// supported schedule and reports it in `payment_adjustments`. That coercion is
// only defensible if the user is actually TOLD -- otherwise their billing
// schedule changes behind their back, which is the failure #1503 reported.
//
// These are the end-to-end guards: a green unit test on the formatter alone
// could not prove the notice survives to a toast the user actually sees.
describe('#1503 — payment-option coercion is disclosed in the purchase toast', () => {
function buildAzureBucket(id: string) {
return {
key: `key-${id}`,
label: `Bucket ${id}`,
provider: 'azure',
service: `svc-${id}`,
recs: [buildMinimalRec()],
payment: 'partial-upfront',
capacityPercent: 100,
};
}

beforeEach(() => {
jest.clearAllMocks();
(recs.getFanOutBuckets as jest.Mock).mockReturnValue([]);
(recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([buildMinimalRec()]);
(plans.closePurchaseModal as jest.Mock).mockImplementation(() => undefined);
});

afterEach(() => {
document.body.textContent = '';
});

test('single path — partial-upfront coerced to monthly raises a warning toast', async () => {
(api.executePurchase as jest.Mock).mockResolvedValue({
execution_id: 'exec-adj-1',
status: 'queued',
email_sent: true,
approval_recipient: 'approver@example.com',
payment_adjustments: [
{
rec_index: 0,
provider: 'azure',
service: 'vm',
requested_payment_option: 'partial-upfront',
applied_payment_option: 'monthly',
reason: 'payment option "partial-upfront" is not in azure\'s supported set',
},
],
});

const btn = setup();
btn.click();
await new Promise((r) => setTimeout(r, 0));

const messages = allToastMessages();
// The success toast must still be shown -- disclosure is additive.
expect(messages.some((m) => m.includes('Approval request sent to'))).toBe(true);
// ...and the coercion must be surfaced, naming both schedules so the user
// can tell what they asked for from what they are actually getting.
const notice = messages.find((m) => m.includes('Billing schedule adjusted'));
expect(notice).toBeDefined();
expect(notice).toContain('Partial Upfront');
expect(notice).toContain('Pay Monthly');
expect(notice).toContain('AZURE');
// Warning, and non-expiring: an irreversible billing-schedule change must
// not scroll away before the user reads it.
expect(lastToastKind()).toBe('warning');
});

test('single path — no adjustments means no extra toast (no noise on the common case)', async () => {
(api.executePurchase as jest.Mock).mockResolvedValue({
execution_id: 'exec-adj-2',
status: 'queued',
email_sent: true,
approval_recipient: 'approver@example.com',
// payment_adjustments intentionally absent: everything was canonical.
});

const btn = setup();
btn.click();
await new Promise((r) => setTimeout(r, 0));

const messages = allToastMessages();
expect(messages.some((m) => m.includes('Billing schedule adjusted'))).toBe(false);
expect(lastToastKind()).toBe('success');
});

test('fan-out path — adjustments from every bucket are disclosed, incl. email-failed buckets', async () => {
(recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([]);
(recs.getFanOutBuckets as jest.Mock).mockReturnValue([
buildAzureBucket('a'),
buildAzureBucket('b'),
]);

const adjustment = (recIndex: number) => ({
rec_index: recIndex,
provider: 'azure',
service: 'vm',
requested_payment_option: 'partial-upfront',
applied_payment_option: 'monthly',
reason: 'no azure equivalent',
});

(api.executePurchase as jest.Mock)
.mockResolvedValueOnce({
execution_id: 'exec-a',
status: 'queued',
email_sent: true,
approval_recipient: 'alice@example.com',
payment_adjustments: [adjustment(0)],
})
// Bucket b's approval email failed to send, but the pending execution
// still exists carrying the coerced schedule, so its adjustment must
// be disclosed too rather than dropped with the "failed" bucket.
.mockResolvedValueOnce({
execution_id: 'exec-b',
status: 'queued',
email_sent: false,
email_reason: 'SMTP timeout',
payment_adjustments: [adjustment(0)],
});

const btn = setup();
btn.click();
await new Promise((r) => setTimeout(r, 0));

const notice = allToastMessages().find((m) => m.includes('Billing schedule adjusted'));
expect(notice).toBeDefined();
// Both buckets contributed, so the count must be 2 -- proving the
// email-failed bucket's coercion was not silently dropped.
expect(notice).toContain('2 recommendations');
expect(notice).toContain('Partial Upfront');
expect(notice).toContain('Pay Monthly');
});
});
31 changes: 31 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,26 @@ export interface DeploymentInfo {
}

// Purchase types

/**
* PaymentAdjustment is one payment-option coercion notice returned by the
* purchase-execute endpoint (#1503 follow-up). Mirrors the backend
* PaymentAdjustment struct (internal/api/validation.go) field for field.
*
* Named (rather than inlined into PurchaseResult) so the presentation helper
* that turns these into user-facing copy
* (commitmentOptions.ts:formatPaymentAdjustmentNotice) can be typed and unit
* tested against the same shape the API actually returns.
*/
export interface PaymentAdjustment {
rec_index: number;
provider: string;
service: string;
requested_payment_option: string;
applied_payment_option: string;
reason: string;
}

export interface PurchaseResult {
execution_id: string;
status: string;
Expand All @@ -378,6 +398,17 @@ export interface PurchaseResult {
// True when the request was handled via the direct-execute path (issue
// #289). Absent (undefined) on the standard approval-required flow.
direct_execute?: boolean;
// Per-rec payment-option coercion notices (#1503 follow-up). Present only
// when the backend normalized a requested payment option onto a token that
// bills on a DIFFERENT schedule (e.g. Azure "partial-upfront" -> "monthly"):
// each entry names what was requested, what was actually applied, and why,
// so the UI can tell the user instead of silently changing the billing
// schedule. Absent (never an empty array) when every option was already
// canonical, and also when the backend only respelled a token into the
// provider's own vocabulary for the same schedule (Azure "all-upfront" ->
// "upfront") — that changes nothing the user pays, so warning about it
// would just teach them to dismiss the notice that matters.
payment_adjustments?: PaymentAdjustment[];
results?: Array<{
recommendation_id: string;
status: string;
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { loadHistory, setupHistoryHandlers } from './history';
import { initSavingsHistory } from './modules/savings-history';
import { setupRIExchangeHandlers, saveAutomationSettings } from './riexchange';
import { showToast } from './toast';
import { formatPaymentAdjustmentNotice } from './commitmentOptions';
import { confirmDialog } from './confirmDialog';
import { handlePurchaseDeeplink } from './purchases-deeplink';
import { handleArcheraDeeplink, openArcheraOfferModal } from './archera';
Expand Down Expand Up @@ -438,6 +439,16 @@ async function handleExecutePurchase(): Promise<void> {
timeout: 10_000,
});
}
// Disclose any payment-option coercion the backend applied (#1503). This
// is a SEPARATE, non-expiring warning toast rather than an addition to the
// success copy above: the billing schedule changing is the one thing the
// user did not ask for, and on Azure it cannot be changed after purchase,
// so it must not scroll away inside a success message.
const paymentNotice = formatPaymentAdjustmentNotice(result.payment_adjustments);
if (paymentNotice) {
showToast({ message: paymentNotice, kind: 'warning', timeout: null });
}

// Offer Archera Insurance immediately after the user approves the
// pre-purchase confirmation and the approval-submission call succeeds
// (issue #499 follow-up). Firing here, rather than after the async
Expand Down Expand Up @@ -550,6 +561,18 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
clearFanOutBuckets();
clearPurchaseModalRecommendations();

// Disclose payment-option coercions across every bucket that reached the
// backend (#1503). Collected from all fulfilled responses, not just the
// truly-succeeded ones: a bucket whose approval email failed to send still
// created a pending execution carrying the coerced billing schedule, so the
// user needs to know before they approve it from History.
const fanOutNotice = formatPaymentAdjustmentNotice(
fulfilled.flatMap((r) => r.value.payment_adjustments ?? []),
);
if (fanOutNotice) {
showToast({ message: fanOutNotice, kind: 'warning', timeout: null });
}

if (failed === 0) {
// Collect the unique approval-recipient set from truly-succeeded responses
// only (email_sent !== false and status !== 'failed') so the toast doesn't
Expand Down
Loading
Loading