Skip to content

Commit

Permalink
Merge pull request #4 from radekholy24/refund
Browse files Browse the repository at this point in the history
Add PayuProvider.refund
  • Loading branch information
PetrDlouhy authored Mar 19, 2024
2 parents c494c81 + d9b8740 commit f6096f1
Show file tree
Hide file tree
Showing 6 changed files with 694 additions and 34 deletions.
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 @@ def __init__(self, *args, **kwargs):
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 @@ def __init__(self, *args, **kwargs):
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 @@ def __init__(self, *args, **kwargs):
)
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 @@ def create_order(self, payment, payment_processor, auto_renew=False):
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 get_paymethod_tokens(self):
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 @@ def process_notification(self, payment, request):
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()
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)
return HttpResponse("ok", status=200)
return HttpResponse("not ok", status=500)

Expand All @@ -593,6 +603,90 @@ def process_data(self, payment, request, *args, **kwargs):
"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(
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(
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(
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(
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


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

0 comments on commit f6096f1

Please sign in to comment.