Repository navigation
sec(money): currency is dropped at every layer boundary, so USD-denominated caps are compared against non-USD amounts [go part] #124
Description
Split from LeanerCloud/cloud-commitments-cli#1637 (cross-component issue, monorepo split)
This is the pkg/ and providers/-scoped half of a cross-cutting class finding. Nothing in the original issue touches cmd/*.go or docs/cli/, so cloud-commitments-cli is not an affected repo and the original issue is closed with backlinks rather than kept open. The internal/-scoped half of the same class went to cloud-commitments-platform (see the shared root-cause section below, repeated in both issues since the fix direction only makes sense read as a whole).
Reviewed commit: be11bdcb5. Re-verified against this repo's current tree (2026-09-28); all three new instances below still reproduce, with updated line numbers where they had drifted.
What this issue is (and is not)
This is the class, not another copy of the instances. Four instances already have their own issues in cloud-commitments-cli (not yet split as of this writing, since they predate this monorepo split and are independently fixable regardless of which repo they end up scoped to):
- #1558
fix(exchange): RI-exchange spend caps are compared against a quote whose CurrencyCode is never checked(this repo:pkg/exchange; ledger half in the platform repo:internal/config/store_postgres.go) - #1555
fix(api): Azure revoke refund-divergence guard is opt-in and currency-blind(platform repo:internal/api/handler_purchases_revoke.go) - #1562
fix(azure): Savings Plan purchase hardcodes CurrencyCode USD(this repo:providers/azure/services/savingsplans/client.go) - #1087
AWS Savings Plans purchase path is USD-only(this repo:providers/aws/services/savingsplans/client.go)
Those stay open and stay independently fixable; do not double-fix them here or as part of this split. This issue (and its cloud-commitments-platform sibling) exist because fixing those one at a time will not close the class: the same shape reappears in more places, and a per-site fix leaves the underlying type design intact so the next money field added reintroduces it.
The shared root cause
Two facts, together:
- Money crosses every layer boundary as a bare
float64(or a bare decimal string), with the currency dropped at the boundary. The currency is frequently read from the provider response and then either stored in a display-only field or discarded outright.providers/azure/internal/recommendations/converter.go:357-360states the assumption in a comment: theamountValuehelper unwraps Modern's*Amount{Currency, Value}to a bare float and discards the currency on the stated assumption that downstreamRecommendationconsumers assume a single-currency view per subscription.common.Recommendationcarries no currency field at all, so there is nowhere for it to go even if a caller wanted to keep it. - Every spend cap in the system is USD-denominated by naming convention only, with no currency field to compare against. This repo's
pkg/exchange'sMaxPaymentDueUSD *big.Rat(pkg/exchange/exchange.go:104) andMaxPaymentPerExchangeUSD/MaxPaymentDailyUSD(pkg/exchange/auto.go:74-75); same shape forinternal/auth/types.go'sMaxPurchaseAmountin the platform repo. TheUSDis in the identifier, not in the type.
The result is that a guardrail comparison is between two numbers whose units are asserted by a variable name. On a non-USD deployment (aws-cn, an MCA subscription billed in EUR, a GBP-billed GCP billing account) that comparison is wrong in whichever direction the exchange rate happens to point: it silently blocks a legal purchase, or it silently authorises one an order of magnitude over the operator's stated ceiling.
The correct behaviour is to fail closed on a non-USD money path, not to compare across currencies. A guardrail that cannot establish the denomination of the number it is checking has not checked anything, and "assume USD" is exactly the fabricated-value pattern the project's no-silent-fallbacks rule exists to prevent. Where the currency genuinely cannot be resolved, the money path must error out and say so, rather than proceed on an assumption.
Instances in this repo (new in this sweep, not filed elsewhere)
-
GCP
Money.CurrencyCodeis never read- Where:
providers/gcp/services/computeengine/client.go:1241-1260(extractCostImpactFromRecommendation). - What: the function reads
costProj.Cost(agoogle.type.Money), negatesUnits + Nanos/1e9intorec.EstimatedSavings, and never touchesCost.CurrencyCode.recommenderpb.CostProjectionalso carriesDuration, likewise unread; the value is treated as monthly by assumption. So this single function drops both the unit and the period. - Failure scenario: a GBP-billed billing account. The CUD recommendation is stored with
EstimatedSavingsin pounds, ranked against USD-denominated recommendations from other providers on the same dashboard, and checked against a USD-denominatedMaxPurchaseAmount(platform repo). TheDurationhalf compounds it: a projection over a 1-year duration is stored as a monthly figure, 12x overstated. - Fix direction: normalise by
Durationto a monthly run-rate, and drop the recommendation with a log whenCurrencyCodeis absent or is not the deployment's expected billing currency.
- Where:
-
Azure Advisor:
savingsCurrencyis never read, and an unparsable savings amount becomes a real 0- Where:
providers/azure/recommendations.go:441(rec.EstimatedSavings = extFloat(ext, "annualSavingsAmount") / 12),extFloatat:453-460. - What: Advisor's
extendedPropertiescarries asavingsCurrencykey that nothing reads.extFloatreturns0for a missing key and for astrconv.ParseFloatfailure, so a payload like"1,234.00"(thousands separator) yields a recommendation valued at exactly zero rather than an error. - Fix direction: read and validate
savingsCurrency; makeextFloatreturn(float64, error)or*float64so an unparsable amount drops the recommendation with a log instead of shipping a fabricated 0.
- Where:
-
providers/azure/internal/recommendations/converter.godiscards the currency at the read boundary, by design- Where:
providers/azure/internal/recommendations/converter.go:357-364(amountValue), called from the converter's amount-extraction path. - What: this is the documented root of the Azure half.
amountValueunwraps Modern's*armconsumption.Amount{Currency, Value}to a bare float and the doc comment states the assumption outright: currency is discarded because downstreamRecommendationconsumers assume a single-currency view per subscription, same as the Legacy path. The siblingamountValuePtrimmediately below (:366-376) shows the codebase already knows how to preserve a signal it cares about (it keeps the absent-vs-zero distinction), so the currency drop is a deliberate scope decision, not an oversight. It is the decision this issue is asking to revisit. - Fix direction: this is the load-bearing fix in the "carry the currency to the guardrail" step below;
amountValue's caller chain needs to carry the sourceAmount.Currencyintocommon.Recommendationrather than discarding it here.
- Where:
Fix direction for the class
Fixing the individual sites without this will not hold. Suggested order:
- Carry the currency to the guardrail.
amountValue,extractCostImpactFromRecommendationand the Advisor path above must stop dropping it, andcommon.Recommendationneeds somewhere to put it. This is the load-bearing step: a comparison cannot fail closed on something it never received. - Make every guardrail fail closed when the amount is not USD, or its currency is unknown. This repo's
pkg/exchangecaps (checkInitialQuote/checkReQuote/ the auto-exchange caps), and the platform repo's Azure revoke divergence guard /MaxPurchaseAmountevaluation. PR #1515 already settled the rule for one call site; this applies the same rule at the rest. - Delete the "USD" defaults (the platform repo's
firstNonEmptyCurrency, and the two Azure SP literals tracked on #1562) and replace them with an explicit unknown that step 2 then refuses.
Deliberately not in scope, unless and until CUDly actually transacts in a second currency:
- A
Money{Amount, Currency}type inpkg/common, or a currency field onMaxPurchaseAmountand thepkg/exchangecap fields. The caps are USD by construction (#1515), so giving them a field to hold "USD" adds a value that is never anything else, threaded through three layers. What the comparison is missing is the denomination of the incoming amount, which is step 1. - A currency column on
ri_exchange_history.payment_due(platform repo). With step 2 in place no non-USD amount can be written, so the daily-spend ledger query cannot sum across denominations. The column is needed on the day the product supports a second currency, and should be added with that work.
Regression tests must exercise a genuinely non-USD path (a quote with CurrencyCode: "JPY", an EUR-billed MCA subscription fixture), not a USD path with an extra assertion. A test that only ever feeds USD will pass with every one of these defects present.
Related
- #1558, #1555, #1562, #1087 (the individually-filed instances, in
cloud-commitments-clias of this writing). - #1081 (
make PurchaseHistoryRecord.MonthlyCost a *float64 to distinguish absent from zero): same root shape (money metadata dropped at a layer boundary), different metadata. - Prior art: PR #1515 established that
MaxPurchaseAmounthas no currency field and that a non-USD money path must fail closed. This issue is that conclusion applied across the whole surface rather than at one call site.
Acceptance criteria
- All three new instances above are fixed to fail closed on a non-USD or unresolvable currency, with a regression test that exercises a genuinely non-USD input.
common.Recommendation(or an equivalent carrier) gains a place to hold the currency, coordinated with thecloud-commitments-platformsibling issue so the value actually reaches the guardrails there.
Activity
cristim commentedon Oct 15, 2026
PR #410 (focused contract issue #409) adds optional commitment-only currency metadata to Recommendation, with unknown legacy values preserved and actual JSON/scaling consumer proof. This is the shared-contract prerequisite only: provider source-leg completeness/population, platform transport and currency-safe enforcement remain open here. It does not label savings or on-demand baseline currency and does not claim to make existing currency-blind guardrails safe. GCP double-counted monthly commitment amounts were separately corrected and published in #408.
feat(common): preserve proven commitment currency metadata (#410)
feat(gcp): retain checked catalog commitment currency (#412)