diff --git a/MIGRATION.md b/MIGRATION.md index a16018e..20151b1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -64,6 +64,65 @@ confirm_result: str = client.confirmWebhook("https://your-webhook.com") confirm_result: ConfirmWebhookResponse = client.webhooks.confirm("https://your-webhook.com") ``` +### Attribute naming convention + +v0.x uses **camelCase** for all type attributes. v1.x uses **snake_case** for all type attributes. + +```python +# before (v0.x - camelCase) +result.checkoutUrl +result.orderCode +result.paymentLinkId +payment_link.amountPaid +payment_link.createdAt +webhook_data.transactionDateTime + +# after (v1.x - snake_case) +result.checkout_url +result.order_code +result.payment_link_id +payment_link.amount_paid +payment_link.created_at +webhook_data.transaction_date_time +``` + +### `to_json()` method behavior change + +The `to_json()` method behavior has changed between v0.x and v1.x: + +| Version | `to_json()` returns | Key format | +| ------- | ------------------- | ---------- | +| v0.x | `dict` | camelCase | +| v1.x | `str` (JSON string) | snake_case | + +```python +# before (v0.x) - returns dict with camelCase keys +result = client.createPaymentLink(payment_data) +json_dict = result.to_json() # Returns: {"orderCode": 123, "checkoutUrl": "...", ...} +type(json_dict) # + +# after (v1.x) - returns JSON string with snake_case keys +result = client.payment_requests.create(payment_data) +json_str = result.to_json() # Returns: '{"order_code": 123, "checkout_url": "...", ...}' +type(json_str) # +``` + +To get equivalent v0.x behavior in v1.x, use `model_dump_camel_case()`: + +```python +# v1.x - get dict with camelCase keys (equivalent to v0.x to_json()) +result = client.payment_requests.create(payment_data) +json_dict = result.model_dump_camel_case() # Returns: {"orderCode": 123, "checkoutUrl": "...", ...} +type(json_dict) # + +# v1.x - other serialization options +result.model_dump() # dict with snake_case keys +result.model_dump(by_alias=True) # dict with camelCase keys (same as model_dump_camel_case) +result.model_dump_snake_case() # dict with snake_case keys (same as model_dump) +result.model_dump_json() # JSON string with snake_case keys (same as to_json) +result.model_dump_json(by_alias=True) # JSON string with camelCase keys +``` + ### Handling errors The library now raise exception as `PayOSError`, API related errors as `APIError`, webhook related errors as `WebhookError` and signature related errors as `InvalidSignatureError` instead of raise `PayOSError` for related API errors and `Error` for other errors. @@ -82,3 +141,47 @@ try: except APIError as e: print(e) ``` + +## Backward compatibility layer + +v1.x includes a backward compatibility layer that allows v0.x code to work with deprecation warnings. This gives you time to migrate gradually. + +### Using the compatibility layer + +Your existing v0.x code will continue to work: + +```python +from payos import PayOS +from payos.type import PaymentData, ItemData + +client = PayOS(client_id, api_key, checksum_key) + +# v0.x style - still works with deprecation warnings +item = ItemData(name="Product", quantity=1, price=1000) +payment_data = PaymentData( + orderCode=123, + amount=1000, + description="Order", + cancelUrl="http://cancel", + returnUrl="http://return", + items=[item], +) + +# Legacy methods return the exact same types as v0.x +result = client.createPaymentLink(payment_data) +print(result.checkoutUrl) # camelCase works +print(result.to_json()) # Returns dict with camelCase keys (v0.x behavior) + +info = client.getPaymentLinkInformation(123) +print(info.orderCode) # camelCase works +print(info.amountPaid) # camelCase works + +# Legacy methods also throw the same errors as v0.x +from payos.custom_error import PayOSError +try: + result = client.createPaymentLink(payment_data) +except PayOSError as e: + print(e.code, e.message) # Same error interface as v0.x +``` + +We recommend migrating to the new API before v2.0.0 release. diff --git a/examples/legacy.py b/examples/legacy.py new file mode 100644 index 0000000..546f134 --- /dev/null +++ b/examples/legacy.py @@ -0,0 +1,111 @@ +"""Legacy API example - demonstrates v0.x compatibility with v1.x SDK. + +Run with: python examples/legacy.py +""" + +import os +import warnings +import time + +# Set to "ignore" for cleaner output +warnings.filterwarnings("ignore", category=DeprecationWarning) + +from payos import PayOS +from payos.type import PaymentData, ItemData +from payos.constants import ERROR_MESSAGE, ERROR_CODE +from payos.custom_error import PayOSError as LegacyPayOSError +from payos.utils import ( + convertObjToQueryStr, + sortObjDataByKey, + createSignatureFromObj, + createSignatureOfPaymentRequest, +) + + +# Legacy constants +print(f"ERROR_MESSAGE: {ERROR_MESSAGE}") +print(f"ERROR_CODE: {ERROR_CODE}\n") + +# Legacy error with (code, message) signature +legacy_error = LegacyPayOSError(code="20", message="Internal Server Error") + +# Legacy types: PaymentData, ItemData +item = ItemData(name="Mi tom hao hao ly", quantity=1, price=1000) +payment_data = PaymentData( + orderCode=int(time.time()), + amount=1000, + description="Thanh toan don hang", + cancelUrl="http://localhost:8000/cancel", + returnUrl="http://localhost:8000/success", + items=[item], + buyerName="Nguyen Van A", + buyerEmail="test@example.com", + buyerPhone="0123456789", +) +webhook_body = { + "code": "00", + "desc": "success", + "success": True, + "data": { + "orderCode": 123, + "amount": 3000, + "description": "VQRIO123", + "accountNumber": "12345678", + "reference": "TF230204212323", + "transactionDateTime": "2023-02-04 18:25:00", + "currency": "VND", + "paymentLinkId": "124c33293c43417ab7879e14c8d9eb18", + "code": "00", + "desc": "Thành công", + "counterAccountBankId": "", + "counterAccountBankName": "", + "counterAccountName": "", + "counterAccountNumber": "", + "virtualAccountName": "", + "virtualAccountNumber": "", + }, + "signature": "", +} + +# Legacy utils +test_key = "test_checksum_key" +sorted_obj = sortObjDataByKey({"b": 2, "a": 1}) +query_str = convertObjToQueryStr({"amount": 1000, "orderCode": 123}) +sig_from_obj = createSignatureFromObj({"amount": 1000}, test_key) +sig_payment = createSignatureOfPaymentRequest(payment_data, test_key) + +# Initialize client +client_id = "your_client_id" +api_key = "your_api_key" +checksum_key = "your_checksum_key" + +if all([client_id, api_key, checksum_key]): + payOS = PayOS(client_id=client_id, api_key=api_key, checksum_key=checksum_key) + + try: + # Legacy: createPaymentLink() -> New: payment_requests.create() + result = payOS.createPaymentLink(payment_data) + print(f"Created: {result.checkoutUrl}") + print(f"to json: {result.to_json()}") + + # Legacy: getPaymentLinkInformation() -> New: payment_requests.get() + info = payOS.getPaymentLinkInformation(payment_data.orderCode) + print(f"Status: {info}") + + # Legacy: cancelPaymentLink() -> New: payment_requests.cancel() + cancelled = payOS.cancelPaymentLink(payment_data.orderCode, "Test") + print(f"Cancelled: {cancelled}") + + # Legacy: confirmWebhook() -> New: webhooks.confirm() + webhook_response = payOS.confirmWebhook("https://your-domain.com/webhook") + print(f"Webhook confirmed: {webhook_response}") + + # Legacy: verifyPaymentWebhookData() -> New: webhooks.verify() + webhook_body["signature"] = createSignatureFromObj(webhook_body["data"], checksum_key) + verified = payOS.verifyPaymentWebhookData(webhook_body) + print(f"Webhook verified: {verified}") + + except LegacyPayOSError as e: + print(f"Error: {e}") +else: + print("Set PAYOS_CLIENT_ID, PAYOS_API_KEY, PAYOS_CHECKSUM_KEY to test API calls") diff --git a/src/payos/_async_client.py b/src/payos/_async_client.py index 856a47e..18e58a2 100644 --- a/src/payos/_async_client.py +++ b/src/payos/_async_client.py @@ -6,13 +6,22 @@ import logging import random import time +import warnings from functools import cached_property from types import TracebackType -from typing import Any, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union from urllib.parse import urlencode, urljoin from typing_extensions import Unpack +if TYPE_CHECKING: + from .type import ( + CreatePaymentResult as LegacyCreatePaymentResult, + PaymentData as LegacyPaymentData, + PaymentLinkInformation as LegacyPaymentLinkInformation, + WebhookData as LegacyWebhookData, + ) + from ._core import ( FileDownloadResponse, FinalRequestOptions, @@ -34,6 +43,10 @@ response_to_dict, validate_positive_number, ) +from .utils._compat import ( + _create_signature_from_obj, + _create_signature_of_payment_request, +) from .utils.logs import SensitiveHeadersFilter try: @@ -561,3 +574,254 @@ def payouts(self) -> AsyncPayouts: @cached_property def payouts_account(self) -> AsyncPayoutsAccount: return AsyncPayoutsAccount(self) + + # ========================================================================= + # DEPRECATED METHODS - Backward compatibility layer for v0.x + # These methods are deprecated and will be removed in v2.0.0 + # ========================================================================= + + async def createPaymentLink( + self, paymentData: "LegacyPaymentData" + ) -> "LegacyCreatePaymentResult": + """Create a payment link. + + .. deprecated:: 1.0.0 + Use :meth:`payment_requests.create` instead. + This method will be removed in v2.0.0. + """ + warnings.warn( + "createPaymentLink() is deprecated and will be removed in v2.0.0. " + "Use client.payment_requests.create() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .custom_error import PayOSError as LegacyPayOSError + from .type import CreatePaymentResult, PaymentData + + ERROR_MESSAGE = { + "INVALID_PARAMETER": "Invalid Parameter.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + } + ERROR_CODE = {"INTERNAL_SERVER_ERROR": "20"} + + if not isinstance(paymentData, PaymentData): + raise ValueError( + f"{ERROR_MESSAGE['INVALID_PARAMETER']} paymentData is not a PaymentData Type" + ) + + paymentData.signature = _create_signature_of_payment_request(paymentData, self.checksum_key) + + url = f"{self.base_url}/v2/payment-requests" + headers = self._build_headers() + + response = await self._http_client.post(url, json=paymentData.to_json(), headers=headers) + + if response.status_code == 200: + response_json = response.json() + if response_json["code"] == "00": + response_signature = _create_signature_from_obj( + response_json["data"], self.checksum_key + ) + if response_signature != response_json["signature"]: + raise Exception(ERROR_MESSAGE["DATA_NOT_INTEGRITY"]) + if response_json["data"] is not None: + return CreatePaymentResult(**response_json["data"]) + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + else: + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["INTERNAL_SERVER_ERROR"] + ) + + async def getPaymentLinkInformation( + self, orderId: Union[str, int] + ) -> "LegacyPaymentLinkInformation": + """Get payment link information. + + .. deprecated:: 1.0.0 + Use :meth:`payment_requests.get` instead. + This method will be removed in v2.0.0. + """ + warnings.warn( + "getPaymentLinkInformation() is deprecated and will be removed in v2.0.0. " + "Use client.payment_requests.get() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .custom_error import PayOSError as LegacyPayOSError + from .type import PaymentLinkInformation, Transaction + + ERROR_MESSAGE = { + "INVALID_PARAMETER": "Invalid Parameter.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + } + ERROR_CODE = {"INTERNAL_SERVER_ERROR": "20"} + + if type(orderId) not in [str, int]: + raise ValueError(ERROR_MESSAGE["INVALID_PARAMETER"]) + + url = f"{self.base_url}/v2/payment-requests/{orderId}" + headers = self._build_headers() + + response = await self._http_client.get(url, headers=headers) + + if response.status_code == 200: + response_json = response.json() + if response_json["code"] == "00": + response_signature = _create_signature_from_obj( + response_json["data"], self.checksum_key + ) + if response_signature != response_json["signature"]: + raise Exception(ERROR_MESSAGE["DATA_NOT_INTEGRITY"]) + if response_json["data"] is not None: + response_json["data"]["transactions"] = [ + Transaction(**x) for x in response_json["data"]["transactions"] + ] + return PaymentLinkInformation(**response_json["data"]) + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + else: + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["INTERNAL_SERVER_ERROR"] + ) + + async def cancelPaymentLink( + self, orderId: Union[str, int], cancellationReason: Optional[str] = None + ) -> "LegacyPaymentLinkInformation": + """Cancel a payment link. + + .. deprecated:: 1.0.0 + Use :meth:`payment_requests.cancel` instead. + This method will be removed in v2.0.0. + """ + warnings.warn( + "cancelPaymentLink() is deprecated and will be removed in v2.0.0. " + "Use client.payment_requests.cancel() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .custom_error import PayOSError as LegacyPayOSError + from .type import PaymentLinkInformation, Transaction + + ERROR_MESSAGE = { + "INVALID_PARAMETER": "Invalid Parameter.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + } + ERROR_CODE = {"INTERNAL_SERVER_ERROR": "20"} + + if type(orderId) not in [str, int]: + raise ValueError(ERROR_MESSAGE["INVALID_PARAMETER"]) + + url = f"{self.base_url}/v2/payment-requests/{orderId}/cancel" + headers = self._build_headers() + body = ( + {"cancellationReason": cancellationReason} if cancellationReason is not None else None + ) + + response = await self._http_client.post(url, headers=headers, json=body) + + if response.status_code == 200: + response_json = response.json() + if response_json["code"] == "00": + response_signature = _create_signature_from_obj( + response_json["data"], self.checksum_key + ) + if response_signature != response_json["signature"]: + raise Exception(ERROR_MESSAGE["DATA_NOT_INTEGRITY"]) + if response_json["data"] is not None: + response_json["data"]["transactions"] = [ + Transaction(**x) for x in response_json["data"]["transactions"] + ] + return PaymentLinkInformation(**response_json["data"]) + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + else: + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["INTERNAL_SERVER_ERROR"] + ) + + async def confirmWebhook(self, webhookUrl: str) -> str: + """Confirm a webhook URL. + + .. deprecated:: 1.0.0 + Use :meth:`webhooks.confirm` instead. + This method will be removed in v2.0.0. + """ + warnings.warn( + "confirmWebhook() is deprecated and will be removed in v2.0.0. " + "Use client.webhooks.confirm() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .custom_error import PayOSError as LegacyPayOSError + + ERROR_MESSAGE = { + "INVALID_PARAMETER": "Invalid Parameter.", + "WEBHOOK_URL_INVALID": "Webhook URL invalid.", + "UNAUTHORIZED": "Unauthorized.", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + } + ERROR_CODE = {"INTERNAL_SERVER_ERROR": "20", "UNAUTHORIZED": "401"} + + if webhookUrl is None or len(webhookUrl) == 0: + raise ValueError(ERROR_MESSAGE["INVALID_PARAMETER"]) + + url = f"{self.base_url}/confirm-webhook" + headers = self._build_headers() + data = {"webhookUrl": webhookUrl} + + response = await self._http_client.post(url, json=data, headers=headers) + + if response.status_code == 200: + return webhookUrl + elif response.status_code == 404: + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["WEBHOOK_URL_INVALID"] + ) + elif response.status_code == 401: + raise LegacyPayOSError(ERROR_CODE["UNAUTHORIZED"], ERROR_MESSAGE["UNAUTHORIZED"]) + + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["INTERNAL_SERVER_ERROR"] + ) + + async def verifyPaymentWebhookData(self, webhookBody: Any) -> "LegacyWebhookData": + """Verify payment webhook data. + + .. deprecated:: 1.0.0 + Use :meth:`webhooks.verify` instead. + This method will be removed in v2.0.0. + """ + warnings.warn( + "verifyPaymentWebhookData() is deprecated and will be removed in v2.0.0. " + "Use client.webhooks.verify() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .type import WebhookData + + ERROR_MESSAGE = { + "NO_DATA": "No data.", + "NO_SIGNATURE": "No signature.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + } + + data = webhookBody["data"] + signature = webhookBody["signature"] + + if data is None: + raise ValueError(ERROR_MESSAGE["NO_DATA"]) + if signature is None: + raise ValueError(ERROR_MESSAGE["NO_SIGNATURE"]) + + sign_data = _create_signature_from_obj(data, self.checksum_key) + if sign_data != signature: + raise Exception(ERROR_MESSAGE["DATA_NOT_INTEGRITY"]) + + return WebhookData(**data) diff --git a/src/payos/_client.py b/src/payos/_client.py index d3e563c..5230457 100644 --- a/src/payos/_client.py +++ b/src/payos/_client.py @@ -5,9 +5,10 @@ import logging import random import time +import warnings from functools import cached_property from types import TracebackType -from typing import Any, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union from urllib.parse import urlencode, urljoin from typing_extensions import Unpack @@ -33,8 +34,21 @@ response_to_dict, validate_positive_number, ) +from .utils._compat import ( + _create_signature_from_obj, + _create_signature_of_payment_request, +) from .utils.logs import SensitiveHeadersFilter +if TYPE_CHECKING: + # Legacy types from payos.type module (v0 compatibility) + from .type import ( + CreatePaymentResult as LegacyCreatePaymentResult, + PaymentData as LegacyPaymentData, + PaymentLinkInformation as LegacyPaymentLinkInformation, + WebhookData as LegacyWebhookData, + ) + try: import httpx except ImportError: @@ -556,3 +570,306 @@ def payouts(self) -> Payouts: @cached_property def payouts_account(self) -> PayoutsAccount: return PayoutsAccount(self) + + # ========================================================================= + # DEPRECATED METHODS - Backward compatibility layer for v0.x + # These methods are deprecated and will be removed in v2.0.0 + # ========================================================================= + + def createPaymentLink(self, paymentData: "LegacyPaymentData") -> "LegacyCreatePaymentResult": + """Create a payment link. + + .. deprecated:: 1.0.0 + Use :meth:`payment_requests.create` instead. + This method will be removed in v2.0.0. + + Args: + paymentData: Payment data (PaymentData from payos.type). + + Returns: + CreatePaymentResult: The created payment link information. + + Raises: + PayOSError: If the API returns an error. + ValueError: If paymentData is invalid. + Exception: If signature verification fails. + """ + warnings.warn( + "createPaymentLink() is deprecated and will be removed in v2.0.0. " + "Use client.payment_requests.create() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .custom_error import PayOSError as LegacyPayOSError + from .type import CreatePaymentResult, PaymentData + + # v0 error constants (inline to avoid triggering deprecation warning) + ERROR_MESSAGE = { + "INVALID_PARAMETER": "Invalid Parameter.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + } + ERROR_CODE = {"INTERNAL_SERVER_ERROR": "20"} + + if not isinstance(paymentData, PaymentData): + raise ValueError( + f"{ERROR_MESSAGE['INVALID_PARAMETER']} paymentData is not a PaymentData Type" + ) + + paymentData.signature = _create_signature_of_payment_request(paymentData, self.checksum_key) + + url = f"{self.base_url}/v2/payment-requests" + headers = self._build_headers() + + response = self._http_client.post(url, json=paymentData.to_json(), headers=headers) + + if response.status_code == 200: + response_json = response.json() + if response_json["code"] == "00": + response_signature = _create_signature_from_obj( + response_json["data"], self.checksum_key + ) + if response_signature != response_json["signature"]: + raise Exception(ERROR_MESSAGE["DATA_NOT_INTEGRITY"]) + if response_json["data"] is not None: + return CreatePaymentResult(**response_json["data"]) + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + else: + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["INTERNAL_SERVER_ERROR"] + ) + + def getPaymentLinkInformation(self, orderId: Union[str, int]) -> "LegacyPaymentLinkInformation": + """Get payment link information. + + .. deprecated:: 1.0.0 + Use :meth:`payment_requests.get` instead. + This method will be removed in v2.0.0. + + Args: + orderId: The order ID or payment link ID. + + Returns: + PaymentLinkInformation: The payment link information. + + Raises: + PayOSError: If the API returns an error. + ValueError: If orderId is invalid. + Exception: If signature verification fails. + """ + warnings.warn( + "getPaymentLinkInformation() is deprecated and will be removed in v2.0.0. " + "Use client.payment_requests.get() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .custom_error import PayOSError as LegacyPayOSError + from .type import PaymentLinkInformation, Transaction + + # v0 error constants + ERROR_MESSAGE = { + "INVALID_PARAMETER": "Invalid Parameter.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + } + ERROR_CODE = {"INTERNAL_SERVER_ERROR": "20"} + + if type(orderId) not in [str, int]: + raise ValueError(ERROR_MESSAGE["INVALID_PARAMETER"]) + + url = f"{self.base_url}/v2/payment-requests/{orderId}" + headers = self._build_headers() + + response = self._http_client.get(url, headers=headers) + + if response.status_code == 200: + response_json = response.json() + if response_json["code"] == "00": + response_signature = _create_signature_from_obj( + response_json["data"], self.checksum_key + ) + if response_signature != response_json["signature"]: + raise Exception(ERROR_MESSAGE["DATA_NOT_INTEGRITY"]) + if response_json["data"] is not None: + response_json["data"]["transactions"] = [ + Transaction(**x) for x in response_json["data"]["transactions"] + ] + return PaymentLinkInformation(**response_json["data"]) + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + else: + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["INTERNAL_SERVER_ERROR"] + ) + + def cancelPaymentLink( + self, orderId: Union[str, int], cancellationReason: Optional[str] = None + ) -> "LegacyPaymentLinkInformation": + """Cancel a payment link. + + .. deprecated:: 1.0.0 + Use :meth:`payment_requests.cancel` instead. + This method will be removed in v2.0.0. + + Args: + orderId: The order ID or payment link ID. + cancellationReason: Optional reason for cancellation. + + Returns: + PaymentLinkInformation: The cancelled payment link information. + + Raises: + PayOSError: If the API returns an error. + ValueError: If orderId is invalid. + Exception: If signature verification fails. + """ + warnings.warn( + "cancelPaymentLink() is deprecated and will be removed in v2.0.0. " + "Use client.payment_requests.cancel() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .custom_error import PayOSError as LegacyPayOSError + from .type import PaymentLinkInformation, Transaction + + ERROR_MESSAGE = { + "INVALID_PARAMETER": "Invalid Parameter.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + } + ERROR_CODE = {"INTERNAL_SERVER_ERROR": "20"} + + if type(orderId) not in [str, int]: + raise ValueError(ERROR_MESSAGE["INVALID_PARAMETER"]) + + url = f"{self.base_url}/v2/payment-requests/{orderId}/cancel" + headers = self._build_headers() + body = ( + {"cancellationReason": cancellationReason} if cancellationReason is not None else None + ) + + response = self._http_client.post(url, headers=headers, json=body) + + if response.status_code == 200: + response_json = response.json() + if response_json["code"] == "00": + response_signature = _create_signature_from_obj( + response_json["data"], self.checksum_key + ) + if response_signature != response_json["signature"]: + raise Exception(ERROR_MESSAGE["DATA_NOT_INTEGRITY"]) + if response_json["data"] is not None: + response_json["data"]["transactions"] = [ + Transaction(**x) for x in response_json["data"]["transactions"] + ] + return PaymentLinkInformation(**response_json["data"]) + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + else: + raise LegacyPayOSError(code=response_json["code"], message=response_json["desc"]) + + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["INTERNAL_SERVER_ERROR"] + ) + + def confirmWebhook(self, webhookUrl: str) -> str: + """Confirm a webhook URL. + + .. deprecated:: 1.0.0 + Use :meth:`webhooks.confirm` instead. + This method will be removed in v2.0.0. + + Args: + webhookUrl: The webhook URL to confirm. + + Returns: + str: The confirmed webhook URL. + + Raises: + PayOSError: If the API returns an error. + ValueError: If webhookUrl is invalid. + """ + warnings.warn( + "confirmWebhook() is deprecated and will be removed in v2.0.0. " + "Use client.webhooks.confirm() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .custom_error import PayOSError as LegacyPayOSError + + ERROR_MESSAGE = { + "INVALID_PARAMETER": "Invalid Parameter.", + "WEBHOOK_URL_INVALID": "Webhook URL invalid.", + "UNAUTHORIZED": "Unauthorized.", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + } + ERROR_CODE = {"INTERNAL_SERVER_ERROR": "20", "UNAUTHORIZED": "401"} + + if webhookUrl is None or len(webhookUrl) == 0: + raise ValueError(ERROR_MESSAGE["INVALID_PARAMETER"]) + + url = f"{self.base_url}/confirm-webhook" + headers = self._build_headers() + data = {"webhookUrl": webhookUrl} + + response = self._http_client.post(url, json=data, headers=headers) + + if response.status_code == 200: + return webhookUrl # v0 returns just the URL string + elif response.status_code == 404: + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["WEBHOOK_URL_INVALID"] + ) + elif response.status_code == 401: + raise LegacyPayOSError(ERROR_CODE["UNAUTHORIZED"], ERROR_MESSAGE["UNAUTHORIZED"]) + + raise LegacyPayOSError( + ERROR_CODE["INTERNAL_SERVER_ERROR"], ERROR_MESSAGE["INTERNAL_SERVER_ERROR"] + ) + + def verifyPaymentWebhookData(self, webhookBody: Any) -> "LegacyWebhookData": + """Verify payment webhook data. + + .. deprecated:: 1.0.0 + Use :meth:`webhooks.verify` instead. + This method will be removed in v2.0.0. + + Args: + webhookBody: The webhook body to verify. + + Returns: + WebhookData: The verified webhook data. + + Raises: + ValueError: If data or signature is missing. + Exception: If signature verification fails. + """ + warnings.warn( + "verifyPaymentWebhookData() is deprecated and will be removed in v2.0.0. " + "Use client.webhooks.verify() instead. ", + DeprecationWarning, + stacklevel=2, + ) + from .type import WebhookData + + ERROR_MESSAGE = { + "NO_DATA": "No data.", + "NO_SIGNATURE": "No signature.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + } + + data = webhookBody["data"] + signature = webhookBody["signature"] + + if data is None: + raise ValueError(ERROR_MESSAGE["NO_DATA"]) + if signature is None: + raise ValueError(ERROR_MESSAGE["NO_SIGNATURE"]) + + sign_data = _create_signature_from_obj(data, self.checksum_key) + if sign_data != signature: + raise Exception(ERROR_MESSAGE["DATA_NOT_INTEGRITY"]) + + return WebhookData(**data) diff --git a/src/payos/constants.py b/src/payos/constants.py new file mode 100644 index 0000000..bfd50c3 --- /dev/null +++ b/src/payos/constants.py @@ -0,0 +1,40 @@ +"""This module is deprecated and will be removed in v2.0.0. +The constants defined here are no longer part of the public API. + +For error handling, use the exception classes from the main payos module: + from payos import PayOSError, APIError, WebhookError, InvalidSignatureError + +For the base URL, use the PayOS client's base_url attribute or environment variables. +""" + +import warnings + +warnings.warn( + "The 'payos.constants' module is deprecated and will be removed in v2.0.0. " + "Constants are no longer part of the public API. ", + DeprecationWarning, + stacklevel=2, +) + +# Legacy error messages for backward compatibility +ERROR_MESSAGE = { + "NO_SIGNATURE": "No signature.", + "NO_DATA": "No data.", + "INVALID_SIGNATURE": "Invalid signature.", + "DATA_NOT_INTEGRITY": "The data is unreliable because the signature of the response does not match the signature of the data", + "WEBHOOK_URL_INVALID": "Webhook URL invalid.", + "UNAUTHORIZED": "Unauthorized.", + "INTERNAL_SERVER_ERROR": "Internal Server Error.", + "INVALID_PARAMETER": "Invalid Parameter.", +} + +# Legacy error codes for backward compatibility +ERROR_CODE = { + "INTERNAL_SERVER_ERROR": "20", + "UNAUTHORIZED": "401", +} + +# Legacy base URL constant +PAYOS_BASE_URL = "https://api-merchant.payos.vn" + +__all__ = ["ERROR_MESSAGE", "ERROR_CODE", "PAYOS_BASE_URL"] diff --git a/src/payos/custom_error.py b/src/payos/custom_error.py new file mode 100644 index 0000000..2aefb06 --- /dev/null +++ b/src/payos/custom_error.py @@ -0,0 +1,41 @@ +"""This module is deprecated and will be removed in v2.0.0. +Import PayOSError from the main payos module instead: + + from payos import PayOSError + +For more specific error handling, use: + from payos import APIError, WebhookError, InvalidSignatureError +""" + +import warnings + +warnings.warn( + "The 'payos.custom_error' module is deprecated and will be removed in v2.0.0. " + "Import PayOSError from 'payos' directly instead: from payos import PayOSError. ", + DeprecationWarning, + stacklevel=2, +) + + +class PayOSError(Exception): + """Legacy PayOSError class. + + .. deprecated:: 1.0.0 + Use APIError from 'payos' module instead. + This class will be removed in v2.0.0. + + The v0.x PayOSError accepted (code, message) parameters. + """ + + def __init__(self, code: str, message: str) -> None: + """Initialize PayOSError with legacy signature. + + Args: + code: Error code (e.g., "20", "401"). + message: Error message. + """ + super().__init__(message) + self.code = code + + +__all__ = ["PayOSError"] diff --git a/src/payos/type.py b/src/payos/type.py new file mode 100644 index 0000000..bb5189e --- /dev/null +++ b/src/payos/type.py @@ -0,0 +1,217 @@ +"""Legacy type module for backward compatibility with v0.x. + +.. deprecated:: 1.0.0 + Import from 'payos.types' instead. This module will be removed in v2.0.0. + +""" + +import warnings +from dataclasses import dataclass +from typing import Any, Optional + +warnings.warn( + "The 'payos.type' module is deprecated and will be removed in v2.0.0. " + "Use 'payos.types' instead.", + DeprecationWarning, + stacklevel=2, +) + + +class ItemData: + def __init__(self, name: str, quantity: int, price: int): + self.name = name + self.quantity = quantity + self.price = price + + def to_json(self) -> dict[str, Any]: + return { + "name": self.name, + "quantity": self.quantity, + "price": self.price, + } + + +class PaymentData: + def __init__( + self, + orderCode: int, + amount: int, + description: str, + cancelUrl: str, + returnUrl: str, + buyerName: Optional[str] = None, + items: Optional[list[ItemData]] = None, + buyerEmail: Optional[str] = None, + buyerPhone: Optional[str] = None, + buyerAddress: Optional[str] = None, + expiredAt: Optional[int] = None, + signature: Optional[str] = None, + ): + self.orderCode = orderCode + self.amount = amount + self.description = description + self.items = items + self.cancelUrl = cancelUrl + self.returnUrl = returnUrl + self.signature = signature + self.buyerName = buyerName + self.buyerEmail = buyerEmail + self.buyerPhone = buyerPhone + self.buyerAddress = buyerAddress + self.expiredAt = expiredAt + + def to_json(self) -> dict[str, Any]: + return { + "orderCode": self.orderCode, + "amount": self.amount, + "description": self.description, + "items": [item.to_json() for item in self.items] if self.items else None, + "cancelUrl": self.cancelUrl, + "returnUrl": self.returnUrl, + "signature": self.signature, + "buyerName": self.buyerName, + "buyerEmail": self.buyerEmail, + "buyerPhone": self.buyerPhone, + "buyerAddress": self.buyerAddress, + "expiredAt": self.expiredAt, + } + + +@dataclass +class CreatePaymentResult: + bin: str + accountNumber: str + accountName: str + amount: int + description: str + orderCode: int + currency: str + paymentLinkId: str + status: str + checkoutUrl: str + qrCode: str + expiredAt: Optional[int] = None + + def to_json(self) -> dict[str, Any]: + return { + "bin": self.bin, + "accountNumber": self.accountNumber, + "accountName": self.accountName, + "amount": self.amount, + "description": self.description, + "orderCode": self.orderCode, + "currency": self.currency, + "paymentLinkId": self.paymentLinkId, + "status": self.status, + "expiredAt": self.expiredAt, + "checkoutUrl": self.checkoutUrl, + "qrCode": self.qrCode, + } + + +@dataclass +class Transaction: + reference: str + amount: int + accountNumber: str + description: str + transactionDateTime: str + virtualAccountName: Optional[str] + virtualAccountNumber: Optional[str] + counterAccountBankId: Optional[str] + counterAccountBankName: Optional[str] + counterAccountName: Optional[str] + counterAccountNumber: Optional[str] + + def to_json(self) -> dict[str, Any]: + return { + "reference": self.reference, + "amount": self.amount, + "accountNumber": self.accountNumber, + "description": self.description, + "transactionDateTime": self.transactionDateTime, + "virtualAccountName": self.virtualAccountName, + "virtualAccountNumber": self.virtualAccountNumber, + "counterAccountBankId": self.counterAccountBankId, + "counterAccountBankName": self.counterAccountBankName, + "counterAccountName": self.counterAccountName, + "counterAccountNumber": self.counterAccountNumber, + } + + +@dataclass +class PaymentLinkInformation: + id: str + orderCode: int + amount: int + amountPaid: int + amountRemaining: int + status: str + createdAt: str + transactions: list[Transaction] + cancellationReason: Optional[str] + canceledAt: Optional[str] + + def to_json(self) -> dict[str, Any]: + return { + "id": self.id, + "orderCode": self.orderCode, + "amount": self.amount, + "amountPaid": self.amountPaid, + "amountRemaining": self.amountRemaining, + "status": self.status, + "createdAt": self.createdAt, + "transactions": [t.to_json() for t in self.transactions] if self.transactions else None, + "cancellationReason": self.cancellationReason, + "canceledAt": self.canceledAt, + } + + +@dataclass +class WebhookData: + orderCode: int + amount: int + description: str + accountNumber: str + reference: str + transactionDateTime: str + paymentLinkId: str + code: str + desc: str + counterAccountBankId: Optional[str] + counterAccountBankName: Optional[str] + counterAccountName: Optional[str] + counterAccountNumber: Optional[str] + virtualAccountName: Optional[str] + virtualAccountNumber: Optional[str] + currency: str + + def to_json(self) -> dict[str, Any]: + return { + "orderCode": self.orderCode, + "amount": self.amount, + "description": self.description, + "accountNumber": self.accountNumber, + "reference": self.reference, + "transactionDateTime": self.transactionDateTime, + "paymentLinkId": self.paymentLinkId, + "currency": self.currency, + "code": self.code, + "desc": self.desc, + "counterAccountBankId": self.counterAccountBankId, + "counterAccountBankName": self.counterAccountBankName, + "counterAccountName": self.counterAccountName, + "counterAccountNumber": self.counterAccountNumber, + "virtualAccountName": self.virtualAccountName, + "virtualAccountNumber": self.virtualAccountNumber, + } + + +__all__ = [ + "ItemData", + "PaymentData", + "CreatePaymentResult", + "Transaction", + "PaymentLinkInformation", + "WebhookData", +] diff --git a/src/payos/utils/__init__.py b/src/payos/utils/__init__.py index 6e4ba09..0538470 100644 --- a/src/payos/utils/__init__.py +++ b/src/payos/utils/__init__.py @@ -1,5 +1,12 @@ """Utilities module.""" +# Legacy v0.x compatibility exports +from ._compat import ( + convertObjToQueryStr, + createSignatureFromObj, + createSignatureOfPaymentRequest, + sortObjDataByKey, +) from .casting import cast_to from .env import get_env_var from .json_utils import build_query_string, request_to_dict, response_to_dict, safe_json_parse @@ -19,4 +26,9 @@ "response_to_dict", "validate_positive_number", "cast_to", + # Legacy v0.x compatibility + "convertObjToQueryStr", + "sortObjDataByKey", + "createSignatureFromObj", + "createSignatureOfPaymentRequest", ] diff --git a/src/payos/utils/_compat.py b/src/payos/utils/_compat.py new file mode 100644 index 0000000..8b60282 --- /dev/null +++ b/src/payos/utils/_compat.py @@ -0,0 +1,151 @@ +"""Legacy utils compatibility layer for v0.x API. + +This module provides backward-compatible utility functions from payOS SDK v0.x. +All functions emit DeprecationWarning when used. + +Migration guide: + - convertObjToQueryStr() -> Use payos._crypto.CryptoProvider internally + - sortObjDataByKey() -> Use payos._crypto.CryptoProvider internally + - createSignatureFromObj() -> Use payos._crypto.CryptoProvider.create_signature_from_object() + - createSignatureOfPaymentRequest() -> Use payos._crypto.CryptoProvider.create_signature_of_payment_request() +""" + +import hashlib +import hmac +import json +import warnings +from typing import Any + + +def _sort_obj_data_by_key(obj: dict[str, Any]) -> dict[str, Any]: + """Internal: Sort dictionary by keys (no deprecation warning).""" + return dict(sorted(obj.items())) + + +def _convert_obj_to_query_str(obj: dict[str, Any]) -> str: + """Internal: Convert dictionary to URL query string format (no deprecation warning).""" + query_string = [] + + for key, value in obj.items(): + value_as_string = "" + if isinstance(value, (int, float, bool)): + value_as_string = str(value) + elif value in [None, "null", "NULL"]: + value_as_string = "" + elif isinstance(value, list): + value_as_string = json.dumps( + [dict(sorted(item.items())) for item in value], separators=(",", ":") + ).replace("None", "null") + else: + value_as_string = str(value) + query_string.append(f"{key}={value_as_string}") + + return "&".join(query_string) + + +def _create_signature_from_obj(data: dict[str, Any], key: str) -> str: + """Internal: Create HMAC-SHA256 signature from dictionary (no deprecation warning).""" + sorted_data_by_key = _sort_obj_data_by_key(data) + data_query_str = _convert_obj_to_query_str(sorted_data_by_key) + return hmac.new( + key.encode("utf-8"), msg=data_query_str.encode("utf-8"), digestmod=hashlib.sha256 + ).hexdigest() + + +def _create_signature_of_payment_request(data: Any, key: str) -> str: + """Internal: Create signature for payment request (no deprecation warning).""" + if hasattr(data, "amount"): + amount = data.amount + else: + amount = data.get("amount", "") + + if hasattr(data, "cancelUrl"): + cancel_url = data.cancelUrl + elif hasattr(data, "cancel_url"): + cancel_url = data.cancel_url + else: + cancel_url = data.get("cancelUrl", data.get("cancel_url", "")) + + if hasattr(data, "description"): + description = data.description + else: + description = data.get("description", "") + + if hasattr(data, "orderCode"): + order_code = data.orderCode + elif hasattr(data, "order_code"): + order_code = data.order_code + else: + order_code = data.get("orderCode", data.get("order_code", "")) + + if hasattr(data, "returnUrl"): + return_url = data.returnUrl + elif hasattr(data, "return_url"): + return_url = data.return_url + else: + return_url = data.get("returnUrl", data.get("return_url", "")) + + data_str = f"amount={amount}&cancelUrl={cancel_url}&description={description}&orderCode={order_code}&returnUrl={return_url}" + return hmac.new( + key.encode("utf-8"), msg=data_str.encode("utf-8"), digestmod=hashlib.sha256 + ).hexdigest() + + +def sortObjDataByKey(obj: dict[str, Any]) -> dict[str, Any]: + """Sort dictionary by keys. + + .. deprecated:: 1.0.0 + This is an internal utility. Use the SDK's built-in signature methods instead. + """ + warnings.warn( + "sortObjDataByKey() is deprecated and will be removed in v2.0.0. " + "Use the SDK's built-in signature methods instead.", + DeprecationWarning, + stacklevel=2, + ) + return _sort_obj_data_by_key(obj) + + +def convertObjToQueryStr(obj: dict[str, Any]) -> str: + """Convert dictionary to URL query string format. + + .. deprecated:: 1.0.0 + This is an internal utility. Use the SDK's built-in signature methods instead. + """ + warnings.warn( + "convertObjToQueryStr() is deprecated and will be removed in v2.0.0. " + "Use the SDK's built-in signature methods instead.", + DeprecationWarning, + stacklevel=2, + ) + return _convert_obj_to_query_str(obj) + + +def createSignatureFromObj(data: dict[str, Any], key: str) -> str: + """Create HMAC-SHA256 signature from dictionary. + + .. deprecated:: 1.0.0 + Use payos._crypto.CryptoProvider.create_signature_from_object() instead. + """ + warnings.warn( + "createSignatureFromObj() is deprecated and will be removed in v2.0.0. " + "Use payos._crypto.CryptoProvider.create_signature_from_object() instead.", + DeprecationWarning, + stacklevel=2, + ) + return _create_signature_from_obj(data, key) + + +def createSignatureOfPaymentRequest(data: Any, key: str) -> str: + """Create signature for payment request. + + .. deprecated:: 1.0.0 + Use payos._crypto.CryptoProvider.create_signature_of_payment_request() instead. + """ + warnings.warn( + "createSignatureOfPaymentRequest() is deprecated and will be removed in v2.0.0. " + "Use payos._crypto.CryptoProvider.create_signature_of_payment_request() instead.", + DeprecationWarning, + stacklevel=2, + ) + return _create_signature_of_payment_request(data, key) diff --git a/tests/_crypto/test_provider.py b/tests/_crypto/test_provider.py index 2ccec5b..c1b91c3 100644 --- a/tests/_crypto/test_provider.py +++ b/tests/_crypto/test_provider.py @@ -19,7 +19,9 @@ # Filter test cases by type BODY_TEST_CASES = [tc for tc in ALL_TEST_CASES if tc["type"] == "body"] -CREATE_PAYMENT_LINK_TEST_CASES = [tc for tc in ALL_TEST_CASES if tc["type"] == "create-payment-link"] +CREATE_PAYMENT_LINK_TEST_CASES = [ + tc for tc in ALL_TEST_CASES if tc["type"] == "create-payment-link" +] HEADER_TEST_CASES = [tc for tc in ALL_TEST_CASES if tc["type"] == "header"] diff --git a/tests/utils/test_json_utils.py b/tests/utils/test_json_utils.py index 12fd8c8..b4ebf18 100644 --- a/tests/utils/test_json_utils.py +++ b/tests/utils/test_json_utils.py @@ -5,7 +5,12 @@ import httpx import pytest -from payos.utils.json_utils import build_query_string, request_to_dict, response_to_dict, safe_json_parse +from payos.utils.json_utils import ( + build_query_string, + request_to_dict, + response_to_dict, + safe_json_parse, +) class TestSafeJsonParse: