diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..eb298395 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -185,3 +185,9 @@ XVCJ Yapily Zalopay Zalora +codegen +datamodel +pisp +PISP +pyca +SECP diff --git a/code/sdk/python/ap2/sdk/generated/payment_receipt.py b/code/sdk/python/ap2/sdk/generated/payment_receipt.py index 5b381535..2bb7e647 100644 --- a/code/sdk/python/ap2/sdk/generated/payment_receipt.py +++ b/code/sdk/python/ap2/sdk/generated/payment_receipt.py @@ -50,6 +50,10 @@ class PaymentReceiptSuccess(BaseModel): ..., description='A unique identifier for the transaction confirmation at the network. Present only if status is Success.', ) + rail_confirmation_verified: bool | None = Field( + default=None, + description='Meaningful only if status is Success. True only if the issuer independently confirmed psp_confirmation_id and network_confirmation_id against the actual payment rail (PSP/network) before issuing this receipt, rather than merely generating them. Absent or false means these confirmation IDs are self-declared by the issuer and MUST NOT be treated as evidence that payment-rail settlement occurred: presence of a confirmation ID proves the issuer asserted a claim, not that the claim was checked.', + ) class PaymentReceiptError(BaseModel): @@ -82,6 +86,10 @@ class PaymentReceiptError(BaseModel): default=None, description='A unique identifier for the transaction confirmation at the network. Present only if status is Success.', ) + rail_confirmation_verified: bool | None = Field( + default=None, + description='Meaningful only if status is Success. True only if the issuer independently confirmed psp_confirmation_id and network_confirmation_id against the actual payment rail (PSP/network) before issuing this receipt, rather than merely generating them. Absent or false means these confirmation IDs are self-declared by the issuer and MUST NOT be treated as evidence that payment-rail settlement occurred: presence of a confirmation ID proves the issuer asserted a claim, not that the claim was checked.', + ) class PaymentReceipt(RootModel[PaymentReceiptSuccess | PaymentReceiptError]): diff --git a/code/sdk/python/ap2/sdk/receipt_wrapper.py b/code/sdk/python/ap2/sdk/receipt_wrapper.py index 3927bd97..34a2f8a7 100644 --- a/code/sdk/python/ap2/sdk/receipt_wrapper.py +++ b/code/sdk/python/ap2/sdk/receipt_wrapper.py @@ -45,6 +45,8 @@ def create_payment_receipt( self, payment_mandate_content: PaymentMandate, reference: str, + psp_confirmation_id: str | None = None, + network_confirmation_id: str | None = None, ) -> PaymentReceipt: """Creates a PaymentReceipt model instance. @@ -52,6 +54,18 @@ def create_payment_receipt( payment_mandate_content: The closed payment mandate whose PISP this receipt inherits as its issuer (when present). reference: The payment mandate reference this receipt binds to. + psp_confirmation_id: The PSP's own confirmation identifier for this + payment, obtained by actually checking the payment rail (e.g. the + PSP's settlement/charge API response). If omitted, a locally + generated placeholder is used instead and the receipt's + `rail_confirmation_verified` field is left unset -- callers MUST + NOT treat the resulting receipt as proof that payment-rail + settlement occurred unless they supply a real, rail-checked value + here (see https://github.com/google-agentic-commerce/AP2/issues/327). + network_confirmation_id: The network's own confirmation identifier + for this payment. Same caveat as `psp_confirmation_id`: omitting + it means the receipt's confirmation IDs are self-declared, not + independently verified. Returns: A PaymentReceipt model instance. @@ -67,11 +81,16 @@ def create_payment_receipt( issuer=issuer, reference=reference, ) + rail_confirmation_verified = ( + psp_confirmation_id is not None + and network_confirmation_id is not None + ) return PaymentReceipt( **base, payment_id=payment_id, - psp_confirmation_id=payment_id, - network_confirmation_id=payment_id, + psp_confirmation_id=psp_confirmation_id or payment_id, + network_confirmation_id=network_confirmation_id or payment_id, + rail_confirmation_verified=rail_confirmation_verified, ) def create_checkout_receipt( diff --git a/code/sdk/python/ap2/tests/receipt_wrapper_tests.py b/code/sdk/python/ap2/tests/receipt_wrapper_tests.py index c3cfaf75..6ac4dcbe 100644 --- a/code/sdk/python/ap2/tests/receipt_wrapper_tests.py +++ b/code/sdk/python/ap2/tests/receipt_wrapper_tests.py @@ -59,6 +59,46 @@ def test_create_payment_receipt_no_pisp(issuer_key): assert receipt.root.iss == '' +def test_create_payment_receipt_defaults_to_unverified(issuer_key): + """Without real rail confirmation IDs, the receipt is honestly unverified. + + Regression test for + https://github.com/google-agentic-commerce/AP2/issues/327: a Success + receipt must not silently imply payment-rail evidence it does not have. + """ + client = ReceiptClient() + reference = 'test_reference' + payment_mandate_content = _payment_mandate(pisp=None) + + receipt = client.create_payment_receipt(payment_mandate_content, reference) + + assert receipt.root.status == 'Success' + # Placeholder confirmation IDs are still populated (unchanged behavior for + # existing callers) ... + assert receipt.root.psp_confirmation_id == receipt.root.payment_id + assert receipt.root.network_confirmation_id == receipt.root.payment_id + # ... but the receipt now says so honestly instead of staying silent. + assert receipt.root.rail_confirmation_verified is False + + +def test_create_payment_receipt_with_real_rail_confirmation(issuer_key): + """Real, caller-supplied confirmation IDs mark the receipt as verified.""" + client = ReceiptClient() + reference = 'test_reference' + payment_mandate_content = _payment_mandate(pisp=None) + + receipt = client.create_payment_receipt( + payment_mandate_content, + reference, + psp_confirmation_id='psp-real-123', + network_confirmation_id='network-real-456', + ) + + assert receipt.root.psp_confirmation_id == 'psp-real-123' + assert receipt.root.network_confirmation_id == 'network-real-456' + assert receipt.root.rail_confirmation_verified is True + + def test_create_checkout_receipt(): """Test creation of a CheckoutReceipt.""" client = ReceiptClient() diff --git a/code/sdk/schemas/ap2/payment_receipt.json b/code/sdk/schemas/ap2/payment_receipt.json index f58ef045..da21c2c6 100644 --- a/code/sdk/schemas/ap2/payment_receipt.json +++ b/code/sdk/schemas/ap2/payment_receipt.json @@ -40,6 +40,10 @@ "network_confirmation_id": { "type": "string", "description": "A unique identifier for the transaction confirmation at the network. Present only if status is Success." + }, + "rail_confirmation_verified": { + "type": "boolean", + "description": "Meaningful only if status is Success. True only if the issuer independently confirmed psp_confirmation_id and network_confirmation_id against the actual payment rail (PSP/network) before issuing this receipt, rather than merely generating them. Absent or false means these confirmation IDs are self-declared by the issuer and MUST NOT be treated as evidence that payment-rail settlement occurred: presence of a confirmation ID proves the issuer asserted a claim, not that the claim was checked." } }, "required": [