Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PayuProvider.refund #4

Merged
merged 5 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ jobs:
tests:
# The type of runner that the job will run on
runs-on: ubuntu-latest

strategy:
matrix:
DJANGO_VERSION: [ '2.2.*', '3.0.*', '3.1.*', '3.2.*', '4.0.*', '4.1.*']
python-version: ['3.7', '3.8', '3.9', '3.10']
exclude:
- DJANGO_VERSION: '4.1.*'
python-version: '3.7'
python-version: '3.7'
- DJANGO_VERSION: '4.0.*'
python-version: '3.7'
python-version: '3.7'
- DJANGO_VERSION: '3.1.*'
python-version: '3.10'
- DJANGO_VERSION: '3.0.*'
Expand All @@ -52,7 +52,7 @@ jobs:

steps:
- uses: actions/checkout@v2

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
Expand Down Expand Up @@ -92,7 +92,7 @@ jobs:

- name: Install
run: |
pip install flake8 isort black mypy django-stubs dj_database_url types-six types-requests types-mock
pip install setuptools flake8 isort black mypy django-stubs dj_database_url types-six types-requests types-mock
python setup.py develop
pip install -e .
pip install -r requirements.txt
Expand Down
2 changes: 1 addition & 1 deletion AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ Development Lead
Contributors
------------

None yet. Why not be the first?
* Radek Holý
6 changes: 6 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
History
-------

Unreleased
++++++++++
* add PayuProvider.refund
* update payment.captured_amount only when order is completed
* subtract refunds from payment.captured_amount rather than from payment.total

1.2.3 (2022-01-25)
++++++++++++++++++
* better distinct PayU API errors
Expand Down
26 changes: 15 additions & 11 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Quickstart

Install `django-payments <https://github.com/mirumee/django-payments>`_ and set up PayU payment provider backend according to `django-payments documentation <https://django-payments.readthedocs.io/en/latest/modules.html>`_:

.. class:: payments_payu.provider.PayuProvider(client_secret, second_key, pos_id, [sandbox=False, endpoint="https://secure.payu.com/", recurring_payments=False, express_payments=False, widget_branding=False])
.. class:: payments_payu.provider.PayuProvider(client_secret, second_key, pos_id, get_refund_description, [sandbox=False, endpoint="https://secure.payu.com/", recurring_payments=False, express_payments=False, widget_branding=False, get_refund_ext_id=_DEFAULT_GET_REFUND_EXT_ID])

This backend implements payments using `PayU.com <https://payu.com>`_.

Expand All @@ -42,20 +42,24 @@ Example::
'client_secret': 'peopleiseedead',
'sandbox': True,
'capture': False,
'get_refund_description': lambda payment, amount: 'My refund',
'get_refund_ext_id': lambda payment, amount: str(uuid.uuid4()),
}),
}

Here are valid parameters for the provider:
:client_secret: PayU OAuth protocol client secret
:pos_id: PayU POS ID
:second_key: PayU second key (MD5)
:shop_name: Name of the shop send to the API
:sandbox: if ``True``, set the endpoint to sandbox
:endpoint: endpoint URL, if not set, the will be automatically set based on `sandbox` settings
:recurring_payments: enable recurring payments, only valid with ``express_payments=True``, see bellow for additional setup, that is needed
:express_payments: use PayU express form
:widget_branding: tell express form to show PayU branding
:store_card: (default: False) whether PayU should store the card
:client_secret: PayU OAuth protocol client secret
:pos_id: PayU POS ID
:second_key: PayU second key (MD5)
:shop_name: Name of the shop send to the API
:sandbox: if ``True``, set the endpoint to sandbox
:endpoint: endpoint URL, if not set, the will be automatically set based on `sandbox` settings
:recurring_payments: enable recurring payments, only valid with ``express_payments=True``, see bellow for additional setup, that is needed
:express_payments: use PayU express form
:widget_branding: tell express form to show PayU branding
:store_card: (default: False) whether PayU should store the card
:get_refund_description: A mandatory callable that is called with two keyword arguments `payment` and `amount` in order to get the string description of the particular refund whenever ``provider.refund(payment, amount)`` is called.
:get_refund_ext_id: An optional callable that is called with two keyword arguments `payment` and `amount` in order to get the External string refund ID of the particular refund whenever ``provider.refund(payment, amount)`` is called. If ``None`` is returned, no External refund ID is set. An External refund ID is not necessary if partial refunds won't be performed more than once per second. Otherwise, a unique ID is recommended since `PayuProvider.refund` is idempotent and if exactly same data will be provided, it will return the result of the already previously performed refund instead of performing a new refund. Defaults to a random UUID version 4 in the standard form.


NOTE: notifications about the payment status from PayU are requested to be sent to `django-payments` `process_payment` url. The request from PayU can fail for several reasons (i.e. it can be blocked by proxy). Use "Show reports" page in PayU administration to get more information about the requests.
Expand Down
122 changes: 108 additions & 14 deletions payments_payu/provider.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import hashlib
import json
import logging
import uuid
from decimal import ROUND_HALF_UP, Decimal
from urllib.parse import urljoin

Expand Down Expand Up @@ -162,9 +163,11 @@
self.payu_sandbox = kwargs.pop("sandbox", False)
self.payu_base_url = kwargs.pop(
"base_payu_url",
"https://secure.snd.payu.com/"
if self.payu_sandbox
else "https://secure.payu.com/",
(
"https://secure.snd.payu.com/"
if self.payu_sandbox
else "https://secure.payu.com/"
),
)
self.payu_auth_url = kwargs.pop(
"auth_url", urljoin(self.payu_base_url, "/pl/standard/user/oauth/authorize")
Expand All @@ -175,13 +178,17 @@
self.payu_token_url = kwargs.pop(
"token_url", urljoin(self.payu_api_url, "tokens/")
)
self.payu_api_order_url = urljoin(self.payu_api_url, "orders/")
self.payu_api_orders_url = urljoin(self.payu_api_url, "orders/")
self.payu_api_paymethods_url = urljoin(self.payu_api_url, "paymethods/")
self.payu_widget_branding = kwargs.pop("widget_branding", False)
self.payu_store_card = kwargs.pop("store_card", False)
self.payu_shop_name = kwargs.pop("shop_name", "")
self.grant_type = kwargs.pop("grant_type", "client_credentials")
self.recurring_payments = kwargs.pop("recurring_payments", False)
self.get_refund_description = kwargs.pop("get_refund_description")
self.get_refund_ext_id = kwargs.pop(
"get_refund_ext_id", lambda payment, amount: str(uuid.uuid4())
)

# Use card on file paremeter instead of recurring.
# PayU asks CVV2 every time with this setting which can be used for testing purposes.
Expand All @@ -196,6 +203,9 @@
)
super(PayuProvider, self).__init__(*args, **kwargs)

def _get_payu_api_order_url(self, order_id):
return urljoin(self.payu_api_orders_url, order_id)

def get_sig(self, payu_data):
string = "".join(
str(payu_data[key]) for key in sig_sorted_key_list if key in payu_data
Expand Down Expand Up @@ -401,7 +411,7 @@
payment_processor.pos_id = self.pos_id
json_data = payment_processor.as_json()
response_dict = self.post_request(
self.payu_api_order_url,
self.payu_api_orders_url,
data=json.dumps(json_data),
allow_redirects=False,
)
Expand Down Expand Up @@ -485,10 +495,7 @@
def reject_order(self, payment):
"Reject order"

url = urljoin(
self.payu_api_order_url,
payment.transaction_id,
)
url = self._get_payu_api_order_url(payment.transaction_id)

try:
# If the payment have status WAITING_FOR_CONFIRMATION, it is needed to make two calls of DELETE
Expand Down Expand Up @@ -547,29 +554,32 @@
print(refunded_price, payment.total)
if data["refund"]["status"] == "FINALIZED":
payment.message += data["refund"]["reasonDescription"]
if refunded_price == payment.total:
if refunded_price == payment.captured_amount:
payment.change_status(PaymentStatus.REFUNDED)
else:
payment.total -= refunded_price
payment.captured_amount -= refunded_price
payment.save()
Comment on lines -550 to 561
Copy link
Contributor Author

@radekholy24 radekholy24 Mar 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return HttpResponse("ok", status=200)
else:
raise Exception("Refund was not finelized", data)
else:
status = data["order"]["status"]
status_map = {
"COMPLETED": PaymentStatus.CONFIRMED,
"PENDING": PaymentStatus.INPUT,
"WAITING_FOR_CONFIRMATION": PaymentStatus.INPUT,
"CANCELED": PaymentStatus.REJECTED,
"NEW": PaymentStatus.WAITING,
}
if PaymentStatus.CONFIRMED and "totalAmount" in data["order"]:
status = status_map[data["order"]["status"]]
if (
status == PaymentStatus.CONFIRMED
and "totalAmount" in data["order"]
):
payment.captured_amount = dequantize_price(
data["order"]["totalAmount"],
data["order"]["currencyCode"],
)
payment.change_status(status_map[status])
payment.change_status(status)
Comment on lines -559 to +582
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that that if PaymentStatus.CONFIRMED was just a bug?

return HttpResponse("ok", status=200)
return HttpResponse("not ok", status=500)

Expand All @@ -593,6 +603,90 @@
"request not recognized by django-payments-payu provider", status=500
)

def refund(self, payment, amount=None):
request_url = self._get_payu_api_order_url(payment.transaction_id) + "/refunds"

request_data = {
"refund": {
"currencyCode": payment.currency,
"description": self.get_refund_description(
payment=payment, amount=amount
),
}
}
if amount is not None:
request_data.setdefault("refund", {}).setdefault(
"amount", quantize_price(amount, payment.currency)
)
ext_refund_id = self.get_refund_ext_id(payment=payment, amount=amount)
if ext_refund_id is not None:
request_data.setdefault("refund", {}).setdefault(
"extRefundId", ext_refund_id
)

response = self.post_request(request_url, data=json.dumps(request_data))

payment_extra_data = json.loads(payment.extra_data or "{}")
payment_extra_data_refund_responses = payment_extra_data.setdefault(
"refund_responses", []
)
payment_extra_data_refund_responses.append(response)
payment.extra_data = json.dumps(payment_extra_data, indent=2)
payment.save()

try:
refund = response["refund"]
refund_id = refund["refundId"]
except Exception:
refund_id = None

try:
response_status = dict(response["status"])
response_status_code = response_status["statusCode"]
except Exception:
raise ValueError(

Check warning on line 647 in payments_payu/provider.py

View check run for this annotation

Codecov / codecov/patch

payments_payu/provider.py#L646-L647

Added lines #L646 - L647 were not covered by tests
f"invalid response to refund {refund_id or '???'} of payment {payment.id}: {response}"
)
if response_status_code != "SUCCESS":
raise ValueError(
f"refund {refund_id or '???'} of payment {payment.id} failed: "
f"code={response_status.get('code', '???')}, "
f"statusCode={response_status_code}, "
f"codeLiteral={response_status.get('codeLiteral', '???')}, "
f"statusDesc={response_status.get('statusDesc', '???')}"
)
if refund_id is None:
raise ValueError(

Check warning on line 659 in payments_payu/provider.py

View check run for this annotation

Codecov / codecov/patch

payments_payu/provider.py#L659

Added line #L659 was not covered by tests
f"invalid response to refund of payment {payment.id}: {response}"
)

try:
refund_order_id = response["orderId"]
refund_status = refund["status"]
refund_currency = refund["currencyCode"]
refund_amount = dequantize_price(refund["amount"], refund_currency)
except Exception:
raise ValueError(

Check warning on line 669 in payments_payu/provider.py

View check run for this annotation

Codecov / codecov/patch

payments_payu/provider.py#L668-L669

Added lines #L668 - L669 were not covered by tests
f"invalid response to refund {refund_id} of payment {payment.id}: {response}"
)
if refund_order_id != payment.transaction_id:
raise NotImplementedError(
f"response of refund {refund_id} of payment {payment.id} containing a different order_id "
f"not supported yet: {refund_order_id}"
)
if refund_status == "CANCELED":
raise ValueError(f"refund {refund_id} of payment {payment.id} canceled")
elif refund_status not in {"PENDING", "FINALIZED"}:
raise ValueError(

Check warning on line 680 in payments_payu/provider.py

View check run for this annotation

Codecov / codecov/patch

payments_payu/provider.py#L680

Added line #L680 was not covered by tests
f"invalid status of refund {refund_id} of payment {payment.id}"
)
if refund_currency != payment.currency:
raise NotImplementedError(
f"refund {refund_id} of payment {payment.id} in different currency not supported yet: "
f"{refund_currency}"
)
return refund_amount
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like that we return the amount before the refund actually happens but this is how PayPal refunds work too so I decided to be consistent...



class PaymentProcessor(object):
"Payment processor"
Expand Down
Loading
Loading