diff --git a/eopayment/payfip_ws.py b/eopayment/payfip_ws.py index 843ba5b..bef4dc0 100644 --- a/eopayment/payfip_ws.py +++ b/eopayment/payfip_ws.py @@ -26,6 +26,8 @@ import xml.etree.ElementTree as ET from gettext import gettext as _ +import pytz + import six from six.moves.urllib.parse import parse_qs @@ -34,7 +36,8 @@ import zeep.exceptions from .systempayv2 import isonow from .common import (PaymentCommon, PaymentResponse, URL, PAID, DENIED, - CANCELLED, ERROR, ResponseError, PaymentException) + CANCELLED, ERROR, ResponseError, PaymentException, + WAITING, EXPIRED) WSDL_URL = 'https://www.tipi.budget.gouv.fr/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService?wsdl' # noqa: E501 @@ -229,6 +232,46 @@ class Payment(PaymentCommon): return str(idop), URL, self.payment_url + '?idop=%s' % idop + def payment_status(self, transaction_id, transaction_date=None, **kwargs): + # idop are valid for 15 minutes after their generation + # between generation and payment, any call to get_info_paiement() will return a PayFiPError with code=P5 + # before the end of the 15 minutes it can mean the payment is in progress + # after the 15 minutes period it means the payment will never happen, + # and after one day the code will change for P1, meaning the idop is + # now unknown as it as been cleaned by the night cleaning job. + # + # So in order to interpret the meaning of PayFiP error codes we need + # the date of the start of the transaction and add to it some margin + # to. + idop = transaction_id + if transaction_date: + if transaction_date.tzinfo: # date is aware + now = datetime.datetime.now(tz=pytz.utc) + else: + now = datetime.datetime.now() + delta = now - transaction_date + else: + delta = datetime.timedelta(seconds=0) + # set the threshold between transaction 'in progress' and 'expired' at 20 minutes + threshold = datetime.timedelta(seconds=20 * 60) + + try: + response = self.payfip.get_info_paiement(idop) + except PayFiPError as e: + if e.code == 'P1' or ( + e.code == 'P5' and delta >= threshold): + return PaymentResponse( + result=EXPIRED, + signed=True, + order_id=transaction_id) + if e.code == 'P5' and delta < threshold: + return PaymentResponse( + result=WAITING, + signed=True, + order_id=transaction_id) + raise e + return self.payfip_response_to_eopayment_response(idop, response) + def response(self, query_string, **kwargs): fields = parse_qs(query_string, True) idop = (fields.get('idop') or [None])[0] @@ -236,11 +279,10 @@ class Payment(PaymentCommon): if not idop: raise ResponseError('missing idop parameter in query string') - try: - response = self.payfip.get_info_paiement(idop) - except PayFiPError as e: - raise ResponseError('invalid return from payfip', e) + return self.payment_status(idop) + @classmethod + def payfip_response_to_eopayment_response(cls, idop, response): if response.resultrans == 'P': result = PAID bank_status = 'paid CB' diff --git a/tests/test_payfip_ws.py b/tests/test_payfip_ws.py index 607bb97..ca3088c 100644 --- a/tests/test_payfip_ws.py +++ b/tests/test_payfip_ws.py @@ -18,9 +18,12 @@ from __future__ import print_function, unicode_literals +import datetime import json import lxml.etree as ET +import pytz + import httmock import pytest @@ -42,16 +45,26 @@ NUMCLI = '090909' @pytest.fixture(autouse=True) def freezer(freezer): freezer.move_to('2019-12-12') + return freezer + + +@pytest.fixture +def backend(history): + return eopayment.Payment('payfip_ws', { + 'numcli': '090909', + 'automatic_return_url': NOTIF_URL, + 'normal_return_url': REDIRECT_URL, + }) class PayFiPHTTMock(object): - def __init__(self, request): - history_path = 'tests/data/payfip-%s.json' % request.function.__name__ + def __init__(self, history_name): + history_path = 'tests/data/payfip-%s.json' % history_name with open(history_path) as fd: self.history = json.load(fd) self.counter = 0 - @httmock.urlmatch() + @httmock.urlmatch(scheme='https') def mock(self, url, request): request_content, response_content = self.history[self.counter] self.counter += 1 @@ -60,7 +73,22 @@ class PayFiPHTTMock(object): @pytest.fixture -def payfip(request): +def history_name(request): + return getattr(request.function, 'history_name', request.function.__name__) + + +@pytest.fixture +def history(history_name, request): + if 'update_data' not in request.keywords: + history_mock = PayFiPHTTMock(history_name) + with httmock.HTTMock(history_mock.mock): + yield history_mock + else: + yield None + + +@pytest.fixture +def payfip(history, history_name, request): history = HistoryPlugin() @httmock.urlmatch() @@ -72,24 +100,27 @@ def payfip(request): with httmock.HTTMock(raise_on_request): payfip = PayFiP(wsdl_url='./eopayment/resource/PaiementSecuriseService.wsdl', zeep_client_kwargs={'plugins': [history]}) - try: - if 'update_data' not in request.keywords: - with httmock.HTTMock(PayFiPHTTMock(request).mock): - yield payfip - else: - yield payfip - finally: - # add @pytest.mark.update_data to test to update fixtures data - if 'update_data' in request.keywords: - history_path = 'tests/data/payfip-%s.json' % request.function.__name__ - d = [ - (xmlindent(exchange['sent']['envelope']), - xmlindent(exchange['received']['envelope'])) - for exchange in history._buffer - ] - content = json.dumps(d) - with open(history_path, 'wb') as fd: - fd.write(content) + yield payfip + + # add @pytest.mark.update_data to test to update fixtures data + if 'update_data' in request.keywords: + history_path = 'tests/data/payfip-%s.json' % history_name + d = [ + (xmlindent(exchange['sent']['envelope']), + xmlindent(exchange['received']['envelope'])) + for exchange in history._buffer + ] + content = json.dumps(d) + with open(history_path, 'wb') as fd: + fd.write(content) + + +def set_history_name(name): + # decorator to add history_name to test + def decorator(func): + func.history_name = name + return func + return decorator # pytestmark = pytest.mark.update_data @@ -163,89 +194,164 @@ def test_get_info_paiement_ok(payfip): } -def test_get_info_paiement_P1(payfip): +def test_get_info_paiement_P1(payfip, freezer): # idop par pas encore reçu par la plate-forme ou déjà nettoyé (la nuit) with pytest.raises(PayFiPError, match='.*P1.*IdOp incorrect.*'): payfip.get_info_paiement('cc0cb210-1cd4-11ea-8cca-0213ad91a103') +@set_history_name('test_get_info_paiement_P1') +def test_P1_and_payment_status(payfip, backend, freezer): + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103') + assert response.result == eopayment.EXPIRED + + +@set_history_name('test_get_info_paiement_P1') +def test_P1_and_payment_status_utc_aware_now(payfip, backend, freezer): + utc_now = datetime.datetime.now(pytz.utc) + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now) + assert response.result == eopayment.EXPIRED + + +@set_history_name('test_get_info_paiement_P1') +def test_P1_and_payment_status_utc_naive_now(payfip, backend, freezer): + now = datetime.datetime.now() + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now) + assert response.result == eopayment.EXPIRED + + +@set_history_name('test_get_info_paiement_P1') +def test_P1_and_payment_status_utc_aware_now_later(payfip, backend, freezer): + utc_now = datetime.datetime.now(pytz.utc) + freezer.move_to(datetime.timedelta(minutes=25)) + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now) + assert response.result == eopayment.EXPIRED + + +@set_history_name('test_get_info_paiement_P1') +def test_P1_and_payment_status_utc_naive_now_later(payfip, backend, freezer): + now = datetime.datetime.now() + freezer.move_to(datetime.timedelta(minutes=25)) + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now) + assert response.result == eopayment.EXPIRED + + def test_get_info_paiement_P5(payfip): # idop reçu par la plate-forme mais transaction en cours with pytest.raises(PayFiPError, match='.*P5.*sultat de la transaction non connu.*'): payfip.get_info_paiement('cc0cb210-1cd4-11ea-8cca-0213ad91a103') -def test_payment_ok(request): - payment = eopayment.Payment('payfip_ws', { - 'numcli': '090909', - 'automatic_return_url': NOTIF_URL, - 'normal_return_url': REDIRECT_URL, - }) - - with httmock.HTTMock(PayFiPHTTMock(request).mock): - payment_id, kind, url = payment.request( - amount='10.00', - email='john.doe@example.com', - # make test deterministic - refdet='201912261758460053903194') - - assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103' - assert kind == eopayment.URL - assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103' - - response = payment.response('idop=%s' % payment_id) - assert response.result == eopayment.PAID - assert response.bank_status == 'paid CB' - assert response.order_id == payment_id - assert response.transaction_id == ( - '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103 112233445566-tip') +@set_history_name('test_get_info_paiement_P5') +def test_P5_and_payment_status(payfip, backend, freezer): + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103') + assert response.result == eopayment.WAITING -def test_payment_denied(request): - payment = eopayment.Payment('payfip_ws', { - 'numcli': '090909', - 'automatic_return_url': NOTIF_URL, - 'normal_return_url': REDIRECT_URL, - }) - - with httmock.HTTMock(PayFiPHTTMock(request).mock): - payment_id, kind, url = payment.request( - amount='10.00', - email='john.doe@example.com', - # make test deterministic - refdet='201912261758460053903194') - - assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103' - assert kind == eopayment.URL - assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103' - - response = payment.response('idop=%s' % payment_id) - assert response.result == eopayment.DENIED - assert response.bank_status == 'refused CB' - assert response.order_id == payment_id - assert response.transaction_id == '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103' +@set_history_name('test_get_info_paiement_P5') +def test_P5_and_payment_status_utc_aware_now(payfip, backend, freezer): + utc_now = datetime.datetime.now(pytz.utc) + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now) + assert response.result == eopayment.WAITING -def test_payment_cancelled(request): - payment = eopayment.Payment('payfip_ws', { - 'numcli': '090909', - 'automatic_return_url': NOTIF_URL, - 'normal_return_url': REDIRECT_URL, - }) +@set_history_name('test_get_info_paiement_P5') +def test_P5_and_payment_status_utc_naive_now(payfip, backend, freezer): + now = datetime.datetime.now() + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now) + assert response.result == eopayment.WAITING - with httmock.HTTMock(PayFiPHTTMock(request).mock): - payment_id, kind, url = payment.request( - amount='10.00', - email='john.doe@example.com', - # make test deterministic - refdet='201912261758460053903194') - assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103' - assert kind == eopayment.URL - assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103' +@set_history_name('test_get_info_paiement_P5') +def test_P5_and_payment_status_utc_aware_now_later(payfip, backend, freezer): + utc_now = datetime.datetime.now(pytz.utc) + freezer.move_to(datetime.timedelta(minutes=25)) + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now) + assert response.result == eopayment.EXPIRED - response = payment.response('idop=%s' % payment_id) - assert response.result == eopayment.CANCELLED - assert response.bank_status == 'cancelled CB' - assert response.order_id == payment_id - assert response.transaction_id == '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103' + +@set_history_name('test_get_info_paiement_P5') +def test_P5_and_payment_status_utc_naive_now_later(payfip, backend, freezer): + now = datetime.datetime.now() + freezer.move_to(datetime.timedelta(minutes=25)) + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now) + assert response.result == eopayment.EXPIRED + + +def test_payment_ok(payfip, backend): + payment_id, kind, url = backend.request( + amount='10.00', + email='john.doe@example.com', + # make test deterministic + refdet='201912261758460053903194') + + assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103' + assert kind == eopayment.URL + assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103' + + response = backend.response('idop=%s' % payment_id) + assert response.result == eopayment.PAID + assert response.bank_status == 'paid CB' + assert response.order_id == payment_id + assert response.transaction_id == ( + '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103 112233445566-tip') + + +@set_history_name('test_payment_ok') +def test_payment_status_ok(backend, freezer, history): + history.counter = 1 # only the response + now = datetime.datetime.now() + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now) + assert response.result == eopayment.PAID + + +def test_payment_denied(backend): + payment_id, kind, url = backend.request( + amount='10.00', + email='john.doe@example.com', + # make test deterministic + refdet='201912261758460053903194') + + assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103' + assert kind == eopayment.URL + assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103' + + response = backend.response('idop=%s' % payment_id) + assert response.result == eopayment.DENIED + assert response.bank_status == 'refused CB' + assert response.order_id == payment_id + assert response.transaction_id == '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103' + + +@set_history_name('test_payment_denied') +def test_payment_status_denied(backend, freezer, history): + history.counter = 1 # only the response + now = datetime.datetime.now() + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now) + assert response.result == eopayment.DENIED + + +def test_payment_cancelled(backend): + payment_id, kind, url = backend.request( + amount='10.00', + email='john.doe@example.com', + # make test deterministic + refdet='201912261758460053903194') + + assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103' + assert kind == eopayment.URL + assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103' + + response = backend.response('idop=%s' % payment_id) + assert response.result == eopayment.CANCELLED + assert response.bank_status == 'cancelled CB' + assert response.order_id == payment_id + assert response.transaction_id == '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103' + + +@set_history_name('test_payment_cancelled') +def test_payment_status_cancelled(backend, freezer, history): + history.counter = 1 # only the response + now = datetime.datetime.now() + response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now) + assert response.result == eopayment.CANCELLED