From 273d769836bb11465ca511407a1ea58b2c1f53f8 Mon Sep 17 00:00:00 2001 From: baaakgun4543 Date: Wed, 14 Oct 2026 09:45:42 +0900 Subject: [PATCH] fix(sdk): convert payment.budget max to minor units per ISO 4217 BudgetEvaluator converted the major-unit budget with int(max * 100). For zero-decimal currencies this raised the cap 100x (a 1000 JPY budget admitted 100000 JPY), and binary-float truncation rejected payments exactly at the cap (19.99 USD -> 1998 cents). Convert with exact decimal arithmetic and the ISO 4217 minor-unit exponent, flooring fractions so rounding can never raise the cap. --- code/sdk/python/ap2/sdk/constraints.py | 69 ++++++++++++++++++- .../sdk/python/ap2/tests/constraints_tests.py | 42 +++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/code/sdk/python/ap2/sdk/constraints.py b/code/sdk/python/ap2/sdk/constraints.py index 48aed798..aaa93eb1 100644 --- a/code/sdk/python/ap2/sdk/constraints.py +++ b/code/sdk/python/ap2/sdk/constraints.py @@ -9,6 +9,7 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta +from decimal import ROUND_FLOOR, Decimal from ap2.sdk.generated.open_checkout_mandate import ( AllowedMerchants, @@ -34,6 +35,66 @@ from pydantic import BaseModel +# ISO 4217 currencies whose minor-unit exponent differs from the default 2. +_ZERO_DECIMAL_CURRENCIES = frozenset( + { + 'BIF', + 'CLP', + 'DJF', + 'GNF', + 'ISK', + 'JPY', + 'KMF', + 'KRW', + 'PYG', + 'RWF', + 'UGX', + 'UYI', + 'VND', + 'VUV', + 'XAF', + 'XOF', + 'XPF', + } +) +_THREE_DECIMAL_CURRENCIES = frozenset( + { + 'BHD', + 'IQD', + 'JOD', + 'KWD', + 'LYD', + 'OMR', + 'TND', + } +) +_FOUR_DECIMAL_CURRENCIES = frozenset({'CLF', 'UYW'}) + + +def minor_unit_exponent(currency: str) -> int: + """Return the ISO 4217 minor-unit exponent for ``currency``.""" + code = currency.upper() + if code in _ZERO_DECIMAL_CURRENCIES: + return 0 + if code in _THREE_DECIMAL_CURRENCIES: + return 3 + if code in _FOUR_DECIMAL_CURRENCIES: + return 4 + return 2 + + +def major_to_minor_units(value: float | int | str, currency: str) -> int: + """Convert a major-unit amount to integer minor units for ``currency``. + + Uses exact decimal arithmetic (``19.99`` USD -> ``1999``, not ``1998``) + and the ISO 4217 minor-unit exponent (``1000`` JPY -> ``1000``, + ``1.5`` KWD -> ``1500``). Fractions of a minor unit are floored so a + budget can never be exceeded through rounding. + """ + scaled = Decimal(str(value)) * Decimal(10) ** minor_unit_exponent(currency) + return int(scaled.to_integral_value(rounding=ROUND_FLOOR)) + + class MandateContext(BaseModel): """Aggregated usage context for a mandate.""" @@ -304,11 +365,13 @@ def evaluate( past_spend = self.mandate_context.total_amount total_spend = past_spend + closed_mandate.payment_amount.amount - budget_max_cents = int(self.constraint.max * 100) - if total_spend > budget_max_cents: + budget_max_minor = major_to_minor_units( + self.constraint.max, self.constraint.currency + ) + if total_spend > budget_max_minor: return [ f'Cumulative spend {total_spend} exceeds ' - f'budget limit {budget_max_cents} (past spend: {past_spend})' + f'budget limit {budget_max_minor} (past spend: {past_spend})' ] return [] diff --git a/code/sdk/python/ap2/tests/constraints_tests.py b/code/sdk/python/ap2/tests/constraints_tests.py index 910c97fc..5ce1692e 100644 --- a/code/sdk/python/ap2/tests/constraints_tests.py +++ b/code/sdk/python/ap2/tests/constraints_tests.py @@ -492,6 +492,48 @@ def test_payment_budget_missing_context(): ) +def test_payment_budget_decimal_boundary_usd(): + """A budget of 19.99 USD admits a 1999-cent spend (no float truncation).""" + violations = check_payment_constraints( + _open_payment( + constraints=[ + Budget(max=19.99, currency='USD'), # 1999 cents + ] + ), + _closed_payment(payment_amount=Amount(amount=1999, currency='USD')), + mandate_context=MandateContext(total_uses=0, total_amount=0), + ) + assert violations == [] + + +@pytest.mark.parametrize( + ('budget_max', 'currency', 'amount', 'allowed'), + [ + pytest.param(1000, 'JPY', 1000, True, id='jpy_zero_decimal_at_limit'), + pytest.param(1000, 'JPY', 1001, False, id='jpy_zero_decimal_over'), + pytest.param(50000, 'KRW', 50000, True, id='krw_zero_decimal_at_limit'), + pytest.param(1.5, 'KWD', 1500, True, id='kwd_three_decimal_at_limit'), + pytest.param(1.5, 'KWD', 1501, False, id='kwd_three_decimal_over'), + pytest.param(0.29, 'EUR', 29, True, id='eur_float_truncation'), + ], +) +def test_payment_budget_iso4217_minor_units( + budget_max, currency, amount, allowed +): + """Budget max (major units) is converted with the currency's exponent.""" + violations = check_payment_constraints( + _open_payment(constraints=[Budget(max=budget_max, currency=currency)]), + _closed_payment( + payment_amount=Amount(amount=amount, currency=currency) + ), + mandate_context=MandateContext(total_uses=0, total_amount=0), + ) + if allowed: + assert violations == [] + else: + assert any('exceeds budget limit' in v for v in violations) + + # ── check_payment_constraints – execution_date ───────────────────────────