Skip to content

feat(azure): wire payment_option to reservation billingPlanType (Azure RIs always bill upfront) #1502

Description

@cristim

Gap

Azure Reserved Instance purchases always bill upfront regardless of the requested payment_option. The purchase body builder (providers/azure/services/compute/client.go, buildReservationBody) is called with opts.Source and opts.IdempotencyToken but never receives opts.PaymentOption, so Azure's reservation API sends no billingPlanType and defaults to upfront.

Impact

  • A caller requesting no-upfront or partial-upfront for an Azure RI would silently get an upfront commitment (different cash-flow schedule than requested).
  • Currently guarded, not honored: the CUDly MCP server (PR feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP #1495) fails loud and rejects non-all-upfront Azure RI real purchases rather than silently mis-billing. So there is no active money-safety hole; this issue is to actually support the other billing plans.

Fix

  • Thread opts.PaymentOption into buildReservationBody and map it to Azure's billingPlanType (Upfront vs Monthly) on the reservation purchase request. Azure reservations support Upfront and Monthly billing plans; map all-upfront -> Upfront, no-upfront/partial-upfront -> the closest supported plan (Monthly), or reject combinations Azure genuinely cannot express (fail loud, no silent default).
  • Use the SDK's typed billing-plan enum, not a raw string literal (project convention).
  • Once wired, remove the MCP tool's real-purchase rejection of non-all-upfront in mcp/tools/azure_compute_ri.go and let the value flow through.

Scope note

This is a provider-layer change that also benefits the CLI and web (internal/api) purchase paths, not just MCP. Verify against Azure's reservation billing-plan API surface before wiring; do not assume the mapping.

Related

Activity

  1. cristim commented on Jul 23, 2026

    @cristim
    MemberAuthor

    Scope widened: the same billingPlan omission affects 6 sibling Azure services

    The compute wiring is landing in PR #1495 (new helper reservations.BillingPlanForPaymentOption + billingPlan set in compute's buildReservationBody). An adversarial review confirmed the other six Azure reservation-based services build their own request bodies and never set billingPlan, so a no-upfront/monthly recommendation for any of them is still silently billed at Azure's Upfront default:

    • providers/azure/services/database
    • providers/azure/services/cache
    • providers/azure/services/cosmosdb
    • providers/azure/services/search
    • providers/azure/services/managedredis
    • providers/azure/services/synapse

    Remaining work for this issue

    Wire each of the six clients' reservation-body builders through the shared reservations.BillingPlanForPaymentOption helper (same pattern PR #1495 applied to compute), with a per-service test asserting the marshaled body carries billingPlan: Monthly for no-upfront and Upfront for all-upfront, and fails loud on empty/partial-upfront. Compute is done in #1495; this issue now tracks the remaining six.

  2. bughunter-dotcom commented on Jul 23, 2026

    @bughunter-dotcom

    I can take the six remaining Azure service clients as an assignment-first fixed scope for 120 USDC, delivered within 48 hours after escrow funding.

    Proposed acceptance checklist:

    • thread each service's requested payment option through the existing shared reservations.BillingPlanForPaymentOption helper for database, cache, cosmosdb, search, managedredis, and synapse;
    • serialize the SDK's typed billing-plan enum into each reservation purchase body — Upfront for all-upfront, Monthly for no-upfront;
    • preserve fail-loud behavior for empty/unsupported/partial-upfront values rather than allowing Azure's implicit Upfront default;
    • add a table-driven or per-service body-marshalling test that proves both supported values and the rejection path for every client;
    • run the affected provider tests plus go test ./..., and provide exact output;
    • no Azure credentials, real reservations, or production purchases required.

    Before implementation, please confirm:

    1. assignment to bughunter-dotcom;
    2. whether the branch should target current main or be based on open PR feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP #1495 where the shared helper and compute reference implementation currently live;
    3. that the remaining scope is exactly the six services listed in your follow-up;
    4. a funded 120 USDC milestone and payout network.

    Escrow option (LaborX, Base USDC): https://laborx.com/gigs/i-will-debug-and-fix-your-api-ci-or-qa-automation-with-tests-109222

    Relevant deterministic test-delivery samples: https://github.com/bughunter-dotcom/resilient-http-client-sample and https://github.com/bughunter-dotcom/playwright-ci-sample

    If direct fixed-USDC contracting is not available, no problem — I will not submit a speculative implementation.

  3. 9 remaining items

  4. cristim commented on Jul 28, 2026

    @cristim
    MemberAuthor

    Additional evidence from the 2026-07-28 full-repo review (reviewed commit be11bdcb5, origin/main). Confirmed still live on origin/main; filing here rather than as a duplicate issue.

    The gap is wider than "Azure RIs always bill upfront": the system actively offers and prices a billing schedule that the purchase path cannot request.

    Where (all seven purchase bodies, none of which sets billingPlan):

    • providers/azure/services/cache/client.go:305-321
    • providers/azure/services/compute/client.go:422-438
    • providers/azure/services/cosmosdb/client.go:306-322
    • providers/azure/services/database/client.go:336-352
    • providers/azure/services/managedredis/client.go:273-281
    • providers/azure/services/search/client.go:256-272
    • providers/azure/services/synapse/client.go:279-287

    What: rec.PaymentOption is only consumed to build the displayName string. billingPlan (armreservations.ReservationBillingPlanMonthly / ...Upfront) is never emitted in any of the seven.

    The aggravating detail: ExpandPaymentVariants (providers/azure/internal/recommendations/converter.go:358-403) actively fans every recommendation into an "upfront" and a "monthly" variant, and GetOfferingDetails (e.g. cache/client.go) renders a monthly cashflow preview for the monthly variant that the purchase path cannot honor.

    Failure scenario: a user picks the "monthly" variant of a 3-year VM reservation quoted at roughly $X/month. Azure defaults the omitted billingPlan to Upfront and charges the full 3-year price on day one. The approved cashflow and the actual charge differ by the entire upfront amount.

    Fix direction (unchanged from this issue): map rec.PaymentOption to the armreservations.ReservationBillingPlan enum constant, send it in both the calculatePrice and purchase bodies, and fail closed on an unrecognised value.

    Given that the monthly variant is surfaced and priced to the user, the severity and priority on this issue (currently severity/medium, priority/p3) may be worth revisiting.

  5. cristim commented on Sep 2, 2026

    @cristim
    MemberAuthor

    Verified resolved at 3c0f8ac: rec.PaymentOption is mapped through reservations.BillingPlanForPaymentOption to the typed armreservations.ReservationBillingPlan enum and emitted as properties.billingPlan in the reservation body, with empty and partial-upfront values failing loud instead of silently defaulting to Upfront. Evidence: providers/azure/services/compute/client.go:499-505 and :436 (#1495, commit 887d51f). Residual axes checked: all seven sibling services (compute, cache, cosmosdb, database, managedredis, search, synapse) set billingPlan and each carries a billingPlan regression test; the same body is sent to both calculatePrice and purchase so the quoted and charged schedules agree; the MCP tool's blanket non-all-upfront rejection was lifted and only partial-upfront is now refused, which Azure genuinely cannot express; the RI-exchange path hardcodes Upfront but exposes no caller-supplied payment option, so nothing is silently overridden there. Closing as completed; reopen if the behaviour recurs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions