Skip to content

Commit ebb74a0

Browse files
committed
feat(types): add budget field to IntentMandate
Adds an optional `budget: PaymentCurrencyAmount` field to `IntentMandate` in both Python and Go, giving agents a machine-readable spend ceiling to enforce when fulfilling a purchase intent. - Python: `src/ap2/types/mandate.py` -- new field + import - Go: `samples/go/pkg/ap2/types/mandate.go` -- new field with omitempty - Tests: 6 Python tests + 5 Go tests (optional, set, JSON round-trip, absent-from-JSON) Closes #133
1 parent 0b3379b commit ebb74a0

4 files changed

Lines changed: 239 additions & 6 deletions

File tree

samples/go/pkg/ap2/types/mandate.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,16 @@ const (
2323
)
2424

2525
type IntentMandate struct {
26-
UserCartConfirmationRequired *bool `json:"user_cart_confirmation_required,omitempty"`
27-
NaturalLanguageDescription string `json:"natural_language_description"`
28-
Merchants []string `json:"merchants,omitempty"`
29-
SKUs []string `json:"skus,omitempty"`
30-
RequiresRefundability *bool `json:"requires_refundability,omitempty"`
31-
IntentExpiry string `json:"intent_expiry"`
26+
UserCartConfirmationRequired *bool `json:"user_cart_confirmation_required,omitempty"`
27+
NaturalLanguageDescription string `json:"natural_language_description"`
28+
Merchants []string `json:"merchants,omitempty"`
29+
SKUs []string `json:"skus,omitempty"`
30+
RequiresRefundability *bool `json:"requires_refundability,omitempty"`
31+
IntentExpiry string `json:"intent_expiry"`
32+
// Budget is the maximum total amount the agent is authorized to spend when
33+
// fulfilling this intent. If set, the agent must not place orders whose
34+
// total exceeds this value.
35+
Budget *PaymentCurrencyAmount `json:"budget,omitempty"`
3236
}
3337

3438
func NewIntentMandate() *IntentMandate {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package types
16+
17+
import (
18+
"encoding/json"
19+
"testing"
20+
)
21+
22+
func TestIntentMandateBudgetOptional(t *testing.T) {
23+
mandate := &IntentMandate{
24+
NaturalLanguageDescription: "Red basketball shoes",
25+
IntentExpiry: "2026-12-31T00:00:00Z",
26+
}
27+
if mandate.Budget != nil {
28+
t.Errorf("expected Budget to be nil, got %+v", mandate.Budget)
29+
}
30+
}
31+
32+
func TestIntentMandateBudgetCanBeSet(t *testing.T) {
33+
budget := &PaymentCurrencyAmount{Currency: "USD", Value: 150.00}
34+
mandate := &IntentMandate{
35+
NaturalLanguageDescription: "Red basketball shoes",
36+
IntentExpiry: "2026-12-31T00:00:00Z",
37+
Budget: budget,
38+
}
39+
if mandate.Budget == nil {
40+
t.Fatal("expected Budget to be set")
41+
}
42+
if mandate.Budget.Currency != "USD" {
43+
t.Errorf("expected currency USD, got %s", mandate.Budget.Currency)
44+
}
45+
if mandate.Budget.Value != 150.00 {
46+
t.Errorf("expected value 150.00, got %f", mandate.Budget.Value)
47+
}
48+
}
49+
50+
func TestIntentMandateBudgetSerializesToJSON(t *testing.T) {
51+
budget := &PaymentCurrencyAmount{Currency: "EUR", Value: 200.00}
52+
mandate := &IntentMandate{
53+
NaturalLanguageDescription: "Concert tickets",
54+
IntentExpiry: "2026-06-01T00:00:00Z",
55+
Budget: budget,
56+
}
57+
data, err := json.Marshal(mandate)
58+
if err != nil {
59+
t.Fatalf("json.Marshal failed: %v", err)
60+
}
61+
62+
var decoded map[string]interface{}
63+
if err := json.Unmarshal(data, &decoded); err != nil {
64+
t.Fatalf("json.Unmarshal failed: %v", err)
65+
}
66+
67+
budgetField, ok := decoded["budget"]
68+
if !ok {
69+
t.Fatal("expected 'budget' key in JSON output")
70+
}
71+
budgetMap, ok := budgetField.(map[string]interface{})
72+
if !ok {
73+
t.Fatalf("expected budget to be an object, got %T", budgetField)
74+
}
75+
if budgetMap["currency"] != "EUR" {
76+
t.Errorf("expected currency EUR, got %v", budgetMap["currency"])
77+
}
78+
if budgetMap["value"] != 200.00 {
79+
t.Errorf("expected value 200.00, got %v", budgetMap["value"])
80+
}
81+
}
82+
83+
func TestIntentMandateBudgetAbsentFromJSON(t *testing.T) {
84+
mandate := &IntentMandate{
85+
NaturalLanguageDescription: "Groceries",
86+
IntentExpiry: "2026-01-01T00:00:00Z",
87+
}
88+
data, err := json.Marshal(mandate)
89+
if err != nil {
90+
t.Fatalf("json.Marshal failed: %v", err)
91+
}
92+
93+
var decoded map[string]interface{}
94+
if err := json.Unmarshal(data, &decoded); err != nil {
95+
t.Fatalf("json.Unmarshal failed: %v", err)
96+
}
97+
98+
if _, ok := decoded["budget"]; ok {
99+
t.Error("expected 'budget' to be absent from JSON when nil")
100+
}
101+
}
102+
103+
func TestIntentMandateBudgetRoundTrip(t *testing.T) {
104+
budget := &PaymentCurrencyAmount{Currency: "GBP", Value: 75.50}
105+
original := &IntentMandate{
106+
NaturalLanguageDescription: "Books",
107+
IntentExpiry: "2026-03-01T00:00:00Z",
108+
Budget: budget,
109+
}
110+
111+
data, err := json.Marshal(original)
112+
if err != nil {
113+
t.Fatalf("json.Marshal failed: %v", err)
114+
}
115+
116+
var restored IntentMandate
117+
if err := json.Unmarshal(data, &restored); err != nil {
118+
t.Fatalf("json.Unmarshal failed: %v", err)
119+
}
120+
121+
if restored.Budget == nil {
122+
t.Fatal("expected Budget to survive round-trip")
123+
}
124+
if restored.Budget.Currency != "GBP" {
125+
t.Errorf("expected currency GBP, got %s", restored.Budget.Currency)
126+
}
127+
if restored.Budget.Value != 75.50 {
128+
t.Errorf("expected value 75.50, got %f", restored.Budget.Value)
129+
}
130+
}

src/ap2/types/mandate.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from datetime import timezone
1919
from typing import Optional
2020

21+
from ap2.types.payment_request import PaymentCurrencyAmount
2122
from ap2.types.payment_request import PaymentItem
2223
from ap2.types.payment_request import PaymentRequest
2324
from ap2.types.payment_request import PaymentResponse
@@ -74,6 +75,14 @@ class IntentMandate(BaseModel):
7475
...,
7576
description="When the intent mandate expires, in ISO 8601 format.",
7677
)
78+
budget: Optional[PaymentCurrencyAmount] = Field(
79+
None,
80+
description=(
81+
"The maximum total amount the agent is authorized to spend when"
82+
" fulfilling this intent. If set, the agent must not place orders"
83+
" whose total exceeds this value."
84+
),
85+
)
7786

7887

7988
class CartContents(BaseModel):

tests/test_mandate.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for AP2 mandate types."""
16+
17+
import json
18+
19+
import pytest
20+
21+
from ap2.types.mandate import IntentMandate
22+
from ap2.types.payment_request import PaymentCurrencyAmount
23+
24+
25+
class TestIntentMandateBudget:
26+
"""Tests for the budget field on IntentMandate."""
27+
28+
def test_budget_is_optional(self):
29+
"""IntentMandate can be created without a budget."""
30+
mandate = IntentMandate(
31+
natural_language_description="Red basketball shoes",
32+
intent_expiry="2026-12-31T00:00:00Z",
33+
)
34+
assert mandate.budget is None
35+
36+
def test_budget_can_be_set(self):
37+
"""IntentMandate accepts a PaymentCurrencyAmount budget."""
38+
budget = PaymentCurrencyAmount(currency="USD", value=150.00)
39+
mandate = IntentMandate(
40+
natural_language_description="Red basketball shoes",
41+
intent_expiry="2026-12-31T00:00:00Z",
42+
budget=budget,
43+
)
44+
assert mandate.budget is not None
45+
assert mandate.budget.currency == "USD"
46+
assert mandate.budget.value == 150.00
47+
48+
def test_budget_serializes_to_json(self):
49+
"""Budget field serializes correctly in JSON output."""
50+
budget = PaymentCurrencyAmount(currency="EUR", value=200.00)
51+
mandate = IntentMandate(
52+
natural_language_description="Concert tickets",
53+
intent_expiry="2026-06-01T00:00:00Z",
54+
budget=budget,
55+
)
56+
data = json.loads(mandate.model_dump_json())
57+
assert data["budget"] == {"currency": "EUR", "value": 200.00}
58+
59+
def test_budget_absent_omitted_from_json(self):
60+
"""Budget field is absent from JSON when not set."""
61+
mandate = IntentMandate(
62+
natural_language_description="Groceries",
63+
intent_expiry="2026-01-01T00:00:00Z",
64+
)
65+
data = json.loads(mandate.model_dump_json(exclude_none=True))
66+
assert "budget" not in data
67+
68+
def test_budget_round_trip(self):
69+
"""IntentMandate with budget survives a JSON round-trip."""
70+
budget = PaymentCurrencyAmount(currency="GBP", value=75.50)
71+
original = IntentMandate(
72+
natural_language_description="Books",
73+
intent_expiry="2026-03-01T00:00:00Z",
74+
budget=budget,
75+
)
76+
serialized = original.model_dump_json()
77+
restored = IntentMandate.model_validate_json(serialized)
78+
assert restored.budget is not None
79+
assert restored.budget.currency == "GBP"
80+
assert restored.budget.value == 75.50
81+
82+
def test_budget_zero_value_allowed(self):
83+
"""Budget of zero is a valid (if unusual) value."""
84+
budget = PaymentCurrencyAmount(currency="USD", value=0.0)
85+
mandate = IntentMandate(
86+
natural_language_description="Free items only",
87+
intent_expiry="2026-12-31T00:00:00Z",
88+
budget=budget,
89+
)
90+
assert mandate.budget.value == 0.0

0 commit comments

Comments
 (0)