diff --git a/code/samples/go/pkg/ap2/types/mandate.go b/code/samples/go/pkg/ap2/types/mandate.go index 4aa6701c..5ce5d239 100644 --- a/code/samples/go/pkg/ap2/types/mandate.go +++ b/code/samples/go/pkg/ap2/types/mandate.go @@ -29,6 +29,10 @@ type IntentMandate struct { SKUs []string `json:"skus,omitempty"` RequiresRefundability *bool `json:"requires_refundability,omitempty"` IntentExpiry string `json:"intent_expiry"` + // Budget is the maximum total amount the agent is authorized to spend when + // fulfilling this intent. If set, the agent must not place orders whose + // total exceeds this value. + Budget *PaymentCurrencyAmount `json:"budget,omitempty"` } func NewIntentMandate() *IntentMandate { diff --git a/code/samples/go/pkg/ap2/types/mandate_test.go b/code/samples/go/pkg/ap2/types/mandate_test.go new file mode 100644 index 00000000..d7172bff --- /dev/null +++ b/code/samples/go/pkg/ap2/types/mandate_test.go @@ -0,0 +1,129 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package types + +import ( + "encoding/json" + "testing" +) + +func TestIntentMandateBudgetOptional(t *testing.T) { + mandate := &IntentMandate{ + NaturalLanguageDescription: "Red basketball shoes", + IntentExpiry: "2026-12-31T00:00:00Z", + } + if mandate.Budget != nil { + t.Errorf("expected Budget to be nil, got %+v", mandate.Budget) + } +} + +func TestIntentMandateBudgetCanBeSet(t *testing.T) { + budget := &PaymentCurrencyAmount{Currency: "USD", Value: 150.00} + mandate := &IntentMandate{ + NaturalLanguageDescription: "Red basketball shoes", + IntentExpiry: "2026-12-31T00:00:00Z", + Budget: budget, + } + if mandate.Budget == nil { + t.Fatal("expected Budget to be set") + } + if mandate.Budget.Currency != "USD" { + t.Errorf("expected currency USD, got %s", mandate.Budget.Currency) + } + if mandate.Budget.Value != 150.00 { + t.Errorf("expected value 150.00, got %f", mandate.Budget.Value) + } +} + +func TestIntentMandateBudgetSerializesToJSON(t *testing.T) { + budget := &PaymentCurrencyAmount{Currency: "EUR", Value: 200.00} + mandate := &IntentMandate{ + NaturalLanguageDescription: "Concert tickets", + IntentExpiry: "2026-06-01T00:00:00Z", + Budget: budget, + } + data, err := json.Marshal(mandate) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + budgetField, ok := decoded["budget"] + if !ok { + t.Fatal("expected 'budget' key in JSON output") + } + budgetMap, ok := budgetField.(map[string]interface{}) + if !ok { + t.Fatalf("expected budget to be an object, got %T", budgetField) + } + if budgetMap["currency"] != "EUR" { + t.Errorf("expected currency EUR, got %v", budgetMap["currency"]) + } + if budgetMap["value"] != 200.00 { + t.Errorf("expected value 200.00, got %v", budgetMap["value"]) + } +} + +func TestIntentMandateBudgetAbsentOmittedFromJSON(t *testing.T) { + mandate := &IntentMandate{ + NaturalLanguageDescription: "Groceries", + IntentExpiry: "2026-01-01T00:00:00Z", + } + data, err := json.Marshal(mandate) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var decoded map[string]interface{} + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + if _, ok := decoded["budget"]; ok { + t.Error("expected 'budget' key to be omitted from JSON when nil") + } +} + +func TestIntentMandateBudgetRoundTrip(t *testing.T) { + budget := &PaymentCurrencyAmount{Currency: "GBP", Value: 75.50} + original := &IntentMandate{ + NaturalLanguageDescription: "Books", + IntentExpiry: "2026-03-01T00:00:00Z", + Budget: budget, + } + serialized, err := json.Marshal(original) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + + var restored IntentMandate + if err := json.Unmarshal(serialized, &restored); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + if restored.Budget == nil { + t.Fatal("expected restored Budget to be set") + } + if restored.Budget.Currency != "GBP" { + t.Errorf("expected currency GBP, got %s", restored.Budget.Currency) + } + if restored.Budget.Value != 75.50 { + t.Errorf("expected value 75.50, got %f", restored.Budget.Value) + } +} diff --git a/code/sdk/python/ap2/models/mandate.py b/code/sdk/python/ap2/models/mandate.py index e3cead02..bf2c5c25 100644 --- a/code/sdk/python/ap2/models/mandate.py +++ b/code/sdk/python/ap2/models/mandate.py @@ -17,6 +17,7 @@ from datetime import UTC, datetime from ap2.models.payment_request import ( + PaymentCurrencyAmount, PaymentItem, PaymentRequest, PaymentResponse, @@ -74,6 +75,14 @@ class IntentMandate(BaseModel): ..., description='When the intent mandate expires, in ISO 8601 format.', ) + budget: PaymentCurrencyAmount | None = Field( + None, + description=( + 'The maximum total amount the agent is authorized to spend when' + ' fulfilling this intent. If set, the agent must not place orders' + ' whose total exceeds this value.' + ), + ) class CartContents(BaseModel): diff --git a/code/sdk/python/ap2/tests/mandate_tests.py b/code/sdk/python/ap2/tests/mandate_tests.py new file mode 100644 index 00000000..25f6e8d4 --- /dev/null +++ b/code/sdk/python/ap2/tests/mandate_tests.py @@ -0,0 +1,91 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for AP2 mandate models.""" + +import json + +from ap2.models.mandate import IntentMandate +from ap2.models.payment_request import PaymentCurrencyAmount + + +def test_intent_mandate_budget_is_optional(): + """IntentMandate can be created without a budget.""" + mandate = IntentMandate( + natural_language_description='Red basketball shoes', + intent_expiry='2026-12-31T00:00:00Z', + ) + assert mandate.budget is None + + +def test_intent_mandate_budget_can_be_set(): + """IntentMandate accepts a PaymentCurrencyAmount budget.""" + budget = PaymentCurrencyAmount(currency='USD', value=150.00) + mandate = IntentMandate( + natural_language_description='Red basketball shoes', + intent_expiry='2026-12-31T00:00:00Z', + budget=budget, + ) + assert mandate.budget is not None + assert mandate.budget.currency == 'USD' + assert mandate.budget.value == 150.00 + + +def test_intent_mandate_budget_serializes_to_json(): + """Budget field serializes correctly in JSON output.""" + budget = PaymentCurrencyAmount(currency='EUR', value=200.00) + mandate = IntentMandate( + natural_language_description='Concert tickets', + intent_expiry='2026-06-01T00:00:00Z', + budget=budget, + ) + data = json.loads(mandate.model_dump_json()) + assert data['budget'] == {'currency': 'EUR', 'value': 200.00} + + +def test_intent_mandate_budget_absent_omitted_from_json(): + """Budget field is absent from JSON when not set.""" + mandate = IntentMandate( + natural_language_description='Groceries', + intent_expiry='2026-01-01T00:00:00Z', + ) + data = json.loads(mandate.model_dump_json(exclude_none=True)) + assert 'budget' not in data + + +def test_intent_mandate_budget_round_trip(): + """IntentMandate with budget survives a JSON round-trip.""" + budget = PaymentCurrencyAmount(currency='GBP', value=75.50) + original = IntentMandate( + natural_language_description='Books', + intent_expiry='2026-03-01T00:00:00Z', + budget=budget, + ) + serialized = original.model_dump_json() + restored = IntentMandate.model_validate_json(serialized) + assert restored.budget is not None + assert restored.budget.currency == 'GBP' + assert restored.budget.value == 75.50 + + +def test_intent_mandate_budget_zero_value_allowed(): + """Budget of zero is a valid (if unusual) value.""" + budget = PaymentCurrencyAmount(currency='USD', value=0.0) + mandate = IntentMandate( + natural_language_description='Free items only', + intent_expiry='2026-12-31T00:00:00Z', + budget=budget, + ) + assert mandate.budget is not None + assert mandate.budget.value == 0.0