Compare commits
24 Commits
main
...
wip/python
Author | SHA1 | Date |
---|---|---|
Frédéric Péters | 3814073d30 | |
Frédéric Péters | fd56510014 | |
Frédéric Péters | bbf699c827 | |
Frédéric Péters | b88a759e10 | |
Frédéric Péters | d313a9cac4 | |
François Poulain | 36f2e38a84 | |
François Poulain | 714bf8023b | |
Frédéric Péters | 7b2f106348 | |
Frédéric Péters | 9e1ead2e7c | |
Frédéric Péters | f99d6eeb91 | |
Frédéric Péters | f3611bcc38 | |
Frédéric Péters | fd6566a4ca | |
Frédéric Péters | c028af586b | |
Frédéric Péters | d009b35ab2 | |
Frédéric Péters | fe718e1159 | |
Frédéric Péters | 79735f6418 | |
Frédéric Péters | f21d662912 | |
Frédéric Péters | 8e74d949b5 | |
Frédéric Péters | 2469de19e2 | |
Frédéric Péters | b4cc0e71d4 | |
Frédéric Péters | fbf3ebf34d | |
Frédéric Péters | 019188f70e | |
Frédéric Péters | 2ee8b2e5fd | |
Frédéric Péters | c2f059e7aa |
|
@ -1,6 +1,2 @@
|
|||
[run]
|
||||
dynamic_context = test_function
|
||||
omit = */.tox/*
|
||||
|
||||
[html]
|
||||
show_contexts = True
|
||||
|
|
|
@ -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
|
|
@ -1,7 +1,4 @@
|
|||
/debian/source
|
||||
/dist
|
||||
/build
|
||||
/htmlcov
|
||||
junit*xml
|
||||
test_*xml
|
||||
*.pyc
|
||||
*.swp
|
||||
|
|
|
@ -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
674
COPYING
|
@ -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>.
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
191
README.txt
191
README.txt
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
7
|
|
@ -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.
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
README.txt
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
|
@ -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 l’interface 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 l’usager"
|
||||
|
||||
#: 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é d’API"
|
||||
|
||||
#: 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)"
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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 l’acheteur',
|
||||
'PBX_RETOUR': 'Liste des variables à retourner par Paybox',
|
||||
'PBX_HASH': 'Type d’algorit hme de hachage pour le calcul de l’empreinte',
|
||||
|
@ -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 d’autorisation [voir '
|
||||
'§12.112.1 Codes réponses du centre d’autorisationCodes réponses du '
|
||||
'centre d’autorisation]. En cas d’autorisation de la transaction par '
|
||||
'le centre d’autorisation 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 d’un 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 d’attente > 15 mn par l’internaute/acheteur au niveau de la page de paiements.'
|
||||
},
|
||||
'00031': {'message': 'Réservé'},
|
||||
'00032': {'message': 'Réservé'},
|
||||
'00033': {
|
||||
'message': 'Code pays de l’adresse IP du navigateur de l’acheteur 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 d’autorisation 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 d’autorisation [voir '
|
||||
'§12.112.1 Codes réponses du centre d’autorisationCodes réponses du '
|
||||
'centre d’autorisation]. En cas d’autorisation de la transaction par '
|
||||
'le centre d’autorisation 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 d’un 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 d’attente > 15 mn par l’internaute/acheteur au niveau '
|
||||
'de la page de paiements.',
|
||||
'00031': 'Réservé',
|
||||
'00032': 'Réservé',
|
||||
'00033': 'Code pays de l’adresse IP du navigateur de l’acheteur 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)
|
||||
|
|
|
@ -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()
|
|
@ -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/'
|
||||
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/bash
|
||||
echo -ne 0!!coin
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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]))
|
|
@ -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': 'L’adresse IP appartient à la liste grise du marchand.'},
|
||||
'06': {'message': 'Le code bin appartient à la liste grise du marchand.'},
|
||||
'07': {'message': 'Détection d’une e-carte bleue.'},
|
||||
'08': {'message': 'Détection d’une carte commerciale nationale.'},
|
||||
'09': {'message': 'Détection d’une carte commerciale étrangère.'},
|
||||
'14': {'message': 'Détection d’une carte à autorisation systématique.'},
|
||||
'30': {'message': 'Le pays de l’adresse 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
|
||||
|
|
|
@ -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 l’administrateur 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)
|
||||
|
|
|
@ -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
138
setup.py
|
@ -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,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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/'
|
|
@ -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"]]
|
|
@ -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él incorrecte. </libelle>\n <severite>2</severite>\n </ns2:FonctionnelleErreur>\n </detail>\n </S:Fault>\n </S:Body>\n</S:Envelope>\n"]]
|
|
@ -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"]]
|
|
@ -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è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"]]
|
|
@ -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"]]
|
|
@ -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é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"]]
|
|
@ -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"]]
|
|
@ -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"]
|
||||
]
|
|
@ -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"]
|
||||
]
|
|
@ -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
|
@ -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
|
|
@ -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')
|
|
@ -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)
|
|
@ -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¤cy=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¤cy=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
|
|
@ -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)
|
|
@ -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¤cy=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¤cy=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
|
||||
|
|
|
@ -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 <oo></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
|
||||
|
|
|
@ -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',
|
||||
)
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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
|
||||
|
|
|
@ -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
21
tox.ini
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue