Skip to content

Commit

Permalink
Use a JSONField for BasePayment.extra_data
Browse files Browse the repository at this point in the history
  • Loading branch information
Hugo Osvaldo Barrera authored and WhyNotHugo committed Oct 19, 2023
1 parent 0874543 commit 6082843
Show file tree
Hide file tree
Showing 17 changed files with 95 additions and 114 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ v3.0.0
------
- **BREAKING**: Dropped support for Django 2.2, 3.0, 3.1 and 4.0.
Supported versions of Django are 3.2 (LTS), 4.1 and 4.2.
- **BREAKING** ``BasePayment.extra_data`` is now a JSONField and django will
handle the serialisation. Due to this, usage of the ``BasePayment.attrs``
proxy has been deprecated. A migration needs to be generated to update this
column in place. Application code needs to be updated from
``payment.extra_data.field`` to ``payment.extra_data["field"]``.
- Stripe backends now sends order_id in the metadata parameter.
- A new ``StripeProviderV3`` has been added using the latest Stripe API.
- Added support for Python 3.11, Django 4.1 and Django 4.2.
Expand Down
2 changes: 1 addition & 1 deletion docs/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ about the payment or the order, such as an order number, additional customer
information, or a special comment or request from the customer. This can be
accomplished by passing your data to the :class:`Payment` instance::

>>> payment.attrs.merchant_defined_data = {'01': 'foo', '02': 'bar'}
>>> payment.extra_data["merchant_defined_data"] = {'01': 'foo', '02': 'bar'}

Fingerprinting::

Expand Down
18 changes: 9 additions & 9 deletions payments/cybersource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,11 @@ def charge(self, payment, data):
else:
params = self._prepare_preauth(payment, data)
response = self._make_request(payment, params)
payment.attrs.capture = self._capture
payment.extra_data["capture"] = self._capture
payment.transaction_id = response.requestID
if response.reasonCode == AUTHENTICATE_REQUIRED:
xid = response.payerAuthEnrollReply.xid
payment.attrs.xid = xid
payment.extra_data["xid"] = xid
payment.change_status(
PaymentStatus.WAITING, message=_("3-D Secure verification in progress")
)
Expand Down Expand Up @@ -276,8 +276,8 @@ def _get_params_for_new_payment(self, payment):
"merchantReferenceCode": payment.id,
}
try:
fingerprint_id = payment.attrs.fingerprint_session_id
except AttributeError:
fingerprint_id = payment.extra_data["fingerprint_session_id"]
except KeyError:

Check warning on line 280 in payments/cybersource/__init__.py

View check run for this annotation

Codecov / codecov/patch

payments/cybersource/__init__.py#L280

Added line #L280 was not covered by tests
pass
else:
params["deviceFingerprintID"] = fingerprint_id
Expand All @@ -288,7 +288,7 @@ def _get_params_for_new_payment(self, payment):

def _make_request(self, payment, params):
response = self.client.service.runTransaction(**params)
payment.attrs.last_response = self._serialize_response(response)
payment.extra_data["last_response"] = self._serialize_response(response)

Check warning on line 291 in payments/cybersource/__init__.py

View check run for this annotation

Codecov / codecov/patch

payments/cybersource/__init__.py#L291

Added line #L291 was not covered by tests
return response

def _prepare_payer_auth_validation_check(self, payment, card_data, pa_response):
Expand All @@ -297,7 +297,7 @@ def _prepare_payer_auth_validation_check(self, payment, card_data, pa_response):
check_service.signedPARes = pa_response
params = self._get_params_for_new_payment(payment)
params["payerAuthValidateService"] = check_service
if payment.attrs.capture:
if payment.extra_data["capture"]:
service = self.client.factory.create("data:CCCreditService")
service._run = "true"
params["ccCreditService"] = service
Expand Down Expand Up @@ -440,8 +440,8 @@ def _prepare_items(self, payment):

def _prepare_merchant_defined_data(self, payment):
try:
merchant_defined_data = payment.attrs.merchant_defined_data
except AttributeError:
merchant_defined_data = payment.extra_data["merchant_defined_data"]
except KeyError:

Check warning on line 444 in payments/cybersource/__init__.py

View check run for this annotation

Codecov / codecov/patch

payments/cybersource/__init__.py#L444

Added line #L444 was not covered by tests
return None
else:
data = self.client.factory.create("data:MerchantDefinedData")
Expand Down Expand Up @@ -471,7 +471,7 @@ def _serialize_response(self, response):

def process_data(self, payment, request):
xid = request.POST.get("MD")
if xid != payment.attrs.xid:
if xid != payment.extra_data["xid"]:
return redirect(payment.get_failure_url())
if payment.status in [PaymentStatus.CONFIRMED, PaymentStatus.PREAUTH]:
return redirect(payment.get_success_url())
Expand Down
4 changes: 2 additions & 2 deletions payments/cybersource/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.provider.org_id:
try:
fingerprint_id = self.payment.attrs.fingerprint_session_id
fingerprint_id = self.payment.extra_data["fingerprint_session_id"]
except KeyError:
fingerprint_id = str(uuid4())
self.fields["fingerprint"] = FingerprintInput(
Expand All @@ -57,7 +57,7 @@ def clean(self):
if not self.errors:
if self.provider.org_id:
fingerprint = cleaned_data["fingerprint"]
self.payment.attrs.fingerprint_session_id = fingerprint
self.payment.extra_data["fingerprint_session_id"] = fingerprint
if not self.payment.transaction_id:
try:
self.provider.charge(self.payment, cleaned_data)
Expand Down
14 changes: 7 additions & 7 deletions payments/cybersource/test_cybersource.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ class Payment(Mock):
transaction_id = None
captured_amount = 0
message = ""

class attrs:
fingerprint_session_id = "fake"
merchant_defined_data: dict[str, str] = {}
extra_data = {
"fingerprint_session_id": "fake",
"merchant_defined_data": {},
}

def get_process_url(self):
return "http://example.com"
Expand Down Expand Up @@ -153,7 +153,7 @@ def test_provider_redirects_on_success_captured_payment(
):
transaction_id = 1234
xid = "abc"
self.payment.attrs.xid = xid
self.payment.extra_data["xid"] = xid

response = MagicMock()
response.requestID = transaction_id
Expand Down Expand Up @@ -188,7 +188,7 @@ def test_provider_redirects_on_success_preauth_payment(
)
transaction_id = 1234
xid = "abc"
self.payment.attrs.xid = xid
self.payment.extra_data["xid"] = xid

response = MagicMock()
response.requestID = transaction_id
Expand Down Expand Up @@ -218,7 +218,7 @@ def test_provider_redirects_on_success_preauth_payment(
def test_provider_redirects_on_failure(self, mocked_request, mocked_redirect):
transaction_id = 1234
xid = "abc"
self.payment.attrs.xid = xid
self.payment.extra_data["xid"] = xid

response = MagicMock()
response.requestID = transaction_id
Expand Down
6 changes: 3 additions & 3 deletions payments/mercadopago/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def create_preference(self, payment: BasePayment):
if payment.transaction_id:
raise ValueError("This payment already has a preference.")

payment.attrs.external_reference = uuid4().hex
payment.extra_data["external_reference"] = uuid4().hex

payload = {
"auto_return": "all",
Expand All @@ -89,7 +89,7 @@ def create_preference(self, payment: BasePayment):
}
for item in payment.get_purchased_items()
],
"external_reference": payment.attrs.external_reference,
"external_reference": payment.extra_data["external_reference"],
"back_urls": {
"success": self.get_return_url(payment),
"pending": self.get_return_url(payment),
Expand Down Expand Up @@ -218,7 +218,7 @@ def poll_for_updates(self, payment: BasePayment):
"""
data = self.client.payment().search(
{
"external_reference": payment.attrs.external_reference,
"external_reference": payment.extra_data["external_reference"],
}
)

Expand Down
1 change: 1 addition & 0 deletions payments/mercadopago/test_mercadopago.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Payment(Mock):
captured_amount = 0
transaction_id: str | None = None
billing_email = "[email protected]"
extra_data: dict = {}

def change_status(self, status, message=""):
self.status = status
Expand Down
54 changes: 17 additions & 37 deletions payments/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

import json
import warnings
from typing import Iterable
from uuid import uuid4

Expand All @@ -15,30 +15,6 @@
from .core import provider_factory


class PaymentAttributeProxy:
def __init__(self, payment):
self._payment = payment
super().__init__()

def __getattr__(self, item):
data = json.loads(self._payment.extra_data or "{}")
try:
return data[item]
except KeyError as e:
raise AttributeError(*e.args) from e

def __setattr__(self, key, value):
if key == "_payment":
return super().__setattr__(key, value)
try:
data = json.loads(self._payment.extra_data)
except ValueError:
data = {}
data[key] = value
self._payment.extra_data = json.dumps(data)
return None


class BasePayment(models.Model):
"""
Represents a single transaction. Each instance has one or more PaymentItem.
Expand Down Expand Up @@ -80,7 +56,7 @@ class BasePayment(models.Model):
billing_email = models.EmailField(blank=True)
billing_phone = PhoneNumberField(blank=True)
customer_ip_address = models.GenericIPAddressField(blank=True, null=True)
extra_data = models.TextField(blank=True, default="")
extra_data = models.JSONField(blank=True, default=dict)
message = models.TextField(blank=True, default="")
token = models.CharField(max_length=36, blank=True, default="")
captured_amount = models.DecimalField(max_digits=9, decimal_places=2, default="0.0")
Expand Down Expand Up @@ -226,14 +202,18 @@ def refund(self, amount=None):

@property
def attrs(self):
"""A JSON-serialised wrapper around `extra_data`.
This property exposes a a dict or list which is serialised into the `extra_data`
text field. Usage of this wrapper is preferred over accessing the underlying
field directly.
You may think of this as a `JSONField` which is saved to the `extra_data`
column.
"""
# TODO: Deprecate in favour of JSONField when we drop support for django 2.2.
return PaymentAttributeProxy(self)
warnings.warn(

Check warning on line 205 in payments/models.py

View check run for this annotation

Codecov / codecov/patch

payments/models.py#L205

Added line #L205 was not covered by tests
"Using BasePayment.attrs is deprecated. Use BasePayment.extra_data instead",
DeprecationWarning,
stacklevel=2,
)
return self.extra_data

Check warning on line 210 in payments/models.py

View check run for this annotation

Codecov / codecov/patch

payments/models.py#L210

Added line #L210 was not covered by tests

@attrs.setter
def attrs(self, value):
warnings.warn(

Check warning on line 214 in payments/models.py

View check run for this annotation

Codecov / codecov/patch

payments/models.py#L214

Added line #L214 was not covered by tests
"Using BasePayment.attrs is deprecated. Use BasePayment.extra_data instead",
DeprecationWarning,
stacklevel=2,
)
self.extra_data = value

Check warning on line 219 in payments/models.py

View check run for this annotation

Codecov / codecov/patch

payments/models.py#L219

Added line #L219 was not covered by tests
18 changes: 9 additions & 9 deletions payments/paypal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,34 +82,34 @@ def __init__(
super().__init__(capture=capture)

def set_response_data(self, payment, response, is_auth=False):
extra_data = json.loads(payment.extra_data or "{}")
extra_data = payment.extra_data or {}
if is_auth:
extra_data["auth_response"] = response
else:
extra_data["response"] = response
if "links" in response:
extra_data["links"] = {link["rel"]: link for link in response["links"]}
payment.extra_data = json.dumps(extra_data)
payment.extra_data = extra_data
payment.save()

def set_response_links(self, payment, response):
transaction = response["transactions"][0]
related_resources = transaction["related_resources"][0]
resource_key = "sale" if self._capture else "authorization"
links = related_resources[resource_key]["links"]
extra_data = json.loads(payment.extra_data or "{}")
extra_data = payment.extra_data or {}
extra_data["links"] = {link["rel"]: link for link in links}
payment.extra_data = json.dumps(extra_data)
payment.extra_data = extra_data
payment.save()

def set_error_data(self, payment, error):
extra_data = json.loads(payment.extra_data or "{}")
extra_data = payment.extra_data or {}
extra_data["error"] = error
payment.extra_data = json.dumps(extra_data)
payment.extra_data = extra_data
payment.save()

def _get_links(self, payment):
extra_data = json.loads(payment.extra_data or "{}")
extra_data = payment.extra_data or {}
return extra_data.get("links", {})

@authorize
Expand Down Expand Up @@ -144,7 +144,7 @@ def post(self, payment, *args, **kwargs):
return data

def get_last_response(self, payment, is_auth=False):
extra_data = json.loads(payment.extra_data or "{}")
extra_data = payment.extra_data or {}
if is_auth:
return extra_data.get("auth_response", {})
return extra_data.get("response", {})
Expand Down Expand Up @@ -249,7 +249,7 @@ def process_data(self, payment, request):
except PaymentError:
return redirect(failure_url)
self.set_response_links(payment, executed_payment)
payment.attrs.payer_info = executed_payment["payer"]["payer_info"]
payment.extra_data["payer_info"] = executed_payment["payer"]["payer_info"]

Check warning on line 252 in payments/paypal/__init__.py

View check run for this annotation

Codecov / codecov/patch

payments/paypal/__init__.py#L252

Added line #L252 was not covered by tests
if self._capture:
payment.captured_amount = payment.total
payment.change_status(PaymentStatus.CONFIRMED)
Expand Down
36 changes: 16 additions & 20 deletions payments/paypal/test_paypal.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import json
from datetime import date
from decimal import Decimal
from unittest import TestCase
Expand Down Expand Up @@ -46,16 +45,14 @@ class Payment(Mock):
variant = VARIANT
transaction_id = None
message = ""
extra_data = json.dumps(
{
"links": {
"approval_url": None,
"capture": {"href": "http://capture.com"},
"refund": {"href": "http://refund.com"},
"execute": {"href": "http://execute.com"},
}
extra_data = {
"links": {
"approval_url": None,
"capture": {"href": "http://capture.com"},
"refund": {"href": "http://refund.com"},
"execute": {"href": "http://execute.com"},
}
)
}

def change_status(self, status, message=""):
self.status = status
Expand Down Expand Up @@ -225,23 +222,22 @@ def test_provider_renews_access_token(self, mocked_post):
mocked_post.side_effect = [HTTPError(response=response401), response, response]

self.payment.created = timezone.now()
self.payment.extra_data = json.dumps(
{
"auth_response": {
"access_token": "expired_token",
"token_type": "token type",
"expires_in": 99999,
}
self.payment.extra_data = {
"auth_response": {
"access_token": "expired_token",
"token_type": "token type",
"expires_in": 99999,
}
)
}

self.provider.create_payment(self.payment)
payment_response = json.loads(self.payment.extra_data)["auth_response"]
payment_response = self.payment.extra_data["auth_response"]
self.assertEqual(payment_response["access_token"], new_token)


class TestPaypalCardProvider(TestCase):
def setUp(self):
self.payment = Payment(extra_data="")
self.payment = Payment(extra_data={})
self.provider = PaypalCardProvider(secret=SECRET, client_id=CLIENT_ID)

def test_provider_raises_redirect_needed_on_success_captured_payment(self):
Expand Down
4 changes: 2 additions & 2 deletions payments/sofort/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def process_data(self, payment, request):
else:
payment.captured_amount = payment.total
payment.change_status(PaymentStatus.CONFIRMED)
payment.extra_data = json.dumps(doc)
payment.extra_data = doc
sender_data = doc["transactions"]["transaction_details"]["sender"]
holder_data = sender_data["holder"]
first_name, last_name = holder_data.rsplit(" ", 1)
Expand All @@ -112,7 +112,7 @@ def process_data(self, payment, request):
def refund(self, payment, amount=None):
if amount is None:
amount = payment.captured_amount
doc = json.loads(payment.extra_data)
doc = payment.extra_data
sender_data = doc["transactions"]["transaction_details"]["sender"]
refund_request = render_to_string(
"payments/sofort/refund_transaction.xml",
Expand Down

0 comments on commit 6082843

Please sign in to comment.