Compare commits

..

24 Commits

Author SHA1 Message Date
Frédéric Péters 3814073d30 add basic unit test for dummy 2018-07-25 21:10:16 +02:00
Frédéric Péters fd56510014 add basic unit test for tipi 2018-07-25 21:10:16 +02:00
Frédéric Péters bbf699c827 dummy: always parse query string as text 2018-07-25 21:10:16 +02:00
Frédéric Péters b88a759e10 dummy: use urlencode from six 2018-07-25 18:23:26 +02:00
Frédéric Péters d313a9cac4 dummy: iterate over a copy of dictionary keys as they are changed in the loop 2018-07-25 18:23:26 +02:00
François Poulain 36f2e38a84 use relative import 2018-07-25 10:53:32 +02:00
François Poulain 714bf8023b python3 exceptions 2018-07-25 10:53:29 +02:00
Frédéric Péters 7b2f106348 systempayv2: update for python3 2018-07-25 10:22:22 +02:00
Frédéric Péters 9e1ead2e7c spplus: adapt for python3 2018-07-25 10:22:22 +02:00
Frédéric Péters f99d6eeb91 sips2: adapt for python3 2018-07-25 10:22:22 +02:00
Frédéric Péters f3611bcc38 paybox: adapt for python3 2018-07-25 10:22:22 +02:00
Frédéric Péters fd6566a4ca ogone: use force_text 2018-07-25 10:22:22 +02:00
Frédéric Péters c028af586b common: add escape() method for python3 2018-07-25 10:17:59 +02:00
Frédéric Péters d009b35ab2 python3: force_text/force_byte 2018-07-25 10:14:25 +02:00
Frédéric Péters fe718e1159 python3: use importlib.import_module to import backends 2018-07-25 10:14:25 +02:00
Frédéric Péters 79735f6418 python3: use items instead of iteritems 2018-07-25 10:14:25 +02:00
Frédéric Péters f21d662912 python3: use string.ascii_letters 2018-07-25 10:14:25 +02:00
Frédéric Péters 8e74d949b5 python3: use six to get urlparse/urllib modules 2018-07-25 10:14:25 +02:00
Frédéric Péters 2469de19e2 tox: also run tests with python 3 2018-07-25 10:13:27 +02:00
Frédéric Péters b4cc0e71d4 python3: don't use leading 0 in integers 2018-07-25 10:13:27 +02:00
Frédéric Péters fbf3ebf34d python3: use print() for compatibility 2018-07-25 10:13:27 +02:00
Frédéric Péters 019188f70e python3: use relative import 2018-07-25 10:13:27 +02:00
Frédéric Péters 2ee8b2e5fd adapt setup.py for python3 compatibility 2018-07-25 10:13:27 +02:00
Frédéric Péters c2f059e7aa setup: add six as a dependency for python 2/3 compatibility 2018-07-25 10:13:27 +02:00
66 changed files with 1591 additions and 7152 deletions

View File

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

View File

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

5
.gitignore vendored
View File

@ -1,7 +1,4 @@
/debian/source
/dist
/build
/htmlcov
junit*xml
test_*xml
*.pyc
*.swp

View File

@ -1,22 +0,0 @@
# 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']

674
COPYING
View File

@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

52
Jenkinsfile vendored
View File

@ -1,52 +0,0 @@
@Library('eo-jenkins-lib@main') import eo.Utils
pipeline {
agent any
stages {
stage('Unit Tests') {
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 {
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 -d bullseye,bookworm --branch ${env.GIT_BRANCH} --hotfix ${SHORT_JOB_NAME}"
}
}
}
}
}
post {
always {
script {
utils = new Utils()
utils.mail_notify(currentBuild, env, 'ci+jenkins-eopayment@entrouvert.org')
}
}
success {
cleanWs()
}
}
}

View File

@ -1,11 +1,6 @@
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
include eopayment/resource/PaiementSecuriseService2.xsd
include eopayment/resource/PaiementSecuriseService3.xsd

View File

@ -2,7 +2,7 @@ Interface with French's bank online credit card processing services
===================================================================
Services supported are:
- ATOS/SIPS v2 used by:
- ATOS/SIPS used by:
- BNP under the name Mercanet,
- Banque Populaire (before 2010/2011) under the name Cyberplus,
- CCF under the name Elysnet,
@ -12,11 +12,11 @@ Services supported are:
- LCL under the name Sherlocks,
- Société Générale under the name Sogenactif
- and Crédit du Nord under the name Webaffaires,
- Payzen/SystemPay v2 by Banque Populaire (since 2010/2011) and Caisse d'Épargne (Natixis)
- TIPI/PayFiP Régie
- PayFiP Régie Web-service
- SystemPay by Banque Populaire (since 2010/2011) and Caisse d'Épargne
- TIPI
- Ogone
- Paybox
- SPPlus by Caisse d'épargne (obsolete)
- Payzen
You can emit payment request under a simple API which takes as input a
@ -26,178 +26,43 @@ 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 paybox module also depend upon the python Cryptodome library for RSA
signature validation on the responses.
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.
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:
- Payzen
- SIPS
- SystemPay
- PayFiP Régie Web-Service
For other backends, the order and transaction ids, separated by '!' are sent in
order id field, so they can be matched in backoffice.
PayFiP Régie Web-Service
========================
Changelog
=========
You can test your PayFiP regie web-service connection with an integrated CLI utility:
1.8
---
- fix UTF-8 character in non unicode log message
$ python3 -m eopayment.payfip_ws info-client --help
Usage: payfip_ws.py info-client [OPTIONS] NUMCLI
1.7
---
- check responses and raise ResponseError when theyr are malformed
Options:
--help Show this message and exit.
$ python3 -m eopayment.payfip_ws get-idop --help
Usage: payfip_ws.py get-idop [OPTIONS] NUMCLI
Options:
--saisie [T|X|W] [required]
--exer TEXT [required]
--montant INTEGER [required]
--refdet TEXT [required]
--mel TEXT [required]
--url-notification TEXT [required]
--url-redirect TEXT [required]
--objet TEXT
--help Show this message and exit.
$ python3 -m eopayment.payfip_ws info-paiement --help
Usage: payfip_ws.py info-paiement [OPTIONS] IDOP
Options:
--help Show this message and exit.
Generic CLI Tool
================
You can put some configuration in ~/.config/eopayment.init ::
[default]
debug=yes
[systempayv2]
# same name as passed in the options argument to Payment.__init__()
vads_site_id=12345678
secret_test=xyzabcdefgh
vads_ctx_mode=TEST
$ python3 -m eopayment --option vads_site_id=56781234 request 10.0 --param transaction_id=1234 --param email=john.doe@example.com
Transaction ID: 1234
<form method="POST" action="https://paiement.systempay.fr/vads-payment/">
<input type="hidden" name="vads_cust_country" value="FR"/>
<input type="hidden" name="vads_validation_mode" value=""/>
<input type="hidden" name="vads_site_id" value="12345678"/>
<input type="hidden" name="vads_payment_config" value="SINGLE"/>
<input type="hidden" name="vads_trans_id" value="GavPXW"/>
<input type="hidden" name="vads_action_mode" value="INTERACTIVE"/>
<input type="hidden" name="vads_contrib" value="eopayment"/>
<input type="hidden" name="vads_page_action" value="PAYMENT"/>
<input type="hidden" name="vads_amount" value="1010"/>
<input type="hidden" name="signature" value="d5690d02fed621687b19c90053a77b37a1c78370"/>
<input type="hidden" name="vads_ctx_mode" value="TEST"/>
<input type="hidden" name="vads_version" value="V2"/>
<input type="hidden" name="vads_payment_cards" value=""/>
<input type="hidden" name="vads_ext_info_eopayment_trans_id" value="1234"/>
<input type="hidden" name="vads_trans_date" value="20201027211254"/>
<input type="hidden" name="vads_language" value="fr"/>
<input type="hidden" name="vads_capture_delay" value=""/>
<input type="hidden" name="vads_currency" value="978"/>
<input type="hidden" name="vads_cust_email" value="john.doe@example.com"/>
<input type="hidden" name="vads_return_mode" value="GET"/>
<input type="submit" name="Submit" value="Submit" />
</form>
[ Local browser is automatically opened with this form which is auto-submitted ]
$ python3 -m eopayment --debug response 'vads_amount=1010&vads_auth_mode=FULL&vads_auth_number=3fd070&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB&vads_card_number=597010XXXXXX0018&vads_payment_certificate=f582e920616a33bdaa0c242ee3fc3d435d367575&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1010&vads_effective_currency=978&vads_site_id=12345678&vads_trans_date=20201029093825&vads_trans_id=Vlco55&vads_trans_uuid=e8defc7bd32c418c93c4b2be676d2796&vads_validation_mode=0&vads_version=V2&vads_warranty_result=&vads_payment_src=EC&vads_cust_country=FR&vads_contrib=eopayment&vads_tid=001&vads_sequence_number=1&vads_contract_used=2334410&vads_trans_status=AUTHORISED&vads_expiry_month=6&vads_expiry_year=2021&vads_bank_label=Banque+de+d%C3%A9mo+et+de+l%27innovation&vads_bank_product=MCW&vads_pays_ip=FR&vads_presentation_date=20201029093826&vads_effective_creation_date=20201029093826&vads_operation_type=DEBIT&vads_result=00&vads_extra_result=&vads_card_country=FR&vads_language=fr&vads_brand_management=%7B%22userChoice%22%3Afalse%2C%22brandList%22%3A%22CB%7CMASTERCARD%22%2C%22brand%22%3A%22CB%22%7D&vads_action_mode=INTERACTIVE&vads_payment_config=SINGLE&vads_page_action=PAYMENT&vads_threeds_enrolled=Y&vads_threeds_auth_type=CHALLENGE&vads_threeds_eci=02&vads_threeds_xid=RFBSMkdWdFE0Wk15VWw0RkJjMzU%3D&vads_threeds_cavvAlgorithm=2&vads_threeds_status=Y&vads_threeds_sign_valid=1&vads_threeds_error_code=&vads_threeds_exit_status=10&vads_threeds_cavv=jG26AYSjvclBARFYSf%2FtXRmjGXM%3D&signature=5594aa2bc35c9e45e759b08df339e5f8ecf2c410'
result : 3
signed : True
bank_data :
{'__bank_id': '3fd070',
'signature': '5594aa2bc35c9e45e759b08df339e5f8ecf2c410',
'vads_action_mode': 'INTERACTIVE',
'vads_amount': '1010',
'vads_auth_mode': 'FULL',
'vads_auth_number': '3fd070',
'vads_auth_result': '00',
'vads_auth_result_message': 'Transaction approuvée ou traitée avec succès',
'vads_bank_label': "Banque de démo et de l'innovation",
'vads_bank_product': 'MCW',
'vads_brand_management': '{"userChoice":false,"brandList":"CB|MASTERCARD","brand":"CB"}',
'vads_capture_delay': '0',
'vads_card_brand': 'CB',
'vads_card_country': 'FR',
'vads_card_number': '597010XXXXXX0018',
'vads_contract_used': '2334410',
'vads_contrib': 'eopayment',
'vads_ctx_mode': 'TEST',
'vads_currency': '978',
'vads_cust_country': 'FR',
'vads_effective_amount': '1010',
'vads_effective_creation_date': '20201029093826',
'vads_effective_currency': '978',
'vads_expiry_month': '6',
'vads_expiry_year': '2021',
'vads_extra_result': '',
'vads_extra_result_message': 'Pas de contrôle effectué.',
'vads_language': 'fr',
'vads_operation_type': 'DEBIT',
'vads_page_action': 'PAYMENT',
'vads_payment_certificate': 'f582e920616a33bdaa0c242ee3fc3d435d367575',
'vads_payment_config': 'SINGLE',
'vads_payment_src': 'EC',
'vads_pays_ip': 'FR',
'vads_presentation_date': '20201029093826',
'vads_result': '00',
'vads_result_message': 'Paiement réalisé avec succés.',
'vads_sequence_number': '1',
'vads_site_id': '12345678',
'vads_threeds_auth_type': 'CHALLENGE',
'vads_threeds_cavv': 'jG26AYSjvclBARFYSf/tXRmjGXM=',
'vads_threeds_cavvAlgorithm': '2',
'vads_threeds_eci': '02',
'vads_threeds_enrolled': 'Y',
'vads_threeds_error_code': '',
'vads_threeds_exit_status': '10',
'vads_threeds_sign_valid': '1',
'vads_threeds_status': 'Y',
'vads_threeds_xid': 'RFBSMkdWdFE0Wk15VWw0RkJjMzU=',
'vads_tid': '001',
'vads_trans_date': '20201029093825',
'vads_trans_id': 'Vlco55',
'vads_trans_status': 'AUTHORISED',
'vads_trans_uuid': 'e8defc7bd32c418c93c4b2be676d2796',
'vads_validation_mode': '0',
'vads_version': 'V2',
'vads_warranty_result': ''}
return_content : None
bank_status : Paiement réalisé avec succés.
transaction_id : 3fd070
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.6
---
- fix payzen service_url
- rationalize Payment object constructors
1.5
---
- uniformize constructors
- fix service_url in the payzen backend
1.4
---
- add sips2 backend to conform with version 2.3 of their interface
1.3
---
- add payzen backend

10
debian/changelog vendored
View File

@ -1,5 +1,11 @@
eopayment (0.0.20-0) stable; urgency=low
eopayment (0.0.20-0) squeeze; urgency=low
* Dummy changelog.
* New packaging for debian squeeze.
-- Benjamin Dauvergne <bdauvergne@dor-lomin.entrouvert.com> Mon, 07 Oct 2013 15:13:59 +0200
eopayment (0.0.1-1) lenny; urgency=low
* Initial package, targetting lenny.
-- Frederic Peters <fpeters@debian.org> Mon, 02 May 2011 09:51:28 +0200

1
debian/compat vendored Normal file
View File

@ -0,0 +1 @@
7

23
debian/control vendored
View File

@ -2,27 +2,16 @@ Source: eopayment
Section: python
Priority: optional
Maintainer: Frederic Peters <fpeters@debian.org>
Build-Depends: debhelper-compat (= 12),
python3-all,
python3-django,
python3-requests,
python3-setuptools,
python3-tz,
dh-python,
git
Build-Depends: python-setuptools (>= 0.6b3), python-all (>= 2.6), debhelper (>= 7.4.3)
Standards-Version: 3.9.1
X-Python3-Version: >= 3.4
X-Python-Version: >= 2.6
Homepage: http://dev.entrouvert.org/projects/eopayment/
Package: python3-eopayment
Package: python-eopayment
Architecture: all
Depends: ${python3:Depends},
python3-zeep (>= 2.5),
python3-pycryptodome,
python3-click
Description: common API to access online payment services (Python 3)
Depends: ${python:Depends}
XB-Python-Version: ${python:Versions}
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.
.
This is the Python 3 version of the package.

View File

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

7
debian/rules vendored
View File

@ -1,8 +1,9 @@
#!/usr/bin/make -f
export PYBUILD_NAME=eopayment
export PYBUILD_DISABLE=test
# This file was automatically generated by stdeb 0.6.0+git at
# Fri, 14 Jun 2013 17:33:52 +0200
%:
dh $@ --with python3 --buildsystem=pybuild
dh $@ --with python2 --buildsystem=python_distutils

View File

@ -1,322 +1,164 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
import datetime
import importlib
import logging
import pytz
from .common import (URL, HTML, FORM, RECEIVED, ACCEPTED, PAID, DENIED,
CANCELED, CANCELLED, ERROR, WAITING, ResponseError, force_text)
from .common import ( # noqa: F401
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',
'KEYWARE',
'MOLLIE',
]
__all__ = ['Payment', 'URL', 'HTML', 'FORM', 'SIPS',
'SYSTEMPAY', 'SPPLUS', 'TIPI', 'DUMMY', 'get_backend', 'RECEIVED', 'ACCEPTED',
'PAID', 'DENIED', 'CANCELED', 'CANCELLED', 'ERROR', 'WAITING', 'get_backends']
SIPS = 'sips'
SIPS2 = 'sips2'
SYSTEMPAY = 'systempayv2'
SPPLUS = 'spplus'
TIPI = 'tipi'
DUMMY = 'dummy'
OGONE = 'ogone'
PAYBOX = 'paybox'
PAYZEN = 'payzen'
PAYFIP_WS = 'payfip_ws'
KEYWARE = 'keyware'
MOLLIE = 'mollie'
SAGA = 'saga'
logger = logging.getLogger(__name__)
def get_backend(kind):
'''Resolve a backend name into a module object'''
module = importlib.import_module('.' + kind, package='eopayment')
return module.Payment
__BACKENDS = [DUMMY, SIPS2, SYSTEMPAY, OGONE, PAYBOX, PAYZEN, TIPI, PAYFIP_WS, KEYWARE, MOLLIE, SAGA]
__BACKENDS = [ DUMMY, SIPS, SIPS2, SYSTEMPAY, SPPLUS, OGONE, PAYBOX, PAYZEN, TIPI ]
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 {backend: get_backend(backend) for backend in __BACKENDS}
'''
return dict((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.
>>> spplus_options = { \
'cle': '58 6d fc 9c 34 91 9b 86 3f fd 64 ' \
'63 c9 13 4a 26 ba 29 74 1e c7 e9 80 79', \
'siret': '00000000000001-01', \
}
>>> p = Payment(kind=SPPLUS, options=spplus_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.spplus.net/paiement/init.do?...')
>>> 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:
- sips, 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.
- spplus for Caisse d'épargne
- systempay for Banque Populaire (after 2010)
- 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.
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')
delay_param = False
for parameter in self.backend.description['parameters']:
if parameter['name'] == 'capture_day':
delay_param = True
break
if not delay_param:
raise ValueError('capture_date is not supported by the backend.')
if not isinstance(capture_date, datetime.date):
raise ValueError('capture_date should be a datetime.date 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)
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.')
kwargs['capture_day'] = force_text(capture_day)
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)
for param in kwargs:
# encode all input params to unicode
kwargs[param] = force_text(kwargs[param])
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.
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'):
res = []
for param in self.backend.description.get('parameters', []):
if param.get('scope', 'global') != scope:
continue
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.
'''
Validate and trigger the transmission of a transaction to the bank.
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
Arguments:
- amount -- the amount of money
- bank_data -- the transaction dictionary received from the bank
'''
return self.backend.validate(amount, bank_data, **kwargs)

View File

@ -1,127 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 configparser
import decimal
import logging
import os.path
import pprint
import subprocess
import tempfile
import click
from . import FORM, URL, Payment
def option(value):
try:
name, value = value.split('=', 1)
except Exception:
raise ValueError('invalid option %s' % value)
return (name, value)
@click.group()
@click.option('--option', type=option, multiple=True)
@click.option('--name', type=str, default='')
@click.option('--backend', type=str)
@click.option('--debug/--no-debug', default=None)
@click.pass_context
def main(ctx, backend, debug, option, name):
config_file = os.path.expanduser('~/.config/eopayment.ini')
option = list(option)
if os.path.exists(config_file):
parser = configparser.ConfigParser(interpolation=None)
with open(config_file) as fd:
parser.read_file(fd)
if debug is None:
debug = parser.getboolean('default', 'debug', fallback=False)
if debug is True:
logging.basicConfig(level=logging.DEBUG)
for section in parser.sections():
if section == 'default':
continue
if ':' in section:
config_backend, config_name = section.split(':', 1)
else:
config_backend, config_name = section, None
load = False
if not name and not backend:
# use first section
logging.debug('no backend and not name given using first section found')
backend = config_backend
load = True
elif name and backend:
load = config_backend == backend and config_name == name
elif name:
load = config_name == name
elif backend:
load = config_backend == backend
if load:
logging.debug('loading configuration "%s"', section)
backend = backend or config_backend
option.extend(parser.items(section=section))
break
else:
if not backend:
raise ValueError('no backend found')
if parser.has_section(backend):
option.extend(parser.items(section=backend))
if debug is True:
logging.basicConfig(level=logging.DEBUG)
ctx.obj = Payment(backend, dict(option))
@main.command()
@click.argument('amount', type=decimal.Decimal)
@click.option('--param', type=option, multiple=True)
@click.pass_obj
def request(backend, amount, param):
transaction_id, kind, what = backend.request(amount, **dict(param))
print('Transaction ID:', transaction_id)
if kind == FORM:
print(what)
with tempfile.NamedTemporaryFile(mode='w', delete=False) as fd:
print('<!doctype html> <html> <head> <meta charset="utf-8"> </head> <body>', file=fd)
print(what, file=fd)
print('''<script>document.querySelector('input[type="submit"]').click()</script>''', file=fd)
subprocess.call(['gnome-www-browser', fd.name])
print('</body> <html>', file=fd)
elif kind == URL:
print('Please click on URL:', what)
subprocess.call(['gnome-www-browser', what])
@main.command()
@click.argument('query_string', type=str)
@click.option('--param', type=option, multiple=True)
@click.pass_obj
def response(backend, query_string, param):
payment_response = backend.response(query_string, **dict(param))
for key, value in payment_response.__dict__.items():
if not isinstance(value, (dict, list)):
print(' ', key, ':', value)
else:
print(' ', key, ':')
formatted_value = pprint.pformat(value)
for line in formatted_value.splitlines(False):
print(' ', line)
main()

View File

@ -1,64 +1,39 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
'''Responses codes emitted by EMV Card or 'Carte Bleu' in France'''
from . import CANCELLED, DENIED, ERROR, PAID
CB_RESPONSE_CODES = {
'00': {'message': 'Transaction approuvée ou traitée avec succès', 'result': PAID},
'02': {'message': 'Contacter l\'émetteur de carte'},
'03': {'message': 'Accepteur invalide'},
'04': {'message': 'Conserver la carte'},
'05': {'message': 'Ne pas honorer', 'result': DENIED},
'07': {'message': 'Conserver la carte, conditions spéciales'},
'08': {'message': 'Approuver après identification'},
'12': {'message': 'Transaction invalide'},
'13': {'message': 'Montant invalide'},
'14': {'message': 'Numéro de porteur invalide'},
'15': {'message': 'Emetteur de carte inconnu'},
'17': {'message': 'Annulation par l\'acheteur', 'result': CANCELLED},
'30': {'message': 'Erreur de format'},
'31': {'message': 'Identifiant de l\'organisme acquéreur inconnu'},
'33': {'message': 'Date de validité de la carte dépassée'},
'34': {'message': 'Suspicion de fraude'},
'41': {'message': 'Carte perdue'},
'43': {'message': 'Carte volée'},
'51': {'message': 'Provision insuffisante ou crédit dépassé'},
'54': {'message': 'Date de validité de la carte dépassée'},
'56': {'message': 'Carte absente du fichier'},
'57': {'message': 'Transaction non permise à ce porteur'},
'58': {'message': 'Transaction interdite au terminal'},
'59': {'message': 'Suspicion de fraude'},
'60': {'message': 'L\'accepteur de carte doit contacter l\'acquéreur'},
'61': {'message': 'Dépasse la limite du montant de retrait'},
'63': {'message': 'Règles de sécurité non respectées'},
'68': {'message': 'Réponse non parvenue ou reçue trop tard'},
'90': {'message': 'Arrêt momentané du système'},
'91': {'message': 'Emetteur de cartes inaccessible'},
'96': {'message': 'Mauvais fonctionnement du système'},
'97': {'message': 'Échéance de la temporisation de surveillance globale'},
'98': {'message': 'Serveur indisponible routage réseau demandé à nouveau'},
'99': {'message': 'Incident domaine initiateur'},
'00': 'Transaction approuvée ou traitée avec succès',
'02': 'Contacter l\'émetteur de carte',
'03': 'Accepteur invalide',
'04': 'Conserver la carte',
'05': 'Ne pas honorer',
'07': 'Conserver la carte, conditions spéciales',
'08': 'Approuver après identification',
'12': 'Transaction invalide',
'13': 'Montant invalide',
'14': 'Numéro de porteur invalide',
'15': 'Emetteur de carte inconnu',
'30': 'Erreur de format',
'31': 'Identifiant de l\'organisme acquéreur inconnu',
'33': 'Date de validité de la carte dépassée',
'34': 'Suspicion de fraude',
'41': 'Carte perdue',
'43': 'Carte volée',
'51': 'Provision insuffisante ou crédit dépassé',
'54': 'Date de validité de la carte dépassée',
'56': 'Carte absente du fichier',
'57': 'Transaction non permise à ce porteur',
'58': 'Transaction interdite au terminal',
'59': 'Suspicion de fraude',
'60': 'L\'accepteur de carte doit contacter l\'acquéreur',
'61': 'Dépasse la limite du montant de retrait',
'63': 'Règles de sécurité non respectées',
'68': 'Réponse non parvenue ou reçue trop tard',
'90': 'Arrêt momentané du système',
'91': 'Emetteur de cartes inaccessible',
'96': 'Mauvais fonctionnement du système',
'97': 'Échéance de la temporisation de surveillance globale',
'98': 'Serveur indisponible routage réseau demandé à nouveau',
'99': 'Incident domaine initiateur',
}
def translate_cb_error_code(error_code):
'Returns message, eopayment_error_code'
if error_code in CB_RESPONSE_CODES:
return CB_RESPONSE_CODES[error_code]['message'], CB_RESPONSE_CODES[error_code].get('result', ERROR)
return None, None

View File

@ -1,37 +1,18 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 html
import logging
import os
import os.path
import os
import random
import sys
import logging
from datetime import date
from decimal import ROUND_DOWN, Decimal
from gettext import gettext as _
try:
if 'django' in sys.modules:
from django.utils.translation import gettext_lazy as _
except ImportError:
pass
import six
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()
@ -47,7 +28,6 @@ DENIED = 4
CANCELLED = 5
CANCELED = 5 # typo for backward compatibility
WAITING = 6
EXPIRED = 7
ERROR = 99
# separator between order and transaction ids
@ -55,21 +35,25 @@ ORDERID_TRANSACTION_SEPARATOR = '!'
def force_text(s, encoding='utf-8'):
if issubclass(type(s), str):
if issubclass(type(s), six.text_type):
return s
try:
if not issubclass(type(s), str):
if isinstance(s, bytes):
s = str(s, encoding)
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)
else:
s = str(s)
s = six.text_type(bytes(s), encoding)
else:
s = s.decode(encoding)
except UnicodeDecodeError:
return str(s, encoding, 'ignore')
return six.text_type(s, encoding, 'ignore')
return s
def force_byte(s, encoding='utf-8'):
if isinstance(s, bytes):
return s
@ -79,51 +63,34 @@ def force_byte(s, encoding='utf-8'):
return s.encode()
class PaymentException(Exception):
class ResponseError(Exception):
pass
class ResponseError(PaymentException):
pass
class PaymentResponse(object):
'''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.
'''
class BackendNotFound(PaymentException):
pass
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,
):
def __init__(self, result=None, signed=None, bank_data=dict(),
return_content=None, bank_status='', transaction_id='',
order_id='', test=False):
self.result = result
self.signed = signed
self.bank_data = bank_data
@ -132,7 +99,6 @@ class PaymentResponse:
self.transaction_id = transaction_id
self.order_id = order_id
self.test = test
self.transaction_date = transaction_date
def __repr__(self):
return '<%s %s>' % (self.__class__.__name__, repr(self.__dict__))
@ -150,10 +116,7 @@ class PaymentResponse:
return self.result == ERROR
class PaymentCommon:
has_free_transaction_id = False
has_empty_response = False
class PaymentCommon(object):
PATH = '/tmp'
BANK_ID = '__bank_id'
@ -171,35 +134,21 @@ class PaymentCommon:
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)
except Exception:
fd = os.open(os.path.join(self.PATH, name),
os.O_CREAT | os.O_EXCL)
except:
raise
else:
os.close(fd)
return id
@staticmethod
def clean_amount(amount, min_amount=0, max_amount=None, cents=True):
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('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)
class Form:
def __init__(self, url, method, fields, encoding='utf-8', submit_name='Submit', submit_value='Submit'):
class Form(object):
def __init__(self, url, method, fields, encoding='utf-8',
submit_name='Submit', submit_value='Submit'):
self.url = url
self.method = method
self.fields = fields
@ -217,16 +166,17 @@ class Form:
return s
def escape(self, s):
return html.escape(force_text(s, self.encoding))
if six.PY3:
return html.escape(force_text(s, self.encoding))
else:
return cgi.escape(force_text(s, self.encoding)).encode(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

@ -1,195 +1,116 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 string
import logging
import uuid
import warnings
from urllib.parse import parse_qs, urlencode
from .common import ERROR, PAID, URL, WAITING, PaymentCommon, PaymentResponse, ResponseError, _, force_text
def N_(message): return message
__all__ = ['Payment']
from six.moves.urllib.parse import parse_qs, urlencode
from .common import (PaymentCommon, URL, PaymentResponse, PAID, ERROR, WAITING,
ResponseError, force_text)
SERVICE_URL = 'https://dummy-payment.entrouvert.com/'
__all__ = [ 'Payment' ]
SERVICE_URL = 'http://dummy-payment.demo.entrouvert.com/'
ALPHANUM = string.ascii_letters + string.digits
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:
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).
"""
http://dummy-payment.demo.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).
'''
description = {
'caption': 'Dummy payment backend',
'parameters': [
{
'name': 'normal_return_url',
'caption': _('Normal return URL'),
'default': '',
'required': True,
},
{
'name': 'automatic_return_url',
'caption': _('Automatic return URL'),
'required': False,
},
{
'name': 'dummy_service_url',
'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'),
'type': str,
'default': 'origin',
},
{
'name': 'consider_all_response_signed',
'caption': _(
'All response will be considered as signed '
'(to test payment locally for example, as you '
'cannot received the signed callback)'
),
'type': bool,
'default': False,
},
{
'name': 'number',
'caption': 'dummy integer input test',
'type': int,
},
{
'name': 'choice',
'caption': 'dummy choice input test',
'choices': ['a', 'b'],
},
{
'name': 'choices',
'caption': 'dummy choices input test',
'choices': ['a', 'b'],
'type': list,
},
{
'name': 'direct_notification_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'),
'type': str,
'deprecated': True,
},
{
'name': 'capture_day',
'type': str,
},
],
'caption': 'Dummy payment backend',
'parameters': [
{
'name': 'normal_return_url',
'caption': N_('Normal return URL'),
'default': '',
'required': True,
},
{
'name': 'automatic_return_url',
'caption': N_('Automatic return URL'),
'required': False,
},
{ 'name': 'dummy_service_url',
'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',
'type': str,
},
{ 'name': 'siret',
'caption': 'dummy siret parameter',
'type': str,
},
{ 'name': 'consider_all_response_signed',
'caption': 'All response will be considered as signed '
'(to test payment locally for example, as you '
'cannot received the signed callback)',
'type': bool,
'default': False,
},
{ 'name': 'direct_notification_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',
'type': str,
'deprecated': True,
},
],
}
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,
)
transaction_id = str(uuid.uuid4().hex)
def request(self, amount, name=None, address=None, email=None, phone=None,
orderid=None, info1=None, info2=None, info3=None, next_url=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)
transaction_id = self.transaction_id(30, ALPHANUM, 'dummy', self.siret)
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,
'amount': amount,
'email': email,
'return_url': normal_return_url or '',
'direct_notification_url': automatic_return_url or '',
'origin': self.origin,
'transaction_id': transaction_id,
'siret': self.siret,
'amount': amount,
'email': email,
'return_url': normal_return_url or '',
'direct_notification_url': automatic_return_url or '',
'origin': self.origin
}
query.update(
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:
query['subject'] = subject
query.update(dict(name=name, address=address, email=email, phone=phone,
orderid=orderid, info1=info1, info2=info2, info3=info3))
for key in list(query.keys()):
if query[key] is None:
del query[key]
@ -198,9 +119,9 @@ class Payment(PaymentCommon):
def response(self, query_string, logger=LOGGER, **kwargs):
form = parse_qs(force_text(query_string))
if 'transaction_id' not in form:
raise ResponseError('missing transaction_id')
transaction_id = form.get('transaction_id', [''])[0]
if not 'transaction_id' in form:
raise ResponseError()
transaction_id = form.get('transaction_id',[''])[0]
form[self.BANK_ID] = transaction_id
signed = 'signed' in form
@ -213,16 +134,14 @@ class Payment(PaymentCommon):
if 'waiting' in form:
result = WAITING
response = PaymentResponse(
result=result,
signed=signed,
bank_data=form,
return_content=content,
order_id=transaction_id,
transaction_id=transaction_id,
bank_status=form.get('reason'),
test=True,
)
response = PaymentResponse(result=result,
signed=signed,
bank_data=form,
return_content=content,
order_id=transaction_id,
transaction_id=transaction_id,
bank_status=form.get('reason'),
test=True)
return response
def validate(self, amount, bank_data, **kwargs):
@ -230,9 +149,3 @@ 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

@ -1,143 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
from urllib.parse import parse_qs, urljoin
import requests
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'),
'parameters': [
{
'name': 'normal_return_url',
'caption': _('Normal return URL'),
'required': True,
},
{
'name': 'automatic_return_url',
'caption': _('Asychronous return URL'),
'required': True,
},
{
'name': 'service_url',
'caption': _('URL of the payment service'),
'default': service_url,
'type': str,
'validation': lambda x: x.startswith('https'),
},
{
'name': 'api_key',
'caption': _('API key'),
'required': True,
},
],
}
def request(self, amount, email=None, first_name=None, last_name=None, **kwargs):
amount = int(self.clean_amount(amount, min_amount=0.01))
body = {
'currency': 'EUR',
'amount': amount,
'return_url': self.normal_return_url,
'webhook_url': self.automatic_return_url,
'customer': {
'email_address': email,
'first_name': first_name,
'last_name': last_name,
},
}
resp = self.call_endpoint('POST', 'orders', data=body)
return resp['id'], URL, resp['order_url']
def response(self, query_string, **kwargs):
fields = parse_qs(query_string, keep_blank_values=True)
order_id = fields['order_id'][0]
resp = self.call_endpoint('GET', 'orders/' + order_id)
# XXX: to add accepted we need to handle the capture mode (manual or
# delayed), see
# https://dev.online.emspay.eu/rest-api/features/authorizations-captures-and-voiding
status = resp['status']
if status == 'completed':
result = PAID
elif status in ('new', 'processing'):
result = WAITING
elif status in ('cancelled', 'expired'):
result = CANCELLED
else:
result = ERROR
response = PaymentResponse(
result=result,
signed=True,
bank_data=resp,
order_id=order_id,
transaction_id=order_id,
bank_status=status,
test=bool('is-test' in resp.get('flags', [])),
)
return response
def cancel(self, amount, bank_data, **kwargs):
order_id = bank_data['id']
resp = self.call_endpoint('DELETE', 'orders/' + order_id)
status = resp['status']
if not status == 'cancelled':
raise ResponseError('expected "cancelled" status, got "%s" instead' % status)
return resp
def call_endpoint(self, method, endpoint, data=None):
url = urljoin(self.service_url, endpoint)
try:
response = requests.request(method, url, auth=(self.api_key, ''), json=data)
except requests.exceptions.RequestException as e:
raise PaymentException('%s error on endpoint "%s": %s' % (method, endpoint, e))
try:
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)
)
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))
return result

View File

@ -1,178 +0,0 @@
# 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

@ -1,179 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
from urllib.parse import parse_qs, urljoin
import requests
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 = {
'caption': 'Mollie payment backend',
'parameters': [
{
'name': 'normal_return_url',
'caption': _('Normal return URL'),
'required': True,
},
{
'name': 'automatic_return_url',
'caption': _('Asychronous return URL'),
'required': True,
},
{
'name': 'service_url',
'caption': _('URL of the payment service'),
'default': service_url,
'type': str,
'validation': lambda x: x.startswith('https'),
},
{
'name': 'api_key',
'caption': _('API key'),
'required': True,
'validation': lambda x: x.startswith('test_') or x.startswith('live_'),
},
{
'name': 'description_text',
'caption': _('General description that will be displayed for all payments'),
'required': True,
},
],
}
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
}
body = {
'amount': {
'value': amount,
'currency': 'EUR',
},
'redirectUrl': self.normal_return_url,
'webhookUrl': self.automatic_return_url,
'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):
if redirect:
if order_status_hint in (PAID, CANCELLED, ERROR):
return PaymentResponse(order_id=order_id_hint, result=order_status_hint)
else:
payment_id = order_id_hint
elif query_string:
fields = parse_qs(query_string)
payment_id = fields['id'][0]
else:
raise ResponseError('cannot infer payment id')
resp = self.call_endpoint('GET', 'payments/' + payment_id)
status = resp['status']
if status == 'paid':
result = PAID
elif status in ('canceled', 'expired'):
result = CANCELLED
elif status in ('open', 'pending'):
result = WAITING
elif status == 'authorized':
result = ACCEPTED
else:
result = ERROR
response = PaymentResponse(
result=result,
signed=True,
bank_data=resp,
order_id=payment_id,
transaction_id=payment_id,
bank_status=status,
test=resp['mode'] == 'test',
)
return response
def call_endpoint(self, method, endpoint, data=None):
url = urljoin(self.service_url, endpoint)
headers = {'Authorization': 'Bearer %s' % self.api_key}
try:
response = requests.request(method, url, headers=headers, json=data)
except requests.exceptions.RequestException as e:
raise PaymentException('%s error on endpoint "%s": %s' % (method, endpoint, e))
try:
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)
)
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))
)
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,41 +1,14 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
import hashlib
import uuid
from decimal import ROUND_HALF_UP, Decimal
from urllib import parse as urlparse
import string
import six
from six.moves.urllib import parse as urlparse
from decimal import Decimal, ROUND_HALF_UP
from .common import (
ACCEPTED,
CANCELLED,
DENIED,
ERROR,
FORM,
ORDERID_TRANSACTION_SEPARATOR,
PAID,
WAITING,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
)
from .common import (PaymentCommon, PaymentResponse, FORM, CANCELLED, PAID,
ERROR, Form, DENIED, ACCEPTED, ORDERID_TRANSACTION_SEPARATOR,
WAITING, ResponseError, force_byte, force_text)
def N_(message): return message
ENVIRONMENT_TEST = 'TEST'
ENVIRONMENT_TEST_URL = 'https://secure.ogone.com/ncol/test/orderstandard.asp'
@ -436,95 +409,73 @@ TRXDATE
VC
""".split()
class Payment(PaymentCommon):
# See http://payment-services.ingenico.com/fr/fr/ogone/support/guides/integration%20guides/e-commerce
description = {
'caption': _('Ogone / Ingenico Payment System e-Commerce'),
'caption': N_('Système de paiement Ogone / Ingenico Payment System e-Commerce'),
'parameters': [
{
'name': 'normal_return_url',
'caption': _('Normal return URL'),
'caption': N_('Normal return URL'),
'default': '',
'required': True,
},
{
'name': 'automatic_return_url',
'caption': _('Automatic return URL (ignored, must be set in Ogone backoffice)'),
'caption': N_('Automatic return URL (ignored, must be set in Ogone backoffice)'),
'required': False,
},
{
'name': 'environment',
{'name': 'environment',
'default': ENVIRONMENT_TEST,
'caption': 'Environnement',
'caption': N_(u'Environnement'),
'choices': ENVIRONMENT,
},
{
'name': 'pspid',
'caption': "Nom d'affiliation dans le système",
{'name': 'pspid',
'caption': N_(u"Nom d'affiliation dans le système"),
'required': True,
},
{
'name': 'language',
'caption': _('Language'),
{'name': 'language',
'caption': N_(u'Langage'),
'default': 'fr_FR',
'choices': (('fr_FR', 'français'),),
'choices': (('fr_FR', N_('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': 'Algorithme de hachage',
{'name': 'hash_algorithm',
'caption': N_(u'Algorithme de hachage'),
'default': 'sha1',
},
{
'name': 'sha_in',
'caption': 'Clé SHA-IN',
{'name': 'sha_in',
'caption': N_(u'Clé SHA-IN'),
'required': True,
},
{
'name': 'sha_out',
'caption': 'Clé SHA-OUT',
{'name': 'sha_out',
'caption': N_(u'Clé SHA-OUT'),
'required': True,
},
{
'name': 'currency',
'caption': 'Monnaie',
{'name': 'currency',
'caption': N_(u'Monnaie'),
'default': 'EUR',
'choices': ('EUR',),
},
],
]
}
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'):
def sha_sign(self, algo, key, params, keep):
'''Ogone signature algorithm of query string'''
values = params.items()
values = [(a.upper(), b) for a, b in values]
values = sorted(values)
values = ['%s=%s' % (a, b) for a, b in values if a in keep and b]
values = [u'%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=encoding)
tosign = force_byte(tosign)
hashing = getattr(hashlib, algo)
return hashing(tosign).hexdigest().upper()
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_in(self, params):
return self.sha_sign(self.hash_algorithm, self.sha_in, params, SHA_IN_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 sha_sign_out(self, params):
return self.sha_sign(self.hash_algorithm, self.sha_out, params, SHA_OUT_PARAMS)
def get_request_url(self):
if self.environment == ENVIRONMENT_TEST:
@ -533,39 +484,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,
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
def request(self, amount, orderid=None, name=None, email=None,
language=None, description=None, **kwargs):
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
# arrondi comptable francais
amount = amount.quantize(Decimal('1.'), rounding=ROUND_HALF_UP)
params = {
'AMOUNT': force_text(amount),
'ORDERID': orderid,
'PSPID': self.pspid,
'LANGUAGE': language,
'CURRENCY': self.currency,
'COMPLUS': complus,
'AMOUNT': force_text(amount),
'ORDERID': reference,
'PSPID': self.pspid,
'LANGUAGE': language,
'CURRENCY': self.currency,
}
if self.normal_return_url:
params['ACCEPTURL'] = self.normal_return_url
@ -587,35 +526,34 @@ class Payment(PaymentCommon):
params[key] = force_text(params[key])
url = self.get_request_url()
form = Form(
url=url,
method='POST',
fields=[{'type': 'hidden', 'name': key, 'value': params[key]} for key in params],
)
return complus, FORM, form
url=url,
method='POST',
fields=[{'type': 'hidden',
'name': key,
'value': params[key]} for key in params])
return reference, FORM, form
def response(self, query_string, **kwargs):
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')
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']):
raise ResponseError()
# py2: decode binary strings in query-string
# uniformize iso-8859-1 encoded values
for key in params:
params[key] = force_text(params[key], self.encoding)
orderid = params['ORDERID']
complus = params.get('COMPLUS')
params[key] = force_text(params[key], 'iso-8859-1')
reference = params['ORDERID']
transaction_id = params['PAYID']
status = params['STATUS']
error = params['NCERROR']
signed = False
if self.sha_in:
signature = params.get('SHASIGN')
# 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
expected_signature = self.sha_sign_out(params)
signed = signature == expected_signature
if status == '1':
result = CANCELLED
elif status == '2':
@ -631,35 +569,15 @@ 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=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
result=result,
signed=signed,
bank_data=params,
order_id=reference,
transaction_id=transaction_id)

View File

@ -1,55 +1,27 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8
import base64
import codecs
from collections import OrderedDict
import datetime
import logging
import hashlib
import hmac
import logging
import random
import re
import uuid
from decimal import Decimal, ROUND_DOWN
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 string
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 . import cb
from .common import (
CANCELLED,
DENIED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
)
from .common import (PaymentCommon, PaymentResponse, FORM, PAID, ERROR, Form,
ORDERID_TRANSACTION_SEPARATOR, ResponseError, force_text)
__all__ = ['sign', 'Payment']
@ -66,7 +38,7 @@ VARS = {
'PBX_IDENTIFIANT': 'Identifiant interne (fourni par Paybox)',
'PBX_TOTAL': 'Montant total de la transaction',
'PBX_DEVISE': 'Devise de la transaction',
'PBX_CMD': 'Référence commande côté commerçant',
'PBX_CMD': 'Référence commande côté commerçant',
'PBX_PORTEUR': 'Adresse E - mail de lacheteur',
'PBX_RETOUR': 'Liste des variables à retourner par Paybox',
'PBX_HASH': 'Type dalgorit hme de hachage pour le calcul de lempreinte',
@ -75,52 +47,40 @@ VARS = {
}
PAYBOX_ERROR_CODES = {
'00000': {'message': 'Paiement réalisé avec succés.', 'result': PAID},
'00001': {
'message': 'Demande annulée par l\'usager.',
'result': CANCELLED,
},
'001xx': {
'message': 'Paiement refusé par le centre dautorisation [voir '
'§12.112.1 Codes réponses du centre dautorisationCodes réponses du '
'centre dautorisation]. En cas dautorisation de la transaction par '
'le centre dautorisation de la banque ou de létablissement financier '
'privatif, le code erreur “00100” sera en fait remplacé directement '
'par “00000”.'
},
'00003': {
'message': 'Erreur Paybox. Dans ce cas, il est souhaitable de faire une '
'tentative sur le site secondaire FQDN tpeweb1.paybox.com.'
},
'00004': {'message': 'Numéro de porteur ou cryptogramme visuel invalide.'},
'00006': {'message': 'Accès refusé ou site/rang/identifiant incorrect.'},
'00008': {'message': 'Date de fin de validité incorrecte.'},
'00009': {'message': 'Erreur de création dun abonnement.'},
'00010': {'message': 'Devise inconnue.'},
'00011': {'message': 'Montant incorrect.'},
'00015': {'message': 'Paiement déjà effectué.'},
'00016': {
'message': 'Abonné déjà existant (inscription nouvel abonné). Valeur '
'U de la variable PBX_RETOUR.'
},
'00021': {'message': 'Carte non autorisée.', 'result': DENIED},
'00029': {
'message': 'Carte non conforme. Code erreur renvoyé lors de la documentation de la variable « PBX_EMPREINTE ».'
},
'00030': {
'message': 'Temps dattente > 15 mn par linternaute/acheteur au niveau de la page de paiements.'
},
'00031': {'message': 'Réservé'},
'00032': {'message': 'Réservé'},
'00033': {
'message': 'Code pays de ladresse IP du navigateur de lacheteur non autorisé.',
'result': DENIED,
},
'00040': {
'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.'},
'00000': 'Opération réussie.',
'00001': 'La connexion au centre dautorisation a échoué ou une '
'erreur interne est survenue. Dans ce cas, il est souhaitable de faire '
'une tentative sur le site secondaire : tpeweb1.paybox.com.',
'001xx': 'Paiement refusé par le centre dautorisation [voir '
'§12.112.1 Codes réponses du centre dautorisationCodes réponses du '
'centre dautorisation]. En cas dautorisation de la transaction par '
'le centre dautorisation de la banque ou de létablissement financier '
'privatif, le code erreur “00100” sera en fait remplacé directement '
'par “00000”.',
'00003': 'Erreur Paybox. Dans ce cas, il est souhaitable de faire une '
'tentative sur le site secondaire FQDN tpeweb1.paybox.com.',
'00004': 'Numéro de porteur ou cryptogramme visuel invalide.',
'00006': 'Accès refusé ou site/rang/identifiant incorrect.',
'00008': 'Date de fin de validité incorrecte.',
'00009': 'Erreur de création dun abonnement.',
'00010': 'Devise inconnue.',
'00011': 'Montant incorrect.',
'00015': 'Paiement déjà effectué.',
'00016': 'Abonné déjà existant (inscription nouvel abonné). Valeur '
'U de la variable PBX_RETOUR.',
'00021': 'Carte non autorisée.',
'00029': 'Carte non conforme. Code erreur renvoyé lors de la '
'documentation de la variable « PBX_EMPREINTE ».',
'00030': 'Temps dattente > 15 mn par linternaute/acheteur au niveau '
'de la page de paiements.',
'00031': 'Réservé',
'00032': 'Réservé',
'00033': 'Code pays de ladresse IP du navigateur de lacheteur non '
'autorisé.',
'00040': 'Opération sans authentification 3-DSecure, bloquée par le '
'filtre.',
'99999': 'Opération en attente de validation par lémetteur du moyen '
'de paiement.',
}
ALGOS = {
@ -131,32 +91,19 @@ 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',
}
PAYBOX_DIRECT_CANCEL_OPERATION = '00055'
PAYBOX_DIRECT_VALIDATE_OPERATION = '00002'
PAYBOX_DIRECT_VERSION_NUMBER = '00103'
PAYBOX_DIRECT_SUCCESS_RESPONSE_CODE = '00000'
# payment modes
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)
@ -177,27 +124,26 @@ def sign(data, key):
def verify(data, signature, key=PAYBOX_KEY):
'''Verify signature using SHA1withRSA by Paybox'''
key = RSA.importKey(key)
h = SHA.new(force_byte(data))
h = SHA.new(data)
verifier = PKCS1_v1_5.new(key)
return verifier.verify(h, signature)
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.
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
"""
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
'''
callback = None
description = {
@ -216,139 +162,74 @@ class Payment(PaymentCommon):
},
{
'name': 'platform',
'caption': 'Plateforme cible',
'caption': _('Plateforme cible'),
'default': 'test',
'choices': (
('test', 'Test'),
('backup', 'Backup'),
('prod', 'Production'),
),
'validation': lambda x: isinstance(x, basestring) and
x.lower() in ('test', 'prod'),
},
{
'name': 'site',
'caption': 'Numéro de site',
'caption': _('Numéro de site'),
'required': True,
'validation': lambda x: isinstance(x, str) and x.isdigit() and len(x) == 7,
},
{
'name': 'cle',
'caption': 'Clé',
'help_text': 'Uniquement nécessaire pour l\'annulation / remboursement / encaissement (PayBox Direct)',
'required': False,
'validation': lambda x: isinstance(x, str),
'validation': lambda x: isinstance(x, basestring) and
x.isdigit() and len(x) == 7,
},
{
'name': 'rang',
'caption': 'Numéro de rang',
'caption': _('Numéro de rang'),
'required': True,
'validation': lambda x: isinstance(x, str) and x.isdigit() and (len(x) in (2, 3)),
'validation': lambda x: isinstance(x, basestring) and
x.isdigit() and len(x) == 2,
},
{
'name': 'identifiant',
'caption': 'Identifiant',
'caption': _('Identifiant'),
'required': True,
'validation': lambda x: isinstance(x, str) and x.isdigit() and (0 < len(x) < 10),
'validation': lambda x: isinstance(x, basestring) and
x.isdigit() and (0 < len(x) < 10),
},
{
'name': 'shared_secret',
'caption': 'Secret partagé (clé HMAC)',
'validation': lambda x: isinstance(x, str)
and all(a.lower() in '0123456789abcdef' for a in x)
and (len(x) % 2 == 0),
'caption': _('Secret partagé'),
'validation': lambda x: isinstance(x, str) and
all(a.lower() in '0123456789ABCDEF' for a in x),
'required': True,
},
{
'name': 'devise',
'caption': 'Devise',
'caption': _('Devise'),
'default': '978',
'choices': (('978', 'Euro'),),
'choices': (
('978', 'Euro'),
),
},
{
'name': 'callback',
'caption': _('Callback URL'),
'deprecated': True,
},
{
'name': 'capture_day',
'caption': 'Nombre de jours pour un paiement différé',
'default': '',
'required': False,
'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),
},
{
'name': 'manual_validation',
'caption': 'Validation manuelle',
'type': bool,
'default': False,
'scope': 'transaction',
},
{
'name': 'timezone',
'caption': _('Default Timezone'),
'default': 'Europe/Paris',
'required': False,
},
],
]
}
def make_pbx_cmd(self, guid, orderid=None, transaction_id=None):
if not transaction_id:
date = datetime.datetime.now(pytz.timezone(self.timezone)).strftime('%Y-%m-%dT%H%M%S')
transaction_id = '%s_%s' % (date, guid)
pbx_cmd = transaction_id
if orderid:
pbx_cmd += '!' + orderid
return pbx_cmd
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,
):
def request(self, amount, email, name=None, orderid=None, **kwargs):
d = OrderedDict()
d['PBX_SITE'] = force_text(self.site)
d['PBX_RANG'] = force_text(self.rang).strip()[-3:]
d['PBX_RANG'] = force_text(self.rang).strip()[-2:]
d['PBX_IDENTIFIANT'] = force_text(self.identifiant)
d['PBX_TOTAL'] = self.clean_amount(amount)
d['PBX_TOTAL'] = (amount * Decimal(100)).to_integral_value(ROUND_DOWN)
d['PBX_DEVISE'] = force_text(self.devise)
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
)
transaction_id = kwargs.get('transaction_id') or \
self.transaction_id(12, string.digits, 'paybox', self.site,
self.rang, self.identifiant)
d['PBX_CMD'] = force_text(transaction_id)
# prepend order id command reference
if orderid:
d['PBX_CMD'] = orderid + ORDERID_TRANSACTION_SEPARATOR + d['PBX_CMD']
d['PBX_PORTEUR'] = force_text(email)
d['PBX_RETOUR'] = (
'montant:M;reference:R;code_autorisation:A;erreur:E;numero_appel:T;'
'numero_transaction:S;'
'date_transaction:W;heure_transaction:Q;'
'signature:K'
)
d['PBX_RETOUR'] = 'montant:M;reference:R;code_autorisation:A;erreur:E;signature:K'
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'] = pbx_archivage
d['PBX_TIME'] = kwargs.get('time') or (force_text(datetime.datetime.utcnow().isoformat('T')).split('.')[0]+'+00:00')
d['PBX_ARCHIVAGE'] = transaction_id
if self.normal_return_url:
d['PBX_EFFECTUE'] = self.normal_return_url
d['PBX_REFUSE'] = self.normal_return_url
@ -356,79 +237,42 @@ 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:
d['PBX_DIFF'] = capture_day.zfill(2)
d['PBX_AUTOSEULE'] = PAYMENT_MODES[self.capture_mode]
if manual_validation:
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()
shared_secret = codecs.decode(bytes(self.shared_secret, 'ascii'), 'hex')
if six.PY3:
shared_secret = codecs.decode(bytes(self.shared_secret, 'ascii'), 'hex')
else:
shared_secret = codecs.decode(bytes(self.shared_secret), 'hex')
d = sign(d, shared_secret)
url = URLS[self.platform]
fields = []
for k, v in d:
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')
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')
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) >= {'erreur', 'reference'}:
raise ResponseError('missing erreur or reference')
if not set(d) >= set(['erreur', 'reference']):
raise ResponseError()
signed = False
if 'signature' in d:
sig = d['signature'][0]
try:
sig = base64.b64decode(sig)
except (TypeError, ValueError):
raise ResponseError('invalid signature')
sig = base64.b64decode(sig)
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'):
data.append('%s=%s' % (key, urllib.quote(d[key][0])))
else:
for key, value in urlparse.parse_qsl(query_string, True, True):
@ -437,79 +281,23 @@ class Payment(PaymentCommon):
data.append('%s=%s' % (key, urllib.quote(value)))
data = '&'.join(data)
signed = verify(data, sig)
erreur = d['erreur'][0]
if re.match(r'^001[0-9][0-9]$', erreur):
cb_error_code = erreur[3:5]
message, result = cb.translate_cb_error_code(cb_error_code)
elif erreur in PAYBOX_ERROR_CODES:
message = PAYBOX_ERROR_CODES[erreur]['message']
result = PAYBOX_ERROR_CODES[erreur].get('result', ERROR)
if d['erreur'][0] == '00000':
result = PAID
else:
message = 'Code erreur inconnu %s' % erreur
result = ERROR
pbx_cmd = d['reference'][0]
transaction_date = None
if 'date_transaction' in d and 'heure_transaction' in d:
try:
full_date_string = '%sT%s' % (d['date_transaction'][0], d['heure_transaction'][0])
transaction_date = datetime.datetime.strptime(full_date_string, '%Y%m%dT%H:%M:%S')
except ValueError:
pass
else:
# We suppose Europe/Paris is the default timezone for Paybox
# servers.
paris_tz = pytz.timezone(self.timezone)
transaction_date = paris_tz.localize(transaction_date)
for l in (5, 3):
prefix = d['erreur'][0][:l]
suffix = 'x' * (5-l)
bank_status = PAYBOX_ERROR_CODES.get(prefix + suffix)
if bank_status is not None:
break
orderid = d['reference'][0]
# decode order id from returned reference
if ORDERID_TRANSACTION_SEPARATOR in orderid:
orderid, transaction_id = orderid.split(ORDERID_TRANSACTION_SEPARATOR, 1)
return PaymentResponse(
order_id=pbx_cmd,
order_id=orderid,
signed=signed,
bank_data=d,
result=result,
bank_status=message,
transaction_date=transaction_date,
)
def perform(self, amount, bank_data, operation):
logger = logging.getLogger(__name__)
url = PAYBOX_DIRECT_URLS[self.platform]
params = {
'VERSION': PAYBOX_DIRECT_VERSION_NUMBER,
'TYPE': operation,
'SITE': force_text(self.site),
'RANG': self.rang.strip(),
'CLE': force_text(self.cle),
'NUMQUESTION': bank_data['numero_transaction'][0].zfill(10),
'MONTANT': self.clean_amount(amount),
'DEVISE': force_text(self.devise),
'NUMTRANS': bank_data['numero_transaction'][0], # paybox transaction number
'NUMAPPEL': bank_data['numero_appel'][0],
'REFERENCE': bank_data['reference'][0],
'DATEQ': datetime.datetime.now().strftime('%d%m%Y%H%M%S'),
}
response = requests.post(url, params)
response.raise_for_status()
logger.debug('received %r', response.text)
data = dict(urlparse.parse_qsl(response.text, True, True))
if data.get('CODEREPONSE') != PAYBOX_DIRECT_SUCCESS_RESPONSE_CODE:
raise ResponseError(data['COMMENTAIRE'])
return data
def validate(self, amount, bank_data, **kwargs):
return self.perform(amount, bank_data, PAYBOX_DIRECT_VALIDATE_OPERATION)
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
bank_status=bank_status)

View File

@ -1,473 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 copy
import datetime
import decimal
import functools
import os
import random
import re
import unicodedata
import xml.etree.ElementTree as ET
from urllib.parse import parse_qs
import pytz
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
# 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}$')
def clear_namespace(element):
def helper(element):
if element.tag.startswith('{'):
element.tag = element.tag[element.tag.index('}') + 1 :]
for subelement in element:
helper(subelement)
element = copy.deepcopy(element)
helper(element)
return element
def normalize_objet(objet):
'''Make objet a string of 100 chars in alphabet [A-Za-z0-9 ]'''
if not objet:
return objet
objet = force_text(objet)
objet = unicodedata.normalize('NFKD', objet).encode('ascii', 'ignore').decode()
objet = re.sub(r'[\'-]', ' ', objet).strip()
objet = re.sub(r'[^A-Za-z0-9 ]', '', objet).strip()
objet = re.sub(r'[\s]+', ' ', objet)
return objet[:100]
class PayFiPError(PaymentException):
def __init__(self, code, message, origin=None):
self.code = code
self.message = message
self.origin = origin
args = [code, message]
if origin:
args.append(origin)
super().__init__(*args)
class PayFiP:
'''Encapsulate SOAP web-services of PayFiP'''
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
):
return
detail = clear_namespace(fault.detail)
code = detail.find('FonctionnelleErreur/code')
if code is None or not code.text:
return PayFiPError('inconnu', ET.tostring(detail))
descriptif = detail.find('FonctionnelleErreur/descriptif')
libelle = detail.find('FonctionnelleErreur/libelle')
return PayFiPError(
code=code.text,
message=(descriptif is not None and descriptif.text)
or (libelle is not None and libelle.text)
or '',
)
def _perform(self, request_qname, operation, **kwargs):
RequestType = self.client.get_type(request_qname) # noqa: E501
try:
return getattr(self.client.service, operation)(RequestType(**kwargs))
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('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,
)
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',
numcli=numcli,
saisie=saisie,
exer=exer,
montant=montant,
refdet=refdet,
mel=mel,
urlnotif=url_notification,
urlredirect=url_redirect,
objet=objet,
)
def get_info_paiement(self, idop):
return self._perform(
'{http://securite.service.tpa.cp.finances.gouv.fr/reponse}RecupererDetailPaiementSecuriseRequest',
'recupererDetailPaiementSecurise',
idOp=idop,
)
class Payment(PaymentCommon):
"""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': _('Client number'),
'help_text': _('6 digits number provided by DGFIP'),
'validation': lambda s: str.isdigit(s) and len(s) == 6,
'required': True,
},
{
'name': 'saisie',
'caption': _('Payment type'),
'default': 'T',
'choices': [
('T', _('test')),
('X', _('activation')),
('W', _('production')),
],
},
{
'name': 'normal_return_url',
'caption': _('User return URL'),
'required': True,
},
{
'name': 'automatic_return_url',
'caption': _('Asynchronous return URL'),
'required': True,
},
],
}
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().__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
):
montant = self.clean_amount(amount, max_amount=100000)
numcli = self.numcli
urlnotif = self.automatic_return_url
urlredirect = self.normal_return_url
if not exer:
exer = str(datetime.date.today().year)
if refdet:
pass
elif transaction_id and REFDET_RE.match(transaction_id):
refdet = transaction_id
elif orderid and REFDET_RE.match(orderid):
refdet = orderid
else:
refdet = self._generate_refdet()
objet_parts = []
if orderid and refdet != orderid:
objet_parts.extend(['O', orderid])
if subject:
if objet_parts:
objet_parts.append('S')
objet_parts.append(subject)
if transaction_id and refdet != transaction_id:
objet_parts.extend(['T', transaction_id])
objet = normalize_objet(' '.join(objet_parts))
mel = email
if hasattr(mel, 'decode'):
mel = email.decode('ascii')
try:
if '@' not in mel:
raise ValueError('no @ in MEL')
if not (6 <= len(mel) <= 80):
raise ValueError('len(MEL) is invalid, must be between 6 and 80')
except Exception as e:
raise ValueError('MEL is not a valid email, %r' % mel, e)
# check saisie
saisie = self.saisie
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,
)
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
# between generation and payment, any call to get_info_paiement() will return a PayFiPError with code=P5
# before the end of the 15 minutes it can mean the payment is in progress
# after the 15 minutes period it means the payment will never happen,
# and after one day the code will change for P1, meaning the idop is
# now unknown as it as been cleaned by the night cleaning job.
#
# So in order to interpret the meaning of PayFiP error codes we need
# the date of the start of the transaction and add to it some margin
# to.
idop = transaction_id
if transaction_date:
if transaction_date.tzinfo: # date is aware
now = datetime.datetime.now(tz=pytz.utc)
else:
now = datetime.datetime.now()
delta = now - transaction_date
else:
delta = datetime.timedelta(seconds=0)
# set the threshold between transaction 'in progress' and 'expired' at 20 minutes
threshold = datetime.timedelta(seconds=20 * 60)
try:
response = self.payfip.get_info_paiement(idop)
except PayFiPError as e:
if e.code == 'P1' or (e.code == 'P5' and delta >= threshold):
return PaymentResponse(result=EXPIRED, signed=True, order_id=transaction_id)
if e.code == 'P5' and delta < threshold:
return PaymentResponse(result=WAITING, signed=True, order_id=transaction_id)
raise e
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)
idop = (fields.get('idop') or [None])[0]
if not idop:
raise ResponseError('missing idop parameter in query string')
return self.payment_status(idop)
@classmethod
def payfip_response_to_eopayment_response(cls, idop, response):
if response.resultrans == 'P':
result = PAID
bank_status = 'paid CB'
elif response.resultrans == 'V':
result = PAID
bank_status = 'paid direct debit'
elif response.resultrans == 'R':
result = DENIED
bank_status = 'refused CB'
elif response.resultrans == 'Z':
result = DENIED
bank_status = 'refused direct debit'
elif response.resultrans == 'A':
result = CANCELLED
bank_status = 'cancelled CB'
else:
result = ERROR
bank_status = 'unknown result code: %r' % response.resultrans
transaction_id = response.refdet
transaction_id += ' ' + idop
if response.numauto:
transaction_id += ' ' + response.numauto
return PaymentResponse(
result=result,
bank_status=bank_status,
signed=True,
bank_data={k: response[k] for k in response},
order_id=idop,
transaction_id=transaction_id,
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__':
import click
def show_payfip_error(func):
@functools.wraps(func)
def f(*args, **kwargs):
try:
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()
@click.option('--wsdl-url', default=None)
@click.option('--service-url', default=None)
@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
ctx.obj = PayFiP(wsdl_url=wsdl_url, service_url=service_url)
def numcli(ctx, param, value):
if not isinstance(value, str) or len(value) != 6 or not value.isdigit():
raise click.BadParameter('numcli must a 6 digits number')
return value
@main.command()
@click.argument('numcli', callback=numcli, type=str)
@click.pass_obj
@show_payfip_error
def info_client(payfip, numcli):
response = payfip.get_info_client(numcli)
for key in response:
print('%15s:' % key, response[key])
@main.command()
@click.argument('numcli', callback=numcli, type=str)
@click.option('--saisie', type=click.Choice(['T', 'X', 'W']), required=True)
@click.option('--exer', type=str, required=True)
@click.option('--montant', type=int, required=True)
@click.option('--refdet', type=str, required=True)
@click.option('--mel', type=str, required=True)
@click.option('--url-notification', type=str, required=True)
@click.option('--url-redirect', type=str, required=True)
@click.option('--objet', default=None, type=str)
@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,
)
print('idOp:', idop)
print(PAYMENT_URL + '?idop=%s' % idop)
@main.command()
@click.argument('idop', type=str)
@click.pass_obj
@show_payfip_error
def info_paiement(payfip, idop):
print(payfip.get_info_paiement(idop))
main()

View File

@ -1,26 +1,9 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
from copy import deepcopy
from . import systempayv2
__all__ = ['Payment']
class Payment(systempayv2.Payment):
service_url = 'https://secure.payzen.eu/vads-payment/'

2
eopayment/request Executable file
View File

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

View File

@ -1,140 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?><!-- Published by JAX-WS RI at http://jax-ws.dev.java.net. RI's version is JAX-WS RI 2.2.8 svn-revision#13980. --><!-- Generated by JAX-WS RI at http://jax-ws.dev.java.net. RI's version is JAX-WS RI 2.1.7-b01-. --><definitions xmlns="http://schemas.xmlsoap.org/wsdl/" xmlns:tns="http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" targetNamespace="http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService" name="PaiementSecuriseService">
<types>
<xsd:schema>
<xsd:import namespace="http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService" schemaLocation="PaiementSecuriseService1.xsd"/>
</xsd:schema>
<xsd:schema>
<xsd:import namespace="http://securite.service.tpa.cp.finances.gouv.fr/requete" schemaLocation="PaiementSecuriseService2.xsd"/>
</xsd:schema>
<xsd:schema>
<xsd:import namespace="http://securite.service.tpa.cp.finances.gouv.fr/reponse" schemaLocation="PaiementSecuriseService3.xsd"/>
</xsd:schema>
</types>
<message name="creerPaiementSecurise">
<part name="parameters" element="tns:creerPaiementSecurise"/>
</message>
<message name="creerPaiementSecuriseResponse">
<part name="parameters" element="tns:creerPaiementSecuriseResponse"/>
</message>
<message name="FonctionnelleErreur">
<part name="fault" element="tns:FonctionnelleErreur"/>
</message>
<message name="TechDysfonctionnementErreur">
<part name="fault" element="tns:TechDysfonctionnementErreur"/>
</message>
<message name="TechIndisponibiliteErreur">
<part name="fault" element="tns:TechIndisponibiliteErreur"/>
</message>
<message name="TechProtocolaireErreur">
<part name="fault" element="tns:TechProtocolaireErreur"/>
</message>
<message name="recupererDetailClient">
<part name="parameters" element="tns:recupererDetailClient"/>
</message>
<message name="recupererDetailClientResponse">
<part name="parameters" element="tns:recupererDetailClientResponse"/>
</message>
<message name="recupererDetailPaiementSecurise">
<part name="parameters" element="tns:recupererDetailPaiementSecurise"/>
</message>
<message name="recupererDetailPaiementSecuriseResponse">
<part name="parameters" element="tns:recupererDetailPaiementSecuriseResponse"/>
</message>
<portType name="PaiementSecuriseService">
<operation name="creerPaiementSecurise">
<input message="tns:creerPaiementSecurise"/>
<output message="tns:creerPaiementSecuriseResponse"/>
<fault message="tns:FonctionnelleErreur" name="FonctionnelleErreur"/>
<fault message="tns:TechDysfonctionnementErreur" name="TechDysfonctionnementErreur"/>
<fault message="tns:TechIndisponibiliteErreur" name="TechIndisponibiliteErreur"/>
<fault message="tns:TechProtocolaireErreur" name="TechProtocolaireErreur"/>
</operation>
<operation name="recupererDetailClient">
<input message="tns:recupererDetailClient"/>
<output message="tns:recupererDetailClientResponse"/>
<fault message="tns:FonctionnelleErreur" name="FonctionnelleErreur"/>
<fault message="tns:TechDysfonctionnementErreur" name="TechDysfonctionnementErreur"/>
<fault message="tns:TechIndisponibiliteErreur" name="TechIndisponibiliteErreur"/>
<fault message="tns:TechProtocolaireErreur" name="TechProtocolaireErreur"/>
</operation>
<operation name="recupererDetailPaiementSecurise">
<input message="tns:recupererDetailPaiementSecurise"/>
<output message="tns:recupererDetailPaiementSecuriseResponse"/>
<fault message="tns:FonctionnelleErreur" name="FonctionnelleErreur"/>
<fault message="tns:TechDysfonctionnementErreur" name="TechDysfonctionnementErreur"/>
<fault message="tns:TechIndisponibiliteErreur" name="TechIndisponibiliteErreur"/>
<fault message="tns:TechProtocolaireErreur" name="TechProtocolaireErreur"/>
</operation>
</portType>
<binding name="PaiementSecuriseServicePortBinding" type="tns:PaiementSecuriseService">
<soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
<operation name="creerPaiementSecurise">
<soap:operation soapAction=""/>
<input>
<soap:body use="literal"/>
</input>
<output>
<soap:body use="literal"/>
</output>
<fault name="FonctionnelleErreur">
<soap:fault name="FonctionnelleErreur" use="literal"/>
</fault>
<fault name="TechDysfonctionnementErreur">
<soap:fault name="TechDysfonctionnementErreur" use="literal"/>
</fault>
<fault name="TechIndisponibiliteErreur">
<soap:fault name="TechIndisponibiliteErreur" use="literal"/>
</fault>
<fault name="TechProtocolaireErreur">
<soap:fault name="TechProtocolaireErreur" use="literal"/>
</fault>
</operation>
<operation name="recupererDetailClient">
<soap:operation soapAction=""/>
<input>
<soap:body use="literal"/>
</input>
<output>
<soap:body use="literal"/>
</output>
<fault name="FonctionnelleErreur">
<soap:fault name="FonctionnelleErreur" use="literal"/>
</fault>
<fault name="TechDysfonctionnementErreur">
<soap:fault name="TechDysfonctionnementErreur" use="literal"/>
</fault>
<fault name="TechIndisponibiliteErreur">
<soap:fault name="TechIndisponibiliteErreur" use="literal"/>
</fault>
<fault name="TechProtocolaireErreur">
<soap:fault name="TechProtocolaireErreur" use="literal"/>
</fault>
</operation>
<operation name="recupererDetailPaiementSecurise">
<soap:operation soapAction=""/>
<input>
<soap:body use="literal"/>
</input>
<output>
<soap:body use="literal"/>
</output>
<fault name="FonctionnelleErreur">
<soap:fault name="FonctionnelleErreur" use="literal"/>
</fault>
<fault name="TechDysfonctionnementErreur">
<soap:fault name="TechDysfonctionnementErreur" use="literal"/>
</fault>
<fault name="TechIndisponibiliteErreur">
<soap:fault name="TechIndisponibiliteErreur" use="literal"/>
</fault>
<fault name="TechProtocolaireErreur">
<soap:fault name="TechProtocolaireErreur" use="literal"/>
</fault>
</operation>
</binding>
<service name="PaiementSecuriseService">
<port name="PaiementSecuriseServicePort" binding="tns:PaiementSecuriseServicePortBinding">
<soap:address location="https://www.payfip.gouv.fr/tpa/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService"/>
</port>
</service>
</definitions>

View File

@ -1,116 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?><!-- Published by JAX-WS RI at http://jax-ws.dev.java.net. RI's version is JAX-WS RI 2.2.8 svn-revision#13980. --><xs:schema xmlns:ns2="http://securite.service.tpa.cp.finances.gouv.fr/reponse" xmlns:ns1="http://securite.service.tpa.cp.finances.gouv.fr/requete" xmlns:tns="http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService" xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" targetNamespace="http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService">
<xs:import namespace="http://securite.service.tpa.cp.finances.gouv.fr/requete" schemaLocation="PaiementSecuriseService2.xsd"/>
<xs:import namespace="http://securite.service.tpa.cp.finances.gouv.fr/reponse" schemaLocation="PaiementSecuriseService3.xsd"/>
<xs:element name="FonctionnelleErreur" type="tns:FonctionnelleErreur"/>
<xs:element name="TechDysfonctionnementErreur" type="tns:TechDysfonctionnementErreur"/>
<xs:element name="TechIndisponibiliteErreur" type="tns:TechIndisponibiliteErreur"/>
<xs:element name="TechProtocolaireErreur" type="tns:TechProtocolaireErreur"/>
<xs:element name="creerPaiementSecurise" type="tns:creerPaiementSecurise"/>
<xs:element name="creerPaiementSecuriseResponse" type="tns:creerPaiementSecuriseResponse"/>
<xs:element name="recupererDetailClient" type="tns:recupererDetailClient"/>
<xs:element name="recupererDetailClientResponse" type="tns:recupererDetailClientResponse"/>
<xs:element name="recupererDetailPaiementSecurise" type="tns:recupererDetailPaiementSecurise"/>
<xs:element name="recupererDetailPaiementSecuriseResponse" type="tns:recupererDetailPaiementSecuriseResponse"/>
<xs:complexType name="creerPaiementSecurise">
<xs:sequence>
<xs:element name="arg0" type="ns1:CreerPaiementSecuriseRequest" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="creerPaiementSecuriseResponse">
<xs:sequence>
<xs:element name="return" type="ns2:CreerPaiementSecuriseResponse" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="FonctionnelleErreur">
<xs:sequence>
<xs:element name="code" type="xs:string" minOccurs="0"/>
<xs:element name="descriptif" type="xs:string" minOccurs="0"/>
<xs:element name="libelle" type="xs:string" minOccurs="0"/>
<xs:element name="message" type="xs:string" minOccurs="0"/>
<xs:element name="severite" type="xs:int"/>
<xs:element name="suppressed" type="tns:throwable" nillable="true" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="throwable">
<xs:sequence>
<xs:element name="stackTrace" type="tns:stackTraceElement" nillable="true" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="stackTraceElement" final="extension restriction">
<xs:sequence/>
</xs:complexType>
<xs:complexType name="TechDysfonctionnementErreur">
<xs:sequence>
<xs:element name="code" type="xs:string" minOccurs="0"/>
<xs:element name="descriptif" type="xs:string" minOccurs="0"/>
<xs:element name="libelle" type="xs:string" minOccurs="0"/>
<xs:element name="message" type="xs:string" minOccurs="0"/>
<xs:element name="severite" type="xs:int"/>
<xs:element name="suppressed" type="tns:throwable" nillable="true" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="TechIndisponibiliteErreur">
<xs:sequence>
<xs:element name="code" type="xs:string" minOccurs="0"/>
<xs:element name="descriptif" type="xs:string" minOccurs="0"/>
<xs:element name="libelle" type="xs:string" minOccurs="0"/>
<xs:element name="message" type="xs:string" minOccurs="0"/>
<xs:element name="severite" type="xs:int"/>
<xs:element name="suppressed" type="tns:throwable" nillable="true" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="TechProtocolaireErreur">
<xs:sequence>
<xs:element name="code" type="xs:string" minOccurs="0"/>
<xs:element name="descriptif" type="xs:string" minOccurs="0"/>
<xs:element name="libelle" type="xs:string" minOccurs="0"/>
<xs:element name="message" type="xs:string" minOccurs="0"/>
<xs:element name="severite" type="xs:int"/>
<xs:element name="suppressed" type="tns:throwable" nillable="true" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="recupererDetailClient">
<xs:sequence>
<xs:element name="arg0" type="ns2:RecupererDetailClientRequest" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="recupererDetailClientResponse">
<xs:sequence>
<xs:element name="return" type="ns2:RecupererDetailClientResponse" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="recupererDetailPaiementSecurise">
<xs:sequence>
<xs:element name="arg0" type="ns2:RecupererDetailPaiementSecuriseRequest" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="recupererDetailPaiementSecuriseResponse">
<xs:sequence>
<xs:element name="return" type="ns2:RecupererDetailPaiementSecuriseResponse" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
</xs:schema>

View File

@ -1,18 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?><!-- Published by JAX-WS RI at http://jax-ws.dev.java.net. RI's version is JAX-WS RI 2.2.8 svn-revision#13980. --><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" targetNamespace="http://securite.service.tpa.cp.finances.gouv.fr/requete">
<xs:complexType name="CreerPaiementSecuriseRequest">
<xs:sequence>
<xs:element name="exer" type="xs:string" minOccurs="0"/>
<xs:element name="mel" type="xs:string" minOccurs="0"/>
<xs:element name="montant" type="xs:string" minOccurs="0"/>
<xs:element name="numcli" type="xs:string" minOccurs="0"/>
<xs:element name="objet" type="xs:string" minOccurs="0"/>
<xs:element name="refdet" type="xs:string" minOccurs="0"/>
<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,47 +0,0 @@
<?xml version='1.0' encoding='UTF-8'?><!-- Published by JAX-WS RI at http://jax-ws.dev.java.net. RI's version is JAX-WS RI 2.2.8 svn-revision#13980. --><xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" version="1.0" targetNamespace="http://securite.service.tpa.cp.finances.gouv.fr/reponse">
<xs:complexType name="CreerPaiementSecuriseResponse">
<xs:sequence>
<xs:element name="idOp" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RecupererDetailClientRequest">
<xs:sequence>
<xs:element name="numCli" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RecupererDetailClientResponse">
<xs:sequence>
<xs:element name="libelleN1" type="xs:string" minOccurs="0"/>
<xs:element name="libelleN2" type="xs:string" minOccurs="0"/>
<xs:element name="libelleN3" type="xs:string" minOccurs="0"/>
<xs:element name="numcli" type="xs:string" minOccurs="0"/>
<xs:element name="IdentifiantGen" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RecupererDetailPaiementSecuriseRequest">
<xs:sequence>
<xs:element name="idOp" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="RecupererDetailPaiementSecuriseResponse">
<xs:sequence>
<xs:element name="dattrans" type="xs:string" minOccurs="0"/>
<xs:element name="exer" type="xs:string" minOccurs="0"/>
<xs:element name="heurtrans" type="xs:string" minOccurs="0"/>
<xs:element name="idOp" type="xs:string" minOccurs="0"/>
<xs:element name="mel" type="xs:string" minOccurs="0"/>
<xs:element name="montant" type="xs:string" minOccurs="0"/>
<xs:element name="numauto" type="xs:string" minOccurs="0"/>
<xs:element name="numcli" type="xs:string" minOccurs="0"/>
<xs:element name="objet" type="xs:string" minOccurs="0"/>
<xs:element name="refdet" type="xs:string" minOccurs="0"/>
<xs:element name="resultrans" type="xs:string" minOccurs="0"/>
<xs:element name="saisie" type="xs:string" minOccurs="0"/>
</xs:sequence>
</xs:complexType>
</xs:schema>

View File

@ -1,276 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 functools
from urllib.parse import parse_qs, urljoin
import lxml.etree as ET
import zeep
import zeep.exceptions
from .common import (
CANCELLED,
DENIED,
ERROR,
PAID,
URL,
PaymentCommon,
PaymentException,
PaymentResponse,
ResponseError,
)
_zeep_transport = None
class SagaError(PaymentException):
pass
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()
if _zeep_transport and 'transport' not in kwargs:
kwargs['transport'] = _zeep_transport
self.client = zeep.Client(wsdl_url, **kwargs)
# distribued WSDL is wrong :/
if service_url:
self.client.service._binding_options['address'] = service_url
def soap_call(self, operation, content_tag, **kwargs):
content = getattr(self.client.service, operation)(**kwargs)
if 'ISO-8859-1' in content:
encoded_content = content.encode('latin1')
else:
encoded_content = content.encode('utf-8')
try:
tree = ET.fromstring(encoded_content)
except Exception:
raise SagaError('Invalid SAGA response "%s"' % content[:1024])
if tree.tag == 'erreur':
raise SagaError(tree.text)
if tree.tag != content_tag:
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,
):
tree = self.soap_call(
'Transaction',
'url',
num_service=num_service,
id_tiers=id_tiers,
compte=compte,
lib_ecriture=lib_ecriture,
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
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 == <ok id_tiers="A1"
# etat="paye" email="albert,dupond@monsite.com" num_service="222222"
# montant="100.00" compte="708"
# lib_ecriture="Paiement pour M. Albert Dupondréservationsejourxxxx" />
return tree.attrib
def page_retour_synchrone(self, idop):
return self.page_retour('PageRetourSynchrone', idop)
def page_retour_asynchrone(self, idop):
return self.page_retour('PageRetourAsynchrone', idop)
class Payment(PaymentCommon):
description = {
'caption': 'Système de paiement Saga de Futur System',
'parameters': [
{
'name': 'base_url',
'caption': 'URL de base du WSDL',
'help_text': 'Sans la partie /paiement_internet_ws_ministere?wsdl',
'required': True,
},
{
'name': 'num_service',
'caption': 'Numéro du service',
'required': True,
},
{
'name': 'compte',
'caption': 'Compte de recettes',
'required': True,
},
{
'name': 'normal_return_url',
'caption': 'URL de retour',
'default': '',
'required': True,
},
{
'name': 'automatic_return_url',
'caption': 'URL de notification',
'required': False,
},
],
}
@property
def saga(self):
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
id_tiers = '-1'
compte = self.compte
lib_ecriture = subject
montant = self.clean_amount(amount, max_amount=100000, cents=False)
urlretour_synchrone = self.normal_return_url
urlretour_asynchrone = self.automatic_return_url
url = self.saga.transaction(
num_service=num_service,
id_tiers=id_tiers,
compte=compte,
lib_ecriture=lib_ecriture,
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
urlretour_synchrone=urlretour_synchrone,
)
try:
idop = parse_qs(url.split('?', 1)[-1])['idop'][0]
except Exception:
raise SagaError('Invalid payment URL, no idop: %s' % url)
return str(idop), URL, url
def response(self, query_string, **kwargs):
fields = parse_qs(query_string, True)
idop = (fields.get('idop') or [None])[0]
if not idop:
raise ResponseError('missing idop parameter in query')
redirect = kwargs.get('redirect', False)
if redirect:
response = self.saga.page_retour_synchrone(idop=idop)
else:
response = self.saga.page_retour_asynchrone(idop=idop)
etat = response['etat']
if etat == 'paye':
result = PAID
bank_status = 'paid'
elif etat == 'refus':
result = DENIED
bank_status = 'refused'
elif etat == 'abandon':
result = CANCELLED
bank_status = 'cancelled'
else:
result = ERROR
bank_status = 'unknown result code: etat=%r' % etat
return PaymentResponse(
result=result,
bank_status=bank_status,
signed=True,
bank_data=dict(response),
order_id=idop,
transaction_id=idop,
test=False,
)
if __name__ == '__main__':
import click
def show_payfip_error(func):
@functools.wraps(func)
def f(*args, **kwargs):
try:
return func(*args, **kwargs)
except SagaError as e:
click.echo(click.style('SAGA ERROR : %s' % e, fg='red'))
return f
@click.group()
@click.option('--wsdl-url')
@click.option('--service-url', default=None)
@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
ctx.obj = Saga(wsdl_url=wsdl_url, service_url=service_url)
@main.command()
@click.option('--num-service', type=str, required=True)
@click.option('--id-tiers', type=str, required=True)
@click.option('--compte', type=str, required=True)
@click.option('--lib-ecriture', type=str, required=True)
@click.option('--montant', type=str, required=True)
@click.option('--urlretour-asynchrone', type=str, required=True)
@click.option('--email', type=str, required=True)
@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,
):
url = saga.transaction(
num_service=num_service,
id_tiers=id_tiers,
compte=compte,
lib_ecriture=lib_ecriture,
montant=montant,
urlretour_asynchrone=urlretour_asynchrone,
email=email,
urlretour_synchrone=urlretour_synchrone,
)
print('url:', url)
main()

179
eopayment/sips.py Normal file
View File

@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
from six.moves.urllib import parse as urlparse
import string
import subprocess
from decimal import Decimal
import logging
import os
import os.path
import uuid
import warnings
from .common import PaymentCommon, HTML, PaymentResponse, ResponseError
from .cb import CB_RESPONSE_CODES
'''
Payment backend module for the ATOS/SIPS system used by many Frenck banks.
It use the middleware given by the bank.
The necessary options are:
- pathfile, to indicate the absolute path of the pathfile file given by the
bank,
- binpath, the path of the directory containing the request and response
executables,
All the other needed parameters SHOULD already be set in the parmcom files
contained in the middleware distribution file.
'''
__all__ = ['Payment']
BINPATH = 'binpath'
PATHFILE = 'pathfile'
AUTHORISATION_ID = 'authorisation_id'
REQUEST_VALID_PARAMS = ['merchant_id', 'merchant_country', 'amount',
'currency_code', 'pathfile', 'normal_return_url', 'cancel_return_url',
'automatic_response_url', 'language', 'payment_means', 'header_flag',
'capture_day', 'capture_mode', 'bgcolor', 'block_align', 'block_order',
'textcolor', 'receipt_complement', 'caddie', 'customer_id',
'customer_email', 'customer_ip_address', 'data', 'return_context',
'target', 'order_id']
RESPONSE_PARAMS = ['code', 'error', 'merchant_id', 'merchant_country',
'amount', 'transaction_id', 'payment_means', 'transmission_date',
'payment_time', 'payment_date', 'response_code', 'payment_certificate',
AUTHORISATION_ID, 'currency_code', 'card_number', 'cvv_flag',
'cvv_response_code', 'bank_response_code', 'complementary_code',
'complementary_info', 'return_context', 'caddie', 'receipt_complement',
'merchant_language', 'language', 'customer_id', 'order_id',
'customer_email', 'customer_ip_address', 'capture_day', 'capture_mode',
'data', ]
DATA = 'DATA'
PARAMS = 'params'
TRANSACTION_ID = 'transaction_id'
ORDER_ID = 'order_id'
MERCHANT_ID = 'merchant_id'
RESPONSE_CODE = 'response_code'
DEFAULT_PARAMS = {'merchant_id': '014213245611111',
'merchant_country': 'fr',
'currency_code': '978'}
LOGGER = logging.getLogger(__name__)
CB_BANK_RESPONSE_CODES = CB_RESPONSE_CODES
AMEX_BANK_RESPONSE_CODE = {
'00': 'Transaction approuvée ou traitée avec succès',
'02': 'Dépassement de plafond',
'04': 'Conserver la carte',
'05': 'Ne pas honorer',
'97': 'Échéance de la temporisation de surveillance globale',
}
FINAREF_BANK_RESPONSE_CODE = {
'00': 'Transaction approuvée',
'03': 'Commerçant inconnu - Identifiant de commerçant incorrect',
'05': 'Compte / Porteur avec statut bloqué ou invalide',
'11': 'Compte / porteur inconnu',
'16': 'Provision insuffisante',
'20': 'Commerçant invalide - Code monnaie incorrect - ' + \
'Opération commerciale inconnue - Opération commerciale invalide',
'80': 'Transaction approuvée avec dépassement',
'81': 'Transaction approuvée avec augmentation capital',
'82': 'Transaction approuvée NPAI',
'83': 'Compte / porteur invalide',
}
class Payment(PaymentCommon):
description = {
'caption': 'SIPS',
'parameters': [{
'name': 'merchand_id',
},
{'name': 'merchant_country', },
{'name': 'currency_code', }
],
}
def __init__(self, options, logger=None):
super(Payment, self).__init__(options, logger=logger)
self.options = options
self.binpath = self.options.pop(BINPATH)
self.logger.debug('initializing sips payment class with %s' % options)
def execute(self, executable, params):
if PATHFILE in self.options:
params[PATHFILE] = self.options[PATHFILE]
executable = os.path.join(self.binpath, executable)
args = [executable] + ["%s=%s" % p for p in params.items()]
self.logger.debug('executing %s' % args)
result,_ = subprocess.Popen(' '.join(args),
stdout=subprocess.PIPE, shell=True).communicate()
try:
if result[0] == '!':
result = result[1:]
if result[-1] == '!':
result = result[:-1]
except IndexError:
raise ValueError("Invalid response", result)
return False
result = result.split('!')
self.logger.debug('got response %s' % result)
return result
def get_request_params(self):
params = DEFAULT_PARAMS.copy()
params.update(self.options)
return params
def request(self, amount, name=None, address=None, email=None, phone=None, orderid=None,
info1=None, info2=None, info3=None, next_url=None, **kwargs):
params = self.get_request_params()
transaction_id = self.transaction_id(6, string.digits, 'sips',
params[MERCHANT_ID])
params[TRANSACTION_ID] = transaction_id
params[ORDER_ID] = orderid or str(uuid.uuid4())
params[ORDER_ID] = params[ORDER_ID].replace('-', '')
params['amount'] = str(int(Decimal(amount) * 100))
if email:
params['customer_email'] = email
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)
normal_return_url = next_url
if normal_return_url:
params['normal_return_url'] = normal_return_url
code, error, form = self.execute('request', params)
if int(code) == 0:
return params[ORDER_ID], HTML, form
else:
raise RuntimeError('sips/request returned -1: %s' % error)
def response(self, query_string, **kwargs):
form = urlparse.parse_qs(query_string)
if not DATA in form:
raise ResponseError()
params = {'message': form[DATA][0]}
result = self.execute('response', params)
d = dict(zip(RESPONSE_PARAMS, result))
# The reference identifier for the payment is the authorisation_id
d[self.BANK_ID] = d.get(AUTHORISATION_ID)
self.logger.debug('response contains fields %s' % d)
response_result = d.get(RESPONSE_CODE) == '00'
response_code_msg = CB_BANK_RESPONSE_CODES.get(d.get(RESPONSE_CODE))
response = PaymentResponse(
result=response_result,
signed=response_result,
bank_data=d,
order_id=d.get(ORDER_ID),
transaction_id=d.get(AUTHORISATION_ID),
bank_status=response_code_msg)
return response

View File

@ -1,49 +1,24 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
import collections
import datetime
import json
from six.moves.urllib import parse as urlparse
import string
from decimal import Decimal
import uuid
import hashlib
import hmac
import json
import uuid
import warnings
from urllib import parse as urlparse
import pytz
from gettext import gettext as _
import requests
import warnings
from .common import (
CANCELED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_text,
)
from .common import (PaymentCommon, FORM, Form, PaymentResponse, PAID, ERROR,
CANCELED, 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:
@ -56,44 +31,42 @@ 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-services.com/paymentInit',
'prod': 'https://payment-webinit.sips-services.com/paymentInit',
'test': 'https://payment-webinit.simu.sips-atos.com/paymentInit',
'prod': 'https://payment-webinit.sips-atos.com/paymentInit',
}
WS_URL = {
'test': 'https://office-server.test.sips-services.com',
'prod': 'https://office-server.sips-services.com',
'test': 'https://office-server.test.sips-atos.com',
'prod': 'https://office-server.sips-atos.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',
}
@ -157,18 +130,17 @@ class Payment(PaymentCommon):
'caption': _('Capture Day'),
'required': False,
},
{'name': 'payment_means', 'caption': _('Payment Means'), 'required': False},
{
'name': 'timezone',
'caption': _('SIPS server Timezone'),
'default': 'Europe/Paris',
'required': False,
},
'name': 'payment_means',
'caption': _('Payment Means'),
'required': False
}
],
}
def encode_data(self, data):
return '|'.join('%s=%s' % (force_text(key), force_text(value)) for key, value in data.items())
return u'|'.join(u'%s=%s' % (force_text(key), force_text(value))
for key, value in data.items())
def seal_data(self, data):
s = self.encode_data(data)
@ -195,44 +167,26 @@ 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, **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
transactionReference = transaction_id or uuid.uuid4().hex
data['transactionReference'] = transactionReference
data['orderId'] = orderid or transactionReference
transaction_id = self.transaction_id(10, string.digits, 'sips2', data['merchantId'])
data['transactionReference'] = force_text(transaction_id)
data['orderId'] = orderid or force_text(uuid.uuid4()).replace('-', '')
if info1:
data['statementReference'] = force_text(info1)
else:
data['statementReference'] = data['transactionReference']
data['amount'] = self.clean_amount(amount)
data['amount'] = force_text(int(Decimal(amount) * 100))
if email:
data['billingContact.email'] = email
if 'capture_day' in kwargs:
data['captureDay'] = kwargs.get('capture_day')
if 'captureDay' in kwargs:
data['captureDay'] == kwargs.get('captureDay')
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
@ -240,7 +194,11 @@ 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',
@ -251,13 +209,11 @@ class Payment(PaymentCommon):
'name': 'InterfaceVersion',
'value': self.INTERFACE_VERSION,
},
],
)
])
self.logger.debug('emitting request %r', data)
return transactionReference, FORM, form
return transaction_id, FORM, form
@classmethod
def decode_data(cls, data):
def decode_data(self, data):
data = data.split('|')
data = [map(force_text, p.split('=', 1)) for p in data]
return collections.OrderedDict(data)
@ -272,8 +228,8 @@ class Payment(PaymentCommon):
def response(self, query_string, **kwargs):
form = urlparse.parse_qs(query_string)
if not set(form) >= {'Data', 'Seal', 'InterfaceVersion'}:
raise ResponseError('missing Data, Seal or InterfaceVersion')
if not set(form) >= set(['Data', 'Seal', 'InterfaceVersion']):
raise ResponseError()
self.logger.debug('received query string %r', form)
data = self.decode_data(form['Data'][0])
seal = form['Seal'][0]
@ -284,27 +240,14 @@ class Payment(PaymentCommon):
result = self.response_code_to_result.get(response_code, ERROR)
merchant_id = data.get('merchantId')
test = merchant_id == self.TEST_MERCHANT_ID
transaction_date = None
if 'transactionDateTime' in data:
try:
transaction_date = datetime.datetime.strptime(
data['transactionDateTime'], '%Y-%m-%d %H:%M:%S'
)
except (ValueError, TypeError):
pass
else:
sips_tz = pytz.timezone(self.timezone)
transaction_date = sips_tz.localize(transaction_date)
return PaymentResponse(
result=result,
signed=signed,
bank_data=data,
order_id=transaction_id,
transaction_id=data.get('authorisationId'),
bank_status=self.RESPONSE_CODES.get(response_code, 'unknown code - ' + response_code),
test=test,
transaction_date=transaction_date,
)
bank_status=self.RESPONSE_CODES.get(response_code, u'unknown code - ' + response_code),
test=test)
def get_seal_for_json_ws_data(self, data):
data_to_send = []
@ -312,10 +255,8 @@ class Payment(PaymentCommon):
if key in ('keyVersion', 'sealAlgorithm', 'seal'):
continue
data_to_send.append(force_text(data[key]))
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()
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()
def perform_cash_management_operation(self, endpoint, data):
data['merchantId'] = self.merchant_id
@ -325,11 +266,9 @@ class Payment(PaymentCommon):
data['seal'] = self.get_seal_for_json_ws_data(data)
url = self.WS_URL.get(self.platform) + '/rs-services/v2/cashManagement/%s' % endpoint
self.logger.debug('posting %r to %s endpoint', data, endpoint)
response = requests.post(
url,
data=json.dumps(data),
headers={'content-type': 'application/json', 'accept': 'application/json'},
)
response = requests.post(url, data=json.dumps(data),
headers={'content-type': 'application/json',
'accept': 'application/json'})
self.logger.debug('received %r', response.content)
response.raise_for_status()
json_response = response.json()
@ -343,13 +282,13 @@ class Payment(PaymentCommon):
def cancel(self, amount, bank_data, **kwargs):
data = {}
data['operationAmount'] = self.clean_amount(amount)
data['operationAmount'] = force_text(int(Decimal(amount) * 100))
data['transactionReference'] = bank_data.get('transactionReference')
return self.perform_cash_management_operation('cancel', data)
def validate(self, amount, bank_data, **kwargs):
data = {}
data['operationAmount'] = self.clean_amount(amount)
data['operationAmount'] = force_text(int(Decimal(amount) * 100))
data['transactionReference'] = bank_data.get('transactionReference')
return self.perform_cash_management_operation('validate', data)
@ -362,31 +301,9 @@ class Payment(PaymentCommon):
data['seal'] = self.get_seal_for_json_ws_data(data)
url = self.WS_URL.get(self.platform) + '/rs-services/v2/diagnostic/getTransactionData'
self.logger.debug('posting %r to %s endpoint', data, 'diagnostic')
response = requests.post(
url,
data=json.dumps(data),
headers={
'content-type': 'application/json',
'accept': 'application/json',
},
)
response = requests.post(url, data=json.dumps(data),
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

240
eopayment/spplus.py Normal file
View File

@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
from decimal import Decimal
import binascii
from gettext import gettext as _
import hmac
import hashlib
from six.moves.urllib import parse as urlparse
from six.moves.urllib import parse as urllib
import string
import datetime as dt
import logging
import re
import warnings
import Crypto.Cipher.DES
from .common import (PaymentCommon, URL, PaymentResponse, RECEIVED, ACCEPTED,
PAID, ERROR, ResponseError, force_byte)
def N_(message): return message
__all__ = ['Payment']
KEY_DES_KEY = b'\x45\x1f\xba\x4f\x4c\x3f\xd4\x97'
IV = b'\x30\x78\x30\x62\x2c\x30\x78\x30'
REFERENCE = 'reference'
ETAT = 'etat'
SPCHECKOK = 'spcheckok'
LOGGER = logging.getLogger(__name__)
REFSFP = 'refsfp'
# Pour un paiement comptant la chaine des états est: 1 -> 4 -> 10, seul l'état
# 10 garanti le paiement
SPPLUS_RESPONSE_CODES = {
'1': 'Autorisation de paiement acceptée',
'2': 'Autorisation de paiement refusée',
'4': 'Echéance du paiement acceptée et en attente de remise',
'5': 'Echéance du paiement refusée',
'6': 'Paiement par chèque accepté',
'8': 'Chèque encaissé',
'10': 'Paiement terminé',
'11': 'Echéance du paiement annulée par le commerçant',
'12': 'Abandon de l\internaute',
'15': 'Remboursement enregistré',
'16': 'Remboursement annulé',
'17': 'Remboursement accepté',
'20': 'Echéance du paiement avec un impayé',
'21': 'Echéance du paiement avec un impayé et en attente de validation des services SP PLUS',
'30': 'Echéance du paiement remisée',
'99': 'Paiement de test en production',
}
VALID_STATE = ('1', '4', '10')
ACCEPTED_STATE = ('1', '4')
PAID_STATE = ('10',)
TEST_STATE = ('99',)
def decrypt_ntkey(ntkey):
key = binascii.unhexlify(ntkey.replace(b' ', b''))
return decrypt_key(key)
def decrypt_key(key):
CIPHER = Crypto.Cipher.DES.new(KEY_DES_KEY, Crypto.Cipher.DES.MODE_CBC, IV)
return CIPHER.decrypt(key)
def extract_values(query_string):
kvs = query_string.split('&')
result = []
for kv in kvs:
k, v = kv.split('=', 1)
if k != 'hmac':
result.append(v)
return force_byte(''.join(result))
def sign_ntkey_query(ntkey, query):
key = decrypt_ntkey(ntkey)
data_to_sign = extract_values(query)
return hmac.new(key[:20], data_to_sign, hashlib.sha1).hexdigest().upper()
PAIEMENT_FIELDS = [ 'siret', REFERENCE, 'langue', 'devise', 'montant',
'taxe', 'validite' ]
def sign_url_paiement(ntkey, query):
if '?' in query:
query = query[query.index('?')+1:]
key = decrypt_ntkey(ntkey)
data = urlparse.parse_qs(query, True)
fields = [data.get(field,[''])[0] for field in PAIEMENT_FIELDS]
data_to_sign = ''.join(fields)
return hmac.new(key[:20], data_to_sign, hashlib.sha1).hexdigest().upper()
ALPHANUM = string.ascii_letters + string.digits
SERVICE_URL = "https://www.spplus.net/paiement/init.do"
class Payment(PaymentCommon):
description = {
'caption': "SPPlus payment service of French bank Caisse d'epargne",
'parameters': [
{
'name': 'normal_return_url',
'caption': N_('Normal return URL'),
'default': '',
'required': True,
},
{
'name': 'automatic_return_url',
'caption': N_('Automatic return URL'),
'required': False,
},
{ 'name': 'cle',
'caption': 'Secret key, a 40 digits hexadecimal number',
'regexp': re.compile('^ *((?:[a-fA-F0-9] *){40}) *$')
},
{ 'name': 'siret',
'caption': 'Siret of the entreprise augmented with the '
'site number, example: 00000000000001-01',
'regexp': re.compile('^ *(\d{14}-\d{2}) *$')
},
{ 'name': 'langue',
'caption': 'Language of the customers',
'default': 'FR',
},
{ 'name': 'taxe',
'caption': 'Taxes',
'default': '0.00'
},
{ 'name': 'modalite',
'caption': '1x, 2x, 3x, xx, nx (if multiple separated by "/")',
'default': '1x',
},
{ 'name': 'moyen',
'caption': 'AUR, AMX, CBS, CGA, '
'CHK, DIN, PRE (if multiple separate by "/")',
'default': 'CBS',
},
]
}
devise = '978'
def request(self, amount, name=None, address=None, email=None, phone=None,
orderid=None, info1=None, info2=None, info3=None, next_url=None,
logger=LOGGER, **kwargs):
logger.debug('requesting spplus payment with montant %s email=%s' % (amount, email))
reference = self.transaction_id(20, ALPHANUM, 'spplus', self.siret)
validite = dt.date.today()+dt.timedelta(days=1)
validite = validite.strftime('%d/%m/%Y')
fields = { 'siret': self.siret,
'devise': self.devise,
'langue': self.langue,
'taxe': self.taxe,
'montant': str(Decimal(amount)),
REFERENCE: orderid or reference,
'validite': validite,
'version': '1',
'modalite': self.modalite,
'moyen': self.moyen }
if email:
fields['email'] = email
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)
normal_return_url = next_url
if normal_return_url:
if (not normal_return_url.startswith('http://') \
and not normal_return_url.startswith('https://')) \
or '?' in normal_return_url:
raise ValueError('normal_return_url must be an absolute URL without parameters')
fields['urlretour'] = normal_return_url
logger.debug('sending fields %s' % fields)
query = urllib.urlencode(fields)
url = '%s?%s&hmac=%s' % (SERVICE_URL, query, sign_url_paiement(self.cle,
query))
logger.debug('full url %s' % url)
return reference, URL, url
def response(self, query_string, logger=LOGGER, **kwargs):
form = urlparse.parse_qs(query_string)
if not set(form) >= set([REFERENCE, ETAT, REFSFP]):
raise ResponseError()
for key, value in form.items():
form[key] = value[0]
logger.debug('received query_string %s' % query_string)
logger.debug('parsed as %s' % form)
reference = form.get(REFERENCE)
bank_status = []
signed = False
form[self.BANK_ID] = form.get(REFSFP)
etat = form.get('etat')
status = '%s: %s' % (etat, SPPLUS_RESPONSE_CODES.get(etat, 'Unknown code'))
logger.debug('status is %s', status)
bank_status.append(status)
if 'hmac' in form:
try:
signed_data, signature = query_string.rsplit('&', 1)
_, hmac = signature.split('=', 1)
logger.debug('got signature %s' % hmac)
computed_hmac = sign_ntkey_query(self.cle, signed_data)
logger.debug('computed signature %s' % computed_hmac)
signed = hmac == computed_hmac
if not signed:
bank_status.append('invalid signature')
except ValueError:
bank_status.append('invalid signature')
test = False
if etat in PAID_STATE:
result = PAID
elif etat in ACCEPTED_STATE:
result = ACCEPTED
elif etat in VALID_STATE:
result = RECEIVED
elif etat in TEST_STATE:
result = RECEIVED # what else ?
test = True
else:
result = ERROR
response = PaymentResponse(
result=result,
signed=signed,
bank_data=form,
order_id=reference,
transaction_id=form[self.BANK_ID],
bank_status=' - '.join(bank_status),
return_content=SPCHECKOK,
test=test)
return response
if __name__ == '__main__':
import sys
ntkey = '58 6d fc 9c 34 91 9b 86 3f fd 64 63 c9 13 4a 26 ba 29 74 1e c7 e9 80 79'
if len(sys.argv) == 2:
print(sign_url_paiement(ntkey, sys.argv[1]))
print(sign_ntkey_query(ntkey, sys.argv[1]))
elif len(sys.argv) > 2:
print(sign_url_paiement(sys.argv[1], sys.argv[2]))
print(sign_ntkey_query(sys.argv[1], sys.argv[2]))

View File

@ -1,46 +1,16 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
import base64
import datetime as dt
import hashlib
import hmac
import random
import re
import logging
import string
from six.moves.urllib import parse as urlparse
import warnings
from urllib import parse as urlparse
from gettext import gettext as _
import pytz
from .cb import translate_cb_error_code
from .common import (
CANCELLED,
DENIED,
ERROR,
FORM,
PAID,
Form,
PaymentCommon,
PaymentResponse,
ResponseError,
_,
force_byte,
force_text,
)
from .common import (PaymentCommon, PaymentResponse, PAID, ERROR, FORM, Form,
ResponseError, force_text, force_byte)
from .cb import CB_RESPONSE_CODES
__all__ = ['Payment']
@ -63,37 +33,17 @@ VADS_SITE_ID = 'vads_site_id'
VADS_TRANS_ID = 'vads_trans_id'
SIGNATURE = 'signature'
VADS_CTX_MODE = 'vads_ctx_mode'
VADS_EFFECTIVE_CREATION_DATE = 'vads_effective_creation_date'
VADS_EOPAYMENT_TRANS_ID = 'vads_ext_info_eopayment_trans_id'
def isonow():
return dt.datetime.utcnow().isoformat('T').replace('-', '').replace('T', '').replace(':', '')[:14]
def parse_utc(value):
try:
naive_dt = dt.datetime.strptime(value, '%Y%m%d%H%M%S')
except ValueError:
return None
return pytz.utc.localize(naive_dt)
return dt.datetime.utcnow().isoformat('T').replace('-', '') \
.replace('T', '').replace(':', '')[:14]
class Parameter:
def __init__(
self,
name,
ptype,
code,
max_length=None,
length=None,
needed=False,
default=None,
choices=None,
description=None,
help_text=None,
scope='global',
):
def __init__(self, name, ptype, code, max_length=None, length=None,
needed=False, default=None, choices=None, description=None,
help_text=None):
self.name = name
self.ptype = ptype
self.code = code
@ -104,7 +54,6 @@ 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:
@ -129,14 +78,10 @@ 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
# deferred payment
Parameter('capture_day', 'n', 6, max_length=3, default=''),
Parameter('vads_contrib', 'ans', 31, max_length=255, default='eopayment'),
# defaut currency = EURO, norme ISO4217
Parameter('vads_currency', 'n', 10, length=3, default='978', needed=True),
@ -150,72 +95,80 @@ 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="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',
),
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')),
# 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='Identifiant de la boutique'),
Parameter('vads_site_id', 'n', 2, length=8, needed=True,
description=_(u'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),
# 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_url_success', 'ans', 24, max_length=1024),
Parameter(VADS_TRANS_DATE, 'n', 4, length=14, needed=True,
default=isonow),
Parameter('vads_trans_id', 'n', 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_url_success', 'ans', 24, max_length=127),
Parameter('vads_url_referral', 'ans', 26, max_length=127),
Parameter('vads_url_refused', 'ans', 25, max_length=1024),
Parameter('vads_url_cancel', 'ans', 27, max_length=1024),
Parameter('vads_url_error', 'ans', 29, max_length=1024),
Parameter('vads_url_return', 'ans', 28, max_length=1024),
Parameter('vads_url_refused', 'ans', 25, max_length=127),
Parameter('vads_url_cancel', 'ans', 27, max_length=127),
Parameter('vads_url_error', 'ans', 29, max_length=127),
Parameter('vads_url_return', 'ans', 28, max_length=127),
Parameter('vads_user_info', 'ans', 61, max_length=255),
Parameter('vads_contracts', 'ans', 62, max_length=255),
Parameter(VADS_CUST_FIRST_NAME, 'ans', 104, max_length=63),
Parameter(VADS_CUST_LAST_NAME, 'ans', 104, max_length=63),
]
PARAMETER_MAP = {parameter.name: parameter for parameter in PARAMETERS}
PARAMETER_MAP = dict(((parameter.name,
parameter) for parameter in PARAMETERS))
AUTH_RESULT_MAP = CB_RESPONSE_CODES
RESULT_MAP = {
'00': 'paiement réalisé avec succés',
'02': 'le commerçant doit contacter la banque du porteur',
'05': 'paiement refusé',
'17': 'annulation client',
'30': 'erreur de format',
'96': 'erreur technique lors du paiement'
}
EXTRA_RESULT_MAP = {
'': "Pas de contrôle effectué",
'00': "Tous les contrôles se sont déroulés avec succés",
'02': "La carte a dépassé l'encours autorisé",
'03': "La carte appartient à la liste grise du commerçant",
'04': "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': "L'addresse IP appartient à la liste grise du commerçant",
'99': "Problème technique recontré par le serveur lors du traitement "
"d'un des contrôles locaux",
}
def add_vads(kwargs):
@ -234,40 +187,37 @@ 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/'
signature_algo = 'sha1'
'''
service_url = "https://paiement.systempay.fr/vads-payment/"
description = {
'caption': 'SystemPay, système de paiement du groupe BPCE',
'caption': 'SystemPay, système de paiment du groupe BPCE',
'parameters': [
{
'name': 'normal_return_url',
@ -280,126 +230,50 @@ 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': 'URL du service de paiement',
'help_text': 'ne pas modifier si vous ne savez pas',
'caption': _(u'URL du service de paiment'),
'help_text': _(u'ne pas modifier si vous ne savez pas'),
'validation': lambda x: x.startswith('http'),
'required': True,
},
{
'name': 'secret_test',
'caption': 'Secret pour la configuration de TEST',
'validation': lambda value: str.isalnum(value),
'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',
},
],
'required': True, },
{'name': 'secret_test',
'caption': _(u'Secret pour la configuration de TEST'),
'validation': lambda value: str.isdigit(value),
'required': True, },
{'name': 'secret_production',
'caption': _(u'Secret pour la configuration de PRODUCTION'),
'validation': lambda value: str.isdigit(value), },
]
}
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'):
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,
'scope': parameter.scope,
}
x = {'name': name,
'caption': parameter.description or name,
'validation': lambda value: parameter.check_value(value),
'default': parameter.default,
'required': parameter.needed,
'help_text': parameter.help_text,
'max_length': parameter.max_length}
description['parameters'].append(x)
def __init__(self, options, logger=None):
super().__init__(options, logger=logger)
super(Payment, self).__init__(options, logger=logger)
options = add_vads(options)
self.options = options
def make_vads_trans_id(self):
# vads_trans_id must be 6 alphanumeric characters,
# 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()
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))
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, **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)}))
@ -407,10 +281,8 @@ 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)
@ -440,18 +312,19 @@ 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()
assert re.match(r'^[0-9a-zA-Z]{6}$', vads_trans_id)
kwargs[VADS_TRANS_ID] = vads_trans_id
transaction_id = self.transaction_id(6, string.digits, 'systempay',
self.options[VADS_SITE_ID])
kwargs[VADS_TRANS_ID] = force_text(transaction_id)
fields = kwargs
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:
@ -459,185 +332,94 @@ class Payment(PaymentCommon):
fields[name] = parameter.default()
else:
fields[name] = parameter.default
capture_day = fields.pop('capture_day')
if capture_day:
fields['vads_capture_delay'] = capture_day
if manual_validation:
fields['vads_validation_mode'] = '1'
check_vads(fields)
if transaction_id:
fields[VADS_EOPAYMENT_TRANS_ID] = transaction_id
else:
transaction_id = '%s_%s' % (fields[VADS_TRANS_DATE], vads_trans_id)
fields[SIGNATURE] = force_text(self.signature(fields))
self.logger.debug('%s request contains fields: %s', __name__, fields)
transaction_id = '%s_%s' % (fields[VADS_TRANS_DATE], transaction_id)
self.logger.debug('%s transaction id: %s', __name__, transaction_id)
form = Form(
url=self.service_url,
method='POST',
fields=[
{
'type': 'hidden',
'type': u'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 = {
'00': {'message': 'Paiement réalisé avec succés.', 'result': PAID},
'02': {'message': 'Le commerçant doit contacter la banque du porteur.'},
'05': {'message': 'Paiement refusé.', 'result': DENIED},
'17': {'message': 'Annulation client.', 'result': CANCELLED},
'30': {'message': 'Erreur de format.'},
'96': {'message': 'Erreur technique lors du paiement.'},
}
EXTRA_RESULT_MAP = {
'': {'message': 'Pas de contrôle effectué.'},
'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.'
},
'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.'},
'08': {'message': 'Détection dune carte commerciale nationale.'},
'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.'
},
}
@classmethod
def make_eopayment_result(cls, fields):
# https://paiement.systempay.fr/doc/fr-FR/payment-file/oneclick-payment/vads-result.html
# https://paiement.systempay.fr/doc/fr-FR/payment-file/oneclick-payment/vads-auth-result.html
# https://paiement.systempay.fr/doc/fr-FR/payment-file/oneclick-payment/vads-extra-result.html
vads_result = fields.get(VADS_RESULT)
vads_auth_result = fields.get(VADS_AUTH_RESULT)
vads_extra_result = fields.get(VADS_EXTRA_RESULT)
# map to human messages and update return
vads_result_message = cls.RESULT_MAP.get(vads_result, {}).get('message')
if vads_result_message:
fields[VADS_RESULT + '_message'] = vads_result_message
vads_extra_result_message = cls.EXTRA_RESULT_MAP.get(vads_extra_result, {}).get('message')
if vads_extra_result_message:
fields[VADS_EXTRA_RESULT + '_message'] = vads_extra_result_message
vads_auth_result_message, auth_eopayment_result = translate_cb_error_code(vads_auth_result)
if vads_auth_result_message:
fields[VADS_AUTH_RESULT + '_message'] = vads_auth_result_message
# now build eopayment resume
if vads_result is None:
return ERROR, 'absence de champ vads_result'
if vads_result_message is None:
return ERROR, 'valeur vads_result inconnue'
result = cls.RESULT_MAP[vads_result].get('result', ERROR)
message = vads_result_message
if vads_auth_result_message and (vads_result != '00' or vads_result != vads_auth_result):
message += ' ' + vads_auth_result_message
if vads_result in ('00', '05', '30') and vads_extra_result_message and vads_extra_result != '':
message += ' ' + vads_extra_result_message
if result == ERROR and auth_eopayment_result not in (PAID, ERROR, None):
result = auth_eopayment_result
return result, message
def response(self, query_string, **kwargs):
fields = urlparse.parse_qs(query_string, True)
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))
if not set(fields) >= set([SIGNATURE, VADS_CTX_MODE, VADS_AUTH_RESULT]):
raise ResponseError()
for key, value in fields.items():
fields[key] = value[0]
copy = fields.copy()
bank_status = []
if VADS_AUTH_RESULT in fields:
v = copy[VADS_AUTH_RESULT]
ctx = (v, AUTH_RESULT_MAP.get(v, 'Code inconnu'))
copy[VADS_AUTH_RESULT] = '%s: %s' % ctx
bank_status.append(copy[VADS_AUTH_RESULT])
if VADS_RESULT in copy:
v = copy[VADS_RESULT]
ctx = (v, RESULT_MAP.get(v, 'Code inconnu'))
copy[VADS_RESULT] = '%s: %s' % ctx
bank_status.append(copy[VADS_RESULT])
if v == '30':
if VADS_EXTRA_RESULT in fields:
v = fields[VADS_EXTRA_RESULT]
if v.isdigit():
for parameter in PARAMETERS:
if int(v) == parameter.code:
s = 'erreur dans le champ %s' % parameter.name
copy[VADS_EXTRA_RESULT] = s
bank_status.append(copy[VADS_EXTRA_RESULT])
elif v in ('05', '00'):
if VADS_EXTRA_RESULT in fields:
v = fields[VADS_EXTRA_RESULT]
extra_result_name = EXTRA_RESULT_MAP.get(v, 'Code inconnu')
copy[VADS_EXTRA_RESULT] = '%s: %s' % (v, extra_result_name)
bank_status.append(copy[VADS_EXTRA_RESULT])
self.logger.debug('checking systempay response on:')
for key in sorted(fields.keys()):
self.logger.debug(' %s: %s' % (key, copy[key]))
signature = self.signature(fields)
result, message = self.make_eopayment_result(copy)
self.logger.debug('checking systempay response on: %r', copy)
signature_result = signature == fields[SIGNATURE]
self.logger.debug('signature check: %s <!> %s', signature,
fields[SIGNATURE])
if not signature_result:
self.logger.debug('signature check: %s <!> %s', signature, fields[SIGNATURE])
bank_status.append('invalid signature')
if not signature_result:
message += ' signature invalide.'
test = fields[VADS_CTX_MODE] == 'TEST'
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
if fields[VADS_AUTH_RESULT] == '00':
result = PAID
else:
raise ResponseError('backend error', message)
result = ERROR
test = fields[VADS_CTX_MODE] == 'TEST'
transaction_id = '%s_%s' % (copy[VADS_TRANS_DATE], copy[VADS_TRANS_ID])
# 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
if VADS_EFFECTIVE_CREATION_DATE in fields:
transaction_date = parse_utc(fields[VADS_EFFECTIVE_CREATION_DATE])
response = PaymentResponse(
result=result,
signed=signature_result,
bank_data=copy,
order_id=transaction_id,
transaction_id=transaction_id,
bank_status=message,
transaction_date=transaction_date,
test=test,
)
transaction_id=copy.get(VADS_AUTH_NUMBER),
bank_status=' - '.join(bank_status),
test=test)
return response
def sha1_sign(self, secret, signed_data):
return hashlib.sha1(signed_data).hexdigest()
def hmac_sha256_sign(self, secret, signed_data):
digest = hmac.HMAC(secret, digestmod=hashlib.sha256, msg=signed_data).digest()
return base64.b64encode(digest)
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()))
secret = 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('generating signature on «%s»', signed_data)
sign_method = getattr(self, '%s_sign' % self.signature_algo)
sign = sign_method(secret, signed_data)
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
signed_data = b'%s+%s' % (signed_data, force_byte(secret))
self.logger.debug(u'generating signature on «%s»', signed_data)
sign = hashlib.sha1(signed_data).hexdigest()
self.logger.debug(u'signature «%s»', sign)
return sign

View File

@ -1,111 +1,87 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
import datetime
import decimal
from decimal import Decimal, ROUND_DOWN
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 random
import re
import warnings
from urllib.parse import parse_qs, urlencode
import pytz
from .common import CANCELLED, DENIED, ERROR, PAID, URL, PaymentCommon, PaymentResponse, ResponseError, _
from .systempayv2 import isonow
__all__ = ['Payment']
TIPI_URL = 'https://www.payfip.gouv.fr/tpa/paiement.web'
TIPI_URL = 'http://www.jepaiemesserviceslocaux.dgfip.finances.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': _('Client number'),
'help_text': _('6 digits number provided by DGFIP'),
'validation': lambda s: str.isdigit(s) and (0 < int(s) < 1000000),
'required': True,
},
{
'name': 'normal_return_url',
'caption': _('Normal return URL (unused by TIPI)'),
'required': False,
},
{
'name': 'automatic_return_url',
'caption': _('Automatic return URL'),
'required': True,
},
{
'name': 'saisie',
'caption': _('Payment type'),
'required': True,
'default': 'T',
'choices': [
('T', _('test')),
('X', _('activation')),
('A', _('production')),
],
},
],
'caption': 'TIPI, Titres Payables par Internet',
'parameters': [
{
'name': 'numcli',
'caption': _(u'Numéro client'),
'help_text': _(u'un numéro à 6 chiffres communiqué par ladministrateur TIPI'),
'validation': lambda s: str.isdigit(s) and (0 < int(s) < 1000000),
'required': True,
},
{
'name': 'service_url',
'default': TIPI_URL,
'caption': _(u'URL du service TIPI'),
'help_text': _(u'ne pas modifier si vous ne savez pas'),
'validation': lambda x: x.startswith('http'),
'required': True,
},
{
'name': 'normal_return_url',
'caption': _('Normal return URL (unused by TIPI)'),
'required': False,
},
{
'name': 'automatic_return_url',
'caption': _('Automatic return URL'),
'required': True,
},
{
'name': 'saisie',
'caption': _('Payment type'),
'required': True,
'default': 'T',
},
],
}
REFDET_RE = re.compile('^[a-zA-Z0-9]{6,30}$')
minimal_amount = decimal.Decimal('1.0')
maximal_amount = decimal.Decimal('100000.0')
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)
def request(self, amount, next_url=None, exer=None, orderid=None,
refdet=None, objet=None, email=None, saisie=None, **kwargs):
try:
montant = Decimal(amount)
if Decimal('0') > montant > Decimal('9999.99'):
raise ValueError('MONTANT > 9999.99 euros')
montant = montant*Decimal('100')
montant = montant.to_integral_value(ROUND_DOWN)
except ValueError:
raise ValueError('MONTANT invalid format, must be '
'a decimal integer with less than 4 digits '
'before and 2 digits after the decimal point '
', here it is %s' % repr(amount))
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:
@ -114,28 +90,23 @@ class Payment(PaymentCommon):
raise ValueError()
except ValueError:
raise ValueError('EXER format invalide')
assert not (orderid and refdet), 'orderid and refdet cannot be used together'
# check or generate refdet
if refdet:
try:
if not self.REFDET_RE.match(refdet):
raise ValueError
except (TypeError, ValueError):
raise ValueError('refdet must be 6 to 30 alphanumeric characters string')
if orderid:
if self.REFDET_RE.match(orderid):
refdet = orderid
else:
objet = orderid + (' ' + objet) if objet else ''
if not refdet:
refdet = self._generate_refdet()
transaction_id = refdet
# check objet or fix objet
try:
refdet = orderid or refdet
refdet = str(refdet)
if 6 > len(refdet) > 30:
raise ValueError('len(REFDET) < 6 or > 30')
except Exception as e:
raise ValueError('REFDET format invalide, %r' % refdet, e)
if objet is not None:
try:
objet = objet.encode('ascii')
objet = str(objet)
except Exception as e:
raise ValueError('OBJET must be an alphanumeric string', e)
raise ValueError('OBJET must be a string', e)
if not objet.replace(' ','').isalnum():
raise ValueError('OBJECT must only contains '
'alphanumeric characters, %r' % objet)
if len(objet) > 99:
raise ValueError('OBJET length must be less than 100')
try:
mel = str(email)
if '@' not in mel:
@ -145,36 +116,46 @@ class Payment(PaymentCommon):
except Exception as e:
raise ValueError('MEL is not a valid email, %r' % mel, e)
# check saisie
saisie = saisie or self.saisie
if saisie not in ('M', 'T', 'X', 'A'):
raise ValueError('SAISIE invalid format, %r, must be M, T, X or A' % saisie)
iso_now = isonow()
transaction_id = '%s_%s' % (iso_now, refdet)
if objet:
objet = objet[:100-len(iso_now)-2] + ' ' + iso_now
else:
objet = iso_now
params = {
'numcli': self.numcli,
'refdet': refdet,
'montant': montant,
'mel': mel,
'saisie': saisie,
'numcli': self.numcli,
'refdet': refdet,
'montant': montant,
'mel': mel,
'saisie': saisie,
'objet': objet,
}
if exer:
params['exer'] = exer
if objet:
params['objet'] = objet
if automatic_return_url:
params['urlcl'] = automatic_return_url
url = '%s?%s' % (TIPI_URL, urlencode(params))
url = '%s?%s' % (self.service_url, urlencode(params))
return transaction_id, URL, url
def response(self, query_string, **kwargs):
fields = parse_qs(query_string, True)
if not set(fields) >= {'refdet', 'resultrans'}:
raise ResponseError('missing refdet or resultrans')
if not set(fields) >= set(['refdet', 'resultrans']):
raise ResponseError()
for key, value in fields.items():
fields[key] = value[0]
refdet = fields.get('refdet')
if refdet is None:
raise ResponseError('refdet is missing')
raise ValueError('refdet is missing')
if 'objet' in fields:
iso_now = fields['objet']
else:
iso_now = isonow()
transaction_id = '%s_%s' % (iso_now, refdet)
result = fields.get('resultrans')
if result == 'P':
@ -193,25 +174,9 @@ class Payment(PaymentCommon):
test = fields.get('saisie') == 'T'
return PaymentResponse(
result=result,
bank_status=bank_status,
signed=True,
bank_data=fields,
order_id=refdet,
transaction_id=refdet,
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
result=result,
bank_status=bank_status,
signed=True,
bank_data=fields,
transaction_id=transaction_id,
test=test)

9
jenkins.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/sh
set -e
pip install --upgrade pip
pip install --upgrade pylint pylint-django
pip install --upgrade tox
(pylint -f parseable --rcfile /var/lib/jenkins/pylint.django.rc eopayment | tee pylint.out) || /bin/true
tox -r

138
setup.py
View File

@ -4,24 +4,16 @@
Setup script for eopayment
'''
import subprocess
import distutils
import distutils.core
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
from distutils.command.sdist import sdist
from glob import glob
from os.path import splitext, basename, join as pjoin, dirname
import os
from unittest import TextTestRunner, TestLoader
import doctest
class TestCommand(distutils.core.Command):
@ -34,25 +26,27 @@ 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()
@ -60,111 +54,51 @@ 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') as v:
with open('VERSION', 'r') 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
if '-' in result: # not a tagged version
try:
real_number, commit_count, commit_hash = result.split('-', 2)
except ValueError:
real_number, commit_hash = result.split('-', 2)
commit_count = 0
version = '%s.post%s+%s' % (real_number, commit_count, commit_hash)
else:
version = result.replace('.dirty', '+dirty')
version = str(result.split()[0][1:])
version = version.replace('-', '.')
return version
else:
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',
include_package_data=True,
long_description=open(os.path.join(os.path.dirname(__file__), 'README.txt'), encoding='utf-8').read(),
long_description_content_type='text/plain',
description='Common API to use all French online payment credit card '
'processing services',
long_description=open(
os.path.join(
os.path.dirname(__file__),
'README.txt')).read(),
url='http://dev.entrouvert.org/projects/eopayment/',
author="Entr'ouvert",
author_email='info@entrouvert.com',
maintainer='Benjamin Dauvergne',
maintainer_email='bdauvergne@entrouvert.com',
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Web Environment',
'Intended Audience :: Developers',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Operating System :: POSIX',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
],
author_email="info@entrouvert.com",
maintainer="Benjamin Dauvergne",
maintainer_email="bdauvergne@entrouvert.com",
packages=['eopayment'],
install_requires=[
'pycryptodomex',
'pytz',
'pycrypto >= 2.5',
'requests',
'click',
'zeep >= 2.5',
'six',
],
cmdclass={
'build': build,
'compile_translations': compile_translations,
'install_lib': install_lib,
'sdist': eo_sdist,
},
}
)

View File

@ -1,111 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 json
import httmock
import lxml.etree as ET
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')
class LoggingAdapter(HTTPAdapter):
def __init__(self, *args, **kwargs):
self.history = []
super().__init__(*args, **kwargs)
def send(self, request, *args, **kwargs):
response = super().send(request, *args, **kwargs)
self.history.append((request, response))
return response
def xmlindent(content):
if hasattr(content, 'encode') or hasattr(content, 'decode'):
content = ET.fromstring(content)
return ET.tostring(content, pretty_print=True).decode('utf-8', 'ignore')
@pytest.fixture
def record_http_session(request):
module_name = request.module.__name__.split('test_', 1)[-1]
function_name = request.function.__name__
save = request.config.getoption('--save-http-session')
filename = 'tests/data/%s-%s.json' % (module_name, function_name)
def is_xml_content_type(r):
headers = r.headers
content_type = headers.get('content-type')
return content_type and content_type.startswith(('text/xml', 'application/xml'))
if save:
session = Session()
adapter = LoggingAdapter()
session.mount('http://', adapter)
session.mount('https://', adapter)
try:
yield session
finally:
with open(filename, 'w') as fd:
history = []
for request, response in adapter.history:
request_content = request.body or b''
response_content = response.content or b''
if is_xml_content_type(request):
request_content = xmlindent(request_content)
else:
request_content = request_content.decode('utf-8')
if is_xml_content_type(response):
response_content = xmlindent(response_content)
else:
response_content = response_content.decode('utf-8')
history.append((request_content, response_content))
json.dump(history, fd)
else:
with open(filename) as fd:
history = json.load(fd)
class Mocker:
counter = 0
@httmock.urlmatch()
def mock(self, url, request):
expected_request_content, response_content = history[self.counter]
self.counter += 1
if expected_request_content:
request_content = request.body or b''
if is_xml_content_type(request):
request_content = xmlindent(request_content)
else:
request_content = request_content.decode('utf-8')
assert request_content == expected_request_content
return response_content
with httmock.HTTMock(Mocker().mock):
yield None
@pytest.fixture
def target_url(request):
return request.config.getoption('--target-url') or 'https://target.url/'

View File

@ -1 +0,0 @@
[["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:recupererDetailClient xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <numCli>090909</numCli>\n </arg0>\n </ns0:recupererDetailClient>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:recupererDetailClientResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <libelleN1>RR COMPOSTEURS INDIVIDUELS</libelleN1>\n <libelleN2>POUETPOUET</libelleN2>\n <libelleN3>COLLECTE VALORISATION DECHETS</libelleN3>\n <numcli>090909</numcli>\n </return>\n </ns2:recupererDetailClientResponse>\n </S:Body>\n</S:Envelope>\n"]]

View File

@ -1 +0,0 @@
[["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:creerPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <exer>2019</exer>\n <mel>john.doeexample.com</mel>\n <montant>9990000001</montant>\n <numcli>090909</numcli>\n <objet>coucou</objet>\n <refdet>ABCDEF</refdet>\n <saisie>T</saisie>\n <urlnotif>https://notif.payfip.example.com/</urlnotif>\n <urlredirect>https://redirect.payfip.example.com/</urlredirect>\n </arg0>\n </ns0:creerPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <S:Fault xmlns:ns4=\"http://www.w3.org/2003/05/soap-envelope\">\n <faultcode>S:Server</faultcode>\n <faultstring>fr.gouv.finances.cp.tpa.webservice.exceptions.FonctionnelleErreur</faultstring>\n <detail>\n <ns2:FonctionnelleErreur xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <code>A2</code>\n <descriptif/>\n <libelle>Adresse m&#233;l incorrecte. </libelle>\n <severite>2</severite>\n </ns2:FonctionnelleErreur>\n </detail>\n </S:Fault>\n </S:Body>\n</S:Envelope>\n"]]

View File

@ -1 +0,0 @@
[["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:creerPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <exer>2019</exer>\n <mel>john.doe@example.com</mel>\n <montant>1000</montant>\n <numcli>090909</numcli>\n <objet>coucou</objet>\n <refdet>ABCDEFGH</refdet>\n <saisie>T</saisie>\n <urlnotif>https://notif.payfip.example.com/</urlnotif>\n <urlredirect>https://redirect.payfip.example.com/</urlredirect>\n </arg0>\n </ns0:creerPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:creerPaiementSecuriseResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </return>\n </ns2:creerPaiementSecuriseResponse>\n </S:Body>\n</S:Envelope>\n"]]

View File

@ -1 +0,0 @@
[["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:creerPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <exer>2019</exer>\n <mel>john.doe@example.com</mel>\n <montant>1000</montant>\n <numcli>090909</numcli>\n <objet>coucou</objet>\n <refdet>ABCD</refdet>\n <saisie>T</saisie>\n <urlnotif>https://notif.payfip.example.com/</urlnotif>\n <urlredirect>https://redirect.payfip.example.com/</urlredirect>\n </arg0>\n </ns0:creerPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <S:Fault xmlns:ns4=\"http://www.w3.org/2003/05/soap-envelope\">\n <faultcode>S:Server</faultcode>\n <faultstring>fr.gouv.finances.cp.tpa.webservice.exceptions.FonctionnelleErreur</faultstring>\n <detail>\n <ns2:FonctionnelleErreur xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <code>R3</code>\n <descriptif/>\n <libelle>Le format du param&#232;tre REFDET n'est pas conforme</libelle>\n <severite>2</severite>\n </ns2:FonctionnelleErreur>\n </detail>\n </S:Fault>\n </S:Body>\n</S:Envelope>\n"]]

View File

@ -1 +0,0 @@
[["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:recupererDetailPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </arg0>\n </ns0:recupererDetailPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <S:Fault xmlns:ns4=\"http://www.w3.org/2003/05/soap-envelope\">\n <faultcode>S:Server</faultcode>\n <faultstring>fr.gouv.finances.cp.tpa.webservice.exceptions.FonctionnelleErreur</faultstring>\n <detail>\n <ns2:FonctionnelleErreur xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <code>P1</code>\n <descriptif/>\n <libelle>IdOp incorrect.</libelle>\n <severite>2</severite>\n </ns2:FonctionnelleErreur>\n </detail>\n </S:Fault>\n </S:Body>\n</S:Envelope>\n"]]

View File

@ -1 +0,0 @@
[["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:recupererDetailPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </arg0>\n </ns0:recupererDetailPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <S:Fault xmlns:ns4=\"http://www.w3.org/2003/05/soap-envelope\">\n <faultcode>S:Server</faultcode>\n <faultstring>fr.gouv.finances.cp.tpa.webservice.exceptions.FonctionnelleErreur</faultstring>\n <detail>\n <ns2:FonctionnelleErreur xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <code>P5</code><descriptif />\n <libelle>R&#233;sultat de la transaction non connu.</libelle><severite>2</severite>\n </ns2:FonctionnelleErreur>\n </detail>\n </S:Fault>\n </S:Body>\n</S:Envelope>\n"]]

View File

@ -1 +0,0 @@
[["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:recupererDetailPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </arg0>\n </ns0:recupererDetailPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:recupererDetailPaiementSecuriseResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <dattrans>12122019</dattrans><exer>20</exer><heurtrans>1311</heurtrans><idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp><mel>john.doe@example.com</mel><montant>1000</montant><numauto>112233445566-tip</numauto><numcli>090909</numcli><objet>coucou</objet><refdet>EFEFAEFG</refdet><resultrans>V</resultrans><saisie>T</saisie>\n </return>\n </ns2:recupererDetailPaiementSecuriseResponse>\n </S:Body>\n</S:Envelope>\n"]]

View File

@ -1,4 +0,0 @@
[
["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:creerPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <exer>2019</exer>\n <mel>john.doe@example.com</mel>\n <montant>1000</montant>\n <numcli>090909</numcli>\n <refdet>201912261758460053903194</refdet>\n <saisie>T</saisie>\n <urlnotif>https://notif.payfip.example.com/</urlnotif>\n <urlredirect>https://redirect.payfip.example.com/</urlredirect>\n </arg0>\n </ns0:creerPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:creerPaiementSecuriseResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </return>\n </ns2:creerPaiementSecuriseResponse>\n </S:Body>\n</S:Envelope>\n"],
["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:recupererDetailPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </arg0>\n </ns0:recupererDetailPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:recupererDetailPaiementSecuriseResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <dattrans>12122019</dattrans><exer>20</exer><heurtrans>1311</heurtrans><idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp><mel>john.doe@example.com</mel><montant>1000</montant><numcli>090909</numcli><refdet>201912261758460053903194</refdet><resultrans>A</resultrans><saisie>T</saisie>\n </return>\n </ns2:recupererDetailPaiementSecuriseResponse>\n </S:Body>\n</S:Envelope>\n"]
]

View File

@ -1,4 +0,0 @@
[
["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:creerPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <exer>2019</exer>\n <mel>john.doe@example.com</mel>\n <montant>1000</montant>\n <numcli>090909</numcli>\n <refdet>201912261758460053903194</refdet>\n <saisie>T</saisie>\n <urlnotif>https://notif.payfip.example.com/</urlnotif>\n <urlredirect>https://redirect.payfip.example.com/</urlredirect>\n </arg0>\n </ns0:creerPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:creerPaiementSecuriseResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </return>\n </ns2:creerPaiementSecuriseResponse>\n </S:Body>\n</S:Envelope>\n"],
["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:recupererDetailPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </arg0>\n </ns0:recupererDetailPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:recupererDetailPaiementSecuriseResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <dattrans>12122019</dattrans><exer>20</exer><heurtrans>1311</heurtrans><idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp><mel>john.doe@example.com</mel><montant>1000</montant><numcli>090909</numcli><refdet>201912261758460053903194</refdet><resultrans>R</resultrans><saisie>T</saisie>\n </return>\n </ns2:recupererDetailPaiementSecuriseResponse>\n </S:Body>\n</S:Envelope>\n"]
]

View File

@ -1,4 +0,0 @@
[
["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:creerPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <exer>2019</exer>\n <mel>john.doe@example.com</mel>\n <montant>1000</montant>\n <numcli>090909</numcli>\n <refdet>201912261758460053903194</refdet>\n <saisie>T</saisie>\n <urlnotif>https://notif.payfip.example.com/</urlnotif>\n <urlredirect>https://redirect.payfip.example.com/</urlredirect>\n </arg0>\n </ns0:creerPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:creerPaiementSecuriseResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </return>\n </ns2:creerPaiementSecuriseResponse>\n </S:Body>\n</S:Envelope>\n"],
["<soap-env:Envelope xmlns:soap-env=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <soap-env:Body>\n <ns0:recupererDetailPaiementSecurise xmlns:ns0=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <arg0>\n <idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp>\n </arg0>\n </ns0:recupererDetailPaiementSecurise>\n </soap-env:Body>\n</soap-env:Envelope>\n", "<S:Envelope xmlns:S=\"http://schemas.xmlsoap.org/soap/envelope/\">\n <S:Body>\n <ns2:recupererDetailPaiementSecuriseResponse xmlns:ns2=\"http://securite.service.tpa.cp.finances.gouv.fr/services/mas_securite/contrat_paiement_securise/PaiementSecuriseService\">\n <return>\n <dattrans>12122019</dattrans><exer>20</exer><heurtrans>1311</heurtrans><idOp>cc0cb210-1cd4-11ea-8cca-0213ad91a103</idOp><mel>john.doe@example.com</mel><montant>1000</montant><numauto>112233445566-tip</numauto><numcli>090909</numcli><refdet>201912261758460053903194</refdet><resultrans>P</resultrans><saisie>T</saisie>\n </return>\n </ns2:recupererDetailPaiementSecuriseResponse>\n </S:Body>\n</S:Envelope>\n"]
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,128 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
from datetime import date, datetime, timedelta
from unittest import mock
import pytest
import eopayment
from eopayment.common import PaymentCommon
def do_mock_backend(monkeypatch):
class MockBackend(PaymentCommon):
request = mock.Mock()
description = {
'parameters': [
{
'name': 'capture_day',
},
{
'name': 'manual_validation',
'caption': 'Validation manuelle',
'type': bool,
'default': False,
'scope': 'transaction',
},
{
'name': 'global_param',
'caption': 'Global Param',
'type': bool,
'default': False,
'scope': 'global',
},
]
}
def get_backend(*args, **kwargs):
def backend(*args, **kwargs):
return MockBackend
return backend
monkeypatch.setattr(eopayment, 'get_backend', get_backend)
return MockBackend, eopayment.Payment('kind', None)
def test_deferred_payment(monkeypatch):
mock_backend, payment = do_mock_backend(monkeypatch)
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': '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.'):
payment.request(amount=12.2, capture_date=capture_date)
# capture date should be a date object
capture_date = 'not a date'
with pytest.raises(ValueError, match='capture_date should be a datetime.date object.'):
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)
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)
def test_paris_timezone(freezer, monkeypatch):
freezer.move_to('2018-10-02 23:50:00')
_, 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'):
# 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
payment.request(amount=12.2, capture_date=capture_date)
def test_get_parameters(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
global_parameters = payment.get_parameters()
assert len(global_parameters) == 2
assert global_parameters[0]['name'] == 'capture_day'
assert global_parameters[1]['name'] == 'global_param'
transaction_parameters = payment.get_parameters(scope='transaction')
assert len(transaction_parameters) == 1
assert transaction_parameters[0]['name'] == 'manual_validation'
def test_payment_status(monkeypatch):
_, payment = do_mock_backend(monkeypatch)
assert not payment.has_payment_status
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

@ -1,82 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 datetime
from urllib.parse import parse_qs, urlparse
import pytest
import eopayment
@pytest.fixture
def backend():
options = {
'automatic_notification_url': 'http://example.com/direct_notification_url',
'origin': 'Mairie de Perpette-les-oies',
}
return eopayment.Payment('dummy', options)
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'
)
assert transaction_id
assert method == 1
url = urlparse(raw_url)
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()}
assert qs['transaction_id'] == transaction_id
assert qs['amount'] == '10.10'
assert qs['origin'] == 'Mairie de Perpette-les-oies'
assert qs['capture_day'] == '6'
assert qs['subject'] == 'Repas pour 4 personnes'
def test_response(backend):
retour = (
'http://example.com/retour?amount=10.0'
'&direct_notification_url=http%3A%2F%2Fexample.com%2Fdirect_notification_url'
'&email=toto%40example.com'
'&transaction_id=6Tfw2e1bPyYnz7CedZqvdHt7T9XX6T'
'&return_url=http%3A%2F%2Fexample.com%2Fretour'
'&nok=1'
)
r = backend.response(retour.split('?', 1)[1])
assert not r.signed
assert r.transaction_id == '6Tfw2e1bPyYnz7CedZqvdHt7T9XX6T'
assert r.return_content is None
retour = (
'http://example.com/retour'
'?amount=10.0'
'&direct_notification_url=http%3A%2F%2Fexample.com%2Fdirect_notification_url'
'&email=toto%40example.com'
'&transaction_id=6Tfw2e1bPyYnz7CedZqvdHt7T9XX6T'
'&return_url=http%3A%2F%2Fexample.com%2Fretour'
'&ok=1&signed=1'
)
r = backend.response(retour.split('?', 1)[1])
assert r.signed
assert r.transaction_id == '6Tfw2e1bPyYnz7CedZqvdHt7T9XX6T'
assert r.return_content == 'signature ok'
with pytest.raises(eopayment.ResponseError, match='missing transaction_id'):
backend.response('foo=bar')

View File

@ -1,226 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 json
import pytest
import requests
from httmock import HTTMock, all_requests, remember_called, response, urlmatch, with_httmock
import eopayment
from eopayment.keyware import Payment
WEBHOOK_URL = 'https://callback.example.com'
RETURN_URL = 'https://return.example.com'
API_KEY = 'test'
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',
}
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,
'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',
}
],
'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',
}
],
}
@remember_called
@urlmatch(scheme='https', netloc='api.online.emspay.eu', path='/v1/orders', method='POST')
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):
resp = GET_ORDER_RESPONSE.copy()
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):
resp = GET_ORDER_RESPONSE.copy()
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,
}
)
@with_httmock(add_order)
def test_keyware_request(keyware):
email = 'test@test.com'
order_id, kind, url = keyware.request(2.5, email=email)
assert order_id == ORDER_ID
assert kind == eopayment.URL
assert 'api.online.emspay.eu/pay/' in url
body = json.loads(add_order.call['requests'][0].body.decode())
assert body['currency'] == 'EUR'
assert body['customer']['email_address'] == email
assert isinstance(body['amount'], int)
assert body['amount'] == 250
assert body['webhook_url'] == WEBHOOK_URL
assert body['return_url'] == RETURN_URL
@with_httmock(successful_order)
def test_keyware_response(keyware):
payment_response = keyware.response(QUERY_STRING)
assert payment_response.result == eopayment.PAID
assert payment_response.signed is True
assert payment_response.bank_data == GET_ORDER_RESPONSE
assert payment_response.order_id == ORDER_ID
assert payment_response.transaction_id == ORDER_ID
assert payment_response.bank_status == 'completed'
assert payment_response.test is False
request = successful_order.call['requests'][0]
assert ORDER_ID in request.url
@with_httmock(error_order)
def test_keyware_response_error(keyware):
payment_response = keyware.response(QUERY_STRING)
assert payment_response.result == eopayment.ERROR
@with_httmock(cancelled_order)
def test_keyware_cancel(keyware):
resp = keyware.cancel(amount=995, bank_data=POST_ORDER_RESPONSE)
request = cancelled_order.call['requests'][0]
assert ORDER_ID in request.url
@with_httmock(error_order)
def test_keyware_cancel_error(keyware):
with pytest.raises(eopayment.ResponseError) as excinfo:
keyware.cancel(amount=995, bank_data=POST_ORDER_RESPONSE)
assert 'expected "cancelled" status, got "error" instead' in str(excinfo.value)
@with_httmock(connection_error)
def test_keyware_endpoint_connection_error(keyware):
with pytest.raises(eopayment.PaymentException) as excinfo:
keyware.call_endpoint('GET', 'orders')
assert 'test msg' in str(excinfo.value)
@with_httmock(http_error)
def test_keyware_endpoint_http_error(keyware):
with pytest.raises(eopayment.PaymentException) as excinfo:
keyware.call_endpoint('GET', 'orders')
assert 'Bad request' in str(excinfo.value)
@with_httmock(invalid_json)
def test_keyware_endpoint_json_error(keyware):
with pytest.raises(eopayment.PaymentException) as excinfo:
keyware.call_endpoint('GET', 'orders')
assert 'JSON' in str(excinfo.value)

View File

@ -1,157 +0,0 @@
# 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

@ -1,294 +0,0 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 json
import pytest
import requests
from httmock import remember_called, response, urlmatch, with_httmock
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'
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',
},
},
}
GET_PAYMENTS_RESPONSE = {
'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',
},
'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/',
}
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='POST')
def add_payment(url, request):
return response(200, POST_PAYMENTS_RESPONSE, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET')
def successful_payment(url, request):
return response(200, GET_PAYMENTS_RESPONSE, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path=r'/v2/payments', method='DELETE')
def canceled_payment(url, request):
resp = GET_PAYMENTS_RESPONSE.copy()
resp['status'] = 'canceled'
return response(200, resp, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments')
def failed_payment(url, request):
resp = GET_PAYMENTS_RESPONSE.copy()
resp['status'] = 'failed'
return response(200, resp, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments')
def expired_payment(url, request):
resp = GET_PAYMENTS_RESPONSE.copy()
resp['status'] = 'expired'
return response(200, resp, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path=r'/v2/payments', method='GET')
def canceled_payment_get(url, request):
resp = GET_PAYMENTS_RESPONSE.copy()
resp['status'] = 'canceled'
return response(200, resp, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET')
def connection_error(url, request):
raise requests.ConnectionError('test msg')
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET')
def http_error(url, request):
error_payload = {
'status': 404,
'title': 'Not Found',
'detail': 'No payment exists with token hop.',
}
return response(400, error_payload, request=request)
@remember_called
@urlmatch(scheme='https', netloc='api.mollie.com', path='/v2/payments', method='GET')
def invalid_json(url, request):
return response(200, '{', request=request)
@pytest.fixture
def mollie():
return Payment(
{
'normal_return_url': RETURN_URL,
'automatic_return_url': WEBHOOK_URL,
'api_key': API_KEY,
}
)
@with_httmock(add_payment)
def test_mollie_request(mollie):
email = 'test@test.com'
payment_id, kind, url = mollie.request(2.5, email=email)
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['webhookUrl'] == WEBHOOK_URL
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)
assert payment_response.result == eopayment.PAID
assert payment_response.signed is True
assert payment_response.bank_data == GET_PAYMENTS_RESPONSE
assert payment_response.order_id == PAYMENT_ID
assert payment_response.transaction_id == PAYMENT_ID
assert payment_response.bank_status == 'paid'
assert payment_response.test is True
request = successful_payment.call['requests'][0]
assert PAYMENT_ID in request.url
@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
)
assert payment_response.result == eopayment.PAID
request = successful_payment.call['requests'][0]
assert PAYMENT_ID in request.url
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
)
assert payment_response.result == eopayment.PAID
assert payment_response.order_id == PAYMENT_ID
@with_httmock(failed_payment)
def test_mollie_response_failed(mollie):
payment_response = mollie.response(QUERY_STRING)
assert payment_response.result == eopayment.ERROR
@with_httmock(canceled_payment_get)
def test_mollie_response_canceled(mollie):
payment_response = mollie.response(QUERY_STRING)
assert payment_response.result == eopayment.CANCELED
@with_httmock(expired_payment)
def test_mollie_response_expired(mollie):
payment_response = mollie.response(QUERY_STRING)
assert payment_response.result == eopayment.CANCELED
@with_httmock(connection_error)
def test_mollie_endpoint_connection_error(mollie):
with pytest.raises(eopayment.PaymentException) as excinfo:
mollie.call_endpoint('GET', 'payments')
assert 'test msg' in str(excinfo.value)
@with_httmock(http_error)
def test_mollie_endpoint_http_error(mollie):
with pytest.raises(eopayment.PaymentException) as excinfo:
mollie.call_endpoint('GET', 'payments')
assert 'Not Found' in str(excinfo.value)
assert 'token' in str(excinfo.value)
@with_httmock(invalid_json)
def test_mollie_endpoint_json_error(mollie):
with pytest.raises(eopayment.PaymentException) as excinfo:
mollie.call_endpoint('GET', 'payments')
assert 'JSON' in str(excinfo.value)

View File

@ -1,157 +1,96 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
from urllib import parse as urllib
from xml.etree import ElementTree as ET
import pytest
from unittest import TestCase
from six.moves.urllib import parse as urllib
import eopayment
import eopayment.ogone as ogone
from eopayment import ResponseError
PSPID = '2352566ö'
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'
}
@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
class OgoneTests(TestCase):
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)
from xml.etree import ElementTree as ET
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_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_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'3EE0CF69B5A8514962C9CF8A545861F0CA1C6891'}
# 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_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=435D5E36E1F4B17739C1054FFD204218E65C15AB'
response = ogone_backend.response(backend_response)
assert response.signed
self.assertEqual(response.order_id, order_id)
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_bad_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
order_id = 'myorder'
data = {'payid': '32100123', 'status': 9, 'ncerror': 0}
with self.assertRaises(ResponseError):
response = ogone_backend.response(urllib.urlencode(data))
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
def test_bank_transfer_response(self):
ogone_backend = eopayment.Payment('ogone', BACKEND_PARAMS)
order_id = 'myorder'
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'0E35F687ACBEAA6CA769E0ADDBD0863EB6C1678A'}
# 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

View File

@ -1,362 +1,96 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
import base64
import codecs
from unittest import TestCase
from decimal import Decimal
from unittest import TestCase, mock
from urllib import parse as urllib
from xml.etree import ElementTree as ET
import base64
from six.moves.urllib import parse as urllib
import pytest
import eopayment
import eopayment.paybox as paybox
import eopayment
BACKEND_PARAMS = {
'platform': 'test',
'site': '12345678',
'rang': '001',
'identifiant': '12345678',
'shared_secret': (
'0123456789ABCDEF0123456789ABCDEF01234'
'56789ABCDEF0123456789ABCDEF0123456789'
'ABCDEF0123456789ABCDEF0123456789ABCDE'
'F0123456789ABCDEF'
),
'automatic_return_url': 'http://example.com/callback',
'platform': u'test',
'site': u'12345678',
'rang': u'001',
'identifiant': u'12345678',
'shared_secret': u'0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF',
'callback': u'http://example.com/callback',
}
class PayboxTests(TestCase):
def test_sign(self):
key = (
b'0123456789ABCDEF0123456789ABCDEF0123456789'
b'ABCDEF0123456789ABCDEF0123456789ABCDEF0123'
b'456789ABCDEF0123456789ABCDEF0123456789ABCD'
b'EF'
)
key = b'0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF'
key = codecs.decode(key, 'hex')
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'],
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,
)
)
result = (
'7E74D8E9A0DBB65AAE51C5C50C2668FD98FC99AED'
'F18244BB1935F602B6C2E953B61FD84364F34FDB8'
'8B049901C0A07F6040AF446BBF5589113F48A733D'
'551D4'
)
key))
result = '7E74D8E9A0DBB65AAE51C5C50C2668FD98FC99AEDF18244BB1935F602B6C2E953B61FD84364F34FDB88B049901C0A07F6040AF446BBF5589113F48A733D551D4'
self.assertIn('PBX_HMAC', d)
self.assertEqual(d['PBX_HMAC'], result)
@mock.patch('eopayment.paybox.Payment.make_pbx_archivage', wraps=True)
def test_request(self, make_pbx_archivage):
def test_request(self):
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,
total_quantity='1',
first_name='Kyan <oo>',
last_name='Khojandi',
address1='169 rue du Château',
zipcode='75014',
city='Paris',
country_code=250,
)
Decimal(amount), email=email, orderid=order_id,
transaction_id=transaction, time=time)
self.assertEqual(kind, eopayment.FORM)
self.assertEqual(transaction_id, '%s!%s' % (transaction, order_id))
self.assertEqual(transaction_id, '1234')
from xml.etree import ElementTree as ET
root = ET.fromstring(str(what))
self.assertEqual(root.tag, 'form')
self.assertEqual(root.attrib['method'], 'POST')
self.assertEqual(root.attrib['action'], paybox.URLS['test'])
expected_form_values = {
'PBX_RANG': '001',
'PBX_SITE': '12345678',
'PBX_IDENTIFIANT': '12345678',
'PBX_RETOUR': (
'montant:M;reference:R;code_autorisation:A;'
'erreur:E;numero_appel:T;numero_transaction:S;'
'date_transaction:W;heure_transaction:Q;signature:K'
),
'PBX_TIME': time,
'PBX_PORTEUR': email,
'PBX_CMD': '%s!%s' % (transaction, order_id),
'PBX_TOTAL': amount.replace('.', ''),
'PBX_DEVISE': '978',
'PBX_HASH': 'SHA512',
'PBX_HMAC': (
'EC9B753691D804F15B3369BEF9CA49F20585BE32E84'
'9A9758815903CE5A89822C251C7EBC712145FCA6321'
'C6A6F90EE45EBEC618FFC8B7A69CC23E1BFC6CACC7'
),
'PBX_ARCHIVAGE': '4YQEFSFZSNWA',
'PBX_REPONDRE_A': 'http://example.com/callback',
'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()), {'type', 'value'})
self.assertEqual(set(node.attrib.keys()), set(['type', 'value']))
if node.attrib['type'] == 'hidden':
self.assertEqual(set(node.attrib.keys()), {'type', 'name', 'value'})
self.assertEqual(set(node.attrib.keys()), set(['type', 'name', 'value']))
name = node.attrib['name']
form_params[name] = node.attrib['value']
assert form_params == expected_form_values
def test_request_with_capture_day(self):
params = BACKEND_PARAMS.copy()
time = '2018-08-21T10:26:32+02:00'
email = 'user@entrouvert.com'
order_id = '20180821'
transaction = '1234'
amount = '42.99'
for capture_day in ('7', '07'):
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
)
root = ET.fromstring(str(what))
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')
# capture_day can be used as a request argument
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',
)
root = ET.fromstring(str(what))
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')
# capture_day passed as a request argument
# overrides capture_day from backend params
params = BACKEND_PARAMS.copy()
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',
)
root = ET.fromstring(str(what))
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')
def test_request_with_authorization_only(self):
params = BACKEND_PARAMS.copy()
time = '2018-08-21T10:26:32+02:00'
email = 'user@entrouvert.com'
order_id = '20180821'
transaction = '1234'
amount = '42.99'
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
)
root = ET.fromstring(str(what))
form_params = {
node.attrib['name']: node.attrib['value'] for node in root if node.attrib['type'] == 'hidden'
}
self.assertEqual(form_params['PBX_AUTOSEULE'], 'O')
reference = order_id + eopayment.common.ORDERID_TRANSACTION_SEPARATOR + transaction
values = {
'PBX_RANG': '01',
'PBX_SITE': '12345678',
'PBX_IDENTIFIANT': '12345678',
'PBX_RETOUR': 'montant:M;reference:R;code_autorisation:A;erreur:E;signature:K',
'PBX_TIME': time,
'PBX_PORTEUR': email,
'PBX_CMD': reference,
'PBX_TOTAL': amount.replace('.', ''),
'PBX_DEVISE': '978',
'PBX_HASH': 'SHA512',
'PBX_HMAC': '173483CFF84A7ECF21039F99E9A95C5FB53D98A1562184F5B2C4543E4F87BFA227CC2CA10DE989D6C8B4DC03BC2ED44B7D7BDF5B4FABA8274D5D37C2F6445F36',
'PBX_ARCHIVAGE': '1234',
'PBX_REPONDRE_A': 'http://example.com/callback',
}
self.assertIn(name, values)
self.assertEqual(node.attrib['value'], values[name])
def test_response(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',
}
reference = order_id + eopayment.common.ORDERID_TRANSACTION_SEPARATOR + transaction
data = {'montant': '4242', 'reference': reference,
'code_autorisation': 'A', 'erreur': '00000'}
response = backend.response(urllib.urlencode(data))
self.assertEqual(response.order_id, reference)
assert not response.signed
assert response.transaction_date.isoformat() == '2020-01-01T01:01:01+01:00'
with self.assertRaisesRegex(eopayment.ResponseError, 'missing erreur or reference'):
backend.response('foo=bar')
def test_perform_operations(self):
operations = {'validate': '00002', 'cancel': '00055'}
for operation_name, operation_code in operations.items():
params = BACKEND_PARAMS.copy()
params['cle'] = 'cancelling_key'
backend = eopayment.Payment('paybox', params)
bank_data = {
'numero_transaction': ['13957441'],
'numero_appel': ['30310733'],
'reference': ['830657461681'],
}
backend_raw_response = (
'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': '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)
requests_post.return_value = response
backend_response = getattr(backend, operation_name)(Decimal('10'), bank_data)
self.assertEqual(requests_post.call_args[0][0], 'https://preprod-ppps.paybox.com/PPPS.php')
params_sent = requests_post.call_args[0][1]
# make sure the date parameter is present
assert 'DATEQ' in params_sent
# don't care about its value
params_sent.pop('DATEQ')
expected_params = {
'CLE': 'cancelling_key',
'VERSION': '00103',
'TYPE': operation_code,
'MONTANT': '1000',
'NUMAPPEL': '30310733',
'NUMTRANS': '13957441',
'NUMQUESTION': '0013957441',
'REFERENCE': '830657461681',
'RANG': backend.backend.rang,
'SITE': backend.backend.site,
'DEVISE': backend.backend.devise,
}
self.assertEqual(params_sent, expected_params)
self.assertEqual(backend_response, backend_expected_response)
params['platform'] = 'prod'
backend = eopayment.Payment('paybox', params)
with mock.patch('eopayment.paybox.requests.post') as requests_post:
response = mock.Mock(status_code=200, text=backend_raw_response)
requests_post.return_value = response
getattr(backend, operation_name)(Decimal('10'), bank_data)
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 = """CODEREPONSE=00015&COMMENTAIRE=PAYBOX : Transaction non trouvée"""
response = mock.Mock(status_code=200, text=error_response)
requests_post.return_value = response
self.assertRaisesRegex(
eopayment.ResponseError,
'Transaction non trouvée',
getattr(backend, operation_name),
Decimal('10'),
bank_data,
)
def test_validate_payment(self):
params = BACKEND_PARAMS.copy()
params['cle'] = 'cancelling_key'
backend = eopayment.Payment('paybox', params)
bank_data = {
'numero_transaction': ['13957441'],
'numero_appel': ['30310733'],
'reference': ['830657461681'],
}
backend_raw_response = (
'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:
response = mock.Mock(status_code=200, text=backend_raw_response)
requests_post.return_value = response
backend.validate(Decimal(12), bank_data)
self.assertEqual(response.order_id, order_id)
def test_rsa_signature_validation(self):
pkey = '''-----BEGIN PUBLIC KEY-----
@ -365,78 +99,8 @@ UX4D2A/QcMvkEcRVXFx5tQqcE9/JnMqE41TF/ebn7jC/MBxxtPFkUN7+EZoeMN7x
OWzAMDm/xsCWRvvel4GGixgm3aQRUPyTrlm4Ksy32Ya0rNnEDMAvB3dxOn7cp8GR
ZdzrudBlevZXpr6iYwIDAQAB
-----END PUBLIC KEY-----'''
data = 'coin\n'
data = b'coin\n'
sig64 = '''VCt3sgT0ecacmDEWWNVXJ+jGmIPBMApK42tBJV0FlDjpllOGPy8MsAmLW4/QjTtx
z0Dkz0NjxvU+5WzQZh9Uuxr/egRCwV4NMRWqu0zaVVioeBvl4/5CWm4f4/1L9+0m
FBFKOZhgBJnkC+l6+XhT4aYWKaQ4ocmOMV92yjeXTE4='''
self.assertTrue(paybox.verify(data, base64.b64decode(sig64), key=pkey))
def test_request_manual_validation(self):
params = BACKEND_PARAMS.copy()
time = '2018-08-21T10:26:32+02:00'
email = 'user@entrouvert.com'
order_id = '20180821'
transaction = '1234'
amount = '42.99'
backend = eopayment.Payment('paybox', params)
transaction_id, kind, what = backend.request(
Decimal(amount), email=email, orderid=order_id, transaction_id=transaction, time=time
)
root = ET.fromstring(str(what))
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')
transaction_id, kind, what = backend.request(
Decimal(amount),
email=email,
orderid=order_id,
transaction_id=transaction,
time=time,
manual_validation=True,
)
root = ET.fromstring(str(what))
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,556 +0,0 @@
#
# eopayment - online payment library
# Copyright (C) 2011-2020 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 datetime
import json
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/'
MEL = 'john.doe@example.com'
EXER = '2019'
REFDET = '201912261758460053903194'
REFDET_GEN = '201912261758460053903195'
def xmlindent(content):
if hasattr(content, 'encode') or hasattr(content, 'decode'):
content = ET.fromstring(content)
return ET.tostring(content, pretty_print=True).decode('utf-8', 'ignore')
# freeze time to fix EXER field to 2019
@pytest.fixture(autouse=True)
def freezer(freezer):
freezer.move_to('2019-12-12')
return freezer
class PayFiPHTTMock:
def __init__(self, history_name):
history_path = 'tests/data/payfip-%s.json' % history_name
with open(history_path) as fd:
self.history = json.load(fd)
self.counter = 0
@httmock.urlmatch(scheme='https')
def mock(self, url, request):
request_content, response_content = self.history[self.counter]
self.counter += 1
assert xmlindent(request.body) == request_content
return response_content
@pytest.fixture
def history_name(request):
return getattr(request.function, 'history_name', request.function.__name__)
@pytest.fixture
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
def get_idop():
with mock.patch('eopayment.payfip_ws.PayFiP.get_idop') as get_idop:
get_idop.return_value = 'idop-1234'
yield get_idop
@pytest.fixture
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,
},
)
@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 zeep_history_plugin():
return HistoryPlugin()
@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': [zeep_history_plugin]},
)
yield payfip
@pytest.fixture
def payfip_history(history, payfip, zeep_history_plugin, request):
yield
def set_history_name(name):
# decorator to add history_name to test
def decorator(func):
func.history_name = name
return func
return decorator
# pytestmark = pytest.mark.update_data
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(history, payfip):
result = payfip.get_idop(
numcli=NUMCLI,
exer='2019',
refdet='ABCDEFGH',
montant='1000',
mel=MEL,
objet='coucou',
url_notification=NOTIF_URL,
url_redirect=REDIRECT_URL,
saisie='T',
)
assert result == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
def test_get_idop_refdet_error(history, payfip):
with pytest.raises(PayFiPError, match='.*R3.*Le format.*REFDET.*conforme'):
payfip.get_idop(
numcli=NUMCLI,
exer='2019',
refdet='ABCD',
montant='1000',
mel=MEL,
objet='coucou',
url_notification='https://notif.payfip.example.com/',
url_redirect='https://redirect.payfip.example.com/',
saisie='T',
)
def test_get_idop_adresse_mel_incorrect(payfip, payfip_history):
with pytest.raises(PayFiPError, match='.*A2.*Adresse.*incorrecte'):
payfip.get_idop(
numcli=NUMCLI,
exer='2019',
refdet='ABCDEF',
montant='9990000001',
mel='john.doeexample.com',
objet='coucou',
url_notification='https://notif.payfip.example.com/',
url_redirect='https://redirect.payfip.example.com/',
saisie='T',
)
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',
'exer': '20',
'heurtrans': '1311',
'idOp': 'cc0cb210-1cd4-11ea-8cca-0213ad91a103',
'mel': MEL,
'montant': '1000',
'numauto': '112233445566-tip',
'numcli': NUMCLI,
'objet': 'coucou',
'refdet': 'EFEFAEFG',
'resultrans': 'V',
'saisie': 'T',
}
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')
@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
@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
)
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
)
assert response.result == eopayment.EXPIRED
@set_history_name('test_get_info_paiement_P1')
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
)
assert response.result == eopayment.EXPIRED
@set_history_name('test_get_info_paiement_P1')
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
)
assert response.result == eopayment.EXPIRED
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(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(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
)
assert response.result == eopayment.WAITING
@set_history_name('test_get_info_paiement_P5')
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
)
assert response.result == eopayment.WAITING
@set_history_name('test_get_info_paiement_P5')
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
)
assert response.result == eopayment.EXPIRED
@set_history_name('test_get_info_paiement_P5')
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
)
assert response.result == eopayment.EXPIRED
def test_payment_ok(history, payfip, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194',
)
assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert kind == eopayment.URL
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'
)
@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
)
assert response.result == eopayment.PAID
def test_payment_denied(history, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194',
)
assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert kind == eopayment.URL
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
assert response.bank_status == 'refused CB'
assert response.order_id == payment_id
assert response.transaction_id == '201912261758460053903194 cc0cb210-1cd4-11ea-8cca-0213ad91a103'
@set_history_name('test_payment_denied')
def test_payment_status_denied(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
)
assert response.result == eopayment.DENIED
def test_payment_cancelled(history, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
refdet='201912261758460053903194',
)
assert payment_id == 'cc0cb210-1cd4-11ea-8cca-0213ad91a103'
assert kind == eopayment.URL
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.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'
@set_history_name('test_payment_cancelled')
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
)
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():
assert normalize_objet(None) is None
assert (
normalize_objet('18/09/2020 Établissement attestation hors-sol n#1234')
== '18092020 Etablissement attestation hors sol n1234'
)
def test_refdet_exer(get_idop, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
exer=EXER,
refdet=REFDET,
)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
assert kwargs == {
'exer': EXER,
'refdet': REFDET,
'montant': '1000',
'objet': None,
'mel': MEL,
'numcli': NUMCLI,
'saisie': 'T',
'url_notification': NOTIF_URL,
'url_redirect': REDIRECT_URL,
}
def test_transaction_id_orderid_subject(get_idop, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
exer=EXER,
transaction_id='TR12345',
orderid='F20190003',
subject='Précompte famille #1234',
)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
assert kwargs == {
'exer': EXER,
'refdet': 'TR12345',
'montant': '1000',
'objet': 'O F20190003 S Precompte famille 1234',
'mel': MEL,
'numcli': NUMCLI,
'saisie': 'T',
'url_notification': NOTIF_URL,
'url_redirect': REDIRECT_URL,
}
def test_invalid_transaction_id_valid_orderid(get_idop, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
exer=EXER,
transaction_id='TR-12345',
orderid='F20190003',
subject='Précompte famille #1234',
)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
assert kwargs == {
'exer': EXER,
'refdet': 'F20190003',
'montant': '1000',
'objet': 'Precompte famille 1234 T TR 12345',
'mel': MEL,
'numcli': NUMCLI,
'saisie': 'T',
'url_notification': NOTIF_URL,
'url_redirect': REDIRECT_URL,
}
def test_invalid_transaction_id_invalid_orderid(get_idop, backend):
payment_id, kind, url = backend.request(
amount='10.00',
email=MEL,
# make test deterministic
exer=EXER,
transaction_id='TR-12345',
orderid='F/20190003',
subject='Précompte famille #1234',
)
assert payment_id == 'idop-1234'
kwargs = get_idop.call_args[1]
assert kwargs == {
'exer': EXER,
'refdet': REFDET_GEN,
'montant': '1000',
'objet': 'O F20190003 S Precompte famille 1234 T TR 12345',
'mel': MEL,
'numcli': NUMCLI,
'saisie': 'T',
'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,102 +0,0 @@
#
# eopayment - online payment library
# Copyright (C) 2011-2020 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 json
import pytest
import zeep.transports
import eopayment
@pytest.fixture
def saga(record_http_session):
if record_http_session:
from eopayment import saga
saga._zeep_transport = zeep.transports.Transport(session=record_http_session)
try:
yield None
finally:
saga._zeep_transport = None
else:
yield
@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 factory
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'
)
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'
)
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'
)
def test_response(backend_factory):
response = backend_factory().response('idop=28b52f40-1ace-11eb-8ce3-0213ad91a104', redirect=False)
assert response.__dict__ == {
'bank_data': {
'email': 'john.doe@entrouvert.com',
'etat': 'paye',
'id_tiers': '-1',
'montant': '10.00',
'num_service': '868',
'numcp': '70688',
'numcpt_lib_ecriture': 'COUCOU',
},
'bank_status': 'paid',
'order_id': '28b52f40-1ace-11eb-8ce3-0213ad91a104',
'result': 3,
'return_content': None,
'signed': True,
'test': False,
'transaction_date': None,
'transaction_id': '28b52f40-1ace-11eb-8ce3-0213ad91a104',
}
# Check bank_data is JSON serializable
json.dumps(response.bank_data)

View File

@ -1,66 +1,33 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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
# -*- coding: utf-8 -*-
import eopayment
def test_build_request():
backend = eopayment.Payment('sips2', {})
transaction, f, form = backend.request(amount='12', last_name='Foo', first_name='Félix000000')
transaction, f, form = backend.request(amount=u'12', last_name=u'Foo',
first_name=u'Félix000000')
data = [f for f in form.fields if f['name'] == 'Data']
assert 'lix000000' not in data[0]['value']
assert not u'lix000000' in data[0]['value']
transaction, f, form = backend.request(amount='12')
transaction, f, form = backend.request(amount=u'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='12', info1='foobar')
transaction, f, form = backend.request(amount=u'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='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': 'VALIDATION'})
payment = eopayment.Payment('sips2', {'capture_mode': u'VALIDATION'})
assert payment.backend.get_data()['captureMode'] == 'VALIDATION'
payment = eopayment.Payment('sips2', {})
assert 'captureDay' not in payment.backend.get_data()
assert not 'captureDay' in payment.backend.get_data()
payment = eopayment.Payment('sips2', {'capture_day': '10'})
assert 'captureDay' in payment.backend.get_data()
def test_parse_response():
qs = '''Data=captureDay%3D0%7CcaptureMode%3DAUTHOR_CAPTURE%7CcurrencyCode%3D978%7CmerchantId%3D002001000000001%7CorderChannel%3DINTERNET%7CresponseCode%3D00%7CtransactionDateTime%3D2016-02-01T17%3A44%3A20%2B01%3A00%7CtransactionReference%3D668930%7CkeyVersion%3D1%7CacquirerResponseCode%3D00%7Camount%3D1200%7CauthorisationId%3D12345%7CcardCSCResultCode%3D4E%7CpanExpiryDate%3D201605%7CpaymentMeanBrand%3DMASTERCARD%7CpaymentMeanType%3DCARD%7CcustomerIpAddress%3D82.244.203.243%7CmaskedPan%3D5100%23%23%23%23%23%23%23%23%23%23%23%2300%7CorderId%3Dd4903de7027f4d56ac01634fd7ab9526%7CholderAuthentRelegation%3DN%7CholderAuthentStatus%3D3D_ERROR%7CtransactionOrigin%3DINTERNET%7CpaymentPattern%3DONE_SHOT&Seal=6ca3247765a19b45d25ad54ef4076483e7d55583166bd5ac9c64357aac097602&InterfaceVersion=HP_2.0&Encode=''' # noqa: E501
qs = '''Data=captureDay%3D0%7CcaptureMode%3DAUTHOR_CAPTURE%7CcurrencyCode%3D978%7CmerchantId%3D002001000000001%7CorderChannel%3DINTERNET%7CresponseCode%3D00%7CtransactionDateTime%3D2016-02-01T17%3A44%3A20%2B01%3A00%7CtransactionReference%3D668930%7CkeyVersion%3D1%7CacquirerResponseCode%3D00%7Camount%3D1200%7CauthorisationId%3D12345%7CcardCSCResultCode%3D4E%7CpanExpiryDate%3D201605%7CpaymentMeanBrand%3DMASTERCARD%7CpaymentMeanType%3DCARD%7CcustomerIpAddress%3D82.244.203.243%7CmaskedPan%3D5100%23%23%23%23%23%23%23%23%23%23%23%2300%7CorderId%3Dd4903de7027f4d56ac01634fd7ab9526%7CholderAuthentRelegation%3DN%7CholderAuthentStatus%3D3D_ERROR%7CtransactionOrigin%3DINTERNET%7CpaymentPattern%3DONE_SHOT&Seal=6ca3247765a19b45d25ad54ef4076483e7d55583166bd5ac9c64357aac097602&InterfaceVersion=HP_2.0&Encode='''
backend = eopayment.Payment('sips2', {})
response = backend.response(qs)
assert response.signed
assert response.transaction_date is None
qs = '''Data=captureDay%3D0%7CcaptureMode%3DAUTHOR_CAPTURE%7CcurrencyCode%3D978%7CmerchantId%3D002001000000001%7CorderChannel%3DINTERNET%7CresponseCode%3D00%7CtransactionDateTime%3D2016-02-01T17%3A44%3A20%2B01%3A00%7CtransactionReference%3D668930%7CkeyVersion%3D1%7CacquirerResponseCode%3D00%7Camount%3D1200%7CauthorisationId%3D12345%7CcardCSCResultCode%3D4E%7CpanExpiryDate%3D201605%7CpaymentMeanBrand%3DMASTERCARD%7CpaymentMeanType%3DCARD%7CcustomerIpAddress%3D82.244.203.243%7CmaskedPan%3D5100%23%23%23%23%23%23%23%23%23%23%23%2300%7CorderId%3Dd4903de7027f4d56ac01634fd7ab9526%7CholderAuthentRelegation%3DN%7CholderAuthentStatus%3D3D_ERROR%7CtransactionOrigin%3DINTERNET%7CpaymentPattern%3DONE_SHOT%7CtransactionDateTime%3D2020-01-01%2001:01:01&Seal=6ca3247765a19b45d25ad54ef4076483e7d55583166bd5ac9c64357aac097602&InterfaceVersion=HP_2.0&Encode=''' # noqa: E501
response = backend.response(qs)
assert not response.signed
assert response.transaction_date.isoformat() == '2020-01-01T01:01:01+01:00'
with pytest.raises(eopayment.ResponseError, match='missing Data, Seal or InterfaceVersion'):
backend.response('foo=bar')
assert backend.response(qs)

16
tests/test_spplus.py Normal file
View File

@ -0,0 +1,16 @@
from unittest import TestCase
import eopayment.spplus as spplus
class SPPlustTest(TestCase):
ntkey = b'58 6d fc 9c 34 91 9b 86 3f ' \
b'fd 64 63 c9 13 4a 26 ba 29 74 1e c7 e9 80 79'
tests = [('x=coin', 'c04f8266d6ae3ce37551cce996c751be4a95d10a'),
('x=coin&y=toto', 'ef008e02f8dbf5e70e83da416b0b3a345db203de'),
('x=wdwd%20%3Fdfgfdgd&z=343&hmac=04233b78bb5aff332d920d4e89394f505ec58a2a', '04233b78bb5aff332d920d4e89394f505ec58a2a')]
def test_spplus(self):
payment = spplus.Payment({'cle': self.ntkey, 'siret': '00000000000001-01'})
for query, result in self.tests:
self.assertEqual(spplus.sign_ntkey_query(self.ntkey, query).lower(), result)

View File

@ -1,59 +1,27 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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/>.
# -*- coding: utf-8 -*-
from six.moves.urllib import parse as urlparse
from datetime import datetime, timedelta
from urllib import parse as urlparse
import pytest
import eopayment
from eopayment import ResponseError
from eopayment.systempayv2 import PAID, VADS_CUST_FIRST_NAME, VADS_CUST_LAST_NAME, Payment
from eopayment.systempayv2 import Payment, VADS_CUST_FIRST_NAME, \
VADS_CUST_LAST_NAME, PAID
PARAMS = {
'secret_test': '1122334455667788',
'vads_site_id': '12345678',
'vads_ctx_mode': 'TEST',
'vads_trans_date': '20090501193530',
'signature_algo': 'sha1',
'secret_test': u'1122334455667788',
'vads_site_id': u'12345678',
'vads_ctx_mode': u'TEST',
'vads_trans_date': u'20090501193530',
}
@pytest.fixture
def backend():
return eopayment.Payment('systempayv2', PARAMS)
def get_field(form, field_name):
for field in form.fields:
if field['name'] == field_name:
return field
def test_systempayv2(caplog):
caplog.set_level(0)
def test_systempayv2():
p = Payment(PARAMS)
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'
'T&vads_trans_id=654321&vads_trans_date=20090501193530&vads_amount=15'
'24&vads_currency=978&vads_cust_first_name=Jean+Mich%C3%A9l&vads_cust_last_name=Mihaï'
)
data = {'amount': 15.24, 'orderid': '654321',
'first_name': u'Jean Michél',
'last_name': u'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' \
'T&vads_trans_id=654321&vads_trans_date=20090501193530&vads_amount=15' \
'24&vads_currency=978&vads_cust_first_name=Jean+Mich%C3%A9l&vads_cust_last_name=Mihaï'
qs = urlparse.parse_qs(qs)
for key in qs.keys():
qs[key] = qs[key][0]
@ -63,126 +31,15 @@ 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 ('Jean Michél', 'Mihaï')
assert field['value'] in (u'Jean Michél', u'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_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' \
'&signature=62a4fb6738ebadebf9cc720164bc70e47282d36e'
response = p.response(response_qs)
assert response.result == PAID
assert response.signed
assert response.transaction_date
assert response.transaction_date.isoformat() == '2020-03-30T16:25:30+00:00'
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 = p.response(response_qs)
assert response.result == PAID
assert response.signed
# bad response
with pytest.raises(ResponseError, match='missing signature, vads_ctx_mode or vads_auth_result'):
p.response('foo=bar')
def test_systempayv2_deferred_payment():
default_params = {
'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()
params['vads_capture_delay'] = 1
backend = eopayment.Payment('systempayv2', params)
data = default_data.copy()
transaction_id, f, form = backend.request(**data)
assert get_field(form, 'vads_capture_delay')['value'] == '1'
# vads_capture_delay can used in request and
# override default vads_capture_delay
params = default_params.copy()
params['vads_capture_delay'] = 1
p = eopayment.Payment('systempayv2', params)
data = default_data.copy()
data['vads_capture_delay'] = '3'
transaction_id, f, form = p.request(**data)
assert get_field(form, 'vads_capture_delay')['value'] == '3'
# capture_date can be used for deferred_payment
params = default_params.copy()
params['vads_capture_delay'] = 1
p = eopayment.Payment('systempayv2', params)
data = default_data.copy()
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': '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())
assert get_field(form, 'vads_validation_mode')['value'] == ''
data['manual_validation'] = True
transaction_id, f, form = backend.request(**data.copy())
assert get_field(form, 'vads_validation_mode')['value'] == '1'
data['manual_validation'] = False
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
found = None
for field in form.fields:
if field['name'] == 'vads_ext_info_eopayment_trans_id':
found = field
break
assert found
assert found['value'] == FIXED_TRANSACTION_ID
def test_transaction_id_response(backend, caplog):
caplog.set_level(0)
response = '''vads_amount=1000&vads_auth_mode=FULL&vads_auth_number=3fcdd2&vads_auth_result=00&vads_capture_delay=0&vads_card_brand=CB&vads_card_number=597010XXXXXX0018&vads_payment_certificate=4db13859ab429cb6b9bae7546952846efd190e3a&vads_ctx_mode=TEST&vads_currency=978&vads_effective_amount=1000&vads_effective_currency=978&vads_site_id=51438584&vads_trans_date=20201027212030&vads_trans_id=sDJJeQ&vads_trans_uuid=368ef4d0822448e3a2e7413c4e9f8be8&vads_validation_mode=0&vads_version=V2&vads_warranty_result=&vads_payment_src=EC&vads_cust_country=FR&vads_contrib=eopayment&vads_tid=001&vads_sequence_number=1&vads_contract_used=2334410&vads_trans_status=AUTHORISED&vads_expiry_month=6&vads_expiry_year=2021&vads_bank_label=Banque+de+d%C3%A9mo+et+de+l%27innovation&vads_bank_product=MCW&vads_pays_ip=FR&vads_presentation_date=20201027212031&vads_effective_creation_date=20201027212031&vads_operation_type=DEBIT&vads_result=00&vads_extra_result=&vads_card_country=FR&vads_language=fr&vads_brand_management=%7B%22userChoice%22%3Afalse%2C%22brandList%22%3A%22CB%7CMASTERCARD%22%2C%22brand%22%3A%22CB%22%7D&vads_action_mode=INTERACTIVE&vads_payment_config=SINGLE&vads_page_action=PAYMENT&vads_ext_info_eopayment_trans_id=1234&vads_threeds_enrolled=Y&vads_threeds_auth_type=CHALLENGE&vads_threeds_eci=02&vads_threeds_xid=bVpsTUhLSWpodnJjdXJVdE5rb0g%3D&vads_threeds_cavvAlgorithm=2&vads_threeds_status=Y&vads_threeds_sign_valid=1&vads_threeds_error_code=&vads_threeds_exit_status=10&vads_threeds_cavv=jG26AYSjvclBARFYSf%2FtXRmjGXM%3D&signature=fBGbFQPlUiyrL0yVgQzbhokMt6cqG24hOr%2BYsXKr/b8='''
result = backend.response(response)
assert result.signed
assert result.transaction_id == FIXED_TRANSACTION_ID

View File

@ -1,44 +1,18 @@
# eopayment - online payment library
# Copyright (C) 2011-2020 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 datetime
from decimal import Decimal
from urllib.parse import parse_qs, urlparse
import pytest
import pytz
from six.moves.urllib.parse import urlparse, parse_qs
import eopayment
import eopayment.tipi
def test_tipi():
p = eopayment.Payment('tipi', {'numcli': '12345'})
payment_id, kind, url = p.request(
amount=Decimal('123.12'),
exer='9999',
refdet='999900000000999999',
objet='tout a fait',
email='info@entrouvert.com',
urlcl='http://example.com/tipi/test',
saisie='T',
)
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id) is not None
payment_id, kind, url = p.request(amount=Decimal('123.12'),
exer=9999,
refdet=999900000000999999,
objet='tout a fait',
email='info@entrouvert.com',
urlcl='http://example.com/tipi/test',
saisie='T')
parsed_qs = parse_qs(urlparse(url).query)
assert parsed_qs['objet'][0].startswith('tout a fait')
assert parsed_qs['objet'][0].startswith('tout a fait ')
assert parsed_qs['montant'] == ['12312']
assert parsed_qs['saisie'] == ['T']
assert parsed_qs['mel'] == ['info@entrouvert.com']
@ -46,84 +20,6 @@ def test_tipi():
assert parsed_qs['exer'] == ['9999']
assert parsed_qs['refdet'] == ['999900000000999999']
response = p.response(
'objet=tout+a+fait&montant=12312&saisie=T&mel=info%40entrouvert.com'
'&numcli=12345&exer=9999&refdet=999900000000999999&resultrans=P'
)
response = p.response('objet=tout+a+fait&montant=12312&saisie=T&mel=info%40entrouvert.com&numcli=12345&exer=9999&refdet=999900000000999999&resultrans=P')
assert response.signed # ...
assert response.order_id == '999900000000999999'
assert response.transaction_id == '999900000000999999'
assert response.result == eopayment.PAID
with pytest.raises(eopayment.ResponseError, match='missing refdet or resultrans'):
p.response('foo=bar')
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'
)
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
assert parsed_qs['montant'] == ['12312']
assert parsed_qs['saisie'] == ['T']
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')
)
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'
)
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id)
expected_url = urlparse(eopayment.tipi.TIPI_URL)
parsed_url = urlparse(url)
assert parsed_url[:3] == expected_url[:3]
parsed_qs = parse_qs(parsed_url.query)
assert 'objet' not in parsed_qs
assert 'exer' not in parsed_qs
assert parsed_qs['montant'] == ['12312']
assert parsed_qs['saisie'] == ['A']
assert parsed_qs['mel'] == ['info@entrouvert.com']
assert parsed_qs['numcli'] == ['12345']
assert parsed_qs['refdet'] == ['F121212']
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'
)
assert eopayment.tipi.Payment.REFDET_RE.match(payment_id) is not None
expected_url = urlparse(eopayment.tipi.TIPI_URL)
parsed_url = urlparse(url)
assert parsed_url[:3] == expected_url[:3]
parsed_qs = parse_qs(parsed_url.query)
assert 'exer' not in parsed_qs
assert parsed_qs['montant'] == ['12312']
assert parsed_qs['saisie'] == ['A']
assert parsed_qs['mel'] == ['info@entrouvert.com']
assert parsed_qs['numcli'] == ['12345']
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

21
tox.ini
View File

@ -4,29 +4,14 @@
# and then run "tox" from this directory.
[tox]
envlist = py3,codestyle
toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/eopayment/{env:BRANCH_NAME:}
envlist = py2,py3
toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/eopayment/
[testenv]
# django.contrib.auth is not tested it does not work with our templates
setenv =
SETUPTOOLS_USE_DISTUTILS=stdlib
commands =
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
py.test --junitxml=junit.xml --cov-report xml --cov=eopayment/ tests
usedevelop = True
deps = coverage
pytest
pytest-freezegun
pytest-cov
mock<4
httmock
lxml
pre-commit
[pytest]
filterwarnings =
ignore:defusedxml.lxml is no longer supported.*
markers =
update_data
junit_family=xunit2