Compare commits

...

79 Commits

Author SHA1 Message Date
Benjamin Dauvergne 59c38e2c90 paybox: create PBX_ARCHIVAGE reference using the correct format (#90279)
gitea/eopayment/pipeline/head This commit looks good Details
PBX_ARCHIVAGE must be composed of a maximum of 12 alphanumeric characters.

See https://www.paybox.com/espace-integrateur-documentation/dictionnaire-des-donnees/paybox-system/#PBX_ARCHIVAGE
2024-05-03 00:10:13 +02:00
Thomas NOËL 331f6e4a8a paybox: handle 3DSv2 informations in request (#90207)
gitea/eopayment/pipeline/head This commit looks good Details
2024-05-02 15:21:44 +02:00
Frédéric Péters d69e64eb8b add guess() method to dummy backend (#82921)
gitea/eopayment/pipeline/head This commit looks good Details
2023-11-10 08:56:52 +01:00
Emmanuel Cazenave 5749ee4266 setup: compute pep440 compliant dirty version number (#81731)
gitea/eopayment/pipeline/head This commit looks good Details
2023-10-30 17:19:14 +01:00
Valentin Deniaud f0fbf9835f misc: update git-blame-ignore-revs to ignore quote changes (#79788)
gitea/eopayment/pipeline/head This commit looks good Details
2023-08-16 11:52:35 +02:00
Valentin Deniaud 02781300dc misc: apply double-quote-string-fixer (#79788) 2023-08-16 11:52:35 +02:00
Valentin Deniaud ac990b7920 misc: add pre commit hook to force single quotes (#79788) 2023-08-16 11:52:35 +02:00
Frédéric Péters afd9017a7c debian: remove obsolete python-eopayment.docs (#78966)
gitea/eopayment/pipeline/head This commit looks good Details
2023-06-27 20:19:07 +02:00
Frédéric Péters 174b902ee7 ci: build deb package for bookworm (#78968)
gitea/eopayment/pipeline/head This commit looks good Details
2023-06-23 15:14:59 +02:00
Paul Marillonnet 7c0d198ba7 setup: use django's command caller for translations compilation (#78765)
gitea/eopayment/pipeline/head This commit looks good Details
2023-06-20 14:18:06 +02:00
Agate a193f26291 Prepare Jenkinsfile for Gitea migration (#74572)
gitea/eopayment/pipeline/head This commit looks good Details
2023-02-20 15:05:00 +01:00
Benjamin Dauvergne d42c639a9d update translation 2023-02-20 14:07:14 +01:00
Benjamin Dauvergne 2649aa3d05 tipi: fix enumerations of "code de saisie" (#74371) 2023-02-09 12:59:57 +01:00
Frédéric Péters a8507ef299 ci: upgrade isort (#74044) 2023-02-01 09:46:54 +01:00
Frédéric Péters e806db9e48 ci: only build package for bullseye (#72729) 2022-12-22 17:24:54 +01:00
Thomas NOËL 1f781057c7 dummy: use a new URL https://dummy-payment.entrouvert.com/ (#72234) 2022-12-09 15:55:53 +01:00
Benjamin Dauvergne 1841f1fa4a payfip_ws: consider cancelled transaction as still waiting for a resolution (#71155) 2022-11-09 06:41:37 +01:00
Frédéric Péters 97b6bac733 trivial: adjust import order after six cleanup (#70693) 2022-10-28 08:26:03 +02:00
Frédéric Péters f5e05ee948 trivial: apply pyupgdade -> remove six (#70693) 2022-10-28 08:13:07 +02:00
Frédéric Péters 95c59b7dea ci: update pyupgrade to 3.1.0 (#70693) 2022-10-28 08:06:19 +02:00
Agate 76252be8af django4: replaced ugettext* calls with corresponding gettext* calls (#68187) 2022-08-31 09:09:56 +02:00
Benjamin Dauvergne 7b73275f70 add a method to guess transaction_id and backend from an HTTP response (#32224) 2022-06-30 12:11:18 +02:00
Benjamin Dauvergne fe27aeb4e5 misc: remove dead code (#32224) 2022-06-30 12:11:18 +02:00
Benjamin Dauvergne 02d1238761 misc: remove references to sips (#32224)
It broke get_backends().
2022-06-30 12:11:05 +02:00
Serghei Mihai 3abc468775 paybox: accept two digits value for rang parameter (#65828)
gitea/eopayment/pipeline/head Build started... Details
2022-05-31 17:35:13 +02:00
Benjamin Dauvergne 2f7c4374f3 systempayv2: change scope of some parameters (#65136)
Change scope of parameters "vads_orde_info, vads_order_info2, vads_order_info3 and
vads_payment_config" to "transaction".
2022-05-11 16:56:34 +02:00
Frédéric Péters 80c979113c trivial: bump black version to 22.3.0 2022-04-03 16:52:13 +02:00
Frédéric Péters 944e54873c trivial: bump black version to 22.1.0 (#62312) 2022-03-01 19:40:56 +01:00
Benjamin Dauvergne 7cb11a273e mollie: fix subject in body (#61417)
Subject does not exist in body.
2022-02-04 08:36:47 +01:00
Frédéric Péters d43c66caa4 trivial: remove python 2 from classifiers 2022-02-02 08:09:18 +01:00
Benjamin Dauvergne 80c103a257 mollie: set has_empty_response flag (#61241) 2022-01-29 09:45:41 +01:00
Benjamin Dauvergne ae14959c2a misc: add empty_response flag (#61241) 2022-01-29 09:12:08 +01:00
Benjamin Dauvergne 0608e27dfe mollie: pass orderid in description field (#60808)
The field "description" can also be filled with a more human description
using the subject parameter of request().
2022-01-19 10:48:10 +01:00
Emmanuel Cazenave 6f9ebcd866 jenkins: show execution context in coverage reports (#60446) 2022-01-11 16:13:24 +01:00
Thomas NOËL b0fe753a58 replace Crypto with Cryptodome (#59924) 2021-12-21 11:07:05 +01:00
Frédéric Péters fd729ab30a debian: remove obsolete python3-crypto build-dep 2021-12-12 12:26:07 +01:00
Frédéric Péters ba6d08f8a8 jenkins: build packages for buster & bullseye 2021-12-12 12:00:49 +01:00
Frédéric Péters 2365453f52 debian: use debhelper compat level 12 2021-12-12 12:00:49 +01:00
Frédéric Péters 4abdb75fed debian: stop declaring python2 package 2021-12-12 12:00:35 +01:00
Serghei Mihai 2b74e7f34b debian: really stop building py2 package (#58937) 2021-11-29 15:52:46 +01:00
Serghei Mihai 025d2320e3 debian: stop building py2 version (#58937) 2021-11-29 13:57:29 +01:00
Serghei Mihai 3afaa1148a misc: add isort (#58937)
gitea/eopayment/pipeline/head Build started... Details
2021-11-26 12:20:51 +01:00
Serghei Mihai 8bf4ab81c5 trivial: apply isort (#58937) 2021-11-26 12:19:46 +01:00
Serghei Mihai 766991930a misc: add black (#58937) 2021-11-26 12:19:45 +01:00
Serghei Mihai 3ee72e5336 trivial: apply black (#58937) 2021-11-26 12:18:09 +01:00
Serghei Mihai f350cf3169 misc: add pyupgrade (#58937) 2021-11-26 12:18:08 +01:00
Serghei Mihai caa40e7e77 trivial: apply pyupgrade (#58937) 2021-11-26 12:15:14 +01:00
Serghei Mihai 19ab05d59e misc: migrate code to python 3 (#58944) 2021-11-24 19:01:31 +01:00
Serghei Mihai 0f59994d80 tox: don't run tests against python 2 (#58940) 2021-11-24 12:42:06 +01:00
Benjamin Dauvergne cc54798189 payfip_ws: encapsulate requests exceptions in PayFiPError (#57838) 2021-10-21 08:15:39 +02:00
Benjamin Dauvergne 6284e0fbb3 add minimal and maximal amount attribute to backends (#57367) 2021-09-28 22:01:56 +02:00
Benjamin Dauvergne db91463687 tipi/payfip_ws: remove URL backend parameters (#46688) 2021-06-11 06:37:17 +02:00
Benjamin Dauvergne 8406ab8be8 tipi/payfip_ws: change URL from www.tipi.budget.gouv.fr to www.payfip.gouv.fr (#46688)
WSDL are also updated to their last version.
2021-06-11 06:37:17 +02:00
Frédéric Péters b74ed9cde6 sips2: use new domain names (#53235) 2021-06-06 22:07:51 +02:00
Benjamin Dauvergne e65214d4b1 translation update 2021-05-06 17:12:57 +02:00
Benjamin Dauvergne 842eeaacf0 payfip: set min_time_between_transactions to 20 minutes (#49144) 2021-05-06 17:08:04 +02:00
Benjamin Dauvergne 3b5dbb6537 misc: add get_min_time_between_transactions() accessor (#49144) 2021-05-06 17:08:04 +02:00
Benjamin Dauvergne a9516b5c64 ogone: use COMPLUS to transmit the transaction_id (#47536)
Now ORDERID will contain the orderid if it is given or the
transaction_id if there is no orderid.

response() is adapted to work with old and new reponse:
* old, there is no COMPLUS in the response, ORDERID is used as the
  transaction_id
* new, COMPLUS is present, its value is returned as response.order_id
  (which is in fact the transaction_id :/ )

Ref:
https://epayments-support.ingenico.com/fr/integration/all-sales-channels/integrate-with-e-commerce/guide#variable-feedback-parameters
https://epayments-support.ingenico.com/fr/integration/all-sales-channels/integrate-with-e-commerce/guide#form-parameters
2021-05-06 17:07:34 +02:00
Benjamin Dauvergne c9174c008f payfip_ws: initialize PayFiP SOAP client lazily and encapsulate internal exceptions (#53590) 2021-04-30 11:20:33 +02:00
Benjamin Dauvergne 0c13ae109d paybox: raise ResponseError on malformed signatures (#49705) 2021-03-31 19:20:37 +02:00
Benjamin Dauvergne b456c19e6a systempayv2: raise an error when no transaction_id is found (#47538) 2021-03-11 14:06:45 +01:00
Benjamin Dauvergne a546bbaf5b ogone: add algo parameter (#51305) 2021-02-25 10:54:39 +01:00
Benjamin Dauvergne 42644a5a71 ogone: use pytest style tests (#51305) 2021-02-25 10:54:39 +01:00
Benjamin Dauvergne 5afe44fd60 paybox: improve shared_secret validation (#49822) 2021-02-20 20:02:15 +01:00
Frédéric Péters 2acbdd4095 ogone: check signature using both iso-8859-1 and utf-8 encodings (#51304) 2021-02-19 19:21:33 +01:00
Benjamin Dauvergne 1f3c6826ee sips2 : add flag has_free_transaction_id=True (#48233) 2021-02-08 16:24:29 +01:00
Benjamin Dauvergne dbb2301eb5 misc: transform Payment.payment_status into a property (#49148) 2021-01-29 15:38:07 +01:00
Frédéric Péters 715b7555f2 build: update to use origin/main 2020-12-26 15:21:15 +01:00
Frédéric Péters e826eed313 do not mark French strings for translation (#48218) 2020-12-03 21:23:25 +01:00
Serghei Mihai 74fc1fe43b paybox: add help text for 'cle' parameter (#48218) 2020-12-03 17:22:27 +01:00
Benjamin Dauvergne 7fcee11aa2 setup.py: remove DJANGO_SETTINGS_MODULE from environment (#48707) 2020-11-20 15:38:59 +01:00
Frédéric Péters 62c5549eb6 build: don't let a compilemessages failure abort the build (#48707) 2020-11-20 09:57:16 +01:00
Frédéric Péters 46f3145a09 trivial: remove unused request script (#48695) 2020-11-19 19:31:22 +01:00
Frédéric Péters 8dcae040e6 jenkins: publish coverage data after tests step (#48694)
gitea/eopayment/pipeline/head Build started... Details
2020-11-19 19:28:22 +01:00
Frédéric Péters 52b90ec5c9 debian: remove build-dep on python-django-common, python3-django is enough 2020-11-19 09:31:37 +01:00
Frédéric Péters 064f205d93 debian: add build-dependency on python3-django, required for django-admin 2020-11-19 09:27:37 +01:00
Serghei Mihai d2bf85f4b6 systempayv2: trivial typo fix (#48639) 2020-11-19 09:20:19 +01:00
Frédéric Péters c5245ed58f add French translation 2020-11-19 09:05:52 +01:00
Frédéric Péters 6fe18d4d58 add support for translation when used as a django app (#43082) 2020-11-19 09:05:52 +01:00
43 changed files with 2547 additions and 1389 deletions

View File

@ -1,2 +1,6 @@
[run]
dynamic_context = test_function
omit = */.tox/*
[html]
show_contexts = True

8
.git-blame-ignore-revs Normal file
View File

@ -0,0 +1,8 @@
# trivial: apply pyupgrade (#58937)
caa40e7e771b41bd1d164341a2e7f40689cbe4ba
# trivial: apply black (#58937)
3ee72e5336d03526c7ab297a5cf09057a6d5d1c2
# trivial: apply isort (#58937)
8bf4ab81c5483af946365645d15708796c832d7e
# misc: apply double-quote-string-fixer (#79788)
02781300dc176ec56da26763b69cb6b4ada965f1

22
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,22 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: double-quote-string-fixer
- repo: https://github.com/psf/black
rev: 22.3.0
hooks:
- id: black
args: ['--target-version', 'py37', '--skip-string-normalization', '--line-length', '110']
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
args: ['--profile', 'black', '--line-length', '110']
- repo: https://github.com/asottile/pyupgrade
rev: v3.1.0
hooks:
- id: pyupgrade
args: ['--keep-percent-format', '--py37-plus']

29
Jenkinsfile vendored
View File

@ -1,4 +1,4 @@
@Library('eo-jenkins-lib@master') import eo.Utils
@Library('eo-jenkins-lib@main') import eo.Utils
pipeline {
agent any
@ -7,14 +7,32 @@ pipeline {
steps {
sh 'tox -rv'
}
post {
always {
script {
utils = new Utils()
utils.publish_coverage('coverage.xml')
utils.publish_coverage_native('index.html')
}
mergeJunitResults()
}
}
}
stage('Packaging') {
steps {
script {
if (env.JOB_NAME == 'eopayment' && env.GIT_BRANCH == 'origin/master') {
sh 'sudo -H -u eobuilder /usr/local/bin/eobuilder eopayment'
env.SHORT_JOB_NAME=sh(
returnStdout: true,
// given JOB_NAME=gitea/project/PR-46, returns project
// given JOB_NAME=project/main, returns project
script: '''
echo "${JOB_NAME}" | sed "s/gitea\\///" | awk -F/ '{print $1}'
'''
).trim()
if (env.GIT_BRANCH == 'main' || env.GIT_BRANCH == 'origin/main') {
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm ${SHORT_JOB_NAME}"
} else if (env.GIT_BRANCH.startsWith('hotfix/')) {
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder --branch ${env.GIT_BRANCH} --hotfix eopayment"
sh "sudo -H -u eobuilder /usr/local/bin/eobuilder -d bullseye,bookworm --branch ${env.GIT_BRANCH} --hotfix ${SHORT_JOB_NAME}"
}
}
}
@ -25,10 +43,7 @@ pipeline {
script {
utils = new Utils()
utils.mail_notify(currentBuild, env, 'ci+jenkins-eopayment@entrouvert.org')
utils.publish_coverage('coverage.xml')
utils.publish_coverage_native('index.html')
}
mergeJunitResults()
}
success {
cleanWs()

View File

@ -1,9 +1,9 @@
recursive-include debian *
recursive-include tests *.py
recursive-include eopayment/locale *.po *.mo
include COPYING
include VERSION
include README.txt
include eopayment/request
include eopayment/response
include eopayment/resource/PaiementSecuriseService.wsdl
include eopayment/resource/PaiementSecuriseService1.xsd

View File

@ -26,8 +26,8 @@ from those services, reporting whether the transaction was successful and which
one it was. The full content (which is specific to the service) is also
reported for logging purpose.
The spplus and paybox module also depend upon the python Crypto library for DES
decoding of the merchant key and RSA signature validation on the responses.
The paybox module also depend upon the python Cryptodome library for RSA
signature validation on the responses.
Some backends allow to specify the order and transaction ids in different
fields, in order to allow to match them in payment system backoffice. They are:
@ -179,3 +179,25 @@ You can put some configuration in ~/.config/eopayment.init ::
order_id : 20201029093825_Vlco55
test : True
transaction_date : 2020-10-29 09:38:26+00:00
Code Style
==========
black is used to format the code, using thoses parameters:
black --target-version py35 --skip-string-normalization --line-length 110
isort is used to format the imports, using those parameters:
isort --profile black --line-length 110
pyupgrade is used to automatically upgrade syntax, using those parameters:
pyupgrade --keep-percent-format --py37-plus
There is .pre-commit-config.yaml to use pre-commit to automatically run black,
isort and pyupgrade before commits. (execute `pre-commit install` to install
the git hook.)

1
debian/compat vendored
View File

@ -1 +0,0 @@
9

23
debian/control vendored
View File

@ -2,40 +2,23 @@ Source: eopayment
Section: python
Priority: optional
Maintainer: Frederic Peters <fpeters@debian.org>
Build-Depends: debhelper (>= 9),
python-all (>= 2.6),
python-crypto,
python-requests,
python-setuptools (>= 0.6b3),
python-six,
python-tz,
Build-Depends: debhelper-compat (= 12),
python3-all,
python3-crypto,
python3-django,
python3-requests,
python3-setuptools,
python3-six,
python3-tz,
dh-python,
git
Standards-Version: 3.9.1
X-Python-Version: >= 2.7
X-Python3-Version: >= 3.4
Homepage: http://dev.entrouvert.org/projects/eopayment/
Package: python-eopayment
Architecture: all
Depends: ${python:Depends},
python-zeep (>= 2.5),
python-click
Description: common API to access online payment services
eopayment is a Python module to interface with French's bank credit
card online payment services. Supported services are ATOS/SIP, SystemPay,
and SPPLUS.
Package: python3-eopayment
Architecture: all
Depends: ${python3:Depends},
python3-zeep (>= 2.5),
python3-pycryptodome,
python3-click
Description: common API to access online payment services (Python 3)
eopayment is a Python module to interface with French's bank credit

View File

@ -1 +0,0 @@
README.txt

2
debian/rules vendored
View File

@ -4,5 +4,5 @@ export PYBUILD_NAME=eopayment
export PYBUILD_DISABLE=test
%:
dh $@ --with python2,python3 --buildsystem=pybuild
dh $@ --with python3 --buildsystem=pybuild

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -18,23 +17,53 @@
import datetime
import importlib
import logging
import pytz
import six
from .common import ( # noqa: F401
URL, HTML, FORM, RECEIVED, ACCEPTED, PAID, DENIED,
CANCELED, CANCELLED, ERROR, WAITING, EXPIRED, force_text,
ResponseError, PaymentException,
ACCEPTED,
CANCELED,
CANCELLED,
DENIED,
ERROR,
EXPIRED,
FORM,
HTML,
PAID,
RECEIVED,
URL,
WAITING,
BackendNotFound,
PaymentException,
ResponseError,
force_text,
)
__all__ = ['Payment', 'URL', 'HTML', 'FORM', 'SIPS', 'SYSTEMPAY',
'TIPI', 'DUMMY', 'get_backend', 'RECEIVED', 'ACCEPTED', 'PAID',
'DENIED', 'CANCELED', 'CANCELLED', 'ERROR', 'WAITING',
'EXPIRED', 'get_backends', 'PAYFIP_WS', 'SAGA']
if six.PY3:
__all__.extend(['KEYWARE', 'MOLLIE'])
__all__ = [
'Payment',
'URL',
'HTML',
'FORM',
'SIPS',
'SYSTEMPAY',
'TIPI',
'DUMMY',
'get_backend',
'RECEIVED',
'ACCEPTED',
'PAID',
'DENIED',
'CANCELED',
'CANCELLED',
'ERROR',
'WAITING',
'EXPIRED',
'get_backends',
'PAYFIP_WS',
'SAGA',
'KEYWARE',
'MOLLIE',
]
SIPS = 'sips'
SIPS2 = 'sips2'
@ -57,87 +86,87 @@ def get_backend(kind):
module = importlib.import_module('.' + kind, package='eopayment')
return module.Payment
__BACKENDS = [DUMMY, SIPS, SIPS2, SYSTEMPAY, OGONE, PAYBOX, PAYZEN,
TIPI, PAYFIP_WS, KEYWARE, MOLLIE, SAGA]
__BACKENDS = [DUMMY, SIPS2, SYSTEMPAY, OGONE, PAYBOX, PAYZEN, TIPI, PAYFIP_WS, KEYWARE, MOLLIE, SAGA]
def get_backends():
'''Return a dictionnary mapping existing eopayment backends name to their
description.
"""Return a dictionnary mapping existing eopayment backends name to their
description.
>>> get_backends()['dummy'].description['caption']
'Dummy payment backend'
>>> get_backends()['dummy'].description['caption']
'Dummy payment backend'
'''
return dict((backend, get_backend(backend)) for backend in __BACKENDS)
"""
return {backend: get_backend(backend) for backend in __BACKENDS}
class Payment(object):
'''
Interface to credit card online payment servers of French banks. The
only use case supported for now is a unique automatic payment.
class Payment:
"""
Interface to credit card online payment servers of French banks. The
only use case supported for now is a unique automatic payment.
>>> options = {
'numcli': '12345',
}
>>> p = Payment(kind=TIPI, options=options)
>>> transaction_id, kind, data = p.request('10.00', email='bob@example.com')
>>> print (transaction_id, kind, data) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
('...', 1, 'https://www.payfip.gov.fr/tpa/paiement.web?...')
>>> options = {
'numcli': '12345',
}
>>> p = Payment(kind=TIPI, options=options)
>>> transaction_id, kind, data = p.request('10.00', email='bob@example.com')
>>> print (transaction_id, kind, data) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
('...', 1, 'https://www.payfip.gov.fr/tpa/paiement.web?...')
Supported backend of French banks are:
Supported backend of French banks are:
- TIPI/PayFiP
- SIPS 2.0, for BNP, Banque Populaire (before 2010), CCF, HSBC, Crédit
Agricole, La Banque Postale, LCL, Société Générale and Crédit du
Nord.
- SystemPay v2/Payzen for Banque Populaire and Caise d'Epargne (Natixis, after 2010)
- Ogone
- Paybox
- Mollie (Belgium)
- Keyware (Belgium)
- TIPI/PayFiP
- SIPS 2.0, for BNP, Banque Populaire (before 2010), CCF, HSBC, Crédit
Agricole, La Banque Postale, LCL, Société Générale and Crédit du
Nord.
- SystemPay v2/Payzen for Banque Populaire and Caise d'Epargne (Natixis, after 2010)
- Ogone
- Paybox
- Mollie (Belgium)
- Keyware (Belgium)
For SIPs you also need the bank provided middleware especially the two
executables, request and response, as the protocol from ATOS/SIPS is not
documented. For the other backends the modules are autonomous.
For SIPs you also need the bank provided middleware especially the two
executables, request and response, as the protocol from ATOS/SIPS is not
documented. For the other backends the modules are autonomous.
Each backend need some configuration parameters to be used, the
description of the backend list those parameters. The description
dictionary can be used to generate configuration forms.
Each backend need some configuration parameters to be used, the
description of the backend list those parameters. The description
dictionary can be used to generate configuration forms.
>>> d = get_backend(SPPLUS).description
>>> print d['caption']
SPPlus payment service of French bank Caisse d'epargne
>>> print [p['name'] for p in d['parameters']] # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
['cle', ..., 'moyen']
>>> print d['parameters'][0]['caption']
Secret key, a 40 digits hexadecimal number
>>> d = get_backend(SPPLUS).description
>>> print d['caption']
SPPlus payment service of French bank Caisse d'epargne
>>> print [p['name'] for p in d['parameters']] # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
['cle', ..., 'moyen']
>>> print d['parameters'][0]['caption']
Secret key, a 40 digits hexadecimal number
'''
"""
def __init__(self, kind, options, logger=None):
self.kind = kind
self.backend = get_backend(kind)(options, logger=logger)
def request(self, amount, **kwargs):
'''Request a payment to the payment backend.
"""Request a payment to the payment backend.
Arguments:
amount -- the amount of money to ask
email -- the email of the customer (optional)
usually redundant with the hardwired settings in the bank
configuration panel. At this url you must use the Payment.response
method to analyze the bank returned values.
Arguments:
amount -- the amount of money to ask
email -- the email of the customer (optional)
usually redundant with the hardwired settings in the bank
configuration panel. At this url you must use the Payment.response
method to analyze the bank returned values.
It returns a triple of values, (transaction_id, kind, data):
- the first gives a string value to later match the payment with
the invoice,
- kind gives the type of the third value, payment.URL or
payment.HTML or payment.FORM,
- the third is the URL or the HTML form to contact the payment
server, which must be sent to the customer browser.
'''
logger.debug(u'%r' % kwargs)
It returns a triple of values, (transaction_id, kind, data):
- the first gives a string value to later match the payment with
the invoice,
- kind gives the type of the third value, payment.URL or
payment.HTML or payment.FORM,
- the third is the URL or the HTML form to contact the payment
server, which must be sent to the customer browser.
"""
logger.debug('%r' % kwargs)
if 'capture_date' in kwargs:
capture_date = kwargs.pop('capture_date')
@ -157,69 +186,68 @@ class Payment(object):
# backend timezone should come from some backend configuration
backend_tz = pytz.timezone('Europe/Paris')
utc_tz = pytz.timezone('Etc/UTC')
backend_trans_date = utc_tz.localize(
datetime.datetime.utcnow()).astimezone(backend_tz)
backend_trans_date = utc_tz.localize(datetime.datetime.utcnow()).astimezone(backend_tz)
capture_day = (capture_date - backend_trans_date.date()).days
if capture_day <= 0:
raise ValueError("capture_date needs to be superior to the transaction date.")
raise ValueError('capture_date needs to be superior to the transaction date.')
kwargs['capture_day'] = force_text(capture_day)
return self.backend.request(amount, **kwargs)
def response(self, query_string, **kwargs):
'''
Process a response from the Bank API. It must be used on the URL
where the user browser of the payment server is going to post the
result of the payment. Beware it can happen multiple times for the
same payment, so you MUST support multiple notification of the same
event, i.e. it should be idempotent. For example if you already
validated some invoice, receiving a new payment notification for the
same invoice should alter this state change.
"""
Process a response from the Bank API. It must be used on the URL
where the user browser of the payment server is going to post the
result of the payment. Beware it can happen multiple times for the
same payment, so you MUST support multiple notification of the same
event, i.e. it should be idempotent. For example if you already
validated some invoice, receiving a new payment notification for the
same invoice should alter this state change.
Beware that when notified directly by the bank (and not through the
customer browser) no applicative session will exist, so you should
not depend on it in your handler.
Beware that when notified directly by the bank (and not through the
customer browser) no applicative session will exist, so you should
not depend on it in your handler.
Arguments:
query_string -- the URL encoded form-data from a GET or a POST
Arguments:
query_string -- the URL encoded form-data from a GET or a POST
It returns a quadruplet of values:
It returns a quadruplet of values:
(result, transaction_id, bank_data, return_content)
(result, transaction_id, bank_data, return_content)
- result is a boolean stating whether the transaction worked, use it
to decide whether to act on a valid payment,
- the transaction_id return the same id than returned by request
when requesting for the payment, use it to find the invoice or
transaction which is linked to the payment,
- bank_data is a dictionnary of the data sent by the bank, it should
be logged for security reasons,
- return_content, if not None you must return this content as the
result of the HTTP request, it's used when the bank is calling
your site as a web service.
- result is a boolean stating whether the transaction worked, use it
to decide whether to act on a valid payment,
- the transaction_id return the same id than returned by request
when requesting for the payment, use it to find the invoice or
transaction which is linked to the payment,
- bank_data is a dictionnary of the data sent by the bank, it should
be logged for security reasons,
- return_content, if not None you must return this content as the
result of the HTTP request, it's used when the bank is calling
your site as a web service.
'''
"""
return self.backend.response(query_string, **kwargs)
def cancel(self, amount, bank_data, **kwargs):
'''
Cancel or edit the amount of a transaction sent to the bank.
"""
Cancel or edit the amount of a transaction sent to the bank.
Arguments:
- amount -- the amount of money to cancel
- bank_data -- the transaction dictionary received from the bank
'''
Arguments:
- amount -- the amount of money to cancel
- bank_data -- the transaction dictionary received from the bank
"""
return self.backend.cancel(amount, bank_data, **kwargs)
def validate(self, amount, bank_data, **kwargs):
'''
Validate and trigger the transmission of a transaction to the bank.
"""
Validate and trigger the transmission of a transaction to the bank.
Arguments:
- amount -- the amount of money
- bank_data -- the transaction dictionary received from the bank
'''
Arguments:
- amount -- the amount of money
- bank_data -- the transaction dictionary received from the bank
"""
return self.backend.validate(amount, bank_data, **kwargs)
def get_parameters(self, scope='global'):
@ -230,11 +258,65 @@ class Payment(object):
res.append(param)
return res
def get_min_time_between_transactions(self):
if hasattr(self.backend, 'min_time_between_transactions'):
return self.backend.min_time_between_transactions
return 0
@property
def has_free_transaction_id(self):
return self.backend.has_free_transaction_id
@property
def has_empty_response(self):
return self.backend.has_free_transaction_id
def payment_status(self, transaction_id, **kwargs):
if not self.backend.payment_status:
raise NotImplementedError('payment_status is not implemented on this backend')
return self.backend.payment_status(transaction_id=transaction_id, **kwargs)
@property
def has_payment_status(self):
return hasattr(self.backend, 'payment_status')
def get_minimal_amount(self):
return getattr(self.backend, 'minimal_amount', None)
def get_maximal_amount(self):
return getattr(self.backend, 'maximal_amount', None)
@property
def has_guess(self):
return hasattr(self.backend.__class__, 'guess')
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
'''Try to guess the kind of backend and the transaction_id given part of an HTTP response.
method CAN be GET or POST.
query_string is the URL encoded query-string as a regular string.
body is the bytes content of the response.
headers can eventually give access to the response headers.
backends is to limit the accepted kind of backends if the possible backends are known.
'''
last_exception = None
for kind, backend in get_backends().items():
if not hasattr(backend, 'guess'):
continue
if backends and kind not in backends:
continue
try:
transaction_id = backend.guess(
method=method, query_string=query_string, body=body, headers=headers
)
except Exception as e:
last_exception = e
continue
if transaction_id:
return kind, transaction_id
if last_exception is not None:
raise last_exception
raise BackendNotFound

View File

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import print_function
import configparser
import decimal
@ -26,7 +25,7 @@ import tempfile
import click
from . import Payment, FORM, URL
from . import FORM, URL, Payment
def option(value):
@ -68,7 +67,7 @@ def main(ctx, backend, debug, option, name):
backend = config_backend
load = True
elif name and backend:
load = (config_backend == backend and config_name == name)
load = config_backend == backend and config_name == name
elif name:
load = config_name == name
elif backend:
@ -124,4 +123,5 @@ def response(backend, query_string, param):
for line in formatted_value.splitlines(False):
print(' ', line)
main()

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -17,8 +16,7 @@
'''Responses codes emitted by EMV Card or 'Carte Bleu' in France'''
from . import PAID, CANCELLED, ERROR, DENIED
from . import CANCELLED, DENIED, ERROR, PAID
CB_RESPONSE_CODES = {
'00': {'message': 'Transaction approuvée ou traitée avec succès', 'result': PAID},

View File

@ -14,22 +14,24 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os.path
import os
import random
import html
import logging
import os
import os.path
import random
import sys
from datetime import date
from decimal import ROUND_DOWN, Decimal
from gettext import gettext as _
import six
try:
if 'django' in sys.modules:
from django.utils.translation import gettext_lazy as _
except ImportError:
pass
if six.PY3:
import html
else:
import cgi
__all__ = ['PaymentCommon', 'URL', 'HTML', 'RANDOM', 'RECEIVED', 'ACCEPTED',
'PAID', 'ERROR', 'WAITING']
__all__ = ['PaymentCommon', 'URL', 'HTML', 'RANDOM', 'RECEIVED', 'ACCEPTED', 'PAID', 'ERROR', 'WAITING']
RANDOM = random.SystemRandom()
@ -53,23 +55,18 @@ ORDERID_TRANSACTION_SEPARATOR = '!'
def force_text(s, encoding='utf-8'):
if issubclass(type(s), six.text_type):
if issubclass(type(s), str):
return s
try:
if not issubclass(type(s), six.string_types):
if six.PY3:
if isinstance(s, bytes):
s = six.text_type(s, encoding)
else:
s = six.text_type(s)
elif hasattr(s, '__unicode__'):
s = six.text_type(s)
if not issubclass(type(s), str):
if isinstance(s, bytes):
s = str(s, encoding)
else:
s = six.text_type(bytes(s), encoding)
s = str(s)
else:
s = s.decode(encoding)
except UnicodeDecodeError:
return six.text_type(s, encoding, 'ignore')
return str(s, encoding, 'ignore')
return s
@ -90,30 +87,43 @@ class ResponseError(PaymentException):
pass
class PaymentResponse(object):
'''Holds a generic view on the result of payment transaction response.
class BackendNotFound(PaymentException):
pass
result -- holds the declarative result of the transaction, does not use
it to validate the payment in your backoffice, it's just for informing
the user that all is well.
test -- indicates if the transaction was a test
signed -- holds whether the message was signed
bank_data -- a dictionnary containing some data depending on the bank,
you have to log it for audit purpose.
return_content -- when handling a response in a callback endpoint, i.e.
a response transmitted directly from the bank to the merchant website,
you usually have to confirm good reception of the message by returning a
properly formatted response, this is it.
bank_status -- if result is False, it contains the reason
order_id -- the id given by the merchant in the payment request
transaction_id -- the id assigned by the bank to this transaction, it
could be the one sent by the merchant in the request, but it is usually
an identifier internal to the bank.
'''
def __init__(self, result=None, signed=None, bank_data=dict(),
return_content=None, bank_status='', transaction_id='',
order_id='', test=False, transaction_date=None):
class PaymentResponse:
"""Holds a generic view on the result of payment transaction response.
result -- holds the declarative result of the transaction, does not use
it to validate the payment in your backoffice, it's just for informing
the user that all is well.
test -- indicates if the transaction was a test
signed -- holds whether the message was signed
bank_data -- a dictionnary containing some data depending on the bank,
you have to log it for audit purpose.
return_content -- when handling a response in a callback endpoint, i.e.
a response transmitted directly from the bank to the merchant website,
you usually have to confirm good reception of the message by returning a
properly formatted response, this is it.
bank_status -- if result is False, it contains the reason
order_id -- the id given by the merchant in the payment request
transaction_id -- the id assigned by the bank to this transaction, it
could be the one sent by the merchant in the request, but it is usually
an identifier internal to the bank.
"""
def __init__(
self,
result=None,
signed=None,
bank_data=dict(),
return_content=None,
bank_status='',
transaction_id='',
order_id='',
test=False,
transaction_date=None,
):
self.result = result
self.signed = signed
self.bank_data = bank_data
@ -140,8 +150,9 @@ class PaymentResponse(object):
return self.result == ERROR
class PaymentCommon(object):
class PaymentCommon:
has_free_transaction_id = False
has_empty_response = False
PATH = '/tmp'
BANK_ID = '__bank_id'
@ -160,11 +171,9 @@ class PaymentCommon(object):
while True:
parts = [RANDOM.choice(choices) for x in range(length)]
id = ''.join(parts)
name = '%s_%s_%s' % (str(date.today()),
'-'.join(prefixes), str(id))
name = '%s_%s_%s' % (str(date.today()), '-'.join(prefixes), str(id))
try:
fd = os.open(os.path.join(self.PATH, name),
os.O_CREAT | os.O_EXCL)
fd = os.open(os.path.join(self.PATH, name), os.O_CREAT | os.O_EXCL)
except Exception:
raise
else:
@ -176,21 +185,21 @@ class PaymentCommon(object):
try:
amount = Decimal(amount)
except ValueError:
raise ValueError('invalid amount %s: it must be a decimal integer with two digits '
'at most after the decimal point', amount)
if int(amount) < min_amount or (max_amount and int(amount) > max_amount):
raise ValueError(
'invalid amount %s: it must be a decimal integer with two digits '
'at most after the decimal point',
amount,
)
if int(amount) < min_amount or (max_amount and int(amount) > max_amount):
raise ValueError('amount %s is not in range [%s, %s]' % (amount, min_amount, max_amount))
if cents:
amount *= Decimal('100') # convert to cents
amount = amount.to_integral_value(ROUND_DOWN)
return str(amount)
payment_status = None
class Form(object):
def __init__(self, url, method, fields, encoding='utf-8',
submit_name='Submit', submit_value='Submit'):
class Form:
def __init__(self, url, method, fields, encoding='utf-8', submit_name='Submit', submit_value='Submit'):
self.url = url
self.method = method
self.fields = fields
@ -208,17 +217,16 @@ class Form(object):
return s
def escape(self, s):
if six.PY3:
return html.escape(force_text(s, self.encoding))
else:
return cgi.escape(force_text(s, self.encoding)).encode(self.encoding)
return html.escape(force_text(s, self.encoding))
def __str__(self):
s = '<form method="%s" action="%s">' % (self.method, self.url)
for field in self.fields:
s += '\n <input type="%s" name="%s" value="%s"/>' % (self.escape(field['type']),
self.escape(field['name']),
self.escape(field['value']))
s += '\n <input type="%s" name="%s" value="%s"/>' % (
self.escape(field['type']),
self.escape(field['name']),
self.escape(field['value']),
)
s += '\n <input type="submit"'
if self.submit_name:
s += ' name="%s"' % self.escape(self.submit_name)

View File

@ -17,79 +17,67 @@
import logging
import uuid
import warnings
from urllib.parse import parse_qs, urlencode
from six.moves.urllib.parse import parse_qs, urlencode
from .common import (
PaymentCommon,
PaymentResponse,
ResponseError,
URL,
PAID, ERROR, WAITING,
force_text
)
from .common import ERROR, PAID, URL, WAITING, PaymentCommon, PaymentResponse, ResponseError, _, force_text
__all__ = ['Payment']
def N_(message):
return message
SERVICE_URL = 'http://dummy-payment.demo.entrouvert.com/'
SERVICE_URL = 'https://dummy-payment.entrouvert.com/'
LOGGER = logging.getLogger(__name__)
class Payment(PaymentCommon):
'''
Dummy implementation of the payment interface.
"""
Dummy implementation of the payment interface.
It is used with a dummy implementation of a bank payment service that
you can find on:
It is used with a dummy implementation of a bank payment service that
you can find on:
http://dummy-payment.demo.entrouvert.com/
https://dummy-payment.entrouvert.com/
You must pass the following keys inside the options dictionnary:
- dummy_service_url, the URL of the dummy payment service, it defaults
to the one operated by Entr'ouvert.
- automatic_return_url: where to POST to notify the service of a
payment
- origin: a human string to display to the user about the origin of
the request.
- siret: an identifier for the eCommerce site, fake.
- normal_return_url: the return URL for the user (can be overriden on a
per request basis).
"""
You must pass the following keys inside the options dictionnary:
- dummy_service_url, the URL of the dummy payment service, it defaults
to the one operated by Entr'ouvert.
- automatic_return_url: where to POST to notify the service of a
payment
- origin: a human string to display to the user about the origin of
the request.
- siret: an identifier for the eCommerce site, fake.
- normal_return_url: the return URL for the user (can be overriden on a
per request basis).
'''
description = {
'caption': 'Dummy payment backend',
'parameters': [
{
'name': 'normal_return_url',
'caption': N_('Normal return URL'),
'caption': _('Normal return URL'),
'default': '',
'required': True,
},
{
'name': 'automatic_return_url',
'caption': N_('Automatic return URL'),
'caption': _('Automatic return URL'),
'required': False,
},
{
'name': 'dummy_service_url',
'caption': 'URL of the dummy payment service',
'caption': _('URL of the dummy payment service'),
'default': SERVICE_URL,
'type': str,
},
{
'name': 'origin',
'caption': 'name of the requesting service, '
'to present in the user interface',
'caption': _('name of the requesting service, ' 'to present in the user interface'),
'type': str,
'default': 'origin',
},
{
'name': 'consider_all_response_signed',
'caption': (
'caption': _(
'All response will be considered as signed '
'(to test payment locally for example, as you '
'cannot received the signed callback)'
@ -115,13 +103,13 @@ class Payment(PaymentCommon):
},
{
'name': 'direct_notification_url',
'caption': 'direct notification url (replaced by automatic_return_url)',
'caption': _('direct notification url (replaced by automatic_return_url)'),
'type': str,
'deprecated': True,
},
{
'name': 'next_url (replaced by normal_return_url)',
'caption': 'Return URL for the user',
'caption': _('Return URL for the user'),
'type': str,
'deprecated': True,
},
@ -132,23 +120,51 @@ class Payment(PaymentCommon):
],
}
def request(self, amount, name=None, address=None, email=None, phone=None,
orderid=None, info1=None, info2=None, info3=None,
next_url=None, capture_day=None, subject=None, **kwargs):
def request(
self,
amount,
name=None,
address=None,
email=None,
phone=None,
orderid=None,
info1=None,
info2=None,
info3=None,
next_url=None,
capture_day=None,
subject=None,
**kwargs,
):
self.logger.debug(
'%s amount %s name %s address %s email %s phone %s'
' next_url %s info1 %s info2 %s info3 %s kwargs: %s',
__name__, amount, name, address, email, phone, info1, info2, info3, next_url, kwargs)
__name__,
amount,
name,
address,
email,
phone,
info1,
info2,
info3,
next_url,
kwargs,
)
transaction_id = str(uuid.uuid4().hex)
normal_return_url = self.normal_return_url
if next_url and not normal_return_url:
warnings.warn("passing next_url to request() is deprecated, "
"set normal_return_url in options", DeprecationWarning)
warnings.warn(
'passing next_url to request() is deprecated, ' 'set normal_return_url in options',
DeprecationWarning,
)
normal_return_url = next_url
automatic_return_url = self.automatic_return_url
if self.direct_notification_url and not automatic_return_url:
warnings.warn("direct_notification_url option is deprecated, "
"use automatic_return_url", DeprecationWarning)
warnings.warn(
'direct_notification_url option is deprecated, ' 'use automatic_return_url',
DeprecationWarning,
)
automatic_return_url = self.direct_notification_url
query = {
'transaction_id': transaction_id,
@ -156,11 +172,20 @@ class Payment(PaymentCommon):
'email': email,
'return_url': normal_return_url or '',
'direct_notification_url': automatic_return_url or '',
'origin': self.origin
'origin': self.origin,
}
query.update(
dict(name=name, address=address, email=email, phone=phone,
orderid=orderid, info1=info1, info2=info2, info3=info3))
dict(
name=name,
address=address,
email=email,
phone=phone,
orderid=orderid,
info1=info1,
info2=info2,
info3=info3,
)
)
if capture_day is not None:
query['capture_day'] = str(capture_day)
if subject is not None:
@ -196,7 +221,8 @@ class Payment(PaymentCommon):
order_id=transaction_id,
transaction_id=transaction_id,
bank_status=form.get('reason'),
test=True)
test=True,
)
return response
def validate(self, amount, bank_data, **kwargs):
@ -204,3 +230,9 @@ class Payment(PaymentCommon):
def cancel(self, amount, bank_data, **kwargs):
return {}
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
qs = parse_qs(force_text(query_string))
if set(qs.keys()) >= {'transaction_id', 'signed'}:
return qs['transaction_id'][0]

View File

@ -14,23 +14,33 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from gettext import gettext as _
from urllib.parse import parse_qs, urljoin
import requests
from six.moves.urllib.parse import parse_qs, urljoin
from .common import (CANCELLED, ERROR, PAID, URL, WAITING, PaymentCommon,
PaymentException, PaymentResponse, ResponseError)
from .common import (
CANCELLED,
ERROR,
PAID,
URL,
WAITING,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
_,
)
__all__ = ['Payment']
class Payment(PaymentCommon):
'''Implements EMS API, see https://dev.online.emspay.eu/.'''
service_url = 'https://api.online.emspay.eu/v1/'
description = {
'caption': 'Keyware payment backend',
'caption': _('Keyware payment backend'),
'parameters': [
{
'name': 'normal_return_url',
@ -69,7 +79,7 @@ class Payment(PaymentCommon):
'email_address': email,
'first_name': first_name,
'last_name': last_name,
}
},
}
resp = self.call_endpoint('POST', 'orders', data=body)
@ -100,7 +110,7 @@ class Payment(PaymentCommon):
order_id=order_id,
transaction_id=order_id,
bank_status=status,
test=bool('is-test' in resp.get('flags', []))
test=bool('is-test' in resp.get('flags', [])),
)
return response
@ -122,12 +132,12 @@ class Payment(PaymentCommon):
result = response.json()
except ValueError:
self.logger.debug('received invalid json %r', response.text)
raise PaymentException('%s on endpoint "%s" returned invalid JSON: %s' %
(method, endpoint, response.text))
raise PaymentException(
'%s on endpoint "%s" returned invalid JSON: %s' % (method, endpoint, response.text)
)
self.logger.debug('received "%s" with status %s', result, response.status_code)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
raise PaymentException(
'%s error on endpoint "%s": %s "%s"' % (method, endpoint, e, result))
raise PaymentException('%s error on endpoint "%s": %s "%s"' % (method, endpoint, e, result))
return result

View File

@ -0,0 +1,178 @@
# French translation of eopayment
# Copyright (C) 2011-2020 Entr'ouvert
# This file is distributed under the same license as the eopayment package.
#
msgid ""
msgstr ""
"Project-Id-Version: eopayment 0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-02-20 14:06+0100\n"
"PO-Revision-Date: 2021-05-06 17:12+0200\n"
"Last-Translator: Frederic Peters <fpeters@entrouvert.com>\n"
"Language: French\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: eopayment/dummy.py eopayment/keyware.py eopayment/mollie.py
#: eopayment/ogone.py eopayment/paybox.py eopayment/sips2.py
#: eopayment/systempayv2.py
msgid "Normal return URL"
msgstr "URL de retour normal"
#: eopayment/dummy.py eopayment/paybox.py eopayment/sips2.py eopayment/tipi.py
msgid "Automatic return URL"
msgstr "URL de retour automatique"
#: eopayment/dummy.py
msgid "URL of the dummy payment service"
msgstr "URL du service de paiement bouchon"
#: eopayment/dummy.py
msgid "name of the requesting service, to present in the user interface"
msgstr "nom du service appelant qui sera affiché dans linterface utilisateur"
#: eopayment/dummy.py
msgid ""
"All response will be considered as signed (to test payment locally for "
"example, as you cannot received the signed callback)"
msgstr ""
"Toutes les réponses seront considérées comme signées (utile pour tester le "
"paiement en local où le retour signé ne peut pas être obtenu)"
#: eopayment/dummy.py
msgid "direct notification url (replaced by automatic_return_url)"
msgstr "URL de notification directe (remplacée par automatic_return_url)"
#: eopayment/dummy.py
msgid "Return URL for the user"
msgstr "URL de retour pour lusager"
#: eopayment/keyware.py
msgid "Keyware payment backend"
msgstr "Service de paiement Keyware"
#: eopayment/keyware.py eopayment/mollie.py
msgid "Asychronous return URL"
msgstr "URL de retour asynchrone"
#: eopayment/keyware.py eopayment/mollie.py
msgid "URL of the payment service"
msgstr "URL du service de paiement"
#: eopayment/keyware.py eopayment/mollie.py
msgid "API key"
msgstr "Clé dAPI"
#: eopayment/mollie.py
msgid "General description that will be displayed for all payments"
msgstr "Description générale qui sera affichée pour tous les paiements"
#: eopayment/ogone.py
msgid "Ogone / Ingenico Payment System e-Commerce"
msgstr "Système de paiement Ogone/Ingenico"
#: eopayment/ogone.py
msgid "Automatic return URL (ignored, must be set in Ogone backoffice)"
msgstr ""
"URL de retour automatique (ignorée, doit être posée dans le backoffice Ogone)"
#: eopayment/ogone.py
msgid "Language"
msgstr "Langue"
#: eopayment/ogone.py
msgid "Characters encoding"
msgstr "Table d'encodage des caractères (Latin1 ou UTF-8)"
#: eopayment/paybox.py
msgid "Paybox"
msgstr "Paybox"
#: eopayment/paybox.py
msgid "Callback URL"
msgstr "URL de retour"
#: eopayment/paybox.py eopayment/sips2.py
msgid "Capture Mode"
msgstr "Mode de capture"
#: eopayment/paybox.py
msgid "Default Timezone"
msgstr "Fuseau horaire par défaut"
#: eopayment/payfip_ws.py eopayment/tipi.py
msgid "Client number"
msgstr "Numéro de clients"
#: eopayment/payfip_ws.py eopayment/tipi.py
msgid "6 digits number provided by DGFIP"
msgstr "6 chiffres fournis par la DGFIP"
#: eopayment/payfip_ws.py eopayment/tipi.py
msgid "Payment type"
msgstr "Type de paiement"
#: eopayment/payfip_ws.py eopayment/tipi.py
msgid "test"
msgstr "test"
#: eopayment/payfip_ws.py eopayment/tipi.py
msgid "activation"
msgstr "activation"
#: eopayment/payfip_ws.py eopayment/tipi.py
msgid "production"
msgstr "production"
#: eopayment/payfip_ws.py
msgid "User return URL"
msgstr "URL de retour usager"
#: eopayment/payfip_ws.py
msgid "Asynchronous return URL"
msgstr "URL de retour asynchrone"
#: eopayment/sips2.py
msgid "Platform"
msgstr "Plateforme"
#: eopayment/sips2.py
msgid "Merchant ID"
msgstr "Identifiant de marchand"
#: eopayment/sips2.py
msgid "Secret Key"
msgstr "Clé secrète"
#: eopayment/sips2.py
msgid "Key Version"
msgstr "Version de clé"
#: eopayment/sips2.py
msgid "Currency code"
msgstr "Code de la devise"
#: eopayment/sips2.py
msgid "Capture Day"
msgstr "Jour de capture"
#: eopayment/sips2.py
msgid "Payment Means"
msgstr "Méthodes de paiement"
#: eopayment/sips2.py
msgid "SIPS server Timezone"
msgstr "Fuseau horaire du serveur SIPS"
#: eopayment/systempayv2.py
msgid ""
"Automatic return URL (ignored, must be set in Payzen/SystemPay backoffice)"
msgstr ""
"URL de retour automatique (ignorée, doit être posée dans le backoffice "
"Payzen/SystemPay)"
#: eopayment/tipi.py
msgid "Normal return URL (unused by TIPI)"
msgstr "URL de retour normal (pas utilisée par TIPI)"

View File

@ -14,20 +14,32 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from gettext import gettext as _
from urllib.parse import parse_qs, urljoin
import requests
from six.moves.urllib.parse import parse_qs, urljoin
from .common import (CANCELLED, ERROR, PAID, URL, PaymentCommon,
PaymentException, PaymentResponse, ResponseError, WAITING,
ACCEPTED)
from .common import (
ACCEPTED,
CANCELLED,
ERROR,
PAID,
URL,
WAITING,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
_,
)
__all__ = ['Payment']
class Payment(PaymentCommon):
'''Implements Mollie API, see https://docs.mollie.com/reference/v2/.'''
has_empty_response = True
service_url = 'https://api.mollie.com/v2/'
description = {
@ -66,9 +78,12 @@ class Payment(PaymentCommon):
def request(self, amount, **kwargs):
amount = self.clean_amount(amount, cents=False)
orderid = kwargs.pop('orderid', None)
subject = kwargs.pop('subject', None)
metadata = {k: v for k, v in kwargs.items()
if k in ('email', 'first_name', 'last_name') and v is not None}
metadata = {
k: v for k, v in kwargs.items() if k in ('email', 'first_name', 'last_name') and v is not None
}
body = {
'amount': {
'value': amount,
@ -79,13 +94,17 @@ class Payment(PaymentCommon):
'metadata': metadata,
'description': self.description_text,
}
if orderid is not None:
body['description'] = orderid
metadata['orderid'] = orderid
if subject is not None:
body['description'] = subject
resp = self.call_endpoint('POST', 'payments', data=body)
return resp['id'], URL, resp['_links']['checkout']['href']
def response(self, query_string, redirect=False, order_id_hint=None,
order_status_hint=None, **kwargs):
def response(self, query_string, redirect=False, order_id_hint=None, order_status_hint=None, **kwargs):
if redirect:
if order_status_hint in (PAID, CANCELLED, ERROR):
return PaymentResponse(order_id=order_id_hint, result=order_status_hint)
@ -118,7 +137,7 @@ class Payment(PaymentCommon):
order_id=payment_id,
transaction_id=payment_id,
bank_status=status,
test=resp['mode'] == 'test'
test=resp['mode'] == 'test',
)
return response
@ -133,13 +152,28 @@ class Payment(PaymentCommon):
result = response.json()
except ValueError:
self.logger.debug('received invalid json %r', response.text)
raise PaymentException('%s on endpoint "%s" returned invalid JSON: %s' %
(method, endpoint, response.text))
raise PaymentException(
'%s on endpoint "%s" returned invalid JSON: %s' % (method, endpoint, response.text)
)
self.logger.debug('received "%s" with status %s', result, response.status_code)
try:
response.raise_for_status()
except requests.exceptions.HTTPError as e:
raise PaymentException(
'%s error on endpoint "%s": %s "%s"' % (method, endpoint, e,
result.get('detail', result)))
'%s error on endpoint "%s": %s "%s"' % (method, endpoint, e, result.get('detail', result))
)
return result
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = parse_qs(content)
if set(fields) == {'id'}:
return fields['id'][0]
return None

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -16,21 +15,28 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import hashlib
import string
import six
from six.moves.urllib import parse as urlparse
from decimal import Decimal, ROUND_HALF_UP
import uuid
from decimal import ROUND_HALF_UP, Decimal
from urllib import parse as urlparse
from .common import (
PaymentCommon, PaymentResponse, FORM, CANCELLED, PAID,
ERROR, Form, DENIED, ACCEPTED, ORDERID_TRANSACTION_SEPARATOR,
WAITING, ResponseError, force_byte, force_text
ACCEPTED,
CANCELLED,
DENIED,
ERROR,
FORM,
ORDERID_TRANSACTION_SEPARATOR,
PAID,
WAITING,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
)
def N_(message):
return message
ENVIRONMENT_TEST = 'TEST'
ENVIRONMENT_TEST_URL = 'https://secure.ogone.com/ncol/test/orderstandard.asp'
ENVIRONMENT_PROD = 'PROD'
@ -434,77 +440,91 @@ VC
class Payment(PaymentCommon):
# See http://payment-services.ingenico.com/fr/fr/ogone/support/guides/integration%20guides/e-commerce
description = {
'caption': N_('Système de paiement Ogone / Ingenico Payment System e-Commerce'),
'caption': _('Ogone / Ingenico Payment System e-Commerce'),
'parameters': [
{
'name': 'normal_return_url',
'caption': N_('Normal return URL'),
'caption': _('Normal return URL'),
'default': '',
'required': True,
},
{
'name': 'automatic_return_url',
'caption': N_('Automatic return URL (ignored, must be set in Ogone backoffice)'),
'caption': _('Automatic return URL (ignored, must be set in Ogone backoffice)'),
'required': False,
},
{
'name': 'environment',
'default': ENVIRONMENT_TEST,
'caption': N_(u'Environnement'),
'caption': 'Environnement',
'choices': ENVIRONMENT,
},
{
'name': 'pspid',
'caption': N_(u"Nom d'affiliation dans le système"),
'caption': "Nom d'affiliation dans le système",
'required': True,
},
{
'name': 'language',
'caption': N_(u'Langage'),
'caption': _('Language'),
'default': 'fr_FR',
'choices': (('fr_FR', N_('français')),),
'choices': (('fr_FR', 'français'),),
},
{
'name': 'encoding',
'caption': _('Characters encoding'),
'default': 'utf-8',
'choices': [
('iso-8859-1', 'Latin1 (ISO-8859-1)'),
('utf-8', 'Unicode (UTF-8)'),
],
},
{
'name': 'hash_algorithm',
'caption': N_(u'Algorithme de hachage'),
'caption': 'Algorithme de hachage',
'default': 'sha1',
},
{
'name': 'sha_in',
'caption': N_(u'Clé SHA-IN'),
'caption': 'Clé SHA-IN',
'required': True,
},
{
'name': 'sha_out',
'caption': N_(u'Clé SHA-OUT'),
'caption': 'Clé SHA-OUT',
'required': True,
},
{
'name': 'currency',
'caption': N_(u'Monnaie'),
'caption': 'Monnaie',
'default': 'EUR',
'choices': ('EUR',),
},
]
],
}
def sha_sign(self, algo, key, params, keep):
def __init__(self, options, logger=None):
# retro-compatibility with old default of latin1
options.setdefault('encoding', 'iso-8859-1')
super().__init__(options, logger=logger)
def sha_sign(self, algo, key, params, keep, encoding='iso-8859-1'):
'''Ogone signature algorithm of query string'''
values = params.items()
values = [(a.upper(), b) for a, b in values]
values = sorted(values)
values = [u'%s=%s' % (a, b) for a, b in values if a in keep and b]
values = ['%s=%s' % (a, b) for a, b in values if a in keep and b]
tosign = key.join(values)
tosign += key
tosign = force_byte(tosign, encoding='iso-8859-1')
tosign = force_byte(tosign, encoding=encoding)
hashing = getattr(hashlib, algo)
return hashing(tosign).hexdigest().upper()
def sha_sign_in(self, params):
return self.sha_sign(self.hash_algorithm, self.sha_in, params, SHA_IN_PARAMS)
def sha_sign_in(self, params, encoding='iso-8859-1'):
return self.sha_sign(self.hash_algorithm, self.sha_in, params, SHA_IN_PARAMS, encoding=encoding)
def sha_sign_out(self, params):
return self.sha_sign(self.hash_algorithm, self.sha_out, params, SHA_OUT_PARAMS)
def sha_sign_out(self, params, encoding='iso-8859-1'):
return self.sha_sign(self.hash_algorithm, self.sha_out, params, SHA_OUT_PARAMS, encoding=encoding)
def get_request_url(self):
if self.environment == ENVIRONMENT_TEST:
@ -513,20 +533,27 @@ class Payment(PaymentCommon):
return ENVIRONMENT_PROD_URL
raise NotImplementedError('unknown environment %s' % self.environment)
def request(self, amount, orderid=None, name=None, email=None,
language=None, description=None, **kwargs):
def request(
self,
amount,
orderid=None,
name=None,
email=None,
language=None,
description=None,
transaction_id=None,
**kwargs,
):
# use complus for transmitting and receiving the transaction_id see
# https://epayments-support.ingenico.com/fr/integration/all-sales-channels/integrate-with-e-commerce/guide#variable-feedback-parameters
# orderid is now only used for unicity of payments check (it's
# garanteed that no payment for the same ORDERID can happen during a 45
# days window, see
# https://epayments-support.ingenico.com/fr/integration/all-sales-channels/integrate-with-e-commerce/guide#form-parameters)
complus = transaction_id or uuid.uuid4().hex
if not orderid:
orderid = complus
reference = self.transaction_id(20, string.digits + string.ascii_letters)
# prepend order id in payment reference
if orderid:
if len(orderid) > 24:
raise ValueError('orderid length exceeds 25 characters')
reference = (
orderid
+ ORDERID_TRANSACTION_SEPARATOR
+ self.transaction_id(29 - len(orderid),
string.digits + string.ascii_letters))
language = language or self.language
# convertir en centimes
amount = Decimal(amount) * 100
@ -534,10 +561,11 @@ class Payment(PaymentCommon):
amount = amount.quantize(Decimal('1.'), rounding=ROUND_HALF_UP)
params = {
'AMOUNT': force_text(amount),
'ORDERID': reference,
'ORDERID': orderid,
'PSPID': self.pspid,
'LANGUAGE': language,
'CURRENCY': self.currency,
'COMPLUS': complus,
}
if self.normal_return_url:
params['ACCEPTURL'] = self.normal_return_url
@ -561,32 +589,33 @@ class Payment(PaymentCommon):
form = Form(
url=url,
method='POST',
fields=[{'type': 'hidden',
'name': key,
'value': params[key]} for key in params])
return reference, FORM, form
fields=[{'type': 'hidden', 'name': key, 'value': params[key]} for key in params],
)
return complus, FORM, form
def response(self, query_string, **kwargs):
if six.PY3:
params = urlparse.parse_qs(query_string, True, encoding='iso-8859-1')
else:
params = urlparse.parse_qs(query_string, True)
params = dict((key.upper(), params[key][0]) for key in params)
if not set(params) >= set(['ORDERID', 'PAYID', 'STATUS', 'NCERROR']):
params = urlparse.parse_qs(query_string, True, encoding=self.encoding)
params = {key.upper(): params[key][0] for key in params}
if not set(params) >= {'ORDERID', 'PAYID', 'STATUS', 'NCERROR'}:
raise ResponseError('missing ORDERID, PAYID, STATUS or NCERROR')
# uniformize iso-8859-1 encoded values
# py2: decode binary strings in query-string
for key in params:
params[key] = force_text(params[key], 'iso-8859-1')
reference = params['ORDERID']
params[key] = force_text(params[key], self.encoding)
orderid = params['ORDERID']
complus = params.get('COMPLUS')
transaction_id = params['PAYID']
status = params['STATUS']
error = params['NCERROR']
signed = False
if self.sha_in:
signature = params.get('SHASIGN')
expected_signature = self.sha_sign_out(params)
signed = signature == expected_signature
# check signature against both encoding
for encoding in ('iso-8859-1', 'utf-8'):
expected_signature = self.sha_sign_out(params, encoding=encoding)
signed = signature == expected_signature
if signed:
break
if status == '1':
result = CANCELLED
elif status == '2':
@ -602,15 +631,35 @@ class Payment(PaymentCommon):
# status 91: payment waiting/pending)
result = WAITING
else:
self.logger.error('response STATUS=%s NCERROR=%s NCERRORPLUS=%s',
status, error, params.get('NCERRORPLUS', ''))
self.logger.error(
'response STATUS=%s NCERROR=%s NCERRORPLUS=%s', status, error, params.get('NCERRORPLUS', '')
)
result = ERROR
# extract reference from received order id
if ORDERID_TRANSACTION_SEPARATOR in reference:
reference, transaction_id = reference.split(ORDERID_TRANSACTION_SEPARATOR, 1)
return PaymentResponse(
result=result,
signed=signed,
bank_data=params,
order_id=reference,
transaction_id=transaction_id)
order_id=complus or orderid,
transaction_id=transaction_id,
)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = {key.upper(): values for key, values in urlparse.parse_qs(content).items()}
if not set(fields) >= {'ORDERID', 'PAYID', 'STATUS', 'NCERROR'}:
continue
orderid = fields.get('ORDERID')
complus = fields.get('COMPLUS')
if complus:
return complus[0]
return orderid[0]
return None

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -15,34 +14,42 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import codecs
from collections import OrderedDict
import datetime
import logging
import hashlib
import hmac
import logging
import random
import re
import requests
import uuid
import warnings
from collections import OrderedDict
from urllib import parse as urllib
from urllib import parse as urlparse
from xml.sax.saxutils import escape as xml_escape
import pytz
import requests
from Cryptodome.Hash import SHA
from Cryptodome.PublicKey import RSA
from Cryptodome.Signature import PKCS1_v1_5
from Crypto.Signature import PKCS1_v1_5
from Crypto.PublicKey import RSA
from Crypto.Hash import SHA
import six
from six.moves.urllib import parse as urlparse
from six.moves.urllib import parse as urllib
import base64
from gettext import gettext as _
import warnings
from .common import (PaymentCommon, PaymentResponse, FORM, PAID, CANCELLED,
DENIED, ERROR, Form, ResponseError, force_text,
force_byte)
from . import cb
from .common import (
CANCELLED,
DENIED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
)
__all__ = ['sign', 'Payment']
@ -113,9 +120,7 @@ PAYBOX_ERROR_CODES = {
'message': 'Opération sans authentification 3-DSecure, bloquée par le filtre.',
'result': DENIED,
},
'99999': {
'message': 'Opération en attente de validation par lémetteur du moyen de paiement.'
},
'99999': {'message': 'Opération en attente de validation par lémetteur du moyen de paiement.'},
}
ALGOS = {
@ -126,18 +131,15 @@ ALGOS = {
}
URLS = {
'test':
'https://preprod-tpeweb.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'prod':
'https://tpeweb.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'backup':
'https://tpeweb1.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'test': 'https://preprod-tpeweb.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'prod': 'https://tpeweb.paybox.com/cgi/MYchoix_pagepaiement.cgi',
'backup': 'https://tpeweb1.paybox.com/cgi/MYchoix_pagepaiement.cgi',
}
PAYBOX_DIRECT_URLS = {
'test': 'https://preprod-ppps.paybox.com/PPPS.php',
'prod': 'https://ppps.paybox.com/PPPS.php',
'backup': 'https://ppps1.paybox.com/PPPS.php'
'backup': 'https://ppps1.paybox.com/PPPS.php',
}
PAYBOX_DIRECT_CANCEL_OPERATION = '00055'
@ -148,14 +150,13 @@ PAYBOX_DIRECT_VERSION_NUMBER = '00103'
PAYBOX_DIRECT_SUCCESS_RESPONSE_CODE = '00000'
# payment modes
PAYMENT_MODES = {'AUTHOR_CAPTURE': 'O',
'IMMEDIATE': 'N'}
PAYMENT_MODES = {'AUTHOR_CAPTURE': 'O', 'IMMEDIATE': 'N'}
def sign(data, key):
'''Take a list of tuple key, value and sign it by building a string to
sign.
'''
"""Take a list of tuple key, value and sign it by building a string to
sign.
"""
logger = logging.getLogger(__name__)
algo = None
logger.debug('signature key %r', key)
@ -182,20 +183,21 @@ def verify(data, signature, key=PAYBOX_KEY):
class Payment(PaymentCommon):
'''Paybox backend for eopayment.
"""Paybox backend for eopayment.
If you want to handle Instant Payment Notification, you must pass
provide a automatic_return_url option specifying the URL of the
callback endpoint.
If you want to handle Instant Payment Notification, you must pass
provide a automatic_return_url option specifying the URL of the
callback endpoint.
Email is mandatory to emit payment requests with paybox.
Email is mandatory to emit payment requests with paybox.
IP adresses to authorize:
IN OUT
test 195.101.99.73 195.101.99.76
production 194.2.160.66 194.2.122.158
backup 195.25.7.146 195.25.7.166
"""
IP adresses to authorize:
IN OUT
test 195.101.99.73 195.101.99.76
production 194.2.160.66 194.2.122.158
backup 195.25.7.146 195.25.7.166
'''
callback = None
description = {
@ -214,55 +216,52 @@ class Payment(PaymentCommon):
},
{
'name': 'platform',
'caption': _('Plateforme cible'),
'caption': 'Plateforme cible',
'default': 'test',
'choices': (
('test', 'Test'),
('backup', 'Backup'),
('prod', 'Production'),
)
),
},
{
'name': 'site',
'caption': _('Numéro de site'),
'caption': 'Numéro de site',
'required': True,
'validation': lambda x: isinstance(x, six.string_types)
and x.isdigit() and len(x) == 7,
'validation': lambda x: isinstance(x, str) and x.isdigit() and len(x) == 7,
},
{
'name': 'cle',
'caption': _('Site key'),
'caption': 'Clé',
'help_text': 'Uniquement nécessaire pour l\'annulation / remboursement / encaissement (PayBox Direct)',
'required': False,
'validation': lambda x: isinstance(x, six.string_types),
'validation': lambda x: isinstance(x, str),
},
{
'name': 'rang',
'caption': _('Numéro de rang'),
'caption': 'Numéro de rang',
'required': True,
'validation': lambda x: isinstance(x, six.string_types)
and x.isdigit() and len(x) == 3,
'validation': lambda x: isinstance(x, str) and x.isdigit() and (len(x) in (2, 3)),
},
{
'name': 'identifiant',
'caption': _('Identifiant'),
'caption': 'Identifiant',
'required': True,
'validation': lambda x: isinstance(x, six.string_types)
and x.isdigit() and (0 < len(x) < 10),
'validation': lambda x: isinstance(x, str) and x.isdigit() and (0 < len(x) < 10),
},
{
'name': 'shared_secret',
'caption': _('Secret partagé (clé HMAC)'),
'caption': 'Secret partagé (clé HMAC)',
'validation': lambda x: isinstance(x, str)
and all(a.lower() in '0123456789abcdef' for a in x),
and all(a.lower() in '0123456789abcdef' for a in x)
and (len(x) % 2 == 0),
'required': True,
},
{
'name': 'devise',
'caption': _('Devise'),
'caption': 'Devise',
'default': '978',
'choices': (
('978', 'Euro'),
),
'choices': (('978', 'Euro'),),
},
{
'name': 'callback',
@ -271,33 +270,32 @@ class Payment(PaymentCommon):
},
{
'name': 'capture_day',
'caption': _('Nombre de jours pour un paiement différé'),
'caption': 'Nombre de jours pour un paiement différé',
'default': '',
'required': False,
'validation': lambda x: isinstance(x, six.string_types)
and x.isdigit() and (1 <= len(x) <= 2)
'validation': lambda x: isinstance(x, str) and x.isdigit() and (1 <= len(x) <= 2),
},
{
'name': 'capture_mode',
'caption': _('Capture Mode'),
'default': 'IMMEDIATE',
'required': False,
'choices': list(PAYMENT_MODES)
'choices': list(PAYMENT_MODES),
},
{
'name': 'manual_validation',
'caption': 'Validation manuelle',
'type': bool,
'default': False,
'scope': 'transaction'
'scope': 'transaction',
},
{
'name': 'timezone',
'caption': _('Default Timezone'),
'default': 'Europe/Paris',
'required': False,
}
]
},
],
}
def make_pbx_cmd(self, guid, orderid=None, transaction_id=None):
@ -309,17 +307,36 @@ class Payment(PaymentCommon):
pbx_cmd += '!' + orderid
return pbx_cmd
def request(self, amount, email, name=None, orderid=None, manual_validation=None, **kwargs):
def make_pbx_archivage(self):
return ''.join(random.choices('ABCDEFGHJKMNPQRSTUVWXYZ246789', k=12))
def request(
self,
amount,
email,
name=None,
orderid=None,
manual_validation=None,
# 3DSv2 informations
total_quantity=None,
first_name=None,
last_name=None,
address1=None,
zipcode=None,
city=None,
country_code=None,
**kwargs,
):
d = OrderedDict()
d['PBX_SITE'] = force_text(self.site)
d['PBX_RANG'] = force_text(self.rang).strip()[-3:]
d['PBX_IDENTIFIANT'] = force_text(self.identifiant)
d['PBX_TOTAL'] = self.clean_amount(amount)
d['PBX_DEVISE'] = force_text(self.devise)
guid = str(uuid.uuid4().hex)
transaction_id = d['PBX_CMD'] = self.make_pbx_cmd(guid=guid,
transaction_id=kwargs.get('transaction_id'),
orderid=orderid)
pbx_archivage = self.make_pbx_archivage()
transaction_id = d['PBX_CMD'] = self.make_pbx_cmd(
guid=pbx_archivage, transaction_id=kwargs.get('transaction_id'), orderid=orderid
)
d['PBX_PORTEUR'] = force_text(email)
d['PBX_RETOUR'] = (
'montant:M;reference:R;code_autorisation:A;erreur:E;numero_appel:T;'
@ -329,9 +346,9 @@ class Payment(PaymentCommon):
)
d['PBX_HASH'] = 'SHA512'
d['PBX_TIME'] = kwargs.get('time') or (
force_text(datetime.datetime.utcnow().isoformat('T')).split('.')[0]
+ '+00:00')
d['PBX_ARCHIVAGE'] = orderid or guid
force_text(datetime.datetime.utcnow().isoformat('T')).split('.')[0] + '+00:00'
)
d['PBX_ARCHIVAGE'] = pbx_archivage
if self.normal_return_url:
d['PBX_EFFECTUE'] = self.normal_return_url
d['PBX_REFUSE'] = self.normal_return_url
@ -339,8 +356,7 @@ class Payment(PaymentCommon):
d['PBX_ATTENTE'] = self.normal_return_url
automatic_return_url = self.automatic_return_url
if not automatic_return_url and self.callback:
warnings.warn("callback option is deprecated, "
"use automatic_return_url", DeprecationWarning)
warnings.warn('callback option is deprecated, ' 'use automatic_return_url', DeprecationWarning)
automatic_return_url = self.callback
capture_day = capture_day = kwargs.get('capture_day', self.capture_day)
if capture_day:
@ -350,38 +366,69 @@ class Payment(PaymentCommon):
d['PBX_AUTOSEULE'] = PAYMENT_MODES['AUTHOR_CAPTURE']
if automatic_return_url:
d['PBX_REPONDRE_A'] = force_text(automatic_return_url)
# PBX_SHOPPINGCART and PBX_BILLING: 3DSv2 new informations
if total_quantity:
total_quantity = xml_escape('%s' % total_quantity)
d['PBX_SHOPPINGCART'] = (
'<?xml version="1.0" encoding="utf-8"?><shoppingcart><total>'
'<totalQuantity>%s</totalQuantity></total></shoppingcart>' % total_quantity
)
if first_name or last_name or address1 or zipcode or city or country_code:
pbx_billing = '<?xml version="1.0" encoding="utf-8"?><Billing><Address>'
if first_name:
pbx_billing += '<FirstName>%s</FirstName>' % xml_escape(first_name)
if last_name:
pbx_billing += '<LastName>%s</LastName>' % xml_escape(last_name)
if address1:
pbx_billing += '<Address1>%s</Address1>' % xml_escape(address1)
if zipcode:
pbx_billing += '<ZipCode>%s</ZipCode>' % xml_escape('%s' % zipcode)
if city:
pbx_billing += '<City>%s</City>' % xml_escape(city)
if country_code:
pbx_billing += '<CountryCode>%s</CountryCode>' % xml_escape('%s' % country_code)
pbx_billing += '</Address></Billing>'
d['PBX_BILLING'] = force_text(pbx_billing)
d = d.items()
if six.PY3:
shared_secret = codecs.decode(bytes(self.shared_secret, 'ascii'), 'hex')
else:
shared_secret = codecs.decode(bytes(self.shared_secret), 'hex')
shared_secret = codecs.decode(bytes(self.shared_secret, 'ascii'), 'hex')
d = sign(d, shared_secret)
url = URLS[self.platform]
fields = []
for k, v in d:
fields.append({
'type': u'hidden',
'name': force_text(k),
'value': force_text(v),
})
form = Form(url, 'POST', fields, submit_name=None,
submit_value=u'Envoyer', encoding='utf-8')
fields.append(
{
'type': 'hidden',
'name': force_text(k),
'value': force_text(v),
}
)
form = Form(url, 'POST', fields, submit_name=None, submit_value='Envoyer', encoding='utf-8')
return transaction_id, FORM, form
def response(self, query_string, callback=False, **kwargs):
d = urlparse.parse_qs(query_string, True, False)
if not set(d) >= set(['erreur', 'reference']):
if not set(d) >= {'erreur', 'reference'}:
raise ResponseError('missing erreur or reference')
signed = False
if 'signature' in d:
sig = d['signature'][0]
sig = base64.b64decode(sig)
try:
sig = base64.b64decode(sig)
except (TypeError, ValueError):
raise ResponseError('invalid signature')
data = []
if callback:
for key in ('montant', 'reference', 'code_autorisation',
'erreur', 'numero_appel', 'numero_transaction',
'date_transaction', 'heure_transaction'):
for key in (
'montant',
'reference',
'code_autorisation',
'erreur',
'numero_appel',
'numero_transaction',
'date_transaction',
'heure_transaction',
):
data.append('%s=%s' % (key, urllib.quote(d[key][0])))
else:
for key, value in urlparse.parse_qsl(query_string, True, True):
@ -419,7 +466,8 @@ class Payment(PaymentCommon):
bank_data=d,
result=result,
bank_status=message,
transaction_date=transaction_date)
transaction_date=transaction_date,
)
def perform(self, amount, bank_data, operation):
logger = logging.getLogger(__name__)
@ -443,8 +491,6 @@ class Payment(PaymentCommon):
logger.debug('received %r', response.text)
data = dict(urlparse.parse_qsl(response.text, True, True))
if data.get('CODEREPONSE') != PAYBOX_DIRECT_SUCCESS_RESPONSE_CODE:
if six.PY2:
raise ResponseError(data['COMMENTAIRE'].encode('utf-8'))
raise ResponseError(data['COMMENTAIRE'])
return data
@ -453,3 +499,17 @@ class Payment(PaymentCommon):
def cancel(self, amount, bank_data, **kwargs):
return self.perform(amount, bank_data, PAYBOX_DIRECT_CANCEL_OPERATION)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = urlparse.parse_qs(content)
if 'erreur' in fields and 'reference' in fields:
return fields['reference'][0]
return None

View File

@ -14,37 +14,48 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import print_function, unicode_literals
import copy
import datetime
import decimal
import functools
import os
import random
import re
import unicodedata
import xml.etree.ElementTree as ET
from gettext import gettext as _
from urllib.parse import parse_qs
import pytz
import six
from six.moves.urllib.parse import parse_qs
import requests
import zeep
import zeep.exceptions
from .common import (
CANCELLED,
DENIED,
ERROR,
EXPIRED,
PAID,
URL,
WAITING,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
_,
force_text,
)
from .systempayv2 import isonow
from .common import (PaymentCommon, PaymentResponse, URL, PAID, DENIED,
CANCELLED, ERROR, ResponseError, PaymentException,
WAITING, EXPIRED, force_text)
WSDL_URL = 'https://www.tipi.budget.gouv.fr/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService?wsdl' # noqa: E501
SERVICE_URL = 'https://www.tipi.budget.gouv.fr/tpa/services/securite' # noqa: E501
PAYMENT_URL = 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web'
# The URL of the WSDL published in the documentation is still wrong, it
# references XSD files which are not resolvable :/ we must use this other URL
# where the XSD files are resolvable. To not depend too much on those files, we
# provide copy in eopayment and we patch them to fix the service binding URL
# and the path of XSD files. The PayFiP development team is full of morons.
WSDL_URL = 'https://www.payfip.gouv.fr/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService?wsdl' # noqa: E501
SERVICE_URL = 'https://www.payfip.gouv.fr/tpa/services/securite' # noqa: E501
PAYMENT_URL = 'https://www.payfip.gouv.fr/tpa/paiementws.web'
REFDET_RE = re.compile(r'^[A-Za-z0-9]{1,30}$')
@ -52,7 +63,7 @@ REFDET_RE = re.compile(r'^[A-Za-z0-9]{1,30}$')
def clear_namespace(element):
def helper(element):
if element.tag.startswith('{'):
element.tag = element.tag[element.tag.index('}') + 1:]
element.tag = element.tag[element.tag.index('}') + 1 :]
for subelement in element:
helper(subelement)
@ -82,19 +93,37 @@ class PayFiPError(PaymentException):
args = [code, message]
if origin:
args.append(origin)
super(PayFiPError, self).__init__(*args)
super().__init__(*args)
class PayFiP(object):
class PayFiP:
'''Encapsulate SOAP web-services of PayFiP'''
def __init__(self, wsdl_url=None, service_url=None, zeep_client_kwargs=None):
self.client = zeep.Client(wsdl_url or WSDL_URL, **(zeep_client_kwargs or {}))
# distribued WSDL is wrong :/
self.client.service._binding_options['address'] = service_url or SERVICE_URL
def __init__(self, wsdl_url=None, service_url=None, zeep_client_kwargs=None, use_local_wsdl=True):
# use cached WSDL
if (not wsdl_url or (wsdl_url == WSDL_URL)) and use_local_wsdl:
base_path = os.path.join(os.path.dirname(__file__), 'resource', 'PaiementSecuriseService.wsdl')
wsdl_url = 'file://%s' % base_path
self.wsdl_url = wsdl_url
self.service_url = service_url
self.zeep_client_kwargs = zeep_client_kwargs
@property
def client(self):
if not hasattr(self, '_client'):
try:
self._client = zeep.Client(self.wsdl_url or WSDL_URL, **(self.zeep_client_kwargs or {}))
# distribued WSDL is wrong :/
self._client.service._binding_options['address'] = self.service_url or SERVICE_URL
except Exception as e:
raise PayFiPError('Cound not initialize the SOAP client', e)
return self._client
def fault_to_exception(self, fault):
if fault.message != 'fr.gouv.finances.cp.tpa.webservice.exceptions.FonctionnelleErreur' or fault.detail is None:
if (
fault.message != 'fr.gouv.finances.cp.tpa.webservice.exceptions.FonctionnelleErreur'
or fault.detail is None
):
return
detail = clear_namespace(fault.detail)
code = detail.find('FonctionnelleErreur/code')
@ -106,7 +135,8 @@ class PayFiP(object):
code=code.text,
message=(descriptif is not None and descriptif.text)
or (libelle is not None and libelle.text)
or '')
or '',
)
def _perform(self, request_qname, operation, **kwargs):
RequestType = self.client.get_type(request_qname) # noqa: E501
@ -115,15 +145,22 @@ class PayFiP(object):
except zeep.exceptions.Fault as fault:
raise self.fault_to_exception(fault) or PayFiPError('unknown', fault.message, fault)
except zeep.exceptions.Error as zeep_error:
raise PayFiPError('erreur-soap', str(zeep_error), zeep_error)
raise PayFiPError('SOAP error', str(zeep_error), zeep_error)
except requests.RequestException as e:
raise PayFiPError('HTTP error', e)
except Exception as e:
raise PayFiPError('Unexpected error', e)
def get_info_client(self, numcli):
return self._perform(
'{http://securite.service.tpa.cp.finances.gouv.fr/reponse}RecupererDetailClientRequest',
'recupererDetailClient',
numCli=numcli)
numCli=numcli,
)
def get_idop(self, numcli, saisie, exer, refdet, montant, mel, url_notification, url_redirect, objet=None):
def get_idop(
self, numcli, saisie, exer, refdet, montant, mel, url_notification, url_redirect, objet=None
):
return self._perform(
'{http://securite.service.tpa.cp.finances.gouv.fr/requete}CreerPaiementSecuriseRequest',
'creerPaiementSecurise',
@ -135,52 +172,33 @@ class PayFiP(object):
mel=mel,
urlnotif=url_notification,
urlredirect=url_redirect,
objet=objet)
objet=objet,
)
def get_info_paiement(self, idop):
return self._perform(
'{http://securite.service.tpa.cp.finances.gouv.fr/reponse}RecupererDetailPaiementSecuriseRequest',
'recupererDetailPaiementSecurise',
idOp=idop)
idOp=idop,
)
class Payment(PaymentCommon):
'''Produce requests for and verify response from the TIPI online payment
"""Produce requests for and verify response from the TIPI online payment
processor from the French Finance Ministry.
'''
"""
description = {
'caption': 'TIPI, Titres Payables par Internet',
'parameters': [
{
'name': 'numcli',
'caption': _(u'Client number'),
'help_text': _(u'6 digits number provided by DGFIP'),
'caption': _('Client number'),
'help_text': _('6 digits number provided by DGFIP'),
'validation': lambda s: str.isdigit(s) and len(s) == 6,
'required': True,
},
{
'name': 'service_url',
'default': SERVICE_URL,
'caption': _(u'PayFIP WS service URL'),
'help_text': _(u'do not modify if you do not know'),
'validation': lambda x: x.startswith('http'),
},
{
'name': 'wsdl_url',
'default': WSDL_URL,
'caption': _(u'PayFIP WS WSDL URL'),
'help_text': _(u'do not modify if you do not know'),
'validation': lambda x: x.startswith('http'),
},
{
'name': 'payment_url',
'default': PAYMENT_URL,
'caption': _(u'PayFiP payment URL'),
'help_text': _(u'do not modify if you do not know'),
'validation': lambda x: x.startswith('http'),
},
{
'name': 'saisie',
'caption': _('Payment type'),
@ -204,20 +222,21 @@ class Payment(PaymentCommon):
],
}
min_time_between_transactions = 60 * 20 # 20 minutes
minimal_amount = decimal.Decimal('1.0')
maximal_amount = decimal.Decimal('100000.0')
def __init__(self, *args, **kwargs):
super(Payment, self).__init__(*args, **kwargs)
wsdl_url = self.wsdl_url
# use cached WSDL
if wsdl_url == WSDL_URL:
base_path = os.path.join(os.path.dirname(__file__), 'resource', 'PaiementSecuriseService.wsdl')
wsdl_url = 'file://%s' % base_path
self.payfip = PayFiP(wsdl_url=wsdl_url, service_url=self.service_url)
super().__init__(*args, **kwargs)
self.payfip = PayFiP()
def _generate_refdet(self):
return '%s%010d' % (isonow(), random.randint(1, 1000000000))
def request(self, amount, email, refdet=None, exer=None, orderid=None,
subject=None, transaction_id=None, **kwargs):
def request(
self, amount, email, refdet=None, exer=None, orderid=None, subject=None, transaction_id=None, **kwargs
):
montant = self.clean_amount(amount, max_amount=100000)
numcli = self.numcli
@ -263,13 +282,19 @@ class Payment(PaymentCommon):
if saisie not in ('T', 'X', 'W'):
raise ValueError('SAISIE invalid format, %r, must be M, T, X or A' % saisie)
idop = self.payfip.get_idop(numcli=numcli, saisie=saisie, exer=exer,
refdet=refdet, montant=montant, mel=mel,
objet=objet or None,
url_notification=urlnotif,
url_redirect=urlredirect)
idop = self.payfip.get_idop(
numcli=numcli,
saisie=saisie,
exer=exer,
refdet=refdet,
montant=montant,
mel=mel,
objet=objet or None,
url_notification=urlnotif,
url_redirect=urlredirect,
)
return str(idop), URL, self.payment_url + '?idop=%s' % idop
return str(idop), URL, PAYMENT_URL + '?idop=%s' % idop
def payment_status(self, transaction_id, transaction_date=None, **kwargs):
# idop are valid for 15 minutes after their generation
@ -297,19 +322,19 @@ class Payment(PaymentCommon):
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 == '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)
return PaymentResponse(result=WAITING, signed=True, order_id=transaction_id)
raise e
return self.payfip_response_to_eopayment_response(idop, response)
eopayment_response = self.payfip_response_to_eopayment_response(idop, response)
# convert CANCELLED to WAITING during the first 20 minutes
if eopayment_response.result == CANCELLED and delta < threshold:
eopayment_response.result = WAITING
eopayment_response.bank_status = (
'%s - still waiting as idop is still active' % eopayment_response.bank_status
)
return eopayment_response
def response(self, query_string, **kwargs):
fields = parse_qs(query_string, True)
@ -353,7 +378,22 @@ class Payment(PaymentCommon):
bank_data={k: response[k] for k in response},
order_id=idop,
transaction_id=transaction_id,
test=response.saisie == 'T')
test=response.saisie == 'T',
)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = parse_qs(content)
if set(fields) == {'idOp'}:
return fields['idOp'][0]
return None
if __name__ == '__main__':
@ -366,6 +406,7 @@ if __name__ == '__main__':
return func(*args, **kwargs)
except PayFiPError as e:
click.echo(click.style('PayFiP ERROR : %s "%s"' % (e.code, e.message), fg='red'))
return f
@click.group()
@ -374,6 +415,7 @@ if __name__ == '__main__':
@click.pass_context
def main(ctx, wsdl_url, service_url):
import logging
logging.basicConfig(level=logging.INFO)
# hide warning from zeep
logging.getLogger('zeep.wsdl.bindings.soap').level = logging.ERROR
@ -381,7 +423,7 @@ if __name__ == '__main__':
ctx.obj = PayFiP(wsdl_url=wsdl_url, service_url=service_url)
def numcli(ctx, param, value):
if not isinstance(value, six.string_types) or len(value) != 6 or not value.isdigit():
if not isinstance(value, str) or len(value) != 6 or not value.isdigit():
raise click.BadParameter('numcli must a 6 digits number')
return value
@ -407,10 +449,17 @@ if __name__ == '__main__':
@click.pass_obj
@show_payfip_error
def get_idop(payfip, numcli, saisie, exer, montant, refdet, mel, objet, url_notification, url_redirect):
idop = payfip.get_idop(numcli=numcli, saisie=saisie, exer=exer,
montant=montant, refdet=refdet, mel=mel,
objet=objet, url_notification=url_notification,
url_redirect=url_redirect)
idop = payfip.get_idop(
numcli=numcli,
saisie=saisie,
exer=exer,
montant=montant,
refdet=refdet,
mel=mel,
objet=objet,
url_notification=url_notification,
url_redirect=url_redirect,
)
print('idOp:', idop)
print(PAYMENT_URL + '?idop=%s' % idop)
@ -422,6 +471,3 @@ if __name__ == '__main__':
print(payfip.get_info_paiement(idop))
main()

View File

@ -1,2 +0,0 @@
#!/bin/bash
echo -ne 0!!coin

View File

@ -134,7 +134,7 @@
</binding>
<service name="PaiementSecuriseService">
<port name="PaiementSecuriseServicePort" binding="tns:PaiementSecuriseServicePortBinding">
<soap:address location="http://www.tipi.budget.gouv.fr:80/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService"/>
<soap:address location="https://www.payfip.gouv.fr/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService"/>
</port>
</service>
</definitions>

View File

@ -11,6 +11,8 @@
<xs:element name="saisie" type="xs:string" minOccurs="0"/>
<xs:element name="urlnotif" type="xs:string" minOccurs="0"/>
<xs:element name="urlredirect" type="xs:string" minOccurs="0"/>
<xs:element name="typeAuthentification" type="xs:string" minOccurs="0"/>
<xs:element name="typeUsager" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
</xs:schema>

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -15,18 +14,25 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals, print_function
import functools
from six.moves.urllib.parse import urljoin, parse_qs
from urllib.parse import parse_qs, urljoin
import lxml.etree as ET
import zeep
import zeep.exceptions
from .common import (PaymentException, PaymentCommon, ResponseError, URL, PAID,
DENIED, CANCELLED, ERROR, PaymentResponse)
from .common import (
CANCELLED,
DENIED,
ERROR,
PAID,
URL,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
)
_zeep_transport = None
@ -35,7 +41,7 @@ class SagaError(PaymentException):
pass
class Saga(object):
class Saga:
def __init__(self, wsdl_url, service_url=None, zeep_client_kwargs=None):
self.wsdl_url = wsdl_url
kwargs = (zeep_client_kwargs or {}).copy()
@ -62,8 +68,17 @@ class Saga(object):
raise SagaError('Invalid SAGA response "%s"' % content[:1024])
return tree
def transaction(self, num_service, id_tiers, compte, lib_ecriture, montant,
urlretour_asynchrone, email, urlretour_synchrone):
def transaction(
self,
num_service,
id_tiers,
compte,
lib_ecriture,
montant,
urlretour_asynchrone,
email,
urlretour_synchrone,
):
tree = self.soap_call(
'Transaction',
'url',
@ -74,15 +89,13 @@ class Saga(object):
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
urlretour_synchrone=urlretour_synchrone)
urlretour_synchrone=urlretour_synchrone,
)
# tree == <url>...</url>
return tree.text
def page_retour(self, operation, idop):
tree = self.soap_call(
operation,
'ok',
idop=idop)
tree = self.soap_call(operation, 'ok', idop=idop)
# tree == <ok id_tiers="A1"
# etat="paye" email="albert,dupond@monsite.com" num_service="222222"
# montant="100.00" compte="708"
@ -127,13 +140,12 @@ class Payment(PaymentCommon):
'caption': 'URL de notification',
'required': False,
},
]
],
}
@property
def saga(self):
return Saga(
wsdl_url=urljoin(self.base_url, 'paiement_internet_ws_ministere?wsdl'))
return Saga(wsdl_url=urljoin(self.base_url, 'paiement_internet_ws_ministere?wsdl'))
def request(self, amount, email, subject, orderid=None, **kwargs):
num_service = self.num_service
@ -152,7 +164,8 @@ class Payment(PaymentCommon):
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
urlretour_synchrone=urlretour_synchrone)
urlretour_synchrone=urlretour_synchrone,
)
try:
idop = parse_qs(url.split('?', 1)[-1])['idop'][0]
@ -196,7 +209,8 @@ class Payment(PaymentCommon):
bank_data=dict(response),
order_id=idop,
transaction_id=idop,
test=False)
test=False,
)
if __name__ == '__main__':
@ -209,6 +223,7 @@ if __name__ == '__main__':
return func(*args, **kwargs)
except SagaError as e:
click.echo(click.style('SAGA ERROR : %s' % e, fg='red'))
return f
@click.group()
@ -217,6 +232,7 @@ if __name__ == '__main__':
@click.pass_context
def main(ctx, wsdl_url, service_url):
import logging
logging.basicConfig(level=logging.INFO)
# hide warning from zeep
logging.getLogger('zeep.wsdl.bindings.soap').level = logging.ERROR
@ -234,8 +250,17 @@ if __name__ == '__main__':
@click.option('--urlretour-synchrone', type=str, required=True)
@click.pass_obj
@show_payfip_error
def transaction(saga, num_service, id_tiers, compte, lib_ecriture, montant,
urlretour_asynchrone, email, urlretour_synchrone):
def transaction(
saga,
num_service,
id_tiers,
compte,
lib_ecriture,
montant,
urlretour_asynchrone,
email,
urlretour_synchrone,
):
url = saga.transaction(
num_service=num_service,
id_tiers=id_tiers,
@ -244,7 +269,8 @@ if __name__ == '__main__':
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
urlretour_synchrone=urlretour_synchrone)
urlretour_synchrone=urlretour_synchrone,
)
print('url:', url)
main()

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -15,29 +14,36 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
from gettext import gettext as _
import collections
import datetime
import hashlib
import hmac
import json
import uuid
import warnings
from six.moves.urllib import parse as urlparse
import requests
from urllib import parse as urlparse
import pytz
import requests
from .common import (PaymentCommon, FORM, Form, PaymentResponse, PAID, ERROR,
CANCELED, ResponseError, force_text)
from .common import (
CANCELED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_text,
)
__all__ = ['Payment']
class Payment(PaymentCommon):
'''
"""
Payment backend module for the ATOS/SIPS system used by many French banks.
The necessary options are:
@ -50,42 +56,44 @@ class Payment(PaymentCommon):
Worldline Benelux_Sips_Technical_integration_guide_Version_1.5.pdf
'''
"""
has_free_transaction_id = True
URL = {
'test': 'https://payment-webinit.simu.sips-atos.com/paymentInit',
'prod': 'https://payment-webinit.sips-atos.com/paymentInit',
'test': 'https://payment-webinit.simu.sips-services.com/paymentInit',
'prod': 'https://payment-webinit.sips-services.com/paymentInit',
}
WS_URL = {
'test': 'https://office-server.test.sips-atos.com',
'prod': 'https://office-server.sips-atos.com',
'test': 'https://office-server.test.sips-services.com',
'prod': 'https://office-server.sips-services.com',
}
INTERFACE_VERSION = 'HP_2.3'
RESPONSE_CODES = {
'00': 'Authorisation accepted',
'02': 'Authorisation request to be performed via telephone with the issuer, as the '
'card authorisation threshold has been exceeded, if the forcing is authorised for '
'the merchant',
'card authorisation threshold has been exceeded, if the forcing is authorised for '
'the merchant',
'03': 'Invalid distance selling contract',
'05': 'Authorisation refused',
'12': 'Invalid transaction, verify the parameters transferred in the request.',
'14': 'Invalid bank details or card security code',
'17': 'Buyer cancellation',
'24': 'Operation impossible. The operation the merchant wishes to perform is not '
'compatible with the status of the transaction.',
'compatible with the status of the transaction.',
'25': 'Transaction not found in the Sips database',
'30': 'Format error',
'34': 'Suspicion of fraud',
'40': 'Function not supported: the operation that the merchant would like to perform '
'is not part of the list of operations for which the merchant is authorised',
'is not part of the list of operations for which the merchant is authorised',
'51': 'Amount too high',
'54': 'Card is past expiry date',
'60': 'Transaction pending',
'63': 'Security rules not observed, transaction stopped',
'75': 'Number of attempts at entering the card number exceeded',
'90': 'Service temporarily unavailable',
'94': 'Duplicated transaction: for a given day, the TransactionReference has already been '
'used',
'94': 'Duplicated transaction: for a given day, the TransactionReference has already been ' 'used',
'97': 'Timeframe exceeded, transaction refused',
'99': 'Temporary problem at the Sips Office Server level',
}
@ -149,23 +157,18 @@ class Payment(PaymentCommon):
'caption': _('Capture Day'),
'required': False,
},
{
'name': 'payment_means',
'caption': _('Payment Means'),
'required': False
},
{'name': 'payment_means', 'caption': _('Payment Means'), 'required': False},
{
'name': 'timezone',
'caption': _('SIPS server Timezone'),
'default': 'Europe/Paris',
'required': False,
}
},
],
}
def encode_data(self, data):
return u'|'.join(u'%s=%s' % (force_text(key), force_text(value))
for key, value in data.items())
return '|'.join('%s=%s' % (force_text(key), force_text(value)) for key, value in data.items())
def seal_data(self, data):
s = self.encode_data(data)
@ -192,9 +195,23 @@ class Payment(PaymentCommon):
def get_url(self):
return self.URL[self.platform]
def request(self, amount, name=None, first_name=None, last_name=None,
address=None, email=None, phone=None, orderid=None,
info1=None, info2=None, info3=None, next_url=None, transaction_id=None, **kwargs):
def request(
self,
amount,
name=None,
first_name=None,
last_name=None,
address=None,
email=None,
phone=None,
orderid=None,
info1=None,
info2=None,
info3=None,
next_url=None,
transaction_id=None,
**kwargs,
):
data = self.get_data()
# documentation:
# https://documentation.sips.worldline.com/fr/WLSIPS.801-MG-Presentation-generale-de-la-migration-vers-Sips-2.0.html#ariaid-title20
@ -212,8 +229,10 @@ class Payment(PaymentCommon):
data['captureDay'] = kwargs.get('capture_day')
normal_return_url = self.normal_return_url
if next_url and not normal_return_url:
warnings.warn("passing next_url to request() is deprecated, "
"set normal_return_url in options", DeprecationWarning)
warnings.warn(
'passing next_url to request() is deprecated, ' 'set normal_return_url in options',
DeprecationWarning,
)
normal_return_url = next_url
if normal_return_url:
data['normalReturnUrl'] = normal_return_url
@ -221,11 +240,7 @@ class Payment(PaymentCommon):
url=self.get_url(),
method='POST',
fields=[
{
'type': 'hidden',
'name': 'Data',
'value': self.encode_data(data)
},
{'type': 'hidden', 'name': 'Data', 'value': self.encode_data(data)},
{
'type': 'hidden',
'name': 'Seal',
@ -236,11 +251,13 @@ class Payment(PaymentCommon):
'name': 'InterfaceVersion',
'value': self.INTERFACE_VERSION,
},
])
],
)
self.logger.debug('emitting request %r', data)
return transactionReference, FORM, form
def decode_data(self, data):
@classmethod
def decode_data(cls, data):
data = data.split('|')
data = [map(force_text, p.split('=', 1)) for p in data]
return collections.OrderedDict(data)
@ -255,7 +272,7 @@ class Payment(PaymentCommon):
def response(self, query_string, **kwargs):
form = urlparse.parse_qs(query_string)
if not set(form) >= set(['Data', 'Seal', 'InterfaceVersion']):
if not set(form) >= {'Data', 'Seal', 'InterfaceVersion'}:
raise ResponseError('missing Data, Seal or InterfaceVersion')
self.logger.debug('received query string %r', form)
data = self.decode_data(form['Data'][0])
@ -270,7 +287,9 @@ class Payment(PaymentCommon):
transaction_date = None
if 'transactionDateTime' in data:
try:
transaction_date = datetime.datetime.strptime(data['transactionDateTime'], '%Y-%m-%d %H:%M:%S')
transaction_date = datetime.datetime.strptime(
data['transactionDateTime'], '%Y-%m-%d %H:%M:%S'
)
except (ValueError, TypeError):
pass
else:
@ -282,9 +301,10 @@ class Payment(PaymentCommon):
bank_data=data,
order_id=transaction_id,
transaction_id=data.get('authorisationId'),
bank_status=self.RESPONSE_CODES.get(response_code, u'unknown code - ' + response_code),
bank_status=self.RESPONSE_CODES.get(response_code, 'unknown code - ' + response_code),
test=test,
transaction_date=transaction_date)
transaction_date=transaction_date,
)
def get_seal_for_json_ws_data(self, data):
data_to_send = []
@ -292,8 +312,10 @@ class Payment(PaymentCommon):
if key in ('keyVersion', 'sealAlgorithm', 'seal'):
continue
data_to_send.append(force_text(data[key]))
data_to_send_str = u''.join(data_to_send).encode('utf-8')
return hmac.new(force_text(self.secret_key).encode('utf-8'), data_to_send_str, hashlib.sha256).hexdigest()
data_to_send_str = ''.join(data_to_send).encode('utf-8')
return hmac.new(
force_text(self.secret_key).encode('utf-8'), data_to_send_str, hashlib.sha256
).hexdigest()
def perform_cash_management_operation(self, endpoint, data):
data['merchantId'] = self.merchant_id
@ -306,10 +328,8 @@ class Payment(PaymentCommon):
response = requests.post(
url,
data=json.dumps(data),
headers={
'content-type': 'application/json',
'accept': 'application/json'
})
headers={'content-type': 'application/json', 'accept': 'application/json'},
)
self.logger.debug('received %r', response.content)
response.raise_for_status()
json_response = response.json()
@ -348,7 +368,25 @@ class Payment(PaymentCommon):
headers={
'content-type': 'application/json',
'accept': 'application/json',
})
},
)
self.logger.debug('received %r', response.content)
response.raise_for_status()
return response.json()
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = urlparse.parse_qs(content)
if not set(fields) >= {'Data', 'Seal', 'InterfaceVersion'}:
continue
data = self.decode_data(fields['Data'][0])
if 'transactionReference' in data:
return data['transactionReference']
return None

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -16,21 +15,32 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import pytz
import datetime as dt
import hashlib
import hmac
import random
import re
import string
import six
from six.moves.urllib import parse as urlparse
import warnings
from gettext import gettext as _
from urllib import parse as urlparse
import pytz
from .common import (PaymentCommon, PaymentResponse, PAID, DENIED, CANCELLED,
ERROR, FORM, Form, ResponseError, force_text, force_byte)
from .cb import translate_cb_error_code
from .common import (
CANCELLED,
DENIED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
)
__all__ = ['Payment']
@ -58,8 +68,7 @@ VADS_EOPAYMENT_TRANS_ID = 'vads_ext_info_eopayment_trans_id'
def isonow():
return dt.datetime.utcnow().isoformat('T').replace('-', '') \
.replace('T', '').replace(':', '')[:14]
return dt.datetime.utcnow().isoformat('T').replace('-', '').replace('T', '').replace(':', '')[:14]
def parse_utc(value):
@ -71,9 +80,20 @@ def parse_utc(value):
class Parameter:
def __init__(self, name, ptype, code, max_length=None, length=None,
needed=False, default=None, choices=None, description=None,
help_text=None):
def __init__(
self,
name,
ptype,
code,
max_length=None,
length=None,
needed=False,
default=None,
choices=None,
description=None,
help_text=None,
scope='global',
):
self.name = name
self.ptype = ptype
self.code = code
@ -84,6 +104,7 @@ class Parameter:
self.choices = choices
self.description = description
self.help_text = help_text
self.scope = scope
def check_value(self, value):
if self.length and len(value) != self.length:
@ -108,8 +129,9 @@ class Parameter:
PARAMETERS = [
# amount as euro cents
Parameter('vads_action_mode', None, 47, needed=True, default='INTERACTIVE',
choices=('SILENT', 'INTERACTIVE')),
Parameter(
'vads_action_mode', None, 47, needed=True, default='INTERACTIVE', choices=('SILENT', 'INTERACTIVE')
),
Parameter('vads_amount', 'n', 9, max_length=12, needed=True),
Parameter('vads_capture_delay', 'n', 6, max_length=3, default=''),
# Same as 'vads_capture_delay' but matches other backend naming for
@ -128,44 +150,60 @@ PARAMETERS = [
Parameter('vads_cust_title', 'an', 17, max_length=63),
Parameter('vads_cust_city', 'an', 21, max_length=63),
Parameter('vads_cust_zip', 'an', 20, max_length=63),
Parameter('vads_ctx_mode', 'a', 11, needed=True, choices=('TEST',
'PRODUCTION'),
default='TEST'),
Parameter('vads_ctx_mode', 'a', 11, needed=True, choices=('TEST', 'PRODUCTION'), default='TEST'),
# ISO 639 code
Parameter('vads_language', 'a', 12, length=2, default='fr'),
Parameter('vads_order_id', 'an-', 13, max_length=32),
Parameter('vads_order_info', 'an', 14, max_length=255,
description=_(u"Complément d'information 1")),
Parameter('vads_order_info2', 'an', 14, max_length=255,
description=_(u"Complément d'information 2")),
Parameter('vads_order_info3', 'an', 14, max_length=255,
description=_(u"Complément d'information 3")),
Parameter('vads_page_action', None, 46, needed=True, default='PAYMENT',
choices=('PAYMENT',)),
Parameter('vads_payment_cards', 'an;', 8, max_length=127, default='',
description=_(u'Liste des cartes de paiement acceptées'),
help_text=_(u'vide ou des valeurs sépareés par un point-virgule '
'parmi AMEX, AURORE-MULTI, BUYSTER, CB, COFINOGA, '
'E-CARTEBLEUE, MASTERCARD, JCB, MAESTRO, ONEY, '
'ONEY_SANDBOX, PAYPAL, PAYPAL_SB, PAYSAFECARD, '
'VISA')),
Parameter(
'vads_order_info',
'an',
14,
max_length=255,
description="Complément d'information 1",
scope='transaction',
),
Parameter(
'vads_order_info2',
'an',
14,
max_length=255,
description="Complément d'information 2",
scope='transaction',
),
Parameter(
'vads_order_info3',
'an',
14,
max_length=255,
description="Complément d'information 3",
scope='transaction',
),
Parameter('vads_page_action', None, 46, needed=True, default='PAYMENT', choices=('PAYMENT',)),
Parameter(
'vads_payment_cards',
'an;',
8,
max_length=127,
default='',
description='Liste des cartes de paiement acceptées',
help_text='vide ou des valeurs sépareés par un point-virgule '
'parmi AMEX, AURORE-MULTI, BUYSTER, CB, COFINOGA, '
'E-CARTEBLEUE, MASTERCARD, JCB, MAESTRO, ONEY, '
'ONEY_SANDBOX, PAYPAL, PAYPAL_SB, PAYSAFECARD, '
'VISA',
scope='transaction',
),
# must be SINGLE or MULTI with parameters
Parameter('vads_payment_config', '', 7, default='SINGLE',
choices=('SINGLE', 'MULTI'), needed=True),
Parameter('vads_return_mode', None, 48, default='GET',
choices=('', 'NONE', 'POST', 'GET')),
Parameter('vads_payment_config', '', 7, default='SINGLE', choices=('SINGLE', 'MULTI'), needed=True),
Parameter('vads_return_mode', None, 48, default='GET', choices=('', 'NONE', 'POST', 'GET')),
Parameter('signature', 'an', None, length=40),
Parameter('vads_site_id', 'n', 2, length=8, needed=True,
description=_(u'Identifiant de la boutique')),
Parameter('vads_site_id', 'n', 2, length=8, needed=True, description='Identifiant de la boutique'),
Parameter('vads_theme_config', 'ans', 32, max_length=255),
Parameter(VADS_TRANS_DATE, 'n', 4, length=14, needed=True,
default=isonow),
Parameter(VADS_TRANS_DATE, 'n', 4, length=14, needed=True, default=isonow),
# https://paiement.systempay.fr/doc/fr-FR/form-payment/reference/vads-trans-id.html
Parameter('vads_trans_id', 'an', 3, length=6, needed=True),
Parameter('vads_validation_mode', 'n', 5, max_length=1, choices=('', '0', '1'),
default=''),
Parameter('vads_version', 'an', 1, default='V2', needed=True,
choices=('V2',)),
Parameter('vads_validation_mode', 'n', 5, max_length=1, choices=('', '0', '1'), default=''),
Parameter('vads_version', 'an', 1, default='V2', needed=True, choices=('V2',)),
Parameter('vads_url_success', 'ans', 24, max_length=1024),
Parameter('vads_url_referral', 'ans', 26, max_length=127),
Parameter('vads_url_refused', 'ans', 25, max_length=1024),
@ -177,8 +215,7 @@ PARAMETERS = [
Parameter(VADS_CUST_FIRST_NAME, 'ans', 104, max_length=63),
Parameter(VADS_CUST_LAST_NAME, 'ans', 104, max_length=63),
]
PARAMETER_MAP = dict(((parameter.name,
parameter) for parameter in PARAMETERS))
PARAMETER_MAP = {parameter.name: parameter for parameter in PARAMETERS}
def add_vads(kwargs):
@ -197,39 +234,40 @@ def check_vads(kwargs, exclude=[]):
if name not in kwargs and name not in exclude and parameter.needed:
raise ValueError('parameter %s must be defined' % name)
if name in kwargs and not parameter.check_value(kwargs[name]):
raise ValueError('parameter %s value %s is not of the type %s' % (
name, kwargs[name],
parameter.ptype))
raise ValueError(
'parameter %s value %s is not of the type %s' % (name, kwargs[name], parameter.ptype)
)
class Payment(PaymentCommon):
'''
Produce request for and verify response from the SystemPay payment
gateway.
"""
Produce request for and verify response from the SystemPay payment
gateway.
>>> gw =Payment(dict(secret_test='xxx', secret_production='yyyy',
site_id=123, ctx_mode='PRODUCTION'))
>>> print gw.request(100)
('20120525093304_188620',
'https://paiement.systempay.fr/vads-payment/?vads_url_return=http%3A%2F%2Furl.de.retour%2Fretour.php&vads_cust_country=FR&vads_site_id=&vads_payment_config=SINGLE&vads_trans_id=188620&vads_action_mode=INTERACTIVE&vads_contrib=eopayment&vads_page_action=PAYMENT&vads_trans_date=20120525093304&vads_ctx_mode=TEST&vads_validation_mode=&vads_version=V2&vads_payment_cards=&signature=5d412498ab523627ec5730a09118f75afa602af5&vads_language=fr&vads_capture_delay=&vads_currency=978&vads_amount=100&vads_return_mode=NONE',
{'vads_url_return': 'http://url.de.retour/retour.php',
'vads_cust_country': 'FR', 'vads_site_id': '',
'vads_payment_config': 'SINGLE', 'vads_trans_id': '188620',
'vads_action_mode': 'INTERACTIVE', 'vads_contrib': 'eopayment',
'vads_page_action': 'PAYMENT', 'vads_trans_date': '20120525093304',
'vads_ctx_mode': 'TEST', 'vads_validation_mode': '',
'vads_version': 'V2', 'vads_payment_cards': '', 'signature':
'5d412498ab523627ec5730a09118f75afa602af5', 'vads_language': 'fr',
'vads_capture_delay': '', 'vads_currency': '978', 'vads_amount': 100,
'vads_return_mode': 'NONE'})
>>> gw =Payment(dict(secret_test='xxx', secret_production='yyyy',
site_id=123, ctx_mode='PRODUCTION'))
>>> print gw.request(100)
('20120525093304_188620',
'https://paiement.systempay.fr/vads-payment/?vads_url_return=http%3A%2F%2Furl.de.retour%2Fretour.php&vads_cust_country=FR&vads_site_id=&vads_payment_config=SINGLE&vads_trans_id=188620&vads_action_mode=INTERACTIVE&vads_contrib=eopayment&vads_page_action=PAYMENT&vads_trans_date=20120525093304&vads_ctx_mode=TEST&vads_validation_mode=&vads_version=V2&vads_payment_cards=&signature=5d412498ab523627ec5730a09118f75afa602af5&vads_language=fr&vads_capture_delay=&vads_currency=978&vads_amount=100&vads_return_mode=NONE',
{'vads_url_return': 'http://url.de.retour/retour.php',
'vads_cust_country': 'FR', 'vads_site_id': '',
'vads_payment_config': 'SINGLE', 'vads_trans_id': '188620',
'vads_action_mode': 'INTERACTIVE', 'vads_contrib': 'eopayment',
'vads_page_action': 'PAYMENT', 'vads_trans_date': '20120525093304',
'vads_ctx_mode': 'TEST', 'vads_validation_mode': '',
'vads_version': 'V2', 'vads_payment_cards': '', 'signature':
'5d412498ab523627ec5730a09118f75afa602af5', 'vads_language': 'fr',
'vads_capture_delay': '', 'vads_currency': '978', 'vads_amount': 100,
'vads_return_mode': 'NONE'})
"""
'''
has_free_transaction_id = True
service_url = "https://paiement.systempay.fr/vads-payment/"
service_url = 'https://paiement.systempay.fr/vads-payment/'
signature_algo = 'sha1'
description = {
'caption': 'SystemPay, système de paiment du groupe BPCE',
'caption': 'SystemPay, système de paiement du groupe BPCE',
'parameters': [
{
'name': 'normal_return_url',
@ -242,57 +280,76 @@ class Payment(PaymentCommon):
'caption': _('Automatic return URL (ignored, must be set in Payzen/SystemPay backoffice)'),
'required': False,
},
{'name': 'service_url',
{
'name': 'service_url',
'default': service_url,
'caption': _(u'URL du service de paiment'),
'help_text': _(u'ne pas modifier si vous ne savez pas'),
'caption': 'URL du service de paiement',
'help_text': 'ne pas modifier si vous ne savez pas',
'validation': lambda x: x.startswith('http'),
'required': True, },
{'name': 'secret_test',
'caption': _(u'Secret pour la configuration de TEST'),
'required': True,
},
{
'name': 'secret_test',
'caption': 'Secret pour la configuration de TEST',
'validation': lambda value: str.isalnum(value),
'required': True, },
{'name': 'secret_production',
'caption': _(u'Secret pour la configuration de PRODUCTION'),
'validation': lambda value: str.isalnum(value), },
{'name': 'signature_algo',
'caption': _(u'Algorithme de signature'),
'required': True,
},
{
'name': 'secret_production',
'caption': 'Secret pour la configuration de PRODUCTION',
'validation': lambda value: str.isalnum(value),
},
{
'name': 'signature_algo',
'caption': 'Algorithme de signature',
'default': 'sha1',
'choices': (
('sha1', 'SHA-1'),
('hmac_sha256', 'HMAC-SHA-256'),
)},
),
},
{
'name': 'manual_validation',
'caption': 'Validation manuelle',
'type': bool,
'default': False,
'scope': 'transaction'
}
]
'scope': 'transaction',
},
],
}
for name in ('vads_ctx_mode', VADS_SITE_ID, 'vads_order_info',
'vads_order_info2', 'vads_order_info3',
'vads_payment_cards', 'vads_payment_config', 'capture_day'):
for name in (
'vads_ctx_mode',
VADS_SITE_ID,
'vads_order_info',
'vads_order_info2',
'vads_order_info3',
'vads_payment_cards',
'vads_payment_config',
'capture_day',
):
parameter = PARAMETER_MAP[name]
def check_value(parameter):
def validate(value):
return parameter.check_value(value)
return validate
x = {'name': name,
'caption': parameter.description or name,
'validation': check_value(parameter),
'default': parameter.default,
'required': parameter.needed,
'help_text': parameter.help_text,
'max_length': parameter.max_length}
x = {
'name': name,
'caption': parameter.description or name,
'validation': check_value(parameter),
'default': parameter.default,
'required': parameter.needed,
'help_text': parameter.help_text,
'max_length': parameter.max_length,
'scope': parameter.scope,
}
description['parameters'].append(x)
def __init__(self, options, logger=None):
super(Payment, self).__init__(options, logger=logger)
super().__init__(options, logger=logger)
options = add_vads(options)
self.options = options
@ -301,28 +358,48 @@ class Payment(PaymentCommon):
# trans_id starting with 9 are reserved for the systempay backoffice
# https://paiement.systempay.fr/doc/fr-FR/form-payment/reference/vads-trans-id.html
gen = random.SystemRandom()
if six.PY3:
alphabet = string.ascii_letters + string.digits
else:
alphabet = string.letters + string.digits
alphabet = string.ascii_letters + string.digits
first_letter_alphabet = alphabet.replace('9', '')
vads_trans_id = (
gen.choice(first_letter_alphabet)
+ ''.join(gen.choice(alphabet) for i in range(5))
)
vads_trans_id = gen.choice(first_letter_alphabet) + ''.join(gen.choice(alphabet) for i in range(5))
return vads_trans_id
def request(self, amount, name=None, first_name=None, last_name=None,
address=None, email=None, phone=None, orderid=None, info1=None,
info2=None, info3=None, next_url=None, manual_validation=None,
transaction_id=None, **kwargs):
'''
Create the URL string to send a request to SystemPay
'''
self.logger.debug('%s amount %s name %s address %s email %s phone %s '
'next_url %s info1 %s info2 %s info3 %s kwargs: %s',
__name__, amount, name, address, email, phone, info1,
info2, info3, next_url, kwargs)
def request(
self,
amount,
name=None,
first_name=None,
last_name=None,
address=None,
email=None,
phone=None,
orderid=None,
info1=None,
info2=None,
info3=None,
next_url=None,
manual_validation=None,
transaction_id=None,
**kwargs,
):
"""
Create the URL string to send a request to SystemPay
"""
self.logger.debug(
'%s amount %s name %s address %s email %s phone %s '
'next_url %s info1 %s info2 %s info3 %s kwargs: %s',
__name__,
amount,
name,
address,
email,
phone,
info1,
info2,
info3,
next_url,
kwargs,
)
# amount unit is cents
amount = '%.0f' % (100 * amount)
kwargs.update(add_vads({'amount': force_text(amount)}))
@ -330,8 +407,10 @@ class Payment(PaymentCommon):
raise ValueError('amount must be an integer >= 0')
normal_return_url = self.normal_return_url
if next_url:
warnings.warn("passing next_url to request() is deprecated, "
"set normal_return_url in options", DeprecationWarning)
warnings.warn(
'passing next_url to request() is deprecated, ' 'set normal_return_url in options',
DeprecationWarning,
)
normal_return_url = next_url
if normal_return_url:
kwargs[VADS_URL_RETURN] = force_text(normal_return_url)
@ -361,8 +440,7 @@ class Payment(PaymentCommon):
ptype = 'an-'
p = Parameter(name, ptype, 13, max_length=32)
if not p.check_value(orderid):
raise ValueError(
'%s value %s is not of the type %s' % (name, orderid, ptype))
raise ValueError('%s value %s is not of the type %s' % (name, orderid, ptype))
kwargs[name] = orderid
vads_trans_id = self.make_vads_trans_id()
@ -373,8 +451,7 @@ class Payment(PaymentCommon):
for parameter in PARAMETERS:
name = parameter.name
# import default parameters from configuration
if name not in fields \
and name in self.options:
if name not in fields and name in self.options:
fields[name] = force_text(self.options[name])
# import default parameters from module
if name not in fields and parameter.default is not None:
@ -400,11 +477,13 @@ class Payment(PaymentCommon):
method='POST',
fields=[
{
'type': u'hidden',
'type': 'hidden',
'name': force_text(field_name),
'value': force_text(field_value),
}
for field_name, field_value in fields.items()])
for field_name, field_value in fields.items()
],
)
return transaction_id, FORM, form
RESULT_MAP = {
@ -421,9 +500,11 @@ class Payment(PaymentCommon):
'00': {'message': 'Tous les contrôles se sont déroulés avec succés.'},
'02': {'message': 'La carte a dépassé l\'encours autorisé.'},
'03': {'message': 'La carte appartient à la liste grise du commerçant.'},
'04': {'messaǵe': 'Le pays d\'émission de la carte appartient à la liste grise du '
'commerçant ou le pays d\'émission de la carte n\'appartient pas à la '
'liste blanche du commerçant.'},
'04': {
'messaǵe': 'Le pays d\'émission de la carte appartient à la liste grise du '
'commerçant ou le pays d\'émission de la carte n\'appartient pas à la '
'liste blanche du commerçant.'
},
'05': {'message': 'Ladresse IP appartient à la liste grise du marchand.'},
'06': {'message': 'Le code bin appartient à la liste grise du marchand.'},
'07': {'message': 'Détection dune e-carte bleue.'},
@ -431,8 +512,10 @@ class Payment(PaymentCommon):
'09': {'message': 'Détection dune carte commerciale étrangère.'},
'14': {'message': 'Détection dune carte à autorisation systématique.'},
'30': {'message': 'Le pays de ladresse IP appartient à la liste grise.'},
'99': {'message': 'Problème technique recontré par le serveur lors du traitement '
'd\'un des contrôles locauxi.'},
'99': {
'message': 'Problème technique recontré par le serveur lors du traitement '
'd\'un des contrôles locauxi.'
},
}
@classmethod
@ -475,9 +558,8 @@ class Payment(PaymentCommon):
def response(self, query_string, **kwargs):
fields = urlparse.parse_qs(query_string, True)
if not set(fields) >= set([SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT]):
raise ResponseError('missing %s, %s or %s' % (SIGNATURE, VADS_CTX_MODE,
VADS_AUTH_RESULT))
if not set(fields) >= {SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT}:
raise ResponseError('missing %s, %s or %s' % (SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT))
for key, value in fields.items():
fields[key] = value[0]
copy = fields.copy()
@ -486,17 +568,21 @@ class Payment(PaymentCommon):
self.logger.debug('checking systempay response on: %r', copy)
signature_result = signature == fields[SIGNATURE]
if not signature_result:
self.logger.debug('signature check: %s <!> %s', signature,
fields[SIGNATURE])
self.logger.debug('signature check: %s <!> %s', signature, fields[SIGNATURE])
if not signature_result:
message += ' signature invalide.'
test = fields[VADS_CTX_MODE] == 'TEST'
if VADS_EOPAYMENT_TRANS_ID in fields:
transaction_id = fields[VADS_EOPAYMENT_TRANS_ID]
vads_eopayment_trans_id = fields.get(VADS_EOPAYMENT_TRANS_ID)
vads_trans_date = fields.get(VADS_TRANS_DATE)
vads_trans_id = fields.get(VADS_TRANS_ID)
if vads_eopayment_trans_id:
transaction_id = vads_eopayment_trans_id
elif vads_trans_date and vads_trans_id:
transaction_id = vads_trans_date + '_' + vads_trans_id
else:
transaction_id = '%s_%s' % (copy[VADS_TRANS_DATE], copy[VADS_TRANS_ID])
raise ResponseError('backend error', message)
# the VADS_AUTH_NUMBER is the number to match payment in bank logs
copy[self.BANK_ID] = copy.get(VADS_AUTH_NUMBER, '')
transaction_date = None
@ -510,7 +596,8 @@ class Payment(PaymentCommon):
transaction_id=transaction_id,
bank_status=message,
transaction_date=transaction_date,
test=test)
test=test,
)
return response
def sha1_sign(self, secret, signed_data):
@ -522,15 +609,35 @@ class Payment(PaymentCommon):
def signature(self, fields):
self.logger.debug('got fields %s to sign' % fields)
ordered_keys = sorted(
[key for key in fields.keys() if key.startswith('vads_')])
ordered_keys = sorted(key for key in fields.keys() if key.startswith('vads_'))
self.logger.debug('ordered keys %s' % ordered_keys)
ordered_fields = [force_byte(fields[key]) for key in ordered_keys]
secret = force_byte(getattr(self, 'secret_%s' % fields['vads_ctx_mode'].lower()))
signed_data = b'+'.join(ordered_fields)
signed_data = b'%s+%s' % (signed_data, secret)
self.logger.debug(u'generating signature on «%s»', signed_data)
self.logger.debug('generating signature on «%s»', signed_data)
sign_method = getattr(self, '%s_sign' % self.signature_algo)
sign = sign_method(secret, signed_data)
self.logger.debug(u'signature «%s»', sign)
self.logger.debug('signature «%s»', sign)
return force_text(sign)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = urlparse.parse_qs(content)
if not set(fields) >= {SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT}:
continue
vads_eopayment_trans_id = fields.get(VADS_EOPAYMENT_TRANS_ID)
vads_trans_date = fields.get(VADS_TRANS_DATE)
vads_trans_id = fields.get(VADS_TRANS_ID)
if vads_eopayment_trans_id:
return vads_eopayment_trans_id[0]
elif vads_trans_date and vads_trans_id:
return vads_trans_date[0] + '_' + vads_trans_id[0]
return None

View File

@ -15,49 +15,39 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import re
import decimal
import logging
import random
import re
import warnings
from urllib.parse import parse_qs, urlencode
import pytz
from .common import (PaymentCommon, PaymentResponse, URL, PAID, DENIED,
CANCELLED, ERROR, ResponseError)
from six.moves.urllib.parse import urlencode, parse_qs
from gettext import gettext as _
import logging
import warnings
from .common import CANCELLED, DENIED, ERROR, PAID, URL, PaymentCommon, PaymentResponse, ResponseError, _
__all__ = ['Payment']
TIPI_URL = 'https://www.tipi.budget.gouv.fr/tpa/paiement.web'
TIPI_URL = 'https://www.payfip.gouv.fr/tpa/paiement.web'
LOGGER = logging.getLogger(__name__)
class Payment(PaymentCommon):
'''Produce requests for and verify response from the TIPI online payment
"""Produce requests for and verify response from the TIPI online payment
processor from the French Finance Ministry.
'''
"""
description = {
'caption': 'TIPI, Titres Payables par Internet',
'parameters': [
{
'name': 'numcli',
'caption': _(u'Client number'),
'help_text': _(u'6 digits number provided by DGFIP'),
'caption': _('Client number'),
'help_text': _('6 digits number provided by DGFIP'),
'validation': lambda s: str.isdigit(s) and (0 < int(s) < 1000000),
'required': True,
},
{
'name': 'service_url',
'default': TIPI_URL,
'caption': _(u'TIPI service URL'),
'help_text': _(u'do not modify if you do not know'),
'validation': lambda x: x.startswith('http'),
'required': True,
},
{
'name': 'normal_return_url',
'caption': _('Normal return URL (unused by TIPI)'),
@ -75,9 +65,8 @@ class Payment(PaymentCommon):
'default': 'T',
'choices': [
('T', _('test')),
('X', _('production')),
('A', _('with user account')),
('M', _('manual entry')),
('X', _('activation')),
('A', _('production')),
],
},
],
@ -85,22 +74,38 @@ class Payment(PaymentCommon):
REFDET_RE = re.compile('^[a-zA-Z0-9]{6,30}$')
def _generate_refdet(self):
return '%s%010d' % (datetime.datetime.now(pytz.timezone('Europe/Paris')).strftime('%Y%m%d%H%M%S'),
random.randint(1, 1000000000))
minimal_amount = decimal.Decimal('1.0')
maximal_amount = decimal.Decimal('100000.0')
def request(self, amount, email, next_url=None, exer=None, orderid=None,
refdet=None, objet=None, saisie=None, **kwargs):
def _generate_refdet(self):
return '%s%010d' % (
datetime.datetime.now(pytz.timezone('Europe/Paris')).strftime('%Y%m%d%H%M%S'),
random.randint(1, 1000000000),
)
def request(
self,
amount,
email,
next_url=None,
exer=None,
orderid=None,
refdet=None,
objet=None,
saisie=None,
**kwargs,
):
montant = self.clean_amount(amount, max_amount=9999.99)
automatic_return_url = self.automatic_return_url
if next_url and not automatic_return_url:
warnings.warn("passing next_url to request() is deprecated, "
"set automatic_return_url in options", DeprecationWarning)
warnings.warn(
'passing next_url to request() is deprecated, ' 'set automatic_return_url in options',
DeprecationWarning,
)
automatic_return_url = next_url
if automatic_return_url is not None:
if (not isinstance(automatic_return_url, str)
or not automatic_return_url.startswith('http')):
if not isinstance(automatic_return_url, str) or not automatic_return_url.startswith('http'):
raise ValueError('URLCL invalid URL format')
try:
if exer is not None:
@ -158,12 +163,12 @@ class Payment(PaymentCommon):
params['objet'] = objet
if automatic_return_url:
params['urlcl'] = automatic_return_url
url = '%s?%s' % (self.service_url, urlencode(params))
url = '%s?%s' % (TIPI_URL, urlencode(params))
return transaction_id, URL, url
def response(self, query_string, **kwargs):
fields = parse_qs(query_string, True)
if not set(fields) >= set(['refdet', 'resultrans']):
if not set(fields) >= {'refdet', 'resultrans'}:
raise ResponseError('missing refdet or resultrans')
for key, value in fields.items():
fields[key] = value[0]
@ -194,4 +199,19 @@ class Payment(PaymentCommon):
bank_data=fields,
order_id=refdet,
transaction_id=refdet,
test=test)
test=test,
)
@classmethod
def guess(self, *, method=None, query_string=None, body=None, headers=None, backends=(), **kwargs):
for content in [query_string, body]:
if isinstance(content, bytes):
try:
content = content.decode()
except UnicodeDecodeError:
pass
if isinstance(content, str):
fields = parse_qs(content)
if 'refdet' in fields and 'resultrans' in fields:
return fields['refdet'][0]
return None

117
setup.py
View File

@ -4,17 +4,24 @@
Setup script for eopayment
'''
import io
import subprocess
import distutils
import distutils.core
import setuptools
from setuptools.command.sdist import sdist
from glob import glob
from os.path import splitext, basename, join as pjoin
import os
from unittest import TextTestRunner, TestLoader
import doctest
import io
import os
import subprocess
import sys
from distutils.cmd import Command
from distutils.command.build import build as _build
from glob import glob
from os.path import basename
from os.path import join as pjoin
from os.path import splitext
from unittest import TestLoader, TextTestRunner
import setuptools
from setuptools.command.install_lib import install_lib as _install_lib
from setuptools.command.sdist import sdist
class TestCommand(distutils.core.Command):
@ -27,27 +34,25 @@ class TestCommand(distutils.core.Command):
pass
def run(self):
'''
"""
Finds all the tests modules in tests/, and runs them.
'''
"""
testfiles = []
for t in glob(pjoin(self._dir, 'tests', '*.py')):
if not t.endswith('__init__.py'):
testfiles.append('.'.join(
['tests', splitext(basename(t))[0]])
)
testfiles.append('.'.join(['tests', splitext(basename(t))[0]]))
tests = TestLoader().loadTestsFromNames(testfiles)
import eopayment
tests.addTests(doctest.DocTestSuite(eopayment))
t = TextTestRunner(verbosity=4)
t.run(tests)
class eo_sdist(sdist):
def run(self):
print("creating VERSION file")
print('creating VERSION file')
if os.path.exists('VERSION'):
os.remove('VERSION')
version = get_version()
@ -55,22 +60,22 @@ class eo_sdist(sdist):
version_file.write(version)
version_file.close()
sdist.run(self)
print("removing VERSION file")
print('removing VERSION file')
if os.path.exists('VERSION'):
os.remove('VERSION')
def get_version():
'''Use the VERSION, if absent generates a version with git describe, if not
tag exists, take 0.0.0- and add the length of the commit log.
'''
"""Use the VERSION, if absent generates a version with git describe, if not
tag exists, take 0.0.0- and add the length of the commit log.
"""
if os.path.exists('VERSION'):
with open('VERSION', 'r') as v:
with open('VERSION') as v:
return v.read()
if os.path.exists('.git'):
p = subprocess.Popen(['git', 'describe', '--dirty',
'--match=v*'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
p = subprocess.Popen(
['git', 'describe', '--dirty', '--match=v*'], stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
result = p.communicate()[0]
if p.returncode == 0:
result = result.decode('ascii').strip()[1:] # strip spaces/newlines and initial v
@ -82,32 +87,63 @@ def get_version():
commit_count = 0
version = '%s.post%s+%s' % (real_number, commit_count, commit_hash)
else:
version = result
version = result.replace('.dirty', '+dirty')
return version
else:
return '0.0.post%s' % len(
subprocess.check_output(
['git', 'rev-list', 'HEAD']).splitlines())
return '0.0.post%s' % len(subprocess.check_output(['git', 'rev-list', 'HEAD']).splitlines())
return '0.0.0'
class compile_translations(Command):
description = 'compile message catalogs to MO files via django compilemessages'
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def run(self):
curdir = os.getcwd()
try:
from django.core.management import call_command
for path, dirs, files in os.walk('eopayment'):
if 'locale' not in dirs:
continue
os.chdir(os.path.realpath(path))
call_command('compilemessages')
except ImportError:
sys.stderr.write('!!! Please install Django >= 3.2 to build translations\n')
finally:
os.chdir(curdir)
class build(_build):
sub_commands = [('compile_translations', None)] + _build.sub_commands
class install_lib(_install_lib):
def run(self):
self.run_command('compile_translations')
_install_lib.run(self)
setuptools.setup(
name='eopayment',
version=get_version(),
license='GPLv3 or later',
description='Common API to use all French online payment credit card '
'processing services',
description='Common API to use all French online payment credit card ' 'processing services',
include_package_data=True,
long_description=io.open(
os.path.join(
os.path.dirname(__file__),
'README.txt'), encoding='utf-8').read(),
long_description=open(os.path.join(os.path.dirname(__file__), 'README.txt'), encoding='utf-8').read(),
long_description_content_type='text/plain',
url='http://dev.entrouvert.org/projects/eopayment/',
author="Entr'ouvert",
author_email="info@entrouvert.com",
maintainer="Benjamin Dauvergne",
maintainer_email="bdauvergne@entrouvert.com",
author_email='info@entrouvert.com',
maintainer='Benjamin Dauvergne',
maintainer_email='bdauvergne@entrouvert.com',
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
@ -115,19 +151,20 @@ setuptools.setup(
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Operating System :: POSIX',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 3',
],
packages=['eopayment'],
install_requires=[
'pycrypto >= 2.5',
'pycryptodomex',
'pytz',
'requests',
'six',
'click',
'zeep >= 2.5',
],
cmdclass={
'build': build,
'compile_translations': compile_translations,
'install_lib': install_lib,
'sdist': eo_sdist,
}
},
)

View File

@ -16,27 +16,25 @@
import json
import pytest
import httmock
import lxml.etree as ET
from requests.adapters import HTTPAdapter
import pytest
from requests import Session
from requests.adapters import HTTPAdapter
def pytest_addoption(parser):
parser.addoption("--save-http-session", action="store_true", help="save HTTP session")
parser.addoption("--target-url", help="target URL")
parser.addoption('--save-http-session', action='store_true', help='save HTTP session')
parser.addoption('--target-url', help='target URL')
class LoggingAdapter(HTTPAdapter):
def __init__(self, *args, **kwargs):
self.history = []
super(LoggingAdapter, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
def send(self, request, *args, **kwargs):
response = super(LoggingAdapter, self).send(request, *args, **kwargs)
response = super().send(request, *args, **kwargs)
self.history.append((request, response))
return response
@ -71,8 +69,8 @@ def record_http_session(request):
history = []
for request, response in adapter.history:
request_content = (request.body or b'')
response_content = (response.content or b'')
request_content = request.body or b''
response_content = response.content or b''
if is_xml_content_type(request):
request_content = xmlindent(request_content)
@ -103,6 +101,7 @@ def record_http_session(request):
request_content = request_content.decode('utf-8')
assert request_content == expected_request_content
return response_content
with httmock.HTTMock(Mocker().mock):
yield None

View File

@ -15,8 +15,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import date, datetime, timedelta
from unittest import mock
import mock
import pytest
import eopayment
@ -37,22 +37,22 @@ def do_mock_backend(monkeypatch):
'caption': 'Validation manuelle',
'type': bool,
'default': False,
'scope': 'transaction'
'scope': 'transaction',
},
{
'name': 'global_param',
'caption': 'Global Param',
'type': bool,
'default': False,
'scope': 'global'
'scope': 'global',
},
]
}
def get_backend(*args, **kwargs):
def backend(*args, **kwargs):
return MockBackend
return backend
monkeypatch.setattr(eopayment, 'get_backend', get_backend)
@ -62,14 +62,13 @@ def do_mock_backend(monkeypatch):
def test_deferred_payment(monkeypatch):
mock_backend, payment = do_mock_backend(monkeypatch)
capture_date = (datetime.now().date() + timedelta(days=3))
capture_date = datetime.now().date() + timedelta(days=3)
payment.request(amount=12.2, capture_date=capture_date)
mock_backend.request.assert_called_with(12.2, **{'capture_day': u'3'})
mock_backend.request.assert_called_with(12.2, **{'capture_day': '3'})
# capture date can't be inferior to the transaction date
capture_date = (datetime.now().date() - timedelta(days=3))
with pytest.raises(
ValueError, match='capture_date needs to be superior to the transaction date.'):
capture_date = datetime.now().date() - timedelta(days=3)
with pytest.raises(ValueError, match='capture_date needs to be superior to the transaction date.'):
payment.request(amount=12.2, capture_date=capture_date)
# capture date should be a date object
@ -78,7 +77,7 @@ def test_deferred_payment(monkeypatch):
payment.request(amount=12.2, capture_date=capture_date)
# using capture date on a backend that does not support it raise an error
capture_date = (datetime.now().date() + timedelta(days=3))
capture_date = datetime.now().date() + timedelta(days=3)
mock_backend.description['parameters'] = []
with pytest.raises(ValueError, match='capture_date is not supported by the backend.'):
payment.request(amount=12.2, capture_date=capture_date)
@ -89,8 +88,7 @@ def test_paris_timezone(freezer, monkeypatch):
_, payment = do_mock_backend(monkeypatch)
capture_date = date(year=2018, month=10, day=3)
with pytest.raises(
ValueError, match='capture_date needs to be superior to the transaction date'):
with pytest.raises(ValueError, match='capture_date needs to be superior to the transaction date'):
# utcnow will return 2018-10-02 23:50:00,
# converted to Europe/Paris it is already 2018-10-03
# so 2018-10-03 for capture_date is invalid
@ -112,6 +110,19 @@ def test_get_parameters(monkeypatch):
def test_payment_status(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert not payment.has_payment_status
with pytest.raises(NotImplementedError):
payment.payment_status('whatever')
def test_get_min_time_between_transaction(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert payment.get_min_time_between_transactions() == 0
def test_get_minimal_amount(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert payment.get_minimal_amount() is None
def test_get_maximal_amount(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert payment.get_maximal_amount() is None

View File

@ -14,18 +14,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from six.moves.urllib.parse import urlparse, parse_qs
import datetime
from urllib.parse import parse_qs, urlparse
import pytest
import eopayment
import pytest
@pytest.fixture
def backend():
options = {
'automatic_notification_url': 'http://example.com/direct_notification_url',
'origin': 'Mairie de Perpette-les-oies'
'origin': 'Mairie de Perpette-les-oies',
}
return eopayment.Payment('dummy', options)
@ -33,12 +34,13 @@ def backend():
def test_request(backend, freezer):
freezer.move_to('2020-01-01 00:00:00+01:00')
transaction_id, method, raw_url = backend.request(
'10.10', capture_date=datetime.date(2020, 1, 7), subject='Repas pour 4 personnes')
'10.10', capture_date=datetime.date(2020, 1, 7), subject='Repas pour 4 personnes'
)
assert transaction_id
assert method == 1
url = urlparse(raw_url)
assert url.scheme == 'http'
assert url.netloc == 'dummy-payment.demo.entrouvert.com'
assert url.scheme == 'https'
assert url.netloc == 'dummy-payment.entrouvert.com'
assert url.path == '/'
assert url.fragment == ''
qs = {k: v[0] for k, v in parse_qs(url.query).items()}

View File

@ -18,85 +18,78 @@ import json
import pytest
import requests
import six
from httmock import response, urlmatch, HTTMock, with_httmock, all_requests, remember_called
from httmock import HTTMock, all_requests, remember_called, response, urlmatch, with_httmock
import eopayment
from eopayment.keyware import Payment
pytestmark = pytest.mark.skipif(six.PY2, reason='this payment module only supports python3')
WEBHOOK_URL = 'https://callback.example.com'
RETURN_URL = 'https://return.example.com'
API_KEY = 'test'
ORDER_ID = "1c969951-f5f1-4290-ae41-6177961fb3cb"
ORDER_ID = '1c969951-f5f1-4290-ae41-6177961fb3cb'
QUERY_STRING = 'order_id=' + ORDER_ID
POST_ORDER_RESPONSE = {
"amount": 995,
"client": {
"user_agent": "Testing API"
},
"created": "2016-07-04T11:41:57.121017+00:00",
"currency": "EUR",
"description": "Example description",
"id": ORDER_ID,
"merchant_id": "7131b462-1b7d-489f-aba9-de2f0eadc9dc",
"modified": "2016-07-04T11:41:57.183822+00:00",
"order_url": "https://api.online.emspay.eu/pay/1c969951-f5f1-4290-ae41-6177961fb3cb/",
"project_id": "1ef558ed-d77d-470d-b43b-c0f4a131bcef",
"status": "new"
'amount': 995,
'client': {'user_agent': 'Testing API'},
'created': '2016-07-04T11:41:57.121017+00:00',
'currency': 'EUR',
'description': 'Example description',
'id': ORDER_ID,
'merchant_id': '7131b462-1b7d-489f-aba9-de2f0eadc9dc',
'modified': '2016-07-04T11:41:57.183822+00:00',
'order_url': 'https://api.online.emspay.eu/pay/1c969951-f5f1-4290-ae41-6177961fb3cb/',
'project_id': '1ef558ed-d77d-470d-b43b-c0f4a131bcef',
'status': 'new',
}
GET_ORDER_RESPONSE = {
"amount": 995,
"client": {
"user_agent": "Testing API"
},
"created": "2016-07-04T11:41:55.635115+00:00",
"currency": "EUR",
"description": "Example order #1",
"id": ORDER_ID,
"last_transaction_added": "2016-07-04T11:41:55.831655+00:00",
"merchant_id": "7131b462-1b7d-489f-aba9-de2f0eadc9dc",
"merchant_order_id": "EXAMPLE001",
"modified": "2016-07-04T11:41:56.215543+00:00",
"project_id": "1ef558ed-d77d-470d-b43b-c0f4a131bcef",
"return_url": "http://www.example.com/",
"status": "completed",
"transactions": [
'amount': 995,
'client': {'user_agent': 'Testing API'},
'created': '2016-07-04T11:41:55.635115+00:00',
'currency': 'EUR',
'description': 'Example order #1',
'id': ORDER_ID,
'last_transaction_added': '2016-07-04T11:41:55.831655+00:00',
'merchant_id': '7131b462-1b7d-489f-aba9-de2f0eadc9dc',
'merchant_order_id': 'EXAMPLE001',
'modified': '2016-07-04T11:41:56.215543+00:00',
'project_id': '1ef558ed-d77d-470d-b43b-c0f4a131bcef',
'return_url': 'http://www.example.com/',
'status': 'completed',
'transactions': [
{
"amount": 995,
"balance": "internal",
"created": "2016-07-04T11:41:55.831655+00:00",
"credit_debit": "credit",
"currency": "EUR",
"description": "Example order #1",
"events": [
'amount': 995,
'balance': 'internal',
'created': '2016-07-04T11:41:55.831655+00:00',
'credit_debit': 'credit',
'currency': 'EUR',
'description': 'Example order #1',
'events': [
{
"event": "new",
"id": "0c4bd0cd-f197-446b-b218-39cbeb028290",
"noticed": "2016-07-04T11:41:55.987468+00:00",
"occurred": "2016-07-04T11:41:55.831655+00:00",
"source": "set_status"
'event': 'new',
'id': '0c4bd0cd-f197-446b-b218-39cbeb028290',
'noticed': '2016-07-04T11:41:55.987468+00:00',
'occurred': '2016-07-04T11:41:55.831655+00:00',
'source': 'set_status',
}
],
"expiration_period": "PT60M",
"id": "6c81499c-14e4-4974-99e5-fe72ce019411",
"merchant_id": "7131b462-1b7d-489f-aba9-de2f0eadc9dc",
"modified": "2016-07-04T11:41:56.065147+00:00",
"order_id": ORDER_ID,
"payment_method": "ideal",
"payment_method_details": {
"issuer_id": "INGBNL2A",
'expiration_period': 'PT60M',
'id': '6c81499c-14e4-4974-99e5-fe72ce019411',
'merchant_id': '7131b462-1b7d-489f-aba9-de2f0eadc9dc',
'modified': '2016-07-04T11:41:56.065147+00:00',
'order_id': ORDER_ID,
'payment_method': 'ideal',
'payment_method_details': {
'issuer_id': 'INGBNL2A',
},
"payment_url": "https://api.online.emspay.eu/redirect/6c81499c-14e4-4974-99e5-fe72ce019411/to/payment/",
"project_id": "1ef558ed-d77d-470d-b43b-c0f4a131bcef",
"status": "completed"
'payment_url': 'https://api.online.emspay.eu/redirect/6c81499c-14e4-4974-99e5-fe72ce019411/to/payment/',
'project_id': '1ef558ed-d77d-470d-b43b-c0f4a131bcef',
'status': 'completed',
}
]
],
}
@ -105,11 +98,13 @@ GET_ORDER_RESPONSE = {
def add_order(url, request):
return response(200, POST_ORDER_RESPONSE, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='GET')
def successful_order(url, request):
return response(200, GET_ORDER_RESPONSE, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path=r'/v1/orders', method='DELETE')
def cancelled_order(url, request):
@ -117,6 +112,7 @@ def cancelled_order(url, request):
resp['status'] = 'cancelled'
return response(200, resp, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders')
def error_order(url, request):
@ -124,29 +120,35 @@ def error_order(url, request):
resp['status'] = 'error'
return response(200, resp, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='GET')
def connection_error(url, request):
raise requests.ConnectionError('test msg')
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='GET')
def http_error(url, request):
error_payload = {'error': {'status': 400, 'type': 'Bad request', 'value': 'error'}}
return response(400, error_payload, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='GET')
def invalid_json(url, request):
return response(200, '{', request=request)
@pytest.fixture
def keyware():
return Payment({
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
})
return Payment(
{
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
}
)
@with_httmock(add_order)

157
tests/test_misc.py Normal file
View File

@ -0,0 +1,157 @@
# eopayment - online payment library
# Copyright (C) 2011-2022 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
import eopayment
def test_get_backends():
assert len(eopayment.get_backends()) > 1
GUESS_TEST_VECTORS = [
{
'name': 'tipi',
'kwargs': {
'query_string': 'objet=tout+a+fait&montant=12312&saisie=T&mel=info%40entrouvert.com'
'&numcli=12345&exer=9999&refdet=999900000000999999&resultrans=P',
},
'result': ['tipi', '999900000000999999'],
},
{
'name': 'payfip_ws',
'kwargs': {
'query_string': 'idOp=1234',
},
'result': ['payfip_ws', '1234'],
},
{
'name': 'systempayv2-old-transaction-id',
'kwargs': {
'query_string': 'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf'
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB'
'&vads_result=00'
'&vads_card_number=497010XXXXXX0000'
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53'
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042'
'&vads_site_id=70168983&vads_trans_date=20161013101355'
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a'
'&vads_effective_creation_date=20200330162530'
'&signature=c17fab393f94dc027dc029510c85d5fc46c4710f',
},
'result': ['systempayv2', '20161013101355_226787'],
},
{
'name': 'systempayv2-eo-trans-id',
'kwargs': {
'query_string': 'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf'
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB'
'&vads_result=00'
'&vads_card_number=497010XXXXXX0000'
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53'
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042'
'&vads_site_id=70168983&vads_trans_date=20161013101355'
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a'
'&vads_effective_creation_date=20200330162530'
'&signature=c17fab393f94dc027dc029510c85d5fc46c4710f'
'&vads_ext_info_eopayment_trans_id=123456',
},
'result': ['systempayv2', '123456'],
},
{
'name': 'paybox',
'kwargs': {
'query_string': 'montant=4242&reference=abcdef&code_autorisation=A'
'&erreur=00000&date_transaction=20200101&heure_transaction=01%3A01%3A01',
},
'result': ['paybox', 'abcdef'],
},
{
'name': 'ogone-no-complus',
'kwargs': {
'query_string': 'orderid=myorder&status=9&payid=3011229363&cn=Us%C3%A9r&ncerror=0'
'&trxdate=10%2F24%2F16&acceptance=test123&currency=eur&amount=7.5',
},
'result': ['ogone', 'myorder'],
},
{
'name': 'ogone-with-complus',
'kwargs': {
'query_string': 'complus=neworder&orderid=myorder&status=9&payid=3011229363&cn=Us%C3%A9r'
'&ncerror=0&trxdate=10%2F24%2F16&acceptance=test123&currency=eur&amount=7.5',
},
'result': ['ogone', 'neworder'],
},
{
'name': 'mollie',
'kwargs': {
'body': b'id=tr_7UhSN1zuXS',
},
'result': ['mollie', 'tr_7UhSN1zuXS'],
},
{
'name': 'sips2',
'kwargs': {
'body': (
b'Data=captureDay%3D0%7CcaptureMode%3DAUTHOR_CAPTURE%7CcurrencyCode%3D978%7CmerchantId%3D002001000000001%7CorderChannel%3D'
b'INTERNET%7CresponseCode%3D00%7CtransactionDateTime%3D2016-02-01T17%3A44%3A20%2B01%3A00%7C'
b'transactionReference%3D668930%7CkeyVersion%3D1%7CacquirerResponseCode%3D00%7Camou'
b'nt%3D1200%7CauthorisationId%3D12345%7CcardCSCResultCode%3D4E%7CpanExpiryDate%3D201605%7Cpay'
b'mentMeanBrand%3DMASTERCARD%7CpaymentMeanType%3DCARD%7CcustomerIpAddress%3D82.244.203.243%7CmaskedPan'
b'%3D5100%23%23%23%23%23%23%23%23%23%23%23%2300%7CorderId%3Dd4903de7027f4d56ac01634fd7ab9526%7CholderAuthentRelegation'
b'%3DN%7CholderAuthentStatus%3D3D_ERROR%7CtransactionOrigin%3DINTERNET%7CpaymentPattern%3D'
b'ONE_SHOT&Seal=6ca3247765a19b45d25ad54ef4076483e7d55583166bd5ac9c64357aac097602&InterfaceVersion=HP_2.0&Encode='
),
},
'result': ['sips2', '668930'],
},
{
'name': 'dummy',
'kwargs': {
'query_string': b'transaction_id=123&ok=1&signed=1',
},
'result': ['dummy', '123'],
},
{
'name': 'notfound',
'kwargs': {},
'exception': eopayment.BackendNotFound,
},
{
'name': 'notfound-2',
'kwargs': {'query_string': None, 'body': [12323], 'headers': {b'1': '2'}},
'exception': eopayment.BackendNotFound,
},
{
'name': 'backends-limitation',
'kwargs': {
'body': b'id=tr_7UhSN1zuXS',
'backends': ['payfips_ws'],
},
'exception': eopayment.BackendNotFound,
},
]
@pytest.mark.parametrize('test_vector', GUESS_TEST_VECTORS, ids=lambda tv: tv['name'])
def test_guess(test_vector):
kwargs, result, exception = test_vector['kwargs'], test_vector.get('result'), test_vector.get('exception')
if exception is not None:
with pytest.raises(exception):
eopayment.Payment.guess(**kwargs)
else:
assert list(eopayment.Payment.guess(**kwargs)) == result

View File

@ -16,95 +16,73 @@
import json
import requests
import six
import eopayment
import pytest
from eopayment.mollie import Payment
import requests
from httmock import remember_called, response, urlmatch, with_httmock
pytestmark = pytest.mark.skipif(six.PY2, reason='this payment module only supports python3')
import eopayment
from eopayment.mollie import Payment
WEBHOOK_URL = 'https://callback.example.com'
RETURN_URL = 'https://return.example.com'
API_KEY = 'test'
PAYMENT_ID = "tr_7UhSN1zuXS"
PAYMENT_ID = 'tr_7UhSN1zuXS'
QUERY_STRING = 'id=' + PAYMENT_ID
POST_PAYMENTS_RESPONSE = {
"resource": "payment",
"id": PAYMENT_ID,
"mode": "test",
"createdAt": "2018-03-20T09:13:37+00:00",
"amount": {
"value": "3.50",
"currency": "EUR"
},
"description": "Payment #12345",
"method": "null",
"status": "open",
"isCancelable": True,
"expiresAt": "2018-03-20T09:28:37+00:00",
"sequenceType": "oneoff",
"redirectUrl": "https://webshop.example.org/payment/12345/",
"webhookUrl": "https://webshop.example.org/payments/webhook/",
"_links": {
"checkout": {
"href": "https://www.mollie.com/payscreen/select-method/7UhSN1zuXS",
"type": "text/html"
'resource': 'payment',
'id': PAYMENT_ID,
'mode': 'test',
'createdAt': '2018-03-20T09:13:37+00:00',
'amount': {'value': '3.50', 'currency': 'EUR'},
'description': 'Payment #12345',
'method': 'null',
'status': 'open',
'isCancelable': True,
'expiresAt': '2018-03-20T09:28:37+00:00',
'sequenceType': 'oneoff',
'redirectUrl': 'https://webshop.example.org/payment/12345/',
'webhookUrl': 'https://webshop.example.org/payments/webhook/',
'_links': {
'checkout': {
'href': 'https://www.mollie.com/payscreen/select-method/7UhSN1zuXS',
'type': 'text/html',
},
}
},
}
GET_PAYMENTS_RESPONSE = {
"amount": {
"currency": "EUR",
"value": "3.50"
'amount': {'currency': 'EUR', 'value': '3.50'},
'amountRefunded': {'currency': 'EUR', 'value': '0.00'},
'amountRemaining': {'currency': 'EUR', 'value': '3.50'},
'countryCode': 'FR',
'createdAt': '2020-05-06T13:04:26+00:00',
'description': 'Publik',
'details': {
'cardAudience': 'consumer',
'cardCountryCode': 'NL',
'cardHolder': 'T. TEST',
'cardLabel': 'Mastercard',
'cardNumber': '6787',
'cardSecurity': 'normal',
'feeRegion': 'other',
},
"amountRefunded": {
"currency": "EUR",
"value": "0.00"
},
"amountRemaining": {
"currency": "EUR",
"value": "3.50"
},
"countryCode": "FR",
"createdAt": "2020-05-06T13:04:26+00:00",
"description": "Publik",
"details": {
"cardAudience": "consumer",
"cardCountryCode": "NL",
"cardHolder": "T. TEST",
"cardLabel": "Mastercard",
"cardNumber": "6787",
"cardSecurity": "normal",
"feeRegion": "other"
},
"id": PAYMENT_ID,
"metadata": {
"email": "test@entrouvert.com",
"first_name": "test",
"last_name": "test"
},
"isCancelable": True,
"method": "creditcard",
"mode": "test",
"paidAt": "2020-05-06T14:01:04+00:00",
"profileId": "pfl_WNPCPTGepu",
"redirectUrl": "https://localhost/lingo/return-payment-backend/3/MTAw.1jWJis.6TbbjwSEurag6v4Z2VCheISBFjw/",
"resource": "payment",
"sequenceType": "oneoff",
"settlementAmount": {
"currency": "EUR",
"value": "3.50"
},
"status": "paid",
"webhookUrl": "https://localhost/lingo/callback-payment-backend/3/"
'id': PAYMENT_ID,
'metadata': {'email': 'test@entrouvert.com', 'first_name': 'test', 'last_name': 'test'},
'isCancelable': True,
'method': 'creditcard',
'mode': 'test',
'paidAt': '2020-05-06T14:01:04+00:00',
'profileId': 'pfl_WNPCPTGepu',
'redirectUrl': 'https://localhost/lingo/return-payment-backend/3/MTAw.1jWJis.6TbbjwSEurag6v4Z2VCheISBFjw/',
'resource': 'payment',
'sequenceType': 'oneoff',
'settlementAmount': {'currency': 'EUR', 'value': '3.50'},
'status': 'paid',
'webhookUrl': 'https://localhost/lingo/callback-payment-backend/3/',
}
@ -177,11 +155,13 @@ def invalid_json(url, request):
@pytest.fixture
def mollie():
return Payment({
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
})
return Payment(
{
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
}
)
@with_httmock(add_payment)
@ -201,6 +181,44 @@ def test_mollie_request(mollie):
assert body['redirectUrl'] == RETURN_URL
@with_httmock(add_payment)
def test_mollie_request_orderid(mollie):
email = 'test@test.com'
payment_id, kind, url = mollie.request(2.5, email=email, orderid='1234')
assert payment_id == PAYMENT_ID
assert kind == eopayment.URL
assert 'mollie.com/payscreen/' in url
body = json.loads(add_payment.call['requests'][0].body.decode())
assert body['amount']['value'] == '2.5'
assert body['amount']['currency'] == 'EUR'
assert body['metadata']['email'] == email
assert body['metadata']['orderid'] == '1234'
assert body['webhookUrl'] == WEBHOOK_URL
assert body['redirectUrl'] == RETURN_URL
assert body['description'] == '1234'
@with_httmock(add_payment)
def test_mollie_request_orderid_subject(mollie):
email = 'test@test.com'
payment_id, kind, url = mollie.request(2.5, email=email, orderid='1234', subject='Ticket cantine #1234')
assert payment_id == PAYMENT_ID
assert kind == eopayment.URL
assert 'mollie.com/payscreen/' in url
body = json.loads(add_payment.call['requests'][0].body.decode())
assert body['amount']['value'] == '2.5'
assert body['amount']['currency'] == 'EUR'
assert body['metadata']['email'] == email
assert body['metadata']['orderid'] == '1234'
assert body['webhookUrl'] == WEBHOOK_URL
assert body['redirectUrl'] == RETURN_URL
assert body['description'] == 'Ticket cantine #1234'
@with_httmock(successful_payment)
def test_mollie_response(mollie):
payment_response = mollie.response(QUERY_STRING)
@ -219,8 +237,9 @@ def test_mollie_response(mollie):
@with_httmock(successful_payment)
def test_mollie_response_on_redirect(mollie):
payment_response = mollie.response(query_string=None, redirect=True, order_id_hint=PAYMENT_ID,
order_status_hint=0)
payment_response = mollie.response(
query_string=None, redirect=True, order_id_hint=PAYMENT_ID, order_status_hint=0
)
assert payment_response.result == eopayment.PAID
request = successful_payment.call['requests'][0]
@ -228,8 +247,9 @@ def test_mollie_response_on_redirect(mollie):
def test_mollie_response_on_redirect_final_status(mollie):
payment_response = mollie.response(query_string=None, redirect=True, order_id_hint=PAYMENT_ID,
order_status_hint=eopayment.PAID)
payment_response = mollie.response(
query_string=None, redirect=True, order_id_hint=PAYMENT_ID, order_status_hint=eopayment.PAID
)
assert payment_response.result == eopayment.PAID
assert payment_response.order_id == PAYMENT_ID

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -15,117 +14,144 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from unittest import TestCase
from urllib import parse as urllib
from xml.etree import ElementTree as ET
import six
from six.moves.urllib import parse as urllib
import pytest
import eopayment
import eopayment.ogone as ogone
from eopayment import ResponseError
PSPID = u'2352566ö'
BACKEND_PARAMS = {
'environment': ogone.ENVIRONMENT_TEST,
'pspid': PSPID,
'sha_in': u'sécret',
'sha_out': u'sécret',
'automatic_return_url': u'http://example.com/autömatic_réturn_url'
}
PSPID = '2352566ö'
class OgoneTests(TestCase):
if six.PY2:
def assertRaisesRegex(self, *args, **kwargs):
return self.assertRaisesRegexp(*args, **kwargs)
@pytest.fixture(params=[None, 'iso-8859-1', 'utf-8'])
def params(request):
params = {
'environment': ogone.ENVIRONMENT_TEST,
'pspid': PSPID,
'sha_in': 'sécret',
'sha_out': 'sécret',
'automatic_return_url': 'http://example.com/autömatic_réturn_url',
}
encoding = request.param
if encoding:
params['encoding'] = encoding
return params
def test_request(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
amount = '42.42'
order_id = u'my ordér'
reference, kind, what = ogone_backend.request(
amount=amount,
orderid=order_id,
email='foo@example.com')
self.assertEqual(len(reference), 30)
assert reference.startswith(order_id)
root = ET.fromstring(str(what))
self.assertEqual(root.tag, 'form')
self.assertEqual(root.attrib['method'], 'POST')
self.assertEqual(root.attrib['action'], ogone.ENVIRONMENT_TEST_URL)
values = {
'CURRENCY': u'EUR',
'ORDERID': reference,
'PSPID': PSPID,
'EMAIL': 'foo@example.com',
'AMOUNT': amount.replace('.', ''),
'LANGUAGE': 'fr_FR',
}
values.update({'SHASIGN': ogone_backend.backend.sha_sign_in(values)})
for node in root:
self.assertIn(node.attrib['type'], ('hidden', 'submit'))
self.assertEqual(set(node.attrib.keys()), set(['type', 'name', 'value']))
name = node.attrib['name']
if node.attrib['type'] == 'hidden':
self.assertIn(name, values)
self.assertEqual(node.attrib['value'], values[name])
def test_unicode_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
order_id = 'myorder'
data = {'orderid': u'myorder', 'status': u'9', 'payid': u'3011229363',
'cn': u'Usér', 'ncerror': u'0',
'trxdate': u'10/24/16', 'acceptance': u'test123',
'currency': u'eur', 'amount': u'7.5',
'shasign': u'CA4B3C2767B5EFAB33B9122A5D4CF6F27747303D'}
# uniformize to utf-8 first
for k in data:
data[k] = eopayment.common.force_byte(data[k])
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
self.assertEqual(response.order_id, order_id)
def test_request(params):
ogone_backend = eopayment.Payment('ogone', params)
amount = '42.42'
order_id = 'my ordér'
reference, kind, what = ogone_backend.request(amount=amount, orderid=order_id, email='foo@example.com')
assert len(reference) == 32
root = ET.fromstring(str(what))
assert root.tag == 'form'
assert root.attrib['method'] == 'POST'
assert root.attrib['action'] == ogone.ENVIRONMENT_TEST_URL
values = {
'CURRENCY': 'EUR',
'ORDERID': order_id,
'PSPID': PSPID,
'EMAIL': 'foo@example.com',
'AMOUNT': amount.replace('.', ''),
'LANGUAGE': 'fr_FR',
'COMPLUS': reference,
}
values.update({'SHASIGN': ogone_backend.backend.sha_sign_in(values)})
for node in root:
assert node.attrib['type'] in ('hidden', 'submit')
assert set(node.attrib.keys()), {'type', 'name' == 'value'}
name = node.attrib['name']
if node.attrib['type'] == 'hidden':
assert name in values
assert node.attrib['value'] == values[name]
def test_iso_8859_1_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
order_id = 'lRXK4Rl1N2yIR3R5z7Kc'
backend_response = (
'orderID=lRXK4Rl1N2yIR3R5z7Kc&currency=EUR&amount=7%2E5'
'&PM=CreditCard&ACCEPTANCE=test123&STATUS=9'
'&CARDNO=XXXXXXXXXXXX9999&ED=0118'
'&CN=Miha%EF+Serghe%EF&TRXDATE=10%2F24%2F16'
'&PAYID=3011228911&NCERROR=0&BRAND=MasterCard'
'&IP=80%2E12%2E92%2E47&SHASIGN=C429BE892FACFBFCE5E2CC809B102D866DD3D48C'
)
response = ogone_backend.response(backend_response)
assert response.signed
self.assertEqual(response.order_id, order_id)
def test_bad_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
data = {'payid': '32100123', 'status': 9, 'ncerror': 0}
with self.assertRaisesRegex(ResponseError, 'missing ORDERID, PAYID, STATUS or NCERROR'):
ogone_backend.response(urllib.urlencode(data))
def test_response(params):
ogone_backend = eopayment.Payment('ogone', params)
order_id = 'myorder'
data = {
'orderid': 'myorder',
'status': '9',
'payid': '3011229363',
'cn': 'Usér',
'ncerror': '0',
'trxdate': '10/24/16',
'acceptance': 'test123',
'currency': 'eur',
'amount': '7.5',
}
data['shasign'] = ogone_backend.backend.sha_sign_out(data, encoding=params.get('encoding', 'iso-8859-1'))
# uniformize to utf-8 first
for k in data:
data[k] = eopayment.common.force_byte(data[k], encoding=params.get('encoding', 'iso-8859-1'))
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
assert response.order_id == order_id
def test_bank_transfer_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
data = {
'orderid': u'myorder',
'status': u'41',
'payid': u'3011229363',
'cn': u'User',
'ncerror': u'0',
'trxdate': u'10/24/16',
'brand': 'Bank transfer',
'pm': 'bank transfer',
'currency': u'eur',
'amount': u'7.5',
'shasign': u'944CBD1E010BA4945415AE4B16CC40FD533F6CE2',
}
# uniformize to utf-8 first
for k in data:
data[k] = eopayment.common.force_byte(data[k])
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
assert response.result == eopayment.WAITING
def test_iso_8859_1_response():
params = {
'environment': ogone.ENVIRONMENT_TEST,
'pspid': PSPID,
'sha_in': 'sécret',
'sha_out': 'sécret',
'automatic_return_url': 'http://example.com/autömatic_réturn_url',
}
ogone_backend = eopayment.Payment('ogone', params)
order_id = 'lRXK4Rl1N2yIR3R5z7Kc'
backend_response = (
'orderID=lRXK4Rl1N2yIR3R5z7Kc&currency=EUR&amount=7%2E5'
'&PM=CreditCard&ACCEPTANCE=test123&STATUS=9'
'&CARDNO=XXXXXXXXXXXX9999&ED=0118'
'&CN=Miha%EF+Serghe%EF&TRXDATE=10%2F24%2F16'
'&PAYID=3011228911&NCERROR=0&BRAND=MasterCard'
'&IP=80%2E12%2E92%2E47&SHASIGN=C429BE892FACFBFCE5E2CC809B102D866DD3D48C'
)
response = ogone_backend.response(backend_response)
assert response.signed
assert response.order_id == order_id
def test_bad_response(params):
ogone_backend = eopayment.Payment('ogone', params)
data = {'payid': '32100123', 'status': 9, 'ncerror': 0}
with pytest.raises(ResponseError, match='missing ORDERID, PAYID, STATUS or NCERROR'):
ogone_backend.response(urllib.urlencode(data))
def test_bank_transfer_response(params):
ogone_backend = eopayment.Payment('ogone', params)
data = {
'orderid': 'myorder',
'status': '41',
'payid': '3011229363',
'cn': 'User',
'ncerror': '0',
'trxdate': '10/24/16',
'brand': 'Bank transfer',
'pm': 'bank transfer',
'currency': 'eur',
'amount': '7.5',
'shasign': '944CBD1E010BA4945415AE4B16CC40FD533F6CE2',
}
# uniformize to expected encoding
for k in data:
data[k] = eopayment.common.force_byte(data[k], encoding=params.get('encoding', 'iso-8859-1'))
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
assert response.result == eopayment.WAITING
# check utf-8 based signature is also ok
data['shasign'] = b'0E35F687ACBEAA6CA769E0ADDBD0863EB6C1678A'
response = ogone_backend.response(urllib.urlencode(data))
assert response.signed
assert response.result == eopayment.WAITING
# check invalid signature is not marked ok
data['shasign'] = b'0000000000000000000000000000000000000000'
response = ogone_backend.response(urllib.urlencode(data))
assert not response.signed

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -16,57 +15,59 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import codecs
from unittest import TestCase
from decimal import Decimal
import base64
import mock
import six
from six.moves.urllib import parse as urllib
import codecs
from decimal import Decimal
from unittest import TestCase, mock
from urllib import parse as urllib
from xml.etree import ElementTree as ET
import eopayment.paybox as paybox
import eopayment
import pytest
import eopayment
import eopayment.paybox as paybox
BACKEND_PARAMS = {
'platform': u'test',
'site': u'12345678',
'rang': u'001',
'identifiant': u'12345678',
'platform': 'test',
'site': '12345678',
'rang': '001',
'identifiant': '12345678',
'shared_secret': (
u'0123456789ABCDEF0123456789ABCDEF01234'
u'56789ABCDEF0123456789ABCDEF0123456789'
u'ABCDEF0123456789ABCDEF0123456789ABCDE'
u'F0123456789ABCDEF'
'0123456789ABCDEF0123456789ABCDEF01234'
'56789ABCDEF0123456789ABCDEF0123456789'
'ABCDEF0123456789ABCDEF0123456789ABCDE'
'F0123456789ABCDEF'
),
'automatic_return_url': u'http://example.com/callback',
'automatic_return_url': 'http://example.com/callback',
}
class PayboxTests(TestCase):
if six.PY2:
def assertRaisesRegex(self, *args, **kwargs):
return self.assertRaisesRegexp(*args, **kwargs)
def test_sign(self):
key = (b'0123456789ABCDEF0123456789ABCDEF0123456789'
b'ABCDEF0123456789ABCDEF0123456789ABCDEF0123'
b'456789ABCDEF0123456789ABCDEF0123456789ABCD'
b'EF')
key = (
b'0123456789ABCDEF0123456789ABCDEF0123456789'
b'ABCDEF0123456789ABCDEF0123456789ABCDEF0123'
b'456789ABCDEF0123456789ABCDEF0123456789ABCD'
b'EF'
)
key = codecs.decode(key, 'hex')
d = dict(paybox.sign([
['PBX_SITE', u'12345678'],
['PBX_RANG', u'32'],
['PBX_IDENTIFIANT', u'12345678'],
['PBX_TOTAL', u'999'],
['PBX_DEVISE', u'978'],
['PBX_CMD', u'appel à Paybox'],
['PBX_PORTEUR', u'test@paybox.com'],
['PBX_RETOUR', u'Mt:M;Ref:R;Auto:A;Erreur:E'],
['PBX_HASH', u'SHA512'],
['PBX_TIME', u'2015-06-08T16:21:16+02:00'],
], key))
d = dict(
paybox.sign(
[
['PBX_SITE', '12345678'],
['PBX_RANG', '32'],
['PBX_IDENTIFIANT', '12345678'],
['PBX_TOTAL', '999'],
['PBX_DEVISE', '978'],
['PBX_CMD', 'appel à Paybox'],
['PBX_PORTEUR', 'test@paybox.com'],
['PBX_RETOUR', 'Mt:M;Ref:R;Auto:A;Erreur:E'],
['PBX_HASH', 'SHA512'],
['PBX_TIME', '2015-06-08T16:21:16+02:00'],
],
key,
)
)
result = (
'7E74D8E9A0DBB65AAE51C5C50C2668FD98FC99AED'
'F18244BB1935F602B6C2E953B61FD84364F34FDB8'
@ -76,16 +77,32 @@ class PayboxTests(TestCase):
self.assertIn('PBX_HMAC', d)
self.assertEqual(d['PBX_HMAC'], result)
def test_request(self):
@mock.patch('eopayment.paybox.Payment.make_pbx_archivage', wraps=True)
def test_request(self, make_pbx_archivage):
backend = eopayment.Payment('paybox', BACKEND_PARAMS)
# fix PBX_ARCHIVAGE for test purpose
make_pbx_archivage.return_value = '4YQEFSFZSNWA'
time = '2015-07-15T18:26:32+02:00'
email = 'bdauvergne@entrouvert.com'
order_id = '20160216'
transaction = '1234'
amount = '19.99'
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time, manual_validation=False)
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time,
manual_validation=False,
total_quantity='1',
first_name='Kyan <oo>',
last_name='Khojandi',
address1='169 rue du Château',
zipcode='75014',
city='Paris',
country_code=250,
)
self.assertEqual(kind, eopayment.FORM)
self.assertEqual(transaction_id, '%s!%s' % (transaction, order_id))
root = ET.fromstring(str(what))
@ -108,22 +125,37 @@ class PayboxTests(TestCase):
'PBX_DEVISE': '978',
'PBX_HASH': 'SHA512',
'PBX_HMAC': (
'A9F561A6EA79390F1741A6B72872470BC1A1688E4581'
'F097EC80B99D2038413AB350F2F5429FFA4F8D426D99'
'B72E038164642F6F9BA10D46837EE486EEB944A2'
'EC9B753691D804F15B3369BEF9CA49F20585BE32E84'
'9A9758815903CE5A89822C251C7EBC712145FCA6321'
'C6A6F90EE45EBEC618FFC8B7A69CC23E1BFC6CACC7'
),
'PBX_ARCHIVAGE': '20160216',
'PBX_ARCHIVAGE': '4YQEFSFZSNWA',
'PBX_REPONDRE_A': 'http://example.com/callback',
'PBX_AUTOSEULE': 'N'
'PBX_AUTOSEULE': 'N',
'PBX_BILLING': (
'<?xml version="1.0" encoding="utf-8"?>'
'<Billing><Address>'
'<FirstName>Kyan &lt;oo&gt;</FirstName>'
'<LastName>Khojandi</LastName>'
'<Address1>169 rue du Château</Address1>'
'<ZipCode>75014</ZipCode>'
'<City>Paris</City>'
'<CountryCode>250</CountryCode>'
'</Address></Billing>'
),
'PBX_SHOPPINGCART': (
'<?xml version="1.0" encoding="utf-8"?>'
'<shoppingcart><total><totalQuantity>1</totalQuantity></total></shoppingcart>'
),
}
form_params = {}
for node in root:
self.assertIn(node.attrib['type'], ('hidden', 'submit'))
if node.attrib['type'] == 'submit':
self.assertEqual(set(node.attrib.keys()), set(['type', 'value']))
self.assertEqual(set(node.attrib.keys()), {'type', 'value'})
if node.attrib['type'] == 'hidden':
self.assertEqual(set(node.attrib.keys()), set(['type', 'name', 'value']))
self.assertEqual(set(node.attrib.keys()), {'type', 'name', 'value'})
name = node.attrib['name']
form_params[name] = node.attrib['value']
assert form_params == expected_form_values
@ -140,12 +172,13 @@ class PayboxTests(TestCase):
params['capture_day'] = capture_day
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time)
Decimal(amount), email=email, orderid=order_id, transaction_id=transaction, time=time
)
root = ET.fromstring(str(what))
form_params = dict(
((node.attrib['name'], node.attrib['value']) for node in root if node.attrib['type'] == 'hidden'))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
self.assertIn('PBX_DIFF', form_params)
self.assertEqual(form_params['PBX_DIFF'], '07')
@ -153,13 +186,18 @@ class PayboxTests(TestCase):
params = BACKEND_PARAMS.copy()
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time, capture_day='2')
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time,
capture_day='2',
)
root = ET.fromstring(str(what))
form_params = dict(((
node.attrib['name'], node.attrib['value']) for node in root
if node.attrib['type'] == 'hidden'))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
self.assertIn('PBX_DIFF', form_params)
self.assertEqual(form_params['PBX_DIFF'], '02')
@ -169,13 +207,18 @@ class PayboxTests(TestCase):
params['capture_day'] = '7'
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time, capture_day='2')
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time,
capture_day='2',
)
root = ET.fromstring(str(what))
form_params = dict(((
node.attrib['name'], node.attrib['value']) for node in root
if node.attrib['type'] == 'hidden'))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
self.assertIn('PBX_DIFF', form_params)
self.assertEqual(form_params['PBX_DIFF'], '02')
@ -190,12 +233,13 @@ class PayboxTests(TestCase):
params['capture_mode'] = 'AUTHOR_CAPTURE'
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time)
Decimal(amount), email=email, orderid=order_id, transaction_id=transaction, time=time
)
root = ET.fromstring(str(what))
form_params = dict(
((node.attrib['name'], node.attrib['value']) for node in root if node.attrib['type'] == 'hidden'))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
self.assertEqual(form_params['PBX_AUTOSEULE'], 'O')
def test_response(self):
@ -209,7 +253,8 @@ class PayboxTests(TestCase):
'code_autorisation': 'A',
'erreur': '00000',
'date_transaction': '20200101',
'heure_transaction': '01:01:01'}
'heure_transaction': '01:01:01',
}
response = backend.response(urllib.urlencode(data))
self.assertEqual(response.order_id, reference)
assert not response.signed
@ -230,20 +275,22 @@ class PayboxTests(TestCase):
'reference': ['830657461681'],
}
backend_raw_response = (
u'NUMTRANS=0013989865&NUMAPPEL=0030378572&NUMQUESTION=0013989862'
u'&SITE=1999888&RANG=32&AUTORISATION=XXXXXX&CODEREPONSE=00000'
u'&COMMENTAIRE=Demande traitée avec succès&REFABONNE=&PORTEUR='
'NUMTRANS=0013989865&NUMAPPEL=0030378572&NUMQUESTION=0013989862'
'&SITE=1999888&RANG=32&AUTORISATION=XXXXXX&CODEREPONSE=00000'
'&COMMENTAIRE=Demande traitée avec succès&REFABONNE=&PORTEUR='
)
backend_expected_response = {"CODEREPONSE": "00000",
"RANG": "32",
"AUTORISATION": "XXXXXX",
"NUMTRANS": "0013989865",
"PORTEUR": "",
"COMMENTAIRE": u"Demande traitée avec succès",
"SITE": "1999888",
"NUMAPPEL": "0030378572",
"REFABONNE": "",
"NUMQUESTION": "0013989862"}
backend_expected_response = {
'CODEREPONSE': '00000',
'RANG': '32',
'AUTORISATION': 'XXXXXX',
'NUMTRANS': '0013989865',
'PORTEUR': '',
'COMMENTAIRE': 'Demande traitée avec succès',
'SITE': '1999888',
'NUMAPPEL': '0030378572',
'REFABONNE': '',
'NUMQUESTION': '0013989862',
}
with mock.patch('eopayment.paybox.requests.post') as requests_post:
response = mock.Mock(status_code=200, text=backend_raw_response)
@ -266,7 +313,7 @@ class PayboxTests(TestCase):
'REFERENCE': '830657461681',
'RANG': backend.backend.rang,
'SITE': backend.backend.site,
'DEVISE': backend.backend.devise
'DEVISE': backend.backend.devise,
}
self.assertEqual(params_sent, expected_params)
self.assertEqual(backend_response, backend_expected_response)
@ -280,7 +327,7 @@ class PayboxTests(TestCase):
self.assertEqual(requests_post.call_args[0][0], 'https://ppps.paybox.com/PPPS.php')
with mock.patch('eopayment.paybox.requests.post') as requests_post:
error_response = u"""CODEREPONSE=00015&COMMENTAIRE=PAYBOX : Transaction non trouvée"""
error_response = """CODEREPONSE=00015&COMMENTAIRE=PAYBOX : Transaction non trouvée"""
response = mock.Mock(status_code=200, text=error_response)
requests_post.return_value = response
self.assertRaisesRegex(
@ -288,7 +335,8 @@ class PayboxTests(TestCase):
'Transaction non trouvée',
getattr(backend, operation_name),
Decimal('10'),
bank_data)
bank_data,
)
def test_validate_payment(self):
params = BACKEND_PARAMS.copy()
@ -297,12 +345,12 @@ class PayboxTests(TestCase):
bank_data = {
'numero_transaction': ['13957441'],
'numero_appel': ['30310733'],
'reference': ['830657461681']
'reference': ['830657461681'],
}
backend_raw_response = (
u'NUMTRANS=0013989865&NUMAPPEL=0030378572&NUMQUESTION=0013989862'
u'&SITE=1999888&RANG=32&AUTORISATION=XXXXXX&CODEREPONSE=00000'
u'&COMMENTAIRE=Demande traitée avec succès&REFABONNE=&PORTEUR='
'NUMTRANS=0013989865&NUMAPPEL=0030378572&NUMQUESTION=0013989862'
'&SITE=1999888&RANG=32&AUTORISATION=XXXXXX&CODEREPONSE=00000'
'&COMMENTAIRE=Demande traitée avec succès&REFABONNE=&PORTEUR='
)
with mock.patch('eopayment.paybox.requests.post') as requests_post:
@ -334,15 +382,12 @@ FBFKOZhgBJnkC+l6+XhT4aYWKaQ4ocmOMV92yjeXTE4='''
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time)
Decimal(amount), email=email, orderid=order_id, transaction_id=transaction, time=time
)
root = ET.fromstring(str(what))
form_params = dict((
(node.attrib['name'], node.attrib['value']) for node in root
if node.attrib['type'] == 'hidden'))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
self.assertIn('PBX_AUTOSEULE', form_params)
self.assertEqual(form_params['PBX_AUTOSEULE'], 'N')
@ -352,10 +397,46 @@ FBFKOZhgBJnkC+l6+XhT4aYWKaQ4ocmOMV92yjeXTE4='''
orderid=order_id,
transaction_id=transaction,
time=time,
manual_validation=True)
manual_validation=True,
)
root = ET.fromstring(str(what))
form_params = dict((
(node.attrib['name'], node.attrib['value']) for node in root
if node.attrib['type'] == 'hidden'))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
self.assertIn('PBX_AUTOSEULE', form_params)
self.assertEqual(form_params['PBX_AUTOSEULE'], 'O')
def test_invalid_signature(self):
backend = eopayment.Payment('paybox', BACKEND_PARAMS)
order_id = '20160216'
transaction = '1234'
reference = '%s!%s' % (transaction, order_id)
data = {
'montant': '4242',
'reference': reference,
'code_autorisation': 'A',
'erreur': '00000',
'date_transaction': '20200101',
'heure_transaction': '01:01:01',
'signature': 'a',
}
with pytest.raises(eopayment.ResponseError, match='invalid signature'):
backend.response(urllib.urlencode(data))
@pytest.mark.parametrize(
'name,value,result',
[
('shared_secret', '1f', True),
('shared_secret', '1fxx', False),
('shared_secret', '1fa', False),
('shared_secret', '1fa2', True),
],
)
def test_param_validation(name, value, result):
for param in paybox.Payment.description['parameters']:
if param['name'] == name:
assert param['validation'](value) is result
break
else:
assert False, 'param %s not found' % name

View File

@ -1,4 +1,3 @@
# coding: utf-8
#
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
@ -16,24 +15,20 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import print_function, unicode_literals
import datetime
import json
import lxml.etree as ET
import mock
import pytz
from unittest import mock
import httmock
import lxml.etree as ET
import pytest
import pytz
from zeep.plugins import HistoryPlugin
import eopayment
from eopayment.payfip_ws import PayFiP, PayFiPError, normalize_objet
NUMCLI = '090909'
NOTIF_URL = 'https://notif.payfip.example.com/'
REDIRECT_URL = 'https://redirect.payfip.example.com/'
@ -56,7 +51,7 @@ def freezer(freezer):
return freezer
class PayFiPHTTMock(object):
class PayFiPHTTMock:
def __init__(self, history_name):
history_path = 'tests/data/payfip-%s.json' % history_name
with open(history_path) as fd:
@ -77,13 +72,21 @@ def history_name(request):
@pytest.fixture
def history(history_name, request):
def history(history_name, request, zeep_history_plugin):
if 'update_data' not in request.keywords:
history_mock = PayFiPHTTMock(history_name)
with httmock.HTTMock(history_mock.mock):
yield history_mock
else:
yield None
history_path = 'tests/data/payfip-%s.json' % history_name
d = [
(xmlindent(exchange['sent']['envelope']), xmlindent(exchange['received']['envelope']))
for exchange in zeep_history_plugin._buffer
]
content = json.dumps(d)
with open(history_path, 'wb') as fd:
fd.write(content)
@pytest.fixture
@ -97,39 +100,42 @@ def get_idop():
def backend(request):
with mock.patch('eopayment.payfip_ws.Payment._generate_refdet') as _generate_refdet:
_generate_refdet.return_value = REFDET_GEN
yield eopayment.Payment('payfip_ws', {
'numcli': '090909',
'automatic_return_url': NOTIF_URL,
'normal_return_url': REDIRECT_URL,
})
yield eopayment.Payment(
'payfip_ws',
{
'numcli': '090909',
'automatic_return_url': NOTIF_URL,
'normal_return_url': REDIRECT_URL,
},
)
@httmock.urlmatch()
def raise_on_request(url, request):
# ensure we do not access network
from requests.exceptions import RequestException
raise RequestException('huhu')
@pytest.fixture
def payfip(history, history_name, request):
history = HistoryPlugin()
def zeep_history_plugin():
return HistoryPlugin()
@httmock.urlmatch()
def raise_on_request(url, request):
# ensure we do not access network
from requests.exceptions import RequestException
raise RequestException('huhu')
@pytest.fixture
def payfip(zeep_history_plugin):
with httmock.HTTMock(raise_on_request):
payfip = PayFiP(wsdl_url='./eopayment/resource/PaiementSecuriseService.wsdl',
zeep_client_kwargs={'plugins': [history]})
payfip = PayFiP(
wsdl_url='./eopayment/resource/PaiementSecuriseService.wsdl',
zeep_client_kwargs={'plugins': [zeep_history_plugin]},
)
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)
@pytest.fixture
def payfip_history(history, payfip, zeep_history_plugin, request):
yield
def set_history_name(name):
@ -137,18 +143,20 @@ def set_history_name(name):
def decorator(func):
func.history_name = name
return func
return decorator
# pytestmark = pytest.mark.update_data
def test_get_client_info(payfip):
def test_get_client_info(history, payfip):
result = payfip.get_info_client(NUMCLI)
assert result.numcli == NUMCLI
assert result.libelleN2 == 'POUETPOUET'
def test_get_idop_ok(payfip):
def test_get_idop_ok(history, payfip):
result = payfip.get_idop(
numcli=NUMCLI,
exer='2019',
@ -158,11 +166,12 @@ def test_get_idop_ok(payfip):
objet='coucou',
url_notification=NOTIF_URL,
url_redirect=REDIRECT_URL,
saisie='T')
saisie='T',
)
assert result == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
def test_get_idop_refdet_error(payfip):
def test_get_idop_refdet_error(history, payfip):
with pytest.raises(PayFiPError, match='.*R3.*Le format.*REFDET.*conforme'):
payfip.get_idop(
numcli=NUMCLI,
@ -173,10 +182,11 @@ def test_get_idop_refdet_error(payfip):
objet='coucou',
url_notification='https://notif.payfip.example.com/',
url_redirect='https://redirect.payfip.example.com/',
saisie='T')
saisie='T',
)
def test_get_idop_adresse_mel_incorrect(payfip):
def test_get_idop_adresse_mel_incorrect(payfip, payfip_history):
with pytest.raises(PayFiPError, match='.*A2.*Adresse.*incorrecte'):
payfip.get_idop(
numcli=NUMCLI,
@ -187,10 +197,11 @@ def test_get_idop_adresse_mel_incorrect(payfip):
objet='coucou',
url_notification='https://notif.payfip.example.com/',
url_redirect='https://redirect.payfip.example.com/',
saisie='T')
saisie='T',
)
def test_get_info_paiement_ok(payfip):
def test_get_info_paiement_ok(history, payfip):
result = payfip.get_info_paiement('cc0cb210-1cd4-11ea-8cca-0213ad91a103')
assert {k: result[k] for k in result} == {
'dattrans': '12122019',
@ -204,11 +215,11 @@ def test_get_info_paiement_ok(payfip):
'objet': 'coucou',
'refdet': 'EFEFAEFG',
'resultrans': 'V',
'saisie': 'T'
'saisie': 'T',
}
def test_get_info_paiement_P1(payfip, freezer):
def test_get_info_paiement_P1(history, 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')
@ -216,6 +227,7 @@ def test_get_info_paiement_P1(payfip, freezer):
@set_history_name('test_get_info_paiement_P1')
def test_P1_and_payment_status(history, backend):
assert backend.has_payment_status
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103')
assert response.result == eopayment.EXPIRED
@ -223,14 +235,18 @@ def test_P1_and_payment_status(history, backend):
@set_history_name('test_get_info_paiement_P1')
def test_P1_and_payment_status_utc_aware_now(history, backend):
utc_now = datetime.datetime.now(pytz.utc)
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now)
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(history, backend):
now = datetime.datetime.now()
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
assert response.result == eopayment.EXPIRED
@ -238,84 +254,100 @@ def test_P1_and_payment_status_utc_naive_now(history, backend):
def test_P1_and_payment_status_utc_aware_now_later(history, 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)
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):
def test_P1_and_payment_status_utc_naive_now_later(history, 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)
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):
def test_get_info_paiement_P5(history, 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')
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status(payfip, backend, freezer):
def test_P5_and_payment_status(history, payfip, backend, freezer):
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103')
assert response.result == eopayment.WAITING
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status_utc_aware_now(payfip, backend, freezer):
def test_P5_and_payment_status_utc_aware_now(history, 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)
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=utc_now
)
assert response.result == eopayment.WAITING
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status_utc_naive_now(payfip, backend, freezer):
def test_P5_and_payment_status_utc_naive_now(history, payfip, backend, freezer):
now = datetime.datetime.now()
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
assert response.result == eopayment.WAITING
@set_history_name('test_get_info_paiement_P5')
def test_P5_and_payment_status_utc_aware_now_later(payfip, backend, freezer):
def test_P5_and_payment_status_utc_aware_now_later(history, 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)
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_P5')
def test_P5_and_payment_status_utc_naive_now_later(payfip, backend, freezer):
def test_P5_and_payment_status_utc_naive_now_later(history, 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)
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):
def test_payment_ok(history, payfip, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194')
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'
assert url == 'https://www.payfip.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')
'201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103 112233445566-tip'
)
@set_history_name('test_payment_ok')
def test_payment_status_ok(history, backend, freezer):
history.counter = 1 # only the response
now = datetime.datetime.now()
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
assert response.result == eopayment.PAID
@ -324,11 +356,12 @@ def test_payment_denied(history, backend):
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194')
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'
assert url == 'https://www.payfip.gouv.fr/tpa/paiementws.web?idop=cc0cb210-1cd4-11ea-8cca-0213ad91a103'
response = backend.response('idop=%s' % payment_id)
assert response.result == eopayment.DENIED
@ -341,7 +374,9 @@ def test_payment_denied(history, backend):
def test_payment_status_denied(history, backend, freezer):
history.counter = 1 # only the response
now = datetime.datetime.now()
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
assert response.result == eopayment.DENIED
@ -350,15 +385,16 @@ def test_payment_cancelled(history, backend):
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194')
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'
assert url == 'https://www.payfip.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.result == eopayment.WAITING
assert response.bank_status == 'cancelled CB - still waiting as idop is still active'
assert response.order_id == payment_id
assert response.transaction_id == '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103'
@ -367,8 +403,20 @@ def test_payment_cancelled(history, backend):
def test_payment_status_cancelled(history, backend, freezer):
history.counter = 1 # only the response
now = datetime.datetime.now()
response = backend.payment_status(transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now)
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
assert response.result == eopayment.WAITING
assert response.bank_status == 'cancelled CB - still waiting as idop is still active'
freezer.move_to(datetime.timedelta(minutes=20, seconds=1))
history.counter = 1 # only the response
response = backend.payment_status(
transaction_id='cc0cb210-1cd4-11ea-8cca-0213ad91a103', transaction_date=now
)
assert response.result == eopayment.CANCELLED
assert response.bank_status == 'cancelled CB'
def test_normalize_objet():
@ -385,7 +433,8 @@ def test_refdet_exer(get_idop, backend):
email=MEL,
# make test deterministic
exer=EXER,
refdet=REFDET)
refdet=REFDET,
)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
@ -411,7 +460,8 @@ def test_transaction_id_orderid_subject(get_idop, backend):
exer=EXER,
transaction_id='TR12345',
orderid='F20190003',
subject='Précompte famille #1234')
subject='Précompte famille #1234',
)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
@ -437,7 +487,8 @@ def test_invalid_transaction_id_valid_orderid(get_idop, backend):
exer=EXER,
transaction_id='TR-12345',
orderid='F20190003',
subject='Précompte famille #1234')
subject='Précompte famille #1234',
)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
@ -463,7 +514,8 @@ def test_invalid_transaction_id_invalid_orderid(get_idop, backend):
exer=EXER,
transaction_id='TR-12345',
orderid='F/20190003',
subject='Précompte famille #1234')
subject='Précompte famille #1234',
)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
@ -479,3 +531,26 @@ def test_invalid_transaction_id_invalid_orderid(get_idop, backend):
'url_notification': NOTIF_URL,
'url_redirect': REDIRECT_URL,
}
def test_get_min_time_between_transactions(backend):
assert backend.get_min_time_between_transactions() == 20 * 60
def test_get_minimal_amount(backend):
assert backend.get_minimal_amount() is not None
def test_get_maximal_amount(backend):
assert backend.get_maximal_amount() is not None
def test_request_error(payfip, backend):
with pytest.raises(PayFiPError):
with httmock.HTTMock(raise_on_request):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194',
)

View File

@ -1,4 +1,3 @@
# coding: utf-8
#
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
@ -16,12 +15,10 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from __future__ import print_function, unicode_literals
import json
import pytest
import zeep.transports
import eopayment
@ -44,13 +41,20 @@ def saga(record_http_session):
@pytest.fixture
def backend_factory(saga, target_url):
def factory(**kwargs):
return eopayment.Payment('saga', dict({
'base_url': target_url,
'num_service': '868',
'compte': '70688',
'automatic_return_url': 'https://automatic.notif.url/automatic/',
'normal_return_url': 'https://normal.notif.url/normal/',
}, **kwargs))
return eopayment.Payment(
'saga',
dict(
{
'base_url': target_url,
'num_service': '868',
'compte': '70688',
'automatic_return_url': 'https://automatic.notif.url/automatic/',
'normal_return_url': 'https://normal.notif.url/normal/',
},
**kwargs,
),
)
return factory
@ -58,19 +62,19 @@ def test_error_parametrage(backend_factory):
payment = backend_factory(num_service='1', compte='1')
with pytest.raises(eopayment.PaymentException, match='Impossible de déterminer le paramétrage'):
transaction_id, kind, url = payment.request(
amount='10.00',
email='john.doe@example.com',
subject='Réservation concert XYZ numéro 1234')
amount='10.00', email='john.doe@example.com', subject='Réservation concert XYZ numéro 1234'
)
def test_request(backend_factory):
transaction_id, kind, url = backend_factory().request(
amount='10.00',
email='john.doe@example.com',
subject='Réservation concert XYZ numéro 1234')
amount='10.00', email='john.doe@example.com', subject='Réservation concert XYZ numéro 1234'
)
assert transaction_id == '347b2060-1a37-11eb-af92-0213ad91a103'
assert kind == eopayment.URL
assert url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=347b2060-1a37-11eb-af92-0213ad91a103'
assert (
url == 'https://www.tipi.budget.gouv.fr/tpa/paiementws.web?idop=347b2060-1a37-11eb-af92-0213ad91a103'
)
def test_response(backend_factory):
@ -83,7 +87,7 @@ def test_response(backend_factory):
'montant': '10.00',
'num_service': '868',
'numcp': '70688',
'numcpt_lib_ecriture': 'COUCOU'
'numcpt_lib_ecriture': 'COUCOU',
},
'bank_status': 'paid',
'order_id': '28b52f40-1ace-11eb-8ce3-0213ad91a104',
@ -96,4 +100,3 @@ def test_response(backend_factory):
}
# Check bank_data is JSON serializable
json.dumps(response.bank_data)

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -16,32 +15,32 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import eopayment
import pytest
import eopayment
def test_build_request():
backend = eopayment.Payment('sips2', {})
transaction, f, form = backend.request(amount=u'12', last_name=u'Foo',
first_name=u'Félix000000')
transaction, f, form = backend.request(amount='12', last_name='Foo', first_name='Félix000000')
data = [f for f in form.fields if f['name'] == 'Data']
assert u'lix000000' not in data[0]['value']
assert 'lix000000' not in data[0]['value']
transaction, f, form = backend.request(amount=u'12')
transaction, f, form = backend.request(amount='12')
data = [f for f in form.fields if f['name'] == 'Data']
assert 'statementReference=%s' % transaction in data[0]['value']
transaction, f, form = backend.request(amount=u'12', info1='foobar')
transaction, f, form = backend.request(amount='12', info1='foobar')
data = [f for f in form.fields if f['name'] == 'Data']
assert 'statementReference=foobar' in data[0]['value']
transaction, f, form = backend.request(amount=u'12', info1='foobar', capture_day=u'1')
transaction, f, form = backend.request(amount='12', info1='foobar', capture_day='1')
data = [f for f in form.fields if f['name'] == 'Data']
assert 'captureDay=1' in data[0]['value']
def test_options():
payment = eopayment.Payment('sips2', {'capture_mode': u'VALIDATION'})
payment = eopayment.Payment('sips2', {'capture_mode': 'VALIDATION'})
assert payment.backend.get_data()['captureMode'] == 'VALIDATION'
payment = eopayment.Payment('sips2', {})

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# eopayment - online payment library
# Copyright (C) 2011-2020 Entr'ouvert
#
@ -17,21 +16,20 @@
from datetime import datetime, timedelta
from urllib import parse as urlparse
import pytest
from six.moves.urllib import parse as urlparse
import eopayment
from eopayment.systempayv2 import Payment, VADS_CUST_FIRST_NAME, \
VADS_CUST_LAST_NAME, PAID
from eopayment import ResponseError
from eopayment.systempayv2 import PAID, VADS_CUST_FIRST_NAME, VADS_CUST_LAST_NAME, Payment
PARAMS = {
'secret_test': u'1122334455667788',
'vads_site_id': u'12345678',
'vads_ctx_mode': u'TEST',
'vads_trans_date': u'20090501193530',
'signature_algo': 'sha1'
'secret_test': '1122334455667788',
'vads_site_id': '12345678',
'vads_ctx_mode': 'TEST',
'vads_trans_date': '20090501193530',
'signature_algo': 'sha1',
}
@ -49,12 +47,7 @@ def get_field(form, field_name):
def test_systempayv2(caplog):
caplog.set_level(0)
p = Payment(PARAMS)
data = {
'amount': 15.24,
'orderid': '654321',
'first_name': u'Jean Michél',
'last_name': u'Mihaï'
}
data = {'amount': 15.24, 'orderid': '654321', 'first_name': 'Jean Michél', 'last_name': 'Mihaï'}
qs = (
'vads_version=V2&vads_page_action=PAYMENT&vads_action_mode=INTERACTIV'
'E&vads_payment_config=SINGLE&vads_site_id=12345678&vads_ctx_mode=TES'
@ -70,18 +63,20 @@ def test_systempayv2(caplog):
# check that user first and last names are unicode
for field in form.fields:
if field['name'] in (VADS_CUST_FIRST_NAME, VADS_CUST_LAST_NAME):
assert field['value'] in (u'Jean Michél', u'Mihaï')
assert field['value'] in ('Jean Michél', 'Mihaï')
response_qs = 'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf' \
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB' \
'&vads_result=00' \
'&vads_card_number=497010XXXXXX0000' \
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53' \
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042' \
'&vads_site_id=70168983&vads_trans_date=20161013101355' \
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a' \
'&vads_effective_creation_date=20200330162530' \
'&signature=c17fab393f94dc027dc029510c85d5fc46c4710f'
response_qs = (
'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf'
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB'
'&vads_result=00'
'&vads_card_number=497010XXXXXX0000'
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53'
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042'
'&vads_site_id=70168983&vads_trans_date=20161013101355'
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a'
'&vads_effective_creation_date=20200330162530'
'&signature=c17fab393f94dc027dc029510c85d5fc46c4710f'
)
response = p.response(response_qs)
assert response.result == PAID
assert response.signed
@ -91,16 +86,18 @@ def test_systempayv2(caplog):
PARAMS['signature_algo'] = 'hmac_sha256'
p = Payment(PARAMS)
assert p.signature(qs) == 'aHrJ7IzSGFa4pcYA8kh99+M/xBzoQ4Odnu3f4BUrpIA='
response_qs = 'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf' \
'&vads_result=00' \
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB' \
'&vads_card_number=497010XXXXXX0000' \
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53' \
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042' \
'&vads_site_id=70168983&vads_trans_date=20161013101355' \
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a' \
'&vads_effective_creation_date=20200330162530' \
'&signature=Wbz3bP6R6wDvAwb2HnSiH9%2FiUUoRVCxK7mdLtCMz8Xw%3D'
response_qs = (
'vads_amount=1042&vads_auth_mode=FULL&vads_auth_number=3feadf'
'&vads_result=00'
'&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB'
'&vads_card_number=497010XXXXXX0000'
'&vads_payment_certificate=582ba2b725057618706d7a06e9e59acdbf69ff53'
'&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1042'
'&vads_site_id=70168983&vads_trans_date=20161013101355'
'&vads_trans_id=226787&vads_trans_uuid=4b5053b3b1fe4b02a07753e7a'
'&vads_effective_creation_date=20200330162530'
'&signature=Wbz3bP6R6wDvAwb2HnSiH9%2FiUUoRVCxK7mdLtCMz8Xw%3D'
)
response = p.response(response_qs)
assert response.result == PAID
assert response.signed
@ -112,14 +109,11 @@ def test_systempayv2(caplog):
def test_systempayv2_deferred_payment():
default_params = {
'secret_test': u'1122334455667788',
'vads_site_id': u'12345678',
'vads_ctx_mode': u'TEST',
}
default_data = {
'amount': 15.24, 'orderid': '654321', 'first_name': u'John',
'last_name': u'Doe'
'secret_test': '1122334455667788',
'vads_site_id': '12345678',
'vads_ctx_mode': 'TEST',
}
default_data = {'amount': 15.24, 'orderid': '654321', 'first_name': 'John', 'last_name': 'Doe'}
# default vads_capture_delay used
params = default_params.copy()
@ -145,21 +139,18 @@ def test_systempayv2_deferred_payment():
params['vads_capture_delay'] = 1
p = eopayment.Payment('systempayv2', params)
data = default_data.copy()
data['capture_date'] = (datetime.now().date() + timedelta(days=4))
data['capture_date'] = datetime.now().date() + timedelta(days=4)
transaction_id, f, form = p.request(**data)
assert get_field(form, 'vads_capture_delay')['value'] == '4'
def test_manual_validation():
params = {
'secret_test': u'1122334455667788',
'vads_site_id': u'12345678',
'vads_ctx_mode': u'TEST',
}
data = {
'amount': 15.24, 'orderid': '654321', 'first_name': u'John',
'last_name': u'Doe'
'secret_test': '1122334455667788',
'vads_site_id': '12345678',
'vads_ctx_mode': 'TEST',
}
data = {'amount': 15.24, 'orderid': '654321', 'first_name': 'John', 'last_name': 'Doe'}
backend = eopayment.Payment('systempayv2', params)
transaction_id, f, form = backend.request(**data.copy())
@ -173,8 +164,10 @@ def test_manual_validation():
transaction_id, f, form = backend.request(**data.copy())
assert get_field(form, 'vads_validation_mode')['value'] == ''
FIXED_TRANSACTION_ID = '1234'
def test_transaction_id_request(backend):
transaction_id, kind, form = backend.request(10.0, transaction_id=FIXED_TRANSACTION_ID)
assert transaction_id == FIXED_TRANSACTION_ID

View File

@ -16,13 +16,13 @@
import datetime
from decimal import Decimal
from six.moves.urllib.parse import urlparse, parse_qs
from urllib.parse import parse_qs, urlparse
import pytest
import pytz
import eopayment
import eopayment.tipi
import pytest
def test_tipi():
@ -34,7 +34,8 @@ def test_tipi():
objet='tout a fait',
email='info@entrouvert.com',
urlcl='http://example.com/tipi/test',
saisie='T')
saisie='T',
)
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id) is not None
parsed_qs = parse_qs(urlparse(url).query)
assert parsed_qs['objet'][0].startswith('tout a fait')
@ -47,7 +48,8 @@ def test_tipi():
response = p.response(
'objet=tout+a+fait&montant=12312&saisie=T&mel=info%40entrouvert.com'
'&numcli=12345&exer=9999&refdet=999900000000999999&resultrans=P')
'&numcli=12345&exer=9999&refdet=999900000000999999&resultrans=P'
)
assert response.signed # ...
assert response.order_id == '999900000000999999'
assert response.transaction_id == '999900000000999999'
@ -60,10 +62,8 @@ def test_tipi():
def test_tipi_no_orderid_no_refdet():
p = eopayment.Payment('tipi', {'numcli': '12345'})
payment_id, kind, url = p.request(
amount=Decimal('123.12'),
exer=9999,
email='info@entrouvert.com',
saisie='T')
amount=Decimal('123.12'), exer=9999, email='info@entrouvert.com', saisie='T'
)
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id) is not None
parsed_qs = parse_qs(urlparse(url).query)
assert 'objet' not in parsed_qs
@ -72,15 +72,16 @@ def test_tipi_no_orderid_no_refdet():
assert parsed_qs['mel'] == ['info@entrouvert.com']
assert parsed_qs['numcli'] == ['12345']
assert parsed_qs['exer'] == ['9999']
assert parsed_qs['refdet'][0].startswith(datetime.datetime.now(pytz.timezone('Europe/Paris')).strftime('%Y%m%d'))
assert parsed_qs['refdet'][0].startswith(
datetime.datetime.now(pytz.timezone('Europe/Paris')).strftime('%Y%m%d')
)
def test_tipi_orderid_refdef_compatible():
p = eopayment.Payment('tipi', {'numcli': '12345', 'saisie': 'A'})
payment_id, kind, url = p.request(
amount=Decimal('123.12'),
email='info@entrouvert.com',
orderid='F121212')
amount=Decimal('123.12'), email='info@entrouvert.com', orderid='F121212'
)
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id)
expected_url = urlparse(eopayment.tipi.TIPI_URL)
parsed_url = urlparse(url)
@ -98,10 +99,8 @@ def test_tipi_orderid_refdef_compatible():
def test_tipi_orderid_not_refdef_compatible():
p = eopayment.Payment('tipi', {'numcli': '12345', 'saisie': 'A'})
payment_id, kind, url = p.request(
amount=Decimal('123.12'),
email='info@entrouvert.com',
objet='coucou',
orderid='F12-12-12')
amount=Decimal('123.12'), email='info@entrouvert.com', objet='coucou', orderid='F12-12-12'
)
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id) is not None
expected_url = urlparse(eopayment.tipi.TIPI_URL)
parsed_url = urlparse(url)
@ -115,3 +114,16 @@ def test_tipi_orderid_not_refdef_compatible():
assert parsed_qs['refdet'][0].startswith(datetime.datetime.now().strftime('%Y%m%d'))
assert 'coucou' in parsed_qs['objet'][0]
assert 'F12-12-12' in parsed_qs['objet'][0]
@pytest.fixture
def payment():
return eopayment.Payment('tipi', {'numcli': '12345'})
def test_get_minimal_amount(payment):
assert payment.get_minimal_amount() is not None
def test_get_maximal_amount(payment):
assert payment.get_maximal_amount() is not None

View File

@ -4,7 +4,7 @@
# and then run "tox" from this directory.
[tox]
envlist = py2,py3
envlist = py3,codestyle
toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/eopayment/{env:BRANCH_NAME:}
[testenv]
@ -12,8 +12,8 @@ toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/eopayment/{env:BRANCH_NAME:}
setenv =
SETUPTOOLS_USE_DISTUTILS=stdlib
commands =
py2: py.test {posargs: --junitxml=junit-{envname}.xml --cov-report xml --cov-report html --cov=eopayment/ tests}
py3: py.test {posargs: --junitxml=junit-{envname}.xml --cov-report xml --cov-report html --cov=eopayment/ tests}
py3: py.test {posargs: --junitxml=junit-{envname}.xml --cov-report xml --cov-report html --cov-config .coveragerc --cov=eopayment/ tests}
codestyle: pre-commit run --all-files --show-diff-on-failure
usedevelop = True
deps = coverage
pytest
@ -22,6 +22,7 @@ deps = coverage
mock<4
httmock
lxml
pre-commit
[pytest]
filterwarnings =