Skip to content

fix(api): make Azure revoke refund guard mandatory and currency-aware - #445

Merged
cristim merged 1 commit into
mainfrom
fix/96-azure-revoke-refund-guard
Sep 28, 2026
Merged

cristim merged 1 commit into
mainfrom
fix/96-azure-revoke-refund-guard

Conversation

@cristim

@cristim cristim commented Sep 28, 2026

Copy link
Copy Markdown
Member

Summary

The Azure revoke refund-divergence guard was opt-in and currency-blind. It is now mandatory, fails closed, and compares currencies.

Root cause

callAzureReturn ran the TOCTOU check only when both expected_refund_amount (client) and BillingRefundAmount (Azure) were non-nil, so an empty body or a quote without an amount went straight to the irreversible Return call. The comparison used amounts only; calcRefundCurrency appeared only in the error message. Separately, the History Revoke button never called GET /revoke/calculate and POSTed no body, so the guard never ran from the UI.

Fix

  • revokeConfirmBody gains expected_refund_currency. Azure revocations require both fields (400 before Azure is called).
  • New checkConfirmedRefund: 422 when Azure returns no amount or currency; 422 on a currency mismatch (no FX conversion, since there is no trusted FX source); 422 on an amount divergence beyond revokeQuoteEpsilon. Currency codes compare case-insensitively after trimming.
  • Frontend: completed rows fetch the quote (getRevokeQuote), show amount CURRENCY in the confirm dialog, and send both fields. A failed quote aborts before the confirm dialog. Scheduled (pre-fire, free cancel) rows skip the quote, since the backend routes them to the execution-cancel path.

Regression test

  • internal/api/handler_purchases_revoke_guard_test.go drives revokePurchase end to end with the injected Azure client factory and counts CalculateRefund/Return calls. Pre-fix, 6 of 8 cases failed (missing body, missing currency, missing amount, quote without amount, quote without currency, EUR vs USD at equal amounts): Return was called in each. Divergence and happy path pass on both.
  • history-revoke-button.test.ts adds click-flow tests (quote fetched and sent, failed quote blocks the revoke, scheduled row skips the quote). Pre-fix the suite fails to compile because getRevokeQuote does not exist.
  • Existing callAzureReturn tests now pass a confirmed quote that matches the stub.

Verification

  • go build ./..., go vet ./internal/api/, go test -race -short ./internal/api/: ok
  • golangci-lint v2.10.1 (CI pin) on ./internal/api/...: 0 issues
  • go mod tidy -diff: empty
  • Frontend: tsc --noEmit clean, eslint clean on changed files, jest 92/92 suites (2994 passed), npm run build ok

Closes #96

POST /api/purchases/{id}/revoke skipped the refund-divergence check when
the body omitted expected_refund_amount or Azure returned no quote, and
compared amounts without currencies. The guard now requires
expected_refund_amount and expected_refund_currency (400), fails closed
when CalculateRefund returns no amount or currency (422), and refuses a
currency mismatch (422) instead of converting.

The History Revoke button now fetches the quote, shows it in the confirm
dialog, and sends it back. Scheduled rows (free cancel) take no quote.

Closes #96
@coderabbitai

coderabbitai Bot commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 8 billable files and costs up to $2.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 43 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 63 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml

Review profile: CHILL

Plan: Essentials

Run ID: 8eb1eacf-0846-4acb-a893-5c196360b281

📥 Commits

Reviewing files that changed from the base of the PR and between 1848880 and 697a0f6.

📒 Files selected for processing (8)
  • frontend/src/__tests__/history-revoke-button.test.ts
  • frontend/src/api/index.ts
  • frontend/src/api/purchases.ts
  • frontend/src/history.ts
  • internal/api/handler_purchases_revoke.go
  • internal/api/handler_purchases_revoke_guard_test.go
  • internal/api/handler_purchases_revoke_test.go
  • internal/api/router.go

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

@cristim

cristim commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

Independent adversarial review (Opus 5.5) at 697a0f6: MERGE. The Azure revoke refund guard is now mandatory and currency-aware: expected amount and currency are required (400), a quote missing either is refused (422), and a currency mismatch is refused (422, no FX). The server re-quotes Azure at submit time and persists Azure's amount, not the client's. The History Revoke button now fetches the quote, shows it escaped, and fails closed if the fetch fails. 'Scheduled' routing is server-side by ID with a status CAS, so there's no bypass. AWS/GCP are unaffected. Pre-fix: 6/8 guard subtests fail. go test -race -short ./internal/api 2377 pass, golangci-lint v2.10.1 clean, npm test 2994 pass; CI 27/27. Minor follow-up filed (the quote endpoint's 0/"" on a missing amount).

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

Labels

effort/m Days impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant 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(api): Azure revoke refund-divergence guard is opt-in and currency-blind

1 participant