lingo: remonter le montant payé et toutes les données de la transaction (#76572) #78

Open
bdauvergne wants to merge 1 commits from wip/76572-lingo-sur-le-paiement-d-une-fact into main
4 changed files with 127 additions and 61 deletions

View File

@ -43,7 +43,7 @@ from django.urls import reverse
from django.utils import dateparse, timezone
from django.utils.encoding import force_bytes
from django.utils.formats import localize
from django.utils.timezone import make_aware, now, utc
from django.utils.timezone import localtime, make_aware, now
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from requests import RequestException
@ -459,31 +459,6 @@ class Regie(models.Model):
)
raise PermissionDenied
def pay_invoice(self, invoice_id, transaction_id, transaction_date):
assert timezone.is_aware(transaction_date), 'transaction_date must be an aware date'
url = self.webservice_url + '/invoice/%s/pay/' % invoice_id
transaction_date = transaction_date.astimezone(utc)
data = {
'transaction_id': transaction_id,
'transaction_date': transaction_date.strftime('%Y-%m-%dT%H:%M:%S'),
}
headers = {'content-type': 'application/json'}
try:
response = requests.post(url, remote_service='auto', data=json.dumps(data), headers=headers)
if 400 <= response.status_code < 500:
raise ObjectDoesNotExist()
response.raise_for_status()
except RequestException as e:
raise RemoteInvoiceException from e
try:
resp = response.json()
except ValueError as e:
raise RemoteInvoiceException from e
if resp.get('err'):
raise RemoteInvoiceException
return resp
def get_lingo_elements(self, element_type, user, payer_external_id=None, history=False):
if not self.is_remote():
return []
@ -808,13 +783,7 @@ class BasketItem(models.Model):
message = {'result': 'ok'}
if status == 'paid':
transaction = self.transaction_set.filter(status__in=(eopayment.ACCEPTED, eopayment.PAID))[0]
message['transaction_id'] = transaction.id
message['order_id'] = transaction.order_id
message['bank_transaction_id'] = transaction.bank_transaction_id
bank_transaction_date = transaction.bank_transaction_date or transaction.end_date
bank_transaction_date = bank_transaction_date.astimezone(utc)
message['bank_transaction_date'] = bank_transaction_date.strftime('%Y-%m-%dT%H:%M:%S')
message['bank_data'] = transaction.bank_data
message.update(transaction.notification_message())
headers = {'content-type': 'application/json'}
r = requests.post(url, remote_service='auto', data=json.dumps(message), headers=headers, timeout=15)
r.raise_for_status()
@ -1055,6 +1024,48 @@ class Transaction(models.Model):
def retry_notify_remote_items_of_payments(self):
self.notify_remote_items_of_payments(self.to_be_paid_remote_items)
@property
def effective_transaction_date(self):
return localtime(self.bank_transaction_date or self.end_date)
def notification_message(self):
date = self.effective_transaction_date.strftime('%Y-%m-%dT%H:%M:%S')
return {
# pay_invoice expects order_id in transaction_id
# notify did put Transaction.id here, but as Transaction.id has no
# meaning, it's just a serial primary key from postgres, we can
# replace it by self.order_id in notify() without a problem
'transaction_id': self.order_id,
# date field expected by pay_invoice
'transaction_date': date,
# date field expected by notify
'bank_transaction_date': date,
'order_id': self.order_id,
'bank_transaction_id': self.bank_transaction_id,
'bank_data': self.bank_data,
'amount': str(self.amount),
}
def pay_invoice(self, invoice_id):
'''Notify a remote regie of the payment of an invoice'''
url = self.regie.webservice_url + '/invoice/%s/pay/' % invoice_id
data = self.notification_message()
headers = {'content-type': 'application/json'}
try:
response = requests.post(url, remote_service='auto', data=json.dumps(data), headers=headers)
if 400 <= response.status_code < 500:
raise ObjectDoesNotExist()
response.raise_for_status()
except RequestException as e:
raise RemoteInvoiceException from e
try:
resp = response.json()
except ValueError as e:
raise RemoteInvoiceException from e
if resp.get('err'):
raise RemoteInvoiceException
return resp
def notify_remote_items_of_payments(self, items):
if not items:
return
@ -1068,7 +1079,7 @@ class Transaction(models.Model):
remote_item = regie.get_invoice(user=self.user, invoice_id=item_id, raise_4xx=True)
with atomic(savepoint=False):
self.items.add(self.create_paid_invoice_basket_item(item_id, remote_item))
regie.pay_invoice(item_id, self.order_id, self.bank_transaction_date or self.end_date)
self.pay_invoice(item_id)
except ObjectDoesNotExist:
# 4xx error
logger.error(

View File

@ -10,6 +10,7 @@ from unittest import mock
import eopayment
import httmock
import pytest
import responses
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
@ -230,12 +231,16 @@ def test_transaction_manual_validation(mock_trigger_request, app, basket_page, u
assert mock_trigger_request.call_args[0][1].startswith('http://example.org/item/1/jump/trigger/paid')
@mock.patch('combo.utils.requests_wrapper.RequestsSession.request')
@responses.activate
@pytest.mark.parametrize('with_payment_backend', [False, True])
def test_successfull_items_payment(mock_trigger_request, app, basket_page, regie, user, with_payment_backend):
mock_response = mock.Mock()
mock_response.status_code = 403
mock_trigger_request.return_value = mock_response
def test_successfull_items_payment(app, basket_page, regie, user, freezer, with_payment_backend):
freezer.move_to('2023-04-13T12:00:00+02:00')
mock = responses.add(
responses.GET,
re.compile(r'http://example.org/item/\d/jump/trigger/paid'),
json={'err': 0},
status=403,
)
items = {
'item1': {'amount': '10.5', 'source_url': 'http://example.org/item/1/'},
'item2': {'amount': '42', 'source_url': 'http://example.org/item/2/'},
@ -249,22 +254,47 @@ def test_successfull_items_payment(mock_trigger_request, app, basket_page, regie
resp = login(app).get('/test_basket_cell/')
resp = resp.form.submit()
assert mock_trigger_request.call_count == 4 # source_url/jump/trigger/paid items had been verified
assert mock.call_count == 4 # source_url/jump/trigger/paid items had been verified
assert resp.status_code == 302
location = resp.location
assert '://dummy-payment' in location
parsed = urllib.parse.urlparse(location)
# get return_url and transaction id from location
qs = urllib.parse.parse_qs(parsed.query)
args = {'transaction_id': qs['transaction_id'][0], 'signed': True, 'ok': True, 'reason': 'Paid'}
transaction_id = qs['transaction_id'][0]
args = {'transaction_id': transaction_id, 'signed': True, 'ok': True, 'reason': 'Paid'}
# make sure return url is the user return URL
assert urllib.parse.urlparse(qs['return_url'][0]).path.startswith(
'/lingo/return-payment-backend/%s/' % regie.payment_backend.id
)
# simulate successful call to callback URL
with mock.patch('combo.utils.requests_wrapper.RequestsSession.request'):
resp = app.get(get_url(with_payment_backend, 'lingo-callback', regie), params=args)
mock = responses.add(
responses.POST,
re.compile(r'http://example.org/item/\d/jump/trigger/paid'),
json={'err': 0},
status=200,
)
resp = app.get(get_url(with_payment_backend, 'lingo-callback', regie), params=args)
assert resp.status_code == 200
assert json.loads(responses.calls[-1].request.body) == {
'result': 'ok',
'amount': '506.50',
# transaction_id and order_id should be the same
'transaction_id': transaction_id,
'order_id': transaction_id,
'bank_transaction_id': transaction_id,
# default timezone in combo is UTC :/
'bank_transaction_date': '2023-04-13T10:00:00',
'transaction_date': '2023-04-13T10:00:00',
'bank_data': {
'__bank_id': transaction_id,
'ok': ['True'],
'reason': ['Paid'],
'signed': ['True'],
'transaction_id': [transaction_id],
},
}
# simulate successful return URL
resp = app.get(qs['return_url'][0], params=args)
# redirect to payment status

View File

@ -7,6 +7,7 @@ from unittest import mock
import eopayment
import httmock
import pytest
import responses
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
@ -564,9 +565,9 @@ def test_remote_regie_past_invoices_cell_failure(mock_get, app, remote_regie):
assert 'Regie &quot;Remote&quot; is unavailable, please retry later.' in content
@mock.patch('combo.apps.lingo.models.Regie.pay_invoice')
@mock.patch('combo.utils.requests_wrapper.RequestsSession.send')
def test_anonymous_successful_item_payment(mock_send, mock_pay_invoice, app, remote_regie):
@responses.activate
def test_anonymous_successful_item_payment(app, remote_regie, freezer):
freezer.move_to('2023-04-13T12:00:00+02:00') # April 13th 2022 12:00:00 Europe/Paris tz
page = Page.objects.create(title='xxx', slug='index', template_name='standard')
cell = InvoicesCell.objects.create(
regie='remote',
@ -585,16 +586,17 @@ def test_anonymous_successful_item_payment(mock_send, mock_pay_invoice, app, rem
invoices = copy.deepcopy(INVOICES)
invoices[0]['amount'] = '100.00'
invoices[0]['amount_paid'] = '23.45'
mock_response = mock.Mock(status_code=200)
mock_response.json.return_value = {'err': 0, 'data': invoices[0]}
mock_send.return_value = mock_response
mock_pay_invoice.return_value = mock.Mock(status_code=200)
invoice_response = responses.add(
responses.GET, 'http://example.org/regie/invoice/F201601/', json={'err': 0, 'data': invoices[0]}
)
resp = app.get('/lingo/item/%s/%s/%s/' % (remote_regie.id, encrypt_id, encrypt_reference))
assert '<button>Pay</button>' in resp
assert 'Total amount: <span class="amount">123.45€</span>' in resp.text
assert 'Amount to pay: <span class="amount">100.00€</span>' in resp.text
assert 'Amount already paid: <span class="amount">23.45€</span>' in resp.text
url = mock_send.call_args[0][0].url
url = invoice_response.calls[0].request.url
scheme, netloc, path, dummy, querystring, dummy = urllib.parse.urlparse(url)
assert scheme == 'http'
assert netloc == 'example.org'
@ -604,13 +606,13 @@ def test_anonymous_successful_item_payment(mock_send, mock_pay_invoice, app, rem
assert 'payer_external_id' not in query
# with payer_external_id
mock_send.reset_mock()
invoice_response.calls.reset()
resp = app.get(
'/lingo/item/%s/%s/%s/?payer_external_id=%s'
% (remote_regie.id, encrypt_id, encrypt_reference, encrypt_payer)
)
assert '<button>Pay</button>' in resp
url = mock_send.call_args[0][0].url
url = invoice_response.calls[0].request.url
scheme, netloc, path, dummy, querystring, dummy = urllib.parse.urlparse(url)
assert scheme == 'http'
assert netloc == 'example.org'
@ -633,10 +635,9 @@ def test_anonymous_successful_item_payment(mock_send, mock_pay_invoice, app, rem
# invoice without amount_paid
cell.include_pay_button = True
cell.save()
mock_response = mock.Mock(status_code=200)
mock_response.json.return_value = {'err': 0, 'data': INVOICES[0]}
mock_send.return_value = mock_response
mock_pay_invoice.return_value = mock.Mock(status_code=200)
responses.replace(
responses.GET, 'http://example.org/regie/invoice/F201601/', json={'err': 0, 'data': INVOICES[0]}
)
resp = app.get('/lingo/item/%s/%s/%s/' % (remote_regie.id, encrypt_id, encrypt_reference))
assert 'Total amount: <span class="amount">123.45€</span>' in resp.text
assert 'Amount to pay: <span class="amount">123.45€</span>' in resp.text
@ -677,10 +678,14 @@ def test_anonymous_successful_item_payment(mock_send, mock_pay_invoice, app, rem
parsed = urllib.parse.urlparse(location)
# get return_url and transaction id from location
qs = urllib.parse.parse_qs(parsed.query)
args = {'transaction_id': qs['transaction_id'][0], 'signed': True, 'ok': True, 'reason': 'Paid'}
transaction_id = qs['transaction_id'][0]
args = {'transaction_id': transaction_id, 'signed': True, 'ok': True, 'reason': 'Paid'}
# make sure return url is the user return URL
return_url = qs['return_url'][0]
assert urllib.parse.urlparse(return_url).path.startswith('/lingo/return-payment-backend')
payment_response = responses.add(
responses.POST, 'http://example.org/regie/invoice/F201601/pay/', json={'err': 0}
)
# simulate successful return URL
resp = app.get(qs['return_url'][0], params=args)
# redirect to payment status
@ -690,6 +695,25 @@ def test_anonymous_successful_item_payment(mock_send, mock_pay_invoice, app, rem
assert urllib.parse.urlparse(resp.html.find('a', {'id': 'next-url'})['href']).path == '/'
# simulate successful call to callback URL
resp = app.get(reverse('lingo-callback', kwargs={'regie_pk': remote_regie.id}), params=args)
assert json.loads(payment_response.calls[0].request.body) == {
# transaction_id and order_id should be the same
'transaction_id': transaction_id,
'order_id': transaction_id,
'bank_transaction_id': transaction_id,
# date fields should be the same (same reason as above)
# default timezone in combo is UTC :/
'bank_transaction_date': '2023-04-13T10:00:00',
'transaction_date': '2023-04-13T10:00:00',
'amount': '123.45',
'bank_data': {
'__bank_id': transaction_id,
'ok': ['True'],
'reason': ['Paid'],
'signed': ['True'],
'transaction_id': [transaction_id],
},
}
trans = Transaction.objects.all()
b_item = BasketItem.objects.all()
@ -830,7 +854,7 @@ def test_remote_item_failure(mock_get, app, remote_regie):
assert '<h2>Technical error: impossible to retrieve invoices.</h2>' in resp.text
@mock.patch('combo.apps.lingo.models.Regie.pay_invoice')
@mock.patch('combo.apps.lingo.models.Transaction.pay_invoice')
@mock.patch('combo.apps.lingo.models.requests.get')
def test_pay_remote_item_failure(mock_get, mock_pay_invoice, app, remote_regie):
page = Page.objects.create(title='xxx', slug='lingo', template_name='standard')
@ -949,7 +973,7 @@ def test_self_declared_invoice(mock_get, app, remote_regie):
assert '<button>Pay</button>' in resp
@mock.patch('combo.apps.lingo.models.Regie.pay_invoice')
@mock.patch('combo.apps.lingo.models.Transaction.pay_invoice')
@mock.patch('combo.apps.lingo.models.requests.get')
@mock.patch('combo.apps.lingo.models.requests.post')
def test_remote_item_payment_failure(mock_post, mock_get, mock_pay_invoice, app, remote_regie):
@ -1029,7 +1053,7 @@ def test_remote_item_payment_failure(mock_post, mock_get, mock_pay_invoice, app,
@pytest.mark.parametrize('can_pay_only_one_basket_item', [False, True])
@mock.patch('combo.apps.lingo.models.Regie.pay_invoice')
@mock.patch('combo.apps.lingo.models.Transaction.pay_invoice')
@mock.patch('combo.apps.lingo.models.requests.get')
def test_remote_invoice_successfull_payment_redirect(
mock_get, mock_pay_invoice, can_pay_only_one_basket_item, app, remote_regie

View File

@ -71,6 +71,7 @@ deps =
pyquery
psycopg2-binary
lxml
responses
git+https://git.entrouvert.org/debian/django-ckeditor.git
git+https://git.entrouvert.org/publik-django-templatetags.git
uwsgidecorators