Skip to content

fix(azure): coerce web partial-upfront to monthly, not upfront (#1503) - #1504

Merged
cristim merged 9 commits into
mainfrom
fix/azure-web-partial-upfront
Jul 28, 2026
Merged

fix(azure): coerce web partial-upfront to monthly, not upfront (#1503)#1504
cristim merged 9 commits into
mainfrom
fix/azure-web-partial-upfront

Conversation

@cristim

@cristim cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

  • The web/API purchase path (internal/api/validation.go -> config.NormalizePaymentOption -> internal/config/validation.go:crossProviderPaymentAlias) coerced Azure partial-upfront to upfront. On current main the normalized token drives the Azure upfront-vs-monthly cost split (GetOfferingDetails) and the persisted rec token; once feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP #1495's Azure billingPlan wiring lands it also selects the actual reservation billing plan, so the old coercion silently billed an all-upfront schedule the caller never chose.
  • Change the Azure partial-upfront mapping to monthly (no-upfront, CUDly's default Azure schedule) instead of upfront. The rec still survives validation, but lands on the default schedule rather than silently flipping to upfront. Azure charges the same total for both schedules, so this is a cash-flow-timing fix, not an overcharge fix.
  • GCP's mapping (partial-upfront -> monthly) and AWS (no coercion; AWS supports partial-upfront natively) are unchanged, verified by the existing/updated regression tests.
  • Note: issue fix(azure): web path silently coerces partial-upfront to all-upfront (bill schedule mismatch) #1503's body sketches a "reject partial-upfront outright" fix for the web path. Per owner direction, this PR instead coerces to monthly (consistent with the GCP precedent and the CUDly-wide no-upfront default) rather than failing loud.

Rebase status: originally stacked on #1495 (docs/mcp-server-design); since rebased onto main, and the PR base is main. No longer blocked on anything: it is fully effective on main today, and once #1495's billingPlan wiring lands (that mapping hard-errors on partial-upfront), this normalization is also what keeps web-path Azure partial-upfront purchases valid.

Closes #1503

Test plan

  • crossProviderPaymentAlias("azure", "partial-upfront") now returns monthly (was upfront); confirmed the updated test fails against the pre-change code and passes post-change.
  • Azure upfront and no-upfront/monthly mappings unchanged.
  • GCP mapping unchanged (regression guard, all tokens -> monthly).
  • Updated internal/api/handler_purchases_guards_test.go's Azure legacy-partial-upfront case to assert the coerced value is monthly, not upfront.
  • go build ./..., go vet ./... clean.
  • go test ./internal/config/... ./internal/api/... ./internal/scheduler/... ./providers/azure/...: 3363 passed, 0 failed.
  • golangci-lint v2.10.1 (pinned, matching CI's ci.yml) run at repo root: 0 issues.
  • gocyclo -over 10 internal/config/: clean.

Summary by CodeRabbit

  • New Features
    • During purchase execution, show a non-expiring warning toast (“Billing schedule adjusted”) when backend coercion changes the effective billing schedule, including requested vs applied details and fan-out aggregation.
  • Bug Fixes
    • Corrected legacy Azure payment option normalization: partial-upfront now maps to monthly (not upfront); all-upfront remains upfront.
  • Validation / Logging
    • Emit a WARN when an incoming payment option is rewritten to a provider-canonical value, and return payment adjustments in API responses only when the billing schedule meaning changes.
  • Tests
    • Expanded coverage for normalization, WARN behavior, adjustment formatting, and toast disclosure.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/few Limited audience effort/s Hours type/bug Defect labels Jul 23, 2026
@cristim

cristim commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Azure partial-upfront now normalizes to monthly. Purchase validation logs and records schedule-changing coercions, propagates payment_adjustments through execution responses, and displays them in frontend warning toasts.

Changes

Payment adjustment disclosure

Layer / File(s) Summary
Canonical payment mapping
internal/config/validation.go, internal/config/validation_test.go, frontend/src/commitmentOptions.ts, frontend/src/__tests__/commitmentOptions.test.ts
Azure aliases and payment schedules are normalized consistently, with schedule changes distinguished from equivalent token renames.
Backend adjustment propagation
internal/api/validation.go, internal/api/handler_purchases.go
Validation logs rewritten tokens, returns adjustment metadata, and includes it in purchase execution responses when applicable.
Frontend notice rendering
frontend/src/api/types.ts, frontend/src/commitmentOptions.ts, frontend/src/app.ts
Purchase results model adjustments, format notices, and display non-expiring warnings for single and fan-out execution.
Regression and UI coverage
internal/api/*test.go, frontend/src/__tests__/*
Tests cover normalization, warnings, response fields, notice formatting, toast behavior, and updated direct-execute calls.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR fixes the all-upfront bug, but it still accepts Azure partial-upfront by mapping it to monthly instead of rejecting it. Reject Azure partial-upfront in web/API validation, or update the linked issue criteria to allow monthly remapping.
Docstring Coverage ⚠️ Warning Docstring coverage is 58.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the Azure web-path payment normalization change and matches the main fix.
Out of Scope Changes check ✅ Passed The added API/frontend disclosure work and tests support the payment-normalization fix and do not introduce unrelated changes.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/azure-web-partial-upfront

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
mcp/tools/aws_rds_ri.go (1)

154-162: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated dry_run/confirm defaulting.

Same identical defaulting block as in aws_ec2_ri.go, aws_elasticache_ri.go, aws_savingsplans.go, and aws_simple_ri.go; candidate for a shared helper (see consolidated comment).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_rds_ri.go` around lines 154 - 162, Extract the duplicated
dryRun and confirm defaulting logic from the current function into a shared
helper, then reuse that helper here and in the corresponding AWS RI and
savings-plan tools. Preserve the defaults of dryRun=true and confirm=false,
while allowing non-nil args.DryRun and args.Confirm values to override them.
mcp/tools/aws_elasticache_ri.go (1)

147-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated dry_run/confirm defaulting.

This 6-line block is identical to the same defaulting logic in aws_ec2_ri.go, aws_rds_ri.go, aws_savingsplans.go, and aws_simple_ri.go. Worth extracting into a single shared helper (see consolidated comment).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_elasticache_ri.go` around lines 147 - 155, Extract the
duplicated dryRun and confirm defaulting logic from the current function into a
shared helper reusable by aws_ec2_ri.go, aws_rds_ri.go, aws_savingsplans.go, and
aws_simple_ri.go. Update each caller, including the current Elasticache RI path,
to use the helper while preserving the existing defaults and pointer override
behavior.
mcp/tools/aws_simple_ri.go (1)

186-194: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated dry_run/confirm defaulting.

Same identical defaulting block as in aws_ec2_ri.go, aws_elasticache_ri.go, aws_rds_ri.go, and aws_savingsplans.go; candidate for a shared helper (see consolidated comment).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_simple_ri.go` around lines 186 - 194, The dryRun and confirm
defaulting logic at the end of the relevant tool flow duplicates the same
behavior across aws_ec2_ri.go, aws_elasticache_ri.go, aws_rds_ri.go, and
aws_savingsplans.go. Extract or reuse a shared helper for applying these
optional argument defaults, then update this flow and the other identified tools
to call it while preserving true/false defaults and explicit argument overrides.
mcp/tools/aws_ec2_ri.go (2)

107-158: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the dry_run/confirm defaulting into a shared helper.

effectiveDryRunConfirm here is the same 3-branch defaulting logic (dryRun=true, confirm=false, overridden by non-nil pointers) that is copy-pasted inline in aws_elasticache_ri.go, aws_rds_ri.go, aws_savingsplans.go, and aws_simple_ri.go. Since this file already took the step of extracting it into a named function, it's the natural place to generalize it (e.g. take *bool, *bool instead of the whole args struct) so all 5 tools call one shared helper instead of maintaining 5 copies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_ec2_ri.go` around lines 107 - 158, Generalize
effectiveDryRunConfirm into a shared helper that accepts the dry-run and confirm
boolean pointers, applies dryRun=true and confirm=false defaults, and preserves
non-nil overrides. Update effectiveDryRunConfirm and the callers in
aws_elasticache_ri.go, aws_rds_ri.go, aws_savingsplans.go, and aws_simple_ri.go
so all five tools use the single helper without duplicating the defaulting
logic.

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the dry_run/confirm defaulting boilerplate into one shared helper. All five AWS purchase tools duplicate the identical logic for resolving dryRun/confirm from optional *bool args (default true/false, overridden only when the caller's pointer is non-nil). One shared helper eliminates five copies and guarantees the default behavior can't silently drift between tools as they evolve.

  • mcp/tools/aws_ec2_ri.go#L145-158: generalize effectiveDryRunConfirm to take (dryRun, confirm *bool) (bool, bool) instead of the whole ec2RIPurchaseArgs, and have this file call the generalized version.
  • mcp/tools/aws_elasticache_ri.go#L147-155: replace the inline dryRun, confirm = true, false; if args.DryRun != nil {...}; if args.Confirm != nil {...} block with a call to the shared helper.
  • mcp/tools/aws_rds_ri.go#L154-162: replace the same inline block with a call to the shared helper.
  • mcp/tools/aws_savingsplans.go#L193-201: replace the same inline block with a call to the shared helper.
  • mcp/tools/aws_simple_ri.go#L186-194: replace the same inline block with a call to the shared helper.
♻️ Proposed shared helper
// resolveDryRunConfirm applies the dry_run=true / confirm=false defaults:
// bool zero values cannot distinguish "caller omitted the field" from
// "caller explicitly set it false", so both flags are pointers and this is
// the single place that resolves them to concrete booleans.
func resolveDryRunConfirm(dryRun, confirm *bool) (bool, bool) {
	d := true
	if dryRun != nil {
		d = *dryRun
	}
	c := false
	if confirm != nil {
		c = *confirm
	}
	return d, c
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_ec2_ri.go` at line 1, Extract the duplicated dryRun/confirm
default resolution into a shared resolveDryRunConfirm helper accepting (*bool,
*bool) and returning (bool, bool), preserving defaults of true and false and
non-nil overrides. Update effectiveDryRunConfirm in the EC2 purchase flow to use
the generalized helper, then replace the inline defaulting blocks in the
ElastiCache, RDS, Savings Plans, and Simple RI purchase tools with calls to
resolveDryRunConfirm.
mcp/tools/aws_savingsplans.go (1)

193-201: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated dry_run/confirm defaulting.

Same identical defaulting block as in aws_ec2_ri.go, aws_elasticache_ri.go, aws_rds_ri.go, and aws_simple_ri.go; candidate for a shared helper (see consolidated comment).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcp/tools/aws_savingsplans.go` around lines 193 - 201, Extract the duplicated
dryRun and confirm defaulting logic from the current function into a shared
helper, reusing the same helper across aws_ec2_ri.go, aws_elasticache_ri.go,
aws_rds_ri.go, aws_simple_ri.go, and this Savings Plans flow. Preserve the
defaults of true for dryRun and false for confirm, while allowing non-nil
argument values to override them.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcp/server_test.go`:
- Around line 173-223: Update mcp/server_test.go lines 173-223 in
TestAzureComputeRIPurchaseSchemaExcludesPartialUpfront to verify Azure
advertises and accepts partial-upfront, and assert the resulting billing plan is
normalized to monthly/no-upfront rather than rejected. Update mcp/README.md line
107 to document this Azure partial-upfront normalization behavior. Rename or
replace the test to reflect the new contract.

In `@mcp/tools/search_recommendations.go`:
- Around line 128-145: Validate non-empty lookback_period in the search
recommendations handler before provider creation, accepting only 7d, 30d, or 60d
and returning an error for other values. Add a regression test in
mcp/tools/search_recommendations_test.go covering an invalid lookback value and
asserting the handler returns an error.

---

Nitpick comments:
In `@mcp/tools/aws_ec2_ri.go`:
- Around line 107-158: Generalize effectiveDryRunConfirm into a shared helper
that accepts the dry-run and confirm boolean pointers, applies dryRun=true and
confirm=false defaults, and preserves non-nil overrides. Update
effectiveDryRunConfirm and the callers in aws_elasticache_ri.go, aws_rds_ri.go,
aws_savingsplans.go, and aws_simple_ri.go so all five tools use the single
helper without duplicating the defaulting logic.
- Line 1: Extract the duplicated dryRun/confirm default resolution into a shared
resolveDryRunConfirm helper accepting (*bool, *bool) and returning (bool, bool),
preserving defaults of true and false and non-nil overrides. Update
effectiveDryRunConfirm in the EC2 purchase flow to use the generalized helper,
then replace the inline defaulting blocks in the ElastiCache, RDS, Savings
Plans, and Simple RI purchase tools with calls to resolveDryRunConfirm.

In `@mcp/tools/aws_elasticache_ri.go`:
- Around line 147-155: Extract the duplicated dryRun and confirm defaulting
logic from the current function into a shared helper reusable by aws_ec2_ri.go,
aws_rds_ri.go, aws_savingsplans.go, and aws_simple_ri.go. Update each caller,
including the current Elasticache RI path, to use the helper while preserving
the existing defaults and pointer override behavior.

In `@mcp/tools/aws_rds_ri.go`:
- Around line 154-162: Extract the duplicated dryRun and confirm defaulting
logic from the current function into a shared helper, then reuse that helper
here and in the corresponding AWS RI and savings-plan tools. Preserve the
defaults of dryRun=true and confirm=false, while allowing non-nil args.DryRun
and args.Confirm values to override them.

In `@mcp/tools/aws_savingsplans.go`:
- Around line 193-201: Extract the duplicated dryRun and confirm defaulting
logic from the current function into a shared helper, reusing the same helper
across aws_ec2_ri.go, aws_elasticache_ri.go, aws_rds_ri.go, aws_simple_ri.go,
and this Savings Plans flow. Preserve the defaults of true for dryRun and false
for confirm, while allowing non-nil argument values to override them.

In `@mcp/tools/aws_simple_ri.go`:
- Around line 186-194: The dryRun and confirm defaulting logic at the end of the
relevant tool flow duplicates the same behavior across aws_ec2_ri.go,
aws_elasticache_ri.go, aws_rds_ri.go, and aws_savingsplans.go. Extract or reuse
a shared helper for applying these optional argument defaults, then update this
flow and the other identified tools to call it while preserving true/false
defaults and explicit argument overrides.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47216eb8-9e44-466d-9e0e-e306012bd3bc

📥 Commits

Reviewing files that changed from the base of the PR and between d9eb097 and 303dde8.

⛔ Files ignored due to path filters (2)
  • go.sum is excluded by !**/*.sum
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (41)
  • README.md
  • cmd/cudly-mcp/main.go
  • cmd/cudly-mcp/main_test.go
  • go.mod
  • internal/api/handler_purchases_guards_test.go
  • internal/config/validation.go
  • internal/config/validation_test.go
  • mcp/README.md
  • mcp/server.go
  • mcp/server_test.go
  • mcp/tools/aws_ec2_ri.go
  • mcp/tools/aws_ec2_ri_test.go
  • mcp/tools/aws_elasticache_ri.go
  • mcp/tools/aws_elasticache_ri_test.go
  • mcp/tools/aws_rds_ri.go
  • mcp/tools/aws_rds_ri_test.go
  • mcp/tools/aws_savingsplans.go
  • mcp/tools/aws_savingsplans_test.go
  • mcp/tools/aws_simple_ri.go
  • mcp/tools/aws_simple_ri_test.go
  • mcp/tools/azure_compute_ri.go
  • mcp/tools/azure_compute_ri_test.go
  • mcp/tools/enums.go
  • mcp/tools/enums_test.go
  • mcp/tools/gcp_computeengine_cud.go
  • mcp/tools/gcp_computeengine_cud_test.go
  • mcp/tools/list_commitment_actions.go
  • mcp/tools/list_commitment_actions_test.go
  • mcp/tools/purchase.go
  • mcp/tools/purchase_test.go
  • mcp/tools/registry.go
  • mcp/tools/schema.go
  • mcp/tools/schema_test.go
  • mcp/tools/search_recommendations.go
  • mcp/tools/search_recommendations_test.go
  • pkg/common/types.go
  • pkg/common/types_test.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/client_test.go
  • providers/azure/services/internal/reservations/purchase.go
  • providers/azure/services/internal/reservations/purchase_test.go

Comment thread mcp/server_test.go Outdated
Comment thread mcp/tools/search_recommendations.go Outdated
@cristim
cristim force-pushed the fix/azure-web-partial-upfront branch from 303dde8 to 820ecb4 Compare July 25, 2026 23:30
@cristim

cristim commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/config/validation.go`:
- Around line 114-118: Update the caller of validatePurchaseRecommendation to
log a warning containing the raw and canonical payment values whenever
normalization changes rec.Payment, before overwriting it; otherwise remove or
revise the validation comment’s promise that callers emit this warning.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7dea9abd-669d-40b7-9c9c-b8ec1d6638d7

📥 Commits

Reviewing files that changed from the base of the PR and between d9eb097 and 820ecb4.

📒 Files selected for processing (3)
  • internal/api/handler_purchases_guards_test.go
  • internal/config/validation.go
  • internal/config/validation_test.go

Comment thread internal/config/validation.go Outdated
@cristim

cristim commented Jul 25, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim force-pushed the fix/azure-web-partial-upfront branch from 14ae1b1 to fbf05f9 Compare July 27, 2026 10:21
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 6 commits July 27, 2026 20:21
The coercion-rationale comments assert the normalized payment option is
"not a billingPlan request field". That is accurate on main today (the
reservation purchase body sends no billingPlan), but becomes false once
the billingPlan wiring lands (#1495/#1502), and that wiring does not
touch this file, so the comment would silently go stale on a money-path
rationale. Rephrase to state the forward-compatible fact instead: the
wiring maps only upfront/monthly tokens and hard-errors on
partial-upfront, which is exactly why normalizing to monthly here keeps
web-path Azure purchases valid.

Comment-only change; no behavior difference.
The web execute path silently normalizes cross-provider payment tokens
(most notably Azure partial-upfront -> monthly, #1503); until now only
operators saw the WARN log while the caller's response gave no hint the
billing schedule they requested was changed. Return the coercion to the
caller: validatePurchaseRecommendation now also yields a
PaymentAdjustment (rec index, provider, service, requested token,
applied token, reason) whenever normalization changes the token, and
executePurchase attaches the collected list to all three response
bodies (approval-pending, duplicate-collapse, direct-execute) under
payment_adjustments. The key is omitted entirely when every option was
already canonical, so existing clients are unaffected on the common
case, and the coercion policy itself (config.NormalizePaymentOption)
plus the operator WARN log are unchanged.

Tests: the guards table now cross-checks every coerced row surfaces a
matching adjustment and every canonical row surfaces none; new
field-level unit test for the Azure partial-upfront case; new
handler-level tests assert the response body carries the adjustment for
a mixed canonical+coerced batch (verified to fail with the attach
stubbed out) and omits the key for a canonical-only batch, both with
mock.AssertExpectations registered. frontend PurchaseResult gains the
matching optional payment_adjustments field.
The Go side of #1503 was fixed to map Azure partial-upfront onto
"monthly", but normalizePaymentValue in the frontend still mapped it
onto "upfront", so the two halves of the web path disagreed.

That function decides which option the plan and purchase dropdowns
pre-select (plans.ts:1166 on plan edit, plans.ts:1562 when prefilling
from a selected commitment), so a rec carrying the legacy
partial-upfront token pre-selected "Pay Upfront". Saving from that
state submits an already-canonical "upfront", which the backend
accepts verbatim, so the backend coercion never gets a chance to run
and the user is committed to a full upfront charge they never chose.

Azure offers exactly two billing plans and the total cost is identical
either way, but the plan cannot be changed after purchase, so landing
on monthly is the only direction that cannot surprise the user with an
irreversible upfront charge.

The jest case that pinned the old expectation is updated and now
documents why monthly is required, mirroring the Go-side assertion in
TestNormalizePaymentOption. Verified failing before this change and
passing after.

Refs #1503
The doc comments justifying the partial-upfront to monthly coercion
cited providers/azure/services/compute/client.go's GetOfferingDetails
as the consumer that "drives the upfront-vs-monthly cost split".
GetOfferingDetails has no non-test callers anywhere in the repo; it
exists only to satisfy the ServiceClient interface, so the stated
mechanism does not run. On a money path a wrong rationale is worse
than none, since the next reader reasons from it.

Replace it with what actually consumes the canonical token today
(persisted on the execution, then copied into
common.Recommendation.PaymentOption in internal/purchase/execution.go)
and the billingPlan wiring arriving in #1495/#1502.

Also record the two Microsoft-documented facts the decision rests on:
upfront and monthly cost the same total, and the billing frequency
cannot be changed after purchase. Note the documented exception that
monthly is unavailable for SUSE Linux, Red Hat, Azure Red Hat
OpenShift and pre-purchase plans, where the coerced token makes Azure
reject the purchase; that loud rejection is the intended outcome.

Adds the cross-reference to the frontend mirror of this mapping so the
two stay in lockstep.

Comments only, no behaviour change.

Refs #1503
The backend already returns `payment_adjustments` when it normalizes a
requested payment option onto a different provider-canonical token (Azure
has exactly two billing plans, Upfront and Monthly, so an inherited
AWS-style `partial-upfront` has nowhere to land and is coerced to
monthly). The field was declared in `frontend/src/api/types.ts` and read
by nothing, so the coercion was disclosed to API clients and to the
operator WARN log but never to the web user whose billing schedule
actually changed.

Coercing instead of rejecting is only defensible if the user is told, so
this closes that gap:

- Add `formatPaymentAdjustmentNotice` next to `normalizePaymentValue`,
  the mapping it discloses. Reuses the existing `getPaymentLabel` so the
  copy shows "Partial Upfront"/"Pay Monthly" rather than raw API tokens,
  and collapses a batch to its distinct requested -> applied pairs.
- Render it as a separate non-expiring warning toast on both purchase
  submit paths (single and fan-out) so an irreversible billing-schedule
  change cannot scroll away inside a success message. The fan-out path
  collects from every fulfilled response, including buckets whose
  approval email failed, since those still created a pending execution
  carrying the coerced schedule.
- Extract the inline `payment_adjustments` element type into a named
  `PaymentAdjustment` interface so the formatter is typed against the
  same shape the API returns.

Regression coverage: the two disclosure tests in
purchase-execution-toast.test.ts fail with the wiring removed and pass
with it, exercising the real handler through to the rendered toast; a
formatter-only unit test could not prove the notice reaches the user.
The #1503 disclosure fired on every raw != canonical rewrite, including
the cross-provider renames that leave the customer's cash flow untouched.
Azure spells AWS's all-upfront "upfront" and AWS's no-upfront "monthly",
so both of those rewrites are bookkeeping, not a billing change.

That mattered on the ordinary path, not an edge case: the fan-out
purchase modal builds its per-bucket Payment dropdown from
paymentOptionsFor (frontend/src/lib/purchase-compatibility.ts), whose
candidate list has no "upfront" entry. An Azure bucket the user chooses
to pay upfront therefore ALWAYS submits "all-upfront", so every ordinary
Azure upfront purchase raised a sticky "Billing schedule adjusted"
warning claiming Azure "does not offer All Upfront" and that the purchase
was applied as something else. Both claims are false, and the noise
trains users to dismiss the one notice that is real.

Classify a payment token by the cash flow it implies
(config.PaymentScheduleFor) and surface a PaymentAdjustment only when
the rewrite crosses schedules (config.PaymentCoercionChangesSchedule).
Azure partial-upfront -> monthly and GCP upfront -> monthly still
disclose; Azure all-upfront -> upfront and no-upfront -> monthly no
longer do.

The operator WARN keeps firing on every rewrite: a non-canonical token
on the wire is an upstream input bug worth auditing even when it costs
the customer nothing. Only the user-facing notice is narrowed.

Adjustment construction moves into paymentAdjustmentFor so
validatePurchaseRecommendation stays at gocyclo 10 (the pre-commit
threshold), not 11.

Regression coverage: the three rename cases assert nil adjustment plus a
still-emitted WARN, and the GCP upfront-shaped tokens assert a surfaced
adjustment. Both new tests fail against the previous raw != canonical
condition and pass after (verified by reverting the condition).
@cristim
cristim force-pushed the fix/azure-web-partial-upfront branch from 8e0c580 to 84a55ac Compare July 27, 2026 18:27
@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Rebased onto main8e0c58084a55ac

#1529 (grpc → v1.82.1, clearing GO-2026-6061) merged as 7b18bfdef, so the external blocker on this PR is gone. Rebased onto origin/main, no conflicts across all 9 commits. History was rewritten by the rebase, so the push was --force-with-lease (never a bare --force), and the lease was verified against 8e0c580 immediately beforehand.

Both go.mod and providers/gcp/go.mod now carry google.golang.org/grpc v1.82.1, which is what Security Scanning was failing on. CI Success was failing only as the downstream aggregate, so the expectation is that both clear together on this head.

Diff is unchanged in content — same 12 files, no code was touched by the rebase. Nothing to re-review beyond what the previous pass covered.

Local gates re-run on the rebased head before pushing:

  • go build ./... — exit 0 against the new grpc.
  • golangci-lint v2.10.1 (CI-pinned) at repo root — exit 0, 0 issues.
  • gocyclo -over 10 -ignore "_test\.go" — no CUDly source file over threshold.
  • go test ./internal/config/... ./internal/api/... (the only two packages this PR changes) — both ok.
  • Frontend: eslint exit 0, tsc --noEmit clean, jest 2728 passed, with the same 3 suites / 8 locale failures that origin/main produces on this machine (verified by running those suites directly on a main worktree: identical 3 failed, 8 tests failed).

In the interest of not overstating: the full go test ./... and the six-module govulncheck sweep are still running locally — this box is heavily contended right now, and rerunning them serially would have delayed CI by another hour for a re-confirmation of what CI runs authoritatively anyway. I'll post their results when they land. Everything above is a completed run with its exit code, not an inference.


@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the rebased head, with attention to the unchanged payment-normalization and disclosure paths against the updated main baseline.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 49 seconds.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current rebased head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 42 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current rebased head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 27 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current rebased head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 11 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current rebased head.

✅ Action performed

Full review finished.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current rebased head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 48 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current PR head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 38 minutes.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current PR head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 32 minutes.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current PR head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 19 minutes.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cristim: Triggering a full review of the current PR head.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/api/handler_purchases_test.go (1)

4113-4113: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover the direct-execute payment_adjustments case directExecutePurchase already forwards paymentAdjustments into withPaymentAdjustments(...), but the direct-execute tests still only exercise the nil path. Add one case with a populated slice so this response shape is covered on the direct branch too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_purchases_test.go` at line 4113, Add a direct-execution
test case around directExecutePurchase that supplies a populated
paymentAdjustments slice, ensuring the forwarded value reaches
withPaymentAdjustments and the resulting response shape is asserted. Retain the
existing nil-path coverage and follow the surrounding direct-execute test setup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/handler_purchases_test.go`:
- Line 4113: Add a direct-execution test case around directExecutePurchase that
supplies a populated paymentAdjustments slice, ensuring the forwarded value
reaches withPaymentAdjustments and the resulting response shape is asserted.
Retain the existing nil-path coverage and follow the surrounding direct-execute
test setup.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e5be3d93-60db-4e6f-bcf7-2b86a2034852

📥 Commits

Reviewing files that changed from the base of the PR and between 7b18bfd and 84a55ac.

📒 Files selected for processing (12)
  • frontend/src/__tests__/commitmentOptions.test.ts
  • frontend/src/__tests__/purchase-execution-toast.test.ts
  • frontend/src/api/types.ts
  • frontend/src/app.ts
  • frontend/src/commitmentOptions.ts
  • internal/api/executed_notification_flow_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_guards_test.go
  • internal/api/handler_purchases_test.go
  • internal/api/validation.go
  • internal/config/validation.go
  • internal/config/validation_test.go

@cristim
cristim merged commit 3e9660d into main Jul 28, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(azure): web path silently coerces partial-upfront to all-upfront (bill schedule mismatch)

1 participant