Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion phonepe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@

"""Package for integration with PhonePe APIs"""

__version__ = "2.1.7"
__version__ = "2.1.8"
1 change: 1 addition & 0 deletions phonepe/sdk/pg/common/constants/headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@
CONTENT_TYPE = "Content-Type"
APPLICATION_JSON = "application/json"
X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded"
X_DEVICE_OS = "x-device-os"
16 changes: 14 additions & 2 deletions phonepe/sdk/pg/common/models/request/pg_payment_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import List
from typing import List, Optional
from dataclasses import dataclass, field
from dataclasses_json import dataclass_json, LetterCase
from dataclasses_json import dataclass_json, LetterCase, config

from phonepe.sdk.pg.common.models.request.payment_flow import PaymentFlow
from phonepe.sdk.pg.common.models.request.device_context import DeviceContext
Expand Down Expand Up @@ -87,6 +87,14 @@ class PgPaymentRequest:
expire_after: int = field(default=None)
expire_at: int = field(default=None)

# The "x-device-os" request header is currently required only for UPI COLLECT transactions.
# Once the COLLECT payment instrument is fully disabled, as per the guidelines,
# this header will no longer be necessary, even for iOS devices.
# To streamline the request structure and avoid unnecessary complexity,
# this header is now being incorporated directly into the request builder
# instead of being managed as a separate value.
device_os: Optional[str] = field(default=None, metadata=config(exclude=lambda x: True))

@staticmethod
def build_upi_intent_pay_request(
merchant_order_id: str,
Expand Down Expand Up @@ -148,6 +156,7 @@ def build_upi_collect_pay_via_vpa_request(
meta_info: MetaInfo = None,
constraints: List[InstrumentConstraint] = None,
expire_after: int = None,
device_os: str = None,
):
"""
Builds a payment request for UPI collect payment via VPA (Virtual Payment Address).
Expand Down Expand Up @@ -185,6 +194,7 @@ def build_upi_collect_pay_via_vpa_request(
)
),
expire_after=expire_after,
device_os=device_os
)

@staticmethod
Expand All @@ -196,6 +206,7 @@ def build_upi_collect_pay_via_phone_number_request(
meta_info: MetaInfo = None,
constraints: List[InstrumentConstraint] = None,
expire_after: int = None,
device_os: str = None,
):
"""
Builds a payment request for UPI collect payment via phone number.
Expand Down Expand Up @@ -234,6 +245,7 @@ def build_upi_collect_pay_via_phone_number_request(
)
),
expire_after=expire_after,
device_os=device_os
)

@staticmethod
Expand Down
3 changes: 3 additions & 0 deletions phonepe/sdk/pg/payments/v2/custom_checkout_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from phonepe.sdk.pg.common.exceptions import PhonePeException
from phonepe.sdk.pg.common.http_client_modules.http_method_type import HttpMethodType
from phonepe.sdk.pg.common.models.request.pg_payment_request import PgPaymentRequest
from phonepe.sdk.pg.common.constants.headers import X_DEVICE_OS
from phonepe.sdk.pg.common.models.response.pg_payment_response import PgPaymentResponse
from phonepe.sdk.pg.common.utils.hash_utils import calculate_hash, is_callback_valid
from phonepe.sdk.pg.env import Env
Expand Down Expand Up @@ -149,11 +150,13 @@ def pay(self, pay_request: PgPaymentRequest) -> PgPaymentResponse:
contains instrument related details
"""
try:
extra_headers = {X_DEVICE_OS: pay_request.device_os} if pay_request.device_os else {}
response = self._request_via_auth_refresh(
method=HttpMethodType.POST,
url=PAY_API,
data=pay_request.to_json(),
response_obj=PgPaymentResponse,
headers=extra_headers,
)
self.event_publisher.send(
build_custom_checkout_pay_event(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copyright 2025 PhonePe Private Limited
#
# 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
#
# http://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.

from dataclasses import dataclass, field

from dataclasses_json import dataclass_json, LetterCase


@dataclass_json(letter_case=LetterCase.CAMEL)
@dataclass
class PrefillUserLoginDetails:
phone_number: str = field(default=None)
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@

from dataclasses import dataclass, field

from dataclasses_json import dataclass_json, LetterCase
from dataclasses_json import dataclass_json, LetterCase, config
from typing import Optional

from phonepe.sdk.pg.common.models.request.meta_info import MetaInfo
from phonepe.sdk.pg.common.models.request.payment_flow import PaymentFlow
Expand All @@ -25,6 +26,9 @@
from phonepe.sdk.pg.payments.v2.models.request.pg_checkout_payment_flow import (
PgCheckoutPaymentFlow,
)
from phonepe.sdk.pg.payments.v2.models.request.prefill_user_login_details import (
PrefillUserLoginDetails,
)


@dataclass_json(letter_case=LetterCase.CAMEL)
Expand All @@ -36,6 +40,9 @@ class StandardCheckoutPayRequest:
payment_flow: PaymentFlow = field(default=None)
expire_after: int = field(default=None)
disable_payment_retry: bool = field(default=None)
# prefill_user_login_details: PrefillUserLoginDetails = field(default=None)
prefill_user_login_details: Optional[PrefillUserLoginDetails] = field(
default=None,metadata=config(exclude=lambda x: x is None))

@staticmethod
def build_request(
Expand All @@ -47,6 +54,7 @@ def build_request(
meta_info: MetaInfo = None,
payment_mode_config: PaymentModeConfig = None,
disable_payment_retry: bool = None,
prefill_user_login_details: PrefillUserLoginDetails = None,
):
"""
Builds Standard Checkout Pay Request
Expand All @@ -69,6 +77,8 @@ def build_request(
Payment mode configuration for standard checkout. Contains enabled and disabled payment modes for instrument control. If not passed default value will be used
disable_payment_retry: bool
disable payment retry parameter for standard checkout allows merchants to control if endUser is allowed to do a payment retry on the payment page
prefill_user_login_details: PrefillUserLoginDetails
User login details to prefill on the payment page

Returns
----------
Expand All @@ -86,4 +96,5 @@ def build_request(
),
expire_after=expire_after,
disable_payment_retry=disable_payment_retry,
prefill_user_login_details=prefill_user_login_details,
)
75 changes: 74 additions & 1 deletion tests/test_custom_checkout_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
from phonepe.sdk.pg.common.models.response.order_status_response import OrderStatusResponse

from phonepe.sdk.pg.env import Env, get_pg_base_url, get_oauth_base_url
from phonepe.sdk.pg.payments.v2.custom_checkout.custom_checkout_constants import ORDER_STATUS_API
from phonepe.sdk.pg.payments.v2.custom_checkout.custom_checkout_constants import ORDER_STATUS_API, PAY_API
from phonepe.sdk.pg.common.models.request.pg_payment_request import PgPaymentRequest
from phonepe.sdk.pg.common.models.request.meta_info import MetaInfo
from phonepe.sdk.pg.payments.v2.models.request.pg_v2_instrument_type import PgV2InstrumentType
from phonepe.sdk.pg.payments.v2.models.response.payment_detail import PaymentDetail
Expand Down Expand Up @@ -180,3 +181,75 @@ def test_check_status_success(self):
split_instruments=None)])
assert len(responses.calls) == 3
assert response_object == expected_order_status_obj


class TestCustomCheckoutXDeviceOs(BaseCustomCheckoutClientForTest):

def set_first_token_mock(self):
from time import time
cur_time = int(time())
token_response_data = f"""{{
"access_token": "new_token_generated_from_backend",
"encrypted_access_token": "encrypted_access_token",
"refresh_token": "refresh_token",
"expires_in": 200,
"issued_at": {cur_time},
"expires_at": {int(cur_time + 10)},
"session_expires_at": 1709630316,
"token_type": "O-Bearer"
}}
"""
responses.add(responses.POST, get_oauth_base_url(Env.SANDBOX) + OAUTH_ENDPOINT, status=200,
json=json.loads(token_response_data))

pay_response_string = """{"orderId": "OMO2403071446458436434329", "state": "PENDING", "expireAt": 1709803425841}"""

@responses.activate
def test_upi_collect_pay_sends_x_device_os_header(self):
self.set_first_token_mock()
responses.add(
responses.POST,
get_pg_base_url(Env.SANDBOX) + PAY_API,
status=200,
json=json.loads(self.pay_response_string),
match=[matchers.header_matcher({"x-device-os": "ANDROID"})],
)
request = PgPaymentRequest.build_upi_collect_pay_via_vpa_request(
merchant_order_id="MOID01",
amount=1000,
vpa="test@upi",
message="Pay now",
device_os="ANDROID",
)
response = self.custom_checkout_client.pay(request)
assert response is not None

@responses.activate
def test_upi_collect_pay_without_x_device_os_header(self):
self.set_first_token_mock()
responses.add(
responses.POST,
get_pg_base_url(Env.SANDBOX) + PAY_API,
status=200,
json=json.loads(self.pay_response_string),
)
request = PgPaymentRequest.build_upi_collect_pay_via_vpa_request(
merchant_order_id="MOID01",
amount=1000,
vpa="test@upi",
message="Pay now",
)
response = self.custom_checkout_client.pay(request)
assert response is not None

def test_upi_collect_x_device_os_not_in_json_body(self):
request = PgPaymentRequest.build_upi_collect_pay_via_vpa_request(
merchant_order_id="MOID01",
amount=1000,
vpa="test@upi",
message="Pay now",
device_os="ANDROID",
)
request_json = request.to_json()
assert "device_os" not in request_json
assert "DeviceOs" not in request_json
Loading