From 3e26de97662621023cbfa742dda23c16a753aa13 Mon Sep 17 00:00:00 2001 From: Rakibul Yeasin Date: Sun, 26 Apr 2026 12:03:01 +0600 Subject: [PATCH 1/2] refactor: modernize to Python 3.10+ with repository/service pattern - Introduce repository/service pattern with typed dataclass DTOs (PaymentRequest, PaymentResponse, ValidationResponse, CustomerInfo, ShippingInfo) and a custom exception hierarchy (SSLCommerzError, SSLCommerzAPIError, SSLCommerzValidationError) - Replace flat builder classes (base.py, payment.py, validation.py) with AbstractSSLCommerzRepository, SSLCommerzRepository, and SSLCommerzService; SSLCSession preserved as a deprecation-warned shim - Add request timeout (default 30s), raise_for_status(), and structured logging via logging.getLogger(__name__) - Replace Tests/ with tests/ (32 tests covering service, repository, IPN verification, models, and compat shim) - Bump requires-python to >=3.10, version to 2.0.0, fix CI matrix to Python 3.10/3.11/3.12, update GitHub URLs, drop requirements.txt - Add docstrings to all classes and methods; fix .gitignore globs --- .github/workflows/publish.yml | 35 ++--- .github/workflows/test.yml | 26 +--- .gitignore | 17 +-- README.md | 199 +++++++++++++++----------- Tests/__init__.py | 0 Tests/test_compat.py | 74 ++++++++++ Tests/test_general.py | 23 --- Tests/test_ipn.py | 57 ++++++++ Tests/test_models.py | 87 ++++++++++++ Tests/test_repository.py | 127 +++++++++++++++++ Tests/test_service.py | 87 ++++++++++++ pyproject.toml | 57 +++++--- requirements.txt | Bin 354 -> 0 bytes sslcommerz_python_api/__init__.py | 23 ++- sslcommerz_python_api/_compat.py | 209 ++++++++++++++++++++++++++++ sslcommerz_python_api/base.py | 39 ------ sslcommerz_python_api/exceptions.py | 51 +++++++ sslcommerz_python_api/models.py | 155 +++++++++++++++++++++ sslcommerz_python_api/payment.py | 193 ------------------------- sslcommerz_python_api/repository.py | 199 ++++++++++++++++++++++++++ sslcommerz_python_api/service.py | 151 ++++++++++++++++++++ sslcommerz_python_api/validation.py | 80 ----------- 22 files changed, 1390 insertions(+), 499 deletions(-) delete mode 100644 Tests/__init__.py create mode 100644 Tests/test_compat.py delete mode 100644 Tests/test_general.py create mode 100644 Tests/test_ipn.py create mode 100644 Tests/test_models.py create mode 100644 Tests/test_repository.py create mode 100644 Tests/test_service.py delete mode 100644 requirements.txt create mode 100644 sslcommerz_python_api/_compat.py delete mode 100644 sslcommerz_python_api/base.py create mode 100644 sslcommerz_python_api/exceptions.py create mode 100644 sslcommerz_python_api/models.py delete mode 100644 sslcommerz_python_api/payment.py create mode 100644 sslcommerz_python_api/repository.py create mode 100644 sslcommerz_python_api/service.py delete mode 100644 sslcommerz_python_api/validation.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6e60fdd..d4e9f44 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,35 +6,20 @@ on: jobs: build-n-publish: - name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI + name: Build and publish Python distributions to PyPI runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: [3.7] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} - - name: Install pypa/build - run: >- - python -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . - - name: Publish a Python distribution to PyPI + python-version: "3.12" + - name: Install build + run: pip install build --user + - name: Build wheel and source tarball + run: python -m build --sdist --wheel --outdir dist/ . + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fb8efd5..a268916 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,27 +11,15 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.7] + python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install Requirements - run: >- - python -m - pip install - -r - requirements.txt - - name: Install Pytest - run: >- - python -m - pip install - pytest - - name: Run Test - run: >- - pytest - -vv - + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Run tests + run: pytest tests/ -vv diff --git a/.gitignore b/.gitignore index d28b115..24130cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -venv -**__pycache__** -.vscode -build -dist -*.egg* -.pytest* -test_module \ No newline at end of file +venv/ +**/__pycache__/ +*.pyc +.vscode/ +build/ +dist/ +*.egg-info/ +.pytest_cache/ +test_module/ diff --git a/README.md b/README.md index 986078c..8b0f6b9 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,160 @@ # SSLCOMMERZ Payment Gateway Python API [![Downloads](https://static.pepy.tech/personalized-badge/sslcommerz-python-api?period=total&units=international_system&left_color=blue&right_color=grey&left_text=Downloads)](https://pepy.tech/project/sslcommerz-python-api) -Provides a python module to implement payment gateway in python based web apps. +Python wrapper for the SSLCommerz payment gateway. Requires Python 3.10+. ## Installation -Via PIP - ```sh pip install sslcommerz-python-api ``` -or via git +or from git ```sh pip install git+https://github.com/dreygur/SSLCommerz-Python.git ``` -## Projected use +## Usage -```python3 -#!usr/bin/env python +### Initiate Payment +```python from decimal import Decimal -from sslcommerz_python_api import SSLCSession - -mypayment = SSLCSession( - sslc_is_sandbox=True, - sslc_store_id='your_sslc_store_id', - sslc_store_pass='your_sslc_store_passcode' -) +from uuid import uuid4 -mypayment.set_urls( - success_url='example.com/success', - fail_url='example.com/failed', - cancel_url='example.com/cancel', - ipn_url='example.com/payment_notification' -) +from sslcommerz_python_api import SSLCommerzService +from sslcommerz_python_api.models import CustomerInfo, PaymentRequest, ShippingInfo -mypayment.set_product_integration( - total_amount=Decimal('20.20'), - currency='BDT', - product_category='clothing', - product_name='demo-product', - num_of_item=2, - shipping_method='YES', - product_profile='None' +service = SSLCommerzService.create( + store_id='your_store_id', + store_pass='your_store_pass', + is_sandbox=True, ) -mypayment.set_customer_info( - name='John Doe', - email='johndoe@email.com', - address1='demo address', - address2='demo address 2', - city='Dhaka', postcode='1207', - country='Bangladesh', - phone='01711111111' +request = PaymentRequest( + store_id='your_store_id', + store_pass='your_store_pass', + tran_id=str(uuid4()), + total_amount=Decimal('20.20'), + currency='BDT', + success_url='https://example.com/success', + fail_url='https://example.com/failed', + cancel_url='https://example.com/cancel', + ipn_url='https://example.com/ipn', + product_name='demo-product', + product_category='clothing', + num_of_item=2, + shipping_method='YES', + customer=CustomerInfo( + name='John Doe', + email='johndoe@email.com', + address1='demo address', + address2='demo address 2', + city='Dhaka', + postcode='1207', + country='Bangladesh', + phone='01711111111', + ), + shipping=ShippingInfo( + ship_name='demo customer', + address='demo address', + city='Dhaka', + postcode='1209', + country='Bangladesh', + ), + value_a='extra-a', + value_b='extra-b', ) -mypayment.set_shipping_info( - shipping_to='demo customer', - address='demo address', - city='Dhaka', - postcode='1209', - country='Bangladesh' -) +response = service.initiate_payment(request) +print(response.gateway_url) # redirect user here +print(response.session_key) +print(response.is_success) # True / False +``` -# If you want to post some additional values -mypayment.set_additional_values( - value_a='cusotmer@email.com', - value_b='portalcustomerid', - value_c='1234', - value_d='uuid' -) +### Response -response_data = mypayment.init_payment() +On success, `initiate_payment` returns a `PaymentResponse` dataclass: -# You can Print the response data -print(response_data) -``` +| Field | Type | Description | +|---|---|---| +| `status` | `str` | `"SUCCESS"` or `"FAILED"` | +| `session_key` | `str` | SSLCommerz session key | +| `gateway_url` | `str` | URL to redirect the user to | +| `is_success` | `bool` | convenience property | -## Response parameters +On failure, raises `SSLCommerzAPIError`. -### When Successfull with Auth and Payloads provided +### Validate Transaction -- status -- sessionkey -- GatewayPageURL +```python +from sslcommerz_python_api.exceptions import SSLCommerzValidationError + +try: + result = service.validate_transaction(val_id='VAL_ID_FROM_IPN') + print(result.status) # "VALIDATED" + print(result.data) # full raw response dict +except SSLCommerzValidationError as e: + print(f"Validation failed: {e}") +``` -#### Example +### Verify IPN Signature -```sh -{'status': 'SUCCESS', 'sessionkey': 'F650E87F23DD2A8FFCB4E4E333C13B28', 'GatewayPageURL': 'https://sandbox.sslcommerz.com/EasyCheckOut/testcdef650e87f23dd2a8ffcb4234fasf3b28'} +```python +# ipn_data = POST body received from SSLCommerz webhook +try: + service.verify_ipn(ipn_data) + # signature valid — process the order +except SSLCommerzValidationError: + # signature mismatch — reject + pass ``` -or +### Error Handling ```python ->>> response_data['status'] -SUCCESS ->>> response_data['sessionkey'] -F650E87F23DD2A8FFCB4E4E333C13B28 ->>> response_data['GatewayPageURL'] -https://sandbox.sslcommerz.com/EasyCheckOut/testcdef650e87f23dd2a8ffcb4234fasf3b28 +from sslcommerz_python_api.exceptions import ( + SSLCommerzAPIError, + SSLCommerzValidationError, + SSLCommerzError, +) + +try: + response = service.initiate_payment(request) +except SSLCommerzAPIError as e: + print(e.reason) # SSLCommerz failure reason + print(e.status_code) # HTTP status code if available +except SSLCommerzError: + # catch-all for any library error + pass ``` -### When Failed +### Logging -- status -- failedreason +The library logs via Python's standard `logging` module under the `sslcommerz_python_api` namespace. -#### Example +```python +import logging -```sh -{'status': 'FAILED', 'failedreason': 'Store Credential Error Or Store is De-active'} +# enable for all loggers +logging.basicConfig(level=logging.DEBUG) + +# or target just this library +logging.getLogger("sslcommerz_python_api").setLevel(logging.DEBUG) ``` -or +| Level | Events | +|---|---| +| `DEBUG` | HTTP request URL + transaction/val ID before each call | +| `DEBUG` | Response status after each call | +| `WARNING` | Payment initiation failed (FAILED status from API) | +| `WARNING` | Transaction not validated | -```python ->>> response_data['status'] -FAILED ->>> response_data['failedreason'] -'Store Credential Error Or Store is De-active' -``` +## Migration from v1 + +`SSLCSession` still works but emits a `DeprecationWarning`. Switch to `SSLCommerzService` at your convenience — the old builder API is not planned for removal in the near term. + +## Acknowledgements -## Acknowledgemetns -It's a fork of [Shahed Mehbub's](https://github.com/shahedex) [sslcommerz-python](https://github.com/shahedex/sslcommerz-payment-gateway-python) +Fork of [Shahed Mehbub's](https://github.com/shahedex) [sslcommerz-python](https://github.com/shahedex/sslcommerz-payment-gateway-python). diff --git a/Tests/__init__.py b/Tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/test_compat.py b/Tests/test_compat.py new file mode 100644 index 0000000..2fab389 --- /dev/null +++ b/Tests/test_compat.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import warnings +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest + + +def test_sslcsession_emits_deprecation_warning(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + from sslcommerz_python_api._compat import SSLCSession + SSLCSession(sslc_is_sandbox=True, sslc_store_id="sid", sslc_store_pass="pass") + assert any(issubclass(warning.category, DeprecationWarning) for warning in w) + + +def test_sslcsession_init_payment_returns_dict(): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + from sslcommerz_python_api._compat import SSLCSession + session = SSLCSession(sslc_is_sandbox=True, sslc_store_id="sid", sslc_store_pass="pass") + + mock_resp = MagicMock() + mock_resp.json.return_value = { + "status": "SUCCESS", + "sessionkey": "SESS456", + "GatewayPageURL": "https://sandbox.sslcommerz.com/pay", + } + with patch("sslcommerz_python_api.repository.requests.post", return_value=mock_resp): + session.set_urls( + success_url="https://x.com/s", + fail_url="https://x.com/f", + cancel_url="https://x.com/c", + ) + session.set_product_integration( + total_amount=Decimal("20.20"), + currency="BDT", + product_category="clothes", + product_name="shirt", + num_of_item=1, + shipping_method="YES", + ) + session.set_customer_info( + name="Jane", + email="jane@x.com", + address1="123 St", + city="Dhaka", + postcode="1207", + country="Bangladesh", + phone="01700000000", + ) + result = session.init_payment() + + assert result["status"] == "SUCCESS" + assert result["sessionkey"] == "SESS456" + assert "GatewayPageURL" in result + + +def test_sslcsession_init_payment_failure_returns_failed_dict(): + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + from sslcommerz_python_api._compat import SSLCSession + session = SSLCSession(sslc_is_sandbox=True, sslc_store_id="sid", sslc_store_pass="pass") + + mock_resp = MagicMock() + mock_resp.json.return_value = {"status": "FAILED", "failedreason": "Bad creds"} + with patch("sslcommerz_python_api.repository.requests.post", return_value=mock_resp): + session.set_urls("s", "f", "c") + session.set_product_integration(Decimal("10"), "BDT", "cat", "prod", 1, "NO") + result = session.init_payment() + + assert result["status"] == "FAILED" + assert "failedreason" in result diff --git a/Tests/test_general.py b/Tests/test_general.py deleted file mode 100644 index bd565cd..0000000 --- a/Tests/test_general.py +++ /dev/null @@ -1,23 +0,0 @@ -from sslcommerz_python_api import SSLCSession -from decimal import Decimal - - -def response(): - mypayment = SSLCSession(sslc_is_sandbox=True, sslc_store_id='your_sslc_store_id', - sslc_store_pass='your_sslc_store_passcode') - mypayment.set_urls(success_url='example.com/success', fail_url='example.com/failed', - cancel_url='example.com/cancel', ipn_url='example.com/payment_notification') - mypayment.set_product_integration(total_amount=Decimal('20.20'), currency='BDT', product_category='clothing', - product_name='demo-product', num_of_item=2, shipping_method='YES', product_profile='None') - mypayment.set_customer_info(name='John Doe', email='johndoe@email.com', address1='demo address', - address2='demo address 2', city='Dhaka', postcode='1207', country='Bangladesh', phone='01711111111') - mypayment.set_shipping_info(shipping_to='demo customer', address='demo address', - city='Dhaka', postcode='1209', country='Bangladesh') - # If you want to post some additional values - mypayment.set_additional_values( - value_a='cusotmer@email.com', value_b='portalcustomerid', value_c='1234', value_d='uuid') - return mypayment.init_payment() - - -def test_response(): - assert test_response is not None diff --git a/Tests/test_ipn.py b/Tests/test_ipn.py new file mode 100644 index 0000000..4483a0b --- /dev/null +++ b/Tests/test_ipn.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import hashlib +from unittest.mock import MagicMock + +import pytest + +from sslcommerz_python_api.exceptions import SSLCommerzValidationError +from sslcommerz_python_api.service import SSLCommerzService + + +def _build_valid_ipn(store_pass: str, fields: dict) -> dict: + store_pass_hash = hashlib.md5(store_pass.encode()).hexdigest() + check_params = dict(fields) + check_params["store_passwd"] = store_pass_hash + sign_string = "&".join(f"{k}={v}" for k, v in sorted(check_params.items())) + verify_sign = hashlib.md5(sign_string.encode()).hexdigest() + return {**fields, "verify_key": ",".join(fields.keys()), "verify_sign": verify_sign} + + +@pytest.fixture +def service(): + return SSLCommerzService(MagicMock(), store_pass="mypass") + + +def test_valid_ipn_returns_true(service): + ipn_data = _build_valid_ipn("mypass", {"amount": "100.00", "currency": "BDT"}) + assert service.verify_ipn(ipn_data) is True + + +def test_tampered_amount_raises(service): + ipn_data = _build_valid_ipn("mypass", {"amount": "100.00", "currency": "BDT"}) + ipn_data["amount"] = "9999.00" + with pytest.raises(SSLCommerzValidationError, match="signature mismatch"): + service.verify_ipn(ipn_data) + + +def test_wrong_store_pass_raises(): + service = SSLCommerzService(MagicMock(), store_pass="wrongpass") + ipn_data = _build_valid_ipn("correctpass", {"amount": "100.00"}) + with pytest.raises(SSLCommerzValidationError, match="signature mismatch"): + service.verify_ipn(ipn_data) + + +def test_missing_verify_key_raises(service): + with pytest.raises(SSLCommerzValidationError, match="missing required fields"): + service.verify_ipn({"verify_sign": "abc"}) + + +def test_missing_verify_sign_raises(service): + with pytest.raises(SSLCommerzValidationError, match="missing required fields"): + service.verify_ipn({"verify_key": "amount"}) + + +def test_empty_ipn_raises(service): + with pytest.raises(SSLCommerzValidationError): + service.verify_ipn({}) diff --git a/Tests/test_models.py b/Tests/test_models.py new file mode 100644 index 0000000..db9fdc7 --- /dev/null +++ b/Tests/test_models.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from sslcommerz_python_api.models import ( + CustomerInfo, + PaymentRequest, + PaymentResponse, + ShippingInfo, + ValidationResponse, +) + + +def _base_request(**overrides): + defaults = dict( + store_id="sid", + store_pass="pass", + tran_id="T1", + total_amount=Decimal("10.00"), + currency="BDT", + success_url="s", + fail_url="f", + cancel_url="c", + ) + defaults.update(overrides) + return PaymentRequest(**defaults) + + +def test_payment_request_valid(): + r = _base_request() + assert r.store_id == "sid" + assert r.total_amount == Decimal("10.00") + + +def test_payment_request_negative_amount_raises(): + with pytest.raises(ValueError, match="total_amount must be positive"): + _base_request(total_amount=Decimal("-1.00")) + + +def test_payment_request_zero_amount_raises(): + with pytest.raises(ValueError, match="total_amount must be positive"): + _base_request(total_amount=Decimal("0")) + + +def test_payment_request_empty_store_id_raises(): + with pytest.raises(ValueError, match="store_id is required"): + _base_request(store_id="") + + +def test_payment_request_empty_store_pass_raises(): + with pytest.raises(ValueError, match="store_pass is required"): + _base_request(store_pass="") + + +def test_payment_response_is_success(): + r = PaymentResponse(status="SUCCESS", session_key="K", gateway_url="u") + assert r.is_success is True + + +def test_payment_response_is_not_success(): + r = PaymentResponse(status="FAILED") + assert r.is_success is False + + +def test_validation_response_is_validated(): + r = ValidationResponse(status="VALIDATED", data={}) + assert r.is_validated is True + + +def test_validation_response_not_validated(): + r = ValidationResponse(status="INVALID_TRANSACTION", data={}) + assert r.is_validated is False + + +def test_customer_info_defaults(): + c = CustomerInfo( + name="N", email="e@x.com", address1="a", + city="c", postcode="p", country="BD", phone="0", + ) + assert c.address2 == "" + + +def test_shipping_info_fields(): + s = ShippingInfo(ship_name="Wh", address="42", city="Ctg", postcode="4000", country="BD") + assert s.ship_name == "Wh" diff --git a/Tests/test_repository.py b/Tests/test_repository.py new file mode 100644 index 0000000..c62e812 --- /dev/null +++ b/Tests/test_repository.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest + +from sslcommerz_python_api.exceptions import SSLCommerzAPIError +from sslcommerz_python_api.models import CustomerInfo, PaymentRequest, ShippingInfo +from sslcommerz_python_api.repository import SSLCommerzRepository + + +@pytest.fixture +def repo(): + return SSLCommerzRepository(store_id="sid", store_pass="pass", is_sandbox=True) + + +@pytest.fixture +def sample_request(): + return PaymentRequest( + store_id="sid", + store_pass="pass", + tran_id="T1", + total_amount=Decimal("50.00"), + currency="BDT", + success_url="https://x.com/s", + fail_url="https://x.com/f", + cancel_url="https://x.com/c", + ) + + +def test_sandbox_url_used(repo, sample_request): + mock_resp = MagicMock() + mock_resp.json.return_value = {"status": "SUCCESS", "sessionkey": "K", "GatewayPageURL": "u"} + with patch("sslcommerz_python_api.repository.requests.post", return_value=mock_resp) as mock_post: + repo.initiate_payment(sample_request) + assert "sandbox.sslcommerz.com" in mock_post.call_args[0][0] + + +def test_live_url_used(sample_request): + live_repo = SSLCommerzRepository(store_id="sid", store_pass="pass", is_sandbox=False) + mock_resp = MagicMock() + mock_resp.json.return_value = {"status": "SUCCESS", "sessionkey": "K", "GatewayPageURL": "u"} + with patch("sslcommerz_python_api.repository.requests.post", return_value=mock_resp) as mock_post: + live_repo.initiate_payment(sample_request) + assert "securepay.sslcommerz.com" in mock_post.call_args[0][0] + + +def test_connection_error_raises_api_error(repo, sample_request): + from requests.exceptions import ConnectionError as ReqConnectionError + with patch("sslcommerz_python_api.repository.requests.post", side_effect=ReqConnectionError("down")): + with pytest.raises(SSLCommerzAPIError): + repo.initiate_payment(sample_request) + + +def test_validate_connection_error_raises_api_error(repo): + from requests.exceptions import ConnectionError as ReqConnectionError + with patch("sslcommerz_python_api.repository.requests.get", side_effect=ReqConnectionError("down")): + with pytest.raises(SSLCommerzAPIError): + repo.validate_transaction("VAL001") + + +def test_build_payload_basic_fields(): + request = PaymentRequest( + store_id="sid", + store_pass="pass", + tran_id="T3", + total_amount=Decimal("20.00"), + currency="BDT", + success_url="s", + fail_url="f", + cancel_url="c", + ) + payload = SSLCommerzRepository._build_payload(request) + assert payload["store_id"] == "sid" + assert payload["total_amount"] == "20.00" + assert payload["tran_id"] == "T3" + assert "cus_name" not in payload + assert "ship_name" not in payload + + +def test_build_payload_with_customer(): + request = PaymentRequest( + store_id="sid", + store_pass="pass", + tran_id="T4", + total_amount=Decimal("20.00"), + currency="BDT", + success_url="s", + fail_url="f", + cancel_url="c", + customer=CustomerInfo( + name="Jane", + email="jane@x.com", + address1="addr", + city="Dhaka", + postcode="1000", + country="BD", + phone="01700000000", + ), + ) + payload = SSLCommerzRepository._build_payload(request) + assert payload["cus_name"] == "Jane" + assert payload["cus_email"] == "jane@x.com" + + +def test_build_payload_with_shipping(): + request = PaymentRequest( + store_id="sid", + store_pass="pass", + tran_id="T5", + total_amount=Decimal("20.00"), + currency="BDT", + success_url="s", + fail_url="f", + cancel_url="c", + shipping=ShippingInfo( + ship_name="Warehouse", + address="42 Ship Rd", + city="Chittagong", + postcode="4000", + country="BD", + ), + ) + payload = SSLCommerzRepository._build_payload(request) + assert payload["ship_name"] == "Warehouse" + assert payload["ship_add1"] == "42 Ship Rd" diff --git a/Tests/test_service.py b/Tests/test_service.py new file mode 100644 index 0000000..66df6e9 --- /dev/null +++ b/Tests/test_service.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from decimal import Decimal +from unittest.mock import MagicMock + +import pytest + +from sslcommerz_python_api.exceptions import SSLCommerzAPIError, SSLCommerzValidationError +from sslcommerz_python_api.models import CustomerInfo, PaymentRequest +from sslcommerz_python_api.service import SSLCommerzService + + +@pytest.fixture +def fake_repo(): + return MagicMock() + + +@pytest.fixture +def service(fake_repo): + return SSLCommerzService(repository=fake_repo, store_pass="testpass") + + +@pytest.fixture +def sample_request(): + return PaymentRequest( + store_id="test_store", + store_pass="testpass", + tran_id="TXN-001", + total_amount=Decimal("100.00"), + currency="BDT", + success_url="https://example.com/success", + fail_url="https://example.com/fail", + cancel_url="https://example.com/cancel", + customer=CustomerInfo( + name="John Doe", + email="john@example.com", + address1="123 Main St", + city="Dhaka", + postcode="1207", + country="Bangladesh", + phone="01711111111", + ), + ) + + +def test_initiate_payment_success(service, fake_repo, sample_request): + fake_repo.initiate_payment.return_value = { + "status": "SUCCESS", + "sessionkey": "SESS123", + "GatewayPageURL": "https://sandbox.sslcommerz.com/pay", + } + response = service.initiate_payment(sample_request) + assert response.is_success + assert response.session_key == "SESS123" + assert response.gateway_url == "https://sandbox.sslcommerz.com/pay" + + +def test_initiate_payment_failed_raises(service, fake_repo, sample_request): + fake_repo.initiate_payment.return_value = { + "status": "FAILED", + "failedreason": "Store Credential Error", + } + with pytest.raises(SSLCommerzAPIError, match="Store Credential Error"): + service.initiate_payment(sample_request) + + +def test_validate_transaction_success(service, fake_repo): + fake_repo.validate_transaction.return_value = { + "status": "VALIDATED", + "val_id": "VAL001", + "amount": "100.00", + } + resp = service.validate_transaction("VAL001") + assert resp.is_validated + assert resp.data["val_id"] == "VAL001" + + +def test_validate_transaction_invalid_raises(service, fake_repo): + fake_repo.validate_transaction.return_value = {"status": "INVALID_TRANSACTION"} + with pytest.raises(SSLCommerzValidationError): + service.validate_transaction("BAD_ID") + + +def test_validate_transaction_empty_val_id_raises(service, fake_repo): + with pytest.raises(SSLCommerzValidationError, match="val_id must not be empty"): + service.validate_transaction("") + fake_repo.validate_transaction.assert_not_called() diff --git a/pyproject.toml b/pyproject.toml index 5178490..2573ba8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,22 +1,35 @@ -[project] -name = "sslcommerz_python_api" -version = "1.0.1" -authors = [{ name = "Rakibul Yeasin", email = "ryeasin03@gmail.com" }] -description = "Implements SSLCOMMERZ payment gateway in python based web apps." -readme = "README.md" -requires-python = ">=3.6" -classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", -] - -dependencies = ["urllib3>=2.3.0", "requests>=2.32.3"] - -[project.urls] -Homepage = "https://github.com/ryeasin03/sslcommerz_python_api" -Issues = "https://github.com/ryeasin03/sslcommerz_python_api/issues" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +[project] +name = "sslcommerz_python_api" +version = "2.0.0" +authors = [{ name = "Rakibul Yeasin", email = "ryeasin03@gmail.com" }] +description = "SSLCommerz payment gateway wrapper for Python web applications." +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Internet :: WWW/HTTP", +] + +dependencies = ["requests>=2.32.3"] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "pytest-mock>=3.14"] + +[project.urls] +Homepage = "https://github.com/dreygur/SSLCommerz-Python" +Issues = "https://github.com/dreygur/SSLCommerz-Python/issues" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 85f2142a4151d28149bf108710c0718dd709c6e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 354 zcmYk2OAdlS5JYQj;!!mGG;TZ$A|eq5`7H48>gygghRjTus#o39uVaMX&23H9meYEx++iIVA3Pmgp$mKE{;$XCC!W$#eL}4?J0GKL{`i@ None: + """Initialize a legacy builder session. + + Args: + sslc_is_sandbox: Use sandbox endpoint if True, live if False. + sslc_store_id: SSLCommerz store ID credential. + sslc_store_pass: SSLCommerz store password credential. + """ + warnings.warn( + "SSLCSession is deprecated. Use SSLCommerzService.create() instead.", + DeprecationWarning, + stacklevel=2, + ) + self._service = SSLCommerzService.create( + store_id=sslc_store_id, + store_pass=sslc_store_pass, + is_sandbox=sslc_is_sandbox, + ) + self._store_id = sslc_store_id + self._store_pass = sslc_store_pass + self._urls: dict = {} + self._product: dict = {} + self._customer: dict | None = None + self._shipping: dict | None = None + self._extras: dict = {} + + def set_urls( + self, + success_url: str, + fail_url: str, + cancel_url: str, + ipn_url: str = "", + ) -> None: + """Set redirect and notification URLs for the payment session. + + Args: + success_url: URL to redirect to on successful payment. + fail_url: URL to redirect to on payment failure. + cancel_url: URL to redirect to on cancellation. + ipn_url: URL for the Instant Payment Notification webhook. + """ + self._urls = dict( + success_url=success_url, + fail_url=fail_url, + cancel_url=cancel_url, + ipn_url=ipn_url, + ) + + def set_product_integration( + self, + total_amount: Decimal, + currency: str, + product_category: str, + product_name: str, + num_of_item: int, + shipping_method: str, + product_profile: str = "None", + ) -> None: + """Set product and transaction details. + + Args: + total_amount: Transaction total as a Decimal. + currency: ISO 4217 currency code (e.g. "BDT"). + product_category: Category of the product. + product_name: Name of the product. + num_of_item: Number of items in the order. + shipping_method: Shipping method indicator ("YES" / "NO"). + product_profile: SSLCommerz product profile identifier. + """ + self._product = dict( + total_amount=total_amount, + currency=currency, + product_category=product_category, + product_name=product_name, + num_of_item=num_of_item, + shipping_method=shipping_method, + product_profile=product_profile, + ) + + def set_customer_info( + self, + name: str, + email: str, + address1: str, + city: str, + postcode: str, + country: str, + phone: str, + address2: str = "", + ) -> None: + """Set customer billing information. + + Args: + name: Customer full name. + email: Customer email address. + address1: Primary billing address line. + city: Billing city. + postcode: Billing postal code. + country: Billing country. + phone: Customer phone number. + address2: Optional secondary address line. + """ + self._customer = dict( + name=name, + email=email, + address1=address1, + city=city, + postcode=postcode, + country=country, + phone=phone, + address2=address2, + ) + + def set_shipping_info( + self, + shipping_to: str, + address: str, + city: str, + postcode: str, + country: str, + ) -> None: + """Set shipping destination information. + + Args: + shipping_to: Name of the shipping recipient. + address: Shipping address line. + city: Shipping city. + postcode: Shipping postal code. + country: Shipping country. + """ + self._shipping = dict( + ship_name=shipping_to, + address=address, + city=city, + postcode=postcode, + country=country, + ) + + def set_additional_values( + self, + value_a: str = "", + value_b: str = "", + value_c: str = "", + value_d: str = "", + ) -> None: + """Set optional custom pass-through values. + + These values are returned unchanged in the SSLCommerz callback. + + Args: + value_a: Custom pass-through value A. + value_b: Custom pass-through value B. + value_c: Custom pass-through value C. + value_d: Custom pass-through value D. + """ + self._extras = dict(value_a=value_a, value_b=value_b, value_c=value_c, value_d=value_d) + + def init_payment(self) -> dict: + """Initiate the payment session and return a v1-compatible response dict. + + Builds a PaymentRequest from accumulated builder state, calls the + service, and returns a plain dict matching the v1 response shape. + + Returns: + Dict with keys: status, sessionkey, GatewayPageURL on success. + Dict with keys: status, failedreason on failure. + """ + request = PaymentRequest( + store_id=self._store_id, + store_pass=self._store_pass, + tran_id=str(uuid4()), + customer=CustomerInfo(**self._customer) if self._customer else None, + shipping=ShippingInfo(**self._shipping) if self._shipping else None, + **self._product, + **self._urls, + **self._extras, + ) + try: + resp = self._service.initiate_payment(request) + return { + "status": resp.status, + "sessionkey": resp.session_key, + "GatewayPageURL": resp.gateway_url, + } + except Exception as exc: + return {"status": "FAILED", "failedreason": str(exc)} diff --git a/sslcommerz_python_api/base.py b/sslcommerz_python_api/base.py deleted file mode 100644 index 7a77e08..0000000 --- a/sslcommerz_python_api/base.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python - -class SSLCommerz: - def __init__(self, - sslc_is_sandbox: bool = True, - sslc_store_id: str = '', - sslc_store_pass: str = '' - ) -> None: - """Creates a session - - Args: - sslc_is_sandbox (bool, optional): Sandbox or live api. Defaults to True. - sslc_store_id (str, optional): Store ID. - sslc_store_pass (str, optional): Store Password. - """ - # Configurations - self.SSLCZ_SESSION_API = 'sslcommerz.com/gwprocess/v4/api.php' - self.SSLCZ_VALIDATION_API = 'sslcommerz.com/validator/api/validationserverAPI.php' - self.sslc_mode_name = self.set_sslcommerz_mode(sslc_is_sandbox) - self.sslc_is_sandbox = sslc_is_sandbox - self.sslc_store_id = sslc_store_id - self.sslc_store_pass = sslc_store_pass - self.sslc_session_api = 'https://' + self.sslc_mode_name + '.' + self.SSLCZ_SESSION_API - self.sslc_validation_api = 'https://' + self.sslc_mode_name + '.' + self.SSLCZ_VALIDATION_API - self.integration_data: Dict[str, str] = {} - - @staticmethod - def set_sslcommerz_mode(sslc_is_sandbox: bool) -> str: - """Set status of the api whether sandbox or live - - Args: - sslc_is_sandbox (bool): True for sandbox api False for the live one - - Returns: - str: 'sandbox' or 'securepay' - """ - if sslc_is_sandbox is True or sslc_is_sandbox == 1: - return 'sandbox' - return 'securepay' \ No newline at end of file diff --git a/sslcommerz_python_api/exceptions.py b/sslcommerz_python_api/exceptions.py new file mode 100644 index 0000000..0073cc1 --- /dev/null +++ b/sslcommerz_python_api/exceptions.py @@ -0,0 +1,51 @@ +from __future__ import annotations + + +class SSLCommerzError(Exception): + """Base exception for all SSLCommerz library errors. + + Catch this to handle any error raised by the library without + distinguishing between API failures and validation failures. + """ + + +class SSLCommerzAPIError(SSLCommerzError): + """Raised when an HTTP request to SSLCommerz fails or the API returns an error. + + Covers both network-level failures (connection error, timeout) and + API-level failures (HTTP 200 but status == "FAILED"). + + Attributes: + status_code: HTTP status code, if available. + reason: The failedreason string returned by SSLCommerz, if available. + """ + + def __init__(self, message: str, status_code: int | None = None, reason: str = "") -> None: + """Initialize with a message and optional HTTP status code and reason. + + Args: + message: Human-readable error description. + status_code: HTTP status code from the failed response, if any. + reason: The failedreason field from the SSLCommerz response, if any. + """ + super().__init__(message) + self.status_code = status_code + self.reason = reason + + +class SSLCommerzValidationError(SSLCommerzError): + """Raised when IPN signature verification or transaction validation fails. + + Attributes: + val_id: The transaction validation ID involved, if applicable. + """ + + def __init__(self, message: str, val_id: str = "") -> None: + """Initialize with a message and optional validation ID. + + Args: + message: Human-readable error description. + val_id: The val_id that failed validation, if applicable. + """ + super().__init__(message) + self.val_id = val_id diff --git a/sslcommerz_python_api/models.py b/sslcommerz_python_api/models.py new file mode 100644 index 0000000..f7618f4 --- /dev/null +++ b/sslcommerz_python_api/models.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + + +@dataclass +class CustomerInfo: + """Customer billing information for a payment request. + + Attributes: + name: Full name of the customer. + email: Email address of the customer. + address1: Primary billing address line. + city: Billing city. + postcode: Billing postal code. + country: Billing country. + phone: Customer phone number. + address2: Optional secondary address line. + """ + + name: str + email: str + address1: str + city: str + postcode: str + country: str + phone: str + address2: str = "" + + +@dataclass +class ShippingInfo: + """Shipping destination information for a payment request. + + Attributes: + ship_name: Name of the shipping recipient. + address: Shipping address line. + city: Shipping city. + postcode: Shipping postal code. + country: Shipping country. + """ + + ship_name: str + address: str + city: str + postcode: str + country: str + + +@dataclass +class PaymentRequest: + """All data required to initiate an SSLCommerz payment session. + + Required fields must be supplied at construction. Optional fields + default to empty strings or sensible defaults. + + Raises: + ValueError: If store_id or store_pass are empty, or total_amount <= 0. + + Attributes: + store_id: SSLCommerz store ID credential. + store_pass: SSLCommerz store password credential. + total_amount: Transaction amount as a Decimal. + currency: ISO 4217 currency code (e.g. "BDT", "USD"). + tran_id: Unique transaction ID generated by the caller. + success_url: URL SSLCommerz redirects to on payment success. + fail_url: URL SSLCommerz redirects to on payment failure. + cancel_url: URL SSLCommerz redirects to on cancellation. + ipn_url: URL for Instant Payment Notification webhook. + product_name: Name of the product being purchased. + product_category: Category of the product. + product_profile: SSLCommerz product profile identifier. + num_of_item: Number of items in the order. + shipping_method: Shipping method indicator ("YES" / "NO"). + customer: Optional customer billing details. + shipping: Optional shipping destination details. + value_a: Optional custom pass-through value A. + value_b: Optional custom pass-through value B. + value_c: Optional custom pass-through value C. + value_d: Optional custom pass-through value D. + """ + + store_id: str + store_pass: str + total_amount: Decimal + currency: str + tran_id: str + success_url: str + fail_url: str + cancel_url: str + ipn_url: str = "" + product_name: str = "" + product_category: str = "" + product_profile: str = "None" + num_of_item: int = 1 + shipping_method: str = "NO" + customer: CustomerInfo | None = None + shipping: ShippingInfo | None = None + value_a: str = "" + value_b: str = "" + value_c: str = "" + value_d: str = "" + + def __post_init__(self) -> None: + """Validate required fields after dataclass initialization.""" + if not self.store_id: + raise ValueError("store_id is required") + if not self.store_pass: + raise ValueError("store_pass is required") + if self.total_amount <= 0: + raise ValueError(f"total_amount must be positive, got {self.total_amount}") + + +@dataclass(frozen=True) +class PaymentResponse: + """Result of a successful payment session initiation. + + Attributes: + status: Raw status string from SSLCommerz (e.g. "SUCCESS"). + session_key: SSLCommerz session key for the initiated payment. + gateway_url: URL to redirect the user to complete payment. + failed_reason: Failure reason if status is not SUCCESS. + is_success: True when status == "SUCCESS". + """ + + status: str + session_key: str = "" + gateway_url: str = "" + failed_reason: str = "" + + @property + def is_success(self) -> bool: + """Return True if the payment session was successfully created.""" + return self.status == "SUCCESS" + + +@dataclass(frozen=True) +class ValidationResponse: + """Result of a transaction validation request. + + Attributes: + status: Validation status string from SSLCommerz (e.g. "VALIDATED"). + data: Full raw response dict from SSLCommerz for callers needing extra fields. + is_validated: True when status == "VALIDATED". + """ + + status: str + data: dict[str, Any] + + @property + def is_validated(self) -> bool: + """Return True if the transaction was successfully validated.""" + return self.status == "VALIDATED" diff --git a/sslcommerz_python_api/payment.py b/sslcommerz_python_api/payment.py deleted file mode 100644 index 1188644..0000000 --- a/sslcommerz_python_api/payment.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python - -from typing import Dict -from decimal import Decimal -from uuid import uuid4 -import requests -import json - -# Internal Import -from sslcommerz_python_api.base import SSLCommerz - -class SSLCSession(SSLCommerz): - def __init__(self, - sslc_is_sandbox: bool = True, - sslc_store_id: str = '', - sslc_store_pass: str = '' - ) -> None: - """[summary] - - Args: - sslc_is_sandbox (bool, optional): Defines to use sandbox api or not. Defaults to True. - sslc_store_id (str, optional): Store ID from SSLCommerz. Defaults to ''. - sslc_store_pass (str, optional): Store Password for SSLCommerz store. Defaults to ''. - """ - super().__init__(sslc_is_sandbox, sslc_store_id, sslc_store_pass) - - def set_urls(self, - success_url: str, - fail_url: str, - cancel_url: str, - ipn_url: str = '' - ) -> None: - """Sets urls for IPN - - Args: - success_url (str): Success URL - fail_url (str): Fail URL - cancel_url (str): Cancel URL - ipn_url (str, optional): IPN URL. Defaults to ''. - """ - self.integration_data.update({ - 'success_url': success_url, - 'fail_url': fail_url, - 'cancel_url': cancel_url, - 'ipn_url': ipn_url, - }) - - def set_product_integration(self, - total_amount: Decimal, - currency: str, - product_category: str, - product_name: str, - num_of_item: int, - shipping_method: str, - product_profile: str='None' - ) -> None: - """Set Product Integtration - - Args: - total_amount (Decimal): Total Amount - currency (str): Currency - product_category (str): Peoduct's Category - product_name (str): Product's Name - num_of_item (int): Number of items - shipping_method (str): Shipping Method - product_profile (str, optional): Product's Description. Defaults to 'None'. - """ - self.integration_data.update({ - 'store_id': self.sslc_store_id, - 'store_passwd': self.sslc_store_pass, - 'tran_id': str(uuid4()), - 'total_amount': total_amount, - 'currency': currency, - 'product_category': product_category, - 'product_name': product_name, - 'num_of_item': num_of_item, - 'shipping_method': shipping_method, - 'product_profile': product_profile, - }) - - def set_customer_info(self, - name: str, - email: str, - address1: str, - city: str, - postcode: str, - country: str, - phone: str, - address2: str='' - ) -> None: - """[summary] - - Args: - name (str): Customer's Name - email (str): Customer's E-mail - address1 (str): Address - city (str): City - postcode (str): Postcode - country (str): Country - phone (str): Phone/Mobile Number - address2 (str, optional): Optional Address. Defaults to ''. - """ - self.integration_data.update({ - 'cus_name': name, - 'cus_email': email, - 'cus_add1': address1, - 'cus_add2': address2, - 'cus_city': city, - 'cus_postcode': postcode, - 'cus_country': country, - 'cus_phone': phone, - }) - - def set_shipping_info(self, - shipping_to: str, - address: str, - city: str, - postcode: str, - country: str - ) -> None: - """Shipping Address - - Args: - shipping_to (str): Customer's Name - address (str): Address - city (str): City - postcode (str): Postcode - country (str): Country - """ - self.integration_data.update({ - 'ship_name': shipping_to, - 'ship_add1': address, - 'ship_city': city, - 'ship_postcode': postcode, - 'ship_country': country, - }) - - def set_additional_values(self, - value_a: str = '', - value_b: str = '', - value_c: str = '', - value_d: str = '' - ) -> None: - """Additional Values - - Args: - value_a (str, optional): Additional Value. Defaults to ''. - value_b (str, optional): Additional Value. Defaults to ''. - value_c (str, optional): Additional Value. Defaults to ''. - value_d (str, optional): Additional Value. Defaults to ''. - """ - self.integration_data.update({ - 'value_a': value_a, - 'value_b': value_b, - 'value_c': value_c, - 'value_d': value_d, - }) - - def init_payment(self) -> Dict: - """Initialize the Payment - - Returns: - Dict: Response From SSLCommerz API - """ - post_url: str = self.sslc_session_api - post_data: Dict = self.integration_data - response_sslc = requests.post(post_url, post_data) - response_data: Dict[str, str] = {} - - if response_sslc.status_code == 200: - response_json = json.loads(response_sslc.text) - if response_json['status'] == 'FAILED': - response_data.update({ - 'status': response_json['status'], - 'failedreason': response_json['failedreason'], - }) - return response_data - - response_data.update({ - 'status': response_json['status'], - 'sessionkey': response_json['sessionkey'], - 'GatewayPageURL': response_json['GatewayPageURL'], - }) - - return response_data - - response_json = json.loads(response_sslc.text) - response_data.update({ - 'status': response_json['status'], - 'failedreason': response_json['failedreason'], - }) - - return response_data \ No newline at end of file diff --git a/sslcommerz_python_api/repository.py b/sslcommerz_python_api/repository.py new file mode 100644 index 0000000..6b2cb94 --- /dev/null +++ b/sslcommerz_python_api/repository.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import Any + +import requests +from requests.exceptions import RequestException + +from .exceptions import SSLCommerzAPIError +from .models import PaymentRequest + +logger = logging.getLogger(__name__) + +_SESSION_PATH = "sslcommerz.com/gwprocess/v4/api.php" +_VALIDATION_PATH = "sslcommerz.com/validator/api/validationserverAPI.php" + + +class AbstractSSLCommerzRepository(ABC): + """Abstract interface for SSLCommerz HTTP operations. + + Inject a subclass into SSLCommerzService to swap out the real HTTP + implementation for a fake in tests. + """ + + @abstractmethod + def initiate_payment(self, request: PaymentRequest) -> dict[str, Any]: + """POST a payment initiation request to SSLCommerz. + + Args: + request: Fully populated PaymentRequest dataclass. + + Returns: + Raw JSON response dict from the SSLCommerz API. + + Raises: + SSLCommerzAPIError: On any HTTP or network failure. + """ + ... + + @abstractmethod + def validate_transaction(self, val_id: str) -> dict[str, Any]: + """GET transaction validation status from SSLCommerz. + + Args: + val_id: Validation ID returned by SSLCommerz in the IPN or redirect. + + Returns: + Raw JSON response dict from the SSLCommerz validation API. + + Raises: + SSLCommerzAPIError: On any HTTP or network failure. + """ + ... + + +class SSLCommerzRepository(AbstractSSLCommerzRepository): + """Concrete HTTP implementation of AbstractSSLCommerzRepository. + + Uses the requests library to communicate with the SSLCommerz REST API. + Supports sandbox and live environments. + + Attributes: + _store_id: SSLCommerz store ID credential. + _store_pass: SSLCommerz store password credential. + _timeout: HTTP request timeout in seconds. + _session_url: Full URL for payment session initiation. + _validation_url: Full URL for transaction validation. + """ + + def __init__( + self, + store_id: str, + store_pass: str, + is_sandbox: bool = True, + timeout: int = 30, + ) -> None: + """Initialize the repository with store credentials and environment settings. + + Args: + store_id: SSLCommerz store ID. + store_pass: SSLCommerz store password. + is_sandbox: Use sandbox endpoint if True, live endpoint if False. + timeout: HTTP request timeout in seconds. Defaults to 30. + """ + self._store_id = store_id + self._store_pass = store_pass + self._timeout = timeout + mode = "sandbox" if is_sandbox else "securepay" + self._session_url = f"https://{mode}.{_SESSION_PATH}" + self._validation_url = f"https://{mode}.{_VALIDATION_PATH}" + + def initiate_payment(self, request: PaymentRequest) -> dict[str, Any]: + """POST payment data to SSLCommerz and return the raw response dict. + + Args: + request: Fully populated PaymentRequest dataclass. + + Returns: + Raw JSON response dict (contains status, sessionkey, GatewayPageURL, etc.). + + Raises: + SSLCommerzAPIError: On connection error, timeout, or non-2xx HTTP response. + """ + payload = self._build_payload(request) + logger.debug("POST %s tran_id=%s", self._session_url, request.tran_id) + try: + response = requests.post(self._session_url, data=payload, timeout=self._timeout) + response.raise_for_status() + except RequestException as exc: + raise SSLCommerzAPIError( + f"SSLCommerz HTTP request failed: {exc}", + status_code=getattr(getattr(exc, "response", None), "status_code", None), + ) from exc + data: dict[str, Any] = response.json() + logger.debug("SSLCommerz session response status=%s", data.get("status")) + return data + + def validate_transaction(self, val_id: str) -> dict[str, Any]: + """GET transaction validation data from SSLCommerz. + + Args: + val_id: The validation ID to look up. + + Returns: + Raw JSON response dict (contains status and transaction details). + + Raises: + SSLCommerzAPIError: On connection error, timeout, or non-2xx HTTP response. + """ + params = { + "val_id": val_id, + "store_id": self._store_id, + "store_passwd": self._store_pass, + "format": "json", + } + logger.debug("GET %s val_id=%s", self._validation_url, val_id) + try: + response = requests.get(self._validation_url, params=params, timeout=self._timeout) + response.raise_for_status() + except RequestException as exc: + raise SSLCommerzAPIError( + f"SSLCommerz validation HTTP request failed: {exc}", + status_code=getattr(getattr(exc, "response", None), "status_code", None), + ) from exc + data: dict[str, Any] = response.json() + logger.debug("SSLCommerz validation response status=%s", data.get("status")) + return data + + @staticmethod + def _build_payload(request: PaymentRequest) -> dict[str, Any]: + """Map a PaymentRequest dataclass to the SSLCommerz API field names. + + Args: + request: The payment request to serialize. + + Returns: + Dict of form-encoded fields ready to POST to SSLCommerz. + """ + payload: dict[str, Any] = { + "store_id": request.store_id, + "store_passwd": request.store_pass, + "tran_id": request.tran_id, + "total_amount": str(request.total_amount), + "currency": request.currency, + "success_url": request.success_url, + "fail_url": request.fail_url, + "cancel_url": request.cancel_url, + "ipn_url": request.ipn_url, + "product_name": request.product_name, + "product_category": request.product_category, + "product_profile": request.product_profile, + "num_of_item": request.num_of_item, + "shipping_method": request.shipping_method, + "value_a": request.value_a, + "value_b": request.value_b, + "value_c": request.value_c, + "value_d": request.value_d, + } + if request.customer: + payload.update({ + "cus_name": request.customer.name, + "cus_email": request.customer.email, + "cus_add1": request.customer.address1, + "cus_add2": request.customer.address2, + "cus_city": request.customer.city, + "cus_postcode": request.customer.postcode, + "cus_country": request.customer.country, + "cus_phone": request.customer.phone, + }) + if request.shipping: + payload.update({ + "ship_name": request.shipping.ship_name, + "ship_add1": request.shipping.address, + "ship_city": request.shipping.city, + "ship_postcode": request.shipping.postcode, + "ship_country": request.shipping.country, + }) + return payload diff --git a/sslcommerz_python_api/service.py b/sslcommerz_python_api/service.py new file mode 100644 index 0000000..738d5ac --- /dev/null +++ b/sslcommerz_python_api/service.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import hashlib +import logging + +from .exceptions import SSLCommerzAPIError, SSLCommerzValidationError +from .models import PaymentRequest, PaymentResponse, ValidationResponse +from .repository import AbstractSSLCommerzRepository, SSLCommerzRepository + +logger = logging.getLogger(__name__) + + +class SSLCommerzService: + """Primary interface for SSLCommerz payment and validation operations. + + Orchestrates repository calls, interprets API responses, and raises + typed domain exceptions on failure. Accepts a repository instance so + the HTTP layer can be swapped out in tests. + + Use SSLCommerzService.create() for normal instantiation. Inject a + custom AbstractSSLCommerzRepository via __init__ for testing. + """ + + def __init__( + self, + repository: AbstractSSLCommerzRepository, + store_pass: str = "", + ) -> None: + """Initialize with a repository and store password. + + Args: + repository: Repository implementation handling HTTP calls. + store_pass: Store password used for IPN signature verification. + """ + self._repo = repository + self._store_pass = store_pass + + @classmethod + def create( + cls, + store_id: str, + store_pass: str, + is_sandbox: bool = True, + timeout: int = 30, + ) -> SSLCommerzService: + """Factory method for normal library usage. + + Constructs an SSLCommerzRepository internally — no need to + instantiate it manually. + + Args: + store_id: SSLCommerz store ID credential. + store_pass: SSLCommerz store password credential. + is_sandbox: Use sandbox endpoint if True, live endpoint if False. + timeout: HTTP request timeout in seconds. Defaults to 30. + + Returns: + A fully configured SSLCommerzService instance. + """ + repo = SSLCommerzRepository( + store_id=store_id, + store_pass=store_pass, + is_sandbox=is_sandbox, + timeout=timeout, + ) + return cls(repo, store_pass=store_pass) + + def initiate_payment(self, request: PaymentRequest) -> PaymentResponse: + """Initiate a payment session with SSLCommerz. + + Args: + request: A fully constructed PaymentRequest dataclass. + + Returns: + PaymentResponse with session_key and gateway_url on success. + + Raises: + SSLCommerzAPIError: If the HTTP request fails or SSLCommerz + returns status == "FAILED". + """ + raw = self._repo.initiate_payment(request) + if raw.get("status") == "FAILED": + reason = raw.get("failedreason", "Unknown error") + logger.warning("SSLCommerz payment initiation failed: %s", reason) + raise SSLCommerzAPIError( + f"SSLCommerz payment initiation failed: {reason}", + reason=reason, + ) + return PaymentResponse( + status=raw["status"], + session_key=raw.get("sessionkey", ""), + gateway_url=raw.get("GatewayPageURL", ""), + ) + + def validate_transaction(self, val_id: str) -> ValidationResponse: + """Validate a completed transaction by its validation ID. + + Args: + val_id: The val_id returned by SSLCommerz in the IPN or redirect. + + Returns: + ValidationResponse with status and full raw data dict. + + Raises: + SSLCommerzValidationError: If val_id is empty or the transaction + status is not "VALIDATED". + SSLCommerzAPIError: If the HTTP request fails. + """ + if not val_id: + raise SSLCommerzValidationError("val_id must not be empty") + raw = self._repo.validate_transaction(val_id) + status = raw.get("status", "UNKNOWN") + if status != "VALIDATED": + logger.warning("Transaction %s not validated, status=%s", val_id, status) + raise SSLCommerzValidationError( + f"Transaction validation failed with status: {status}", + val_id=val_id, + ) + return ValidationResponse(status=status, data=raw) + + def verify_ipn(self, ipn_data: dict) -> bool: + """Verify the IPN (Instant Payment Notification) signature locally. + + Recomputes the MD5 signature from the received IPN fields and + compares it against the verify_sign field. No network call is made. + + Args: + ipn_data: The POST body dict received from the SSLCommerz webhook. + + Returns: + True if the signature matches. + + Raises: + SSLCommerzValidationError: If verify_key or verify_sign are missing, + or if the computed signature does not match verify_sign. + """ + if "verify_key" not in ipn_data or "verify_sign" not in ipn_data: + raise SSLCommerzValidationError( + "IPN data missing required fields: verify_key and/or verify_sign" + ) + store_pass_hash = hashlib.md5(self._store_pass.encode()).hexdigest() + check_params: dict[str, str] = { + key: ipn_data[key] + for key in ipn_data["verify_key"].split(",") + } + check_params["store_passwd"] = store_pass_hash + sign_string = "&".join(f"{k}={v}" for k, v in sorted(check_params.items())) + computed_hash = hashlib.md5(sign_string.encode()).hexdigest() + if computed_hash != ipn_data["verify_sign"]: + raise SSLCommerzValidationError("IPN signature mismatch — possible tampering") + return True diff --git a/sslcommerz_python_api/validation.py b/sslcommerz_python_api/validation.py deleted file mode 100644 index 2739035..0000000 --- a/sslcommerz_python_api/validation.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python - -import hashlib -import requests -from typing import Dict - -# Internal Import -from sslcommerz_python_api.base import SSLCommerz - -class Validation(SSLCommerz): - def __init__(self, sslc_is_sandbox=True, sslc_store_id='', sslc_store_pass='') -> None: - super().__init__(sslc_is_sandbox, sslc_store_id, sslc_store_pass) - - def validate_transaction(self, validation_id): - """Validate the Transaction with validation_id from SSLCommerz - - Args: - validation_id (str): Validation ID from SSLCommerz - - Returns: - dict: Validation Status - """ - query_params: Dict[str, str] = {} - response_data: Dict[str, str] = {} - query_params['val_id'] = validation_id - query_params['store_id'] = self.sslc_store_id - query_params['store_passwd'] = self.sslc_store_pass - query_params['format'] = 'json' - - validation_response = requests.get( - self.sslc_validation_api, - params=query_params - ) - - if validation_response.status_code == 200: - validation_json = validation_response.json() - if validation_json['status'] == 'VALIDATED': - response_data['status'] = 'VALIDATED' - response_data['data'] = validation_json - else: - response_data['status'] = validation_json['status'] - response_data['data'] = validation_json - else: - response_data['status'] = 'FAILED' - response_data['data'] = 'Validation failed due to status code ' + str(validation_response.status_code) - return response_data - - def validate_ipn_hash(self, ipn_data): - if self.key_check(ipn_data, 'verify_key') and self.key_check(ipn_data, 'verify_sign'): - check_params: Dict[str, str] = {} - verify_key = ipn_data['verify_key'].split(',') - - for key in verify_key: - check_params[key] = ipn_data[key] - - store_pass = self.sslc_store_pass.encode() - store_pass_hash = hashlib.md5(store_pass).hexdigest() - check_params['store_passwd'] = store_pass_hash - check_params = self.sort_keys(check_params) - - sign_string = '' - for key in check_params: - sign_string += key[0] + '=' + str(key[1]) + '&' - - sign_string = sign_string.strip('&') - sign_string_hash = hashlib.md5(sign_string.encode()).hexdigest() - - if sign_string_hash == ipn_data['verify_sign']: - return True - return False - - @staticmethod - def key_check(data_dict, check_key): - if check_key in data_dict.keys(): - return True - return False - - @staticmethod - def sort_keys(data_dict): - return [(key, data_dict[key]) for key in sorted(data_dict.keys())] From 9cde53c6e0ba97bd385bc1e071af3c94f3653399 Mon Sep 17 00:00:00 2001 From: Rakibul Yeasin Date: Sun, 26 Apr 2026 12:07:46 +0600 Subject: [PATCH 2/2] fix: rename Tests/ to tests/ for case-sensitive filesystems --- tests/__init__.py | 0 {Tests => tests}/test_compat.py | 0 {Tests => tests}/test_ipn.py | 0 {Tests => tests}/test_models.py | 0 {Tests => tests}/test_repository.py | 0 {Tests => tests}/test_service.py | 0 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/__init__.py rename {Tests => tests}/test_compat.py (100%) rename {Tests => tests}/test_ipn.py (100%) rename {Tests => tests}/test_models.py (100%) rename {Tests => tests}/test_repository.py (100%) rename {Tests => tests}/test_service.py (100%) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Tests/test_compat.py b/tests/test_compat.py similarity index 100% rename from Tests/test_compat.py rename to tests/test_compat.py diff --git a/Tests/test_ipn.py b/tests/test_ipn.py similarity index 100% rename from Tests/test_ipn.py rename to tests/test_ipn.py diff --git a/Tests/test_models.py b/tests/test_models.py similarity index 100% rename from Tests/test_models.py rename to tests/test_models.py diff --git a/Tests/test_repository.py b/tests/test_repository.py similarity index 100% rename from Tests/test_repository.py rename to tests/test_repository.py diff --git a/Tests/test_service.py b/tests/test_service.py similarity index 100% rename from Tests/test_service.py rename to tests/test_service.py