Skip to content

Commit

Permalink
feat: add recover failed callbacks command
Browse files Browse the repository at this point in the history
fixes #6
  • Loading branch information
igobranco committed Feb 26, 2024
1 parent 4ca88e2 commit 3f2fb2d
Show file tree
Hide file tree
Showing 8 changed files with 294 additions and 44 deletions.
7 changes: 7 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ Example of the server callback, change the `payment_ref` with your basked identi

curl -d '{"statusCode":"C", "success":"true", "MerchantCode":"NAUFCCN", "returnCode":"ABCDEFGHI", "shortMsg":"Opera%C3%A7%C3%A3o%20bem%20sucedida", "name": "edx", "is_paid": "true", "paymentValue": "1.00", "payment_ref": "EDX-100019"}' -H "Content-Type: application/json" -X POST http://localhost:18130/payment/paygate/callback/server/

VSCode
======

To make the isort work properly inside the Visual Studio Code, you should add this to your Workspace settings JSON::

"isort.args":["--settings-file", "<path to ecommerce project on host>/.isort.cfg"],

License
=======

Expand Down
14 changes: 0 additions & 14 deletions paygate/management/commands/failed_server_callbacks.py

This file was deleted.

68 changes: 68 additions & 0 deletions paygate/management/commands/retry_baskets_payed_in_paygate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""
Command that retries to receive the missing server callbacks from PayGate.
"""

import logging
from datetime import datetime, timedelta

from django.contrib.sites.models import Site
from django.core.management.base import BaseCommand
from paygate.processors import PayGate

log = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Command that retries to receive the missing server callbacks from PayGate.
"""

help = """retries to receive the missing server callbacks from PayGate."""

def add_arguments(self, parser):
"""
Arguments of this command.
"""
parser.add_argument(
"--site",
type=str,
default=Site.objects.get_current().domain,
help="The site domain to execute this recover",
)
parser.add_argument(
"--start",
type=str,
help="The start date period to retry, incompatible with the --delta-in-minutes",
)
parser.add_argument(
"--end",
type=str,
default=None,
help="The end date period to retry, incompatible with the --delta-in-minutes",
)
parser.add_argument(
"--delta-in-minutes",
type=str,
default=1440, # 1 day
help="The number of seconds to retry, default to last day",
)

def handle(self, *args, **kwargs):
"""
Synchronize courses to the Richie marketing site, print to console its sync progress.
"""
start_str = kwargs["start"]
end_str = kwargs["end"]
if start_str or end_str:
start = datetime.strptime(kwargs["start"], "%Y-%m-%d %H:%M:%S")
end = datetime.strptime(kwargs["end"], "%Y-%m-%d %H:%M:%S")
else:
delta_in_minutes = kwargs["delta-in-minutes"]
now = datetime.now()
end = now
start = now - timedelta(minutes=delta_in_minutes)

site_domain = kwargs["site"]
site = Site.objects.filter(domain=site_domain)
paygate = PayGate(site)
paygate.retry_baskets_payed_in_paygate(start, end)
101 changes: 99 additions & 2 deletions paygate/processors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""
PayGate payment processor.
"""

import base64
import datetime
import json
import logging
import re
Expand All @@ -12,14 +14,16 @@
from django.conf import settings
from django.urls import reverse
from oscar.apps.payment.exceptions import GatewayError
from oscar.core.loading import get_model
from oscar.core.loading import get_class, get_model
from paygate.utils import get_basket, order_exist

from ecommerce.core.url_utils import get_ecommerce_url
from ecommerce.extensions.payment.processors import (BasePaymentProcessor,
HandledProcessorResponse)

logger = logging.getLogger(__name__)
ProductClass = get_model("catalogue", "ProductClass")
OrderNumberGenerator = get_class("order.utils", "OrderNumberGenerator")


class PayGate(BasePaymentProcessor):
Expand Down Expand Up @@ -96,6 +100,7 @@ class PayGate(BasePaymentProcessor):
"https://lab.optimistic.blue/paygateWS/api/BackOfficeSearchTransactions"
)
DEFAULT_API_BACK_SEARCH_TRANSACTIONS_TIMEOUT_SECONDS = 10
DEFAULT_RETRY_CALLBACK_SUCCESS_TIMEOUT_SECONDS = 10

def __init__(self, site):
"""
Expand Down Expand Up @@ -128,6 +133,10 @@ def __init__(self, site):
"api_back_search_transactions_timeout_seconds",
self.DEFAULT_API_BACK_SEARCH_TRANSACTIONS_TIMEOUT_SECONDS,
)
self.retry_callback_success_timeout_seconds = self.configuration.get(
"retry_callback_success_timeout_seconds",
self.DEFAULT_RETRY_CALLBACK_SUCCESS_TIMEOUT_SECONDS,
)
self.payment_types = self.configuration.get(
"payment_types",
["VISA", "MASTERCARD", "AMEX", "PAYPAL", "MBWAY", "REFMB", "DUC"], # etc...
Expand Down Expand Up @@ -334,7 +343,7 @@ def get_transaction_parameters(
str(payment_id),
return_code,
short_return_message,
long_return_message
long_return_message,
)

self._raise_api_error(
Expand Down Expand Up @@ -575,3 +584,91 @@ def _raise_api_error(self, message, response=None, response_data=None, basket=No
exc_info=True,
)
raise GatewayError(error)

def retry_baskets_payed_in_paygate(
self,
from_datetime: datetime.datetime,
to_datetime: datetime.datetime,
offset_rows=0,
next_rows=100,
):
"""
Recover PayGate transactions that we haven't received the server call back.
Search PayGate transactions that has been completed on a time range and
that haven't been marked as payed inside the ecommerce.
"""
paygate_back_search_transactions_data = {
"ACCESS_TOKEN": self.access_token,
"MERCHANT_CODE": self.merchant_code,
# search only completed transactions
"STATUS_CODE": "C",
# Sorting parameter. Sort results ('ASC'ending or 'DESC'ending)
# For this call we just need to know if there is a single one.
"SORT_DIRECTION": "ASC",
# Sorting parameter. Sort results by the specified column name.
"SORT_COLUMN": "PAYMENT_REF",
# Paging parameter. How many rows to retrieve from the result set.
"NEXT_ROWS": next_rows,
# Paging parameter. How many rows to skip from the result set
"OFFSET_ROWS": offset_rows,
# Filter by posted transaction datetime. If not null, only transactions
# with posted date greater or equal to the value supplied will be returned.
"FROM_DATETIME": from_datetime.isoformat(),
# Filter by posted transaction datetime. If not null, only transactions
# with posted date less or equal to the value supplied will be returned.
"TO_DATETIME": to_datetime.isoformat(),
}
response_data = self._make_api_json_request(
self.api_back_search_transactions,
method="POST",
data=paygate_back_search_transactions_data,
timeout=self.api_back_search_transactions_timeout_seconds,
basic_auth_user=self.api_basic_auth_user,
basic_auth_pass=self.api_basic_auth_pass,
)
# it should be a single item that have been payed
for paygate_transaction in response_data:
payment_ref = paygate_transaction.get("PAYMENT_REF")
basket_id = OrderNumberGenerator().basket_id(payment_ref)
if not basket_id:
logger.warning(
"Can't reverse the basket_id from payment_ref=%s", payment_ref
)
continue
basket = get_basket(basket_id)
if not basket:
logger.warning("Can't find Basket for payment_ref=%s", payment_ref)
continue

if order_exist(basket):
logger.info("Order already exists for payment_ref=%s", payment_ref)
continue

logger.info("Retrying callback server for payment_ref=%s", payment_ref)

# call the callback server to retry
ecommerce_callback_server = get_ecommerce_url(
reverse("ecommerce_plugin_paygate:callback_server")
)
request_data = {
"payment_ref": payment_ref,
"statusCode": "C",
"success": True,
# so we can differentiate on the PaymentProcessorResponse object
"retry_baskets_payed_in_paygate": "true",
}
response = requests.post(
ecommerce_callback_server,
json=request_data,
timeout=self.retry_callback_success_timeout_seconds,
)
if response.status_code == 200:
logger.info("Successfully retried for payment_ref=%s", payment_ref)

if len(response_data) == next_rows:
self.retry_baskets_payed_in_paygate(
from_datetime=from_datetime,
to_datetime=to_datetime,
offset_rows=offset_rows + next_rows,
next_rows=next_rows,
)
22 changes: 22 additions & 0 deletions paygate/tests/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@


class MockResponse:
"""
A mocked requests response.
"""

def __init__(self, json_data=None, status_code=200):
self.json_data = json_data
self.status_code = status_code

def json(self):
"""
The Json output that will be mocked
"""
return self.json_data

def content(self):
"""
The Json data
"""
return self.json_data
73 changes: 67 additions & 6 deletions paygate/tests/processors/test_paygate.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from datetime import datetime, timedelta
from decimal import Decimal
from unittest.mock import patch

import factory
import mock
import requests
from paygate.processors import PayGate
from paygate.tests.factories import MockResponse

from ecommerce.extensions.payment.processors import HandledProcessorResponse
from ecommerce.extensions.payment.tests.processors.mixins import \
Expand Down Expand Up @@ -200,11 +202,13 @@ def test_handle_processor_response(self):
with mock.patch.object(
PayGate,
"_make_api_json_request",
return_value=[{
"MERCHANT_CODE": "NAU",
"STATUS_CODE": "C",
"PAYMENT_REF": self.basket.order_number,
}],
return_value=[
{
"MERCHANT_CODE": "NAU",
"STATUS_CODE": "C",
"PAYMENT_REF": self.basket.order_number,
}
],
) as mock__make_api_json_request:
self.request.LANGUAGE_CODE = "en"
self.assertEqual(
Expand Down Expand Up @@ -252,3 +256,60 @@ def test_issue_credit(self):

def test_issue_credit_error(self):
pass

@mock.patch.object(
requests,
"post",
return_value=MockResponse(
status_code=200,
),
)
def test_retry_baskets_payed_in_paygate(self, mock_ecommerce_response):
"""
Test `retry_baskets_payed_in_paygate` method.
"""
with mock.patch.object(
PayGate,
"_make_api_json_request",
return_value=[
{
"MERCHANT_CODE": "NAU",
"STATUS_CODE": "C",
"PAYMENT_REF": self.basket.order_number,
}
],
) as mock_search:
start = datetime.now()
end = datetime.now() - timedelta(minutes=5)
self.processor.retry_baskets_payed_in_paygate(start, end, next_rows=2)

mock_search.assert_called_with(
"https://test.optimistic.blue/paygateWS/api/BackOfficeSearchTransactions",
method="POST",
data={
"ACCESS_TOKEN": "PwdX_XXXX_YYYY",
"MERCHANT_CODE": "NAU",
"STATUS_CODE": "C",
"SORT_DIRECTION": "ASC",
"SORT_COLUMN": "PAYMENT_REF",
"NEXT_ROWS": 2,
"OFFSET_ROWS": 0,
"FROM_DATETIME": start.isoformat(),
"TO_DATETIME": end.isoformat(),
},
timeout=10,
basic_auth_user="NAU",
basic_auth_pass="APassword",
)

mock_ecommerce_response.assert_called_with(
"http://testserver.fake/payment/paygate/callback/server/",
json={
"payment_ref": self.basket.order_number,
"statusCode": "C",
"success": True,
# so we can differentiate on the PaymentProcessorResponse object
"retry_baskets_payed_in_paygate": "true",
},
timeout=10,
)
24 changes: 22 additions & 2 deletions paygate/utils.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
from oscar.core.loading import get_model
from django.core.exceptions import ObjectDoesNotExist
from oscar.core.loading import get_class, get_model

Order = get_model('order', 'Order')
from ecommerce.extensions.partner import strategy

Order = get_model("order", "Order")
Basket = get_model("basket", "Basket")
Applicator = get_class("offer.applicator", "Applicator")


def order_exist(basket: Basket) -> bool:
"""
Utility method that check if there is an Order for the Basket
"""
return Order.objects.filter(number=basket.order_number).exists()


def get_basket(basket_id, request=None):
"""
Get the Django Oscar Basket class from its id.
"""
if not basket_id:
return None
try:
basket_id = int(basket_id)
basket = Basket.objects.get(id=basket_id)
basket.strategy = strategy.Selector().strategy()
Applicator().apply(basket, basket.owner, request=request)
return basket
except (ValueError, ObjectDoesNotExist):
return None
Loading

0 comments on commit 3f2fb2d

Please sign in to comment.