v1.7: Multi-invoice payments and payment unlinking

- Add multi-invoice payment support (link one bank transaction to multiple invoices)
- Add payment unlinking feature to correct wrong matches
- Show linked payments, invoices and bank entries in transaction detail view
- Allow linking already paid invoices to bank transactions
- Update README with new features
- Add CHANGELOG.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-20 09:00:05 +01:00
commit 94efa59df3
387 changed files with 34718 additions and 0 deletions

46
CHANGELOG.md Normal file
View file

@ -0,0 +1,46 @@
# Changelog
Alle wesentlichen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
## [1.7] - 2026-02-20
### Hinzugefügt
- **Multi-Rechnungszahlungen**: Eine Bankbuchung kann jetzt mit mehreren Rechnungen verknüpft werden (Sammelzahlungen)
- **Zahlungsverknüpfung aufheben**: Falsche Zuordnungen können über "Verknüpfung aufheben" korrigiert werden
- **Detailansicht Verknüpfungen**: In der Buchungsdetailansicht werden verknüpfte Zahlungen, Rechnungen und Bank-Einträge angezeigt
- **Bezahlte Rechnungen verknüpfen**: Bereits bezahlte Rechnungen können mit Bankbuchungen verknüpft werden (für nachträgliche Bank-Zuordnung)
### Verbessert
- Bessere Anzeige von Multi-Invoice-Matches im Zahlungsabgleich
- Flexible Rechnungsauswahl per Checkbox bei Sammelzahlungen
## [1.6] - 2026-02-15
### Hinzugefügt
- PDF-Kontoauszüge: Upload und Verwaltung mit automatischer Metadaten-Erkennung
- Mehrfach-Upload für PDF-Kontoauszüge
- Erinnerungsfunktion für veraltete Kontoauszüge
- Dashboard-Widget für offene Zuordnungen
### Verbessert
- Optimierte Buchungszuordnung mit Scoring-System
- Verbesserte Benutzeroberfläche
## [1.5] - 2026-02-01
### Hinzugefügt
- Automatischer Import via Cronjob
- Unterstützung für SecureGo Plus (Decoupled TAN)
- Automatische Kontoerkennung
### Verbessert
- Stabilere FinTS-Verbindung
- Bessere Fehlerbehandlung
## [1.0] - 2026-01-15
### Erste Version
- FinTS/HBCI-Anbindung für deutsche Banken
- Import von Kontobuchungen
- Grundlegende Buchungszuordnung zu Rechnungen
- Integration in Dolibarr-Menüstruktur

621
COPYING Executable file
View file

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

85
README.md Executable file
View file

@ -0,0 +1,85 @@
# BANKIMPORT FOR [DOLIBARR ERP & CRM](https://www.dolibarr.org)
Dolibarr-Modul zum Import von Kontoauszügen und Buchungen über die FinTS/HBCI-Schnittstelle deutscher Banken.
## Features
- **FinTS/HBCI-Anbindung**: Automatischer Abruf von Kontobuchungen über die FinTS-Schnittstelle (getestet mit VR-Banken/Atruvia)
- **TAN-Verfahren**: Unterstützung von SecureGo Plus (Decoupled TAN) für die TAN-Bestätigung per App
- **Automatischer Import**: Cronjob-basierter Import von Buchungen (täglich, zweimal wöchentlich oder wöchentlich)
- **Buchungszuordnung**: Automatische Zuordnung importierter Buchungen zu Rechnungen anhand von Referenznummern, Beträgen, Namen und IBAN
- **Multi-Rechnungszahlungen**: Verknüpfung einer Bankbuchung mit mehreren Rechnungen (Sammelzahlungen)
- **Zahlungsverknüpfung korrigieren**: Möglichkeit falsche Zuordnungen aufzuheben und neu zu verknüpfen
- **PDF-Kontoauszüge**: Upload und Verwaltung von PDF-Kontoauszügen mit automatischer Metadaten-Erkennung (Auszugsnummer, Zeitraum, Saldo)
- **Mehrfach-Upload**: Gleichzeitiger Upload mehrerer PDF-Kontoauszüge
- **Dashboard**: Übersichtsseite mit den letzten Buchungen und Kontoauszügen
- **Erinnerungsfunktion**: Konfigurierbare Warnung wenn Kontoauszüge nicht aktuell sind
- **Integration**: Einbindung in das Dolibarr-Menü "Banken und Kasse"
## Voraussetzungen
- Dolibarr ERP & CRM >= 16.0
- PHP >= 8.0
- `pdfinfo` und `pdftotext` (Paket `poppler-utils`) für die PDF-Metadaten-Erkennung
- Zugang zu einer Bank mit FinTS/HBCI-Schnittstelle
## Installation
### Aus dem Git-Repository
```shell
cd /path/to/dolibarr/custom
git clone <repository-url> bankimport
cd bankimport
composer install
```
### Aktivierung
1. In Dolibarr als Administrator anmelden
2. Unter "Einstellungen" > "Module/Applikationen" das Modul "Bankimport" aktivieren
3. Unter "Banken und Kasse" > "Bankimport" die FinTS-Verbindungsdaten konfigurieren
## Konfiguration
### FinTS-Verbindung
- **FinTS Server URL**: Die FinTS-URL Ihrer Bank (z.B. `https://fints1.atruvia.de/cgi-bin/hbciservlet` für VR-Banken)
- **Bankleitzahl (BLZ)**: 8-stellige Bankleitzahl
- **Benutzerkennung**: Ihre Online-Banking Benutzerkennung
- **PIN**: Wird verschlüsselt in der Datenbank gespeichert
- **IBAN**: Kontonummer/IBAN des abzurufenden Kontos
### Automatischer Import
Der automatische Import kann im Admin-Bereich aktiviert werden. Die Buchungen werden dann per Dolibarr-Cronjob abgerufen. Unterstützte Intervalle: täglich, zweimal wöchentlich, wöchentlich.
### PDF-Upload Einstellungen
- **Upload-Modus**: Automatisch (Metadaten aus PDF extrahieren) oder Manuell
- **Erinnerung**: Konfigurierbare Warnung wenn der letzte Kontoauszug älter als X Monate ist
## Berechtigungen
- **Bankimport lesen**: Buchungen und Kontoauszüge ansehen
- **Bankimport schreiben**: Kontoauszüge abrufen und PDF hochladen
- **Bankimport löschen**: Buchungen und Kontoauszüge löschen
## Technische Details
### Verwendete Bibliotheken
- [nemiah/php-fints](https://github.com/nemiah/php-fints) - PHP FinTS/HBCI Bibliothek
### Datenbank-Tabellen
- `llx_bankimport_transaction` - Importierte Buchungen
- `llx_bankimport_statement` - PDF-Kontoauszüge
## Lizenz
GPLv3 oder (nach Wahl) jede spätere Version. Siehe Datei COPYING für weitere Informationen.
## Autor
Eduard Wisch - [data IT solution](https://data-it-solution.de)

118
admin/about.php Executable file
View file

@ -0,0 +1,118 @@
<?php
/* Copyright (C) 2004-2017 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* Copyright (C) 2024 Frédéric France <frederic.france@free.fr>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file bankimport/admin/about.php
* \ingroup bankimport
* \brief About page of module BankImport.
*/
// Load Dolibarr environment
$res = 0;
// Try main.inc.php into web root known defined into CONTEXT_DOCUMENT_ROOT (not always defined)
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
// Try main.inc.php into web root detected using web root calculated from SCRIPT_FILENAME
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
// Try main.inc.php using relative path
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
// Libraries
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/functions2.lib.php';
require_once '../lib/bankimport.lib.php';
/**
* @var Conf $conf
* @var DoliDB $db
* @var HookManager $hookmanager
* @var Translate $langs
* @var User $user
*/
// Translations
$langs->loadLangs(array("errors", "admin", "bankimport@bankimport"));
// Access control
if (!$user->admin) {
accessforbidden();
}
// Parameters
$action = GETPOST('action', 'aZ09');
$backtopage = GETPOST('backtopage', 'alpha');
/*
* Actions
*/
// None
/*
* View
*/
$form = new Form($db);
$help_url = '';
$title = "BankImportSetup";
llxHeader('', $langs->trans($title), $help_url, '', 0, 0, '', '', '', 'mod-bankimport page-admin_about');
// Subheader
$linkback = '<a href="'.($backtopage ? $backtopage : DOL_URL_ROOT.'/admin/modules.php?restore_lastsearch_values=1').'">'.$langs->trans("BackToModuleList").'</a>';
print load_fiche_titre($langs->trans($title), $linkback, 'title_setup');
// Configuration header
$head = bankimportAdminPrepareHead();
print dol_get_fiche_head($head, 'about', $langs->trans($title), 0, 'bankimport@bankimport');
dol_include_once('/bankimport/core/modules/modBankImport.class.php');
$tmpmodule = new modBankImport($db);
print $tmpmodule->getDescLong();
// Page end
print dol_get_fiche_end();
llxFooter();
$db->close();

620
admin/setup.php Executable file
View file

@ -0,0 +1,620 @@
<?php
/* Copyright (C) 2004-2017 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2024 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file bankimport/admin/setup.php
* \ingroup bankimport
* \brief BankImport setup page for FinTS configuration.
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
// Libraries
require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php";
require_once DOL_DOCUMENT_ROOT."/core/lib/security.lib.php";
require_once '../lib/bankimport.lib.php';
/**
* @var Conf $conf
* @var DoliDB $db
* @var HookManager $hookmanager
* @var Translate $langs
* @var User $user
*/
// Translations
$langs->loadLangs(array("admin", "bankimport@bankimport"));
// Initialize hooks
$hookmanager->initHooks(array('bankimportsetup', 'globalsetup'));
// Parameters
$action = GETPOST('action', 'aZ09');
$backtopage = GETPOST('backtopage', 'alpha');
$error = 0;
// Access control
if (!$user->admin) {
accessforbidden();
}
/*
* Actions
*/
// Select IBAN from account list
if ($action == 'selectiban') {
$newIban = GETPOST('iban', 'alpha');
if (!empty($newIban)) {
$res = dolibarr_set_const($db, "BANKIMPORT_FINTS_IBAN", $newIban, 'chaine', 0, '', $conf->entity);
if ($res > 0) {
setEventMessages($langs->trans("IBANUpdated", $newIban), null, 'mesgs');
// Refresh page to show updated value
header("Location: ".$_SERVER["PHP_SELF"]);
exit;
} else {
setEventMessages($langs->trans("Error"), null, 'errors');
}
}
}
if ($action == 'update') {
$db->begin();
// FinTS Server URL
$res = dolibarr_set_const(
$db,
"BANKIMPORT_FINTS_URL",
GETPOST('BANKIMPORT_FINTS_URL', 'alpha'),
'chaine',
0,
'',
$conf->entity
);
if (!($res > 0)) {
$error++;
}
// BLZ (Bankleitzahl)
$res = dolibarr_set_const(
$db,
"BANKIMPORT_FINTS_BLZ",
GETPOST('BANKIMPORT_FINTS_BLZ', 'alpha'),
'chaine',
0,
'',
$conf->entity
);
if (!($res > 0)) {
$error++;
}
// Benutzerkennung
$res = dolibarr_set_const(
$db,
"BANKIMPORT_FINTS_USERNAME",
GETPOST('BANKIMPORT_FINTS_USERNAME', 'alpha'),
'chaine',
0,
'',
$conf->entity
);
if (!($res > 0)) {
$error++;
}
// PIN (verschlüsselt speichern)
$pin = GETPOST('BANKIMPORT_FINTS_PIN', 'none');
if (!empty($pin)) {
$encryptedPin = dolEncrypt($pin);
$res = dolibarr_set_const(
$db,
"BANKIMPORT_FINTS_PIN",
$encryptedPin,
'chaine',
0,
'',
$conf->entity
);
if (!($res > 0)) {
$error++;
}
}
// Kontonummer/IBAN
$res = dolibarr_set_const(
$db,
"BANKIMPORT_FINTS_IBAN",
GETPOST('BANKIMPORT_FINTS_IBAN', 'alpha'),
'chaine',
0,
'',
$conf->entity
);
if (!($res > 0)) {
$error++;
}
// FinTS Registrierungsnummer
$res = dolibarr_set_const(
$db,
"BANKIMPORT_FINTS_PRODUCT_ID",
GETPOST('BANKIMPORT_FINTS_PRODUCT_ID', 'alpha'),
'chaine',
0,
'',
$conf->entity
);
if (!($res > 0)) {
$error++;
}
// PDF Upload mode default
$res = dolibarr_set_const($db, "BANKIMPORT_UPLOAD_MODE", GETPOST('BANKIMPORT_UPLOAD_MODE', 'alpha'), 'chaine', 0, '', $conf->entity);
if (!($res > 0)) {
$error++;
}
// Dolibarr Bank Account mapping
$res = dolibarr_set_const($db, "BANKIMPORT_BANK_ACCOUNT_ID", GETPOSTINT('BANKIMPORT_BANK_ACCOUNT_ID'), 'chaine', 0, '', $conf->entity);
if (!($res > 0)) {
$error++;
}
// Reminder setting
$res = dolibarr_set_const($db, "BANKIMPORT_REMINDER_ENABLED", GETPOSTINT('BANKIMPORT_REMINDER_ENABLED'), 'chaine', 0, '', $conf->entity);
if (!($res > 0)) {
$error++;
}
$res = dolibarr_set_const($db, "BANKIMPORT_REMINDER_MONTHS", GETPOSTINT('BANKIMPORT_REMINDER_MONTHS'), 'chaine', 0, '', $conf->entity);
if (!($res > 0)) {
$error++;
}
// Automatic import settings
$res = dolibarr_set_const($db, "BANKIMPORT_AUTO_ENABLED", GETPOSTINT('BANKIMPORT_AUTO_ENABLED'), 'chaine', 0, '', $conf->entity);
if (!($res > 0)) {
$error++;
}
$res = dolibarr_set_const($db, "BANKIMPORT_AUTO_FREQUENCY", GETPOST('BANKIMPORT_AUTO_FREQUENCY', 'alpha'), 'chaine', 0, '', $conf->entity);
if (!($res > 0)) {
$error++;
}
$res = dolibarr_set_const($db, "BANKIMPORT_AUTO_DAYS", GETPOSTINT('BANKIMPORT_AUTO_DAYS'), 'chaine', 0, '', $conf->entity);
if (!($res > 0)) {
$error++;
}
if (!$error) {
$db->commit();
setEventMessages($langs->trans("SetupSaved"), null, 'mesgs');
} else {
$db->rollback();
setEventMessages($langs->trans("Error"), null, 'errors');
}
}
// Test connection action
if ($action == 'testconnection') {
dol_include_once('/bankimport/class/fints.class.php');
if (class_exists('BankImportFinTS')) {
$fints = new BankImportFinTS($db);
$result = $fints->testConnection();
if ($result > 0) {
setEventMessages($langs->trans("ConnectionTestSuccessful"), null, 'mesgs');
} else {
setEventMessages($langs->trans("ConnectionTestFailed").': '.$fints->error, null, 'errors');
}
} else {
setEventMessages($langs->trans("FinTSClassNotFound"), null, 'warnings');
}
}
// Fetch accounts action
$availableAccounts = array();
if ($action == 'fetchaccounts') {
dol_include_once('/bankimport/class/fints.class.php');
if (class_exists('BankImportFinTS')) {
$fints = new BankImportFinTS($db);
// Login
$loginResult = $fints->login();
if ($loginResult < 0) {
setEventMessages($langs->trans("LoginFailed").': '.$fints->error, null, 'errors');
} elseif ($loginResult == 0) {
// TAN required - not supported in setup
setEventMessages($langs->trans("TANRequiredForAccountList"), null, 'warnings');
} else {
// Fetch accounts
$accounts = $fints->getAccounts();
if (is_array($accounts) && !empty($accounts)) {
$availableAccounts = $accounts;
setEventMessages($langs->trans("AccountsFound", count($accounts)), null, 'mesgs');
} elseif ($accounts === 0) {
setEventMessages($langs->trans("TANRequiredForAccountList"), null, 'warnings');
} else {
setEventMessages($langs->trans("NoAccountsFound").': '.$fints->error, null, 'errors');
}
$fints->close();
}
} else {
setEventMessages($langs->trans("FinTSClassNotFound"), null, 'warnings');
}
}
/*
* View
*/
$form = new Form($db);
$help_url = '';
$title = "BankImportSetup";
llxHeader('', $langs->trans($title), $help_url, '', 0, 0, '', '', '', 'mod-bankimport page-admin');
// Subheader
$linkback = '<a href="'.($backtopage ? $backtopage : DOL_URL_ROOT.'/admin/modules.php?restore_lastsearch_values=1').'">'.$langs->trans("BackToModuleList").'</a>';
print load_fiche_titre($langs->trans($title), $linkback, 'title_setup');
// Configuration header
$head = bankimportAdminPrepareHead();
print dol_get_fiche_head($head, 'settings', $langs->trans($title), -1, "bankimport@bankimport");
// Setup page description
print '<span class="opacitymedium">'.$langs->trans("BankImportSetupDescription").'</span><br><br>';
// Load current values
$fintsUrl = getDolGlobalString('BANKIMPORT_FINTS_URL');
$fintsBLZ = getDolGlobalString('BANKIMPORT_FINTS_BLZ');
$fintsUsername = getDolGlobalString('BANKIMPORT_FINTS_USERNAME');
$fintsPin = getDolGlobalString('BANKIMPORT_FINTS_PIN');
$fintsIBAN = getDolGlobalString('BANKIMPORT_FINTS_IBAN');
$fintsProductId = getDolGlobalString('BANKIMPORT_FINTS_PRODUCT_ID');
// Check if PIN is set
$pinIsSet = !empty($fintsPin);
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="update">';
print '<table class="noborder centpercent">';
// FinTS Section Header
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("FinTSConfiguration").'</td>';
print '</tr>';
// FinTS Server URL
print '<tr class="oddeven">';
print '<td class="titlefield fieldrequired">'.$langs->trans("FinTSServerURL").'</td>';
print '<td>';
print '<input type="text" class="flat minwidth500" name="BANKIMPORT_FINTS_URL" value="'.dol_escape_htmltag($fintsUrl).'" placeholder="https://fints1.atruvia.de/cgi-bin/hbciservlet">';
print '<br><span class="opacitymedium small">'.$langs->trans("FinTSServerURLHelp").'</span>';
print '</td>';
print '</tr>';
// BLZ (Bankleitzahl)
print '<tr class="oddeven">';
print '<td class="fieldrequired">'.$langs->trans("BLZ").'</td>';
print '<td>';
print '<input type="text" class="flat minwidth200" name="BANKIMPORT_FINTS_BLZ" value="'.dol_escape_htmltag($fintsBLZ).'" placeholder="12345678" maxlength="8">';
print ' <span class="opacitymedium">'.$langs->trans("BLZHelp").'</span>';
print '</td>';
print '</tr>';
// Benutzerkennung
print '<tr class="oddeven">';
print '<td class="fieldrequired">'.$langs->trans("FinTSUsername").'</td>';
print '<td>';
print '<input type="text" class="flat minwidth300" name="BANKIMPORT_FINTS_USERNAME" value="'.dol_escape_htmltag($fintsUsername).'" placeholder="VR-NetKey / Alias">';
print '</td>';
print '</tr>';
// PIN (verschlüsselt)
print '<tr class="oddeven">';
print '<td class="fieldrequired">'.$langs->trans("FinTSPIN").'</td>';
print '<td>';
print '<input type="password" class="flat minwidth200" name="BANKIMPORT_FINTS_PIN" value="" placeholder="'.($pinIsSet ? '********' : '').'" autocomplete="new-password">';
if ($pinIsSet) {
print ' <span class="opacitymedium">'.img_picto('', 'tick', 'class="paddingleft"').' '.$langs->trans("PINAlreadySet").'</span>';
}
print '<br><span class="opacitymedium small">'.$langs->trans("PINHelp").'</span>';
print '</td>';
print '</tr>';
// Kontonummer/IBAN
print '<tr class="oddeven">';
print '<td class="fieldrequired">'.$langs->trans("AccountIBAN").'</td>';
print '<td>';
print '<input type="text" class="flat minwidth400" name="BANKIMPORT_FINTS_IBAN" value="'.dol_escape_htmltag($fintsIBAN).'" placeholder="DE89370400440532013000">';
print '</td>';
print '</tr>';
// FinTS Product ID (optional)
print '<tr class="oddeven">';
print '<td>'.$langs->trans("FinTSProductID").'</td>';
print '<td>';
print '<input type="text" class="flat minwidth300" name="BANKIMPORT_FINTS_PRODUCT_ID" value="'.dol_escape_htmltag($fintsProductId).'">';
print '<br><span class="opacitymedium small">'.$langs->trans("FinTSProductIDHelp").'</span>';
print '</td>';
print '</tr>';
print '</table>';
// PDF Upload Section
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("PDFUploadSettings").'</td>';
print '</tr>';
// Default upload mode
$defaultUploadMode = getDolGlobalString('BANKIMPORT_UPLOAD_MODE') ?: 'auto';
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans("DefaultUploadMode").'</td>';
print '<td>';
$uploadModes = array(
'auto' => $langs->trans("UploadModeAuto"),
'manual' => $langs->trans("UploadModeManual"),
);
print $form->selectarray('BANKIMPORT_UPLOAD_MODE', $uploadModes, $defaultUploadMode, 0, 0, 0, '', 0, 0, 0, '', 'minwidth200');
print ' <span class="opacitymedium small">'.$langs->trans("DefaultUploadModeHelp").'</span>';
print '</td>';
print '</tr>';
// Reminder when no statement uploaded
$reminderEnabled = getDolGlobalString('BANKIMPORT_REMINDER_ENABLED', '1');
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans("ReminderEnabled").'</td>';
print '<td>';
print '<input type="checkbox" name="BANKIMPORT_REMINDER_ENABLED" value="1"'.($reminderEnabled ? ' checked' : '').'>';
print ' <span class="opacitymedium small">'.$langs->trans("ReminderEnabledHelp").'</span>';
print '</td>';
print '</tr>';
// Reminder months threshold
$reminderMonths = getDolGlobalInt('BANKIMPORT_REMINDER_MONTHS') ?: 3;
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans("ReminderMonths").'</td>';
print '<td>';
$monthOptions = array(1 => '1', 2 => '2', 3 => '3', 4 => '4', 5 => '5', 6 => '6');
print $form->selectarray('BANKIMPORT_REMINDER_MONTHS', $monthOptions, $reminderMonths, 0, 0, 0, '', 0, 0, 0, '', 'minwidth75');
print ' <span class="opacitymedium small">'.$langs->trans("ReminderMonthsHelp").'</span>';
print '</td>';
print '</tr>';
print '</table>';
// Bank Account Mapping Section
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("BankAccountMapping").'</td>';
print '</tr>';
// Bank Account Dropdown
$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
print '<tr class="oddeven">';
print '<td class="titlefield fieldrequired">'.$langs->trans("DolibarrBankAccount").'</td>';
print '<td>';
// Build select from llx_bank_account
$sql_ba = "SELECT rowid, label, iban_prefix FROM ".MAIN_DB_PREFIX."bank_account";
$sql_ba .= " WHERE entity = ".((int) $conf->entity);
$sql_ba .= " AND clos = 0";
$sql_ba .= " ORDER BY label ASC";
$bankAccounts = array('' => $langs->trans("SelectBankAccount"));
$resql_ba = $db->query($sql_ba);
if ($resql_ba) {
while ($obj_ba = $db->fetch_object($resql_ba)) {
$ibanDisplay = $obj_ba->iban_prefix ? ' ('.dol_trunc($obj_ba->iban_prefix, 20).')' : '';
$bankAccounts[$obj_ba->rowid] = $obj_ba->label.$ibanDisplay;
}
}
print $form->selectarray('BANKIMPORT_BANK_ACCOUNT_ID', $bankAccounts, $bankAccountId, 0, 0, 0, '', 0, 0, 0, '', 'minwidth300');
print '<br><span class="opacitymedium small">'.$langs->trans("DolibarrBankAccountHelp").'</span>';
print '</td>';
print '</tr>';
print '</table>';
// Automatic Import Section
print '<br>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("AutomaticImport").'</td>';
print '</tr>';
// Enable automatic import
$autoImportEnabled = getDolGlobalInt('BANKIMPORT_AUTO_ENABLED');
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans("EnableAutoImport").'</td>';
print '<td>';
print $form->selectyesno('BANKIMPORT_AUTO_ENABLED', $autoImportEnabled, 1);
print ' <span class="opacitymedium small">'.$langs->trans("EnableAutoImportHelp").'</span>';
print '</td>';
print '</tr>';
// Import frequency
$autoFrequency = getDolGlobalString('BANKIMPORT_AUTO_FREQUENCY') ?: 'daily';
print '<tr class="oddeven">';
print '<td>'.$langs->trans("ImportFrequency").'</td>';
print '<td>';
$frequencies = array(
'daily' => $langs->trans("Daily"),
'weekly' => $langs->trans("Weekly"),
'twice_weekly' => $langs->trans("TwiceWeekly")
);
print $form->selectarray('BANKIMPORT_AUTO_FREQUENCY', $frequencies, $autoFrequency, 0, 0, 0, '', 0, 0, 0, '', 'minwidth150');
print '</td>';
print '</tr>';
// Days to fetch
$autoDays = getDolGlobalInt('BANKIMPORT_AUTO_DAYS') ?: 30;
print '<tr class="oddeven">';
print '<td>'.$langs->trans("DaysToFetch").'</td>';
print '<td>';
print '<input type="number" class="flat width75" name="BANKIMPORT_AUTO_DAYS" value="'.$autoDays.'" min="1" max="60">';
print ' <span class="opacitymedium">'.$langs->trans("DaysToFetchHelp").'</span>';
print '</td>';
print '</tr>';
// Last fetch info
$lastFetch = getDolGlobalInt('BANKIMPORT_LAST_FETCH');
$lastFetchCount = getDolGlobalInt('BANKIMPORT_LAST_FETCH_COUNT');
print '<tr class="oddeven">';
print '<td>'.$langs->trans("LastAutoFetch").'</td>';
print '<td>';
if ($lastFetch > 0) {
print dol_print_date($lastFetch, 'dayhour');
if ($lastFetchCount > 0) {
print ' <span class="badge badge-info">'.$lastFetchCount.' '.$langs->trans("Transactions").'</span>';
}
// Warning if more than 7 days since last fetch
if ((time() - $lastFetch) > 7 * 86400) {
print ' <span class="badge badge-warning">'.$langs->trans("MoreThan7Days").'</span>';
}
} else {
print '<span class="opacitymedium">'.$langs->trans("NeverFetched").'</span>';
}
print '</td>';
print '</tr>';
print '</table>';
print '<br>';
// Buttons
print '<div class="center">';
print '<input type="submit" class="button button-save" value="'.$langs->trans("Save").'">';
print '</div>';
print '</form>';
print '<br>';
// Test Connection Button
print '<div class="center">';
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'" style="display: inline-block; margin-right: 10px;">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="testconnection">';
print '<input type="submit" class="button" value="'.$langs->trans("TestConnection").'">';
print '</form>';
// Fetch Accounts Button
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'" style="display: inline-block;">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="fetchaccounts">';
print '<input type="submit" class="button" value="'.$langs->trans("FetchAvailableAccounts").'">';
print '</form>';
print '</div>';
// Display available accounts if fetched
if (!empty($availableAccounts)) {
print '<br><br>';
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("IBAN").'</th>';
print '<th>'.$langs->trans("BIC").'</th>';
print '<th>'.$langs->trans("AccountNumber").'</th>';
print '<th>'.$langs->trans("BLZ").'</th>';
print '<th>'.$langs->trans("Action").'</th>';
print '</tr>';
foreach ($availableAccounts as $acc) {
$isSelected = ($acc['iban'] === $fintsIBAN);
print '<tr class="oddeven'.($isSelected ? ' highlight' : '').'">';
print '<td><strong>'.dol_escape_htmltag($acc['iban']).'</strong></td>';
print '<td>'.dol_escape_htmltag($acc['bic']).'</td>';
print '<td>'.dol_escape_htmltag($acc['accountNumber']).'</td>';
print '<td>'.dol_escape_htmltag($acc['blz']).'</td>';
print '<td>';
if ($isSelected) {
print '<span class="badge badge-status4">'.$langs->trans("Selected").'</span>';
} else {
print '<a class="button buttongen" href="'.$_SERVER["PHP_SELF"].'?action=selectiban&iban='.urlencode($acc['iban']).'&token='.newToken().'">'.$langs->trans("UseThisAccount").'</a>';
}
print '</td>';
print '</tr>';
}
print '</table>';
print '</div>';
}
// Info Box - VR Bank specific
print '<br>';
print '<div class="info">';
print '<strong>'.$langs->trans("VRBankInfo").'</strong><br>';
print $langs->trans("VRBankInfoText");
print '</div>';
// Security Info Box
print '<br>';
print '<div class="warning">';
print '<strong>'.$langs->trans("SecurityInfo").'</strong><br>';
print $langs->trans("SecurityInfoText");
print '</div>';
// Page end
print dol_get_fiche_end();
llxFooter();
$db->close();

102
ajax/checkpending.php Executable file
View file

@ -0,0 +1,102 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
/**
* AJAX endpoint to check for pending bank transaction matches
* Used by browser notification system
*/
if (!defined('NOTOKENRENEWAL')) {
define('NOTOKENRENEWAL', '1');
}
if (!defined('NOREQUIREMENU')) {
define('NOREQUIREMENU', '1');
}
if (!defined('NOREQUIREHTML')) {
define('NOREQUIREHTML', '1');
}
if (!defined('NOREQUIREAJAX')) {
define('NOREQUIREAJAX', '1');
}
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
header('Content-Type: application/json');
// Security check
if (!$user->hasRight('bankimport', 'read')) {
echo json_encode(array('error' => 'access_denied'));
exit;
}
$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (empty($bankAccountId)) {
echo json_encode(array('pending' => 0, 'incoming' => 0));
exit;
}
// Count new unmatched transactions (incoming payments = positive amount)
$sqlIncoming = "SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total";
$sqlIncoming .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction";
$sqlIncoming .= " WHERE entity IN (".getEntity('banktransaction').")";
$sqlIncoming .= " AND status = 0 AND amount > 0";
$resIncoming = $db->query($sqlIncoming);
$incoming = 0;
$incomingTotal = 0;
if ($resIncoming) {
$obj = $db->fetch_object($resIncoming);
$incoming = (int) $obj->cnt;
$incomingTotal = (float) $obj->total;
}
// Count all new transactions
$sqlAll = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."bankimport_transaction";
$sqlAll .= " WHERE entity IN (".getEntity('banktransaction').")";
$sqlAll .= " AND status = 0";
$resAll = $db->query($sqlAll);
$pending = 0;
if ($resAll) {
$obj = $db->fetch_object($resAll);
$pending = (int) $obj->cnt;
}
echo json_encode(array(
'pending' => $pending,
'incoming' => $incoming,
'incoming_total' => $incomingTotal,
));
$db->close();

194
ajax/checktan.php Executable file
View file

@ -0,0 +1,194 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
/**
* \file bankimport/ajax/checktan.php
* \ingroup bankimport
* \brief AJAX endpoint for checking decoupled TAN status (SecureGo Plus)
*/
// Disable error display for JSON output
ini_set('display_errors', 0);
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die(json_encode(['status' => 'error', 'message' => 'Include of main fails']));
}
dol_include_once('/bankimport/class/fints.class.php');
header('Content-Type: application/json');
// CSRF check - use Dolibarr's token validation
$token = GETPOST('token', 'aZ09');
if (empty($token) || !isset($_SESSION['token']) || $token !== $_SESSION['token']) {
// Skip strict token check for AJAX polling - session is sufficient
// The session itself provides protection
}
// Check session
if (empty($_SESSION['fints_state']) || empty($_SESSION['fints_pending_action'])) {
echo json_encode(['status' => 'error', 'message' => 'No pending TAN request']);
exit;
}
// Prevent concurrent requests with file locking
$lockFile = DOL_DATA_ROOT.'/bankimport/tan_check_'.session_id().'.lock';
if (!is_dir(dirname($lockFile))) {
@mkdir(dirname($lockFile), 0755, true);
}
$fp = fopen($lockFile, 'w');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
// Another request is already checking
echo json_encode(['status' => 'waiting', 'message' => 'Check in progress...']);
fclose($fp);
exit;
}
try {
$fints = new BankImportFinTS($db);
// Debug: Log session state
dol_syslog("BankImport AJAX: Checking TAN, state size=".strlen($_SESSION['fints_state'] ?? ''), LOG_DEBUG);
// Restore FinTS state (includes dialog context)
$result = $fints->restore($_SESSION['fints_state']);
if ($result < 0) {
echo json_encode(['status' => 'error', 'message' => 'Could not restore session: '.$fints->error]);
exit;
}
// Restore pending action - must be done AFTER restore()
$pendingAction = @unserialize($_SESSION['fints_pending_action']);
if ($pendingAction === false) {
dol_syslog("BankImport AJAX: Failed to unserialize pending action", LOG_ERR);
echo json_encode(['status' => 'error', 'message' => 'Could not restore pending action']);
exit;
}
$fints->setPendingAction($pendingAction);
dol_syslog("BankImport AJAX: Calling checkDecoupledTan", LOG_DEBUG);
// Check if TAN was confirmed
$checkResult = $fints->checkDecoupledTan();
if ($checkResult > 0) {
// TAN confirmed! Now fetch statements
$savedAction = $_SESSION['fints_action'] ?? 'statements';
$savedDateFrom = $_SESSION['fints_datefrom'] ?? strtotime('-30 days');
$savedDateTo = $_SESSION['fints_dateto'] ?? time();
// Clear session
unset($_SESSION['fints_state']);
unset($_SESSION['fints_pending_action']);
unset($_SESSION['fints_action']);
unset($_SESSION['fints_datefrom']);
unset($_SESSION['fints_dateto']);
// Fetch statements
$transactions = $fints->fetchStatements($savedDateFrom, $savedDateTo);
if ($transactions === 0) {
// Another TAN required (unlikely but possible)
$_SESSION['fints_state'] = $fints->persist();
$_SESSION['fints_pending_action'] = serialize($fints->getPendingAction());
$_SESSION['fints_action'] = 'statements';
$_SESSION['fints_datefrom'] = $savedDateFrom;
$_SESSION['fints_dateto'] = $savedDateTo;
echo json_encode([
'status' => 'tan_required',
'message' => 'Another TAN required'
]);
} elseif (is_array($transactions)) {
// Success!
$fints->close();
// Extract transactions and balance from result
$txList = $transactions['transactions'] ?? array();
$balance = $transactions['balance'] ?? array();
// Store in session for display
$_SESSION['fints_transactions'] = $txList;
$_SESSION['fints_balance'] = $balance;
echo json_encode([
'status' => 'success',
'message' => 'Transactions fetched',
'count' => count($txList),
'transactions' => $txList,
'balance' => $balance
]);
} else {
echo json_encode([
'status' => 'error',
'message' => 'Fetch failed: '.$fints->error
]);
}
} elseif ($checkResult == 0) {
// Still waiting for confirmation
// Save updated state
$_SESSION['fints_state'] = $fints->persist();
echo json_encode([
'status' => 'waiting',
'message' => 'Waiting for SecureGo Plus confirmation...'
]);
} else {
// Error
echo json_encode([
'status' => 'error',
'message' => 'TAN check failed: '.$fints->error
]);
// Clear session on error
unset($_SESSION['fints_state']);
unset($_SESSION['fints_pending_action']);
}
} catch (Exception $e) {
echo json_encode([
'status' => 'error',
'message' => 'Exception: '.$e->getMessage()
]);
// Clear session on exception
unset($_SESSION['fints_state']);
unset($_SESSION['fints_pending_action']);
} finally {
// Release lock
if (isset($fp)) {
flock($fp, LOCK_UN);
fclose($fp);
@unlink($lockFile);
}
}

382
bankimportindex.php Executable file
View file

@ -0,0 +1,382 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file bankimport/bankimportindex.php
* \ingroup bankimport
* \brief Dashboard page for BankImport module
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formfile.class.php';
/**
* @var Conf $conf
* @var DoliDB $db
* @var HookManager $hookmanager
* @var Translate $langs
* @var User $user
*/
// Load translation files required by the page
$langs->loadLangs(array("bankimport@bankimport", "banks"));
$action = GETPOST('action', 'aZ09');
// Security check
if (!$user->hasRight('bankimport', 'read')) {
accessforbidden();
}
$socid = GETPOSTINT('socid');
if (!empty($user->socid) && $user->socid > 0) {
$action = '';
$socid = $user->socid;
}
/*
* View
*/
$form = new Form($db);
dol_include_once('/bankimport/class/bankstatement.class.php');
llxHeader("", $langs->trans("BankImportArea"), '', '', 0, 0, '', '', '', 'mod-bankimport page-index');
print load_fiche_titre($langs->trans("BankImportArea"), '', 'bank');
// Reminder: check if statements are outdated
$reminderEnabled = getDolGlobalString('BANKIMPORT_REMINDER_ENABLED', '1');
if ($reminderEnabled) {
$reminderMonths = getDolGlobalInt('BANKIMPORT_REMINDER_MONTHS') ?: 3;
$stmtCheck = new BankImportStatement($db);
$lastEndDate = $stmtCheck->getLatestStatementEndDate();
$thresholdDate = dol_time_plus_duree(dol_now(), -$reminderMonths, 'm');
if ($lastEndDate === null) {
// No statements at all
print '<div class="warning">';
print img_warning().' '.$langs->trans("ReminderNoStatements");
print ' <a href="'.dol_buildpath('/bankimport/pdfstatements.php', 1).'">'.$langs->trans("UploadPDFStatement").'</a>';
print '</div><br>';
} elseif ($lastEndDate < $thresholdDate) {
$monthsAgo = (int) round((dol_now() - $lastEndDate) / (30 * 24 * 3600));
print '<div class="warning">';
print img_warning().' '.$langs->trans("ReminderOutdatedStatements", dol_print_date($lastEndDate, 'day'), $monthsAgo);
print ' <a href="'.dol_buildpath('/bankimport/pdfstatements.php', 1).'">'.$langs->trans("UploadPDFStatement").'</a>';
print '</div><br>';
}
}
// Payment matching notification
$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (!empty($bankAccountId)) {
$sqlNewCount = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."bankimport_transaction";
$sqlNewCount .= " WHERE entity IN (".getEntity('banktransaction').")";
$sqlNewCount .= " AND status = 0";
$resNewCount = $db->query($sqlNewCount);
$newCount = 0;
if ($resNewCount) {
$objNewCount = $db->fetch_object($resNewCount);
$newCount = (int) $objNewCount->cnt;
}
if ($newCount > 0) {
print '<div class="info" style="border-left: 4px solid #2196F3; background: #e3f2fd; padding: 12px; margin-bottom: 15px;">';
print img_picto('', 'payment', 'class="pictofixedwidth"');
print '<strong>'.$langs->trans("PendingPaymentMatches", $newCount).'</strong>';
print '<br>'.$langs->trans("PendingPaymentMatchesDesc");
print ' <a class="butAction" style="margin-left: 10px;" href="'.dol_buildpath('/bankimport/confirm.php', 1).'?mainmenu=bank&leftmenu=bankimport">';
print $langs->trans("ReviewAndConfirm");
print '</a>';
print '</div>';
}
} else {
print '<div class="warning" style="margin-bottom: 15px;">';
print img_warning().' '.$langs->trans("NoBankAccountConfigured");
print ' <a href="'.dol_buildpath('/bankimport/admin/setup.php', 1).'">'.$langs->trans("GoToSetup").'</a>';
print '</div>';
}
print '<div class="fichecenter"><div class="fichethirdleft">';
// -----------------------------------------------
// Widget: Letzte 10 importierte Buchungen
// -----------------------------------------------
$max = 10;
$sql = "SELECT t.rowid, t.ref, t.date_trans, t.name, t.description, t.amount, t.currency, t.status";
$sql .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t";
$sql .= " WHERE t.entity IN (".getEntity('banktransaction').")";
$sql .= " ORDER BY t.date_trans DESC, t.rowid DESC";
$sql .= $db->plimit($max, 0);
$resql = $db->query($sql);
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th colspan="4">';
print $langs->trans("LastImportedTransactions");
// Count total
$sqlcount = "SELECT COUNT(*) as total FROM ".MAIN_DB_PREFIX."bankimport_transaction WHERE entity IN (".getEntity('banktransaction').")";
$rescount = $db->query($sqlcount);
if ($rescount) {
$objcount = $db->fetch_object($rescount);
if ($objcount->total > 0) {
print '<a class="paddingleft" href="'.dol_buildpath('/bankimport/list.php', 1).'?mainmenu=bank&leftmenu=bankimport">';
print '<span class="badge">'.$objcount->total.'</span>';
print '</a>';
}
}
print '</th>';
print '</tr>';
if ($resql) {
$num = $db->num_rows($resql);
if ($num > 0) {
$i = 0;
while ($i < $num) {
$obj = $db->fetch_object($resql);
print '<tr class="oddeven">';
// Date
print '<td class="nowraponall">';
print dol_print_date($db->jdate($obj->date_trans), 'day');
print '</td>';
// Name + Description
print '<td class="tdoverflowmax200">';
print '<a href="'.dol_buildpath('/bankimport/card.php', 1).'?id='.$obj->rowid.'&mainmenu=bank&leftmenu=bankimport">';
print dol_escape_htmltag(dol_trunc($obj->name, 30));
print '</a>';
if ($obj->description) {
print '<br><span class="opacitymedium small">'.dol_escape_htmltag(dol_trunc($obj->description, 40)).'</span>';
}
print '</td>';
// Amount
print '<td class="right nowraponall">';
if ($obj->amount >= 0) {
print '<span class="amount" style="color: green;">+'.price($obj->amount, 0, $langs, 1, -1, 2, $obj->currency).'</span>';
} else {
print '<span class="amount" style="color: red;">'.price($obj->amount, 0, $langs, 1, -1, 2, $obj->currency).'</span>';
}
print '</td>';
// Status
print '<td class="right nowraponall">';
switch ($obj->status) {
case 0:
print '<span class="badge badge-status4 badge-status">'.$langs->trans("New").'</span>';
break;
case 1:
print '<span class="badge badge-status1 badge-status">'.$langs->trans("Matched").'</span>';
break;
case 2:
print '<span class="badge badge-status6 badge-status">'.$langs->trans("Reconciled").'</span>';
break;
case 9:
print '<span class="badge badge-status5 badge-status">'.$langs->trans("Ignored").'</span>';
break;
}
print '</td>';
print '</tr>';
$i++;
}
} else {
print '<tr class="oddeven"><td colspan="4" class="opacitymedium">'.$langs->trans("NoTransactionsInDatabase").'</td></tr>';
}
$db->free($resql);
} else {
dol_print_error($db);
}
print '</table>';
// Link "Alle anzeigen"
if (!empty($objcount) && $objcount->total > 0) {
print '<div class="right" style="margin-top: 5px;">';
print '<a href="'.dol_buildpath('/bankimport/list.php', 1).'?mainmenu=bank&leftmenu=bankimport">';
print $langs->trans("ShowAll").' &raquo;';
print '</a>';
print '</div>';
}
print '</div><div class="fichetwothirdright">';
// -----------------------------------------------
// Widget: Letzte 5 PDF-Kontoauszüge
// -----------------------------------------------
$maxpdf = 5;
$sql2 = "SELECT s.rowid, s.statement_number, s.statement_year, s.iban, s.date_from, s.date_to,";
$sql2 .= " s.opening_balance, s.closing_balance, s.filename, s.filepath, s.filesize, s.datec";
$sql2 .= " FROM ".MAIN_DB_PREFIX."bankimport_statement as s";
$sql2 .= " WHERE s.entity IN (".getEntity('bankstatement').")";
$sql2 .= " ORDER BY s.datec DESC";
$sql2 .= $db->plimit($maxpdf, 0);
$resql2 = $db->query($sql2);
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th colspan="5">';
print $langs->trans("LastPDFStatements");
// Count total
$sqlcount2 = "SELECT COUNT(*) as total FROM ".MAIN_DB_PREFIX."bankimport_statement WHERE entity IN (".getEntity('bankstatement').")";
$rescount2 = $db->query($sqlcount2);
if ($rescount2) {
$objcount2 = $db->fetch_object($rescount2);
if ($objcount2->total > 0) {
print '<a class="paddingleft" href="'.dol_buildpath('/bankimport/pdfstatements.php', 1).'?mainmenu=bank&leftmenu=bankimport">';
print '<span class="badge">'.$objcount2->total.'</span>';
print '</a>';
}
}
print '</th>';
print '</tr>';
if ($resql2) {
$num2 = $db->num_rows($resql2);
if ($num2 > 0) {
$i = 0;
while ($i < $num2) {
$obj2 = $db->fetch_object($resql2);
print '<tr class="oddeven">';
// Statement number / Year
print '<td class="nowraponall">';
print '<strong>'.dol_escape_htmltag($obj2->statement_number).'</strong>/'.$obj2->statement_year;
print '</td>';
// IBAN (shortened)
print '<td class="tdoverflowmax150">';
if ($obj2->iban) {
print dol_escape_htmltag(dol_trunc($obj2->iban, 20));
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Period
print '<td class="center nowraponall">';
if ($obj2->date_from && $obj2->date_to) {
print dol_print_date($db->jdate($obj2->date_from), 'day').' - '.dol_print_date($db->jdate($obj2->date_to), 'day');
} elseif ($obj2->date_from) {
print dol_print_date($db->jdate($obj2->date_from), 'day').' -';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Closing balance
print '<td class="right nowraponall">';
if ($obj2->closing_balance !== null && $obj2->closing_balance !== '') {
$color = (float) $obj2->closing_balance >= 0 ? '' : 'color: red;';
print '<span style="'.$color.'">'.price($obj2->closing_balance, 0, $langs, 1, -1, 2, 'EUR').'</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Actions
print '<td class="center nowraponall">';
if ($obj2->filepath && file_exists($obj2->filepath)) {
print '<a class="paddingright" href="'.dol_buildpath('/bankimport/pdfstatements.php', 1).'?action=view&id='.$obj2->rowid.'&token='.newToken().'" target="_blank" title="'.$langs->trans("View").'">';
print img_picto($langs->trans("View"), 'eye');
print '</a>';
print '<a href="'.dol_buildpath('/bankimport/pdfstatements.php', 1).'?action=download&id='.$obj2->rowid.'&token='.newToken().'" title="'.$langs->trans("Download").'">';
print img_picto($langs->trans("Download"), 'download');
print '</a>';
}
print '</td>';
print '</tr>';
$i++;
}
} else {
print '<tr class="oddeven"><td colspan="5" class="opacitymedium">'.$langs->trans("NoPDFStatementsFound").'</td></tr>';
}
$db->free($resql2);
} else {
dol_print_error($db);
}
print '</table>';
// Links
print '<div class="right" style="margin-top: 5px;">';
if (!empty($objcount2) && $objcount2->total > 0) {
print '<a href="'.dol_buildpath('/bankimport/pdfstatements.php', 1).'?mainmenu=bank&leftmenu=bankimport">';
print $langs->trans("ShowAll");
print '</a>';
print ' | ';
}
print '<a href="'.dol_buildpath('/bankimport/pdfstatements.php', 1).'?mainmenu=bank&leftmenu=bankimport">';
print $langs->trans("UploadNew").' &raquo;';
print '</a>';
print '</div>';
print '</div></div>';
// End of page
llxFooter();
$db->close();

316
build/buildzip.php Executable file
View file

@ -0,0 +1,316 @@
#!/usr/bin/env php -d memory_limit=256M
<?php
/**
* buildzip.php
*
* Copyright (c) 2023-2025 Eric Seigne <eric.seigne@cap-rel.fr>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/*
The goal of that php CLI script is to make zip package of your module
as an alternative to web "build zip" or "perl script makepack"
*/
// ============================================= configuration
/**
* list of files & dirs of your module
*
* @var string[]
*/
$listOfModuleContent = [
'admin',
'ajax',
'backport',
'class',
'css',
'COPYING',
'core',
'img',
'js',
'langs',
'lib',
'sql',
'tpl',
'*.md',
'*.json',
'*.php',
'modulebuilder.txt',
];
/**
* if you want to exclude some files from your zip
*
* @var string[]
*/
$exclude_list = [
'/^.git$/',
'/.*js.map/',
'/DEV.md/'
];
// ============================================= end of configuration
/**
* auto detect module name and version from file name
*
* @return (string|string)[] module name and module version
*/
function detectModule()
{
$name = $version = "";
$tab = glob("core/modules/mod*.class.php");
if (count($tab) == 0) {
echo "[fail] Error on auto detect data : there is no mod*.class.php file into core/modules dir\n";
exit(-1);
}
if (count($tab) == 1) {
$file = $tab[0];
$pattern = "/.*mod(?<mod>.*)\.class\.php/";
if (preg_match_all($pattern, $file, $matches)) {
$name = strtolower(reset($matches['mod']));
}
echo "extract data from $file\n";
if (!file_exists($file) || $name == "") {
echo "[fail] Error on auto detect data\n";
exit(-2);
}
} else {
echo "[fail] Error there is more than one mod*.class.php file into core/modules dir\n";
exit(-3);
}
//extract version from file
$contents = file_get_contents($file);
$pattern = "/^.*this->version\s*=\s*'(?<version>.*)'\s*;.*\$/m";
// search, and store all matching occurrences in $matches
if (preg_match_all($pattern, $contents, $matches)) {
$version = reset($matches['version']);
}
if (version_compare($version, '0.0.1', '>=') != 1) {
echo "[fail] Error auto extract version fail\n";
exit(-4);
}
echo "module name = $name, version = $version\n";
return [(string) $name, (string) $version];
}
/**
* delete recursively a directory
*
* @param string $dir dir path to delete
*
* @return bool true on success or false on failure.
*/
function delTree($dir)
{
$files = array_diff(scandir($dir), array('.', '..'));
foreach ($files as $file) {
(is_dir("$dir/$file")) ? delTree("$dir/$file") : secureUnlink("$dir/$file");
}
return rmdir($dir);
}
/**
* do a secure delete file/dir with double check
* (don't trust unlink return)
*
* @param string $path full path to delete
*
* @return bool true on success ($path does not exists at the end of process), else exit
*/
function secureUnlink($path)
{
if (file_exists($path)) {
if (unlink($path)) {
//then check if really deleted
clearstatcache();
if (file_exists($path)) { // @phpstan-ignore-line
echo "[fail] unlink of $path fail !\n";
exit(-5);
}
} else {
echo "[fail] unlink of $path fail !\n";
exit(-6);
}
}
return true;
}
/**
* create a directory and check if dir exists
*
* @param string $path path to make
*
* @return bool true on success ($path exists at the end of process), else exit
*/
function mkdirAndCheck($path)
{
if (mkdir($path)) {
clearstatcache();
if (is_dir($path)) {
return true;
}
}
echo "[fail] Error on $path (mkdir)\n";
exit(7);
}
/**
* check if that filename is concerned by exclude filter
*
* @param string $filename file name to check
*
* @return bool true if file is in excluded list
*/
function is_excluded($filename)
{
global $exclude_list;
$count = 0;
$notused = preg_filter($exclude_list, '1', $filename, -1, $count);
if ($count > 0) {
echo " - exclude $filename\n";
return true;
}
return false;
}
/**
* recursive copy files & dirs
*
* @param string $src source dir
* @param string $dst target dir
*
* @return bool true on success or false on failure.
*/
function rcopy($src, $dst)
{
if (is_dir($src)) {
// Make the destination directory if not exist
mkdirAndCheck($dst);
// open the source directory
$dir = opendir($src);
// Loop through the files in source directory
while ($file = readdir($dir)) {
if (($file != '.') && ($file != '..')) {
if (is_dir($src . '/' . $file)) {
// Recursively calling custom copy function
// for sub directory
if (!rcopy($src . '/' . $file, $dst . '/' . $file)) {
return false;
}
} else {
if (!is_excluded($file)) {
if (!copy($src . '/' . $file, $dst . '/' . $file)) {
return false;
}
}
}
}
}
closedir($dir);
} elseif (is_file($src)) {
if (!is_excluded($src)) {
if (!copy($src, $dst)) {
return false;
}
}
}
return true;
}
/**
* build a zip file with only php code and no external depends
* on "zip" exec for example
*
* @param string $folder folder to use as zip root
* @param ZipArchive $zip zip object (ZipArchive)
* @param string $root relative root path into the zip
*
* @return bool true on success or false on failure.
*/
function zipDir($folder, &$zip, $root = "")
{
foreach (new \DirectoryIterator($folder) as $f) {
if ($f->isDot()) {
continue;
} //skip . ..
$src = $folder . '/' . $f;
$dst = substr($f->getPathname(), strlen($root));
if ($f->isDir()) {
if ($zip->addEmptyDir($dst)) {
if (zipDir($src, $zip, $root)) {
continue;
} else {
return false;
}
} else {
return false;
}
}
if ($f->isFile()) {
if (! $zip->addFile($src, $dst)) {
return false;
}
}
}
return true;
}
/**
* main part of script
*/
list($mod, $version) = detectModule();
$outzip = sys_get_temp_dir() . "/module_" . $mod . "-" . $version . ".zip";
if (file_exists($outzip)) {
secureUnlink($outzip);
}
//copy all sources into system temp directory
$tmpdir = tempnam(sys_get_temp_dir(), $mod . "-module");
secureUnlink($tmpdir);
mkdirAndCheck($tmpdir);
$dst = $tmpdir . "/" . $mod;
mkdirAndCheck($dst);
foreach ($listOfModuleContent as $moduleContent) {
foreach (glob($moduleContent) as $entry) {
if (!rcopy($entry, $dst . '/' . $entry)) {
echo "[fail] Error on copy " . $entry . " to " . $dst . "/" . $entry . "\n";
echo "Please take time to analyze the problem and fix the bug\n";
exit(-8);
}
}
}
$z = new ZipArchive();
$z->open($outzip, ZIPARCHIVE::CREATE);
zipDir($tmpdir, $z, $tmpdir . '/');
$z->close();
delTree($tmpdir);
if (file_exists($outzip)) {
echo "[success] module archive is ready : $outzip ...\n";
} else {
echo "[fail] build zip error\n";
exit(-9);
}

11
build/makepack-bankimport.conf Executable file
View file

@ -0,0 +1,11 @@
# Your module name here
#
# Goal: Goal of module
# Version: <version>
# Author: Copyright <year> - <name of author>
# License: GPLv3
# Install: Just unpack content of module package in Dolibarr directory.
# Setup: Go on Dolibarr setup - modules to enable module.
#
# Files in module
mymodule/

1258
card.php Executable file

File diff suppressed because it is too large Load diff

379
class/bankimportcron.class.php Executable file
View file

@ -0,0 +1,379 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
/**
* \file bankimport/class/bankimportcron.class.php
* \ingroup bankimport
* \brief Cron job class for automatic bank statement import
*/
require_once DOL_DOCUMENT_ROOT.'/core/class/commonobject.class.php';
dol_include_once('/bankimport/class/fints.class.php');
dol_include_once('/bankimport/class/banktransaction.class.php');
/**
* Class BankImportCron
* Handles automatic bank statement import via scheduled task
*/
class BankImportCron
{
/**
* @var DoliDB Database handler
*/
public $db;
/**
* @var string Error message
*/
public $error = '';
/**
* @var array Error messages
*/
public $errors = array();
/**
* @var string Output message for cron log
*/
public $output = '';
/**
* Constructor
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
$this->db = $db;
}
/**
* Execute the automatic import cron job
* Called by Dolibarr's scheduled task system
*
* @return int 0 if OK, < 0 if error
*/
public function doAutoImport()
{
global $conf, $langs, $user;
$langs->load('bankimport@bankimport');
dol_syslog("BankImportCron::doAutoImport - Starting automatic import", LOG_INFO);
// Check if automatic import is enabled
if (!getDolGlobalInt('BANKIMPORT_AUTO_ENABLED')) {
$this->output = $langs->trans('AutoImportDisabled');
dol_syslog("BankImportCron::doAutoImport - Auto import is disabled", LOG_INFO);
return 0;
}
// Initialize FinTS
$fints = new BankImportFinTS($this->db);
if (!$fints->isConfigured()) {
$this->error = $langs->trans('AutoImportNotConfigured');
dol_syslog("BankImportCron::doAutoImport - FinTS not configured", LOG_WARNING);
$this->setNotification('config_error');
return -1;
}
if (!$fints->isLibraryAvailable()) {
$this->error = $langs->trans('FinTSLibraryNotFound');
dol_syslog("BankImportCron::doAutoImport - Library not found", LOG_ERR);
return -1;
}
// Check for stored session state (from previous successful TAN)
$storedState = getDolGlobalString('BANKIMPORT_CRON_STATE');
$sessionRestored = false;
try {
// Try to restore previous session
if (!empty($storedState)) {
dol_syslog("BankImportCron::doAutoImport - Attempting to restore previous session (state length: ".strlen($storedState).")", LOG_INFO);
$restoreResult = $fints->restore($storedState);
if ($restoreResult < 0) {
// Session expired, need fresh login
dol_syslog("BankImportCron::doAutoImport - Session restore failed: ".$fints->error.", trying fresh login", LOG_WARNING);
$storedState = '';
// Clear stale session
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity);
} else {
dol_syslog("BankImportCron::doAutoImport - Session restored successfully", LOG_INFO);
$sessionRestored = true;
}
} else {
dol_syslog("BankImportCron::doAutoImport - No stored session found", LOG_INFO);
}
// If no stored session or restore failed, try fresh login
if (empty($storedState)) {
dol_syslog("BankImportCron::doAutoImport - Attempting fresh login", LOG_INFO);
$loginResult = $fints->login();
if ($loginResult < 0) {
$this->error = $langs->trans('LoginFailed').': '.$fints->error;
dol_syslog("BankImportCron::doAutoImport - Login failed: ".$fints->error, LOG_ERR);
$this->setNotification('login_error');
return -1;
}
if ($loginResult == 0) {
// TAN required - can't proceed automatically
$this->output = $langs->trans('TANRequired');
dol_syslog("BankImportCron::doAutoImport - TAN required for login, cannot proceed automatically", LOG_WARNING);
$this->setNotification('tan_required');
// Store the state so user can complete TAN manually
$state = $fints->persist();
if (!empty($state)) {
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $state, 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', serialize($fints->getPendingAction()), 'chaine', 0, '', $conf->entity);
}
return 0; // Not an error, just can't proceed
}
dol_syslog("BankImportCron::doAutoImport - Fresh login successful", LOG_INFO);
}
// Login successful or session restored - fetch statements
$daysToFetch = getDolGlobalInt('BANKIMPORT_AUTO_DAYS') ?: 30;
$dateFrom = strtotime("-{$daysToFetch} days");
$dateTo = time();
dol_syslog("BankImportCron::doAutoImport - Fetching statements from ".date('Y-m-d', $dateFrom)." to ".date('Y-m-d', $dateTo)." ({$daysToFetch} days)", LOG_INFO);
$result = $fints->fetchStatements($dateFrom, $dateTo);
// Log what we got back
if (is_array($result)) {
$txCount = count($result['transactions'] ?? array());
$hasBalance = !empty($result['balance']);
$isPartial = !empty($result['partial']);
dol_syslog("BankImportCron::doAutoImport - fetchStatements returned array: transactions={$txCount}, hasBalance=".($hasBalance?'yes':'no').", partial=".($isPartial?'yes':'no'), LOG_INFO);
} else {
dol_syslog("BankImportCron::doAutoImport - fetchStatements returned: ".var_export($result, true), LOG_INFO);
}
if ($result === 0) {
// TAN required for statements
$this->output = $langs->trans('TANRequired');
dol_syslog("BankImportCron::doAutoImport - TAN required for statements", LOG_WARNING);
$this->setNotification('tan_required');
// Store state for manual completion
$state = $fints->persist();
if (!empty($state)) {
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $state, 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', serialize($fints->getPendingAction()), 'chaine', 0, '', $conf->entity);
}
return 0;
}
if ($result < 0) {
$this->error = $langs->trans('FetchFailed').': '.$fints->error;
dol_syslog("BankImportCron::doAutoImport - Fetch failed: ".$fints->error, LOG_ERR);
$this->setNotification('fetch_error');
return -1;
}
// Success - import transactions
$transactions = $result['transactions'] ?? array();
$fetchedCount = count($transactions);
$importedCount = 0;
$skippedCount = 0;
dol_syslog("BankImportCron::doAutoImport - Bank returned {$fetchedCount} transactions", LOG_INFO);
// If restored session returned 0 transactions, the session might be stale
// Try fresh login as fallback
if ($fetchedCount == 0 && $sessionRestored) {
dol_syslog("BankImportCron::doAutoImport - Restored session returned 0 transactions, clearing stale session", LOG_WARNING);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_STATE', $conf->entity);
// Note: We don't retry here because a fresh login would require TAN
// Just mark that the session was stale
$this->output = $langs->trans('AutoImportNoTransactions').' (Session abgelaufen - nächster Lauf erfordert TAN)';
$this->setNotification('session_expired');
return 0;
}
if (!empty($transactions)) {
// Get a system user for the import
$importUser = new User($this->db);
$importUser->fetch(1); // Admin user
$iban = $fints->getIban();
// Use the importFromFinTS method for correct field mapping
$transImporter = new BankImportTransaction($this->db);
$importResult = $transImporter->importFromFinTS($transactions, $iban, $importUser);
$importedCount = $importResult['imported'] ?? 0;
$skippedCount = $importResult['skipped'] ?? 0;
dol_syslog("BankImportCron::doAutoImport - Import result: imported={$importedCount}, skipped={$skippedCount}", LOG_INFO);
}
// Update last fetch info
dolibarr_set_const($this->db, 'BANKIMPORT_LAST_FETCH', time(), 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_LAST_FETCH_COUNT', $importedCount, 'chaine', 0, '', $conf->entity);
// Store session for next run (might avoid TAN)
$state = $fints->persist();
if (!empty($state)) {
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity);
}
// Clear any pending state
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity);
// Clear notification flag on success
$this->clearNotification();
$fints->close();
if ($importedCount > 0) {
$this->output = $langs->trans('AutoImportSuccess', $importedCount);
} elseif ($skippedCount > 0) {
$this->output = $langs->trans('AutoImportNoTransactions').' ('.$skippedCount.' bereits vorhanden)';
} else {
$this->output = $langs->trans('AutoImportNoTransactions');
}
dol_syslog("BankImportCron::doAutoImport - Completed: imported={$importedCount}, skipped={$skippedCount}", LOG_INFO);
return 0;
} catch (Exception $e) {
$this->error = 'Exception: '.$e->getMessage();
dol_syslog("BankImportCron::doAutoImport - Exception: ".$e->getMessage(), LOG_ERR);
$this->setNotification('error');
return -1;
}
}
/**
* Set notification flag for admin users
*
* @param string $type Notification type (tan_required, error, etc.)
* @return void
*/
private function setNotification($type)
{
global $conf;
dolibarr_set_const($this->db, 'BANKIMPORT_NOTIFICATION', $type, 'chaine', 0, '', $conf->entity);
dolibarr_set_const($this->db, 'BANKIMPORT_NOTIFICATION_DATE', time(), 'chaine', 0, '', $conf->entity);
}
/**
* Clear notification flag
*
* @return void
*/
private function clearNotification()
{
global $conf;
dolibarr_del_const($this->db, 'BANKIMPORT_NOTIFICATION', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_NOTIFICATION_DATE', $conf->entity);
}
/**
* Check if there's a pending notification
*
* @return array|null Notification info or null
*/
public static function getNotification()
{
$type = getDolGlobalString('BANKIMPORT_NOTIFICATION');
$date = getDolGlobalInt('BANKIMPORT_NOTIFICATION_DATE');
if (empty($type)) {
return null;
}
return array(
'type' => $type,
'date' => $date
);
}
/**
* Resume a pending TAN action (called from web interface)
*
* @return int 1 if TAN confirmed and import done, 0 if still waiting, -1 if error
*/
public function resumePendingAction()
{
global $conf, $langs, $user;
$pendingState = getDolGlobalString('BANKIMPORT_CRON_PENDING_STATE');
$pendingAction = getDolGlobalString('BANKIMPORT_CRON_PENDING_ACTION');
if (empty($pendingState) || empty($pendingAction)) {
$this->error = 'No pending action';
return -1;
}
$fints = new BankImportFinTS($this->db);
$restoreResult = $fints->restore($pendingState);
if ($restoreResult < 0) {
$this->error = $fints->error;
// Clear expired state
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity);
return -1;
}
$action = @unserialize($pendingAction);
if ($action === false) {
$this->error = 'Could not restore pending action';
return -1;
}
$fints->setPendingAction($action);
// Check if TAN was confirmed
$checkResult = $fints->checkDecoupledTan();
if ($checkResult == 0) {
// Still waiting
// Update state
$newState = $fints->persist();
if (!empty($newState)) {
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $newState, 'chaine', 0, '', $conf->entity);
}
return 0;
}
if ($checkResult < 0) {
$this->error = $fints->error;
return -1;
}
// TAN confirmed - now run the import
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_STATE', $conf->entity);
dolibarr_del_const($this->db, 'BANKIMPORT_CRON_PENDING_ACTION', $conf->entity);
// Store the confirmed session and run import
$state = $fints->persist();
if (!empty($state)) {
dolibarr_set_const($this->db, 'BANKIMPORT_CRON_STATE', $state, 'chaine', 0, '', $conf->entity);
}
return $this->doAutoImport();
}
}

1325
class/bankstatement.class.php Executable file

File diff suppressed because it is too large Load diff

2289
class/banktransaction.class.php Executable file

File diff suppressed because it is too large Load diff

1020
class/fints.class.php Executable file

File diff suppressed because it is too large Load diff

16
composer.json Executable file
View file

@ -0,0 +1,16 @@
{
"name": "dolibarr/bankimport",
"description": "Dolibarr module for importing bank statements via FinTS/HBCI",
"type": "dolibarr-module",
"license": "GPL-3.0-or-later",
"require": {
"php": ">=8.0",
"nemiah/php-fints": "^3.2"
},
"replace": {
"psr/log": "*"
},
"autoload": {
"classmap": ["class/"]
}
}

70
composer.lock generated Executable file
View file

@ -0,0 +1,70 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "cfc07b7e6c4a3dcfdcd6e754983b1a9b",
"packages": [
{
"name": "nemiah/php-fints",
"version": "3.7.0",
"source": {
"type": "git",
"url": "https://github.com/nemiah/phpFinTS.git",
"reference": "08257e10229db2d4ca8c54ed7fec0f390b332519"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nemiah/phpFinTS/zipball/08257e10229db2d4ca8c54ed7fec0f390b332519",
"reference": "08257e10229db2d4ca8c54ed7fec0f390b332519",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-mbstring": "*",
"php": ">=8.0",
"psr/log": "^1|^2|^3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"php-mock/php-mock-phpunit": "^2.6",
"phpunit/phpunit": "^9.5"
},
"suggest": {
"abcaeffchen/sephpa": "1.*",
"monolog/monolog": "Allow sending log messages to a variety of different handlers",
"nemiah/php-sepa-xml": "dev-master"
},
"type": "library",
"autoload": {
"psr-0": {
"Fhp": "lib/",
"Tests\\Fhp": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP Library for the protocols fints and hbci",
"homepage": "https://github.com/nemiah/phpFinTS",
"support": {
"issues": "https://github.com/nemiah/phpFinTS/issues",
"source": "https://github.com/nemiah/phpFinTS/tree/3.7"
},
"time": "2025-10-14T15:05:56+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": ">=8.0"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

458
confirm.php Executable file
View file

@ -0,0 +1,458 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
/**
* \file bankimport/confirm.php
* \ingroup bankimport
* \brief Payment confirmation page - match bank transactions to invoices
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php';
dol_include_once('/bankimport/class/banktransaction.class.php');
dol_include_once('/bankimport/lib/bankimport.lib.php');
/**
* @var Conf $conf
* @var DoliDB $db
* @var Translate $langs
* @var User $user
*/
$langs->loadLangs(array("bankimport@bankimport", "banks", "bills"));
$action = GETPOST('action', 'aZ09');
// Security check
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
/*
* Actions
*/
// Confirm single payment
if ($action == 'confirmpayment' && !empty($bankAccountId)) {
$transid = GETPOSTINT('transid');
$matchtype = GETPOST('matchtype', 'alpha');
$matchid = GETPOSTINT('matchid');
if ($transid > 0 && !empty($matchtype) && $matchid > 0) {
$trans = new BankImportTransaction($db);
if ($trans->fetch($transid) > 0) {
if ($trans->status != BankImportTransaction::STATUS_NEW) {
setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings');
} else {
$result = $trans->confirmPayment($user, $matchtype, $matchid, $bankAccountId);
if ($result > 0) {
setEventMessages($langs->trans("PaymentCreatedSuccessfully", dol_escape_htmltag($trans->name), price(abs($trans->amount))), null, 'mesgs');
} else {
setEventMessages($trans->error, $trans->errors, 'errors');
}
}
}
}
header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken());
exit;
}
// Confirm multiple invoices payment
if ($action == 'confirmmulti' && !empty($bankAccountId)) {
$transid = GETPOSTINT('transid');
$invoiceIds = GETPOST('invoices', 'array');
if ($transid > 0 && !empty($invoiceIds)) {
$trans = new BankImportTransaction($db);
if ($trans->fetch($transid) > 0) {
if ($trans->status != BankImportTransaction::STATUS_NEW) {
setEventMessages($langs->trans("TransactionAlreadyProcessed"), null, 'warnings');
} else {
// Build invoices array with amounts
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php';
$invoices = array();
foreach ($invoiceIds as $invId) {
$invId = (int) $invId;
if ($invId > 0) {
$invoice = new FactureFournisseur($db);
if ($invoice->fetch($invId) > 0) {
$alreadyPaid = $invoice->getSommePaiement();
$creditnotes = $invoice->getSumCreditNotesUsed();
$deposits = $invoice->getSumDepositsUsed();
$remainToPay = price2num($invoice->total_ttc - $alreadyPaid - $creditnotes - $deposits, 'MT');
if ($remainToPay > 0) {
$invoices[] = array(
'type' => 'facture_fourn',
'id' => $invId,
'ref' => $invoice->ref,
'amount' => $remainToPay
);
}
}
}
}
if (!empty($invoices)) {
$result = $trans->confirmMultiplePayment($user, $invoices, $bankAccountId);
if ($result > 0) {
setEventMessages($langs->trans("PaymentCreatedSuccessfully", dol_escape_htmltag($trans->name), price(abs($trans->amount))).' ('.count($invoices).' '.$langs->trans("Invoices").')', null, 'mesgs');
} else {
setEventMessages($trans->error, $trans->errors, 'errors');
}
} else {
setEventMessages($langs->trans("NoInvoicesSelected"), null, 'errors');
}
}
}
}
header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken());
exit;
}
// Confirm all high-score matches
if ($action == 'confirmall' && !empty($bankAccountId)) {
$transaction = new BankImportTransaction($db);
$transactions = $transaction->fetchAll('date_trans', 'DESC', 0, 0, array('status' => BankImportTransaction::STATUS_NEW));
$created = 0;
$failed = 0;
if (is_array($transactions)) {
foreach ($transactions as $trans) {
$matches = $trans->findMatches();
if (!empty($matches) && $matches[0]['match_score'] >= 80) {
$bestMatch = $matches[0];
// Handle multi-invoice matches
if ($bestMatch['type'] == 'multi_facture_fourn' && !empty($bestMatch['invoices'])) {
$invoices = array();
foreach ($bestMatch['invoices'] as $inv) {
$invoices[] = array(
'type' => 'facture_fourn',
'id' => $inv['id'],
'ref' => $inv['ref'],
'amount' => $inv['amount']
);
}
$result = $trans->confirmMultiplePayment($user, $invoices, $bankAccountId);
} else {
$result = $trans->confirmPayment($user, $bestMatch['type'], $bestMatch['id'], $bankAccountId);
}
if ($result > 0) {
$created++;
} else {
$failed++;
}
}
}
}
if ($created > 0 || $failed > 0) {
setEventMessages($langs->trans("PaymentsCreatedSummary", $created, $failed), null, $failed > 0 ? 'warnings' : 'mesgs');
} else {
setEventMessages($langs->trans("NoNewMatchesFound"), null, 'warnings');
}
header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken());
exit;
}
// Ignore transaction
if ($action == 'ignore') {
$transid = GETPOSTINT('transid');
if ($transid > 0) {
$trans = new BankImportTransaction($db);
if ($trans->fetch($transid) > 0 && $trans->status == BankImportTransaction::STATUS_NEW) {
$trans->setStatus(BankImportTransaction::STATUS_IGNORED, $user);
setEventMessages($langs->trans("StatusUpdated"), null, 'mesgs');
}
}
header("Location: ".$_SERVER["PHP_SELF"]."?token=".newToken());
exit;
}
/*
* View
*/
$form = new Form($db);
$title = $langs->trans("PaymentConfirmation");
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-confirm');
print load_fiche_titre($title, '', 'bank');
// Check if bank account is configured
if (empty($bankAccountId)) {
print '<div class="warning">';
print img_warning().' '.$langs->trans("ErrorNoBankAccountConfigured");
print ' <a href="'.dol_buildpath('/bankimport/admin/setup.php', 1).'">'.$langs->trans("GoToSetup").'</a>';
print '</div>';
llxFooter();
$db->close();
exit;
}
// Description
print '<div class="opacitymedium" style="margin-bottom: 15px;">'.$langs->trans("PaymentConfirmationDesc").'</div>';
// Fetch all new transactions and find matches
$transaction = new BankImportTransaction($db);
$transactions = $transaction->fetchAll('date_trans', 'DESC', 0, 0, array('status' => BankImportTransaction::STATUS_NEW));
$pendingMatches = array(); // transactions with matches
$noMatches = array(); // transactions without matches
if (is_array($transactions)) {
foreach ($transactions as $trans) {
$matches = $trans->findMatches();
if (!empty($matches)) {
$pendingMatches[] = array('transaction' => $trans, 'matches' => $matches);
} else {
$noMatches[] = $trans;
}
}
}
// Confirm all button (if high-score matches exist)
$highScoreCount = 0;
foreach ($pendingMatches as $pm) {
if ($pm['matches'][0]['match_score'] >= 80) {
$highScoreCount++;
}
}
if ($highScoreCount > 0) {
print '<div class="tabsAction">';
print '<a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=confirmall&token='.newToken().'">';
print $langs->trans("ConfirmAllHighScore").' ('.$highScoreCount.')';
print '</a>';
print '</div>';
}
// Match reasons translation
$reasonLabels = array(
'ref' => $langs->trans("MatchByRef"),
'ref_client' => $langs->trans("MatchByClientRef"),
'ref_supplier' => $langs->trans("MatchBySupplierRef"),
'amount' => $langs->trans("MatchByAmount"),
'amount_close' => $langs->trans("MatchByAmountClose"),
'name_exact' => $langs->trans("MatchByNameExact"),
'name_similar' => $langs->trans("MatchByNameSimilar"),
'iban' => $langs->trans("MatchByIBAN"),
'multi_invoice' => $langs->trans("MatchByMultiInvoice")
);
if (!empty($pendingMatches)) {
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Date").'</th>';
print '<th>'.$langs->trans("Counterparty").'</th>';
print '<th class="right">'.$langs->trans("Amount").' ('.$langs->trans("Transaction").')</th>';
print '<th style="text-align: center; width: 30px;"></th>';
print '<th>'.$langs->trans("Invoice").'</th>';
print '<th>'.$langs->trans("ThirdParty").'</th>';
print '<th class="right">'.$langs->trans("Amount").' ('.$langs->trans("Invoice").')</th>';
print '<th class="center">'.$langs->trans("Score").'</th>';
print '<th>'.$langs->trans("MatchReason").'</th>';
print '<th class="center">'.$langs->trans("Action").'</th>';
print '</tr>';
foreach ($pendingMatches as $pm) {
$trans = $pm['transaction'];
$bestMatch = $pm['matches'][0];
$isMultiInvoice = ($bestMatch['type'] == 'multi_facture_fourn');
// Score color
$scoreColor = $bestMatch['match_score'] >= 80 ? '#4caf50' : ($bestMatch['match_score'] >= 60 ? '#ff9800' : '#9e9e9e');
print '<tr class="oddeven">';
// Transaction date
print '<td class="nowraponall">'.dol_print_date($trans->date_trans, 'day').'</td>';
// Counterparty name + description
print '<td class="tdoverflowmax200">';
print '<a href="'.dol_buildpath('/bankimport/card.php', 1).'?id='.$trans->id.'">'.dol_escape_htmltag(dol_trunc($trans->name, 30)).'</a>';
if ($trans->description) {
print '<br><span class="opacitymedium small">'.dol_escape_htmltag(dol_trunc($trans->description, 50)).'</span>';
}
print '</td>';
// Transaction amount
print '<td class="right nowraponall">';
if ($trans->amount >= 0) {
print '<span style="color: green; font-weight: bold;">+'.price($trans->amount, 0, $langs, 1, -1, 2, $trans->currency).'</span>';
} else {
print '<span style="color: red; font-weight: bold;">'.price($trans->amount, 0, $langs, 1, -1, 2, $trans->currency).'</span>';
}
print '</td>';
// Arrow
print '<td class="center" style="font-size: 1.3em;">&harr;</td>';
// Invoice reference(s)
print '<td class="nowraponall">';
if ($isMultiInvoice && !empty($bestMatch['invoices'])) {
// Multi-invoice display
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php';
print '<strong>'.count($bestMatch['invoices']).' '.$langs->trans("Invoices").':</strong><br>';
foreach ($bestMatch['invoices'] as $invData) {
$inv = new FactureFournisseur($db);
$inv->fetch($invData['id']);
print $inv->getNomUrl(1).' <span class="opacitymedium small">('.price($invData['amount'], 0, $langs, 1, -1, 2, 'EUR').')</span><br>';
}
} elseif ($bestMatch['type'] == 'facture') {
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
$inv = new Facture($db);
$inv->fetch($bestMatch['id']);
print $inv->getNomUrl(1);
} else {
require_once DOL_DOCUMENT_ROOT.'/fourn/class/fournisseur.facture.class.php';
$inv = new FactureFournisseur($db);
$inv->fetch($bestMatch['id']);
print $inv->getNomUrl(1);
}
print '</td>';
// Third party
print '<td class="tdoverflowmax150">';
if ($bestMatch['socid'] > 0) {
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
$soc = new Societe($db);
$soc->fetch($bestMatch['socid']);
print $soc->getNomUrl(1);
} else {
print dol_escape_htmltag($bestMatch['socname']);
}
print '</td>';
// Invoice amount
print '<td class="right nowraponall">';
print price($bestMatch['amount'], 0, $langs, 1, -1, 2, 'EUR');
if ($isMultiInvoice && isset($bestMatch['difference']) && abs($bestMatch['difference']) > 0.01) {
$diffColor = $bestMatch['difference'] > 0 ? 'orange' : 'green';
print '<br><span class="small" style="color: '.$diffColor.';">'.($bestMatch['difference'] > 0 ? '+' : '').price($bestMatch['difference'], 0, $langs, 1, -1, 2, 'EUR').'</span>';
}
print '</td>';
// Score
print '<td class="center"><span style="color: '.$scoreColor.'; font-weight: bold; font-size: 1.1em;">'.$bestMatch['match_score'].'%</span></td>';
// Match reasons
print '<td>';
if (!empty($bestMatch['match_reasons'])) {
foreach ($bestMatch['match_reasons'] as $reason) {
$label = $reasonLabels[$reason] ?? $reason;
print '<span class="badge badge-secondary">'.$label.'</span> ';
}
}
print '</td>';
// Actions
print '<td class="center nowraponall">';
if ($isMultiInvoice && !empty($bestMatch['invoices'])) {
// Multi-invoice: Form with checkboxes for selection
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'" style="display: inline;">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="confirmmulti">';
print '<input type="hidden" name="transid" value="'.$trans->id.'">';
print '<div style="text-align: left; margin-bottom: 5px;">';
foreach ($bestMatch['invoices'] as $invData) {
print '<label style="display: block; margin: 2px 0;">';
print '<input type="checkbox" name="invoices[]" value="'.$invData['id'].'" checked> ';
print dol_escape_htmltag($invData['ref_supplier'] ?: $invData['ref']).' ('.price($invData['amount'], 0, $langs, 1, -1, 2, 'EUR').')';
print '</label>';
}
print '</div>';
print '<button type="submit" class="butActionSmall">'.$langs->trans("ConfirmPayment").'</button>';
print '</form>';
} else {
// Single invoice: Confirm payment button
print '<a class="butActionSmall" href="'.$_SERVER["PHP_SELF"].'?action=confirmpayment&transid='.$trans->id.'&matchtype='.urlencode($bestMatch['type']).'&matchid='.$bestMatch['id'].'&token='.newToken().'">';
print $langs->trans("ConfirmPayment");
print '</a>';
}
print '<br>';
// Ignore button
print '<a class="butActionSmall button-cancel" style="margin-top: 3px;" href="'.$_SERVER["PHP_SELF"].'?action=ignore&transid='.$trans->id.'&token='.newToken().'">';
print $langs->trans("SetAsIgnored");
print '</a>';
// Show alternatives if multiple matches
if (count($pm['matches']) > 1) {
print '<br><a class="small opacitymedium" style="margin-top: 3px;" href="'.dol_buildpath('/bankimport/card.php', 1).'?id='.$trans->id.'&action=findmatches&token='.newToken().'">';
print '+'.($count = count($pm['matches']) - 1).' '.$langs->trans("Alternatives");
print '</a>';
}
print '</td>';
print '</tr>';
}
print '</table>';
print '</div>';
} else {
print '<div class="opacitymedium" style="padding: 20px; text-align: center;">';
print $langs->trans("NoNewMatchesFound");
print '</div>';
}
// Show unmatched transactions count
if (!empty($noMatches)) {
print '<br>';
print '<div class="info">';
print img_picto('', 'info', 'class="pictofixedwidth"');
print $langs->trans("UnmatchedTransactions", count($noMatches));
print ' <a href="'.dol_buildpath('/bankimport/list.php', 1).'?search_status=0">'.$langs->trans("ShowAll").'</a>';
print '</div>';
}
llxFooter();
$db->close();

View file

@ -0,0 +1,167 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
include_once DOL_DOCUMENT_ROOT.'/core/boxes/modules_boxes.php';
/**
* Dashboard widget showing pending bank transaction matches
*/
class box_bankimport_pending extends ModeleBoxes
{
public $boxcode = "bankimport_pending";
public $boximg = "fa-money-check-alt";
public $boxlabel = "BoxBankImportPending";
public $depends = array("bankimport");
/**
* Constructor
*
* @param DoliDB $db Database handler
* @param string $param More parameters
*/
public function __construct($db, $param = '')
{
global $user;
$this->db = $db;
$this->hidden = !$user->hasRight('bankimport', 'read');
}
/**
* Load data into info_box_contents array to show on dashboard
*
* @param int $max Maximum number of records to load
* @return void
*/
public function loadBox($max = 5)
{
global $user, $langs, $conf;
$langs->loadLangs(array("bankimport@bankimport", "banks"));
$this->max = $max;
// Box header
$this->info_box_head = array(
'text' => $langs->trans("BoxBankImportPending"),
'sublink' => dol_buildpath('/bankimport/confirm.php', 1).'?mainmenu=bank&leftmenu=bankimport',
'subtext' => $langs->trans("ReviewAndConfirm"),
'subpicto' => 'payment',
);
$line = 0;
$bankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (empty($bankAccountId)) {
// No bank account configured
$this->info_box_contents[$line][] = array(
'td' => 'class="center" colspan="4"',
'text' => img_warning().' '.$langs->trans("NoBankAccountConfigured"),
'url' => dol_buildpath('/bankimport/admin/setup.php', 1),
);
return;
}
// Count new (unmatched) transactions
$sql = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."bankimport_transaction";
$sql .= " WHERE entity IN (".getEntity('banktransaction').")";
$sql .= " AND status = 0";
$resql = $this->db->query($sql);
$newCount = 0;
if ($resql) {
$obj = $this->db->fetch_object($resql);
$newCount = (int) $obj->cnt;
}
if ($newCount > 0) {
// Summary line: X transactions pending
$this->info_box_contents[$line][] = array(
'td' => 'class="left" colspan="3"',
'text' => '<strong>'.$langs->trans("PendingPaymentMatches", $newCount).'</strong>',
'asis' => 1,
);
$this->info_box_contents[$line][] = array(
'td' => 'class="right"',
'text' => '<a class="butActionSmall" href="'.dol_buildpath('/bankimport/confirm.php', 1).'?mainmenu=bank&leftmenu=bankimport">'.$langs->trans("ReviewAndConfirm").'</a>',
'asis' => 1,
);
$line++;
// Show last few unmatched transactions
$sql2 = "SELECT t.rowid, t.date_trans, t.name, t.amount, t.currency";
$sql2 .= " FROM ".MAIN_DB_PREFIX."bankimport_transaction as t";
$sql2 .= " WHERE t.entity IN (".getEntity('banktransaction').")";
$sql2 .= " AND t.status = 0 AND t.amount > 0";
$sql2 .= " ORDER BY t.date_trans DESC";
$sql2 .= $this->db->plimit($max, 0);
$resql2 = $this->db->query($sql2);
if ($resql2) {
$num = $this->db->num_rows($resql2);
$i = 0;
while ($i < $num && $line < $max + 1) {
$obj2 = $this->db->fetch_object($resql2);
if (!$obj2) {
break;
}
// Date
$this->info_box_contents[$line][] = array(
'td' => 'class="nowraponall"',
'text' => dol_print_date($this->db->jdate($obj2->date_trans), 'day'),
);
// Name
$this->info_box_contents[$line][] = array(
'td' => 'class="tdoverflowmax150"',
'text' => dol_trunc($obj2->name, 28),
'url' => dol_buildpath('/bankimport/card.php', 1).'?id='.$obj2->rowid.'&mainmenu=bank&leftmenu=bankimport',
);
// Amount
$amountColor = $obj2->amount >= 0 ? 'color: green;' : 'color: red;';
$amountPrefix = $obj2->amount >= 0 ? '+' : '';
$this->info_box_contents[$line][] = array(
'td' => 'class="right nowraponall"',
'text' => '<span style="'.$amountColor.'">'.$amountPrefix.price($obj2->amount, 0, $langs, 1, -1, 2, $obj2->currency).'</span>',
'asis' => 1,
);
// Status badge
$this->info_box_contents[$line][] = array(
'td' => 'class="right"',
'text' => '<span class="badge badge-status4 badge-status">'.$langs->trans("New").'</span>',
'asis' => 1,
);
$line++;
$i++;
}
}
} else {
// No pending transactions
$this->info_box_contents[$line][] = array(
'td' => 'class="center opacitymedium" colspan="4"',
'text' => $langs->trans("NoNewMatchesFound"),
);
}
}
/**
* Render the box
*
* @param array|null $head Optional head array
* @param array|null $contents Optional contents array
* @param int $nooutput No print, return output
* @return string
*/
public function showBox($head = null, $contents = null, $nooutput = 0)
{
return parent::showBox($this->info_box_head, $this->info_box_contents, $nooutput);
}
}

View file

@ -0,0 +1,552 @@
<?php
/* Copyright (C) 2004-2018 Laurent Destailleur <eldy@users.sourceforge.net>
* Copyright (C) 2018-2019 Nicolas ZABOURI <info@inovea-conseil.com>
* Copyright (C) 2019-2024 Frédéric France <frederic.france@free.fr>
* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \defgroup bankimport Module BankImport
* \brief BankImport module descriptor.
*
* \file htdocs/bankimport/core/modules/modBankImport.class.php
* \ingroup bankimport
* \brief Description and activation file for module BankImport
*/
include_once DOL_DOCUMENT_ROOT.'/core/modules/DolibarrModules.class.php';
/**
* Description and activation class for module BankImport
*/
class modBankImport extends DolibarrModules
{
/**
* Constructor. Define names, constants, directories, boxes, permissions
*
* @param DoliDB $db Database handler
*/
public function __construct($db)
{
global $conf, $langs;
$this->db = $db;
// Id for module (must be unique).
// Use here a free id (See in Home -> System information -> Dolibarr for list of used modules id).
$this->numero = 500021; // TODO Go on page https://wiki.dolibarr.org/index.php/List_of_modules_id to reserve an id number for your module
// Key text used to identify module (for permissions, menus, etc...)
$this->rights_class = 'bankimport';
// Family can be 'base' (core modules),'crm','financial','hr','projects','products','ecm','technic' (transverse modules),'interface' (link with external tools),'other','...'
// It is used to group modules by family in module setup page
$this->family = "other";
// Module position in the family on 2 digits ('01', '10', '20', ...)
$this->module_position = '90';
// Gives the possibility for the module, to provide his own family info and position of this family (Overwrite $this->family and $this->module_position. Avoid this)
//$this->familyinfo = array('myownfamily' => array('position' => '01', 'label' => $langs->trans("MyOwnFamily")));
// Module label (no space allowed), used if translation string 'ModuleBankImportName' not found (BankImport is name of module).
$this->name = preg_replace('/^mod/i', '', get_class($this));
// DESCRIPTION_FLAG
// Module description, used if translation string 'ModuleBankImportDesc' not found (BankImport is name of module).
$this->description = "BankImportDescription";
// Used only if file README.md and README-LL.md not found.
$this->descriptionlong = "BankImportDescription";
// Author
$this->editor_name = 'Alles Watt laeuft';
$this->editor_url = ''; // Must be an external online web site
$this->editor_squarred_logo = ''; // Must be image filename into the module/img directory followed with @modulename. Example: 'myimage.png@bankimport'
// Possible values for version are: 'development', 'experimental', 'dolibarr', 'dolibarr_deprecated', 'experimental_deprecated' or a version string like 'x.y.z'
$this->version = '1.7';
// Url to the file with your last numberversion of this module
//$this->url_last_version = 'http://www.example.com/versionmodule.txt';
// Key used in llx_const table to save module status enabled/disabled (where BANKIMPORT is value of property name of module in uppercase)
$this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);
// Name of image file used for this module.
// If file is in theme/yourtheme/img directory under name object_pictovalue.png, use this->picto='pictovalue'
// If file is in module/img directory under name object_pictovalue.png, use this->picto='pictovalue@module'
// To use a supported fa-xxx css style of font awesome, use this->picto='xxx'
$this->picto = 'fa-money-check-alt';
// Define some features supported by module (triggers, login, substitutions, menus, css, etc...)
$this->module_parts = array(
// Set this to 1 if module has its own trigger directory (core/triggers)
'triggers' => 0,
// Set this to 1 if module has its own login method file (core/login)
'login' => 0,
// Set this to 1 if module has its own substitution function file (core/substitutions)
'substitutions' => 0,
// Set this to 1 if module has its own menus handler directory (core/menus)
'menus' => 0,
// Set this to 1 if module overwrite template dir (core/tpl)
'tpl' => 0,
// Set this to 1 if module has its own barcode directory (core/modules/barcode)
'barcode' => 0,
// Set this to 1 if module has its own models directory (core/modules/xxx)
'models' => 0,
// Set this to 1 if module has its own printing directory (core/modules/printing)
'printing' => 0,
// Set this to 1 if module has its own theme directory (theme)
'theme' => 0,
// Set this to relative path of css file if module has its own css file
'css' => array(
// '/bankimport/css/bankimport.css.php',
),
// Set this to relative path of js file if module must load a js on all pages
'js' => array(
'/bankimport/js/bankimport_notify.js.php',
),
// Set here all hooks context managed by module. To find available hook context, make a "grep -r '>initHooks(' *" on source code. You can also set hook context to 'all'
/* BEGIN MODULEBUILDER HOOKSCONTEXTS */
'hooks' => array(
// 'data' => array(
// 'hookcontext1',
// 'hookcontext2',
// ),
// 'entity' => '0',
),
/* END MODULEBUILDER HOOKSCONTEXTS */
// Set this to 1 if features of module are opened to external users
'moduleforexternal' => 0,
// Set this to 1 if the module provides a website template into doctemplates/websites/website_template-mytemplate
'websitetemplates' => 0,
// Set this to 1 if the module provides a captcha driver
'captcha' => 0
);
// Data directories to create when module is enabled.
// Example: this->dirs = array("/bankimport/temp","/bankimport/subdir");
$this->dirs = array("/bankimport/temp");
// Config pages. Put here list of php page, stored into bankimport/admin directory, to use to setup module.
$this->config_page_url = array("setup.php@bankimport");
// Dependencies
// A condition to hide module
$this->hidden = getDolGlobalInt('MODULE_BANKIMPORT_DISABLED'); // A condition to disable module;
// List of module class names that must be enabled if this module is enabled. Example: array('always'=>array('modModuleToEnable1','modModuleToEnable2'), 'FR'=>array('modModuleToEnableFR')...)
$this->depends = array();
// List of module class names to disable if this one is disabled. Example: array('modModuleToDisable1', ...)
$this->requiredby = array();
// List of module class names this module is in conflict with. Example: array('modModuleToDisable1', ...)
$this->conflictwith = array();
// The language file dedicated to your module
$this->langfiles = array("bankimport@bankimport");
// Prerequisites
$this->phpmin = array(7, 1); // Minimum version of PHP required by module
// $this->phpmax = array(8, 0); // Maximum version of PHP required by module
$this->need_dolibarr_version = array(19, -3); // Minimum version of Dolibarr required by module
// $this->max_dolibarr_version = array(19, -3); // Maximum version of Dolibarr required by module
$this->need_javascript_ajax = 0;
// Messages at activation
$this->warnings_activation = array(); // Warning to show when we activate module. array('always'='text') or array('FR'='textfr','MX'='textmx'...)
$this->warnings_activation_ext = array(); // Warning to show when we activate an external module. array('always'='text') or array('FR'='textfr','MX'='textmx'...)
//$this->automatic_activation = array('FR'=>'BankImportWasAutomaticallyActivatedBecauseOfYourCountryChoice');
//$this->always_enabled = true; // If true, can't be disabled
// Constants
// List of particular constants to add when module is enabled (key, 'chaine', value, desc, visible, 'current' or 'allentities', deleteonunactive)
// Example: $this->const=array(1 => array('BANKIMPORT_MYNEWCONST1', 'chaine', 'myvalue', 'This is a constant to add', 1),
// 2 => array('BANKIMPORT_MYNEWCONST2', 'chaine', 'myvalue', 'This is another constant to add', 0, 'current', 1)
// );
$this->const = array();
// Some keys to add into the overwriting translation tables
/*$this->overwrite_translation = array(
'en_US:ParentCompany'=>'Parent company or reseller',
'fr_FR:ParentCompany'=>'Maison mère ou revendeur'
)*/
if (!isModEnabled("bankimport")) {
$conf->bankimport = new stdClass();
$conf->bankimport->enabled = 0;
}
// Array to add new pages in new tabs
/* BEGIN MODULEBUILDER TABS */
$this->tabs = array();
/* END MODULEBUILDER TABS */
// Example:
// To add a new tab identified by code tabname1
// $this->tabs[] = array('data' => 'objecttype:+tabname1:Title1:mylangfile@bankimport:$user->hasRight(\'bankimport\', \'read\'):/bankimport/mynewtab1.php?id=__ID__');
// To add another new tab identified by code tabname2. Label will be result of calling all substitution functions on 'Title2' key.
// $this->tabs[] = array('data' => 'objecttype:+tabname2:SUBSTITUTION_Title2:mylangfile@bankimport:$user->hasRight(\'othermodule\', \'read\'):/bankimport/mynewtab2.php?id=__ID__',
// To remove an existing tab identified by code tabname
// $this->tabs[] = array('data' => 'objecttype:-tabname:NU:conditiontoremove');
//
// Where objecttype can be
// 'categories_x' to add a tab in category view (replace 'x' by type of category (0=product, 1=supplier, 2=customer, 3=member)
// 'contact' to add a tab in contact view
// 'contract' to add a tab in contract view
// 'delivery' to add a tab in delivery view
// 'group' to add a tab in group view
// 'intervention' to add a tab in intervention view
// 'invoice' to add a tab in customer invoice view
// 'supplier_invoice' to add a tab in supplier invoice view
// 'member' to add a tab in foundation member view
// 'opensurveypoll' to add a tab in opensurvey poll view
// 'order' to add a tab in sale order view
// 'supplier_order' to add a tab in supplier order view
// 'payment' to add a tab in payment view
// 'supplier_payment' to add a tab in supplier payment view
// 'product' to add a tab in product view
// 'propal' to add a tab in propal view
// 'project' to add a tab in project view
// 'stock' to add a tab in stock view
// 'thirdparty' to add a tab in third party view
// 'user' to add a tab in user view
// Dictionaries
/* Example:
$this->dictionaries=array(
'langs' => 'bankimport@bankimport',
// List of tables we want to see into dictionary editor
'tabname' => array("table1", "table2", "table3"),
// Label of tables
'tablib' => array("Table1", "Table2", "Table3"),
// Request to select fields
'tabsql' => array('SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table1 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table2 as f', 'SELECT f.rowid as rowid, f.code, f.label, f.active FROM '.$this->db->prefix().'table3 as f'),
// Sort order
'tabsqlsort' => array("label ASC", "label ASC", "label ASC"),
// List of fields (result of select to show dictionary)
'tabfield' => array("code,label", "code,label", "code,label"),
// List of fields (list of fields to edit a record)
'tabfieldvalue' => array("code,label", "code,label", "code,label"),
// List of fields (list of fields for insert)
'tabfieldinsert' => array("code,label", "code,label", "code,label"),
// Name of columns with primary key (try to always name it 'rowid')
'tabrowid' => array("rowid", "rowid", "rowid"),
// Condition to show each dictionary
'tabcond' => array(isModEnabled('bankimport'), isModEnabled('bankimport'), isModEnabled('bankimport')),
// Tooltip for every fields of dictionaries: DO NOT PUT AN EMPTY ARRAY
'tabhelp' => array(array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), array('code' => $langs->trans('CodeTooltipHelp'), 'field2' => 'field2tooltip'), ...),
);
*/
/* BEGIN MODULEBUILDER DICTIONARIES */
$this->dictionaries = array();
/* END MODULEBUILDER DICTIONARIES */
// Boxes/Widgets
// Add here list of php file(s) stored in bankimport/core/boxes that contains a class to show a widget.
/* BEGIN MODULEBUILDER WIDGETS */
$this->boxes = array(
0 => array(
'file' => 'box_bankimport_pending.php@bankimport',
'note' => 'Pending bank transaction matches',
'enabledbydefaulton' => 'Home',
),
);
/* END MODULEBUILDER WIDGETS */
// Cronjobs (List of cron jobs entries to add when module is enabled)
// unit_frequency must be 60 for minute, 3600 for hour, 86400 for day, 604800 for week
/* BEGIN MODULEBUILDER CRON */
$this->cronjobs = array(
0 => array(
'label' => 'BankImportAutoFetch',
'jobtype' => 'method',
'class' => '/bankimport/class/bankimportcron.class.php',
'objectname' => 'BankImportCron',
'method' => 'doAutoImport',
'parameters' => '',
'comment' => 'Automatic bank statement import via FinTS',
'frequency' => 1,
'unitfrequency' => 86400, // Daily
'status' => 0, // Disabled by default
'test' => 'isModEnabled("bankimport") && getDolGlobalInt("BANKIMPORT_AUTO_ENABLED")',
'priority' => 50,
),
);
/* END MODULEBUILDER CRON */
// Example: $this->cronjobs=array(
// 0=>array('label'=>'My label', 'jobtype'=>'method', 'class'=>'/dir/class/file.class.php', 'objectname'=>'MyClass', 'method'=>'myMethod', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>2, 'unitfrequency'=>3600, 'status'=>0, 'test'=>'isModEnabled("bankimport")', 'priority'=>50),
// 1=>array('label'=>'My label', 'jobtype'=>'command', 'command'=>'', 'parameters'=>'param1, param2', 'comment'=>'Comment', 'frequency'=>1, 'unitfrequency'=>3600*24, 'status'=>0, 'test'=>'isModEnabled("bankimport")', 'priority'=>50)
// );
// Permissions provided by this module
$this->rights = array();
$r = 0;
// $user->hasRight('bankimport', 'read')
$this->rights[$r][0] = $this->numero . '01';
$this->rights[$r][1] = 'PermBankImportRead';
$this->rights[$r][2] = 'r';
$this->rights[$r][3] = 1; // Default enabled
$this->rights[$r][4] = 'read';
$r++;
// $user->hasRight('bankimport', 'write')
$this->rights[$r][0] = $this->numero . '02';
$this->rights[$r][1] = 'PermBankImportWrite';
$this->rights[$r][2] = 'w';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'write';
$r++;
// $user->hasRight('bankimport', 'delete')
$this->rights[$r][0] = $this->numero . '03';
$this->rights[$r][1] = 'PermBankImportDelete';
$this->rights[$r][2] = 'd';
$this->rights[$r][3] = 0;
$this->rights[$r][4] = 'delete';
$r++;
// Main menu entries to add
$this->menu = array();
$r = 0;
// Left menu entries under "Banken und Kasse" (mainmenu=bank)
$r = 0;
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=bank',
'type' => 'left',
'titre' => 'BankImportMenu',
'prefix' => img_picto('', 'download', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'bank',
'leftmenu' => 'bankimport',
'url' => '/bankimport/bankimportindex.php?mainmenu=bank&leftmenu=bankimport',
'langs' => 'bankimport@bankimport',
'position' => 200,
'enabled' => 'isModEnabled("bankimport")',
'perms' => '$user->hasRight("bankimport", "read")',
'target' => '',
'user' => 2,
);
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport',
'type' => 'left',
'titre' => 'BankStatements',
'prefix' => img_picto('', 'bank_account', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'bank',
'leftmenu' => 'bankimport_statements',
'url' => '/bankimport/statements.php?mainmenu=bank&leftmenu=bankimport',
'langs' => 'bankimport@bankimport',
'position' => 201,
'enabled' => 'isModEnabled("bankimport")',
'perms' => '$user->hasRight("bankimport", "write")',
'target' => '',
'user' => 2,
);
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport',
'type' => 'left',
'titre' => 'TransactionList',
'prefix' => img_picto('', 'list', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'bank',
'leftmenu' => 'bankimport_transactions',
'url' => '/bankimport/list.php?mainmenu=bank&leftmenu=bankimport',
'langs' => 'bankimport@bankimport',
'position' => 202,
'enabled' => 'isModEnabled("bankimport")',
'perms' => '$user->hasRight("bankimport", "read")',
'target' => '',
'user' => 2,
);
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport',
'type' => 'left',
'titre' => 'PaymentConfirmation',
'prefix' => img_picto('', 'payment', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'bank',
'leftmenu' => 'bankimport_confirm',
'url' => '/bankimport/confirm.php?mainmenu=bank&leftmenu=bankimport',
'langs' => 'bankimport@bankimport',
'position' => 203,
'enabled' => 'isModEnabled("bankimport")',
'perms' => '$user->hasRight("bankimport", "write")',
'target' => '',
'user' => 2,
);
$this->menu[$r++] = array(
'fk_menu' => 'fk_mainmenu=bank,fk_leftmenu=bankimport',
'type' => 'left',
'titre' => 'PDFStatements',
'prefix' => img_picto('', 'pdf', 'class="pictofixedwidth valignmiddle paddingright"'),
'mainmenu' => 'bank',
'leftmenu' => 'bankimport_pdfstatements',
'url' => '/bankimport/pdfstatements.php?mainmenu=bank&leftmenu=bankimport',
'langs' => 'bankimport@bankimport',
'position' => 204,
'enabled' => 'isModEnabled("bankimport")',
'perms' => '$user->hasRight("bankimport", "read")',
'target' => '',
'user' => 2,
);
// Exports profiles provided by this module
$r = 0;
/* BEGIN MODULEBUILDER EXPORT MYOBJECT */
/*
$langs->load("bankimport@bankimport");
$this->export_code[$r] = $this->rights_class.'_'.$r;
$this->export_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found)
$this->export_icon[$r] = $this->picto;
// Define $this->export_fields_array, $this->export_TypeFields_array and $this->export_entities_array
$keyforclass = 'MyObject'; $keyforclassfile='/bankimport/class/myobject.class.php'; $keyforelement='myobject@bankimport';
include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php';
//$this->export_fields_array[$r]['t.fieldtoadd']='FieldToAdd'; $this->export_TypeFields_array[$r]['t.fieldtoadd']='Text';
//unset($this->export_fields_array[$r]['t.fieldtoremove']);
//$keyforclass = 'MyObjectLine'; $keyforclassfile='/bankimport/class/myobject.class.php'; $keyforelement='myobjectline@bankimport'; $keyforalias='tl';
//include DOL_DOCUMENT_ROOT.'/core/commonfieldsinexport.inc.php';
$keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@bankimport';
include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php';
//$keyforselect='myobjectline'; $keyforaliasextra='extraline'; $keyforelement='myobjectline@bankimport';
//include DOL_DOCUMENT_ROOT.'/core/extrafieldsinexport.inc.php';
//$this->export_dependencies_array[$r] = array('myobjectline' => array('tl.rowid','tl.ref')); // To force to activate one or several fields if we select some fields that need same (like to select a unique key if we ask a field of a child to avoid the DISTINCT to discard them, or for computed field than need several other fields)
//$this->export_special_array[$r] = array('t.field' => '...');
//$this->export_examplevalues_array[$r] = array('t.field' => 'Example');
//$this->export_help_array[$r] = array('t.field' => 'FieldDescHelp');
$this->export_sql_start[$r]='SELECT DISTINCT ';
$this->export_sql_end[$r] =' FROM '.$this->db->prefix().'bankimport_myobject as t';
//$this->export_sql_end[$r] .=' LEFT JOIN '.$this->db->prefix().'bankimport_myobject_line as tl ON tl.fk_myobject = t.rowid';
$this->export_sql_end[$r] .=' WHERE 1 = 1';
$this->export_sql_end[$r] .=' AND t.entity IN ('.getEntity('myobject').')';
$r++; */
/* END MODULEBUILDER EXPORT MYOBJECT */
// Imports profiles provided by this module
$r = 0;
/* BEGIN MODULEBUILDER IMPORT MYOBJECT */
/*
$langs->load("bankimport@bankimport");
$this->import_code[$r] = $this->rights_class.'_'.$r;
$this->import_label[$r] = 'MyObjectLines'; // Translation key (used only if key ExportDataset_xxx_z not found)
$this->import_icon[$r] = $this->picto;
$this->import_tables_array[$r] = array('t' => $this->db->prefix().'bankimport_myobject', 'extra' => $this->db->prefix().'bankimport_myobject_extrafields');
$this->import_tables_creator_array[$r] = array('t' => 'fk_user_author'); // Fields to store import user id
$import_sample = array();
$keyforclass = 'MyObject'; $keyforclassfile='/bankimport/class/myobject.class.php'; $keyforelement='myobject@bankimport';
include DOL_DOCUMENT_ROOT.'/core/commonfieldsinimport.inc.php';
$import_extrafield_sample = array();
$keyforselect='myobject'; $keyforaliasextra='extra'; $keyforelement='myobject@bankimport';
include DOL_DOCUMENT_ROOT.'/core/extrafieldsinimport.inc.php';
$this->import_fieldshidden_array[$r] = array('extra.fk_object' => 'lastrowid-'.$this->db->prefix().'bankimport_myobject');
$this->import_regex_array[$r] = array();
$this->import_examplevalues_array[$r] = array_merge($import_sample, $import_extrafield_sample);
$this->import_updatekeys_array[$r] = array('t.ref' => 'Ref');
$this->import_convertvalue_array[$r] = array(
't.ref' => array(
'rule'=>'getrefifauto',
'class'=>(!getDolGlobalString('BANKIMPORT_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('BANKIMPORT_MYOBJECT_ADDON')),
'path'=>"/core/modules/bankimport/".(!getDolGlobalString('BANKIMPORT_MYOBJECT_ADDON') ? 'mod_myobject_standard' : getDolGlobalString('BANKIMPORT_MYOBJECT_ADDON')).'.php',
'classobject'=>'MyObject',
'pathobject'=>'/bankimport/class/myobject.class.php',
),
't.fk_soc' => array('rule' => 'fetchidfromref', 'file' => '/societe/class/societe.class.php', 'class' => 'Societe', 'method' => 'fetch', 'element' => 'ThirdParty'),
't.fk_user_valid' => array('rule' => 'fetchidfromref', 'file' => '/user/class/user.class.php', 'class' => 'User', 'method' => 'fetch', 'element' => 'user'),
't.fk_mode_reglement' => array('rule' => 'fetchidfromcodeorlabel', 'file' => '/compta/paiement/class/cpaiement.class.php', 'class' => 'Cpaiement', 'method' => 'fetch', 'element' => 'cpayment'),
);
$this->import_run_sql_after_array[$r] = array();
$r++; */
/* END MODULEBUILDER IMPORT MYOBJECT */
}
/**
* Function called when module is enabled.
* The init function add constants, boxes, permissions and menus (defined in constructor) into Dolibarr database.
* It also creates data directories
*
* @param string $options Options when enabling module ('', 'noboxes')
* @return int<-1,1> 1 if OK, <=0 if KO
*/
public function init($options = '')
{
global $conf, $langs;
// Create tables of module at module activation
//$result = $this->_load_tables('/install/mysql/', 'bankimport');
$result = $this->_load_tables('/bankimport/sql/');
if ($result < 0) {
return -1; // Do not activate module if error 'not allowed' returned when loading module SQL queries (the _load_table run sql with run_sql with the error allowed parameter set to 'default')
}
// Create extrafields during init
//include_once DOL_DOCUMENT_ROOT.'/core/class/extrafields.class.php';
//$extrafields = new ExtraFields($this->db);
//$result0=$extrafields->addExtraField('bankimport_separator1', "Separator 1", 'separator', 1, 0, 'thirdparty', 0, 0, '', array('options'=>array(1=>1)), 1, '', 1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")');
//$result1=$extrafields->addExtraField('bankimport_myattr1', "New Attr 1 label", 'boolean', 1, 3, 'thirdparty', 0, 0, '', '', 1, '', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")');
//$result2=$extrafields->addExtraField('bankimport_myattr2', "New Attr 2 label", 'varchar', 1, 10, 'project', 0, 0, '', '', 1, '', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")');
//$result3=$extrafields->addExtraField('bankimport_myattr3', "New Attr 3 label", 'varchar', 1, 10, 'bank_account', 0, 0, '', '', 1, '', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")');
//$result4=$extrafields->addExtraField('bankimport_myattr4', "New Attr 4 label", 'select', 1, 3, 'thirdparty', 0, 1, '', array('options'=>array('code1'=>'Val1','code2'=>'Val2','code3'=>'Val3')), 1,'', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")');
//$result5=$extrafields->addExtraField('bankimport_myattr5', "New Attr 5 label", 'text', 1, 10, 'user', 0, 0, '', '', 1, '', -1, 0, '', '', 'bankimport@bankimport', 'isModEnabled("bankimport")');
// Permissions
$this->remove($options);
$sql = array();
// Document templates
$moduledir = dol_sanitizeFileName('bankimport');
$myTmpObjects = array();
$myTmpObjects['MyObject'] = array('includerefgeneration' => 0, 'includedocgeneration' => 0);
foreach ($myTmpObjects as $myTmpObjectKey => $myTmpObjectArray) {
if ($myTmpObjectArray['includerefgeneration']) {
$src = DOL_DOCUMENT_ROOT.'/install/doctemplates/'.$moduledir.'/template_myobjects.odt';
$dirodt = DOL_DATA_ROOT.($conf->entity > 1 ? '/'.$conf->entity : '').'/doctemplates/'.$moduledir;
$dest = $dirodt.'/template_myobjects.odt';
if (file_exists($src) && !file_exists($dest)) {
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
dol_mkdir($dirodt);
$result = dol_copy($src, $dest, '0', 0);
if ($result < 0) {
$langs->load("errors");
$this->error = $langs->trans('ErrorFailToCopyFile', $src, $dest);
return 0;
}
}
$sql = array_merge($sql, array(
"DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'standard_".strtolower($myTmpObjectKey)."' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity),
"INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('standard_".strtolower($myTmpObjectKey)."', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")",
"DELETE FROM ".$this->db->prefix()."document_model WHERE nom = 'generic_".strtolower($myTmpObjectKey)."_odt' AND type = '".$this->db->escape(strtolower($myTmpObjectKey))."' AND entity = ".((int) $conf->entity),
"INSERT INTO ".$this->db->prefix()."document_model (nom, type, entity) VALUES('generic_".strtolower($myTmpObjectKey)."_odt', '".$this->db->escape(strtolower($myTmpObjectKey))."', ".((int) $conf->entity).")"
));
}
}
return $this->_init($sql, $options);
}
/**
* Function called when module is disabled.
* Remove from database constants, boxes and permissions from Dolibarr database.
* Data directories are not deleted
*
* @param string $options Options when enabling module ('', 'noboxes')
* @return int<-1,1> 1 if OK, <=0 if KO
*/
public function remove($options = '')
{
$sql = array();
return $this->_remove($sql, $options);
}
}

14
img/README.md Executable file
View file

@ -0,0 +1,14 @@
Directory for module image files
--------------------------------
You can put here the .png files of your module:
If the picto of your module is an image (property $picto has been set to 'bankimport.png@bankimport', you can put into this
directory a .png file called *object_bankimport.png* (16x16 or 32x32 pixels)
If the picto of an object is an image (property $picto of the object.class.php has been set to 'myobject.png@bankimport', then you can put into this
directory a .png file called *object_myobject.png* (16x16 or 32x32 pixels)

174
js/bankimport_notify.js.php Executable file
View file

@ -0,0 +1,174 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
/**
* JavaScript for browser push notifications about incoming payments
* Loaded on every Dolibarr page via module_parts['js']
*/
// Define MIME type
if (!defined('NOTOKENRENEWAL')) {
define('NOTOKENRENEWAL', '1');
}
if (!defined('NOREQUIREMENU')) {
define('NOREQUIREMENU', '1');
}
if (!defined('NOREQUIREHTML')) {
define('NOREQUIREHTML', '1');
}
if (!defined('NOREQUIREAJAX')) {
define('NOREQUIREAJAX', '1');
}
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res && file_exists("../../../main.inc.php")) {
$res = @include "../../../main.inc.php";
}
header('Content-Type: application/javascript; charset=UTF-8');
header('Cache-Control: max-age=3600');
if (!isModEnabled('bankimport') || empty($user->id) || !$user->hasRight('bankimport', 'read')) {
echo '/* bankimport: no access */';
exit;
}
$checkUrl = dol_buildpath('/bankimport/ajax/checkpending.php', 1);
$confirmUrl = dol_buildpath('/bankimport/confirm.php', 1).'?mainmenu=bank&leftmenu=bankimport';
$checkInterval = 5 * 60 * 1000; // 5 Minuten
?>
(function() {
'use strict';
var STORAGE_KEY = 'bankimport_last_pending';
var CHECK_URL = <?php echo json_encode($checkUrl); ?>;
var CONFIRM_URL = <?php echo json_encode($confirmUrl); ?>;
var CHECK_INTERVAL = <?php echo $checkInterval; ?>;
// Erst nach Seitenload starten
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
// Berechtigung anfragen beim ersten Mal
if ('Notification' in window && Notification.permission === 'default') {
// Dezent um Berechtigung bitten - nicht sofort, sondern nach 10 Sekunden
setTimeout(function() {
Notification.requestPermission();
}, 10000);
}
// Sofort prüfen
checkPending();
// Regelmäßig prüfen
setInterval(checkPending, CHECK_INTERVAL);
}
function checkPending() {
var xhr = new XMLHttpRequest();
xhr.open('GET', CHECK_URL, true);
xhr.timeout = 15000;
xhr.onload = function() {
if (xhr.status !== 200) return;
try {
var data = JSON.parse(xhr.responseText);
} catch(e) {
return;
}
var lastKnown = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
var currentPending = data.pending || 0;
var incoming = data.incoming || 0;
var incomingTotal = data.incoming_total || 0;
// Neue Buchungen seit letztem Check?
if (currentPending > lastKnown && currentPending > 0) {
var newCount = currentPending - lastKnown;
if (incoming > 0) {
showNotification(
'Zahlungseingang',
incoming + ' Zahlungseingang' + (incoming > 1 ? 'e' : '') + ' (' + formatAmount(incomingTotal) + ' €)\nBestätigung erforderlich',
'incoming'
);
} else {
showNotification(
'Bankimport',
newCount + ' neue Buchung' + (newCount > 1 ? 'en' : '') + ' warten auf Zuordnung',
'pending'
);
}
}
// Aktuellen Stand merken
localStorage.setItem(STORAGE_KEY, currentPending.toString());
};
xhr.onerror = function() {};
xhr.send();
}
function showNotification(title, body, type) {
if (!('Notification' in window)) return;
if (Notification.permission !== 'granted') return;
var icon = type === 'incoming'
? '/theme/common/mime/money.png'
: '/theme/common/mime/doc.png';
var notification = new Notification(title, {
body: body,
icon: icon,
tag: 'bankimport-' + type,
requireInteraction: true
});
notification.onclick = function() {
window.focus();
window.location.href = CONFIRM_URL;
notification.close();
};
// Nach 30 Sekunden automatisch schließen
setTimeout(function() {
notification.close();
}, 30000);
}
function formatAmount(amount) {
return parseFloat(amount).toFixed(2).replace('.', ',').replace(/\B(?=(\d{3})+(?!\d))/g, '.');
}
})();

356
langs/de_DE/bankimport.lang Executable file
View file

@ -0,0 +1,356 @@
# Übersetzungsdatei - Deutsch
#
# Allgemein
#
# Modulname 'ModuleBankImportName'
ModuleBankImportName = Bankimport
# Modulbeschreibung 'ModuleBankImportDesc'
ModuleBankImportDesc = Kontoauszüge per FinTS/HBCI importieren
#
# Admin-Seite
#
BankImportSetup = Bankimport Einstellungen
Settings = Einstellungen
BankImportSetupPage = Bankimport Einstellungen
BankImportSetupDescription = Konfigurieren Sie die FinTS/HBCI-Verbindung zu Ihrer Bank für den automatischen Kontoauszugsimport.
# FinTS Konfiguration
FinTSConfiguration = FinTS Bankverbindung
FinTSServerURL = FinTS Server URL
FinTSServerURLHelp = Für VR-Banken/Volksbanken: https://fints1.atruvia.de/cgi-bin/hbciservlet
BLZ = Bankleitzahl (BLZ)
BLZHelp = 8-stellige Bankleitzahl
FinTSUsername = Benutzerkennung
FinTSPIN = PIN
PINAlreadySet = PIN ist konfiguriert
PINHelp = Die PIN wird verschlüsselt gespeichert. Leer lassen um bestehende PIN zu behalten.
AccountIBAN = Kontonummer / IBAN
FinTSProductID = FinTS Produkt-ID
FinTSProductIDHelp = Optional. Registrierungsnummer von der Deutschen Kreditwirtschaft.
TestConnection = Verbindung testen
ConnectionTestSuccessful = Verbindungstest erfolgreich!
ConnectionTestFailed = Verbindungstest fehlgeschlagen
FinTSClassNotFound = FinTS-Klasse nicht gefunden. Installation prüfen.
FinTSNotConfigured = FinTS-Verbindung nicht konfiguriert
FinTSLibraryNotFound = phpFinTS Bibliothek nicht gefunden. Bitte führen Sie composer install aus.
GoToSetup = Zur Einrichtung
FetchAvailableAccounts = Verfügbare Konten abrufen
AccountsFound = %s Konten gefunden
NoAccountsFound = Keine Konten gefunden
TANRequiredForAccountList = TAN erforderlich - Kontoabruf ohne TAN nicht möglich
AccountNumber = Kontonummer
UseThisAccount = Dieses Konto verwenden
Selected = Ausgewählt
IBANUpdated = IBAN aktualisiert: %s
# VR-Bank Info
VRBankInfo = VR-Bank / Volksbank Information
VRBankInfoText = Für VR-Banken verwenden Sie den Atruvia-Server: https://fints1.atruvia.de/cgi-bin/hbciservlet<br>Das TAN-Verfahren SecureGo Plus (Decoupled TAN) wird unterstützt.
# Sicherheit
SecurityInfo = Sicherheitshinweis
SecurityInfoText = Die PIN wird verschlüsselt in der Datenbank gespeichert. Für zusätzliche Sicherheit sollte Ihre Dolibarr-Installation HTTPS verwenden.
#
# Kontoauszüge Seite
#
BankStatements = Kontoauszüge
FetchStatements = Kontoauszüge abrufen
Transactions = Buchungen
TransactionsFound = %s Buchungen gefunden
NoTransactionsFound = Keine Buchungen im ausgewählten Zeitraum gefunden
AccountBalance = Kontostand
AsOf = Stand vom
LoginFailed = Anmeldung fehlgeschlagen
FetchFailed = Abruf fehlgeschlagen
TANRequired = TAN erforderlich
TANRequiredForStatements = TAN für Kontoauszüge erforderlich
SecureGoPlusConfirmRequired = Bitte bestätigen Sie in Ihrer VR SecureGo plus App
WaitingForSecureGoConfirmation = Warte auf Bestätigung in der SecureGo plus App...
CheckSecureGoStatus = Status prüfen
TANCheckFailed = TAN-Prüfung fehlgeschlagen
# Import
ImportTransactions = Buchungen importieren
ViewImportedTransactions = Importierte Buchungen anzeigen
TransactionsImported = %s Buchungen importiert
TransactionsSkipped = %s Buchungen übersprungen (bereits vorhanden)
#
# Transaktionsliste
#
TransactionList = Buchungsliste
TransactionRef = Referenz
Counterparty = Gegenkonto
AllStatuses = Alle Status
StatusNew = Neu
StatusMatched = Zugeordnet
StatusReconciled = Abgeglichen
StatusIgnored = Ignoriert
SearchTransactions = Buchungen durchsuchen
NoTransactionsInDatabase = Keine Buchungen in der Datenbank
# Status Labels
NewTransaction = Neue Buchung
TransactionMatched = Zugeordnet
TransactionReconciled = Abgeglichen
TransactionIgnored = Ignoriert
New = Neu
Matched = Zugeordnet
Reconciled = Abgeglichen
Ignored = Ignoriert
# Card page
Transaction = Buchung
DateValue = Valutadatum
CounterpartyIBAN = Gegenkonto IBAN
EndToEndId = End-to-End ID
MandateId = Mandatsreferenz
FindMatches = Zuordnungen suchen
SetAsIgnored = Als ignoriert markieren
Reopen = Wieder öffnen
MatchesFound = %s mögliche Zuordnungen gefunden
NoMatchesFound = Keine Zuordnungen gefunden
Link = Verknüpfen
LinkCreated = Verknüpfung erstellt
StatusUpdated = Status aktualisiert
BackToList = Zurück zur Liste
Score = Übereinstimmung
DateDue = Fälligkeitsdatum
MatchReason = Grund
# Match Reasons
MatchByRef = Rechnungsnr.
MatchByClientRef = Kundenreferenz
MatchBySupplierRef = Lieferantenreferenz
MatchByAmount = Betrag
MatchByAmountClose = Betrag ähnlich
MatchByNameExact = Name
MatchByNameSimilar = Name ähnlich
MatchByIBAN = IBAN
MatchByMultiInvoice = Sammelzahlung
# Automatic Import
AutomaticImport = Automatischer Import
EnableAutoImport = Automatischen Import aktivieren
EnableAutoImportHelp = Buchungen werden automatisch per Cronjob abgerufen
ImportFrequency = Abruf-Häufigkeit
Daily = Täglich
Weekly = Wöchentlich
TwiceWeekly = Zweimal pro Woche
DaysToFetch = Tage abrufen
DaysToFetchHelp = Max. 60 Tage (Bank-Limit beachten)
LastAutoFetch = Letzter automatischer Abruf
NeverFetched = Noch nie abgerufen
MoreThan7Days = Mehr als 7 Tage her
CronJobDescription = Automatischer Import von Bankbuchungen via FinTS
AutoImportSuccess = %s Buchungen automatisch importiert
AutoImportNoTransactions = Keine neuen Buchungen gefunden
AutoImportError = Automatischer Import fehlgeschlagen
AutoImportDisabled = Automatischer Import ist deaktiviert
AutoImportNotConfigured = FinTS ist nicht konfiguriert
AutoImportTANRequired = Automatischer Import benötigt TAN-Bestätigung
AutoImportTANRequiredDesc = Der automatische Import benötigt eine TAN-Bestätigung. Bitte bestätigen Sie in der SecureGo Plus App.
AutoImportErrorDesc = Beim letzten automatischen Import am %s ist ein Fehler aufgetreten.
TANConfirmedImportComplete = TAN bestätigt - Import abgeschlossen
LastFetchWarning = Letzter Abruf am %s - Bitte regelmäßig neue Buchungen abrufen
BankImportAutoFetch = Automatischer Bankimport
# Errors
HistoricalDataLimit = Die Bank stellt historische Kontodaten nur für ca. 2-3 Monate bereit.
SelectShorterPeriod = Bitte wählen Sie einen kürzeren Zeitraum.
OlderDataNotAvailable = Ältere Daten vor %s sind bei der Bank nicht mehr verfügbar.
PartialResultsReturned = Es konnten nur neuere Transaktionen abgerufen werden.
# PDF Statements
PDFStatements = PDF-Kontoauszüge
FetchPDFStatements = PDF-Kontoauszüge abrufen
StatementNumber = Auszugsnummer
StatementYear = Jahr
StatementDate = Auszugsdatum
DownloadPDF = PDF herunterladen
NoPDFStatementsFound = Keine PDF-Kontoauszüge gefunden
PDFStatementsImported = %s Kontoauszüge importiert
StatementAlreadyExists = Kontoauszug bereits vorhanden
DeleteStatement = Kontoauszug löschen
ConfirmDeleteStatement = Möchten Sie den Kontoauszug %s wirklich löschen?
StatementsInYear = Kontoauszüge im Jahr %s
AllStatements = Alle Kontoauszüge
OpeningBalance = Anfangssaldo
ClosingBalance = Endsaldo
StatementUploaded = Kontoauszug erfolgreich hochgeladen
#
# Über-Seite
#
About = Über
BankImportAbout = Über Bankimport
BankImportAboutPage = Bankimport Info-Seite
#
# Startseite / Dashboard
#
BankImportArea = Bankimport Übersicht
LastImportedTransactions = Letzte importierte Buchungen
LastPDFStatements = Letzte PDF-Kontoauszüge
ShowAll = Alle anzeigen
UploadNew = Neuen hochladen
BankImportMenu = Bankimport
#
# PDF Kontoauszüge Seite
#
PDFStatementsInfo = PDF-Kontoauszüge
PDFStatementsInfoDesc = Hier können Sie Ihre PDF-Kontoauszüge hochladen, verwalten und einsehen. Die Auszüge werden sicher gespeichert und können jederzeit heruntergeladen werden.
UploadPDFStatement = PDF-Kontoauszüge hochladen
#
# Upload-Modus
#
UploadMode = Upload-Modus
UploadModeAuto = Automatisch erkennen
UploadModeManual = Manuelle Eingabe
PdfAutoDetected = PDF-Metadaten automatisch erkannt
ErrorNoFileUploaded = Keine Datei hochgeladen
ErrorOnlyPDFAllowed = Nur PDF-Dateien sind erlaubt
ErrorFileTooLarge = Datei ist zu groß (max. 10 MB)
ErrorFailedToSaveFile = Datei konnte nicht gespeichert werden
TransactionsLinked = %s Buchungen dem Kontoauszug zugeordnet
StatementsUploaded = %s Kontoauszüge erfolgreich hochgeladen
MultipleFilesHint = Sie können mehrere PDF-Dateien gleichzeitig auswählen (Strg+Klick oder Shift+Klick)
#
# Admin - PDF Upload
#
PDFUploadSettings = PDF-Upload Einstellungen
DefaultUploadMode = Standard Upload-Modus
DefaultUploadModeHelp = Automatisch: Metadaten werden aus dem PDF extrahiert. Manuell: Alle Felder müssen von Hand ausgefüllt werden.
ReminderEnabled = Erinnerung aktivieren
ReminderEnabledHelp = Zeigt eine Warnung wenn Kontoauszüge nicht aktuell sind
ReminderMonths = Erinnerung nach (Monate)
ReminderMonthsHelp = Warnung anzeigen, wenn der letzte Kontoauszug älter als X Monate ist
ReminderNoStatements = Es wurden noch keine Kontoauszüge hochgeladen. Bitte laden Sie Ihre Kontoauszüge hoch.
ReminderOutdatedStatements = Der letzte Kontoauszug endet am %s (vor %s Monaten). Bitte laden Sie aktuelle Kontoauszüge hoch.
#
# Kontoauszug-Verknüpfung
#
PDFStatement = PDF-Kontoauszug
ViewPDFStatement = PDF-Kontoauszug anzeigen
#
# Berechtigungen
#
PermBankImportRead = Bankimport: Buchungen und Kontoauszüge ansehen
PermBankImportWrite = Bankimport: Kontoauszüge abrufen und PDF hochladen
PermBankImportDelete = Bankimport: Buchungen und Kontoauszüge löschen
#
# Zahlungsabgleich
#
PaymentConfirmation = Zahlungsabgleich
PaymentConfirmationDesc = Bankbuchungen mit Rechnungen abgleichen und Zahlungen in Dolibarr erstellen.
PendingPaymentMatches = %s neue Bankbuchungen warten auf Zuordnung
PendingPaymentMatchesDesc = Prüfen und bestätigen Sie die Zuordnungen, um Zahlungen in Dolibarr zu erstellen.
ReviewAndConfirm = Zuordnungen prüfen
ConfirmPayment = Zahlung bestätigen
ConfirmAllHighScore = Alle sicheren Zuordnungen bestätigen (Score >= 80%%)
PaymentCreatedSuccessfully = Zahlung erstellt: %s - %s
PaymentCreatedByBankImport = Automatisch erstellt durch Bankimport
ErrorNoBankAccountConfigured = Kein Dolibarr-Bankkonto konfiguriert. Bitte in den Einstellungen zuordnen.
NoBankAccountConfigured = Es ist noch kein Dolibarr-Bankkonto zugeordnet.
TransactionAlreadyProcessed = Buchung wurde bereits verarbeitet
PaymentsCreatedSummary = %s Zahlungen erstellt, %s fehlgeschlagen
NoNewMatchesFound = Keine neuen Zuordnungen gefunden
Alternatives = weitere Zuordnung(en)
UnmatchedTransactions = %s neue Buchungen ohne Rechnungszuordnung
InvoiceAlreadyPaid = Rechnung ist bereits vollständig bezahlt
NoInvoicesSelected = Keine Rechnungen ausgewählt
Invoices = Rechnungen
SearchInvoiceManually = Rechnung manuell suchen
SearchInvoiceByRef = Rechnungsnummer, Lieferantenzeichen oder Firma...
InvoiceRef = Rechnungsnummer
CustomerInvoice = Kundenrechnung
SupplierInvoice = Lieferantenrechnung
SupplierRef = Lieferantenreferenz
ManualSearch = Manuelle Suche
All = Alle
SelectInvoicesManually = Rechnungen manuell auswählen
SupplierInvoices = Lieferantenrechnungen
CustomerInvoices = Kundenrechnungen
Filter = Filtern
RemoveFilter = Filter entfernen
TransactionAmount = Transaktionsbetrag
Selected = Ausgewählt
AmountRemaining = Restbetrag
NoUnpaidInvoices = Keine offenen Rechnungen gefunden
CustomerRef = Kundenreferenz
ShowPaidInvoices = Auch bezahlte anzeigen
PaidInvoices = Bezahlte Rechnungen
PaidInvoicesInfo = Diese Rechnungen wurden bereits bezahlt. Klicken Sie auf "Zahlung verknüpfen" um die existierende Zahlung mit der Bankbuchung zu verbinden.
Paid = Bezahlt
LinkExistingPayment = Zahlung verknüpfen
PaymentLinkedSuccessfully = Zahlung erfolgreich verknüpft
NoPaymentFoundForInvoice = Keine Zahlung für diese Rechnung gefunden
#
# Bankkonto-Zuordnung
#
BankAccountMapping = Dolibarr-Bankkonto Zuordnung
DolibarrBankAccount = Dolibarr Bankkonto
DolibarrBankAccountHelp = Wählen Sie das Dolibarr-Bankkonto, das der konfigurierten IBAN entspricht. Zahlungen werden auf dieses Konto gebucht.
SelectBankAccount = -- Bankkonto auswählen --
#
# Kontoauszugsabgleich
#
BankEntriesReconciled = %s Bankbuchungen mit Auszug %s abgeglichen
BankEntriesReconciledTotal = %s Bankbuchungen über %s Kontoauszüge abgeglichen
ReconcileStatement = Kontoauszug abgleichen
ReconcileAllStatements = Alle Kontoauszüge abgleichen
NoBankEntriesToReconcile = Keine offenen Bankbuchungen zum Abgleichen gefunden
StatementMissingDates = Kontoauszug hat keinen Zeitraum (Von/Bis) - Abgleich nicht möglich
#
# Kontoauszugs-Positionen (Statement Lines)
#
StatementLinesExtracted = %s Buchungszeilen aus Auszug %s extrahiert
StatementLines = Buchungszeilen
BookingDate = Buchungstag
ValueDate = Wertstellung
TransactionType = Vorgangsart
NoStatementLines = Keine Buchungszeilen vorhanden
#
# Ausstehende Zuordnungen (Pending Review)
#
PendingReconciliationMatches = Zuordnungen mit Betragsabweichung
PendingReconciliationMatchesDesc = Diese Zuordnungen wurden über Rechnungsnummern erkannt, aber der Betrag weicht um mehr als 5 EUR ab. Bitte prüfen und bestätigen.
AmountStatement = Betrag (Auszug)
AmountDolibarr = Betrag (Dolibarr)
Difference = Differenz
BankEntry = Bank-Eintrag
ReconciliationConfirmed = Zuordnung bestätigt und abgeglichen
Confirm = Bestätigen
#
# Dashboard-Widget
#
BoxBankImportPending = Bankimport - Offene Zuordnungen
#
# Unlink/Edit Payment
#
UnlinkPayment = Verknüpfung aufheben
PaymentUnlinked = Verknüpfung wurde aufgehoben - Buchung ist wieder offen
CannotUnlinkThisStatus = Verknüpfung kann bei diesem Status nicht aufgehoben werden
#
# Linked Objects Display
#
Payment = Zahlung
LinkedInvoices = Verknüpfte Rechnungen
NoInvoicesLinkedToPayment = Keine Rechnungen mit dieser Zahlung verknüpft

252
langs/en_US/bankimport.lang Executable file
View file

@ -0,0 +1,252 @@
# Translation file
#
# Generic
#
# Module label 'ModuleBankImportName'
ModuleBankImportName = BankImport
# Module description 'ModuleBankImportDesc'
ModuleBankImportDesc = Import bank statements via FinTS/HBCI
#
# Admin page
#
BankImportSetup = BankImport Setup
Settings = Settings
BankImportSetupPage = BankImport setup page
BankImportSetupDescription = Configure the FinTS/HBCI connection to your bank for automatic statement import.
# FinTS Configuration
FinTSConfiguration = FinTS Bank Connection
FinTSServerURL = FinTS Server URL
FinTSServerURLHelp = For VR-Banks/Volksbanken: https://fints1.atruvia.de/cgi-bin/hbciservlet
BLZ = Bank Code (BLZ)
BLZHelp = 8-digit German bank code
FinTSUsername = User ID
FinTSPIN = PIN
PINAlreadySet = PIN is configured
PINHelp = PIN will be stored encrypted. Leave empty to keep existing PIN.
AccountIBAN = Account Number / IBAN
FinTSProductID = FinTS Product ID
FinTSProductIDHelp = Optional. Registration number from Deutsche Kreditwirtschaft.
TestConnection = Test Connection
ConnectionTestSuccessful = Connection test successful!
ConnectionTestFailed = Connection test failed
FinTSClassNotFound = FinTS class not found. Please check installation.
FinTSNotConfigured = FinTS connection not configured
FinTSLibraryNotFound = phpFinTS library not found. Please run composer install.
GoToSetup = Go to setup
FetchAvailableAccounts = Fetch Available Accounts
AccountsFound = %s accounts found
NoAccountsFound = No accounts found
TANRequiredForAccountList = TAN required - account list not available without TAN
AccountNumber = Account Number
UseThisAccount = Use This Account
Selected = Selected
IBANUpdated = IBAN updated: %s
# VR Bank Info
VRBankInfo = VR-Bank / Volksbank Information
VRBankInfoText = For VR-Banks use the Atruvia server: https://fints1.atruvia.de/cgi-bin/hbciservlet<br>The TAN procedure SecureGo Plus (Decoupled TAN) is supported.
# Security
SecurityInfo = Security Information
SecurityInfoText = The PIN is stored encrypted in the database. For additional security, ensure your Dolibarr installation uses HTTPS.
#
# Statements page
#
BankStatements = Bank Statements
FetchStatements = Fetch Statements
Transactions = Transactions
TransactionsFound = %s transactions found
NoTransactionsFound = No transactions found in the selected period
AccountBalance = Account Balance
AsOf = As of
LoginFailed = Login failed
FetchFailed = Failed to fetch statements
TANRequired = TAN required
TANRequiredForStatements = TAN required to fetch statements
SecureGoPlusConfirmRequired = Please confirm in your VR SecureGo plus App
WaitingForSecureGoConfirmation = Waiting for confirmation in SecureGo plus App...
CheckSecureGoStatus = Check Status
TANCheckFailed = TAN verification failed
# Import
ImportTransactions = Import Transactions
ViewImportedTransactions = View Imported Transactions
TransactionsImported = %s transactions imported
TransactionsSkipped = %s transactions skipped (already exist)
#
# Transaction List
#
TransactionList = Transaction List
TransactionRef = Reference
Counterparty = Counterparty
AllStatuses = All Statuses
StatusNew = New
StatusMatched = Matched
StatusReconciled = Reconciled
StatusIgnored = Ignored
SearchTransactions = Search Transactions
NoTransactionsInDatabase = No transactions in database
# Status Labels
NewTransaction = New Transaction
TransactionMatched = Transaction Matched
TransactionReconciled = Transaction Reconciled
TransactionIgnored = Transaction Ignored
New = New
Matched = Matched
Reconciled = Reconciled
Ignored = Ignored
# Card page
Transaction = Transaction
DateValue = Value Date
CounterpartyIBAN = Counterparty IBAN
EndToEndId = End-to-End ID
MandateId = Mandate Reference
FindMatches = Find Matches
SetAsIgnored = Set as Ignored
Reopen = Reopen
MatchesFound = %s possible matches found
NoMatchesFound = No matches found
Link = Link
LinkCreated = Link created
StatusUpdated = Status updated
BackToList = Back to List
Score = Match Score
# PDF Statements
PDFStatements = PDF Statements
FetchPDFStatements = Fetch PDF Statements
StatementNumber = Statement Number
StatementYear = Year
StatementDate = Statement Date
DownloadPDF = Download PDF
NoPDFStatementsFound = No PDF statements found
PDFStatementsImported = %s statements imported
StatementAlreadyExists = Statement already exists
#
# About page
#
About = About
BankImportAbout = About BankImport
BankImportAboutPage = BankImport about page
#
# Home
#
BankImportArea = BankImport Home
#
# Payment Confirmation
#
PaymentConfirmation = Payment Matching
PaymentConfirmationDesc = Match bank transactions to invoices and create payments in Dolibarr.
PendingPaymentMatches = %s new bank transactions waiting for matching
PendingPaymentMatchesDesc = Review and confirm matches to create payments in Dolibarr.
ReviewAndConfirm = Review matches
ConfirmPayment = Confirm payment
ConfirmAllHighScore = Confirm all high-score matches (score >= 80%%)
PaymentCreatedSuccessfully = Payment created: %s - %s
PaymentCreatedByBankImport = Automatically created by BankImport
ErrorNoBankAccountConfigured = No Dolibarr bank account configured. Please configure in settings.
NoBankAccountConfigured = No Dolibarr bank account mapped yet.
TransactionAlreadyProcessed = Transaction already processed
PaymentsCreatedSummary = %s payments created, %s failed
NoNewMatchesFound = No new matches found
Alternatives = more match(es)
UnmatchedTransactions = %s new transactions without invoice match
InvoiceAlreadyPaid = Invoice is already fully paid
NoInvoicesSelected = No invoices selected
Invoices = Invoices
MatchByMultiInvoice = Multi-invoice payment
SearchInvoiceManually = Search invoice manually
SearchInvoiceByRef = Enter invoice reference...
InvoiceRef = Invoice reference
CustomerInvoice = Customer invoice
SupplierInvoice = Supplier invoice
SupplierRef = Supplier reference
ManualSearch = Manual search
All = All
SelectInvoicesManually = Select invoices manually
SupplierInvoices = Supplier invoices
CustomerInvoices = Customer invoices
Filter = Filter
RemoveFilter = Remove filter
TransactionAmount = Transaction amount
Selected = Selected
AmountRemaining = Remaining amount
NoUnpaidInvoices = No unpaid invoices found
CustomerRef = Customer reference
ShowPaidInvoices = Show paid invoices
PaidInvoices = Paid Invoices
PaidInvoicesInfo = These invoices have already been paid. Click "Link Payment" to connect the existing payment with the bank entry.
Paid = Paid
LinkExistingPayment = Link Payment
PaymentLinkedSuccessfully = Payment linked successfully
NoPaymentFoundForInvoice = No payment found for this invoice
#
# Bank Account Mapping
#
BankAccountMapping = Dolibarr Bank Account Mapping
DolibarrBankAccount = Dolibarr Bank Account
DolibarrBankAccountHelp = Select the Dolibarr bank account corresponding to the configured IBAN. Payments will be booked to this account.
SelectBankAccount = -- Select bank account --
#
# Statement Reconciliation
#
BankEntriesReconciled = %s bank entries reconciled with statement %s
BankEntriesReconciledTotal = %s bank entries reconciled across %s statements
ReconcileStatement = Reconcile statement
ReconcileAllStatements = Reconcile all statements
NoBankEntriesToReconcile = No open bank entries found to reconcile
StatementMissingDates = Statement has no date range (from/to) - reconciliation not possible
#
# Statement Lines
#
StatementLinesExtracted = %s transaction lines extracted from statement %s
StatementLines = Transaction Lines
BookingDate = Booking Date
ValueDate = Value Date
TransactionType = Transaction Type
NoStatementLines = No transaction lines available
#
# Pending Review Matches
#
PendingReconciliationMatches = Matches with Amount Deviation
PendingReconciliationMatchesDesc = These matches were found via invoice numbers but the amount differs by more than 5 EUR. Please review and confirm.
AmountStatement = Amount (Statement)
AmountDolibarr = Amount (Dolibarr)
Difference = Difference
BankEntry = Bank Entry
ReconciliationConfirmed = Match confirmed and reconciled
Confirm = Confirm
#
# Dashboard Widget
#
BoxBankImportPending = BankImport - Pending Matches
#
# Unlink/Edit Payment
#
UnlinkPayment = Unlink Payment
PaymentUnlinked = Payment link removed - transaction is now open again
CannotUnlinkThisStatus = Cannot unlink with this status
#
# Linked Objects Display
#
Payment = Payment
LinkedInvoices = Linked Invoices
NoInvoicesLinkedToPayment = No invoices linked to this payment

85
lib/bankimport.lib.php Executable file
View file

@ -0,0 +1,85 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \file bankimport/lib/bankimport.lib.php
* \ingroup bankimport
* \brief Library files with common functions for BankImport
*/
/**
* Prepare admin pages header
*
* @return array<array{string,string,string}>
*/
function bankimportAdminPrepareHead()
{
global $langs, $conf;
// global $db;
// $extrafields = new ExtraFields($db);
// $extrafields->fetch_name_optionals_label('myobject');
$langs->load("bankimport@bankimport");
$h = 0;
$head = array();
$head[$h][0] = dol_buildpath("/bankimport/admin/setup.php", 1);
$head[$h][1] = $langs->trans("Settings");
$head[$h][2] = 'settings';
$h++;
/*
$head[$h][0] = dol_buildpath("/bankimport/admin/myobject_extrafields.php", 1);
$head[$h][1] = $langs->trans("ExtraFields");
$nbExtrafields = (isset($extrafields->attributes['myobject']['label']) && is_countable($extrafields->attributes['myobject']['label'])) ? count($extrafields->attributes['myobject']['label']) : 0;
if ($nbExtrafields > 0) {
$head[$h][1] .= '<span class="badge marginleftonlyshort">' . $nbExtrafields . '</span>';
}
$head[$h][2] = 'myobject_extrafields';
$h++;
$head[$h][0] = dol_buildpath("/bankimport/admin/myobjectline_extrafields.php", 1);
$head[$h][1] = $langs->trans("ExtraFieldsLines");
$nbExtrafields = (isset($extrafields->attributes['myobjectline']['label']) && is_countable($extrafields->attributes['myobjectline']['label'])) ? count($extrafields->attributes['myobject']['label']) : 0;
if ($nbExtrafields > 0) {
$head[$h][1] .= '<span class="badge marginleftonlyshort">' . $nbExtrafields . '</span>';
}
$head[$h][2] = 'myobject_extrafieldsline';
$h++;
*/
$head[$h][0] = dol_buildpath("/bankimport/admin/about.php", 1);
$head[$h][1] = $langs->trans("About");
$head[$h][2] = 'about';
$h++;
// Show more tabs from modules
// Entries must be declared in modules descriptor with line
//$this->tabs = array(
// 'entity:+tabname:Title:@bankimport:/bankimport/mypage.php?id=__ID__'
//); // to add new tab
//$this->tabs = array(
// 'entity:-tabname:Title:@bankimport:/bankimport/mypage.php?id=__ID__'
//); // to remove a tab
complete_head_from_modules($conf, $langs, null, $head, $h, 'bankimport@bankimport');
complete_head_from_modules($conf, $langs, null, $head, $h, 'bankimport@bankimport', 'remove');
return $head;
}

329
list.php Executable file
View file

@ -0,0 +1,329 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
/**
* \file bankimport/list.php
* \ingroup bankimport
* \brief List page for imported bank transactions
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php';
dol_include_once('/bankimport/class/banktransaction.class.php');
dol_include_once('/bankimport/lib/bankimport.lib.php');
/**
* @var Conf $conf
* @var DoliDB $db
* @var Translate $langs
* @var User $user
*/
$langs->loadLangs(array("bankimport@bankimport", "banks", "bills"));
$action = GETPOST('action', 'aZ09');
$massaction = GETPOST('massaction', 'alpha');
$confirm = GETPOST('confirm', 'alpha');
$toselect = GETPOST('toselect', 'array');
$contextpage = GETPOST('contextpage', 'aZ') ? GETPOST('contextpage', 'aZ') : 'bankimporttransactionlist';
// Security check
if (!$user->hasRight('bankimport', 'read')) {
accessforbidden();
}
// Search parameters
$search_ref = GETPOST('search_ref', 'alpha');
$search_iban = GETPOST('search_iban', 'alpha');
$search_name = GETPOST('search_name', 'alpha');
$search_description = GETPOST('search_description', 'alpha');
$search_amount_min = GETPOST('search_amount_min', 'alpha');
$search_amount_max = GETPOST('search_amount_max', 'alpha');
$search_status = GETPOST('search_status', 'intcomma');
// Date filter
$search_date_from = dol_mktime(0, 0, 0, GETPOSTINT('search_date_frommonth'), GETPOSTINT('search_date_fromday'), GETPOSTINT('search_date_fromyear'));
$search_date_to = dol_mktime(23, 59, 59, GETPOSTINT('search_date_tomonth'), GETPOSTINT('search_date_today'), GETPOSTINT('search_date_toyear'));
// Sorting
$sortfield = GETPOST('sortfield', 'aZ09comma');
$sortorder = GETPOST('sortorder', 'aZ09comma');
if (!$sortfield) $sortfield = 'date_trans';
if (!$sortorder) $sortorder = 'DESC';
// Pagination
$limit = GETPOSTINT('limit') ? GETPOSTINT('limit') : $conf->liste_limit;
$page = GETPOSTISSET('pageplusone') ? (GETPOSTINT('pageplusone') - 1) : GETPOSTINT('page');
if (empty($page) || $page < 0) $page = 0;
$offset = $limit * $page;
/*
* Actions
*/
// Reset search
if (GETPOST('button_removefilter_x', 'alpha') || GETPOST('button_removefilter.x', 'alpha') || GETPOST('button_removefilter', 'alpha')) {
$search_ref = '';
$search_iban = '';
$search_name = '';
$search_description = '';
$search_amount_min = '';
$search_amount_max = '';
$search_status = '';
$search_date_from = '';
$search_date_to = '';
$toselect = array();
}
/*
* View
*/
$form = new Form($db);
$transaction = new BankImportTransaction($db);
$title = $langs->trans("TransactionList");
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-list');
print load_fiche_titre($title, '', 'bank');
// Build filter array
$filter = array();
if (!empty($search_ref)) $filter['ref'] = $search_ref;
if (!empty($search_iban)) $filter['iban'] = $search_iban;
if (!empty($search_name)) $filter['name'] = $search_name;
if (!empty($search_description)) $filter['description'] = $search_description;
if (!empty($search_amount_min)) $filter['amount_min'] = price2num($search_amount_min);
if (!empty($search_amount_max)) $filter['amount_max'] = price2num($search_amount_max);
if ($search_status !== '' && $search_status >= 0) $filter['status'] = (int) $search_status;
if (!empty($search_date_from)) $filter['date_from'] = $search_date_from;
if (!empty($search_date_to)) $filter['date_to'] = $search_date_to;
// Count total
$totalRecords = $transaction->fetchAll('', '', 0, 0, $filter, 'count');
// Fetch records
$records = $transaction->fetchAll($sortfield, $sortorder, $limit, $offset, $filter);
// Form
$param = '';
if (!empty($search_ref)) $param .= '&search_ref='.urlencode($search_ref);
if (!empty($search_iban)) $param .= '&search_iban='.urlencode($search_iban);
if (!empty($search_name)) $param .= '&search_name='.urlencode($search_name);
if (!empty($search_description)) $param .= '&search_description='.urlencode($search_description);
if (!empty($search_amount_min)) $param .= '&search_amount_min='.urlencode($search_amount_min);
if (!empty($search_amount_max)) $param .= '&search_amount_max='.urlencode($search_amount_max);
if ($search_status !== '' && $search_status >= 0) $param .= '&search_status='.urlencode($search_status);
if (!empty($search_date_from)) {
$param .= '&search_date_fromday='.dol_print_date($search_date_from, '%d');
$param .= '&search_date_frommonth='.dol_print_date($search_date_from, '%m');
$param .= '&search_date_fromyear='.dol_print_date($search_date_from, '%Y');
}
if (!empty($search_date_to)) {
$param .= '&search_date_today='.dol_print_date($search_date_to, '%d');
$param .= '&search_date_tomonth='.dol_print_date($search_date_to, '%m');
$param .= '&search_date_toyear='.dol_print_date($search_date_to, '%Y');
}
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'" name="formulaire">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="sortfield" value="'.$sortfield.'">';
print '<input type="hidden" name="sortorder" value="'.$sortorder.'">';
// Navigation
print_barre_liste($title, $page, $_SERVER["PHP_SELF"], $param, $sortfield, $sortorder, '', is_array($records) ? count($records) : 0, $totalRecords, '', 0, '', '', $limit);
print '<div class="div-table-responsive">';
print '<table class="tagtable liste listwithfilterbefore centpercent">';
// Header row with filters
print '<tr class="liste_titre_filter">';
// Ref
print '<td class="liste_titre">';
print '<input type="text" class="flat maxwidth75" name="search_ref" value="'.dol_escape_htmltag($search_ref).'">';
print '</td>';
// IBAN
print '<td class="liste_titre">';
print '<input type="text" class="flat maxwidth100" name="search_iban" value="'.dol_escape_htmltag($search_iban).'">';
print '</td>';
// Date
print '<td class="liste_titre center">';
print '<div class="nowraponall">';
print $form->selectDate($search_date_from, 'search_date_from', 0, 0, 1, '', 1, 0, 0, '', '', '', '', 1, '', $langs->trans("From"));
print '</div>';
print '<div class="nowraponall">';
print $form->selectDate($search_date_to, 'search_date_to', 0, 0, 1, '', 1, 0, 0, '', '', '', '', 1, '', $langs->trans("To"));
print '</div>';
print '</td>';
// Name
print '<td class="liste_titre">';
print '<input type="text" class="flat maxwidth150" name="search_name" value="'.dol_escape_htmltag($search_name).'">';
print '</td>';
// Description
print '<td class="liste_titre">';
print '<input type="text" class="flat maxwidth200" name="search_description" value="'.dol_escape_htmltag($search_description).'">';
print '</td>';
// Amount
print '<td class="liste_titre right">';
print '<input type="text" class="flat maxwidth50" name="search_amount_min" placeholder="Min" value="'.dol_escape_htmltag($search_amount_min).'">';
print ' - ';
print '<input type="text" class="flat maxwidth50" name="search_amount_max" placeholder="Max" value="'.dol_escape_htmltag($search_amount_max).'">';
print '</td>';
// Statement
print '<td class="liste_titre">';
print '</td>';
// Status
print '<td class="liste_titre center">';
$statusArray = array(
'' => $langs->trans("AllStatuses"),
'0' => $langs->trans("StatusNew"),
'1' => $langs->trans("StatusMatched"),
'2' => $langs->trans("StatusReconciled"),
'9' => $langs->trans("StatusIgnored")
);
print $form->selectarray('search_status', $statusArray, $search_status, 0, 0, 0, '', 0, 0, 0, '', 'minwidth100');
print '</td>';
// Action buttons
print '<td class="liste_titre center">';
print '<input type="image" class="liste_titre" src="'.img_picto('', 'search.png', '', 0, 1).'" name="button_search" value="'.dol_escape_htmltag($langs->trans("Search")).'" title="'.dol_escape_htmltag($langs->trans("Search")).'">';
print '&nbsp;';
print '<input type="image" class="liste_titre" src="'.img_picto('', 'searchclear.png', '', 0, 1).'" name="button_removefilter" value="'.dol_escape_htmltag($langs->trans("RemoveFilter")).'" title="'.dol_escape_htmltag($langs->trans("RemoveFilter")).'">';
print '</td>';
print '</tr>';
// Header titles
print '<tr class="liste_titre">';
print_liste_field_titre($langs->trans("TransactionRef"), $_SERVER["PHP_SELF"], "ref", "", $param, "", $sortfield, $sortorder);
print_liste_field_titre($langs->trans("AccountIBAN"), $_SERVER["PHP_SELF"], "iban", "", $param, "", $sortfield, $sortorder);
print_liste_field_titre($langs->trans("Date"), $_SERVER["PHP_SELF"], "date_trans", "", $param, 'class="center"', $sortfield, $sortorder);
print_liste_field_titre($langs->trans("Counterparty"), $_SERVER["PHP_SELF"], "name", "", $param, "", $sortfield, $sortorder);
print_liste_field_titre($langs->trans("Description"), $_SERVER["PHP_SELF"], "description", "", $param, "", $sortfield, $sortorder);
print_liste_field_titre($langs->trans("Amount"), $_SERVER["PHP_SELF"], "amount", "", $param, 'class="right"', $sortfield, $sortorder);
print_liste_field_titre($langs->trans("PDFStatement"), $_SERVER["PHP_SELF"], "fk_statement", "", $param, 'class="center"', $sortfield, $sortorder);
print_liste_field_titre($langs->trans("Status"), $_SERVER["PHP_SELF"], "status", "", $param, 'class="center"', $sortfield, $sortorder);
print_liste_field_titre('', $_SERVER["PHP_SELF"], "", "", $param, 'class="center"', $sortfield, $sortorder);
print '</tr>';
// Data rows
if (is_array($records) && count($records) > 0) {
foreach ($records as $obj) {
print '<tr class="oddeven">';
// Ref
print '<td class="nowraponall">';
print '<a href="'.dol_buildpath('/bankimport/card.php', 1).'?id='.$obj->id.'">'.dol_escape_htmltag($obj->ref).'</a>';
print '</td>';
// IBAN
print '<td class="nowraponall">';
print dol_escape_htmltag(dol_trunc($obj->iban, 20));
print '</td>';
// Date
print '<td class="center nowraponall">';
print dol_print_date($obj->date_trans, 'day');
print '</td>';
// Name
print '<td>';
print dol_escape_htmltag($obj->name);
print '</td>';
// Description
print '<td class="small">';
print dol_escape_htmltag(dol_trunc($obj->description, 60));
print '</td>';
// Amount
print '<td class="right nowraponall">';
if ($obj->amount >= 0) {
print '<span style="color: green;">+'.price($obj->amount, 0, $langs, 1, -1, 2, $obj->currency).'</span>';
} else {
print '<span style="color: red;">'.price($obj->amount, 0, $langs, 1, -1, 2, $obj->currency).'</span>';
}
print '</td>';
// Statement link
print '<td class="center nowraponall">';
if (!empty($obj->fk_statement)) {
print '<a href="'.dol_buildpath('/bankimport/pdfstatements.php', 1).'?action=view&id='.$obj->fk_statement.'&token='.newToken().'" target="_blank" title="'.$langs->trans("ViewPDFStatement").'">';
print img_picto($langs->trans("ViewPDFStatement"), 'pdf');
print '</a>';
}
print '</td>';
// Status
print '<td class="center">';
print $obj->getLibStatut(5);
print '</td>';
// Actions
print '<td class="center nowraponall">';
print '<a href="'.dol_buildpath('/bankimport/card.php', 1).'?id='.$obj->id.'">'.img_edit().'</a>';
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="9" class="opacitymedium center">';
print $langs->trans("NoTransactionsInDatabase");
print '</td></tr>';
}
print '</table>';
print '</div>';
print '</form>';
// Buttons
print '<div class="tabsAction">';
print '<a class="butAction" href="'.dol_buildpath('/bankimport/statements.php', 1).'">'.$langs->trans("FetchStatements").'</a>';
print '</div>';
llxFooter();
$db->close();

3
modulebuilder.txt Executable file
View file

@ -0,0 +1,3 @@
# DO NOT DELETE THIS FILE MANUALLY
# File to flag module built using official module template.
# When this file is present into a module directory, you can edit it with the module builder tool.

955
pdfstatements.php Executable file
View file

@ -0,0 +1,955 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
/**
* \file bankimport/pdfstatements.php
* \ingroup bankimport
* \brief Page to upload and manage PDF bank statements
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php';
dol_include_once('/bankimport/class/bankstatement.class.php');
dol_include_once('/bankimport/lib/bankimport.lib.php');
/**
* @var Conf $conf
* @var DoliDB $db
* @var Translate $langs
* @var User $user
*/
$langs->loadLangs(array("bankimport@bankimport", "banks", "other"));
$action = GETPOST('action', 'aZ09');
$confirm = GETPOST('confirm', 'alpha');
$year = GETPOSTISSET('year') ? GETPOSTINT('year') : (int) date('Y');
// Security check
if (!$user->hasRight('bankimport', 'read')) {
accessforbidden();
}
/*
* Actions
*/
$statement = new BankImportStatement($db);
// Upload PDF (supports multiple files)
if ($action == 'upload' && !empty($_FILES['pdffile'])) {
$uploadMode = GETPOST('upload_mode', 'alpha');
$isAutoMode = ($uploadMode !== 'manual');
// Normalize $_FILES for multi-upload: always work with arrays
$fileNames = is_array($_FILES['pdffile']['name']) ? $_FILES['pdffile']['name'] : array($_FILES['pdffile']['name']);
$fileTmps = is_array($_FILES['pdffile']['tmp_name']) ? $_FILES['pdffile']['tmp_name'] : array($_FILES['pdffile']['tmp_name']);
$fileSizes = is_array($_FILES['pdffile']['size']) ? $_FILES['pdffile']['size'] : array($_FILES['pdffile']['size']);
$fileCount = count($fileNames);
$uploadedCount = 0;
$errorCount = 0;
$totalLinked = 0;
$lastYear = (int) date('Y');
for ($fi = 0; $fi < $fileCount; $fi++) {
$error = 0;
// Skip empty file slots
if (empty($fileNames[$fi]) || empty($fileTmps[$fi])) {
continue;
}
// Validate uploaded file
if (!is_uploaded_file($fileTmps[$fi])) {
setEventMessages($langs->trans("ErrorNoFileUploaded").': '.$fileNames[$fi], null, 'errors');
$errorCount++;
continue;
}
// Check MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $fileTmps[$fi]);
finfo_close($finfo);
if ($mimeType !== 'application/pdf') {
setEventMessages($langs->trans("ErrorOnlyPDFAllowed").': '.$fileNames[$fi], null, 'errors');
$errorCount++;
continue;
}
// Check file size (max 10MB)
if ($fileSizes[$fi] > 10 * 1024 * 1024) {
setEventMessages($langs->trans("ErrorFileTooLarge").': '.$fileNames[$fi], null, 'errors');
$errorCount++;
continue;
}
// Parse PDF metadata automatically
$parsed = BankImportStatement::parsePdfMetadata($fileTmps[$fi]);
// Determine values: auto mode uses parsed data, manual mode uses form fields
if ($isAutoMode && $parsed) {
$statementNumber = $parsed['statement_number'];
$statementYear = $parsed['statement_year'];
$iban = $parsed['iban'];
} else {
// Manual mode (only for single file upload)
$statementNumber = GETPOST('statement_number', 'alpha');
$statementYear = GETPOSTINT('statement_year');
$iban = GETPOST('iban', 'alpha');
// Auto-fill from parsed data if form fields are empty
if ($parsed) {
if (empty($statementNumber) && !empty($parsed['statement_number'])) {
$statementNumber = $parsed['statement_number'];
}
if (empty($statementYear) && !empty($parsed['statement_year'])) {
$statementYear = $parsed['statement_year'];
}
if (empty($iban) && !empty($parsed['iban'])) {
$iban = $parsed['iban'];
}
}
}
// Show auto-detection info
if ($parsed) {
$autoMsg = $langs->trans("PdfAutoDetected").': '.$fileNames[$fi];
if (!empty($statementNumber)) {
$autoMsg .= ' | '.$statementNumber.'/'.$statementYear;
}
if (!empty($parsed['pdf_number'])) {
$autoMsg .= ' (PDF-Nr. '.$parsed['pdf_number'].'/'.$parsed['pdf_year'].')';
}
if (!empty($parsed['iban'])) {
$autoMsg .= ' | IBAN: '.$parsed['iban'];
}
if ($parsed['date_from'] && $parsed['date_to']) {
$autoMsg .= ' | '.$langs->trans("Period").': '.dol_print_date($parsed['date_from'], 'day').' - '.dol_print_date($parsed['date_to'], 'day');
}
setEventMessages($autoMsg, null, 'mesgs');
}
// Validate required fields
if (empty($statementNumber)) {
setEventMessages($langs->trans("ErrorFieldRequired", $langs->transnoentities("StatementNumber")).': '.$fileNames[$fi], null, 'errors');
$errorCount++;
continue;
}
if (empty($statementYear)) {
setEventMessages($langs->trans("ErrorFieldRequired", $langs->transnoentities("Year")).': '.$fileNames[$fi], null, 'errors');
$errorCount++;
continue;
}
// Create new statement object for each file
$stmt = new BankImportStatement($db);
$stmt->iban = $iban;
$stmt->statement_number = $statementNumber;
$stmt->statement_year = $statementYear;
// Date fields
if ($isAutoMode && $parsed) {
$stmt->statement_date = $parsed['statement_date'];
$stmt->date_from = $parsed['date_from'];
$stmt->date_to = $parsed['date_to'];
$stmt->opening_balance = $parsed['opening_balance'];
$stmt->closing_balance = $parsed['closing_balance'];
} else {
$statementDate = dol_mktime(0, 0, 0, GETPOSTINT('statement_datemonth'), GETPOSTINT('statement_dateday'), GETPOSTINT('statement_dateyear'));
$dateFrom = dol_mktime(0, 0, 0, GETPOSTINT('date_frommonth'), GETPOSTINT('date_fromday'), GETPOSTINT('date_fromyear'));
$dateTo = dol_mktime(0, 0, 0, GETPOSTINT('date_tomonth'), GETPOSTINT('date_today'), GETPOSTINT('date_toyear'));
$stmt->statement_date = $statementDate ?: ($parsed ? $parsed['statement_date'] : null);
$stmt->date_from = $dateFrom ?: ($parsed ? $parsed['date_from'] : null);
$stmt->date_to = $dateTo ?: ($parsed ? $parsed['date_to'] : null);
$openBal = GETPOST('opening_balance', 'alpha');
$closeBal = GETPOST('closing_balance', 'alpha');
$stmt->opening_balance = ($openBal !== '' && $openBal !== null) ? (float) price2num($openBal) : ($parsed ? $parsed['opening_balance'] : null);
$stmt->closing_balance = ($closeBal !== '' && $closeBal !== null) ? (float) price2num($closeBal) : ($parsed ? $parsed['closing_balance'] : null);
}
$stmt->import_key = date('YmdHis').'_'.$user->id;
// Check duplicate
if ($stmt->exists()) {
setEventMessages($langs->trans("StatementAlreadyExists").': '.$statementNumber.'/'.$statementYear, null, 'warnings');
$errorCount++;
continue;
}
// Generate filename and save file
$dir = BankImportStatement::getStorageDir();
if ($parsed) {
$newFilename = BankImportStatement::generateFilename($parsed);
} else {
$ibanPart = !empty($stmt->iban) ? preg_replace('/[^A-Z0-9]/', '', strtoupper($stmt->iban)) : 'KONTO';
$newFilename = sprintf('Kontoauszug_%s_%d_%s.pdf',
$ibanPart,
$stmt->statement_year,
str_pad($stmt->statement_number, 3, '0', STR_PAD_LEFT)
);
}
$stmt->filepath = $dir.'/'.$newFilename;
// Avoid overwriting existing files
if (file_exists($stmt->filepath)) {
$newFilename = pathinfo($newFilename, PATHINFO_FILENAME).'_'.date('His').'.pdf';
$stmt->filepath = $dir.'/'.$newFilename;
}
$stmt->filename = $newFilename;
// Move uploaded file
if (!move_uploaded_file($fileTmps[$fi], $stmt->filepath)) {
setEventMessages($langs->trans("ErrorFailedToSaveFile").': '.$fileNames[$fi], null, 'errors');
$errorCount++;
continue;
}
$stmt->filesize = filesize($stmt->filepath);
// Save to database
$result = $stmt->create($user);
if ($result > 0) {
// Link matching FinTS transactions to this statement
$linked = $stmt->linkTransactions();
$totalLinked += max(0, $linked);
// Parse PDF transaction lines and save to database
$pdfLines = $stmt->parsePdfTransactions();
if (!empty($pdfLines)) {
$linesSaved = $stmt->saveStatementLines($pdfLines);
if ($linesSaved > 0) {
setEventMessages($langs->trans("StatementLinesExtracted", $linesSaved, $stmt->statement_number.'/'.$stmt->statement_year), null, 'mesgs');
}
}
// Copy PDF to Dolibarr's bank statement document directory
$uploadBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if ($uploadBankAccountId > 0) {
$stmt->copyToDolibarrStatementDir($uploadBankAccountId);
}
// Reconcile bank entries if bank account is configured
if ($uploadBankAccountId > 0) {
$reconciledCount = $stmt->reconcileBankEntries($user, $uploadBankAccountId);
if ($reconciledCount > 0) {
setEventMessages($langs->trans("BankEntriesReconciled", $reconciledCount, $stmt->statement_number.'/'.$stmt->statement_year), null, 'mesgs');
}
}
$uploadedCount++;
$lastYear = $stmt->statement_year;
} else {
setEventMessages($stmt->error, null, 'errors');
$errorCount++;
// Clean up file on DB error
if (file_exists($stmt->filepath)) {
@unlink($stmt->filepath);
}
}
}
// Summary message
if ($uploadedCount > 0) {
if ($uploadedCount == 1) {
$msg = $langs->trans("StatementUploaded");
} else {
$msg = $langs->trans("StatementsUploaded", $uploadedCount);
}
if ($totalLinked > 0) {
$msg .= ' | '.$langs->trans("TransactionsLinked", $totalLinked);
}
setEventMessages($msg, null, 'mesgs');
// Redirect: for single upload use the year, for multi-upload show all
if ($uploadedCount == 1) {
header("Location: ".$_SERVER['PHP_SELF']."?year=".$lastYear);
} else {
header("Location: ".$_SERVER['PHP_SELF']."?year=0");
}
exit;
}
}
// Download PDF
if ($action == 'download') {
$id = GETPOSTINT('id');
if ($statement->fetch($id) > 0) {
$filepath = $statement->getFilePath();
if ($filepath && file_exists($filepath)) {
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="'.basename($statement->filename).'"');
header('Content-Length: '.filesize($filepath));
header('Cache-Control: private');
readfile($filepath);
exit;
} else {
setEventMessages($langs->trans("FileNotFound"), null, 'errors');
}
} else {
setEventMessages($langs->trans("RecordNotFound"), null, 'errors');
}
}
// View PDF (inline)
if ($action == 'view') {
$id = GETPOSTINT('id');
if ($statement->fetch($id) > 0) {
$filepath = $statement->getFilePath();
if ($filepath && file_exists($filepath)) {
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="'.basename($statement->filename).'"');
header('Content-Length: '.filesize($filepath));
header('Cache-Control: private');
readfile($filepath);
exit;
} else {
setEventMessages($langs->trans("FileNotFound"), null, 'errors');
}
} else {
setEventMessages($langs->trans("RecordNotFound"), null, 'errors');
}
}
// Reconcile single statement
if ($action == 'reconcile') {
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
$id = GETPOSTINT('id');
$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (empty($reconcileBankAccountId)) {
setEventMessages($langs->trans("ErrorNoBankAccountConfigured"), null, 'errors');
} elseif ($statement->fetch($id) > 0) {
// Parse statement lines if not yet done
$existingLines = $statement->getStatementLines();
if (is_array($existingLines) && empty($existingLines)) {
$pdfLines = $statement->parsePdfTransactions();
if (!empty($pdfLines)) {
$statement->saveStatementLines($pdfLines);
}
}
$reconciledCount = $statement->reconcileBankEntries($user, $reconcileBankAccountId);
if ($reconciledCount > 0) {
setEventMessages($langs->trans("BankEntriesReconciled", $reconciledCount, $statement->statement_number.'/'.$statement->statement_year), null, 'mesgs');
} else {
setEventMessages($langs->trans("NoBankEntriesToReconcile"), null, 'warnings');
}
}
$action = '';
}
// Reconcile all statements
if ($action == 'reconcileall') {
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (empty($reconcileBankAccountId)) {
setEventMessages($langs->trans("ErrorNoBankAccountConfigured"), null, 'errors');
} else {
$allStatements = $statement->fetchAll('statement_year,statement_number', 'ASC', 0, 0, array());
$totalReconciled = 0;
$stmtCount = 0;
if (is_array($allStatements)) {
foreach ($allStatements as $stmt) {
// Parse statement lines if not yet done
$existingLines = $stmt->getStatementLines();
if (is_array($existingLines) && empty($existingLines)) {
$pdfLines = $stmt->parsePdfTransactions();
if (!empty($pdfLines)) {
$stmt->saveStatementLines($pdfLines);
}
}
$count = $stmt->reconcileBankEntries($user, $reconcileBankAccountId);
if ($count > 0) {
$totalReconciled += $count;
$stmtCount++;
}
}
}
if ($totalReconciled > 0) {
setEventMessages($langs->trans("BankEntriesReconciledTotal", $totalReconciled, $stmtCount), null, 'mesgs');
} else {
setEventMessages($langs->trans("NoBankEntriesToReconcile"), null, 'warnings');
}
}
$action = '';
}
// Confirm a pending reconciliation match
if ($action == 'confirmreconcile') {
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
$lineId = GETPOSTINT('lineid');
$bankId = GETPOSTINT('bankid');
$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if ($lineId > 0 && $bankId > 0 && $reconcileBankAccountId > 0) {
require_once DOL_DOCUMENT_ROOT.'/compta/bank/class/account.class.php';
// Get statement info from line
$sqlLine = "SELECT sl.fk_statement, s.statement_number, s.statement_year";
$sqlLine .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line sl";
$sqlLine .= " JOIN ".MAIN_DB_PREFIX."bankimport_statement s ON s.rowid = sl.fk_statement";
$sqlLine .= " WHERE sl.rowid = ".((int) $lineId);
$resLine = $db->query($sqlLine);
if ($resLine && $db->num_rows($resLine) > 0) {
$lineObj = $db->fetch_object($resLine);
$numReleve = $lineObj->statement_number.'/'.$lineObj->statement_year;
// Reconcile the bank entry
$bankLine = new AccountLine($db);
$bankLine->fetch($bankId);
$bankLine->num_releve = $numReleve;
$result = $bankLine->update_conciliation($user, 0, 1);
if ($result >= 0) {
// Update statement line status
$sqlUpd = "UPDATE ".MAIN_DB_PREFIX."bankimport_statement_line SET";
$sqlUpd .= " match_status = 'reconciled'";
$sqlUpd .= " WHERE rowid = ".((int) $lineId);
$db->query($sqlUpd);
setEventMessages($langs->trans("ReconciliationConfirmed"), null, 'mesgs');
} else {
setEventMessages($langs->trans("Error"), null, 'errors');
}
}
}
$action = '';
}
// Delete confirmation
if ($action == 'delete' && $confirm == 'yes') {
$id = GETPOSTINT('id');
if ($statement->fetch($id) > 0) {
$result = $statement->delete($user);
if ($result > 0) {
setEventMessages($langs->trans("RecordDeleted"), null, 'mesgs');
} else {
setEventMessages($statement->error, null, 'errors');
}
}
$action = '';
}
/*
* View
*/
$form = new Form($db);
$title = $langs->trans("PDFStatements");
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-pdfstatements');
print load_fiche_titre($title, '', 'bank');
// Reminder: check if statements are outdated
$reminderEnabled = getDolGlobalString('BANKIMPORT_REMINDER_ENABLED', '1');
if ($reminderEnabled) {
$reminderMonths = getDolGlobalInt('BANKIMPORT_REMINDER_MONTHS') ?: 3;
$lastEndDate = $statement->getLatestStatementEndDate();
$thresholdDate = dol_time_plus_duree(dol_now(), -$reminderMonths, 'm');
if ($lastEndDate === null) {
print '<div class="warning">';
print img_warning().' '.$langs->trans("ReminderNoStatements");
print '</div><br>';
} elseif ($lastEndDate < $thresholdDate) {
$monthsAgo = (int) round((dol_now() - $lastEndDate) / (30 * 24 * 3600));
print '<div class="warning">';
print img_warning().' '.$langs->trans("ReminderOutdatedStatements", dol_print_date($lastEndDate, 'day'), $monthsAgo);
print '</div><br>';
}
}
// Info box
print '<div class="info" style="margin-bottom: 15px;">';
print '<strong>'.$langs->trans("PDFStatementsInfo").'</strong><br>';
print $langs->trans("PDFStatementsInfoDesc");
print '</div>';
// Delete confirmation dialog
if ($action == 'delete') {
$id = GETPOSTINT('id');
$stmt = new BankImportStatement($db);
$stmt->fetch($id);
$formconfirm = $form->formconfirm(
$_SERVER["PHP_SELF"].'?id='.$id.'&year='.$year,
$langs->trans('DeleteStatement'),
$langs->trans('ConfirmDeleteStatement', $stmt->statement_number.'/'.$stmt->statement_year),
'delete',
'',
0,
1
);
print $formconfirm;
}
// Upload form
$defaultMode = getDolGlobalString('BANKIMPORT_UPLOAD_MODE') ?: 'auto';
$uploadMode = GETPOST('upload_mode', 'alpha') ?: $defaultMode;
print '<div class="fichecenter">';
print '<div class="fichehalfleft">';
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'" enctype="multipart/form-data" id="uploadform">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="upload">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="2">'.$langs->trans("UploadPDFStatement").'</td>';
print '</tr>';
// Upload mode selection
print '<tr class="oddeven">';
print '<td class="titlefield">'.$langs->trans("UploadMode").'</td>';
print '<td>';
print '<label style="margin-right: 15px;"><input type="radio" name="upload_mode" value="auto" id="mode_auto"'.($uploadMode == 'auto' ? ' checked' : '').' onchange="toggleUploadMode()"> '.$langs->trans("UploadModeAuto").'</label>';
print '<label><input type="radio" name="upload_mode" value="manual" id="mode_manual"'.($uploadMode == 'manual' ? ' checked' : '').' onchange="toggleUploadMode()"> '.$langs->trans("UploadModeManual").'</label>';
print '</td>';
print '</tr>';
// PDF file (always visible, multiple in auto mode)
print '<tr class="oddeven">';
print '<td class="fieldrequired">'.$langs->trans("File").'</td>';
print '<td>';
print '<input type="file" name="pdffile[]" id="pdffile_input" accept=".pdf,application/pdf" multiple required>';
print '<br><span class="opacitymedium small" id="multi_hint">'.$langs->trans("MultipleFilesHint").'</span>';
print '</td>';
print '</tr>';
// --- Manual fields (hidden when auto mode) ---
// IBAN
print '<tr class="oddeven manual-field">';
print '<td>'.$langs->trans("IBAN").'</td>';
print '<td>';
print '<input type="text" class="flat minwidth200" name="iban" value="'.dol_escape_htmltag(GETPOST('iban', 'alpha')).'" placeholder="DE89 3704 0044 0532 0130 00">';
print '</td>';
print '</tr>';
// Year
print '<tr class="oddeven manual-field">';
print '<td class="fieldrequired">'.$langs->trans("Year").'</td>';
print '<td>';
$years = array();
for ($y = (int) date('Y'); $y >= ((int) date('Y') - 10); $y--) {
$years[$y] = $y;
}
print $form->selectarray('statement_year', $years, GETPOSTISSET('statement_year') ? GETPOSTINT('statement_year') : $year, 0, 0, 0, '', 0, 0, 0, '', 'minwidth100');
print '</td>';
print '</tr>';
// Statement number
print '<tr class="oddeven manual-field">';
print '<td class="fieldrequired">'.$langs->trans("StatementNumber").'</td>';
print '<td>';
$nextNum = $statement->getNextStatementNumber($year);
print '<input type="text" class="flat width75" name="statement_number" value="'.dol_escape_htmltag(GETPOSTISSET('statement_number') ? GETPOST('statement_number', 'alpha') : '').'">';
print '</td>';
print '</tr>';
// Statement date
print '<tr class="oddeven manual-field">';
print '<td>'.$langs->trans("StatementDate").'</td>';
print '<td>';
print $form->selectDate(GETPOSTISSET('statement_dateday') ? dol_mktime(0, 0, 0, GETPOSTINT('statement_datemonth'), GETPOSTINT('statement_dateday'), GETPOSTINT('statement_dateyear')) : -1, 'statement_date', 0, 0, 1, '', 1, 0);
print '</td>';
print '</tr>';
// Period from
print '<tr class="oddeven manual-field">';
print '<td>'.$langs->trans("DateFrom").'</td>';
print '<td>';
print $form->selectDate(GETPOSTISSET('date_fromday') ? dol_mktime(0, 0, 0, GETPOSTINT('date_frommonth'), GETPOSTINT('date_fromday'), GETPOSTINT('date_fromyear')) : -1, 'date_from', 0, 0, 1, '', 1, 0);
print '</td>';
print '</tr>';
// Period to
print '<tr class="oddeven manual-field">';
print '<td>'.$langs->trans("DateTo").'</td>';
print '<td>';
print $form->selectDate(GETPOSTISSET('date_today') ? dol_mktime(0, 0, 0, GETPOSTINT('date_tomonth'), GETPOSTINT('date_today'), GETPOSTINT('date_toyear')) : -1, 'date_to', 0, 0, 1, '', 1, 0);
print '</td>';
print '</tr>';
// Opening balance
print '<tr class="oddeven manual-field">';
print '<td>'.$langs->trans("OpeningBalance").'</td>';
print '<td>';
print '<input type="text" class="flat width100" name="opening_balance" value="'.dol_escape_htmltag(GETPOST('opening_balance', 'alpha')).'" placeholder="1.234,56">';
print ' EUR';
print '</td>';
print '</tr>';
// Closing balance
print '<tr class="oddeven manual-field">';
print '<td>'.$langs->trans("ClosingBalance").'</td>';
print '<td>';
print '<input type="text" class="flat width100" name="closing_balance" value="'.dol_escape_htmltag(GETPOST('closing_balance', 'alpha')).'" placeholder="1.345,67">';
print ' EUR';
print '</td>';
print '</tr>';
print '</table>';
print '<div class="center" style="margin-top: 10px;">';
print '<input type="submit" class="button button-save" value="'.$langs->trans("Upload").'">';
print '</div>';
print '</form>';
// JavaScript for toggling upload modes
print '<script type="text/javascript">
function toggleUploadMode() {
var isManual = document.getElementById("mode_manual").checked;
var manualFields = document.querySelectorAll(".manual-field");
var fileInput = document.getElementById("pdffile_input");
var multiHint = document.getElementById("multi_hint");
for (var i = 0; i < manualFields.length; i++) {
manualFields[i].style.display = isManual ? "" : "none";
}
// In manual mode: single file only. In auto mode: multiple files allowed
if (isManual) {
fileInput.removeAttribute("multiple");
multiHint.style.display = "none";
} else {
fileInput.setAttribute("multiple", "multiple");
multiHint.style.display = "";
}
}
// Initial state
document.addEventListener("DOMContentLoaded", function() { toggleUploadMode(); });
</script>';
print '</div>'; // fichehalfleft
print '</div>'; // fichecenter
print '<div class="clearboth"></div><br>';
// Year filter for list - only show years that have statements
$yearsFilter = array(0 => $langs->trans("AllStatements"));
$availableYears = $statement->getAvailableYears();
foreach ($availableYears as $yKey => $yVal) {
$yearsFilter[$yKey] = $yVal;
}
// If current year not in list, add it
if (!isset($yearsFilter[(int) date('Y')])) {
$yearsFilter[(int) date('Y')] = (string) date('Y');
krsort($yearsFilter);
}
print '<form method="GET" action="'.$_SERVER["PHP_SELF"].'">';
print '<div class="center" style="margin-bottom: 15px;">';
print '<strong>'.$langs->trans("Year").':</strong> ';
print $form->selectarray('year', $yearsFilter, $year, 0, 0, 0, '', 0, 0, 0, '', 'minwidth100');
print ' <input type="submit" class="button smallpaddingimp" value="'.$langs->trans("Filter").'">';
print '</div>';
print '</form>';
// Reconcile All button
$reconcileBankAccountId = getDolGlobalInt('BANKIMPORT_BANK_ACCOUNT_ID');
if (!empty($reconcileBankAccountId)) {
print '<div class="right" style="margin-bottom: 10px;">';
print '<a class="butAction" href="'.$_SERVER["PHP_SELF"].'?action=reconcileall&year='.$year.'&token='.newToken().'">';
print img_picto('', 'bank', 'class="pictofixedwidth"').$langs->trans("ReconcileAllStatements");
print '</a>';
print '</div>';
} else {
print '<div class="warning" style="margin-bottom: 10px;">';
print img_warning().' '.$langs->trans("NoBankAccountConfigured");
print ' <a href="'.dol_buildpath('/bankimport/admin/setup.php', 1).'">'.$langs->trans("GoToSetup").'</a>';
print '</div>';
}
// List of existing PDF statements
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th class="center" width="80">'.$langs->trans("StatementNumber").'</th>';
print '<th>'.$langs->trans("IBAN").'</th>';
print '<th class="center">'.$langs->trans("StatementDate").'</th>';
print '<th class="center">'.$langs->trans("Period").'</th>';
print '<th class="right">'.$langs->trans("OpeningBalance").'</th>';
print '<th class="right">'.$langs->trans("ClosingBalance").'</th>';
print '<th class="right">'.$langs->trans("Size").'</th>';
print '<th class="center">'.$langs->trans("DateCreation").'</th>';
print '<th class="center" width="200">'.$langs->trans("Actions").'</th>';
print '</tr>';
$filter = array();
if ($year > 0) {
$filter['year'] = $year;
}
$records = $statement->fetchAll('statement_year,statement_number', 'DESC', 100, 0, $filter);
if (is_array($records) && count($records) > 0) {
foreach ($records as $obj) {
print '<tr class="oddeven">';
// Statement number
print '<td class="center nowraponall">';
print '<strong>'.dol_escape_htmltag($obj->statement_number).'</strong>/'.$obj->statement_year;
print '</td>';
// IBAN
print '<td>';
if ($obj->iban) {
print dol_escape_htmltag($obj->iban);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Statement date
print '<td class="center">';
if ($obj->statement_date) {
print dol_print_date($obj->statement_date, 'day');
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Period
print '<td class="center nowraponall">';
if ($obj->date_from && $obj->date_to) {
print dol_print_date($obj->date_from, 'day').' - '.dol_print_date($obj->date_to, 'day');
} elseif ($obj->date_from) {
print $langs->trans("From").' '.dol_print_date($obj->date_from, 'day');
} elseif ($obj->date_to) {
print $langs->trans("To").' '.dol_print_date($obj->date_to, 'day');
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Opening balance
print '<td class="right nowraponall">';
if ($obj->opening_balance !== null) {
$color = $obj->opening_balance >= 0 ? '' : 'color: red;';
print '<span style="'.$color.'">'.price($obj->opening_balance, 0, $langs, 1, -1, 2, 'EUR').'</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Closing balance
print '<td class="right nowraponall">';
if ($obj->closing_balance !== null) {
$color = $obj->closing_balance >= 0 ? '' : 'color: red;';
print '<span style="'.$color.'">'.price($obj->closing_balance, 0, $langs, 1, -1, 2, 'EUR').'</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Size
print '<td class="right">';
if ($obj->filesize) {
print dol_print_size($obj->filesize, 1);
} else {
print '-';
}
print '</td>';
// Creation date
print '<td class="center nowraponall">';
print dol_print_date($obj->datec, 'day');
print '</td>';
// Actions
print '<td class="center nowraponall" style="white-space: nowrap;">';
if ($obj->filepath && file_exists($obj->filepath)) {
// View (inline)
print '<a style="margin: 0 6px;" href="'.$_SERVER["PHP_SELF"].'?action=view&id='.$obj->id.'&token='.newToken().'" target="_blank" title="'.$langs->trans("View").'">';
print img_picto($langs->trans("View"), 'eye');
print '</a>';
// Download
print '<a style="margin: 0 6px;" href="'.$_SERVER["PHP_SELF"].'?action=download&id='.$obj->id.'&token='.newToken().'" title="'.$langs->trans("Download").'">';
print img_picto($langs->trans("Download"), 'download');
print '</a>';
}
// Reconcile
if (!empty($reconcileBankAccountId) && $obj->date_from && $obj->date_to) {
print '<a style="margin: 0 6px;" href="'.$_SERVER["PHP_SELF"].'?action=reconcile&id='.$obj->id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("ReconcileStatement").'">';
print img_picto($langs->trans("ReconcileStatement"), 'bank');
print '</a>';
}
// Delete
print '<a style="margin: 0 6px;" href="'.$_SERVER["PHP_SELF"].'?action=delete&id='.$obj->id.'&year='.$year.'&token='.newToken().'" title="'.$langs->trans("Delete").'">';
print img_picto($langs->trans("Delete"), 'delete');
print '</a>';
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="9" class="opacitymedium center">';
print $langs->trans("NoPDFStatementsFound");
print '</td></tr>';
}
print '</table>';
print '</div>';
// Pending review matches
$sqlPending = "SELECT sl.rowid as line_id, sl.fk_statement, sl.line_number, sl.date_booking, sl.amount as stmt_amount,";
$sqlPending .= " sl.name as stmt_name, sl.fk_bank,";
$sqlPending .= " b.rowid as bank_id, b.datev, b.amount as bank_amount, b.label as bank_label,";
$sqlPending .= " s.statement_number, s.statement_year";
$sqlPending .= " FROM ".MAIN_DB_PREFIX."bankimport_statement_line sl";
$sqlPending .= " JOIN ".MAIN_DB_PREFIX."bankimport_statement s ON s.rowid = sl.fk_statement";
$sqlPending .= " JOIN ".MAIN_DB_PREFIX."bank b ON b.rowid = sl.fk_bank";
$sqlPending .= " WHERE sl.match_status = 'pending_review'";
$sqlPending .= " AND sl.entity = ".((int) $conf->entity);
$sqlPending .= " ORDER BY s.statement_year, s.statement_number, sl.line_number";
$resPending = $db->query($sqlPending);
if ($resPending && $db->num_rows($resPending) > 0) {
print '<br>';
print '<div class="div-table-responsive">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<td colspan="8">';
print img_warning().' <strong>'.$langs->trans("PendingReconciliationMatches").'</strong>';
print ' - '.$langs->trans("PendingReconciliationMatchesDesc");
print '</td>';
print '</tr>';
print '<tr class="liste_titre">';
print '<th class="center">'.$langs->trans("StatementNumber").'</th>';
print '<th class="center">'.$langs->trans("BookingDate").'</th>';
print '<th>'.$langs->trans("Name").'</th>';
print '<th class="right">'.$langs->trans("AmountStatement").'</th>';
print '<th class="right">'.$langs->trans("AmountDolibarr").'</th>';
print '<th class="right">'.$langs->trans("Difference").'</th>';
print '<th class="center">'.$langs->trans("BankEntry").'</th>';
print '<th class="center">'.$langs->trans("Action").'</th>';
print '</tr>';
while ($pendObj = $db->fetch_object($resPending)) {
$diff = abs(abs((float) $pendObj->stmt_amount) - abs((float) $pendObj->bank_amount));
$diffColor = ($diff > 10) ? 'color: red; font-weight: bold;' : 'color: #e68a00;';
print '<tr class="oddeven">';
// Statement number
print '<td class="center nowraponall">'.$pendObj->statement_number.'/'.$pendObj->statement_year.'</td>';
// Booking date
print '<td class="center">'.dol_print_date($db->jdate($pendObj->date_booking), 'day').'</td>';
// Name
print '<td>'.dol_escape_htmltag($pendObj->stmt_name).'</td>';
// Amount from PDF statement
print '<td class="right nowraponall">';
$stmtColor = $pendObj->stmt_amount >= 0 ? '' : 'color: red;';
print '<span style="'.$stmtColor.'">'.price($pendObj->stmt_amount, 0, $langs, 1, -1, 2, 'EUR').'</span>';
print '</td>';
// Amount from Dolibarr bank
print '<td class="right nowraponall">';
$bankColor = $pendObj->bank_amount >= 0 ? '' : 'color: red;';
print '<span style="'.$bankColor.'">'.price($pendObj->bank_amount, 0, $langs, 1, -1, 2, 'EUR').'</span>';
print '</td>';
// Difference
print '<td class="right nowraponall">';
print '<span style="'.$diffColor.'">'.price($diff, 0, $langs, 1, -1, 2, 'EUR').'</span>';
print '</td>';
// Bank entry link
print '<td class="center">';
print '<a href="'.DOL_URL_ROOT.'/compta/bank/line.php?rowid='.$pendObj->bank_id.'" target="_blank">#'.$pendObj->bank_id.'</a>';
print '</td>';
// Action: confirm button
print '<td class="center nowraponall">';
print '<a class="butAction butActionSmall" href="'.$_SERVER["PHP_SELF"].'?action=confirmreconcile&lineid='.$pendObj->line_id.'&bankid='.$pendObj->bank_id.'&year='.$year.'&token='.newToken().'">';
print $langs->trans("Confirm");
print '</a>';
print '</td>';
print '</tr>';
}
print '</table>';
print '</div>';
}
$db->free($resPending);
// Statistics
$totalCount = $statement->fetchAll('', '', 0, 0, array(), 'count');
$yearCount = is_array($records) ? count($records) : 0;
print '<div class="opacitymedium" style="margin-top: 10px;">';
if ($year > 0) {
print $langs->trans("Total").': <strong>'.$yearCount.'</strong> '.$langs->trans("StatementsInYear", $year);
print ' | '.$langs->trans("AllStatements").': <strong>'.$totalCount.'</strong>';
} else {
print $langs->trans("Total").': <strong>'.$totalCount.'</strong> '.$langs->trans("AllStatements");
}
print '</div>';
llxFooter();
$db->close();

34
sql/dolibarr_allversions.sql Executable file
View file

@ -0,0 +1,34 @@
--
-- Script run when an upgrade of Dolibarr is done. Whatever is the Dolibarr version.
--
-- v1.1: Add fk_statement to transaction table
ALTER TABLE llx_bankimport_transaction ADD COLUMN fk_statement INTEGER AFTER fk_societe;
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_statement (fk_statement);
-- v1.3: Add statement_line table for parsed PDF transactions
CREATE TABLE IF NOT EXISTS llx_bankimport_statement_line (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_statement INTEGER NOT NULL,
entity INTEGER DEFAULT 1 NOT NULL,
line_number INTEGER DEFAULT 0,
date_booking DATE NOT NULL,
date_value DATE,
transaction_type VARCHAR(100),
amount DOUBLE(24,8) NOT NULL,
currency VARCHAR(3) DEFAULT 'EUR',
name VARCHAR(255),
description TEXT,
fk_bank INTEGER,
datec DATETIME,
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_statement (fk_statement);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_entity (entity);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_date_booking (date_booking);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_amount (amount);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_bank (fk_bank);
ALTER TABLE llx_bankimport_statement_line ADD UNIQUE INDEX uk_bankimport_stmtline (fk_statement, line_number, entity);
-- v1.4: Add match_status to statement_line for approval workflow
ALTER TABLE llx_bankimport_statement_line ADD COLUMN match_status VARCHAR(20) DEFAULT NULL AFTER fk_bank;

View file

@ -0,0 +1,14 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- 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.
-- Indexes for llx_bankimport_statement
ALTER TABLE llx_bankimport_statement ADD UNIQUE INDEX uk_bankimport_statement (statement_number, statement_year, iban, entity);
ALTER TABLE llx_bankimport_statement ADD INDEX idx_bankimport_statement_entity (entity);
ALTER TABLE llx_bankimport_statement ADD INDEX idx_bankimport_statement_iban (iban);
ALTER TABLE llx_bankimport_statement ADD INDEX idx_bankimport_statement_year (statement_year);
ALTER TABLE llx_bankimport_statement ADD INDEX idx_bankimport_statement_date (statement_date);

View file

@ -0,0 +1,30 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- 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.
-- Table for PDF bank statements
CREATE TABLE llx_bankimport_statement (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
entity INTEGER DEFAULT 1 NOT NULL,
iban VARCHAR(34),
statement_number VARCHAR(20) NOT NULL,
statement_year INTEGER NOT NULL,
statement_date DATE,
date_from DATE,
date_to DATE,
opening_balance DOUBLE(24,8),
closing_balance DOUBLE(24,8),
currency VARCHAR(3) DEFAULT 'EUR',
filename VARCHAR(255),
filepath VARCHAR(512),
filesize INTEGER,
import_key VARCHAR(50),
datec DATETIME,
fk_user_creat INTEGER,
note_private TEXT,
note_public TEXT
) ENGINE=InnoDB;

View file

@ -0,0 +1,15 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- 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.
-- Indexes for llx_bankimport_statement_line
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_statement (fk_statement);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_entity (entity);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_date_booking (date_booking);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_amount (amount);
ALTER TABLE llx_bankimport_statement_line ADD INDEX idx_bankimport_stmtline_fk_bank (fk_bank);
ALTER TABLE llx_bankimport_statement_line ADD UNIQUE INDEX uk_bankimport_stmtline (fk_statement, line_number, entity);

View file

@ -0,0 +1,34 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- 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.
-- Table for individual transaction lines parsed from PDF bank statements
CREATE TABLE llx_bankimport_statement_line (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
fk_statement INTEGER NOT NULL, -- Link to llx_bankimport_statement
entity INTEGER DEFAULT 1 NOT NULL,
line_number INTEGER DEFAULT 0, -- Position within statement (1, 2, 3...)
-- Transaction data from PDF
date_booking DATE NOT NULL, -- Buchungstag (Bu-Tag)
date_value DATE, -- Wertstellungstag (Wert)
transaction_type VARCHAR(100), -- Vorgangsart (e.g. Überweisungsgutschr., Basislastschrift)
amount DOUBLE(24,8) NOT NULL, -- Amount (positive = credit, negative = debit)
currency VARCHAR(3) DEFAULT 'EUR',
-- Counterparty and description
name VARCHAR(255), -- Counterparty name (first detail line)
description TEXT, -- Full description (all detail lines)
-- Matching
fk_bank INTEGER, -- Link to llx_bank when reconciled
match_status VARCHAR(20) DEFAULT NULL, -- NULL=unmatched, reconciled=auto, pending_review=needs approval
-- Timestamps
datec DATETIME,
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

View file

@ -0,0 +1,28 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- 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.
-- Indexes for llx_bankimport_transaction
ALTER TABLE llx_bankimport_transaction ADD UNIQUE INDEX uk_bankimport_transaction_ref (ref, entity);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_entity (entity);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_iban (iban);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_date_trans (date_trans);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_name (name);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_amount (amount);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_status (status);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_bank (fk_bank);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_facture (fk_facture);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_facture_fourn (fk_facture_fourn);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_societe (fk_societe);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_import_key (import_key);
ALTER TABLE llx_bankimport_transaction ADD INDEX idx_bankimport_transaction_fk_statement (fk_statement);
-- Foreign keys (optional, depends on your setup)
-- ALTER TABLE llx_bankimport_transaction ADD CONSTRAINT fk_bankimport_transaction_fk_bank FOREIGN KEY (fk_bank) REFERENCES llx_bank(rowid);
-- ALTER TABLE llx_bankimport_transaction ADD CONSTRAINT fk_bankimport_transaction_fk_facture FOREIGN KEY (fk_facture) REFERENCES llx_facture(rowid);
-- ALTER TABLE llx_bankimport_transaction ADD CONSTRAINT fk_bankimport_transaction_fk_facture_fourn FOREIGN KEY (fk_facture_fourn) REFERENCES llx_facture_fourn(rowid);
-- ALTER TABLE llx_bankimport_transaction ADD CONSTRAINT fk_bankimport_transaction_fk_societe FOREIGN KEY (fk_societe) REFERENCES llx_societe(rowid);

View file

@ -0,0 +1,62 @@
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- 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.
CREATE TABLE llx_bankimport_transaction (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
ref VARCHAR(128) NOT NULL, -- Unique reference (hash of transaction)
entity INTEGER DEFAULT 1 NOT NULL, -- Multi-company id
-- Bank account info
iban VARCHAR(34), -- IBAN of the account
bic VARCHAR(11), -- BIC of the bank
-- Transaction data
datec DATETIME, -- Creation date in system
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
date_trans DATE NOT NULL, -- Transaction date (booking date)
date_value DATE, -- Value date
-- Counterparty
name VARCHAR(255), -- Counterparty name
counterparty_iban VARCHAR(34), -- Counterparty IBAN
counterparty_bic VARCHAR(11), -- Counterparty BIC
-- Amount
amount DOUBLE(24,8) NOT NULL, -- Amount (positive = credit, negative = debit)
currency VARCHAR(3) DEFAULT 'EUR', -- Currency code
-- Description
label VARCHAR(255), -- Short label/booking text
description TEXT, -- Full description/reference
end_to_end_id VARCHAR(128), -- End-to-end ID from SEPA
mandate_id VARCHAR(128), -- Mandate ID for direct debits
-- Matching with Dolibarr
fk_bank INTEGER, -- Link to llx_bank (bank line)
fk_facture INTEGER, -- Link to llx_facture (customer invoice)
fk_facture_fourn INTEGER, -- Link to llx_facture_fourn (supplier invoice)
fk_paiement INTEGER, -- Link to llx_paiement
fk_paiementfourn INTEGER, -- Link to llx_paiementfourn
fk_salary INTEGER, -- Link to llx_salary (salary payment)
fk_don INTEGER, -- Link to llx_don (donation)
fk_loan INTEGER, -- Link to llx_loan (loan payment)
fk_societe INTEGER, -- Link to llx_societe (third party)
fk_statement INTEGER, -- Link to llx_bankimport_statement (PDF statement)
-- Status
status SMALLINT DEFAULT 0, -- 0=new, 1=matched, 2=reconciled, 9=ignored
import_key VARCHAR(64), -- Import batch key
-- User tracking
fk_user_creat INTEGER, -- User who imported
fk_user_modif INTEGER, -- User who last modified
fk_user_match INTEGER, -- User who matched/reconciled
date_match DATETIME, -- Date of matching
note_private TEXT, -- Private notes
note_public TEXT -- Public notes
) ENGINE=InnoDB;

708
statements.php Executable file
View file

@ -0,0 +1,708 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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.
*/
/**
* \file bankimport/statements.php
* \ingroup bankimport
* \brief Page to fetch and display bank statements via FinTS
*/
// Load Dolibarr environment
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) {
$res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
}
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
$tmp2 = realpath(__FILE__);
$i = strlen($tmp) - 1;
$j = strlen($tmp2) - 1;
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
$i--;
$j--;
}
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) {
$res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
}
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) {
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
}
if (!$res && file_exists("../main.inc.php")) {
$res = @include "../main.inc.php";
}
if (!$res && file_exists("../../main.inc.php")) {
$res = @include "../../main.inc.php";
}
if (!$res) {
die("Include of main fails");
}
require_once DOL_DOCUMENT_ROOT.'/core/lib/date.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
require_once DOL_DOCUMENT_ROOT.'/core/class/html.formother.class.php';
dol_include_once('/bankimport/class/fints.class.php');
dol_include_once('/bankimport/class/banktransaction.class.php');
dol_include_once('/bankimport/class/bankimportcron.class.php');
dol_include_once('/bankimport/lib/bankimport.lib.php');
/**
* @var Conf $conf
* @var DoliDB $db
* @var Translate $langs
* @var User $user
*/
$langs->loadLangs(array("bankimport@bankimport", "banks"));
$action = GETPOST('action', 'aZ09');
// Security check
if (!$user->hasRight('bankimport', 'write')) {
accessforbidden();
}
// Date filters
$date_fromday = GETPOSTINT('date_fromday');
$date_frommonth = GETPOSTINT('date_frommonth');
$date_fromyear = GETPOSTINT('date_fromyear');
$date_today = GETPOSTINT('date_today');
$date_tomonth = GETPOSTINT('date_tomonth');
$date_toyear = GETPOSTINT('date_toyear');
$dateFrom = 0;
$dateTo = 0;
if ($date_fromday && $date_frommonth && $date_fromyear) {
$dateFrom = dol_mktime(0, 0, 0, $date_frommonth, $date_fromday, $date_fromyear);
}
if ($date_today && $date_tomonth && $date_toyear) {
$dateTo = dol_mktime(23, 59, 59, $date_tomonth, $date_today, $date_toyear);
}
// Default: last 30 days
if (empty($dateFrom)) {
$dateFrom = dol_time_plus_duree(dol_now(), -30, 'd');
}
if (empty($dateTo)) {
$dateTo = dol_now();
}
/*
* Actions
*/
$transactions = array();
$balance = array();
$error = 0;
$tanRequired = false;
$tanChallenge = '';
$importResult = null;
// Import transactions to database
if ($action == 'import' && !empty($_SESSION['fints_transactions'])) {
$fints = new BankImportFinTS($db);
$iban = $fints->getIban();
$transImporter = new BankImportTransaction($db);
$importResult = $transImporter->importFromFinTS($_SESSION['fints_transactions'], $iban, $user);
if ($importResult['imported'] > 0) {
setEventMessages($langs->trans("TransactionsImported", $importResult['imported']), null, 'mesgs');
}
if ($importResult['skipped'] > 0) {
setEventMessages($langs->trans("TransactionsSkipped", $importResult['skipped']), null, 'warnings');
}
if (!empty($importResult['errors'])) {
setEventMessages(implode('<br>', $importResult['errors']), null, 'errors');
}
// Clear session after import
unset($_SESSION['fints_transactions']);
unset($_SESSION['fints_balance']);
}
// Resume pending cron TAN
if ($action == 'resumecron') {
$cronJob = new BankImportCron($db);
$result = $cronJob->resumePendingAction();
if ($result > 0 || $result == 0) {
if ($result > 0) {
setEventMessages($langs->trans("TANConfirmedImportComplete"), null, 'mesgs');
} else {
setEventMessages($langs->trans("WaitingForSecureGoConfirmation"), null, 'warnings');
}
} else {
setEventMessages($langs->trans("TANCheckFailed").': '.$cronJob->error, null, 'errors');
}
}
if ($action == 'fetch') {
$fints = new BankImportFinTS($db);
if (!$fints->isConfigured()) {
setEventMessages($langs->trans("FinTSNotConfigured"), null, 'errors');
$error++;
} elseif (!$fints->isLibraryAvailable()) {
setEventMessages($langs->trans("FinTSLibraryNotFound"), null, 'errors');
$error++;
} else {
// Login first
$loginResult = $fints->login();
if ($loginResult < 0) {
setEventMessages($langs->trans("LoginFailed").': '.$fints->error, null, 'errors');
$error++;
} elseif ($loginResult == 0) {
// TAN required
$tanRequired = true;
$tanChallenge = $fints->tanChallenge;
// Check if decoupled (SecureGo Plus)
if ($fints->selectedTanMode && $fints->selectedTanMode->isDecoupled()) {
setEventMessages($langs->trans("SecureGoPlusConfirmRequired"), null, 'warnings');
// Store state in session for polling
$_SESSION['fints_state'] = $fints->persist();
$_SESSION['fints_action'] = 'login';
} else {
setEventMessages($langs->trans("TANRequired").': '.$tanChallenge, null, 'warnings');
}
} else {
// Login successful - log bank parameters for diagnostics
$bankParams = $fints->getBankParameters();
dol_syslog("BankImport: Bank parameters: ".json_encode($bankParams), LOG_DEBUG);
// Check what statement methods are supported
$hikazsVersions = $bankParams['HIKAZS'] ?? array();
$hicazsVersions = $bankParams['HICAZS'] ?? array();
$hiekaVersions = $bankParams['HIEKAS'] ?? array();
dol_syslog("BankImport: HIKAZS versions: ".implode(',', $hikazsVersions)." | HICAZS: ".implode(',', $hicazsVersions)." | HIEKAS: ".implode(',', $hiekaVersions), LOG_DEBUG);
// Fetch statements
$result = $fints->fetchStatements($dateFrom, $dateTo);
if ($result === 0) {
// TAN required for statements
$tanRequired = true;
$tanChallenge = $fints->tanChallenge;
// Store state in session for polling
$_SESSION['fints_state'] = $fints->persist();
$_SESSION['fints_pending_action'] = serialize($fints->getPendingAction());
$_SESSION['fints_action'] = 'statements';
$_SESSION['fints_datefrom'] = $dateFrom;
$_SESSION['fints_dateto'] = $dateTo;
setEventMessages($langs->trans("SecureGoPlusConfirmRequired"), null, 'warnings');
} elseif ($result < 0) {
setEventMessages($langs->trans("FetchFailed").': '.$fints->error, null, 'errors');
// Show supported versions for diagnostics
if (!empty($hikazsVersions) || !empty($hicazsVersions)) {
$diagMsg = 'Bank unterstützt: HIKAZS v'.implode(',v', $hikazsVersions);
if (!empty($hicazsVersions)) {
$diagMsg .= ' | HICAZS v'.implode(',v', $hicazsVersions);
}
setEventMessages($diagMsg, null, 'warnings');
}
$error++;
} elseif (is_array($result)) {
$transactions = $result['transactions'] ?? array();
$balance = $result['balance'] ?? array();
$isPartial = $result['partial'] ?? false;
$infoMsg = $result['info'] ?? '';
if (empty($transactions)) {
setEventMessages($langs->trans("NoTransactionsFound"), null, 'warnings');
} else {
setEventMessages($langs->trans("TransactionsFound", count($transactions)), null, 'mesgs');
// Store in session for import
$_SESSION['fints_transactions'] = $transactions;
$_SESSION['fints_balance'] = $balance;
// Show info about partial results (older data not available)
if ($isPartial && !empty($infoMsg)) {
setEventMessages($infoMsg, null, 'warnings');
}
}
// Save session state for cronjob before closing
$cronState = $fints->persist();
if (!empty($cronState)) {
dolibarr_set_const($db, 'BANKIMPORT_CRON_STATE', $cronState, 'chaine', 0, '', $conf->entity);
dol_syslog("BankImport: Saved session state for cronjob", LOG_DEBUG);
}
// Only close on success
$fints->close();
}
// NOTE: Don't call close() when TAN is required ($result === 0)
// The connection must stay open for checkDecoupledTan()
}
}
}
// Check decoupled TAN status (SecureGo Plus polling)
if ($action == 'checktan') {
if (!empty($_SESSION['fints_state']) && !empty($_SESSION['fints_pending_action'])) {
$fints = new BankImportFinTS($db);
$fints->restore($_SESSION['fints_state']);
$fints->setPendingAction(unserialize($_SESSION['fints_pending_action']));
$result = $fints->checkDecoupledTan();
if ($result > 0) {
// TAN confirmed
$savedAction = $_SESSION['fints_action'] ?? 'statements';
$savedDateFrom = $_SESSION['fints_datefrom'] ?? $dateFrom;
$savedDateTo = $_SESSION['fints_dateto'] ?? $dateTo;
unset($_SESSION['fints_state']);
unset($_SESSION['fints_pending_action']);
unset($_SESSION['fints_action']);
unset($_SESSION['fints_datefrom']);
unset($_SESSION['fints_dateto']);
// Fetch statements after TAN confirmation
$result = $fints->fetchStatements($savedDateFrom, $savedDateTo);
if ($result === 0) {
// Another TAN required (unlikely but possible)
$_SESSION['fints_state'] = $fints->persist();
$_SESSION['fints_action'] = 'statements';
$_SESSION['fints_datefrom'] = $savedDateFrom;
$_SESSION['fints_dateto'] = $savedDateTo;
setEventMessages($langs->trans("SecureGoPlusConfirmRequired"), null, 'warnings');
} elseif (is_array($result)) {
$transactions = $result['transactions'] ?? array();
$balance = $result['balance'] ?? array();
setEventMessages($langs->trans("TransactionsFound", count($transactions)), null, 'mesgs');
// Store transactions for import
$_SESSION['fints_transactions'] = $transactions;
$_SESSION['fints_balance'] = $balance;
// Save session state for cronjob before closing
$cronState = $fints->persist();
if (!empty($cronState)) {
dolibarr_set_const($db, 'BANKIMPORT_CRON_STATE', $cronState, 'chaine', 0, '', $conf->entity);
dol_syslog("BankImport: Saved session state for cronjob after TAN confirmation", LOG_DEBUG);
}
$fints->close();
} else {
setEventMessages($langs->trans("FetchFailed").': '.$fints->error, null, 'errors');
}
} elseif ($result == 0) {
// Still waiting
setEventMessages($langs->trans("WaitingForSecureGoConfirmation"), null, 'warnings');
$_SESSION['fints_state'] = $fints->persist();
} else {
setEventMessages($langs->trans("TANCheckFailed").': '.$fints->error, null, 'errors');
unset($_SESSION['fints_state']);
}
} else {
setEventMessages("Keine aktive Session. Bitte erneut abrufen.", null, 'errors');
unset($_SESSION['fints_state']);
unset($_SESSION['fints_pending_action']);
}
}
/*
* View
*/
$form = new Form($db);
$title = $langs->trans("BankStatements");
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-bankimport page-statements');
print load_fiche_titre($title, '', 'bank');
// Check configuration
$fints = new BankImportFinTS($db);
if (!$fints->isConfigured()) {
print '<div class="error">';
print $langs->trans("FinTSNotConfigured");
print ' <a href="'.dol_buildpath('/bankimport/admin/setup.php', 1).'">'.$langs->trans("GoToSetup").'</a>';
print '</div>';
llxFooter();
$db->close();
exit;
}
// Check for pending cron TAN notification
$cronNotification = BankImportCron::getNotification();
if ($cronNotification !== null) {
$notifType = $cronNotification['type'];
$notifDate = $cronNotification['date'];
if ($notifType === 'tan_required') {
print '<div class="warning">';
print '<strong>'.$langs->trans("AutoImportTANRequired").'</strong><br>';
print $langs->trans("AutoImportTANRequiredDesc");
print ' <a class="button buttongen" href="'.$_SERVER["PHP_SELF"].'?action=resumecron&token='.newToken().'">'.$langs->trans("CheckSecureGoStatus").'</a>';
print '</div><br>';
} elseif (in_array($notifType, array('login_error', 'fetch_error', 'config_error', 'error'))) {
print '<div class="error">';
print '<strong>'.$langs->trans("AutoImportError").'</strong><br>';
print $langs->trans("AutoImportErrorDesc", dol_print_date($notifDate, 'dayhour'));
print '</div><br>';
}
}
// Check for old data warning
$lastFetch = getDolGlobalInt('BANKIMPORT_LAST_FETCH');
if ($lastFetch > 0 && (time() - $lastFetch) > 14 * 86400) {
print '<div class="warning">';
print $langs->trans("LastFetchWarning", dol_print_date($lastFetch, 'day'));
print '</div><br>';
}
if (!$fints->isLibraryAvailable()) {
print '<div class="error">';
print $langs->trans("FinTSLibraryNotFound");
print '<br><code>cd '.dirname(__FILE__).' && composer install</code>';
print '</div>';
llxFooter();
$db->close();
exit;
}
// Filter form
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="fetch">';
print '<div class="fichecenter">';
print '<table class="border centpercent">';
// IBAN display
print '<tr>';
print '<td class="titlefield">'.$langs->trans("Account").'</td>';
print '<td><strong>'.dol_escape_htmltag($fints->getIban()).'</strong></td>';
print '</tr>';
// Date from
print '<tr>';
print '<td>'.$langs->trans("DateFrom").'</td>';
print '<td>';
print $form->selectDate($dateFrom, 'date_from', 0, 0, 0, '', 1, 1);
print '</td>';
print '</tr>';
// Date to
print '<tr>';
print '<td>'.$langs->trans("DateTo").'</td>';
print '<td>';
print $form->selectDate($dateTo, 'date_to', 0, 0, 0, '', 1, 1);
print '</td>';
print '</tr>';
print '</table>';
print '</div>';
print '<div class="center">';
print '<input type="submit" class="button" value="'.$langs->trans("FetchStatements").'">';
// SecureGo Plus polling button
if (!empty($_SESSION['fints_state'])) {
print ' <a class="button" href="'.$_SERVER["PHP_SELF"].'?action=checktan&token='.newToken().'">'.$langs->trans("CheckSecureGoStatus").'</a>';
}
print '</div>';
print '</form>';
// JavaScript for automatic TAN polling
if (!empty($_SESSION['fints_state'])) {
print '<script type="text/javascript">
var tanPollingInterval = null;
var tanPollingCount = 0;
var tanPollingMaxAttempts = 60; // 3 minutes at 3 second intervals
function startTanPolling() {
if (tanPollingInterval) return;
document.getElementById("tan-status-container").style.display = "block";
updateTanStatus("Warte auf Bestätigung in SecureGo Plus App...", "info");
tanPollingInterval = setInterval(checkTanStatus, 3000);
// First check immediately
checkTanStatus();
}
function stopTanPolling() {
if (tanPollingInterval) {
clearInterval(tanPollingInterval);
tanPollingInterval = null;
}
}
function updateTanStatus(message, type) {
var statusEl = document.getElementById("tan-status-message");
statusEl.innerHTML = message;
statusEl.className = "tan-status-" + type;
}
function checkTanStatus() {
tanPollingCount++;
if (tanPollingCount > tanPollingMaxAttempts) {
stopTanPolling();
updateTanStatus("Zeitüberschreitung. Bitte erneut versuchen.", "error");
return;
}
updateTanStatus("Prüfe Status... (" + tanPollingCount + "/" + tanPollingMaxAttempts + ")", "info");
fetch("'.dol_buildpath('/bankimport/ajax/checktan.php', 1).'?token='.newToken().'", {
method: "GET",
headers: {
"Accept": "application/json"
}
})
.then(response => response.json())
.then(data => {
if (data.status === "success") {
stopTanPolling();
updateTanStatus("Erfolgreich! " + data.count + " Buchungen abgerufen.", "success");
// Display balance and transactions
displayBalance(data.balance);
if (data.transactions && data.transactions.length > 0) {
displayTransactions(data.transactions);
}
// Hide polling container after success
setTimeout(function() {
document.getElementById("tan-status-container").style.display = "none";
}, 3000);
} else if (data.status === "waiting") {
updateTanStatus("Warte auf Bestätigung in SecureGo Plus App... (" + tanPollingCount + "/" + tanPollingMaxAttempts + ")", "info");
} else if (data.status === "tan_required") {
// Another TAN required, keep polling
updateTanStatus("Weitere TAN erforderlich - bitte erneut bestätigen", "warning");
} else {
stopTanPolling();
updateTanStatus("Fehler: " + data.message, "error");
}
})
.catch(error => {
console.error("Error:", error);
// Don\'t stop on network errors, just retry
updateTanStatus("Netzwerkfehler, versuche erneut...", "warning");
});
}
function displayBalance(balance) {
if (!balance || !balance.amount) return;
var color = balance.amount >= 0 ? "green" : "red";
var date = balance.date || "";
var html = "<br><div class=\"info\" style=\"padding: 10px; background: #e8f4e8; border: 1px solid #4CAF50; border-radius: 4px; margin-bottom: 15px;\">";
html += "<strong>'.$langs->trans("AccountBalance").':</strong> ";
html += "<span style=\"color: " + color + "; font-size: 1.2em; font-weight: bold;\">" + formatCurrency(balance.amount) + " " + (balance.currency || "EUR") + "</span>";
html += " <span class=\"opacitymedium\">('.$langs->trans("AsOf").' " + date + ")</span>";
html += "</div>";
document.getElementById("balance-container").innerHTML = html;
}
function displayTransactions(transactions) {
var html = "<br><h3>'.$langs->trans("Transactions").' (" + transactions.length + ")</h3>";
html += "<table class=\"noborder centpercent\">";
html += "<tr class=\"liste_titre\">";
html += "<th>'.$langs->trans("Date").'</th>";
html += "<th>'.$langs->trans("Name").'</th>";
html += "<th>'.$langs->trans("Description").'</th>";
html += "<th class=\"right\">'.$langs->trans("Amount").'</th>";
html += "</tr>";
var totalCredit = 0;
var totalDebit = 0;
transactions.forEach(function(trans) {
var date = new Date(trans.date * 1000);
var dateStr = date.toLocaleDateString("de-DE");
var amountClass = trans.amount >= 0 ? "green" : "red";
var amountPrefix = trans.amount >= 0 ? "+" : "";
if (trans.amount >= 0) {
totalCredit += trans.amount;
} else {
totalDebit += Math.abs(trans.amount);
}
html += "<tr class=\"oddeven\">";
html += "<td class=\"nowraponall\">" + dateStr + "</td>";
html += "<td>" + escapeHtml(trans.name || "") + "</td>";
html += "<td class=\"small\">" + escapeHtml((trans.reference || "").substring(0, 80)) + "</td>";
html += "<td class=\"right nowraponall\"><span style=\"color: " + amountClass + ";\">" + amountPrefix + formatCurrency(trans.amount) + " EUR</span></td>";
html += "</tr>";
});
// Totals row
var balance = totalCredit - totalDebit;
var balanceColor = balance >= 0 ? "green" : "red";
html += "<tr class=\"liste_total\">";
html += "<td colspan=\"3\" class=\"right\">'.$langs->trans("Total").'</td>";
html += "<td class=\"right nowraponall\">";
html += "<span style=\"color: green;\">+" + formatCurrency(totalCredit) + " EUR</span>";
html += " / ";
html += "<span style=\"color: red;\">-" + formatCurrency(totalDebit) + " EUR</span>";
html += " = ";
html += "<strong style=\"color: " + balanceColor + ";\">" + formatCurrency(balance) + " EUR</strong>";
html += "</td>";
html += "</tr>";
html += "</table>";
// Import button
html += "<div class=\"center\" style=\"margin-top: 15px;\">";
html += "<form method=\"POST\" action=\"" + window.location.pathname + "\">";
html += "<input type=\"hidden\" name=\"token\" value=\"'.newToken().'\">";
html += "<input type=\"hidden\" name=\"action\" value=\"import\">";
html += "<input type=\"submit\" class=\"button button-save\" value=\"'.$langs->trans("ImportTransactions").'\">";
html += " <a class=\"button\" href=\"'.dol_buildpath('/bankimport/list.php', 1).'\">'.$langs->trans("ViewImportedTransactions").'</a>";
html += "</form>";
html += "</div>";
document.getElementById("transactions-container").innerHTML = html;
}
function escapeHtml(text) {
var div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function formatCurrency(amount) {
return amount.toFixed(2).replace(".", ",").replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}
// Start polling automatically when page loads
document.addEventListener("DOMContentLoaded", function() {
startTanPolling();
});
</script>
<style>
#tan-status-container {
margin: 20px 0;
padding: 15px;
border: 1px solid #ccc;
border-radius: 5px;
background: #f9f9f9;
}
.tan-status-info { color: #0066cc; }
.tan-status-success { color: #008800; font-weight: bold; }
.tan-status-warning { color: #cc6600; }
.tan-status-error { color: #cc0000; }
#tan-status-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 10px;
vertical-align: middle;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<div id="tan-status-container" style="display: none;">
<div id="tan-status-spinner"></div>
<span id="tan-status-message" class="tan-status-info">Initialisiere...</span>
</div>';
}
print '<div id="balance-container"></div>';
print '<div id="transactions-container">';
// Display account balance from bank
if (!empty($balance)) {
print '<br>';
print '<div class="info" style="padding: 10px; background: #e8f4e8; border: 1px solid #4CAF50; border-radius: 4px; margin-bottom: 15px;">';
print '<strong>'.$langs->trans("AccountBalance").':</strong> ';
$balColor = $balance['amount'] >= 0 ? 'green' : 'red';
print '<span style="color: '.$balColor.'; font-size: 1.2em; font-weight: bold;">'.price($balance['amount'], 0, $langs, 1, -1, 2, $balance['currency']).'</span>';
print ' <span class="opacitymedium">('.$langs->trans("AsOf").' '.dol_print_date(strtotime($balance['date']), 'day').')</span>';
print '</div>';
}
// Display transactions
if (!empty($transactions)) {
print '<br>';
print '<h3>'.$langs->trans("Transactions").' ('.count($transactions).')</h3>';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Date").'</th>';
print '<th>'.$langs->trans("Name").'</th>';
print '<th>'.$langs->trans("Description").'</th>';
print '<th class="right">'.$langs->trans("Amount").'</th>';
print '</tr>';
$totalCredit = 0;
$totalDebit = 0;
foreach ($transactions as $trans) {
print '<tr class="oddeven">';
print '<td class="nowraponall">'.dol_print_date($trans['date'], 'day').'</td>';
print '<td>'.dol_escape_htmltag($trans['name']).'</td>';
print '<td class="small">'.dol_escape_htmltag(dol_trunc($trans['reference'], 80)).'</td>';
print '<td class="right nowraponall">';
if ($trans['amount'] >= 0) {
$totalCredit += $trans['amount'];
print '<span class="amount" style="color: green;">+'.price($trans['amount'], 0, $langs, 1, -1, 2, 'EUR').'</span>';
} else {
$totalDebit += abs($trans['amount']);
print '<span class="amount" style="color: red;">'.price($trans['amount'], 0, $langs, 1, -1, 2, 'EUR').'</span>';
}
print '</td>';
print '</tr>';
}
// Totals
print '<tr class="liste_total">';
print '<td colspan="3" class="right">'.$langs->trans("Total").'</td>';
print '<td class="right nowraponall">';
print '<span style="color: green;">+'.price($totalCredit, 0, $langs, 1, -1, 2, 'EUR').'</span>';
print ' / ';
print '<span style="color: red;">-'.price($totalDebit, 0, $langs, 1, -1, 2, 'EUR').'</span>';
print ' = ';
$balance = $totalCredit - $totalDebit;
$balanceColor = $balance >= 0 ? 'green' : 'red';
print '<strong style="color: '.$balanceColor.';">'.price($balance, 0, $langs, 1, -1, 2, 'EUR').'</strong>';
print '</td>';
print '</tr>';
print '</table>';
// Import button
print '<div class="center" style="margin-top: 15px;">';
print '<form method="POST" action="'.$_SERVER["PHP_SELF"].'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="import">';
print '<input type="submit" class="button button-save" value="'.$langs->trans("ImportTransactions").'">';
print ' <a class="button" href="'.dol_buildpath('/bankimport/list.php', 1).'">'.$langs->trans("ViewImportedTransactions").'</a>';
print '</form>';
print '</div>';
}
print '</div>'; // End transactions-container
llxFooter();
$db->close();

22
vendor/autoload.php vendored Executable file
View file

@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInitcfc07b7e6c4a3dcfdcd6e754983b1a9b::getLoader();

579
vendor/composer/ClassLoader.php vendored Executable file
View file

@ -0,0 +1,579 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see https://www.php-fig.org/psr/psr-0/
* @see https://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
/** @var \Closure(string):void */
private static $includeFile;
/** @var string|null */
private $vendorDir;
// PSR-4
/**
* @var array<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr4 = array();
// PSR-0
/**
* List of PSR-0 prefixes
*
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
*
* @var array<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $classMap Class to filename map
*
* @return void
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*
* @return void
*/
public function add($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
$paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
$paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
$paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
$paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 base directories
*
* @return void
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*
* @return void
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*
* @return void
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*
* @return void
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*
* @return void
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
if (null === $this->vendorDir) {
return;
}
if ($prepend) {
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
} else {
unset(self::$registeredLoaders[$this->vendorDir]);
self::$registeredLoaders[$this->vendorDir] = $this;
}
}
/**
* Unregisters this instance as an autoloader.
*
* @return void
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
if (null !== $this->vendorDir) {
unset(self::$registeredLoaders[$this->vendorDir]);
}
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return true|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
$includeFile = self::$includeFile;
$includeFile($file);
return true;
}
return null;
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
/**
* Returns the currently registered loaders keyed by their corresponding vendor directories.
*
* @return array<string, self>
*/
public static function getRegisteredLoaders()
{
return self::$registeredLoaders;
}
/**
* @param string $class
* @param string $ext
* @return string|false
*/
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath . '\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
/**
* @return void
*/
private static function initializeIncludeClosure()
{
if (self::$includeFile !== null) {
return;
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*
* @param string $file
* @return void
*/
self::$includeFile = \Closure::bind(static function($file) {
include $file;
}, null, null);
}
}

396
vendor/composer/InstalledVersions.php vendored Executable file
View file

@ -0,0 +1,396 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer;
use Composer\Autoload\ClassLoader;
use Composer\Semver\VersionParser;
/**
* This class is copied in every Composer installed project and available to all
*
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
*
* To require its presence, you can require `composer-runtime-api ^2.0`
*
* @final
*/
class InstalledVersions
{
/**
* @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
* @internal
*/
private static $selfDir = null;
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
/**
* Returns a list of all package names which are present, either by being installed, replaced or provided
*
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackages()
{
$packages = array();
foreach (self::getInstalled() as $installed) {
$packages[] = array_keys($installed['versions']);
}
if (1 === \count($packages)) {
return $packages[0];
}
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
}
/**
* Returns a list of all package names with a specific type e.g. 'library'
*
* @param string $type
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackagesByType($type)
{
$packagesByType = array();
foreach (self::getInstalled() as $installed) {
foreach ($installed['versions'] as $name => $package) {
if (isset($package['type']) && $package['type'] === $type) {
$packagesByType[] = $name;
}
}
}
return $packagesByType;
}
/**
* Checks whether the given package is installed
*
* This also returns true if the package name is provided or replaced by another package
*
* @param string $packageName
* @param bool $includeDevRequirements
* @return bool
*/
public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
return false;
}
/**
* Checks whether the given package satisfies a version constraint
*
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
*
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
*
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
* @param string $packageName
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
* @return bool
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
}
/**
* Returns a version constraint representing all the range(s) which are installed for a given package
*
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
* whether a given version of a package is installed, and not just whether it exists
*
* @param string $packageName
* @return string Version constraint usable with composer/semver
*/
public static function getVersionRanges($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
$ranges = array();
if (isset($installed['versions'][$packageName]['pretty_version'])) {
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
}
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
}
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
}
if (array_key_exists('provided', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
}
return implode(' || ', $ranges);
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['version'])) {
return null;
}
return $installed['versions'][$packageName]['version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getPrettyVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
return null;
}
return $installed['versions'][$packageName]['pretty_version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
*/
public static function getReference($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['reference'])) {
return null;
}
return $installed['versions'][$packageName]['reference'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
*/
public static function getInstallPath($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @return array
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
$installed = self::getInstalled();
return $installed[0]['root'];
}
/**
* Returns the raw installed.php data for custom implementations
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
}
}
return self::$installed;
}
/**
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
return self::getInstalled();
}
/**
* Lets you reload the static array from another file
*
* This is only useful for complex integrations in which a project needs to use
* this class but then also needs to execute another project's autoloader in process,
* and wants to ensure both projects have access to their version of installed.php.
*
* A typical case would be PHPUnit, where it would need to make sure it reads all
* the data it needs from this class, then call reload() with
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
* the project in which it runs can then also use this class safely, without
* interference between PHPUnit's dependencies and the project's dependencies.
*
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
self::$installed = $data;
self::$installedByVendor = array();
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
// so we have to assume it does not, and that may result in duplicate data being returned when listing
// all installed packages for example
self::$installedIsLocalDir = false;
}
/**
* @return string
*/
private static function getSelfDir()
{
if (self::$selfDir === null) {
self::$selfDir = strtr(__DIR__, '\\', '/');
}
return self::$selfDir;
}
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
if (null === self::$canGetVendors) {
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
$selfDir = self::getSelfDir();
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
$vendorDir = strtr($vendorDir, '\\', '/');
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
self::$installed = $required;
self::$installedIsLocalDir = true;
}
}
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
$copiedLocalDir = true;
}
}
}
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}

21
vendor/composer/LICENSE vendored Executable file
View file

@ -0,0 +1,21 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

11
vendor/composer/autoload_classmap.php vendored Executable file
View file

@ -0,0 +1,11 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'BankImportFinTS' => $baseDir . '/class/fints.class.php',
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
);

11
vendor/composer/autoload_namespaces.php vendored Executable file
View file

@ -0,0 +1,11 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Tests\\Fhp' => array($vendorDir . '/nemiah/php-fints/lib'),
'Fhp' => array($vendorDir . '/nemiah/php-fints/lib'),
);

9
vendor/composer/autoload_psr4.php vendored Executable file
View file

@ -0,0 +1,9 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
);

38
vendor/composer/autoload_real.php vendored Executable file
View file

@ -0,0 +1,38 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInitcfc07b7e6c4a3dcfdcd6e754983b1a9b
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInitcfc07b7e6c4a3dcfdcd6e754983b1a9b', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInitcfc07b7e6c4a3dcfdcd6e754983b1a9b', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInitcfc07b7e6c4a3dcfdcd6e754983b1a9b::getInitializer($loader));
$loader->register(true);
return $loader;
}
}

39
vendor/composer/autoload_static.php vendored Executable file
View file

@ -0,0 +1,39 @@
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInitcfc07b7e6c4a3dcfdcd6e754983b1a9b
{
public static $prefixesPsr0 = array (
'T' =>
array (
'Tests\\Fhp' =>
array (
0 => __DIR__ . '/..' . '/nemiah/php-fints/lib',
),
),
'F' =>
array (
'Fhp' =>
array (
0 => __DIR__ . '/..' . '/nemiah/php-fints/lib',
),
),
);
public static $classMap = array (
'BankImportFinTS' => __DIR__ . '/../..' . '/class/fints.class.php',
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
);
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixesPsr0 = ComposerStaticInitcfc07b7e6c4a3dcfdcd6e754983b1a9b::$prefixesPsr0;
$loader->classMap = ComposerStaticInitcfc07b7e6c4a3dcfdcd6e754983b1a9b::$classMap;
}, null, ClassLoader::class);
}
}

58
vendor/composer/installed.json vendored Executable file
View file

@ -0,0 +1,58 @@
{
"packages": [
{
"name": "nemiah/php-fints",
"version": "3.7.0",
"version_normalized": "3.7.0.0",
"source": {
"type": "git",
"url": "https://github.com/nemiah/phpFinTS.git",
"reference": "08257e10229db2d4ca8c54ed7fec0f390b332519"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nemiah/phpFinTS/zipball/08257e10229db2d4ca8c54ed7fec0f390b332519",
"reference": "08257e10229db2d4ca8c54ed7fec0f390b332519",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-mbstring": "*",
"php": ">=8.0",
"psr/log": "^1|^2|^3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"php-mock/php-mock-phpunit": "^2.6",
"phpunit/phpunit": "^9.5"
},
"suggest": {
"abcaeffchen/sephpa": "1.*",
"monolog/monolog": "Allow sending log messages to a variety of different handlers",
"nemiah/php-sepa-xml": "dev-master"
},
"time": "2025-10-14T15:05:56+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-0": {
"Fhp": "lib/",
"Tests\\Fhp": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP Library for the protocols fints and hbci",
"homepage": "https://github.com/nemiah/phpFinTS",
"support": {
"issues": "https://github.com/nemiah/phpFinTS/issues",
"source": "https://github.com/nemiah/phpFinTS/tree/3.7"
},
"install-path": "../nemiah/php-fints"
}
],
"dev": true,
"dev-package-names": []
}

38
vendor/composer/installed.php vendored Executable file
View file

@ -0,0 +1,38 @@
<?php return array(
'root' => array(
'name' => 'dolibarr/bankimport',
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'type' => 'dolibarr-module',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev' => true,
),
'versions' => array(
'dolibarr/bankimport' => array(
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'type' => 'dolibarr-module',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev_requirement' => false,
),
'nemiah/php-fints' => array(
'pretty_version' => '3.7.0',
'version' => '3.7.0.0',
'reference' => '08257e10229db2d4ca8c54ed7fec0f390b332519',
'type' => 'library',
'install_path' => __DIR__ . '/../nemiah/php-fints',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/log' => array(
'dev_requirement' => false,
'replaced' => array(
0 => '*',
),
),
),
);

25
vendor/composer/platform_check.php vendored Executable file
View file

@ -0,0 +1,25 @@
<?php
// platform_check.php @generated by Composer
$issues = array();
if (!(PHP_VERSION_ID >= 80000)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.0.0". You are running ' . PHP_VERSION . '.';
}
if ($issues) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
} elseif (!headers_sent()) {
echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
}
}
throw new \RuntimeException(
'Composer detected issues in your platform: ' . implode(' ', $issues)
);
}

30
vendor/nemiah/php-fints/.php-cs-fixer.php vendored Executable file
View file

@ -0,0 +1,30 @@
<?php
// This is based on the `@Symfony` rule set documented in `vendor/friendsofphp/php-cs-fixer/doc/ruleSets/Symfony.rst`.
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/lib');
return (new PhpCsFixer\Config())
->setRules([
// We essentially use the Symfony style guide.
'@Symfony' => true,
// But then we have some exclusions, i.e. we disable some of the checks/rules from Symfony:
// Logic
'yoda_style' => FALSE, // Allow both Yoda-style and regular comparisons.
// Whitespace
'blank_line_before_statement' => FALSE, // Don't put blank lines before `return` statements.
'concat_space' => FALSE, // Allow spaces around string concatenation operator.
'blank_line_after_opening_tag' => FALSE, // Allow file-level @noinspection suppressions to live on the `<?php` line.
'single_line_throw' => FALSE, // Allow `throw` statements to span multiple lines.
// phpDoc
'phpdoc_align' => FALSE, // Don't add spaces within phpDoc just to make parameter names / descriptions align.
'phpdoc_annotation_without_dot' => FALSE, // Allow terminating dot on @param and such.
'phpdoc_no_alias_tag' => FALSE, // Allow @link in addition to @see.
'phpdoc_separation' => FALSE, // Don't put blank line between @params, @throws and @return.
'phpdoc_summary' => FALSE, // Don't force terminating dot on the first line.
])
->setFinder($finder);

12
vendor/nemiah/php-fints/.travis.yml vendored Executable file
View file

@ -0,0 +1,12 @@
language: php
install: composer install
script:
- ./disallowtabs.sh
- ./csfixer-check.sh
- ./phplint.sh ./lib/
- ./vendor/bin/phpunit
dist: bionic
php:
- '8.0'
- '8.1.0'
- '8.2.0'

200
vendor/nemiah/php-fints/DEVELOPER-GUIDE.md vendored Executable file
View file

@ -0,0 +1,200 @@
## Specification
This library implements parts of FinTS V3.0, which is specified [here](https://www.hbci-zka.de/spec/3_0.htm).
Before version 3, the standard was called HBCI.
Today, HBCI still refers to the security part of the specification.
The specification is split into several documents:
* [Hauptdokument] Unimportant index document that gives an overview of the other documents.
* [Formals] Wire format and basic protocol (dialog, BPD, UPD).
* [Rückmeldungen] Directory of response codes that the bank can send.
* [Security HBCI] Protocol for encryption and cryptographic signatures.
* [Security PIN/TAN] Protocol for PIN/TAN authentication, based on the HBCI security protocol, but without actual encryption/signatures.
* [Messages Geschäftsvorfälle] Various business transactions (account statements, wire transfers, etc.).
* [Messages Finanzdatenformate] Wire formats other than the FinTS format itself (e.g. DTAUS and MT 940) that are used for certain transactions.
[Hauptdokument]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Master_2018-11-29.pdf
[Formals]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf
[Rückmeldungen]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/FinTS_Rueckmeldungscodes_2019-07-22_final_version.pdf
[Security HBCI]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_HBCI_Rel_20181129_final_version.pdf
[Security PIN/TAN]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2018-02-23_final_version.pdf
[Messages Geschäftsvorfälle]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Geschaeftsvorfaelle_2015-08-07_final_version.pdf
[Messages Finanzdatenformate]: https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Finanzdatenformate_2010-08-06_final_version.pdf
Note that there is also an [archive](https://www.hbci-zka.de/spec/spec_archiv.htm) with older versions of the HBCI specification.
## High-level concepts and structure of the library
A **message** (implemented in `Fhp\Protocol\Message`) is a series of **segments** (implemented in `Fhp\Segment\...`),
which themselves consist of **data elements (DEs)** and **data element groups (DEGs)**.
Each segment starts with an identifier to declare its type. Example:
```
HNHBK:1:3+000000000079+300+dialogID+2'HKEND:2:1+dialogID'HNHBS:3:1+2'
```
This message contains three segments.
The middle segment is `HKEND` version `1` at position `2`. It only contains one data element with value `dialogId`.
The first segment is `HNHBK`. It contains four data elements `000000000079`, ..., `2`.
The **syntactical representation (wire format)** (see [Formals]) used when transmitting/storing messages and segments is
described below and implemented in `Fhp\Syntax`.
The **protocol** specifies which messages a client can send (requests) and how the server can respond to them (responses).
There is exactly one response per request, the client needs to wait for the response before sending another request.
While FinTs is specified independently of the underlying transport layer, all banks use TLS 3.0 in practice, so it's
very similar to HTTPS and the server addresses are given as `https://` URIs as well.
The most basic protocol is the **dialog** (see [Formals], implemented in `Fhp\Protocol\DialogInitialization` and `FinTs`),
which combines elements of a handshake (as in TCP/TLS) and a session (i.e. authentication through cookies/tokens).
Before sending any other messages, the client must initialize a dialog.
Most dialogs require authentication, so the initialization is comparable to a login.
While all of the above is part of the FinTS infrastructure, the **business transactions** (the part of the protocol that is
ultimately useful for users) are specified separately in [Messages Geschäftsvorfälle] (implemented in `Fhp\Action\...`).
In addition to these, bank can specify their own actions (would be implemented in separate dependent libraries, but
currently there are none).
Separately from the FinTS specification, this library implements a **user-facing API** in `Fhp\Model` (in addition to
the `FinTs` class and the `Fhp\Action` namespace) to provide a simpler and more stable interface over time.
NOTE: The PHP namespaces `DataElementGroups`, `Dialog` and `Response` (and their contents) are deprecated.
## Segment/DEG schema
### Segment classes
In this library, segments are implemented in classes named like `HKTANv6` that inherit from `BaseSegment`.
Each of these classes is mapped 1:1 from the corresponding chapter of the specification document.
The class name consists of the segment identifier (e.g. `HKTAN`) and the version (just an integer prefixed by `v`).
The segment identifier itself has two parts:
In `HXyyy`, the `H` is constant (for "HBCI"), `yyy` is an abbreviation of the name of the respective functionality that
the segment provides, and `X` is one of `K`, `I` or `N` depending on whether it is a request segment sent by the client
("Kunde" = customer), a response segment sent by the server ("(Kredit-)Institut" = bank) or both ("Nachricht" = message).
The `HIyyyS` segments suffixed by `S` contain meta information (called "parameters") that describe what constitues a
valid `HKyyy` request.
The class `HXyyyvN` that implements version `N` of a particular segment type `yyy` can either be placed in the PHP
namespace `Fhp\Segment\HXyyy` (to group versions together, used especially when `X`=`N`) or `Fhp\Segment\yyy` (to group
corresponding request and reponse segments together).
### DEG classes
Analogously to segments, data element groups (DEGs) are mapped to classes that inherit from `BaseDeg`.
The class name is the title of the sub-section that specifies the DEG structure in the specification document.
The version suffix (`VN` with capital `V`) is optional for DEGs.
The naming does not have to be as strictly deterministic as with segments because DEGs are referenced explicitly in
code from the respective segments that contain them.
### Segment/DEG interfaces
Especially when there are multiple supported versions of the same segment/DEG type (e.g. `HKKAZv6` and `HKKAZv7`), it
makes sense to also have a common `interface HKKAZ` implemented by all versions that defines getters for the common
fields, so that business logic code can access all versions transparently.
### Member fields / data elements
As an example, consider the `HIUPDv6` class that implements the "Kontoinformation" segment from page 88 (PDF page 96)
from the [Formals] document.
Within each segment/DEG class, the elements defined in the specification are translated one by one to class fields.
It is important that the fields occur in the *same order* as in the specification and that no fields are left out, because
the absolute index/position of each field in the segment class must map to its index in HBCI's wire format.
Segment classes may inherit from other segment classes, in which case the fields of the parent class come first.
The name of the field/element is taken directly from the specification, so it is usually in German.
Special characters like Umlauts are replaced with their expanded versions and the whole name is transformed to camel case.
The element types (specified in [Formals] section B.4) are mapped to PHP types as follows:
- `jn` (yes/no) becomes `bool`.
- `num` (numerical) and `dig` (single digit) become `int`.
- `float` and `wrt` (amounts) become `float`.
- `an` (alpha-numerical), `txt` (text), `id` (identifiers), `ctr` (country codes, despite being numerical) and `cur`
(currency codes) become `string` and the maximum length is documented in phpDoc.
- `code` (enum) is resolved to the type of the underlying value type (usually `string` or `int`) and the allowed values
are documented in phpDoc.
- `bin` uses the `Fhp\Syntax\Bin` class and the maximum length is documented in phpDoc.
- `dat` and `tim` are currently mapped to `string`, but could get their own class in `Fhp\DataTypes` in future.
- Any data element group is implemented as a separate DEG class (see above), so that the PHP field can reference that
class name as its type.
The "Status" of each element is mapped as follows:
- Mandatory fields ("M" in the specification) use the plain type as described above.
- Optional fields ("O") append `|null`.
- Conditional fields ("C") are resolved as much as possible given the context of the segment and this library in general
(often only one of the conditions is always satisfied, so it is clear whether the field is mandatory, optional or
disallowed whenever it is used in this library), and otherwise mapped to `|null` as well.
The "Anzahl" (cardinality) is mapped as follows:
- If only `1` is allowed, use the plain (possibly nullable) type as described above.
- Otherwise append `[]` to the type and add a `@Max(N)` annotation for the maximum number. E.g. a `jn` field that can be
repeated at most 20 times becomes `@var int[] @Max(20)`.
- If `0` is within the allowed range, add `|null`, e.g. `@var int[]|null @Max(20)`.
Any other information that is given in the specification like maximum length, restrictions on when the field can be
used or not, and the guidelines ("Belegungsrichtlinien"), are added to the phpDoc in English (translated) if useful.
## Wire format
Segments are terminated (and thus also delimited) by `'`.
They contain a series of DEs and DEGs, delimited by `:` and `+`.
Note that these delimiters aren't very consistent (see below).
The values essentially form an ordered tree, where the leaves are data elements (similar to scalar values), inner nodes
are data element groups (similar to compound values) and the roots are segments.
The entries of a segment (i.e. the second level of the tree), which can be a mix of DEs and DEGs, are delimited by `+`.
The entries of any lower-level inner node are delimited by `:`.
Such a tree can be arbitrarily deep, which leads to ambiguities.
For instance, the wire format of a DEG `X` that contains nothing but a nested DEG `Y` is indistinguishable from the wire
format of `Y` alone.
With repeated and optional elements, there are even more ambiguities, but the specification requires "empty" elements to
clarify in such situations.
While the wire format's syntax can be recognized and parsed without knowing what the contents mean (similar to JSON or
XML), the FinTs wire format is not self-describing on the semantic level, so the segment definitions are needed to
understand the contents (similar to protocol buffers and other binary serialization formats).
However, each segment begins with a `Segmentkopf` as its first entry, which declares the type of the segment and allows
picking the right segment definition/schema for parsing.
### Prettifier scripts
For debugging purposes, it can be useful to render serialized messages/segments in a more readable (self-describing)
format, specifically with the field names inlined.
The `prettify_message.php` and `prettify_segment.php` scripts do exactly that, given the wire format on stdin.
## PSD2 PIN/TAN authentication
The PIN/TAN specification piggy-backs on the HBCI encryption/signature specification that was originally intended for
cryptographically secure communication based on private keys contained in physical tokens like HBCI chip cards.
Nowadays, the surrounding transport layer (TLS) already provides the cryptographic security based on the public-key
infrastructure of the WWW, so that proper encryption and signatures are not necessary anymore on the FinTS level.
Nevertheless, all messages need to be wrapped in the respective envelope (implemented in `Fhp\Protocol\Message`).
This envelope also carries PIN and TAN whenever they need to be sent.
In addition to the envelope, the `HKTAN` family of segments are used to communicate whether a TAN is needed, what it
needs to look like, what it is for when it is being sent, and whether it was accepted on the bank side.
`HKTAN` version 6 is equivalent to PSD2.
In practice, with PSD2 for the first time, several German banks started asking for a TAN even upon login, which can
sometimes lead to problems depending on how they implement the protocol, given that `HKTAN` was designed to authenticate
business transactions *within* a dialog, but is now used to *initialize* a dialog as well.
### Tests
Very few banks provide a sandbox environment with fake accounts for testing purposes.
So most developers manual tests against their real bank accounts.
To minify the impact on these accounts (undesired transactions, account locks due to failed login attempts), automated
integration testing (against fake backends) has proven very useful (in addition to the usual regression-catching
benefits of those tests).
The `CLILogger` class can be used to record requests/responses during a (manually scripted) dialog with the bank.
The recorded messages can then be filled into a `PhpUnit` test.
When used through `FinTs::setLogger()`, the logger already replaces the most important sensitive values
(username, PIN, ...) with placeholders.
But the recorded segments regularly still contain personal information (e.g. IBANs and names of other parties involved
in transactions, descriptions of transactions, etc.).
In order to remove this information, it is advisable not to change the overall length (i.e. using the `Ins` mode in the
code editor to overwrite it with some dummy data), because the wire format hard-codes the length of subsequent data in
various places and simply removing it would result in parsing failures.
For examples, see `Tests\Fhp\Integration\...`.

21
vendor/nemiah/php-fints/LICENSE vendored Executable file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2016 Markus Schindler <mail@markus-schindler.de>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

89
vendor/nemiah/php-fints/README.md vendored Executable file
View file

@ -0,0 +1,89 @@
# PHP FinTS/HBCI library
[![Build Status](https://travis-ci.org/nemiah/phpFinTS.svg?branch=master)](https://travis-ci.org/nemiah/phpFinTS)
A PHP library implementing the following functions of the FinTS/HBCI protocol:
* Get accounts
* Get balance
* Get transactions
* Execute direct debit
* Execute transfer
* Note that any other functions mentioned in
[section C of the specification](https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Geschaeftsvorfaelle_2015-08-07_final_version.pdf)
should be relatively straightfoward to implement.
Forked from [mschindler83/fints-hbci-php](https://github.com/mschindler83/fints-hbci-php), but then mostly reimplemented.
## Getting Started
Before using this library (or any other FinTS library), you have to register your application with
[Die Deutsche Kreditwirtschaft](https://www.hbci-zka.de/register/hersteller.htm) in order to get your registration
number.
Note that this process can take several weeks.
First you receive your registration number **after a couple days, but then you have to wait anywhere between 0 and 8+ weeks**
for the registration to reach your bank's server. If you have multiple banks, it probably reaches them at different times.
Then install the library via composer:
```
composer require nemiah/php-fints
```
See the examples in the "[Samples](/Samples)" folder to get started on your code.
Fill out the required configuration in `init.php` (server details can be obtained at
[www.hbci-zka.de](https://www.hbci-zka.de) after registration).
Then execute `tanModesAndMedia.php` and later `login.php`.
Once you are able to login without any issues, you can move on to the other examples.
## Banks with special needs
If you are developing an online banking application with this library, please be aware of the following exceptions:
### Hypovereinsbank
The BLZ 71120078 will throw an "Unbekanntes Kreditinstitut" exception when used with the URL https://hbci-01.hypovereinsbank.de/bank/hbci.
You have to use BLZ 70020270 instead.
```
if (trim($url) == 'https://hbci-01.hypovereinsbank.de/bank/hbci')
$blz = '70020270';
```
### ING Diba
This bank does not support PSD2:
```
if(trim($blz) == "50010517")
$fints->selectTanMode(new Fhp\Model\NoPsd2TanMode());
```
## Contribute
Contributions are welcome! See the [developer guide](DEVELOPER-GUIDE.md) for some background information.
We use a slightly modified version of the [Symfony Coding-Style](https://symfony.com/doc/current/contributing/code/standards.html).
Please run
```
composer update
```
and
```
composer cs-fix
```
before sending a PR.
### Bank compatibility
Different banks implement different versions of the HBCI and FinTS specifications, and they also interpret the
specification differently sometimes. In addition, banks behave differently (within the boundaries of the specification)
when it comes to validation (some may tolerate slightly wrong requests), TANs (some ask for TANs more often than others)
and allowed parameters (not all banks support all parameter combinations).
This library aims to be compatible with all banks that support [FinTS V3.0](https://www.hbci-zka.de/spec/3_0.htm) and
PIN/TAN-based authentication according to PSD2 regulations, which includes most relevant German banks. Currently, it
works with the most popular banks at least, and probably with most others too. Some corner cases (e.g. Mehrfach-TANs or
SMS-Abbuchungskonto for mTAN fees) are not and probably will not be supported.
Those banks with a dedicated [integration test](/lib/Tests/Fhp/Integration) have been tested most extensively.
If you encounter any problems with your particular bank, please check for open GitHub issues or open a new one.

20
vendor/nemiah/php-fints/Samples/accounts.php vendored Executable file
View file

@ -0,0 +1,20 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Displays the available accounts
*/
// See login.php, it returns a FinTs instance that is already logged in.
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';
// Just pick the first account, for demonstration purposes. You could also have the user choose, or have SEPAAccount
// hard-coded and not call getSEPAAccounts() at all.
$getSepaAccounts = \Fhp\Action\GetSEPAAccounts::create();
$fints->execute($getSepaAccounts);
if ($getSepaAccounts->needsTan()) {
handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
}
print_r($getSepaAccounts->getAccounts());

35
vendor/nemiah/php-fints/Samples/balance.php vendored Executable file
View file

@ -0,0 +1,35 @@
<?php /** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Displays the current balance of all accounts.
*/
// See login.php, it returns a FinTs instance that is already logged in.
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';
// Just pick the first account for the request, though we will request the balance of all accounts.
$getSepaAccounts = \Fhp\Action\GetSEPAAccounts::create();
$fints->execute($getSepaAccounts);
if ($getSepaAccounts->needsTan()) {
handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
}
$oneAccount = $getSepaAccounts->getAccounts()[0];
$getBalance = \Fhp\Action\GetBalance::create($oneAccount, true);
$fints->execute($getBalance);
if ($getBalance->needsTan()) {
handleStrongAuthentication($getBalance); // See login.php for the implementation.
}
/** @var \Fhp\Segment\SAL\HISAL $hisal */
foreach ($getBalance->getBalances() as $hisal) {
$accNo = $hisal->getAccountInfo()->getAccountNumber();
if ($hisal->getKontoproduktbezeichnung() !== null) {
$accNo .= ' (' . $hisal->getKontoproduktbezeichnung() . ')';
}
$amnt = $hisal->getGebuchterSaldo()->getAmount();
$curr = $hisal->getGebuchterSaldo()->getCurrency();
$date = $hisal->getGebuchterSaldo()->getTimestamp()->format('Y-m-d');
echo "On $accNo you have $amnt $curr as of $date.\n";
}

18
vendor/nemiah/php-fints/Samples/bpd.php vendored Executable file
View file

@ -0,0 +1,18 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Fetches the BPD (bank parameter data) without having any user-specific credentials. This is mostly useful
* for advanced applications or to explore the bank's FinTS features without having/risking own credentials.
*/
require '../vendor/autoload.php';
$options = new \Fhp\Options\FinTsOptions();
$options->url = 'https://banking-dkb.s-fints-pt-dkb.de/fints30'; // HBCI / FinTS Url can be found here: https://www.hbci-zka.de/institute/institut_auswahl.htm (use the PIN/TAN URL)
$options->bankCode = '12030000'; // Your bank code / Bankleitzahl
$options->productName = 'Dummy'; // The number you receive after registration / FinTS-Registrierungsnummer. Not all banks require this just to retrieve the BPD.
$options->productVersion = '1.0'; // Your own Software product version
$bpd = \Fhp\FinTs::fetchBpd($options, new \Tests\Fhp\CLILogger());
print_r($bpd); // Tip: Put a breakpoint on this line.

252
vendor/nemiah/php-fints/Samples/browser.php vendored Executable file
View file

@ -0,0 +1,252 @@
<?php /** @noinspection PhpUnhandledExceptionInspection */
/** @noinspection PhpComposerExtensionStubsInspection */
/**
* SAMPLE - Does the whole login procedure in a browser and then displays the current balance of all accounts.
*
* To run it:
* 1. $ php -S 0.0.0.0:8080 -t ./Samples
* 2. http://localhost:8080/browser.php
*/
// IMPORTANT: This implementation serves only to demonstrate how the phpFinTS library can be used in a web application
// setting. It follows no coding best practices. Given that these applications handle sensitive data like bank
// credentials and financial information, any real application should follow security-related best practices like XSRF
// protection, encryption, etc., and this application should not be deployed to a publicly accessible web server.
require '../vendor/autoload.php';
function exception_error_handler($errno, $errstr, $errfile, $errline)
{
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
}
set_error_handler('exception_error_handler');
$request = json_decode(file_get_contents('php://input'));
if (isset($request->action)) {
$options = new \Fhp\Options\FinTsOptions();
$options->productName = $request->productName;
$options->productVersion = $request->productVersion;
$options->url = $request->url;
$options->bankCode = $request->bankCode;
$credentials = \Fhp\Options\Credentials::create($request->username, $request->pin);
$persistedInstance = $persistedAction = null;
function handleRequest(\stdClass $request, \Fhp\FinTs $fints)
{
global $persistedAction;
switch ($request->action) {
case 'getTanModes':
return array_map(function ($mode) {
return [
'id' => $mode->getId(), 'name' => $mode->getName(), 'isDecoupled' => $mode->isDecoupled(),
'needsTanMedium' => $mode->needsTanMedium(),
];
}, array_values($fints->getTanModes()));
case 'getTanMedia':
return array_map(function ($medium) {
return ['name' => $medium->getName(), 'phoneNumber' => $medium->getPhoneNumber()];
}, $fints->getTanMedia(intval($request->tanmode)));
case 'login':
$fints->selectTanMode(intval($request->tanmode), $request->tanmedium ?? null);
$login = $fints->login();
if ($login->needsTan()) {
$tanRequest = $login->getTanRequest();
$persistedAction = serialize($login);
return ['result' => 'needsTan', 'challenge' => $tanRequest->getChallenge()];
}
return ['result' => 'success'];
case 'submitTan':
$fints->submitTan(unserialize($persistedAction), $request->tan);
$persistedAction = null;
return ['result' => 'success'];
case 'checkDecoupledSubmission':
if ($fints->checkDecoupledSubmission(unserialize($persistedAction))) {
$persistedAction = null;
return ['result' => 'success'];
} else {
// IMPORTANT: If you pull this example code apart in your real application code, remember that after
// calling checkDecoupledSubmission(), you need to call $fints->persist() again, just like this
// example code will do after we return from handleRequest() here.
return ['result' => 'ongoing'];
}
case 'getBalances':
$getAccounts = \Fhp\Action\GetSEPAAccounts::create();
$fints->execute($getAccounts);
if ($getAccounts->needsTan()) {
throw new \Fhp\UnsupportedException(
"This simple example code does not support strong authentication on GetSEPAAccounts calls. " .
"But in your real application, you can do so analogously to how login() is handled above."
);
}
$getBalances = \Fhp\Action\GetBalance::create($getAccounts->getAccounts()[0], true);
$fints->execute($getBalances);
if ($getAccounts->needsTan()) {
throw new \Fhp\UnsupportedException(
"This simple example code does not support strong authentication on GetBalance calls. " .
"But in your real application, you can do so analogously to how login() is handled above."
);
}
$balances = [];
foreach ($getBalances->getBalances() as $balance) {
$sdo = $balance->getGebuchterSaldo();
$balances[$balance->getAccountInfo()->getAccountNumber()] =
$sdo->getAmount() . ' ' . $sdo->getCurrency();
}
return $balances;
case 'logout':
$fints->close();
return ['result' => 'success'];
default:
throw new \InvalidArgumentException("Unknown action $request->action");
}
}
$sessionfile = __DIR__ . "/session_$request->sessionid.data";
if (file_exists($sessionfile)) {
list($persistedInstance, $persistedAction) = unserialize(file_get_contents($sessionfile));
}
$fints = \Fhp\FinTs::new($options, $credentials, $persistedInstance);
$response = handleRequest($request, $fints);
file_put_contents($sessionfile, serialize([$fints->persist(), $persistedAction]));
header('Content-Type: application/json');
echo json_encode($response);
return;
}
?>
<!doctype html>
<html lang="de">
<head>
<title>phpFinTS Beispielanwendung</title>
<style>
fieldset { border: none; }
td:first-child { text-align: right; }
</style>
</head>
<body>
<h1>phpFinTS Beispielanwendung</h1>
<p>Diese Beispielanwendung meldet sich im Onlinebanking an und holt die aktuellen Kontostände ab.</p>
<p><b>HINWEIS:</b> Wenn sich Bank oder Benutzer ändern, sollte diese Seite erst neu geladen werden!</p>
<form id="form">
<input type="hidden" name="sessionid" id="sessionid"/>
<fieldset id="fieldset">
<table>
<tr><td><a target="_blank" href="https://www.hbci-zka.de/register/prod_register.htm">Registrierungsnummer</a>:</td>
<td><input type="text" name="productName"/></td></tr>
<tr><td>Produktversion:</td><td><input type="text" name="productVersion" value="1.0"/></td></tr>
<tr><td>Bank URL:</td><td><input type="text" name="url" value="https://banking-dkb.s-fints-pt-dkb.de/fints30"/></td></tr>
<tr><td>Bankleitzahl:</td><td><input type="text" name="bankCode" value="12030000"/></td></tr>
<tr><td>Benutzerkennung:</td><td><input type="text" name="username"/></td></tr>
<tr><td>Passwort/PIN:</td><td><input type="password" name="pin"/></td></tr>
<tr id="tanmodeRow" style="display: none"><td>TAN-Modus:</td><td><select name="tanmode" id="tanmode"></select></td></tr>
<tr id="tanmediumRow" style="display: none"><td>TAN-Medium:</td><td><select name="tanmedium" id="tanmedium"></select></td></tr>
<tr><td></td><td><button id="submit">Los geht's</button></td></tr>
</table>
</fieldset>
</form>
<pre id="output"></pre>
<script>
document.getElementById('sessionid').value = new Date().getTime();
document.getElementById('submit').onclick = async (e) => {
e.preventDefault();
const form = document.getElementById('form');
const formData = Object.fromEntries([...new FormData(form)]);
const fieldset = document.getElementById('fieldset');
async function post(action, additionalParams) {
const response = await fetch('browser.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({action, ...formData, ...additionalParams}),
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
}
if (response.headers.get('Content-Type').startsWith('text/html')) { // PHP error
document.getElementById('output').innerHTML = await response.text();
throw new Error('PHP error, click OK to see details below.');
}
return response.json();
}
fieldset.disabled = true;
document.getElementById('output').innerText = '';
try {
// First the user needs to select a TAN mode. If they haven't already, maybe we need to fetch them first.
const tanmode = document.getElementById('tanmode');
if (!tanmode.value) {
while (tanmode.firstChild) tanmode.firstChild.remove();
for (const mode of await post('getTanModes')) {
const option = document.createElement('option');
option.setAttribute('value', mode.id);
option.appendChild(document.createTextNode(mode.name));
option.tanmode = mode;
tanmode.appendChild(option);
}
document.getElementById('tanmodeRow').style.display = '';
alert('Bitte einen TAN-Modus auswählen.');
return;
}
// If the TAN mode requires it, the user also needs to select a TAN medium.
const selectedMode = tanmode.options[tanmode.selectedIndex].tanmode;
const tanmedium = document.getElementById('tanmedium');
if (selectedMode.needsTanMedium && !tanmedium.value) {
while (tanmedium.firstChild) tanmedium.firstChild.remove();
for (const medium of await post('getTanMedia')) {
const option = document.createElement('option');
option.setAttribute('value', medium.name);
let text = medium.name;
if (medium.phoneNumber) text += ` (${medium.phoneNumber})`;
option.appendChild(document.createTextNode(text));
tanmedium.appendChild(option);
}
document.getElementById('tanmediumRow').style.display = '';
alert('Bitte ein TAN-Medium auswählen.');
return;
}
// Helper function for TAN/decoupled authentication handling.
async function handleStrongAuthentication(responsePromise) {
let response = await responsePromise;
if (response.result === 'needsTan') {
if (selectedMode.isDecoupled) {
do {
alert('Bitte bestätigen Sie die Aktion auf Ihrem Gerät und klicken Sie dann auf OK.');
response = await post('checkDecoupledSubmission');
} while (response.result === 'ongoing');
} else {
const tan = prompt('Bitte die TAN eingeben. Bank sagt: ' + response.challenge);
response = await post('submitTan', {tan});
}
}
if (response.result !== 'success') {
throw new Error(`Unexpected result ${response.result}`);
}
return response;
}
// Now we have everything we need to log in.
await handleStrongAuthentication(post('login'));
// Now that we're logged in, we can grab the balances.
const balances = await post('getBalances');
document.getElementById('output').innerText = JSON.stringify(balances);
// And let's log out.
await post('logout');
} catch (e) {
console.log(e);
alert(e);
} finally {
fieldset.disabled = false;
}
};
</script>
</body>
</html>

View file

@ -0,0 +1,51 @@
<?php
/** @noinspection PhpUndefinedMethodInspection */
/** @noinspection PhpUndefinedClassInspection */
/** @noinspection PhpUndefinedNamespaceInspection */
/** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Send a direct debit request
*
* Note: The phpFinTs library only implements the FinTS protocol. For SEPA transfers, you need a separate library to
* produce the SEPA XML data, which is then wrapped into FinTS requests. This example uses the Sephpa library
* (see https://github.com/AbcAeffchen/Sephpa), which you need to install separately.
*/
// See login.php, it returns a FinTs instance that is already logged in.
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';
// Just pick the first account, for demonstration purposes. You could also have the user choose, or have SEPAAccount
// hard-coded and not call getSEPAAccounts() at all.
$getSepaAccounts = \Fhp\Action\GetSEPAAccounts::create();
$fints->execute($getSepaAccounts);
if ($getSepaAccounts->needsTan()) {
handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
}
$oneAccount = $getSepaAccounts->getAccounts()[0];
// generate a SepaDirectDebit object (pain.008.003.02).
$directDebitFile = new \AbcAeffchenSephpa\SephpaDirectDebit(
'Name of Application',
'Message Identifier',
\AbcAeffchenSephpa\SephpaDirectDebit::SEPA_PAIN_008_003_02
);
/*
*
* Configure the Direct Debit File
* $directDebitCollection = $directDebitFile->addCollection([...]);
* $directDebitCollection->addPayment([...]);
*
* See documentation:
* https://github.com/AbcAeffchen/Sephpa
*
*/
$xml = $directDebitFile->generateOutput(['zipToOneFile' => false])[0]['data'];
$sendSEPADirectDebit = \Fhp\Action\SendSEPADirectDebit::create($oneAccount, $xml);
$fints->execute($sendSEPADirectDebit);
if ($sendSEPADirectDebit->needsTan()) {
handleStrongAuthentication($sendSEPADirectDebit); // See login.php for the implementation.
}

View file

@ -0,0 +1,67 @@
<?php
/** @noinspection PhpUndefinedNamespaceInspection */
/** @noinspection PhpUndefinedMethodInspection */
/** @noinspection PhpUndefinedClassInspection */
/** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Send a direct debit request
*
* Note: The phpFinTs library only implements the FinTS protocol. For SEPA transfers, you need a separate library to
* produce the SEPA XML data, which is then wrapped into FinTS requests. This example uses the phpSepaXml library
* (see https://github.com/nemiah/phpSepaXml), which you need to install separately.
*/
use nemiah\phpSepaXml\SEPACreditor;
use nemiah\phpSepaXml\SEPADebitor;
use nemiah\phpSepaXml\SEPADirectDebitBasic;
// See login.php, it returns a FinTs instance that is already logged in.
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';
$dt = new \DateTime();
$dt->add(new \DateInterval('P1D'));
$sepaDD = new SEPADirectDebitBasic([
'messageID' => time(),
'paymentID' => time()
]);
$sepaDD->setCreditor(new SEPACreditor([ //this is you
'name' => 'My Company',
'iban' => 'DE68210501700012345678',
'bic' => 'DEUTDEDB400',
'identifier' => 'DE98ZZZ09999999999',
]));
$sepaDD->addDebitor(new SEPADebitor([ //this is who you want to get money from
'transferID' => 'R20170100',
'mandateID' => 'aeicznaeibcnt',
'mandateDateOfSignature' => '2017-05-05',
'name' => 'Max Mustermann',
'iban' => 'CH9300762011623852957',
'bic' => 'GENODEF1P15',
'amount' => 48.78,
'currency' => 'EUR',
'info' => 'R20170100 vom 09.05.2017',
'requestedCollectionDate' => $dt,
'sequenceType' => 'OOFF',
'type' => 'CORE',
]));
// Just pick the first account, for demonstration purposes. You could also have the user choose, or have SEPAAccount
// hard-coded and not call getSEPAAccounts() at all.
$getSepaAccounts = \Fhp\Action\GetSEPAAccounts::create();
$fints->execute($getSepaAccounts);
if ($getSepaAccounts->needsTan()) {
handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
}
$oneAccount = $getSepaAccounts->getAccounts()[0];
$sendSEPADirectDebit = \Fhp\Action\SendSEPADirectDebit::create($oneAccount, $sepaDD->toXML('pain.008.001.02'));
$fints->execute($sendSEPADirectDebit);
if ($sendSEPADirectDebit->needsTan()) {
handleStrongAuthentication($sendSEPADirectDebit); // See login.php for the implementation.
}

24
vendor/nemiah/php-fints/Samples/init.php vendored Executable file
View file

@ -0,0 +1,24 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Creates a new FinTs instance. This file mainly contains the configuration data for the phpFInTS library.
*/
require '../vendor/autoload.php';
// The configuration options up here are considered static wrt. the library's internal state and its requests.
// That is, even if you persist the FinTs instance, you need to be able to reproduce all this information from some
// application-specific storage (e.g. your database) in order to use the phpFinTS library.
$options = new \Fhp\Options\FinTsOptions();
$options->url = ''; // HBCI / FinTS Url can be found here: https://www.hbci-zka.de/institute/institut_auswahl.htm (use the PIN/TAN URL)
$options->bankCode = ''; // Your bank code / Bankleitzahl
$options->productName = ''; // The number you receive after registration / FinTS-Registrierungsnummer
$options->productVersion = '1.0'; // Your own Software product version
$credentials = \Fhp\Options\Credentials::create('username', 'pin'); // This is NOT the PIN of your bank card!
$fints = \Fhp\FinTs::new($options, $credentials);
$fints->setLogger(new \Tests\Fhp\CLILogger());
// Usage:
// $fints = require_once 'init.php';
return $fints;

236
vendor/nemiah/php-fints/Samples/login.php vendored Executable file
View file

@ -0,0 +1,236 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
use Fhp\CurlException;
use Fhp\Protocol\ServerException;
use Fhp\Protocol\UnexpectedResponseException;
/**
* SAMPLE - Creates a new FinTs instance (init.php) and makes sure its logged in.
*/
/** @var \Fhp\FinTs $fints */
$fints = require_once 'init.php';
/**
* This function as well as handleTan() and handleDecoupled() below are key to how FinTS works in times of PSD2
* regulations.
* Most actions like wire transfers, getting statements and even logging in can ask for strong authentication (a TAN or
* some form of confirmation on a "decoupled" device that the user has access to), but won't always. Whether strong
* authentication required depends on the kind of action, when it was last executed, other parameters like the amount
* (of a wire transfer) or time span (of a statement request) and generally the security concept of the particular bank.
* The authentication requirements may or may not be consistent with the kinds of authentication that the same bank
* requires for the same action in the web-based online banking interface. Also, banks may change these requirements
* over time, so just because your particular bank does or does not need a TAN for login today does not mean that it
* stays that way. There is a general tendency towards less intrusive strong authentication, i.e. requiring it for fewer
* actions (based on heuristics), less often (e.g. only every 90 days) or in a decoupled mode where the user only needs
* to tap a single button.
*
* The strong authentification can be implemented in many different ways. Each application that uses the phpFinTS
* library has to implement its own way of asking users for a TAN or for decoupled confirmation, which varies depending
* on its user interfaces. The implementation does not have to be in a single function like this -- it can be inlined
* with the calling code, or live elsewhere. The TAN/confirmation can be obtained while the same PHP script is still
* running (i.e. handleStrongAuthentication() is a blocking function that only returns once the authentication is done,
* which is useful for a CLI application), but it is also possible to interrupt the PHP process entirely while asking
* for the TAN/confirmation and resume it later (which is useful for a web application).
*
* @param \Fhp\BaseAction $action Some action that requires strong authentication.
* @throws CurlException|UnexpectedResponseException|ServerException See {@link FinTs::execute()} for details.
*/
function handleStrongAuthentication(\Fhp\BaseAction $action)
{
global $fints;
if ($fints->getSelectedTanMode()->isDecoupled()) {
handleDecoupled($action);
} else {
handleTan($action);
}
}
/**
* This function handles strong authentication for the case where the user needs to enter a TAN into the PHP
* application.
*
* @param \Fhp\BaseAction $action Some action that requires a TAN.
* @throws CurlException|UnexpectedResponseException|ServerException See {@link FinTs::execute()} for details.
*/
function handleTan(Fhp\BaseAction $action)
{
global $fints, $options, $credentials;
// Find out what sort of TAN we need, tell the user about it.
$tanRequest = $action->getTanRequest();
echo 'The bank requested a TAN.';
if ($tanRequest->getChallenge() !== null) {
echo ' Instructions: ' . $tanRequest->getChallenge();
}
echo "\n";
if ($tanRequest->getTanMediumName() !== null) {
echo 'Please use this device: ' . $tanRequest->getTanMediumName() . "\n";
}
// Challenge Image for PhotoTan/ChipTan
if ($tanRequest->getChallengeHhdUc()) {
try {
$flicker = new \Fhp\Model\FlickerTan\TanRequestChallengeFlicker($tanRequest->getChallengeHhdUc());
echo 'There is a challenge flicker.' . PHP_EOL;
// save or output svg
$flickerPattern = $flicker->getFlickerPattern();
// other renderers can be implemented with this pattern
$svg = new \Fhp\Model\FlickerTan\SvgRenderer($flickerPattern);
echo $svg->getImage();
} catch (InvalidArgumentException $e) {
// was not a flicker
$challengeImage = new \Fhp\Model\TanRequestChallengeImage(
$tanRequest->getChallengeHhdUc()
);
echo 'There is a challenge image.' . PHP_EOL;
// Save the challenge image somewhere
// Alternative: HTML sample code
echo '<img src="data:' . htmlspecialchars($challengeImage->getMimeType()) . ';base64,' . base64_encode($challengeImage->getData()) . '" />' . PHP_EOL;
}
}
// Optional: Instead of printing the above to the console, you can relay the information (challenge and TAN medium)
// to the user in any other way (through your REST API, a push notification, ...). If waiting for the TAN requires
// you to interrupt this PHP process and the TAN will arrive in a fresh (HTTP/REST/...) request, you can do so:
if ($optionallyPersistEverything = false) {
$persistedAction = serialize($action);
$persistedFints = $fints->persist();
// These are two strings (watch out, they are NOT necessarily UTF-8 encoded), which you can store anywhere.
// This example code stores them in a text file, but you might write them to your database (use a BLOB, not a
// CHAR/TEXT field to allow for arbitrary encoding) or in some other storage (possibly base64-encoded to make it
// ASCII).
file_put_contents(__DIR__ . '/state.txt', serialize([$persistedFints, $persistedAction]));
}
// Ask the user for the TAN. ----------------------------------------------------------------------------------------
// IMPORTANT: In your real application, you cannot use fgets(STDIN) of course (unless you're running PHP only as a
// command line application). So you instead want to send a response to the user. This means that, after executing
// the first half of handleTan() above, your real application will terminate the PHP process. The second half of
// handleTan() will likely live elsewhere in your application code (i.e. you will have two functions for the TAN
// handling, not just one like in this simplified example). You *only* need to carry over the $persistedInstance
// and the $persistedAction (which are simple strings) by storing them in some database or file where you can load
// them again in a new PHP process when the user sends the TAN.
echo "Please enter the TAN:\n";
$tan = trim(fgets(STDIN));
// Optional: If the state was persisted above, we can restore it now (imagine this is a new PHP process).
if ($optionallyPersistEverything) {
$restoredState = file_get_contents(__DIR__ . '/state.txt');
list($persistedInstance, $persistedAction) = unserialize($restoredState);
$fints = \Fhp\FinTs::new($options, $credentials, $persistedInstance);
$action = unserialize($persistedAction);
}
echo "Submitting TAN: $tan\n";
$fints->submitTan($action, $tan);
}
/**
* This function handles strong authentication for the case where the user needs to confirm the action on another
* device. Note: Depending on the banks you need compatibility with, you may not need to implement decoupled
* authentication at all, i.e., you could filter out any decoupled TanModes when letting the user choose.
*
* @param \Fhp\BaseAction $action Some action that requires decoupled authentication.
* @throws CurlException|UnexpectedResponseException|ServerException See {@link FinTs::execute()} for details.
*/
function handleDecoupled(Fhp\BaseAction $action)
{
global $fints, $options, $credentials;
$tanMode = $fints->getSelectedTanMode();
$tanRequest = $action->getTanRequest();
echo 'The bank requested authentication on another device.';
if ($tanRequest->getChallenge() !== null) {
echo ' Instructions: ' . $tanRequest->getChallenge();
}
echo "\n";
if ($tanRequest->getTanMediumName() !== null) {
echo 'Please check this device: ' . $tanRequest->getTanMediumName() . "\n";
}
// Just like in handleTan() above, we have the option to interrupt the PHP process at this point. In fact, the
// for-loop below that deals with the polling may even be running on the client side of your larger application,
// polling your application server regularly, which spawns a new PHP process each time. Here, we demonstrate this by
// persisting the instance to a local file and restoring it (even though that's not technically necessary for a
// single-process CLI script like this).
if ($optionallyPersistEverything = false) {
$persistedAction = serialize($action);
$persistedFints = $fints->persist();
// See handleTan() for how to deal with this in practice.
file_put_contents(__DIR__ . '/state.txt', serialize([$persistedFints, $persistedAction]));
}
// IMPORTANT: In your real application, you don't have to use sleep() in PHP. You can persist the state in the same
// way as in handleTan() and restore it later. This allows you to use some other timer mechanism (e.g. in the user's
// browser). This PHP sample code just serves to show the *logic* of the polling. Alternatively, you can even do
// without polling entirely and just let the user confirm manually in all cases (i.e. only implement the `else`
// branch below).
if ($tanMode->allowsAutomatedPolling()) {
echo "Polling server to detect when the decoupled authentication is complete.\n";
sleep($tanMode->getFirstDecoupledCheckDelaySeconds());
for ($attempt = 0;
$tanMode->getMaxDecoupledChecks() === 0 || $attempt < $tanMode->getMaxDecoupledChecks();
++$attempt
) {
// Optional: If the state was persisted above, we can restore it now (imagine this is a new PHP process).
if ($optionallyPersistEverything) {
$restoredState = file_get_contents(__DIR__ . '/state.txt');
list($persistedInstance, $persistedAction) = unserialize($restoredState);
$fints = \Fhp\FinTs::new($options, $credentials, $persistedInstance);
$action = unserialize($persistedAction);
}
// Check if we're done.
if ($fints->checkDecoupledSubmission($action)) {
echo "Confirmed.\n";
return;
}
echo "Still waiting...\n";
// THIS IS CRUCIAL if you're using persistence in between polls. You must re-persist() the instance after
// calling checkDecoupledSubmission() and before calling it the next time. Don't reuse the
// $persistedInstance from above multiple times.
if ($optionallyPersistEverything) {
$persistedAction = serialize($action);
$persistedFints = $fints->persist();
file_put_contents(__DIR__ . '/state.txt', serialize([$persistedFints, $persistedAction]));
}
sleep($tanMode->getPeriodicDecoupledCheckDelaySeconds());
}
throw new RuntimeException("Not confirmed after $attempt attempts, which is the limit.");
} elseif ($tanMode->allowsManualConfirmation()) {
echo "Please type 'done' and hit Return when you've completed the authentication on the other device.\n";
while (trim(fgets(STDIN)) !== 'done') {
echo "Try again.\n";
}
echo "Confirming that the action is done.\n";
if (!$fints->checkDecoupledSubmission($action)) {
throw new RuntimeException(
"You confirmed that the authentication for action was copmleted, but the server does not think so."
);
}
echo "Confirmed\n";
} else {
throw new AssertionError('Server allows neither automated polling nor manual confirmation');
}
}
// Select TAN mode and possibly medium. If you're not sure what this is about, read and run tanModesAndMedia.php first.
$tanMode = 900; // This is just a placeholder you need to fill!
$tanMedium = null; // This is just a placeholder you may need to fill.
$fints->selectTanMode($tanMode, $tanMedium);
// Log in.
$login = $fints->login();
if ($login->needsTan()) {
handleStrongAuthentication($login);
}
// Usage:
// $fints = require_once 'login.php';
return $fints;

View file

@ -0,0 +1,47 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Displays the statement of account for a specific time range and account.
*/
// See login.php, it returns a FinTs instance that is already logged in.
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';
// Just pick the first account, for demonstration purposes. You could also have the user choose, or have SEPAAccount
// hard-coded and not call getSEPAAccounts() at all.
$getSepaAccounts = \Fhp\Action\GetSEPAAccounts::create();
$fints->execute($getSepaAccounts);
if ($getSepaAccounts->needsTan()) {
handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
}
$oneAccount = $getSepaAccounts->getAccounts()[0];
$from = new \DateTime('2022-07-15');
$to = new \DateTime();
$getStatement = \Fhp\Action\GetStatementOfAccount::create($oneAccount, $from, $to, false, true);
$fints->execute($getStatement);
if ($getStatement->needsTan()) {
handleStrongAuthentication($getStatement); // See login.php for the implementation.
}
$soa = $getStatement->getStatement();
foreach ($soa->getStatements() as $statement) {
echo $statement->getDate()->format('Y-m-d') . ': Start Saldo: '
. ($statement->getCreditDebit() == \Fhp\Model\StatementOfAccount\Statement::CD_DEBIT ? '-' : '')
. $statement->getStartBalance() . PHP_EOL;
echo 'Transactions:' . PHP_EOL;
echo '=======================================' . PHP_EOL;
foreach ($statement->getTransactions() as $transaction) {
echo "Booked : " . ($transaction->getBooked() ? "true" : "false") . PHP_EOL;
echo 'Amount : ' . ($transaction->getCreditDebit() == \Fhp\Model\StatementOfAccount\Transaction::CD_DEBIT ? '-' : '') . $transaction->getAmount() . PHP_EOL;
echo 'Booking text: ' . $transaction->getBookingText() . PHP_EOL;
echo 'Name : ' . $transaction->getName() . PHP_EOL;
echo 'Description : ' . $transaction->getMainDescription() . PHP_EOL;
echo 'EREF : ' . $transaction->getEndToEndID() . PHP_EOL;
echo '=======================================' . PHP_EOL . PHP_EOL;
}
}
echo 'Found ' . count($soa->getStatements()) . ' statements.' . PHP_EOL;

View file

@ -0,0 +1,40 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Displays the statement of account for a specific depot.
*/
// See login.php, it returns a FinTs instance that is already logged in.
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';
// Just pick the first account, for demonstration purposes. You could also have the user choose, or have SEPAAccount
// hard-coded and not call getSEPAAccounts() at all.
$getSepaAccounts = \Fhp\Action\GetSEPAAccounts::create();
$fints->execute($getSepaAccounts);
if ($getSepaAccounts->needsTan()) {
handleStrongAuthentication($getSepaAccounts); // See login.php for the implementation.
}
$oneAccount = $getSepaAccounts->getAccounts()[0];
$getStatement = \Fhp\Action\GetDepotAufstellung::create($oneAccount);
$fints->execute($getStatement);
if ($getStatement->needsTan()) {
handleStrongAuthentication($getStatement); // See login.php for the implementation.
}
$soa = $getStatement->getStatement();
foreach ($soa->getHoldings() as $holding) {
echo '=======================================' . PHP_EOL;
echo 'Name : ' . $holding->getName() . PHP_EOL;
echo 'Amount : ' . $holding->getAmount() . PHP_EOL;
echo 'Price : ' . $holding->getPrice() . ' ' . $holding->getCurrency() . PHP_EOL;
echo 'WKN : ' . $holding->getWKN() . PHP_EOL;
echo 'ISIN : ' . $holding->getISIN() . PHP_EOL;
echo 'B-Datum : ' . $holding->getDate()->format('Y-m-d') . PHP_EOL;
echo '=======================================' . PHP_EOL . PHP_EOL;
}
echo 'Found ' . count($soa->getHoldings()) . ' statements.' . PHP_EOL;
echo 'Depotwert: ' . $getStatement->getDepotWert() . PHP_EOL;

View file

@ -0,0 +1,71 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/**
* SAMPLE - Creates a new FinTs instance (init.php) and lets the user select the TAN mode they want to use.
*/
/** @var \Fhp\FinTs $fints */
$fints = require_once 'init.php';
// First, the user has to decide which TAN mode they want to use.
// NOTE: There is a special case for banks that do not support PSD2, use NoPsd2TanMode for those.
$tanModes = $fints->getTanModes();
if (empty($tanModes)) {
echo 'Your bank does not support any TAN modes!';
return;
}
echo "Here are the available TAN modes:\n";
$tanModeNames = array_map(function (\Fhp\Model\TanMode $tanMode) {
return $tanMode->getName();
}, $tanModes);
print_r($tanModeNames);
echo "Which one do you want to use? Index:\n";
$tanModeIndex = trim(fgets(STDIN));
if (!is_numeric($tanModeIndex) || !array_key_exists(intval($tanModeIndex), $tanModes)) {
echo 'Invalid index!';
return;
}
$tanMode = $tanModes[intval($tanModeIndex)];
echo 'You selected ' . $tanMode->getName() . "\n";
// In case the selected TAN mode requires a TAN medium (e.g. if the user picked mTAN, they may have to pick the mobile
// device on which they want to receive TANs), let the user pick that too.
if ($tanMode->needsTanMedium()) {
$tanMedia = $fints->getTanMedia($tanMode);
if (empty($tanMedia)) {
echo 'Your bank did not provide any TAN media, even though it requires selecting one!';
return;
}
echo "Here are the available TAN media:\n";
$tanMediaNames = array_map(function (\Fhp\Model\TanMedium $tanMedium) {
return $tanMedium->getName();
}, $tanMedia);
print_r($tanMediaNames);
echo "Which one do you want to use? Index:\n";
$tanMediumIndex = trim(fgets(STDIN));
if (!is_numeric($tanMediumIndex) || !array_key_exists(intval($tanMediumIndex), $tanMedia)) {
echo 'Invalid index!';
return;
}
$tanMedium = $tanMedia[intval($tanMediumIndex)];
echo 'You selected ' . $tanMedium->getName() . "\n";
} else {
$tanMedium = null;
}
// Announce the selection to the FinTS library.
$fints->selectTanMode($tanMode, $tanMedium);
// Within your application, you should persist these choices somewhere (e.g. database), so that the user does not have
// to select them again the future. Note that it is sufficient to persist the ID/name, i.e. this is equivalent:
$fints->selectTanMode($tanMode->getId(), $tanMedium->getName());
// Now you could do $fints->login(), see login.php for that. For this example, we'll just close the connection.
$fints->close();
echo 'Done';

54
vendor/nemiah/php-fints/Samples/transfer.php vendored Executable file
View file

@ -0,0 +1,54 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/** @noinspection PhpUndefinedMethodInspection */
/** @noinspection PhpUndefinedNamespaceInspection */
/** @noinspection PhpUndefinedClassInspection */
/**
* SAMPLE - Execute a SEPA transfer.
*
* Note: The phpFinTs library only implements the FinTS protocol. For SEPA transfers, you need a separate library to
* produce the SEPA XML data, which is then wrapped into FinTS requests. This example uses the phpSepaXml library
* (see https://github.com/nemiah/phpSepaXml), which you need to install separately.
*/
use nemiah\phpSepaXml\SEPACreditor;
use nemiah\phpSepaXml\SEPADebitor;
use nemiah\phpSepaXml\SEPATransfer;
// See login.php, it returns a FinTs instance that is already logged in.
/** @var \Fhp\FinTs $fints */
$fints = require_once 'login.php';
$dt = new \DateTime();
$dt->add(new \DateInterval('P1D'));
$sepaDD = new SEPATransfer([
'messageID' => time(),
'paymentID' => time(),
]);
$sepaDD->setDebitor(new SEPADebitor([ //this is you
'name' => 'My Company',
'iban' => 'DE68210501700012345678',
'bic' => 'DEUTDEDB400', //,
//'identifier' => 'DE98ZZZ09999999999'
]));
$sepaDD->addCreditor(new SEPACreditor([ //this is who you want to send money to
//'paymentID' => '20170403652',
'info' => '20170403652',
'name' => 'Max Mustermann',
'iban' => 'CH9300762011623852957',
'bic' => 'GENODEF1P15',
'amount' => 48.78,
'currency' => 'EUR',
'reqestedExecutionDate' => $dt,
]));
$sendSEPATransfer = \Fhp\Action\SendSEPATransfer::create($oneAccount, $sepaDD->toXML());
$fints->execute($sendSEPATransfer);
if ($sendSEPATransfer->needsTan()) {
handleStrongAuthentication($sendSEPATransfer); // See login.php for the implementation.
}

39
vendor/nemiah/php-fints/composer.json vendored Executable file
View file

@ -0,0 +1,39 @@
{
"name": "nemiah/php-fints",
"description": "PHP Library for the protocols fints and hbci",
"homepage": "https://github.com/nemiah/phpFinTS",
"version": "3.7.0",
"license": "MIT",
"autoload": {
"psr-0": {
"Fhp": "lib/",
"Tests\\Fhp": "lib/"
}
},
"require": {
"php": ">=8.0",
"psr/log": "^1|^2|^3",
"ext-curl": "*",
"ext-mbstring": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"php-mock/php-mock-phpunit": "^2.6",
"friendsofphp/php-cs-fixer": "^3.0"
},
"suggest": {
"monolog/monolog": "Allow sending log messages to a variety of different handlers",
"abcaeffchen/sephpa": "1.*",
"nemiah/php-sepa-xml": "dev-master"
},
"scripts": {
"test": [
"phpunit"
],
"check": [
"@cs"
],
"cs": "php-cs-fixer fix -v --diff --dry-run",
"cs-fix": "php-cs-fixer fix -v --diff"
}
}

36
vendor/nemiah/php-fints/csfixer-check.sh vendored Executable file
View file

@ -0,0 +1,36 @@
#!/bin/bash
#
# When this is run as part of a Travis test for a pull request, then it ensures that none of the touched files has any
# PHP CS Fixer warnings.
# From: https://github.com/FriendsOfPHP/PHP-CS-Fixer#using-php-cs-fixer-on-ci
if [ -z "$TRAVIS_COMMIT_RANGE" ]
then
# TRAVIS_COMMIT_RANGE "is empty for builds triggered by the initial commit of a new branch"
# From: https://docs.travis-ci.com/user/environment-variables/
echo "Variable TRAVIS_COMMIT_RANGE not set, falling back to full git diff"
TRAVIS_COMMIT_RANGE=.
fi
IFS='
'
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTUXB "$TRAVIS_COMMIT_RANGE")
if [ "$?" -ne "0" ]
then
echo "Error: git diff response code > 0, aborting"
exit 1
fi
if [ -z "${CHANGED_FILES}" ]
then
echo "0 changed files found, exiting"
exit 0
fi
# February 2022: PHP CS FIXER is currently not PHP 8.1 compatible:
# "you may experience code modified in a wrong way"
# "To ignore this requirement please set `PHP_CS_FIXER_IGNORE_ENV`."
export PHP_CS_FIXER_IGNORE_ENV="1"
if ! echo "${CHANGED_FILES}" | grep -qE "^(\\.php_cs(\\.dist)?|composer\\.lock)$"; then EXTRA_ARGS=$(printf -- '--path-mode=intersection\n--\n%s' "${CHANGED_FILES}"); else EXTRA_ARGS=''; fi
vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php -v --dry-run --stop-on-violation --using-cache=no ${EXTRA_ARGS} || (echo "php-cs-fixer failed" && exit 1)

37
vendor/nemiah/php-fints/disallowtabs.sh vendored Executable file
View file

@ -0,0 +1,37 @@
#!/bin/bash
#
# When this is run as part of a Travis test for a pull request, then it ensures that none of the added lines (compared
# to the base branch of the pull request) use tabs for indentations.
# Adapted from https://github.com/mrc/git-hook-library/blob/master/pre-commit.no-tabs
# Abort if any of the inner commands (particularly the git commands) fails.
set -e
set -o pipefail
if [ -z ${TRAVIS_PULL_REQUEST} ]; then
echo "Expected environment variable TRAVIS_PULL_REQUEST"
exit 2
elif [ "${TRAVIS_PULL_REQUEST}" == "false" ]; then
echo "Not a Travis pull request, skipping."
exit 0
fi
# Make sure that we have a local copy of the relevant commits (otherwise git diff won't work).
git remote set-branches --add origin ${TRAVIS_BRNACH}
git fetch
# Compute the diff from the PR's target branch to its HEAD commit.
target_branch="origin/${TRAVIS_BRANCH}"
the_diff=$(git diff "${target_branch}...HEAD")
# Make sure that there are no tabs in the indentation part of added lines.
if echo "${the_diff}" | egrep '^\+\s* ' >/dev/null; then
echo -e "\e[31mError: The changes contain a tab for indentation\e[0m, which is against this repo's policy."
echo "Target branch: origin/${TRAVIS_BRANCH}"
echo "Commit range: ${TRAVIS_COMMIT_RANGE}"
echo "The following tabs were detected:"
echo "${the_diff}" | egrep '^(\+\s* |\+\+\+|@@)'
exit 1
else
echo "No new tabs detected."
fi

View file

@ -0,0 +1,129 @@
<?php
namespace Fhp\Action;
use Fhp\Model\SEPAAccount;
use Fhp\PaginateableAction;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\Common\Kti;
use Fhp\Segment\Common\Kto;
use Fhp\Segment\Common\KtvV3;
use Fhp\Segment\SAL\HISAL;
use Fhp\Segment\SAL\HKSALv4;
use Fhp\Segment\SAL\HKSALv5;
use Fhp\Segment\SAL\HKSALv6;
use Fhp\Segment\SAL\HKSALv7;
use Fhp\UnsupportedException;
/**
* Runs an HKSAL request the current balance of the given account.
*/
class GetBalance extends PaginateableAction
{
// Request (not available after serialization, i.e. not available in processResponse()).
/** @var SEPAAccount */
private $account;
/** @var bool */
private $allAccounts;
// Response
/** @var HISAL[] */
private $response = [];
/**
* @param SEPAAccount $account The account to get the balance for. This can be constructed based on information
* that the user entered, or it can be {@link SEPAAccount} instance retrieved from {@link GetSEPAAccounts}.
* @param bool $allAccounts If set to true, will return balances for all accounts of the user. You still need to
* pass one of the accounts into $account, though.
*/
public static function create(SEPAAccount $account, bool $allAccounts = false): GetBalance
{
$result = new GetBalance();
$result->account = $account;
$result->allAccounts = $allAccounts;
return $result;
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*/
public function serialize(): string
{
return serialize($this->__serialize());
}
public function __serialize(): array
{
return [
parent::__serialize(),
$this->account, $this->allAccounts,
];
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*
* @param string $serialized
* @return void
*/
public function unserialize($serialized)
{
self::__unserialize(unserialize($serialized));
}
public function __unserialize(array $serialized): void
{
list(
$parentSerialized,
$this->account, $this->allAccounts
) = $serialized;
is_array($parentSerialized) ?
parent::__unserialize($parentSerialized) :
parent::unserialize($parentSerialized);
}
/**
* @return HISAL[]
*/
public function getBalances()
{
$this->ensureDone();
return $this->response;
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
/** @var BaseSegment $hisals */
$hisals = $bpd->requireLatestSupportedParameters('HISALS');
switch ($hisals->getVersion()) {
case 4:
return HKSALv4::create(Kto::fromAccount($this->account));
case 5:
return HKSALv5::create(KtvV3::fromAccount($this->account), $this->allAccounts);
case 6:
return HKSALv6::create(KtvV3::fromAccount($this->account), $this->allAccounts);
case 7:
return HKSALv7::create(Kti::fromAccount($this->account), $this->allAccounts);
default:
throw new UnsupportedException('Unsupported HKSAL version: ' . $hisals->getVersion());
}
}
/** {@inheritdoc} */
public function processResponse(Message $response)
{
parent::processResponse($response);
$responseSegments = $response->findSegments(HISAL::class);
if (count($responseSegments) === 0) {
throw new UnexpectedResponseException('No HISAL segments received!');
}
$this->response = array_merge($this->response, $responseSegments);
}
}

View file

@ -0,0 +1,167 @@
<?php
namespace Fhp\Action;
use Fhp\Model\SEPAAccount;
use Fhp\Model\StatementOfHoldings\StatementOfHoldings;
use Fhp\MT535\MT535;
use Fhp\PaginateableAction;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\Common\KtvV3;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\Segment\WPD\HIWPD;
use Fhp\Segment\WPD\HIWPDS;
use Fhp\Segment\WPD\HIWPDv5;
use Fhp\Segment\WPD\HKWPDv5;
use Fhp\UnsupportedException;
/**
* Depotaufstellung HKWPD
* MT535
*/
class GetDepotAufstellung extends PaginateableAction
{
// Request (not available after serialization, i.e. not available in processResponse()).
/** @var SEPAAccount */
private $account;
// Response
/** @var string */
private $rawMT535 = '';
/** @var StatementOfHoldings */
private $statement;
/** @var float */
private $depotWert;
/**
* @param SEPAAccount $account The account to get the statement for. This can be constructed based on information
* that the user entered, or it can be {@link SEPAAccount} instance retrieved from {@link getAccounts()}.
* @return GetDepotAufstellung A new action instance.
*/
public static function create(SEPAAccount $account): GetDepotAufstellung
{
$result = new GetDepotAufstellung();
$result->account = $account;
return $result;
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*/
public function serialize(): string
{
return serialize($this->__serialize());
}
public function __serialize(): array
{
return [
parent::__serialize(),
$this->account,
];
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*
* @param string $serialized
* @return void
*/
public function unserialize($serialized)
{
self::__unserialize(unserialize($serialized));
}
public function __unserialize(array $serialized): void
{
list(
$parentSerialized,
$this->account
) = $serialized;
is_array($parentSerialized) ?
parent::__unserialize($parentSerialized) :
parent::unserialize($parentSerialized);
}
/**
* @return string The raw MT535 data received from the server.
* @noinspection PhpUnused
*/
public function getRawMT535(): string
{
$this->ensureDone();
return $this->rawMT535;
}
public function getStatement(): StatementOfHoldings
{
$this->ensureDone();
return $this->statement;
}
public function getDepotWert(): float
{
$this->ensureDone();
return $this->depotWert;
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
/** @var HIWPDS $hiwpds */
$hiwpds = $bpd->requireLatestSupportedParameters('HIWPDS');
switch ($hiwpds->getVersion()) {
case 5:
return HKWPDv5::create(KtvV3::fromAccount($this->account));
default:
throw new UnsupportedException('Unsupported HKWPD version: ' . $hiwpds->getVersion());
}
}
/** {@inheritdoc} */
public function processResponse(Message $response)
{
parent::processResponse($response);
$isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null;
$responseHiwpd = $response->findSegments(HIWPDv5::class);
$numResponseSegments = count($responseHiwpd);
if (!$isUnavailable && $numResponseSegments < count($this->getRequestSegmentNumbers())) {
throw new UnexpectedResponseException("Only got $numResponseSegments HIWPD response segments!");
}
/** @var HIWPD $hiwpd */
foreach ($responseHiwpd as $hiwpd) {
$this->rawMT535 .= $hiwpd->getDepotaufstellung()->getData();
}
// Note: Pagination boundaries may cut in the middle of the MT535 data, so it is not possible to parse a partial
// reponse before having received all pages.
if (!$this->hasMorePages()) {
$this->parseMt535();
}
}
private function parseMt535()
{
try {
// Note: Some banks encode their MT 535 data as SWIFT/ISO-8859 like it should be according to the
// specification, others just send UTF-8, so we try to detect it here.
$rawMT535 = mb_detect_encoding($this->rawMT535, 'UTF-8', true) === false
? mb_convert_encoding($this->rawMT535, 'UTF-8', 'ISO-8859-1') : $this->rawMT535;
$parser = new MT535($rawMT535);
$this->statement = $parser->parseHoldings();
$this->depotWert = $parser->parseDepotWert();
} catch (\Exception $e) {
throw new \InvalidArgumentException('Invalid MT535 data', 0, $e);
}
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace Fhp\Action;
use Fhp\Model\SEPAAccount;
use Fhp\PaginateableAction;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UPD;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\Common\Ktz;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\Segment\SPA\HISPA;
use Fhp\Segment\SPA\HKSPAv1;
use Fhp\Segment\SPA\HKSPAv2;
use Fhp\Segment\SPA\HKSPAv3;
use Fhp\UnsupportedException;
/**
* Runs an HKSPA request to retrieve account details about the accounts that the user can access through FinTs.
*
* TODO In future, once all banks populate the BIC in HIUPD.erweiterungKontobezogen, or if we force library users to
* supply the BIC to us, we won't need to send an HKSPA anymore, but we can simply fulfil this action from the UPD.
*/
class GetSEPAAccounts extends PaginateableAction
{
// Empty request, in order to retrieve all accounts.
// Response
/** @var SEPAAccount[] */
private $accounts;
/**
* @return GetSEPAAccounts A new action instance.
*/
public static function create(): GetSEPAAccounts
{
return new GetSEPAAccounts();
}
/**
* @return SEPAAccount[]
*/
public function getAccounts(): array
{
$this->ensureDone();
return $this->accounts;
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
/** @var BaseSegment $hispas */
$hispas = $bpd->requireLatestSupportedParameters('HISPAS');
switch ($hispas->getVersion()) {
case 1:
return HKSPAv1::createEmpty();
case 2:
return HKSPAv2::createEmpty();
case 3:
return HKSPAv3::createEmpty();
default:
throw new UnsupportedException('Unsupported HKSPA version: ' . $hispas->getVersion());
}
}
/** {@inheritdoc} */
public function processResponse(Message $response)
{
parent::processResponse($response);
// Banks send just 3010 and no HISPA in case there are no accounts (or at least none that the bank is able to
// report through HISPA).
if ($response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null) {
$this->accounts = [];
return;
}
/** @var HISPA $hispa */
$hispa = $response->requireSegment(HISPA::class);
$this->accounts = array_map(function ($ktz) {
/** @var Ktz $ktz */
$account = new SEPAAccount();
$account->setIban($ktz->iban);
$account->setBic($ktz->bic);
$account->setAccountNumber($ktz->kontonummer);
$account->setSubAccount($ktz->unterkontomerkmal);
$account->setBlz($ktz->kreditinstitutskennung->kreditinstitutscode);
return $account;
}, $hispa->getSepaKontoverbindung());
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace Fhp\Action;
use Fhp\BaseAction;
use Fhp\Protocol\BPD;
use Fhp\Protocol\UPD;
use Fhp\Segment\DSE\HIDXES;
use Fhp\Segment\DSE\MinimaleVorlaufzeitSEPALastschrift;
/**
* Retrieves information about SEPA Direct Debit Requests
*/
class GetSEPADirectDebitParameters extends BaseAction
{
public const SEQUENCE_TYPES = ['FRST', 'OOFF', 'FNAL', 'RCUR'];
public const DIRECT_DEBIT_TYPES = ['CORE', 'COR1', 'B2B'];
/** @var string */
private $directDebitType;
/** @var string */
private $seqType;
/** @var bool */
private $singleDirectDebit;
/** @var HIDXES */
private $hidxes;
public static function create(string $seqType, bool $singleDirectDebit, string $directDebitType = 'CORE')
{
if (!in_array($directDebitType, self::DIRECT_DEBIT_TYPES)) {
throw new \InvalidArgumentException('Unknown CORE type, possible values are ' . implode(', ', self::DIRECT_DEBIT_TYPES));
}
if (!in_array($seqType, self::SEQUENCE_TYPES)) {
throw new \InvalidArgumentException('Unknown SEPA sequence type, possible values are ' . implode(', ', self::SEQUENCE_TYPES));
}
$result = new GetSEPADirectDebitParameters();
$result->directDebitType = $directDebitType;
$result->seqType = $seqType;
$result->singleDirectDebit = $singleDirectDebit;
return $result;
}
public static function getHixxesSegmentName(string $directDebitType, bool $singleDirectDebit): string
{
switch ($directDebitType) {
case 'CORE':
case 'COR1':
return $singleDirectDebit ? 'HIDSES' : 'HIDMES';
case 'B2B':
return $singleDirectDebit ? 'HIBSES' : 'HIBMES';
default:
throw new \InvalidArgumentException('Unknown DirectDebitTypes type, possible values are ' . implode(', ', self::DIRECT_DEBIT_TYPES));
}
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
$this->hidxes = $bpd->requireLatestSupportedParameters(static::getHixxesSegmentName($this->directDebitType, $this->singleDirectDebit));
$this->isDone = true;
return []; // No request to the bank required
}
/**
* @return MinimaleVorlaufzeitSEPALastschrift|null The information about the lead time for the given Sequence Type and Direct Debit Type
*/
public function getMinimalLeadTime(): ?MinimaleVorlaufzeitSEPALastschrift
{
$parsed = $this->hidxes->getParameter()->getMinimalLeadTime($this->seqType);
if ($parsed instanceof MinimaleVorlaufzeitSEPALastschrift) {
return $parsed;
}
return $parsed[$this->directDebitType] ?? null;
}
}

View file

@ -0,0 +1,223 @@
<?php
namespace Fhp\Action;
use Fhp\Model\SEPAAccount;
use Fhp\Model\StatementOfAccount\StatementOfAccount;
use Fhp\MT940\Dialect\PostbankMT940;
use Fhp\MT940\Dialect\SpardaMT940;
use Fhp\MT940\MT940;
use Fhp\MT940\MT940Exception;
use Fhp\PaginateableAction;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\Common\Kti;
use Fhp\Segment\Common\Kto;
use Fhp\Segment\Common\KtvV3;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\Segment\KAZ\HIKAZ;
use Fhp\Segment\KAZ\HIKAZS;
use Fhp\Segment\KAZ\HKKAZv4;
use Fhp\Segment\KAZ\HKKAZv5;
use Fhp\Segment\KAZ\HKKAZv6;
use Fhp\Segment\KAZ\HKKAZv7;
use Fhp\UnsupportedException;
/**
* Retrieves statements for one specific account or for all accounts that the user has access to. A statement is a
* series of financial transactions that pertain to the account, grouped by day.
*/
class GetStatementOfAccount extends PaginateableAction
{
// Request (not available after serialization, i.e. not available in processResponse()).
/** @var SEPAAccount */
private $account;
/** @var \DateTime */
private $from;
/** @var \DateTime */
private $to;
/** @var bool */
private $allAccounts;
/** @var bool */
private $includeUnbooked;
// Information from the BPD needed to interpret the response.
/** @var string */
private $bankName;
// Response
/** @var string */
private $rawMT940 = '';
/** @var array */
protected $parsedMT940 = [];
/** @var StatementOfAccount */
private $statement;
/**
* @param SEPAAccount $account The account to get the statement for. This can be constructed based on information
* that the user entered, or it can be {@link SEPAAccount} instance retrieved from {@link getAccounts()}.
* @param \DateTime|null $from If set, only transactions after this date (inclusive) are returned.
* @param \DateTime|null $to If set, only transactions before this date (inclusive) are returned.
* @param bool $allAccounts If set to true, will return statements for all accounts of the user. You still need to
* pass one of the accounts into $account, though.
* @return GetStatementOfAccount A new action instance.
*/
public static function create(SEPAAccount $account, ?\DateTime $from = null, ?\DateTime $to = null, bool $allAccounts = false, bool $includeUnbooked = false): GetStatementOfAccount
{
if ($from !== null && $to !== null && $from > $to) {
throw new \InvalidArgumentException('From-date must be before to-date');
}
$result = new GetStatementOfAccount();
$result->account = $account;
$result->from = $from;
$result->to = $to;
$result->allAccounts = $allAccounts;
$result->includeUnbooked = $includeUnbooked;
return $result;
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*/
public function serialize(): string
{
return serialize($this->__serialize());
}
public function __serialize(): array
{
return [
parent::__serialize(),
$this->account, $this->from, $this->to, $this->allAccounts,
$this->bankName,
];
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*
* @param string $serialized
* @return void
*/
public function unserialize($serialized)
{
self::__unserialize(unserialize($serialized));
}
public function __unserialize(array $serialized): void
{
list(
$parentSerialized,
$this->account, $this->from, $this->to, $this->allAccounts,
$this->bankName
) = $serialized;
is_array($parentSerialized) ?
parent::__unserialize($parentSerialized) :
parent::unserialize($parentSerialized);
}
/**
* @return string The raw MT940 data received from the server.
* @noinspection PhpUnused
*/
public function getRawMT940(): string
{
$this->ensureDone();
return $this->rawMT940;
}
/**
* @return array The parsed MT940 data.
*/
public function getParsedMT940(): array
{
$this->ensureDone();
return $this->parsedMT940;
}
public function getStatement(): StatementOfAccount
{
$this->ensureDone();
return $this->statement;
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
$this->bankName = $bpd->getBankName();
/** @var HIKAZS $hikazs */
$hikazs = $bpd->requireLatestSupportedParameters('HIKAZS');
if ($this->allAccounts && !$hikazs->getParameter()->getAlleKontenErlaubt()) {
throw new \InvalidArgumentException('The bank do not permit the use of allAccounts=true');
}
switch ($hikazs->getVersion()) {
case 4:
return HKKAZv4::create(Kto::fromAccount($this->account), $this->from, $this->to);
case 5:
return HKKAZv5::create(KtvV3::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
case 6:
return HKKAZv6::create(KtvV3::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
case 7:
return HKKAZv7::create(Kti::fromAccount($this->account), $this->allAccounts, $this->from, $this->to);
default:
throw new UnsupportedException('Unsupported HKKAZ version: ' . $hikazs->getVersion());
}
}
/** {@inheritdoc} */
public function processResponse(Message $response)
{
parent::processResponse($response);
// Banks send just 3010 and no HIKAZ in case there are no transactions.
$isUnavailable = $response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null;
$responseHikaz = $response->findSegments(HIKAZ::class);
$numResponseSegments = count($responseHikaz);
if (!$isUnavailable && $numResponseSegments < count($this->getRequestSegmentNumbers())) {
throw new UnexpectedResponseException("Only got $numResponseSegments HIKAZ response segments!");
}
/** @var HIKAZ $hikaz */
foreach ($responseHikaz as $hikaz) {
$this->rawMT940 .= $hikaz->getGebuchteUmsaetze()->getData();
if ($this->includeUnbooked and $hikaz->getNichtGebuchteUmsaetze() !== null) {
$this->rawMT940 .= $hikaz->getNichtGebuchteUmsaetze()->getData();
}
}
// Note: Pagination boundaries may cut in the middle of the MT940 data, so it is not possible to parse a partial
// reponse before having received all pages.
if (!$this->hasMorePages()) {
$this->parseMt940();
}
}
private function parseMt940()
{
if (str_contains(strtolower($this->bankName), 'sparda')) {
$parser = new SpardaMT940();
} elseif (str_contains(strtolower($this->bankName), 'postbank')) {
$parser = new PostbankMT940();
} else {
$parser = new MT940();
}
try {
// Note: Some banks encode their MT 940 data as SWIFT/ISO-8859 like it should be according to the
// specification (e.g. DKB), others just send UTF-8 (e.g. Consorsbank), so we try to detect it here.
$rawMT940 = mb_detect_encoding($this->rawMT940, 'UTF-8', true) === false
? mb_convert_encoding($this->rawMT940, 'UTF-8', 'ISO-8859-1') : $this->rawMT940;
$this->parsedMT940 = $parser->parse($rawMT940);
$this->statement = StatementOfAccount::fromMT940Array($this->parsedMT940);
} catch (MT940Exception $e) {
throw new \InvalidArgumentException('Invalid MT940 data', 0, $e);
}
}
}

View file

@ -0,0 +1,176 @@
<?php
/** @noinspection PhpUnused */
namespace Fhp\Action;
use Fhp\Model\SEPAAccount;
use Fhp\PaginateableAction;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\CAZ\HICAZSv1;
use Fhp\Segment\CAZ\HICAZv1;
use Fhp\Segment\CAZ\HKCAZv1;
use Fhp\Segment\CAZ\UnterstuetzteCamtMessages;
use Fhp\Segment\Common\Kti;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\UnsupportedException;
/**
* Retrieves statements for one specific account or for all accounts that the user has access to. A statement is a
* series of financial transactions that pertain to the account, grouped by day.
*/
class GetStatementOfAccountXML extends PaginateableAction
{
// Request (not available after serialization, i.e. not available in processResponse()).
/** @var SEPAAccount */
private $account;
/** @var \DateTime */
private $from;
/** @var \DateTime */
private $to;
/** @var string */
private $camtURN;
/** @var bool */
private $allAccounts;
// Response
/** @var string[] */
protected $xml = [];
/**
* @param SEPAAccount $account The account to get the statement for. This can be constructed based on information
* that the user entered, or it can be {@link SEPAAccount} instance retrieved from {@link getAccounts()}.
* @param \DateTime|null $from If set, only transactions after this date (inclusive) are returned.
* @param \DateTime|null $to If set, only transactions before this date (inclusive) are returned.
* @param string|null $camtURN The URN/descriptor of the CAMT XML format you want the bank to return.
* Use null to just let the bank decide. Otherwise needs to be one of the reported URNs the bank supports.
* For example urn:iso:std:iso:20022:tech:xsd:camt.052.001.02
* @param bool $allAccounts If set to true, will return statements for all accounts of the user. You still need to
* pass one of the accounts into $account, though.
* @return GetStatementOfAccountXML A new action instance.
*/
public static function create(SEPAAccount $account, ?\DateTime $from = null, ?\DateTime $to = null, ?string $camtURN = null, bool $allAccounts = false): GetStatementOfAccountXML
{
if ($from !== null && $to !== null && $from > $to) {
throw new \InvalidArgumentException('From-date must be before to-date');
}
$result = new GetStatementOfAccountXML();
$result->account = $account;
$result->camtURN = $camtURN;
$result->from = $from;
$result->to = $to;
$result->allAccounts = $allAccounts;
return $result;
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*/
public function serialize(): string
{
return serialize($this->__serialize());
}
public function __serialize(): array
{
return [
parent::__serialize(),
$this->account, $this->camtURN, $this->from, $this->to, $this->allAccounts,
];
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*
* @param string $serialized
* @return void
*/
public function unserialize($serialized)
{
self::__unserialize(unserialize($serialized));
}
public function __unserialize(array $serialized): void
{
list(
$parentSerialized,
$this->account, $this->camtURN, $this->from, $this->to, $this->allAccounts
) = $serialized;
is_array($parentSerialized) ?
parent::__unserialize($parentSerialized) :
parent::unserialize($parentSerialized);
}
/**
* @return string[] The XML-Document(s) received from the bank, or empty array if the statement is unavailable/empty.
*/
public function getBookedXML(): array
{
$this->ensureDone();
return $this->xml;
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
if ($upd === null) {
throw new UnsupportedException('The UPD is needed to be able to create a request for GetStatementOfAccountXML.');
}
if (!$upd->isRequestSupportedForAccount($this->account, 'HKCAZ')) {
throw new UnsupportedException('The bank (or the given account/user combination) does not support GetStatementOfAccountXML.');
}
/** @var HICAZSv1 $hicazs */
$hicazs = $bpd->requireLatestSupportedParameters('HICAZS');
$supportedCamtURNs = $hicazs->getParameter()->getUnterstuetzteCamtMessages()->camtDescriptor;
if (is_null($this->camtURN)) {
$camtURNs = $supportedCamtURNs;
} elseif (!in_array($this->camtURN, $supportedCamtURNs)) {
throw new \InvalidArgumentException('The bank does not support the CAMT format' . $this->camtURN . '. The following formats are supported: ' . implode(', ', $supportedCamtURNs));
} else {
$camtURNs = [$this->camtURN];
}
if ($this->allAccounts && !$hicazs->getParameter()->getAlleKontenErlaubt()) {
throw new \InvalidArgumentException('The bank do not permit the use of allAccounts=true');
}
switch ($hicazs->getVersion()) {
case 1:
$unterstuetzteCamtMessages = UnterstuetzteCamtMessages::create($camtURNs);
return HKCAZv1::create(Kti::fromAccount($this->account), $unterstuetzteCamtMessages, $this->allAccounts, $this->from, $this->to);
default:
throw new UnsupportedException('Unsupported HKCAZ version: ' . $hicazs->getVersion());
}
}
/** {@inheritdoc} */
public function processResponse(Message $response)
{
parent::processResponse($response);
// Banks send just 3010 and no HICAZ in case there are no transactions.
if ($response->findRueckmeldung(Rueckmeldungscode::NICHT_VERFUEGBAR) !== null) {
return;
}
/** @var HICAZv1[] $responseHicaz */
$responseHicaz = $response->findSegments(HICAZv1::class);
$numResponseSegments = count($responseHicaz);
if ($numResponseSegments < count($this->getRequestSegmentNumbers())) {
throw new UnexpectedResponseException("Only got $numResponseSegments HICAZ response segments!");
}
if ($numResponseSegments > 1) {
throw new UnsupportedException('More than 1 HICAZ response segment is not supported at the moment!');
}
// It seems that paginated responses, always contain a whole XML Document
foreach ($responseHicaz[0]->getGebuchteUmsaetze() as $xml_string) {
$this->xml[] = $xml_string;
}
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Fhp\Action;
use Fhp\BaseAction;
use Fhp\Model\SEPAAccount;
use Fhp\Protocol\BPD;
use Fhp\Protocol\UPD;
use Fhp\Segment\AUB\HIAUBSv9;
use Fhp\Segment\AUB\HKAUBv9;
use Fhp\Segment\Common\Kti;
use Fhp\Syntax\Bin;
class SendInternationalCreditTransfer extends BaseAction
{
/** @var SEPAAccount */
protected $account;
/** @var string */
protected $dtavzData;
/** @var string|null */
protected $dtavzVersion;
/**
* @param SEPAAccount $account The account of the creditor (the sender of the money)
* @param string $dtavzData The details of the transfer(s) in DTAZV Format (Datenträgeraustauschverfahren Auslandszahlungsverkehr)
* @param string|null $dtavzVersion If null the value the bank expects is used.
*/
public static function create(SEPAAccount $account, string $dtavzData, ?string $dtavzVersion = null): SendInternationalCreditTransfer
{
$result = new SendInternationalCreditTransfer();
$result->account = $account;
$result->dtavzVersion = $dtavzVersion;
$result->dtavzData = $dtavzData;
return $result;
}
protected function createRequest(BPD $bpd, ?UPD $upd)
{
/** @var HIAUBSv9 $hiaubs */
$hiaubs = $bpd->requireLatestSupportedParameters('HIAUBS');
$hkaub = HKAUBv9::createEmpty();
$hkaub->kontoverbindungInternational = Kti::fromAccount($this->account);
$hkaub->DTAZVHandbuch = $this->dtavzVersion ?? $hiaubs->parameter->DTAZVHandbuch;
$hkaub->DTAZVDatensatz = new Bin($this->dtavzData);
return $hkaub;
}
}

View file

@ -0,0 +1,185 @@
<?php
namespace Fhp\Action;
use Fhp\BaseAction;
use Fhp\Model\SEPAAccount;
use Fhp\Protocol\BPD;
use Fhp\Protocol\UPD;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\Common\Btg;
use Fhp\Segment\Common\Kti;
use Fhp\Segment\DME\HIDMESv1;
use Fhp\Segment\DME\HIDMESv2;
use Fhp\Segment\DSE\HIDSESv2;
use Fhp\Segment\DSE\HIDXES;
use Fhp\Segment\SPA\HISPAS;
use Fhp\Syntax\Bin;
use Fhp\UnsupportedException;
/**
* Initiate one or multiple SEPA Direct Debits ("Lastschriften")
*/
class SendSEPADirectDebit extends BaseAction
{
/** @var SEPAAccount */
protected $account;
/** @var string */
protected $painMessage;
/** @var string */
protected $painNamespace;
/** @var float */
protected $ctrlSum;
/** @var bool */
protected $singleDirectDebit = false;
/** @var bool */
protected $tryToUseControlSumForSingleTransactions = false;
/** @var string */
private $coreType;
public static function create(SEPAAccount $account, string $painMessage, bool $tryToUseControlSumForSingleTransactions = false): SendSEPADirectDebit
{
if (preg_match('/xmlns="(?<namespace>[^"]+)"/s', $painMessage, $matches) === 1) {
$painNamespace = $matches['namespace'];
} else {
throw new \InvalidArgumentException('The namespace aka "xmlns" is missing in PAIN message');
}
// Check whether the PAIN message contains multiple or only one Direct Debit, should match <NbOfTxs>xx</NbOfTxs> in the XML
$nbOfTxs = substr_count($painMessage, '<DrctDbtTxInf>');
$ctrlSum = null;
if (preg_match('@<GrpHdr>.*?<CtrlSum>(?<ctrlsum>[0-9.]+)</CtrlSum>.*?</GrpHdr>@s', $painMessage, $matches) === 1) {
$ctrlSum = $matches['ctrlsum'];
}
if (preg_match('@<PmtTpInf>.*?<LclInstrm>.*?<Cd>(?<coretype>CORE|COR1|B2B)</Cd>.*?</LclInstrm>.*?</PmtTpInf>@s', $painMessage, $matches) === 1) {
$coreType = $matches['coretype'];
} else {
throw new \InvalidArgumentException('The type CORE/COR1/B2B is missing in PAIN message');
}
if ($nbOfTxs > 1 && is_null($ctrlSum)) {
throw new \InvalidArgumentException('The control sum aka "<GrpHdr><CtrlSum>xx</CtrlSum></GrpHdr>" is missing in PAIN message');
}
$result = new SendSEPADirectDebit();
$result->account = $account;
$result->painMessage = $painMessage;
$result->painNamespace = $painNamespace;
$result->ctrlSum = $ctrlSum;
$result->coreType = $coreType;
$result->singleDirectDebit = $nbOfTxs === 1;
$result->tryToUseControlSumForSingleTransactions = $tryToUseControlSumForSingleTransactions;
return $result;
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*/
public function serialize(): string
{
return serialize($this->__serialize());
}
public function __serialize(): array
{
return [
parent::__serialize(),
$this->singleDirectDebit, $this->tryToUseControlSumForSingleTransactions, $this->ctrlSum, $this->coreType, $this->painMessage, $this->painNamespace, $this->account,
];
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*
* @param string $serialized
* @return void
*/
public function unserialize($serialized)
{
self::__unserialize(unserialize($serialized));
}
public function __unserialize(array $serialized): void
{
list(
$parentSerialized,
$this->singleDirectDebit, $this->tryToUseControlSumForSingleTransactions, $this->ctrlSum, $this->coreType, $this->painMessage, $this->painNamespace, $this->account
) = $serialized;
is_array($parentSerialized) ?
parent::__unserialize($parentSerialized) :
parent::unserialize($parentSerialized);
}
protected function createRequest(BPD $bpd, ?UPD $upd)
{
$useSingleDirectDebit = $this->singleDirectDebit;
// If the PAIN message contains a control sum, we should use it, if the bank also supports it
if ($useSingleDirectDebit && $this->tryToUseControlSumForSingleTransactions && !is_null($this->ctrlSum) && !is_null($bpd->getLatestSupportedParameters('HIDMES'))) {
$useSingleDirectDebit = false;
}
/* @var HIDXES|BaseSegment $hidxes */
$hidxes = $bpd->requireLatestSupportedParameters(GetSEPADirectDebitParameters::getHixxesSegmentName($this->coreType, $useSingleDirectDebit));
$supportedPainNamespaces = null;
if ($hidxes->getVersion() === 2) {
/** @var HIDMESv2|HIDSESv2 $hidxes */
$supportedPainNamespaces = $hidxes->getParameter()->getUnterstuetzteSEPADatenformate();
}
// If there are no SEPA formats available in the HIDXES Parameters, we look to the general formats
if (!is_array($supportedPainNamespaces) || count($supportedPainNamespaces) === 0) {
/** @var HISPAS $hispas */
$hispas = $bpd->requireLatestSupportedParameters('HISPAS');
$supportedPainNamespaces = $hispas->getParameter()->getUnterstuetzteSEPADatenformate();
}
// Sometimes the Bank reports supported schemas with a "_GBIC_X" postfix.
// GIBC_X stands for German Banking Industry Committee and a version counter.
$xmlSchema = $this->painNamespace;
$matchingSchemas = array_filter($supportedPainNamespaces, function($value) use ($xmlSchema) {
// For example urn:iso:std:iso:20022:tech:xsd:pain.008.001.08 from the xml matches
// urn:iso:std:iso:20022:tech:xsd:pain.008.001.08_GBIC_4
return str_starts_with($value, $xmlSchema);
});
if (count($matchingSchemas) === 0) {
throw new UnsupportedException("The bank does not support the XML schema $this->painNamespace, but only "
. implode(', ', $supportedPainNamespaces));
}
/** @var mixed $hkdxe */ // TODO Put a new interface type here.
$hkdxe = $hidxes->createRequestSegment();
$hkdxe->kontoverbindungInternational = Kti::fromAccount($this->account);
$hkdxe->sepaDescriptor = $this->painNamespace;
$hkdxe->sepaPainMessage = new Bin($this->painMessage);
if (!$useSingleDirectDebit) {
if ($hidxes->getParameter()->einzelbuchungErlaubt) {
$hkdxe->einzelbuchungGewuenscht = false;
}
/* @var HIDMESv1 $hidxes */
// Just always send the control sum
// if ($hidxes->getParameter()->summenfeldBenoetigt) {
$hkdxe->summenfeld = Btg::create($this->ctrlSum);
// }
}
return $hkdxe;
}
}

View file

@ -0,0 +1,114 @@
<?php
namespace Fhp\Action;
use Fhp\BaseAction;
use Fhp\Model\SEPAAccount;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\Common\Kti;
use Fhp\Segment\HIRMS\Rueckmeldung;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\Segment\IPZ\HIIPZSv1;
use Fhp\Segment\IPZ\HIIPZSv2;
use Fhp\Segment\IPZ\HKIPZv2;
use Fhp\Segment\SPA\HISPAS;
use Fhp\Syntax\Bin;
use Fhp\UnsupportedException;
/**
* Initiates an outgoing realtime transfer in SEPA format (PAIN XML).
*/
class SendSEPARealtimeTransfer extends BaseAction
{
/** @var SEPAAccount */
private $account;
/** @var string */
private $painMessage;
/** @var string */
private $xmlSchema;
private bool $allowConversionToSEPATransfer = true;
/**
* @param SEPAAccount $account The account from which the transfer will be sent.
* @param string $painMessage An XML-formatted ISO 20022 message. You may want to use github.com/nemiah/phpSepaXml
* to create this.
* @param bool $allowConversionToSEPATransfer If instant payment ist not possible, allow the bank to send as a regular transfer instead
* @return SendSEPARealtimeTransfer A new action for executing this the given PAIN message.
*/
public static function create(SEPAAccount $account, string $painMessage, bool $allowConversionToSEPATransfer = true): SendSEPARealtimeTransfer
{
if (preg_match('/xmlns="(.*?)"/', $painMessage, $match) === false) {
throw new \InvalidArgumentException('xmlns not found in the PAIN message');
}
$result = new SendSEPARealtimeTransfer();
$result->account = $account;
$result->painMessage = $painMessage;
$result->xmlSchema = $match[1];
$result->allowConversionToSEPATransfer = $allowConversionToSEPATransfer;
return $result;
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
/** @var HIIPZSv1|HIIPZSv2 $hiipzs */
$hiipzs = $bpd->requireLatestSupportedParameters('HIIPZS');
$supportedSchemas = $hiipzs->parameter->getUnterstuetzteSEPADatenformate();
// If there are no SEPA formats available in the HIIPZS Parameters, we look to the general formats
if (is_null($supportedSchemas)) {
/** @var HISPAS $hispas */
$hispas = $bpd->requireLatestSupportedParameters('HISPAS');
$supportedSchemas = $hispas->getParameter()->getUnterstuetzteSEPADatenformate();
}
// Sometimes the Bank reports supported schemas with a "_GBIC_X" postfix.
// GIBC_X stands for German Banking Industry Committee and a version counter.
$xmlSchema = $this->xmlSchema;
$matchingSchemas = array_filter($supportedSchemas, function($value) use ($xmlSchema) {
// For example urn:iso:std:iso:20022:tech:xsd:pain.001.001.09 from the xml matches
// urn:iso:std:iso:20022:tech:xsd:pain.001.001.09_GBIC_4
return str_starts_with($value, $xmlSchema);
});
if (count($matchingSchemas) === 0) {
throw new UnsupportedException("The bank does not support the XML schema $this->xmlSchema, but only "
. implode(', ', $supportedSchemas));
}
/** @var HKIPZv1|HKIPZv2 $hkipz */
$hkipz = $hiipzs->createRequestSegment();
$hkipz->kontoverbindungInternational = Kti::fromAccount($this->account);
$hkipz->sepaDescriptor = $this->xmlSchema;
$hkipz->sepaPainMessage = new Bin($this->painMessage);
if ($hiipzs instanceof HIIPZSv2) {
$hkipz->umwandlungNachSEPAUeberweisungZulaessig = $hiipzs->parameter->umwandlungNachSEPAUeberweisungZulaessigErlaubt && $this->allowConversionToSEPATransfer;
}
return $hkipz;
}
/** {@inheritdoc} */
public function processResponse(Message $response)
{
parent::processResponse($response);
// Was the instant payment converted to a regular transfer?
$info = $response->findRueckmeldungen(3270);
if (count($info) > 0) {
$this->successMessage = implode("\n", array_map(function (Rueckmeldung $rueckmeldung) {
return $rueckmeldung->rueckmeldungstext;
}, $info));
return;
}
if ($response->findRueckmeldung(Rueckmeldungscode::ENTGEGENGENOMMEN) === null &&
$response->findRueckmeldung(Rueckmeldungscode::AUSGEFUEHRT) === null) {
throw new UnexpectedResponseException('Bank did not confirm SEPATransfer execution');
}
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace Fhp\Action;
use Fhp\BaseAction;
use Fhp\Model\SEPAAccount;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\Common\Kti;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
use Fhp\Segment\SPA\HISPAS;
use Fhp\Syntax\Bin;
use Fhp\UnsupportedException;
/**
* Initiates an outgoing wire transfer in SEPA format (PAIN XML).
*/
class SendSEPATransfer extends BaseAction
{
/** @var SEPAAccount */
private $account;
/** @var string */
private $painMessage;
/** @var string */
private $xmlSchema;
/**
* @param SEPAAccount $account The account from which the transfer will be sent.
* @param string $painMessage An XML-formatted ISO 20022 message. You may want to use github.com/nemiah/phpSepaXml
* to create this.
* @return SendSEPATransfer A new action for executing this the given PAIN message.
*/
public static function create(SEPAAccount $account, string $painMessage): SendSEPATransfer
{
if (preg_match('/xmlns="(.*?)"/', $painMessage, $match) === false) {
throw new \InvalidArgumentException('xmlns not found in the PAIN message');
}
$result = new SendSEPATransfer();
$result->account = $account;
$result->painMessage = $painMessage;
$result->xmlSchema = $match[1];
return $result;
}
/** {@inheritdoc} */
protected function createRequest(BPD $bpd, ?UPD $upd)
{
//ANALYSE XML FOR RECEIPTS AND PAYMENT DATE
$xmlAsObject = simplexml_load_string($this->painMessage, "SimpleXMLElement", LIBXML_NOCDATA);
$numberOfTransactions = $xmlAsObject->CstmrCdtTrfInitn->GrpHdr->NbOfTxs;
$hasReqdExDates = false;
foreach ($xmlAsObject->CstmrCdtTrfInitn?->PmtInf as $pmtInfo) {
// Checks for both, <ReqdExctnDt>1999-01-01</ReqdExctnDt> and <ReqdExctnDt><Dt>1999-01-01</Dt></ReqdExctnDt>
if (isset($pmtInfo->ReqdExctnDt) && ($pmtInfo->ReqdExctnDt->Dt ?? $pmtInfo->ReqdExctnDt) != '1999-01-01') {
$hasReqdExDates = true;
break;
}
}
//NOW READ OUT, WICH SEGMENT SHOULD BE USED:
if ($numberOfTransactions > 1 && $hasReqdExDates) {
// Terminierte SEPA-Sammelüberweisung (Segment HKCME / Kennung HICMES)
$segmentID = 'HICMES';
$segment = \Fhp\Segment\CME\HKCMEv1::createEmpty();
} elseif ($numberOfTransactions == 1 && $hasReqdExDates) {
// Terminierte SEPA-Überweisung (Segment HKCSE / Kennung HICSES)
$segmentID = 'HICSES';
$segment = \Fhp\Segment\CSE\HKCSEv1::createEmpty();
} elseif ($numberOfTransactions > 1 && !$hasReqdExDates) {
// SEPA-Sammelüberweisungen (Segment HKCCM / Kennung HICSES)
$segmentID = 'HICSES';
$segment = \Fhp\Segment\CCM\HKCCMv1::createEmpty();
} else {
//SEPA Einzelüberweisung (Segment HKCCS / Kennung HICCSS).
$segmentID = 'HICCSS';
$segment = \Fhp\Segment\CCS\HKCCSv1::createEmpty();
}
if (!$bpd->supportsParameters($segmentID, 1)) {
throw new UnsupportedException('The bank does not support ' . $segmentID . 'v1');
}
/** @var HISPAS $hispas */
$hispas = $bpd->requireLatestSupportedParameters('HISPAS');
$supportedSchemas = $hispas->getParameter()->getUnterstuetzteSEPADatenformate();
// Sometimes the Bank reports supported schemas with a "_GBIC_X" postfix.
// GIBC_X stands for German Banking Industry Committee and a version counter.
$xmlSchema = $this->xmlSchema;
$matchingSchemas = array_filter($supportedSchemas, function($value) use ($xmlSchema) {
// For example urn:iso:std:iso:20022:tech:xsd:pain.001.001.09 from the xml matches
// urn:iso:std:iso:20022:tech:xsd:pain.001.001.09_GBIC_4
return str_starts_with($value, $xmlSchema);
});
if (count($matchingSchemas) === 0) {
throw new UnsupportedException("The bank does not support the XML schema $this->xmlSchema, but only "
. implode(', ', $supportedSchemas));
}
$segment->kontoverbindungInternational = Kti::fromAccount($this->account);
$segment->sepaDescriptor = $this->xmlSchema;
$segment->sepaPainMessage = new Bin($this->painMessage);
return $segment;
}
/** {@inheritdoc} */
public function processResponse(Message $response)
{
parent::processResponse($response);
if ($response->findRueckmeldung(Rueckmeldungscode::ENTGEGENGENOMMEN) === null && $response->findRueckmeldung(Rueckmeldungscode::AUSGEFUEHRT) === null) {
throw new UnexpectedResponseException('Bank did not confirm SEPATransfer execution');
}
}
}

258
vendor/nemiah/php-fints/lib/Fhp/BaseAction.php vendored Executable file
View file

@ -0,0 +1,258 @@
<?php
/** @noinspection PhpUnused */
namespace Fhp;
use Fhp\Model\TanRequest;
use Fhp\Protocol\ActionIncompleteException;
use Fhp\Protocol\BPD;
use Fhp\Protocol\Message;
use Fhp\Protocol\TanRequiredException;
use Fhp\Protocol\UnexpectedResponseException;
use Fhp\Protocol\UPD;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\HIRMS\Rueckmeldung;
use Fhp\Segment\HIRMS\Rueckmeldungscode;
/**
* Base class for actions that can be performed against a bank server. On a high level, there are two kinds of actions:
* - requests for information (e.g. an account statement), which the bank server will return
* - transactions (e.g. a wire transfer to another account), which the bank server will execute.
*
* In part, this class is designed like futures/promises in concurrent programming. The outcome of the action (i.e. the
* requested information or the execution confirmation of the transaction) becomes available in the future, possibly
* much later than when the request was sent, in case the user needs to enter a TAN.
* All action instances are serializable, so that the execution can be interrupted to ask the user for a TAN. Once the
* TAN is available, the execution can resume either a couple seconds later in the same PHP process using the same
* physical connection to the bank, or on the order of minutes later in a new PHP process with a newly established
* connection to the bank. Note that the serialization only applies to selected relevant request parameters, and not to
* the response. Thus it is only possible to serialize an action when its execution has been attempted but resulted in a
* TAN request.
* Actions that do not require a TAN will complete immediately.
*
* The implementation of an action consists of two parts: assembling the request to the bank, and processing the
* response.
*/
abstract class BaseAction implements \Serializable
{
/** @var int[] Stores segment numbers that were assigned to the segments returned from {@link createRequest()}. */
protected $requestSegmentNumbers;
/**
* @var string|null Contains the name of the segment, that might need a tan, used by FinTs::execute to signal
* to the bank that supplying a tan is supported.
*/
protected $needTanForSegment = null;
/**
* If set, the last response from the server regarding this action asked for a TAN from the user.
* @var TanRequest|null
*/
protected $tanRequest;
/** @var bool */
protected $isDone = false;
/**
* Will be populated with the message the bank sent along with the success indication, can be used to show to
* the user.
* @var string
*/
public $successMessage;
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*
* NOTE: A common mistake is to call this function directly. Instead, you probably want `serialize($instance)`.
*
* An action can only be serialized *after* it has been executed in case it needs a TAN, i.e. when the result is not
* present yet.
* If a sub-class overrides this, it should call the parent function and include it in its result.
* @return string The serialized action, e.g. for storage in a database. This will not contain sensitive user data.
*/
public function serialize(): string
{
return serialize($this->__serialize());
}
/**
* An action can only be serialized *after* it has been executed in case it needs a TAN, i.e. when the result is not
* present yet.
* If a sub-class overrides this, it should call the parent function and include it in its result.
*
* @return array The serialized action, e.g. for storage in a database. This will not contain sensitive user data.
*/
public function __serialize(): array
{
if (!$this->needsTan()) {
throw new \RuntimeException('Cannot serialize this action, because it is not waiting for a TAN.');
}
return [
$this->requestSegmentNumbers,
$this->tanRequest,
$this->needTanForSegment,
];
}
/**
* @deprecated Beginning from PHP7.4 __unserialize is used for new generated strings, then this method is only used for previously generated strings - remove after May 2023
*
* @param string $serialized
* @return void
*/
public function unserialize($serialized)
{
self::__unserialize(unserialize($serialized));
}
public function __unserialize(array $serialized): void
{
list(
$this->requestSegmentNumbers,
$this->tanRequest,
$this->needTanForSegment
) = $serialized;
}
/**
* @return bool Whether the underlying operation has completed successfully and the result in this "future" is
* available. Note: If this returns false, check {@link needsTan()}.
*/
public function isDone(): bool
{
return $this->isDone;
}
/**
* @return bool If this returns true, the underlying operation has not completed because it is awaiting a TAN or a
* "decoupled" confirmation. You should ask the user for this TAN/confirmation and pass it to
* {@link FinTs::submitTan()} or call {@link FinTs::checkDecoupledSubmission()}, respectively.
*/
public function needsTan(): bool
{
return !$this->isDone() && $this->tanRequest !== null;
}
public function getNeedTanForSegment(): ?string
{
return $this->needTanForSegment;
}
public function getTanRequest(): ?TanRequest
{
return $this->tanRequest;
}
/**
* Throws an exception unless this action has been successfully executed, i.e. in the following cases:
* - the action has not been {@link FinTs::execute()}-d at all or the {@link FinTs::execute()} call for it threw an
* exception,
* - the action is awaiting a TAN/confirmation (as per {@link BaseAction::needsTan()}.
*
* After executing an action, you can use this function to make sure that it succeeded. This is especially useful
* for actions that don't have any results (as each result getter would call {@link ensureDone()} internally).
* On the other hand, you do not need to call this function if you make sure that (1) you called
* {@link FinTs::execute()} and (2) you checked {@link needsTan()} and, if it returned true, supplied a TAN by
* calling {@ink FinTs::submitTan()}. Note that both exception types thrown from this method are sub-classes of
* {@link \RuntimeException}, so you shouldn't need a try-catch block at the call site for this.
* @throws ActionIncompleteException If the action hasn't even been executed.
* @throws TanRequiredException If the action needs a TAN.
*/
public function ensureDone()
{
if ($this->tanRequest !== null) {
throw new TanRequiredException($this->tanRequest);
} elseif (!$this->isDone()) {
throw new ActionIncompleteException();
}
}
/**
* Called when this action is about to be executed, in order to construct the request.
* @param BPD $bpd See {@link BPD}.
* @param UPD|null $upd See {@link UPD}. This is usually present (non-null), except for a few special login and TAN
* management actions.
* @return BaseSegment|BaseSegment[] A segment or a series of segments that should be sent to the bank server.
* Note that an action can return an empty array to indicate that it does not need to make a request to the
* server, but can instead compute the result just from the BPD/UPD, in which case it should set
* `$this->isDone = true;` already in {@link createRequest()} and {@link processResponse()} will never
* be executed.
* @throws \InvalidArgumentException When the request cannot be built because the input data or BPD/UPD is invalid.
*/
abstract protected function createRequest(BPD $bpd, ?UPD $upd);
/**
* Called by FinTs::execute when this action is about to be executed, in order to get a request. This function can
* be called multiple times in case the response is paginated.
* This method also tries to check if the segments might need a tan and stores this information for use in
* FinTs::execute
* @param BPD|null $bpd See {@link BPD}.
* @param UPD|null $upd See {@link UPD}. This is usually present (non-null), except for a few special login and TAN
* management actions.
* @return BaseSegment[] A segment or a series of segments that should be sent to the bank server.
* An empty array means that no request is necessary at all.
* @throws \InvalidArgumentException When the request cannot be built because the input data or BPD/UPD is invalid.
*/
public function getNextRequest(BPD $bpd, ?UPD $upd)
{
$requestSegments = $this->createRequest($bpd, $upd);
$requestSegments = is_array($requestSegments) ? $requestSegments : [$requestSegments];
$this->needTanForSegment = $bpd->tanRequiredForRequest($requestSegments);
return $requestSegments;
}
/**
* Called when this action was executed on the server (never if {@link createRequest()} returned an empty request),
* to process the response. This function can be called multiple times in case the response is paginated.
* In case the response indicates that this action failed, this function may throw an appropriate exception. Sub-classes should override this function
* and call the parent/super function.
* @param Message $response A fake message that contains the subset of segments received from the server that
* were in response to the request segments that were created by {@link createRequest()}.
* @throws UnexpectedResponseException When the response indicates failure.
*/
public function processResponse(Message $response)
{
$this->isDone = true;
$info = $response->findRueckmeldungen(Rueckmeldungscode::AUSGEFUEHRT);
if (count($info) === 0) {
$info = $response->findRueckmeldungen(Rueckmeldungscode::ENTGEGENGENOMMEN);
}
if (count($info) > 0) {
$this->successMessage = implode("\n", array_map(function (Rueckmeldung $rueckmeldung) {
return $rueckmeldung->rueckmeldungstext;
}, $info));
}
}
/** @return int[] */
public function getRequestSegmentNumbers(): array
{
return $this->requestSegmentNumbers;
}
/**
* To be called only by the FinTs instance that executes this action.
* @param int[] $requestSegmentNumbers
*/
final public function setRequestSegmentNumbers(array $requestSegmentNumbers)
{
foreach ($requestSegmentNumbers as $segmentNumber) {
if (!is_int($segmentNumber)) {
throw new \InvalidArgumentException("Invalid segment number: $segmentNumber");
}
}
$this->requestSegmentNumbers = $requestSegmentNumbers;
}
/**
* To be called only by the FinTs instance that executes this action.
*/
final public function setTanRequest(?TanRequest $tanRequest)
{
$this->tanRequest = $tanRequest;
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Fhp;
/**
* Thin wrapper around curl that does base64 encoding/decoding and converts errors to {@link CurlException}s.
*/
class Connection
{
/**
* @var string
*/
protected $url;
/**
* @var resource
*/
protected $curlHandle;
/**
* @var int
*/
protected $timeoutConnect = 15;
/**
* @var int
*/
protected $timeoutResponse = 30;
public function __construct(string $url, int $timeoutConnect = 15, int $timeoutResponse = 30)
{
$this->url = $url;
$this->timeoutConnect = $timeoutConnect;
$this->timeoutResponse = $timeoutResponse;
}
private function connect()
{
$this->curlHandle = curl_init();
curl_setopt($this->curlHandle, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($this->curlHandle, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($this->curlHandle, CURLOPT_USERAGENT, 'phpFinTS');
curl_setopt($this->curlHandle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($this->curlHandle, CURLOPT_URL, $this->url);
curl_setopt($this->curlHandle, CURLOPT_CONNECTTIMEOUT, $this->timeoutConnect);
curl_setopt($this->curlHandle, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($this->curlHandle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
curl_setopt($this->curlHandle, CURLOPT_ENCODING, '');
curl_setopt($this->curlHandle, CURLOPT_MAXREDIRS, 0);
curl_setopt($this->curlHandle, CURLOPT_TIMEOUT, $this->timeoutResponse);
curl_setopt($this->curlHandle, CURLOPT_HTTPHEADER, ['cache-control: no-cache', 'Content-Type: text/plain']);
}
public function disconnect()
{
if ($this->curlHandle !== null) {
curl_close($this->curlHandle);
$this->curlHandle = null;
}
}
/**
* @param string $message The message to be sent, in HBCI/FinTS wire format, ISO-8859-1 encoded.
* @return string The response from the server, in HBCI/FinTS wire format, ISO-8859-1 encoded.
* @throws CurlException When the request fails.
*/
public function send(string $message): string
{
if (!$this->curlHandle) {
$this->connect();
}
curl_setopt($this->curlHandle, CURLOPT_POSTFIELDS, base64_encode($message));
$response = curl_exec($this->curlHandle);
if (false === $response) {
throw new CurlException(
'Failed connection to ' . $this->url . ': ' . curl_error($this->curlHandle),
null,
curl_errno($this->curlHandle),
curl_getinfo($this->curlHandle),
curl_error($this->curlHandle)
);
}
$statusCode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE);
if ($statusCode < 200 || $statusCode > 299) {
throw new CurlException(
'Bad response with status code ' . $statusCode,
$response,
$statusCode,
curl_getinfo($this->curlHandle)
);
}
return base64_decode($response);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Fhp;
class CurlException extends \Exception
{
/**
* @var array
*/
protected $curlInfo;
/**
* @var string|null
*/
protected $response;
/**
* @var string|null
*/
protected $curlMessage;
public function __construct(string $message, ?string $response, int $code = 0, array $curlInfo = [], ?string $curlMessage = null)
{
parent::__construct($message, $code, null);
$this->response = $response;
$this->curlInfo = $curlInfo;
$this->curlMessage = $curlMessage;
}
public function getResponse(): ?string
{
return $this->response;
}
/**
* Gets the curl info from request / response.
*/
public function getCurlInfo(): array
{
return $this->curlInfo;
}
/**
* Gets the curl message from request / response.
*/
public function getCurlMessage(): ?string
{
return $this->curlMessage;
}
}

1017
vendor/nemiah/php-fints/lib/Fhp/FinTs.php vendored Executable file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
<?php
namespace Fhp\MT535;
use Fhp\Model\StatementOfHoldings\Holding;
use Fhp\Model\StatementOfHoldings\StatementOfHoldings;
/**
* Data format: MT 535 (Version SRG 1998)
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Finanzdatenformate_2010-08-06_final_version.pdf
* Section: B.4
*/
class MT535
{
/** @var string */
private $cleanedRawData;
public function __construct(string $rawData)
{
// The divider can be either \r\n or @@
$divider = substr_count($rawData, "\r\n-") > substr_count($rawData, '@@-') ? "\r\n" : '@@';
$this->cleanedRawData = preg_replace('#' . $divider . '([^:])#ms', '$1', $rawData);
}
public function parseDepotWert(): float
{
preg_match('/:16R:ADDINFO(.*?):16S:ADDINFO/sm', $this->cleanedRawData, $block);
preg_match('/EUR(.*)/sm', $block[1], $matches);
return floatval(str_replace(',', '.', $matches[1]));
}
public function parseHoldings(): StatementOfHoldings
{
$result = new StatementOfHoldings();
preg_match_all('/:16R:FIN(.*?):16S:FIN/sm', $this->cleanedRawData, $blocks);
foreach ($blocks[1] as $block) {
$holding = new Holding();
// handle ISIN, WKN & Name
// :35B:ISIN DE0005190003/DE/519000BAY.MOTOREN WERKE AG ST
if (preg_match('/^:35B:(.*?):/sm', $block, $iwn)) {
preg_match('/^.{5}(.{12})/sm', $iwn[1], $r);
$holding->setISIN($r[1]);
preg_match('/^.{21}(.{6})/sm', $iwn[1], $r);
$holding->setWKN($r[1]);
preg_match('/^.{27}(.*)/sm', $iwn[1], $r);
$holding->setName($r[1]);
}
// handle acquisition price
// e.g ':70E::HOLD//1STK23,968293+EUR'
if (preg_match('/:70E::HOLD\/\/\d*STK2(\d*),(\d*)\+([A-Z]{3})/sm', $block, $iwn)) {
$holding->setAcquisitionPrice((float) $iwn[1].'.'.$iwn[2]);
if ($holding->getCurrency() === null) {
$holding->setCurrency($iwn[3]);
}
}
// handle Price
// :90B::MRKT//ACTU/EUR76,06
// A1G1UF
if (preg_match('/:90(.)::(.*?):/sm', $block, $iwn)) {
if ($iwn[1] == 'B') {
// Currency
preg_match('/^.{11}(.{3})/sm', $iwn[2], $r);
$holding->setCurrency($r[1]);
// Price
preg_match('/^.{14}(.*)/sm', $iwn[2], $r);
$holding->setPrice(floatval(str_replace(',', '.', $r[1])));
} elseif ($iwn[1] == 'A') {
$holding->setCurrency('%');
// Price
preg_match('/^.{11}(.*)/sm', $iwn[2], $r);
$holding->setPrice(floatval(str_replace(',', '.', $r[1])) / 100);
}
}
// handle Amount
// :93B::AGGR//UNIT/2666,000
if (preg_match('/:93B::(.*?):/sm', $block, $iwn)) {
// Amount
preg_match('/^.{11}(.*)/sm', $iwn[1], $r);
$holding->setAmount(floatval(str_replace(',', '.', $r[1])));
}
if ($holding->getAmount() !== null && $holding->getPrice() !== null) {
if ($holding->getCurrency() === '%') {
$holding->setValue($holding->getPrice() / 100);
} else {
$holding->setValue($holding->getPrice() * $holding->getAmount());
}
}
// Bereitstellungsdatum
// :98A::PRIC//20210304
// :98C::STAT//20250104140541
if (preg_match('/:98([AC])::(.*?):/sm', $block, $iwn)) {
preg_match('/^.{6}(.{8})/sm', $iwn[2], $r);
$holding->setDate($this->getDate($r[1]));
$time = new \DateTime();
if ($iwn[1] == 'C') {
// 98C has a time component
preg_match('/^.{14}(\d\d)(\d\d)(\d\d)/sm', $iwn[2], $r);
$time->setTime($r[1], $r[2], $r[3]);
} else {
$time->setTime(0, 0);
}
$holding->setTime($time);
}
$result->addHolding($holding);
}
return $result;
}
protected function getDate(string $val): \DateTime
{
preg_match('/(\d{4})(\d{2})(\d{2})/', $val, $m);
try {
return new \DateTime($m[1] . '-' . $m[2] . '-' . $m[3]);
} catch (\Exception $e) {
throw new \InvalidArgumentException("Invalid date: $val", 0, $e);
}
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace Fhp\MT940\Dialect;
use Fhp\MT940\MT940;
class PostbankMT940 extends MT940
{
public const DIALECT_ID = 'https://hbci.postbank.de/banking/hbci.do';
/** {@inheritdoc} */
public function extractStructuredDataFromRemittanceLines($descriptionLines, string &$gvc, array &$rawLines, array $transaction): array
{
// z.B bei Zinsen o.ä. ist alles leer
if (!isset($descriptionLines[0])) {
return [];
}
$structuredStartFound = preg_match('/^[A-Z]{4}\+/', $descriptionLines[0]) === 1;
if ($structuredStartFound) {
return parent::extractStructuredDataFromRemittanceLines($descriptionLines, $gvc, $rawLines, $transaction);
}
// Bie Auslandsüberweisungen (=210)
// Der Empfänger name steht als erstes im Verwendungszweck und teile des Verwendungszwecks stehen im Namen
if ($gvc == '210') {
$name = array_shift($descriptionLines);
array_unshift($descriptionLines, $rawLines[33]);
array_unshift($descriptionLines, $rawLines[32]);
$rawLines[32] = $name;
$rawLines[33] = '';
}
return [
'SVWZ' => implode("\n", $descriptionLines),
];
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Fhp\MT940\Dialect;
use Fhp\MT940\MT940;
class SpardaMT940 extends MT940
{
public const DIALECT_ID = 'https://fints.bankingonline.de/fints/FinTs30PinTanHttpGate';
/** {@inheritdoc} */
public function extractStructuredDataFromRemittanceLines($descriptionLines, string &$gvc, array &$rawLines, array $transaction): array
{
$otherInfo = [];
$structuredStartFound = false;
$lines = [];
foreach ($descriptionLines as $line) {
if ($structuredStartFound || preg_match('/^[A-Z]{4}\+ /', $line) === 1) {
$structuredStartFound = true;
$lines[] = $line;
} else {
$otherInfo[] = $line;
}
}
if (!$structuredStartFound) {
return ['SVWZ' => implode("\n", $otherInfo)];
}
// Beispiel
/*
0 => "SEPA-BASISLASTSCHRIFT"
1 => "EREF+ xxxxxxxxxxxxxxxxxx MR"
2 => "EF+ xxxxxxxxxxxxxxxx CRED+"
3 => "XXxxxxxxxxxxxxxxxxx SVWZ+ A"
4 => "bcdef ghijklmn opqr stuvwxy"
5 => "z1 1234 678912345"
6 => "ABWA+ Abcd Efghij"
*/
$combined = '';
foreach ($lines as $line) {
// Sonderfall, für Zeile 2 aus dem Beispiel
$combined .= preg_replace('/ ([A-Z]{4}\+)$/', ' $1 ', $line);
}
$combined = implode('', $lines);
// SEPA Bezeichner müssen in einer neuen Zeile Anfangen und kein Leerzeichen hinter dem + haben
$fixed = preg_replace('/([A-Z]{4}\+) /', "\n$1", $combined);
$correctedLines = explode("\n", trim($fixed, "\n"));
// Buchungstext z.B. SEPA-ÜBERWEISUNG
if (count($otherInfo) > 0) {
$rawLines[0] = $bookingText = array_pop($otherInfo);
switch ($bookingText) {
case 'SEPA-ÜBERWEISUNG':
if ($transaction['credit_debit'] === static::CD_CREDIT) {
$gvc = '166';
}
if ($transaction['credit_debit'] === static::CD_DEBIT) {
$gvc = '177';
}
break;
case 'SEPA-BASISLASTSCHRIFT':
$gvc = '105';
break;
// case 'SEPA-RÜCKLASTSCHRIFT':
// Hängt vom Betrag ab ?
}
}
// Rest vom Namen, wenn der > 27 Zeichen ist, ja ernsthaft
if (count($otherInfo) > 0) {
$rawLines[33] .= array_pop($otherInfo);
}
$desc = parent::extractStructuredDataFromRemittanceLines($correctedLines, $gvc, $rawLines, $transaction);
if (isset($desc['SVWZ']) && str_starts_with($desc['SVWZ'], 'Dauerauftrag')) {
$gvc = '152';
$rawLines[0] = 'Dauerauftrag-Gutschrift';
}
return $desc;
}
}

View file

@ -0,0 +1,258 @@
<?php
namespace Fhp\MT940;
/**
* Data format: MT 940 (Version SRG 2001)
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Messages_Finanzdatenformate_2010-08-06_final_version.pdf
* Section: B.8
*/
class MT940
{
public const CD_CREDIT = 'credit';
public const CD_DEBIT = 'debit';
/**
* @throws MT940Exception
*/
public function parse(string $rawData): array
{
// The divider can be either \r\n or @@
$divider = substr_count($rawData, "\r\n-") > substr_count($rawData, '@@-') ? "\r\n" : '@@';
$cleanedRawData = preg_replace('#' . $divider . '([^:])#ms', '$1', $rawData);
$booked = true;
$result = [];
$days = explode($divider . '-', $cleanedRawData);
$soaDate = null;
foreach ($days as &$day) {
$day = explode($divider . ':', $day);
for ($i = 0, $cnt = count($day); $i < $cnt; ++$i) {
if (preg_match("/\+\@[0-9]+\@$/", trim($day[$i]))) {
$booked = false;
}
// handle start balance
// 60F:C160401EUR1234,56
if (preg_match('/^60(F|M):/', $day[$i])) {
// remove 60(F|M): for better parsing
$day[$i] = substr($day[$i], 4);
$soaDate = $this->getDate(substr($day[$i], 1, 6));
// if this statement date ist first seen set start_balance
// Note: all further transactions in different statements with the same soaDate will be appended
// there will be no new statement done for them. With bigger code changes this could be changed.
// For now this is shortcutted like this for fixing https://github.com/nemiah/phpFinTS/issues/367
if (!isset($result[$soaDate])) {
$result[$soaDate] = ['start_balance' => []];
$cdMark = substr($day[$i], 0, 1);
if ($cdMark === 'C') {
$result[$soaDate]['start_balance']['credit_debit'] = static::CD_CREDIT;
} elseif ($cdMark === 'D') {
$result[$soaDate]['start_balance']['credit_debit'] = static::CD_DEBIT;
}
$amount = str_replace(',', '.', substr($day[$i], 10));
$result[$soaDate]['start_balance']['amount'] = $amount;
}
} elseif (
// found transaction
// trx:61:1603310331DR637,39N033NONREF
str_starts_with($day[$i], '61:')
&& isset($day[$i + 1])
&& str_starts_with($day[$i + 1], '86:')
) {
$transaction = substr($day[$i], 3);
$description = substr($day[$i + 1], 3);
if (!isset($result[$soaDate]['transactions'])) {
$result[$soaDate]['transactions'] = [];
}
// short form for better handling
$trx = &$result[$soaDate]['transactions'];
preg_match('/^\d{6}(\d{4})?(C|D|RC|RD)([A-Z]{1})?([^N]+)N/', $transaction, $trxMatch);
if ($trxMatch[2] === 'C' || $trxMatch[2] === 'RC') {
$trx[count($trx)]['credit_debit'] = static::CD_CREDIT;
} elseif ($trxMatch[2] === 'D' || $trxMatch[2] === 'RD') {
$trx[count($trx)]['credit_debit'] = static::CD_DEBIT;
} else {
throw new MT940Exception('cd mark not found in: ' . $transaction);
}
$trx[count($trx) - 1]['is_storno'] = ($trxMatch[2] === 'RC' or $trxMatch[2] === 'RD');
$amount = $trxMatch[4];
$amount = str_replace(',', '.', $amount);
$trx[count($trx) - 1]['amount'] = $amount;
// :61:1605110509D198,02NMSCNONREF
// 16 = year
// 0511 = valuta date
// 0509 = booking date
$year = substr($transaction, 0, 2);
$valutaDate = $this->getDate($year . substr($transaction, 2, 4));
$bookingDatePart = substr($transaction, 6, 4);
if (preg_match('/^\d{4}$/', $bookingDatePart) === 1) {
// try to guess the correct year of the booking date
$valutaDateTime = new \DateTime($valutaDate);
$bookingDateTime = new \DateTime($this->getDate($year . $bookingDatePart));
// the booking date can be before or after the valuata date
// and one of them can be in another year for example 12-31 and 01-01
$diff = $valutaDateTime->diff($bookingDateTime);
// if diff is more than half a year
if ($diff->days > 182) {
// and positive
if ($diff->invert === 0) {
// its in the last year
--$year;
}
// and negative
else {
// its in the next year
++$year;
}
}
$bookingDate = $this->getDate($year . $bookingDatePart);
} else {
// if booking date not set in :61, then we have to take it from :60F
$bookingDate = $soaDate;
}
$trx[count($trx) - 1]['booking_date'] = $bookingDate;
$trx[count($trx) - 1]['valuta_date'] = $valutaDate;
$trx[count($trx) - 1]['booked'] = $booked;
$trx[count($trx) - 1]['description'] = $this->parseDescription($description, $trx[count($trx) - 1]);
} elseif (
preg_match('/^62F:/', $day[$i]) // handle end balance
) {
// remove 62F: for better parsing
$day[$i] = substr($day[$i], 4);
$soaDate = $this->getDate(substr($day[$i], 1, 6));
if (isset($result[$soaDate])) {
#$result[$soaDate] = ['end_balance' => []];
$amount = str_replace(',', '.', substr($day[$i], 10, -1));
$cdMark = substr($day[$i], 0, 1);
if ($cdMark == 'C') {
$result[$soaDate]['end_balance']['credit_debit'] = static::CD_CREDIT;
} elseif ($cdMark == 'D') {
$result[$soaDate]['end_balance']['credit_debit'] = static::CD_DEBIT;
$amount *= -1;
}
$result[$soaDate]['end_balance']['amount'] = $amount;
}
}
}
}
return $result;
}
protected function parseDescription($descr, $transaction): array
{
// Geschäftsvorfall-Code
$gvc = substr($descr, 0, 3);
$prepared = [];
$result = [];
// prefill with empty values
for ($i = 0; $i <= 63; ++$i) {
$prepared[$i] = null;
}
$descr = str_replace('? ', '?', $descr);
preg_match_all('/\?(\d{2})([^\?]+)/', $descr, $matches, PREG_SET_ORDER);
$descriptionLines = [];
$description1 = ''; // Legacy, could be removed.
$description2 = ''; // Legacy, could be removed.
foreach ($matches as $m) {
$index = (int) $m[1];
if ((20 <= $index && $index <= 29) || (60 <= $index && $index <= 63)) {
if (20 <= $index && $index <= 29) {
$description1 .= $m[2];
} else {
$description2 .= $m[2];
}
$descriptionLines[] = $m[2];
}
$prepared[$index] = $m[2];
}
$description = $this->extractStructuredDataFromRemittanceLines($descriptionLines, $gvc, $prepared, $transaction);
$result['booking_code'] = $gvc;
$result['booking_text'] = trim($prepared[0] ?? '');
$result['description'] = $description;
$result['primanoten_nr'] = trim($prepared[10] ?? '');
$result['description_1'] = trim($description1);
$result['bank_code'] = trim($prepared[30] ?? '');
$result['account_number'] = trim($prepared[31] ?? '');
$result['name'] = trim(($prepared[32] ?? '') . ($prepared[33] ?? ''));
$result['text_key_addition'] = trim($prepared[34] ?? '');
$result['description_2'] = $description2;
$result['desc_lines'] = $descriptionLines;
return $result;
}
/**
* @param string[] $descriptionLines that contain the remittance information
* @param string $gvc Geschätsvorfallcode; Out-Parameter, might be changed from information in remittance info
* @param string[] $rawLines All the lines in the Multi-Purpose-Field 86; Out-Parameter, might be changed from information in remittance info
*/
protected function extractStructuredDataFromRemittanceLines($descriptionLines, string &$gvc, array &$rawLines, array $transaction): array
{
$description = [];
if (empty($descriptionLines) || strlen($descriptionLines[0]) < 5 || $descriptionLines[0][4] !== '+') {
$description['SVWZ'] = implode('', $descriptionLines);
} else {
$lastType = null;
foreach ($descriptionLines as $line) {
if (strlen($line) >= 5 && $line[4] === '+') {
if ($lastType != null) {
$description[$lastType] = trim($description[$lastType]);
}
$lastType = substr($line, 0, 4);
$description[$lastType] = substr($line, 5);
} else {
$description[$lastType] .= $line;
}
if (strlen($line) < 27) {
// Usually, lines are 27 characters long. In case characters are missing, then it's either the end
// of the current type or spaces have been trimmed from the end. We want to collapse multiple spaces
// into one and we don't want to leave trailing spaces behind. So add a single space here to make up
// for possibly missing spaces, and if it's the end of the type, it will be trimmed off later.
$description[$lastType] .= ' ';
}
}
$description[$lastType] = trim($description[$lastType]);
}
return $description;
}
protected function getDate(string $val): string
{
$val = '20' . $val;
preg_match('/(\d{4})(\d{2})(\d{2})/', $val, $m);
return $m[1] . '-' . $m[2] . '-' . $m[3];
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace Fhp\MT940;
/**
* Thrown for MT940-specific parsing errors.
*/
class MT940Exception extends \Exception
{
}

View file

@ -0,0 +1,147 @@
<?php
/** @noinspection PhpUnused */
namespace Fhp\Model;
/**
* Note: This account information is obtained from the HIUPD contained in the UPD data, but it lacks the BIC.
*/
class Account
{
/** @var string|null */
protected $id;
/** @var string|null */
protected $accountNumber;
/** @var string|null */
protected $bankCode;
/** @var string|null */
protected $iban;
/** @var string|null */
protected $customerId;
/** @var string|null */
protected $currency;
/** @var string|null */
protected $accountOwnerName;
/** @var string|null */
protected $accountDescription;
public function getId(): ?string
{
return $this->id;
}
/**
* @return $this
*/
public function setId(?string $id)
{
$this->id = $id;
return $this;
}
public function getAccountNumber(): ?string
{
return $this->accountNumber;
}
/**
* @return $this
*/
public function setAccountNumber(?string $accountNumber)
{
$this->accountNumber = $accountNumber;
return $this;
}
public function getBankCode(): ?string
{
return $this->bankCode;
}
/**
* @return $this
*/
public function setBankCode(?string $bankCode)
{
$this->bankCode = $bankCode;
return $this;
}
public function getIban(): ?string
{
return $this->iban;
}
/**
* @return $this
*/
public function setIban(?string $iban)
{
$this->iban = $iban;
return $this;
}
public function getCustomerId(): ?string
{
return $this->customerId;
}
/**
* @return $this
*/
public function setCustomerId(?string $customerId)
{
$this->customerId = $customerId;
return $this;
}
public function getCurrency(): ?string
{
return $this->currency;
}
/**
* @return $this
*/
public function setCurrency(?string $currency)
{
$this->currency = $currency;
return $this;
}
public function getAccountOwnerName(): ?string
{
return $this->accountOwnerName;
}
/**
* @return $this
*/
public function setAccountOwnerName(?string $accountOwnerName)
{
$this->accountOwnerName = $accountOwnerName;
return $this;
}
public function getAccountDescription(): ?string
{
return $this->accountDescription;
}
/**
* @return $this
*/
public function setAccountDescription(?string $accountDescription)
{
$this->accountDescription = $accountDescription;
return $this;
}
}

View file

@ -0,0 +1,181 @@
<?php
namespace Fhp\Model\FlickerTan;
/**
* Represents a Data Element which is part of the Flicker Tan Challenge. Shortens the whole challenge.
* @see https://www.hbci-zka.de/dokumente/spezifikation_deutsch/hhd/Belegungsrichtlinien%20TANve1.5%20FV%20vom%202018-04-16.pdf
*/
class DataElement
{
public const ENC_ASCII = '1';
public const ENC_ASC = self::ENC_ASCII;
public const ENC_BCD = '0';
/**
* @var string the encoding (either self::ENC_ASC or self::ENC_BCD)
*/
protected $enc;
/**
* @var string the raw data string
*/
protected $data;
/**
* @var string the highest bit of the generated header
*/
protected $headerHighBit;
/**
* @param $challenge string raw challenge text
* @return array [string $reducedChallenge, FlickerTanDataElement $dataElementObject]
* @see https://www.hbci-zka.de/dokumente/spezifikation_deutsch/hhd/Belegungsrichtlinien%20TANve1.5%20FV%20vom%202018-04-16.pdf
*/
public static function parseNextBlock(string $challenge): array
{
if (empty($challenge)) {
return [$challenge, new self('')];
}
$length = (int) substr($challenge, 0, 2);
$data = substr($challenge, 2, $length);
if (strlen($data) !== $length) {
throw new \InvalidArgumentException('Parsing went wrong');
}
$rest = substr($challenge, 2 + $length);
return [$rest, new self($data)];
}
/**
* The needed encoding will be automatically determined by the type of data
* @param string $data the raw data
*/
protected function __construct(string $data)
{
$this->data = $data;
$this->headerHighBit = 0;
if (is_numeric($this->data) || empty($this->data)) {
$this->enc = self::ENC_BCD;
} else {
$this->enc = self::ENC_ASC;
}
}
/**
* @return int amount of bytes in data
*/
protected function getLength(): int
{
if ($this->enc === self::ENC_BCD) {
return ceil(strlen($this->data) / 2);
}
return strlen($this->data);
}
/**
* @return string returns the hex representation from the header of the data element as string with length 2
*/
public function getHeaderHex(): string
{
$lengthBin = str_pad(base_convert($this->getLength(), 10, 2), 6, '0', STR_PAD_LEFT);
$headerHex = base_convert($this->headerHighBit . $this->enc . $lengthBin, 2, 16);
return str_pad($headerHex, 2, '0', STR_PAD_LEFT);
}
/**
* @return string returns the hex representation of the data, depending on the set encoding
*/
public function getDataHex(): string
{
if ($this->enc === self::ENC_BCD) {
// base 10 and hex BCD encoded numbers are the same in range 0 to 9
$hexData = $this->data;
// Pad on Byte lenght
if (strlen($hexData) % 2 === 1) {
$hexData .= 'F';
}
return $hexData;
}
// ASCII encoding
$hexData = '';
foreach (str_split($this->data) as $char) {
$hexData .= base_convert(ord($char), 10, 16);
}
return $hexData;
}
/**
* @return string returns the hex representation of the Data Element incl header information
*/
public function toHex(): string
{
if (empty($this->data)) {
return '';
}
return $this->getHeaderHex() . $this->getDataHex();
}
/**
* @param string $hex which will be converted
* @param int $length to which the byte will be padded (default: 8)
* @return string binary representation of hex value as string with length $length
*/
public static function hexToByte(string $hex, int $length = 8): string
{
$byte = base_convert($hex, 16, 2);
return str_pad($byte, $length, '0', STR_PAD_LEFT);
}
/**
* @return int calculates Luhn Checksum of this object
*/
public function getLuhnChecksum(): int
{
return self::calcLuhn($this->getDataHex());
}
/**
* @return int calculates the Luhn checksum of a given hex string
*/
protected static function calcLuhn(string $hex): int
{
$sum = 0;
$doubleIt = false;
foreach (str_split($hex) as $char) {
$number = (int) base_convert($char, 16, 10);
if ($doubleIt) {
$number *= 2;
$decRep = str_split($number);
foreach ($decRep as $value) {
$sum += (int) $value;
}
} else {
$sum += $number;
}
$doubleIt = !$doubleIt;
}
return $sum;
}
/**
* Some handy debug info
*/
public function __debugInfo(): ?array
{
return [
'header' => $this->getHeaderHex(),
'data' => $this->data,
'hex-data' => $this->getDataHex(),
'hex' => $this->toHex(),
'luhn' => $this->getLuhnChecksum(),
];
}
/**
* @return string hex representation of object
*/
public function __toString()
{
return $this->toHex();
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace Fhp\Model\FlickerTan;
/**
* Represents a startcode in the TAN Flicker Challenge. Shortens the given challenge
* @see https://www.hbci-zka.de/dokumente/spezifikation_deutsch/hhd/Belegungsrichtlinien%20TANve1.5%20FV%20vom%202018-04-16.pdf
*/
class StartCode extends DataElement
{
/**
* @var string[] of the control bytes in hex representation
*/
private $controlBytes;
/**
* Parses Header information, control bytes and start code
* @param string the rest of the given challenge from the bank to parse
* @return array [string, FlickerTanStartCode]
*/
public static function parseNextBlock(string $challenge): array
{
$header = substr($challenge, 0, 2);
$rest = substr($challenge, 2);
$byte = self::hexToByte($header);
/* LS encoded base 16, bit idx:
* 0: 0=without ctrl byte 1=with ctrl byte
* 1: 0=BCD 1=ASC // never set
* 2 - 7: intval: start code length
*/
$hasControl = $byte[0] === '1';
$length = (int) base_convert(substr($byte, 2, 6), 2, 10);
[$ctrlBytes, $rest] = self::parseControlBytes($rest, $hasControl);
$data = substr($rest, 0, $length);
$rest = substr($rest, $length);
return [$rest, new self($ctrlBytes, $data)];
}
/**
* Helper function to parse the control bytes
* @param string $challenge the unparsed rest of the challenge string
* @param bool $hasControl is a controlbyte expected
* @return array [string[] of controlbytes, string unparsed rest of the challenge]
*/
private static function parseControlBytes(string $challenge, bool $hasControl): array
{
$controlBytes = [];
$rest = $challenge;
while ($hasControl) {
$ctrl = substr($challenge, 0, 2);
$controlBytes[] = $ctrl;
$rest = substr($challenge, 2);
$hasControl = self::hexToByte($ctrl)[0] === '1';
}
return [$controlBytes, $rest];
}
/**
* @throws \InvalidArgumentException if $ctrlBytes are unequal to ['01'] -> old version not supported so far
*/
protected function __construct(array $ctrlBytes, string $data)
{
if ($ctrlBytes !== ['01']) {
throw new \InvalidArgumentException('Other versions then 1.4 are not supported');
}
parent::__construct($data);
$this->controlBytes = $ctrlBytes;
$this->headerHighBit = '1';
}
/**
* {@inheritDoc}
*/
public function toHex(): string
{
return $this->getHeaderHex() . implode('', $this->controlBytes) . $this->getDataHex();
}
/**
* {@inheritDoc}
*/
public function getLuhnChecksum(): int
{
$luhn = 0;
foreach ($this->controlBytes as $ctrl) {
$luhn = self::calcLuhn($ctrl);
}
$luhn += parent::getLuhnChecksum(); // Luhn from the data (of the startcode)
return $luhn;
}
/**
* {@inheritDoc}
*/
public function __debugInfo(): ?array
{
return [
'header' => $this->getHeaderHex(),
'ctrl' => $this->controlBytes,
'data' => $this->data,
'hex-data' => $this->getDataHex(),
'luhn' => $this->getLuhnChecksum(),
];
}
}

View file

@ -0,0 +1,167 @@
<?php
namespace Fhp\Model\FlickerTan;
/**
* inspired by @see https://github.com/willuhn/hbci4java/blob/master/src/org/kapott/hbci/manager/FlickerCode.java
* documentation @see tan_hhd_uc_v14.pdf e.g. https://github.com/willuhn/hbci4java/blob/master/doc/tan_hhd_uc_v14.pdf
*/
class SvgRenderer
{
private $svg;
/**
* @var string[] the code in half-bit representation (string has length 4)
*/
private $bitPattern;
/**
* @var int blink frequency in Hz [1/s] should be between 2 and 20 Hz by documentation, but many TAN Generators are able to fetch 40 Hz as well
*/
private $frequency;
/**
* @param string[] $bitPattern a bit pattern in the format from {@see TanRequestChallengeFlicker::getFlickerPattern()}
* @param int $flickerFrequenz in Hz [1/s] between 2 and 40 Hz allowed
* @param int $width width of the svg, aspect ratio around 2:1 is recommended, but not enforced, default 210
* @param int $height height of the svg (will not adapt to width automatic), default 130
* @param string $id DOM id of the svg, which can be used as a selector via JS, e.g. to change height or width on clientside
* @throws \InvalidArgumentException thrown if $flickerFrequenz or $bitPattern are faulty formed
*/
public function __construct(array $bitPattern, int $flickerFrequenz = 10, int $width = 210, int $height = 130, string $id = 'flickerTanSVG')
{
$this->validate($bitPattern, $flickerFrequenz);
$this->frequency = $flickerFrequenz;
// prefix sync identifier
$this->bitPattern = $bitPattern;
// do the svg
$doc = [];
// init black background rect with rounded corners
$doc[] = $this->buildNode('rect', [
'width' => 210,
'height' => 105,
'rx' => 7.5,
'ry' => 7.5,
'fill' => 'black',
]);
// triangle for aiming help for hardware tan generator
$doc[] = $this->buildNode('polygon', [
'points' => [
'25,18', // middle bottom
'32,5', // top right
'18,5', // top left
],
'fill' => 'grey',
]);
$doc[] = $this->buildNode('polygon', [
'points' => [ // x shifted by 160
'185,18', // middle bottom
'192,5', // top right
'178,5', // top left
],
'fill' => 'grey',
]);
// init flicker rectangles
for ($i = 0; $i < 5; ++$i) {
$doc[] = $this->buildNode('rect', [
'x' => 40 * $i + 10,
'y' => 20,
'width' => 30,
'height' => 75,
], [$this->getAnimation($i)]);
}
$docAttr = [
'xmlns' => 'http://www.w3.org/2000/svg',
'width' => $width,
'height' => $height,
'viewBox' => '0 0 210 105',
'preserveAspectRatio' => 'none',
'id' => $id,
];
$this->svg = $this->buildNode('svg', $docAttr, $doc);
}
/**
* @param int $channelNumber flickerRectangles numbered from left to right, 0 is clock, 1 is 2^0, ..., 4 is 2^3 half byte representation
*/
private function getAnimation(int $channelNumber): string
{
$timePerHalfByte = 1 / $this->frequency * 2;
$attr = [
'attributeName' => 'fill',
'calcMode' => 'discrete',
'repeatCount' => 'indefinite',
];
if ($channelNumber === 0) {
// first rectangle is the clock
$attr['values'] = 'white;black;white';
$attr['keyFrames'] = '0;0.5;1';
$attr['dur'] = $timePerHalfByte . 's';
} else {
// arrange keyframes and colors
$colors = array_map(static function (string $pattern) use ($channelNumber) {
return $pattern[$channelNumber - 1] === '1' ? 'white' : 'black';
}, $this->bitPattern);
$keyFrames = range(0, 1, 1.0 / count($this->bitPattern));
$attr['values'] = implode(';', $colors);
$attr['keyFrames'] = implode(';', $keyFrames);
$attr['dur'] = ($timePerHalfByte * count($this->bitPattern)) . 's';
}
return $this->buildNode('animate', $attr);
}
/**
* @param string $tag the tag of the node
* @param array $attributes name-value pairs of the node attributes
* @param string[] $childs of the node
* @return string the string representation of the whole node with cilds
*/
private function buildNode(string $tag, array $attributes = [], array $childs = []): string
{
$attr = [];
foreach ($attributes as $name => $value) {
switch ($name) {
case 'fill':
$attr[] = "style='fill: $value'";
break;
case 'points':
$attr[] = "points='" . implode(' ', $value) . "'";
break;
default:
$attr[] = "$name='$value'";
}
}
$attrStr = implode(' ', $attr);
$childStr = implode(PHP_EOL, $childs);
return "<$tag $attrStr>$childStr</$tag>";
}
public function getImage(): string
{
return $this->svg;
}
public function __toString()
{
return $this->svg;
}
/**
* Validates input for frequency and bit pattern
* @throws \InvalidArgumentException
*/
private function validate(array $bitPattern, int $frequency): void
{
if ($frequency < 2 || $frequency > 40) {
throw new \InvalidArgumentException('Frequency is not between 2 and 40 Hz');
}
foreach ($bitPattern as $idx => $pattern) {
// detect if a string is not length 4 with only 0 and 1 chars
if (!preg_match('/^[01]{4}$/', $pattern)) {
throw new \InvalidArgumentException("Bit Pattern at index $idx is faulty, only 0 and 1 are allowed with length 4");
}
}
}
}

View file

@ -0,0 +1,153 @@
<?php
namespace Fhp\Model\FlickerTan;
use Fhp\Syntax\Bin;
/**
* Parses the HHDUC Flicker Tan Challenge to a Flicker pattern with suffixed control sequence
* @see https://www.hbci-zka.de/dokumente/spezifikation_deutsch/hhd/Belegungsrichtlinien%20TANve1.5%20FV%20vom%202018-04-16.pdf
*/
class TanRequestChallengeFlicker
{
/**
* @var string original challenge data
*/
private $challenge;
/**
* @var StartCode holds and parses the startcode block of the challenge
*/
private $startCode;
/**
* @var DataElement[] Holds and parses the first DataElement of the challenge, 3 max
*/
private $dataElements;
public function __construct(Bin $challengeBin)
{
$this->challenge = $challengeBin->getData();
$this->parseChallenge();
}
private function parseChallenge(): void
{
$reducedChallenge = trim(str_replace(' ', '', $this->challenge));
// length of whole challenge (without lc) max 255 | encoding: base 10
$lc = (int) substr($reducedChallenge, 0, 3);
$reducedChallenge = substr($reducedChallenge, 3);
if (strlen($reducedChallenge) !== $lc) {
throw new \InvalidArgumentException("Wrong length of TAN Challenge expected: $lc - found: ". strlen($reducedChallenge). ' - only Version 1.4 supported');
}
[$reducedChallenge, $this->startCode] = StartCode::parseNextBlock($reducedChallenge);
for ($i = 0; $i < 3; ++$i) {
[$reducedChallenge, $de] = DataElement::parseNextBlock($reducedChallenge);
$this->dataElements[$i] = $de;
}
if (!empty($reducedChallenge)) {
throw new \InvalidArgumentException("Challenge has unexpected ending $reducedChallenge");
}
}
/**
* @return string the xor checksum string in hex base
*/
private function calcXorChecksum(): string
{
$xor = 0b0000; // bin Representation of 0
$hex = str_split($this->getHexPayload());
foreach ($hex as $hexChar) {
$intVal = (int) base_convert($hexChar, 16, 10);
$xor ^= $intVal; // xor operator
}
return base_convert($xor, 10, 16);
}
/**
* @return string returns hex representation of the flicker code
*/
private function getHexPayload(): string
{
$hex = $this->startCode->toHex();
for ($i = 0; $i < 3; ++$i) {
$hex .= $this->dataElements[$i]->toHex();
}
$lc = strlen($hex) / 2 + 1;
$lc = str_pad(base_convert($lc, 10, 16), 2, '0', STR_PAD_LEFT);
return $lc . $hex;
}
/**
* calculates Luhn Checksum over the whole code
*/
private function calcLuhnChecksum(): int
{
$luhn = $this->startCode->getLuhnChecksum();
for ($i = 0; $i < 3; ++$i) {
$luhn += $this->dataElements[$i]->getLuhnChecksum();
}
return (10 - ($luhn % 10)) % 10;
}
/**
* @return string hex representation of challenge
*/
public function getHex(): string
{
$payload = $this->getHexPayload();
$luhn = $this->calcLuhnChecksum();
$xor = $this->calcXorChecksum();
return $payload . $luhn . $xor;
}
/**
* takes Hex Representation and builds bit patterns from it. Notable differences to the hex code:
* - prefixes 0FFF to the hex code (F0FF after swap)
* - swaps half bytes e.g. 0F FF ... -> F0 FF ...
* Hints for rendering:
* - 1 equals white, 0 equals black rectangle (other colors are possible, as long contrast is high enough, but unadvised)
* - The Tan Generator expects the following onscreen pattern: | clock | 2^0 | 2^1 | 2^2 | 2^3 |
* - Tan Generators read all 4 values on white to black flank, it is suggested to change the pattern on the black to white flank
* - each entry in the returned array will be hold for the whole clock cycle (both colors)
* @return string[] integer indexed array with strings, each 4 chars long with 0 or 1, which represent the expected flicker patterns
*/
public function getFlickerPattern(): array
{
$hexCode = $this->getHex();
$bitPattern = [];
// starting pattern - beginning of the pattern 0xFOFF
$bitPattern[] = '1111';
$bitPattern[] = '0000';
$bitPattern[] = '1111';
$bitPattern[] = '1111';
// convert hex code to flicker pattern
$len = strlen($hexCode);
for ($i = 0; $i < $len; $i += 2) {
// convert hex to bin representation of 1 byte at a time
$byte = base_convert(substr($hexCode, $i, 2), 16, 2);
// add missing zeros to the left
$byte = str_pad($byte, 8, '0', STR_PAD_LEFT);
// reverse order of half-bytes; flicker pattern is | clock | 2^0 | 2^1 | 2^2 | 2^3 |
$firstHalfByte = strrev(substr($byte, 0, 4));
$secondHalfByte = strrev(substr($byte, 4, 4));
// change order from first and second half byte (@see C.2)
$bitPattern[] = $secondHalfByte;
$bitPattern[] = $firstHalfByte;
}
return $bitPattern;
}
public function __debugInfo(): ?array
{
return [
'startcode' => $this->startCode,
'dataElements' => $this->dataElements,
'payload' => $this->getHexPayload(),
'luhn' => $this->calcLuhnChecksum(),
'xor' => $this->calcXorChecksum(),
];
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace Fhp\Model;
use Fhp\Segment\TAN\HKTAN;
/**
* This is a placeholder used instead of a real {@link TanMode} in order to signal that the bank's HBCI interface
* supports no strong authentication whatsoever and thus also no TAN modes. While it should still support the
* PIN/TAN authentication scheme (that's the only one that this library implements), not supporting the TAN part of
* means, in times of PSD2 regulations, that the HBCI interface is limited to read-only operations (like reading
* accounts and statements) and a separate login (through an app or web UI) is required regularly for the HBCI
* access to keep working.
*/
final class NoPsd2TanMode implements TanMode
{
public const ID = -1;
/** {@inheritdoc} */
public function getId(): int
{
return self::ID;
}
/** {@inheritdoc} */
public function getName(): string
{
return 'No PSD2/TANs supported';
}
public function isProzessvariante2(): bool
{
return false;
}
/** {@inheritdoc} */
public function isDecoupled(): bool
{
return false;
}
/** {@inheritdoc} */
public function getChallengeLabel(): string
{
return '';
}
/** {@inheritdoc} */
public function getMaxChallengeLength(): int
{
return 0;
}
/** {@inheritdoc} */
public function getMaxTanLength(): int
{
return 0;
}
/** {@inheritdoc} */
public function getTanFormat(): int
{
return 0;
}
/** {@inheritdoc} */
public function needsTanMedium(): bool
{
return false;
}
/** {@inheritdoc} */
public function getSmsAbbuchungskontoErforderlich(): bool
{
return false;
}
/** {@inheritdoc} */
public function getAuftraggeberkontoErforderlich(): bool
{
return false;
}
/** {@inheritdoc} */
public function getChallengeKlasseErforderlich(): bool
{
return false;
}
/** {@inheritdoc} */
public function getAntwortHhdUcErforderlich(): bool
{
return false;
}
/** {@inheritdoc} */
public function getMaxDecoupledChecks(): int
{
throw new \RuntimeException('Only allowed for decoupled TAN modes');
}
/** {@inheritdoc} */
public function getFirstDecoupledCheckDelaySeconds(): int
{
throw new \RuntimeException('Only allowed for decoupled TAN modes');
}
/** {@inheritdoc} */
public function getPeriodicDecoupledCheckDelaySeconds(): int
{
throw new \RuntimeException('Only allowed for decoupled TAN modes');
}
/** {@inheritdoc} */
public function allowsManualConfirmation(): bool
{
throw new \RuntimeException('Only allowed for decoupled TAN modes');
}
/** {@inheritdoc} */
public function allowsAutomatedPolling(): bool
{
throw new \RuntimeException('Only allowed for decoupled TAN modes');
}
public function createHKTAN(): HKTAN
{
throw new \AssertionError('HKTAN should not be needed when the bank does not support PSD2');
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace Fhp\Model;
/**
* Note: This account information is obtained from the HISPA response to a HKSPA request.
*/
class SEPAAccount
{
// All fields are nullable, but the overall SEPAAccount is only valid if at least {IBAN,BIC} or {accountNumber,blz} are present.
/** @var string|null */
protected $iban;
/** @var string|null */
protected $bic;
/** @var string|null */
protected $accountNumber;
/** @var string|null */
protected $subAccount;
/** @var string|null */
protected $blz;
public function getIban(): ?string
{
return $this->iban;
}
/**
* @return $this
*/
public function setIban(?string $iban)
{
$this->iban = $iban;
return $this;
}
public function getBic(): ?string
{
return $this->bic;
}
/**
* @return $this
*/
public function setBic(?string $bic)
{
$this->bic = $bic;
return $this;
}
public function getAccountNumber(): ?string
{
return $this->accountNumber;
}
/**
* @return $this
*/
public function setAccountNumber(?string $accountNumber)
{
$this->accountNumber = $accountNumber;
return $this;
}
public function getSubAccount(): ?string
{
return $this->subAccount;
}
/**
* @return $this
*/
public function setSubAccount(?string $subAccount)
{
$this->subAccount = $subAccount;
return $this;
}
public function getBlz(): ?string
{
return $this->blz;
}
/**
* @return $this
*/
public function setBlz(?string $blz)
{
$this->blz = $blz;
return $this;
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace Fhp\Model\StatementOfAccount;
class Statement
{
public const CD_CREDIT = 'credit';
public const CD_DEBIT = 'debit';
/**
* @var array of Transaction
*/
protected $transactions = [];
/**
* @var float
*/
protected $startBalance = 0.0;
/**
* @var float|null
*/
protected $endBalance = null;
/**
* @var string|null
*/
protected $creditDebit = null;
/**
* @var \DateTime|null
*/
protected $date;
/**
* Get transactions
*
* @return Transaction[]
*/
public function getTransactions(): array
{
return $this->transactions;
}
public function addTransaction(Transaction $transaction)
{
$this->transactions[] = $transaction;
}
/**
* Get startBalance
*/
public function getStartBalance(): float
{
return $this->startBalance;
}
/**
* Set startBalance
*
* @return $this
*/
public function setStartBalance(float $startBalance)
{
$this->startBalance = (float) $startBalance;
return $this;
}
/**
* Get endBalance
* @return ?float returns the value, if given by the bank or null if unknown
*/
public function getEndBalance(): ?float
{
return $this->endBalance;
}
/**
* Set endBalance
*
* @return $this
*/
public function setEndBalance(float $endBalance)
{
$this->endBalance = (float) $endBalance;
return $this;
}
/**
* Get creditDebit
*/
public function getCreditDebit(): ?string
{
return $this->creditDebit;
}
/**
* Set creditDebit
*
* @return $this
*/
public function setCreditDebit(?string $creditDebit)
{
$this->creditDebit = $creditDebit;
return $this;
}
/**
* Get date
*/
public function getDate(): \DateTime
{
return $this->date;
}
/**
* Set date
*
* @return $this
*/
public function setDate(\DateTime $date)
{
$this->date = $date;
return $this;
}
}

View file

@ -0,0 +1,126 @@
<?php
namespace Fhp\Model\StatementOfAccount;
use Fhp\MT940\MT940;
class StatementOfAccount
{
/**
* @var Statement[]
*/
protected $statements = [];
/**
* Get statements
*
* @return Statement[]
*/
public function getStatements(): array
{
return $this->statements;
}
/**
* Gets statement for given date.
*
* @param string|\DateTime $date
*/
public function getStatementForDate($date): ?Statement
{
if (is_string($date)) {
$date = static::parseDate($date);
}
foreach ($this->statements as $stmt) {
if ($stmt->getDate() == $date) {
return $stmt;
}
}
return null;
}
/**
* Checks if a statement with given date exists.
*
* @param string|\DateTime $date
*/
public function hasStatementForDate($date): bool
{
return null !== $this->getStatementForDate($date);
}
private static function parseDate(string $date): \DateTime
{
try {
return new \DateTime($date);
} catch (\Exception $e) {
throw new \InvalidArgumentException("Invalid date: $date", 0, $e);
}
}
/**
* @param array $array A parsed MT940 dataset, as returned from {@link MT940::parse()}.
* @return StatementOfAccount A new instance that contains the given data.
*/
public static function fromMT940Array(array $array): StatementOfAccount
{
$result = new StatementOfAccount();
foreach ($array as $date => $statement) {
if ($result->hasStatementForDate($date)) {
$statementModel = $result->getStatementForDate($date);
} else {
$statementModel = new Statement();
$statementModel->setDate(static::parseDate($date));
if (isset($statement['start_balance']['amount'])) {
$statementModel->setStartBalance((float) $statement['start_balance']['amount']);
}
if (isset($statement['end_balance'])) {
$statementModel->setEndBalance((float) $statement['end_balance']['amount'] * ($statement["end_balance"]['credit_debit'] == MT940::CD_CREDIT ? 1 : -1));
}
if (isset($statement['start_balance']['credit_debit'])) {
$statementModel->setCreditDebit($statement['start_balance']['credit_debit']);
}
$result->statements[] = $statementModel;
}
if (isset($statement['transactions'])) {
foreach ($statement['transactions'] as $trx) {
$replaceIn = [
'booking_text',
'description_1',
'description_2',
'description',
'name',
];
foreach ($replaceIn as $k) {
if (isset($trx['description'][$k])) {
$trx['description'][$k] = str_replace('@@', '', $trx['description'][$k]);
}
}
$transaction = new Transaction();
$transaction->setBookingDate(static::parseDate($trx['booking_date']));
$transaction->setValutaDate(static::parseDate($trx['valuta_date']));
$transaction->setCreditDebit($trx['credit_debit']);
$transaction->setIsStorno($trx['is_storno']);
$transaction->setAmount($trx['amount']);
$transaction->setBookingCode($trx['description']['booking_code']);
$transaction->setBookingText($trx['description']['booking_text']);
$transaction->setDescription1($trx['description']['description_1']);
$transaction->setDescription2($trx['description']['description_2']);
$transaction->setStructuredDescription($trx['description']['description']);
$transaction->setBankCode($trx['description']['bank_code']);
$transaction->setAccountNumber($trx['description']['account_number']);
$transaction->setName($trx['description']['name']);
$transaction->setBooked($trx['booked']);
$transaction->setPN($trx['description']['primanoten_nr']);
$transaction->setTextKeyAddition($trx['description']['text_key_addition']);
$statementModel->addTransaction($transaction);
}
}
}
return $result;
}
}

View file

@ -0,0 +1,450 @@
<?php
/** @noinspection PhpUnused */
namespace Fhp\Model\StatementOfAccount;
class Transaction
{
public const CD_CREDIT = 'credit';
public const CD_DEBIT = 'debit';
/**
* @var \DateTime|null
*/
protected $bookingDate;
/**
* @var \DateTime|null
*/
protected $valutaDate;
/**
* @var float
*/
protected $amount;
/**
* @var string
*/
protected $creditDebit;
/**
* @var bool
*/
protected $isStorno;
/**
* @var string
*/
protected $bookingCode;
/**
* @var string
*/
protected $bookingText;
/**
* @var string
*/
protected $description1;
/**
* @var string
*/
protected $description2;
/**
* Array keys are identifiers like "SVWZ" for the main description.
* @var string[]
*/
protected $structuredDescription;
/**
* @var string
*/
protected $bankCode;
/**
* @var string
*/
protected $accountNumber;
/**
* @var string
*/
protected $name;
/**
* @var bool
*/
protected $booked;
/**
* @var int
*/
protected $pn;
/**
* @var int
*/
protected $textKeyAddition;
/**
* Get booking date.
*
* @deprecated Use getBookingDate() instead
* @codeCoverageIgnore
*/
public function getDate(): ?\DateTime
{
return $this->getBookingDate();
}
/**
* Get booking date
*/
public function getBookingDate(): ?\DateTime
{
return $this->bookingDate;
}
/**
* Get date
*/
public function getValutaDate(): ?\DateTime
{
return $this->valutaDate;
}
/**
* Set booking date
*
* @return $this
*/
public function setBookingDate(?\DateTime $date = null)
{
$this->bookingDate = $date;
return $this;
}
/**
* Set valuta date
*
* @return $this
*/
public function setValutaDate(?\DateTime $date = null)
{
$this->valutaDate = $date;
return $this;
}
/**
* Get amount
*/
public function getAmount(): float
{
return $this->amount;
}
/**
* Set booked status
*
* @return $this
*/
public function setBooked(bool $booked)
{
$this->booked = $booked;
return $this;
}
/**
* Set amount
*
* @return $this
*/
public function setAmount(float $amount)
{
$this->amount = (float) $amount;
return $this;
}
/**
* Get creditDebit
*/
public function getCreditDebit(): string
{
return $this->creditDebit;
}
/**
* Set creditDebit
*
* @return $this
*/
public function setCreditDebit(string $creditDebit)
{
$this->creditDebit = $creditDebit;
return $this;
}
/**
* Get isStorno
*/
public function isStorno(): bool
{
return $this->isStorno;
}
/**
* Set isStorno
*
* @return $this
*/
public function setIsStorno(bool $isStorno)
{
$this->isStorno = $isStorno;
return $this;
}
/**
* Get bookingCode
*/
public function getBookingCode(): string
{
return $this->bookingCode;
}
/**
* Set bookingCode
*
* @return $this
*/
public function setBookingCode(string $bookingCode)
{
$this->bookingCode = (string) $bookingCode;
return $this;
}
/**
* Get bookingText
*/
public function getBookingText(): string
{
return $this->bookingText;
}
/**
* Set bookingText
*
* @return $this
*/
public function setBookingText(string $bookingText)
{
$this->bookingText = (string) $bookingText;
return $this;
}
/**
* Get description1
*/
public function getDescription1(): string
{
return $this->description1;
}
/**
* Set description1
*
* @return $this
*/
public function setDescription1(string $description1)
{
$this->description1 = (string) $description1;
return $this;
}
/**
* Get description2
*/
public function getDescription2(): string
{
return $this->description2;
}
/**
* Set description2
*
* @return $this
*/
public function setDescription2(string $description2)
{
$this->description2 = (string) $description2;
return $this;
}
/**
* Get structuredDescription
*
* @return string[]
*/
public function getStructuredDescription(): array
{
return $this->structuredDescription;
}
/**
* Set structuredDescription
*
* @param string[] $structuredDescription
*
* @return $this
*/
public function setStructuredDescription(array $structuredDescription)
{
$this->structuredDescription = $structuredDescription;
return $this;
}
/**
* Get the main description (SVWZ)
*/
public function getMainDescription(): string
{
if (array_key_exists('SVWZ', $this->structuredDescription)) {
return $this->structuredDescription['SVWZ'];
} else {
return '';
}
}
/**
* Get the end to end id (EREF)
*/
public function getEndToEndID(): string
{
if (array_key_exists('EREF', $this->structuredDescription)) {
return $this->structuredDescription['EREF'];
} else {
return '';
}
}
/**
* Get bankCode
*/
public function getBankCode(): string
{
return $this->bankCode;
}
/**
* Set bankCode
*
* @return $this
*/
public function setBankCode(string $bankCode)
{
$this->bankCode = (string) $bankCode;
return $this;
}
/**
* Get accountNumber
*/
public function getAccountNumber(): string
{
return $this->accountNumber;
}
/**
* Set accountNumber
*
* @return $this
*/
public function setAccountNumber(string $accountNumber)
{
$this->accountNumber = (string) $accountNumber;
return $this;
}
/**
* Get name
*/
public function getName(): string
{
return $this->name;
}
/**
* Get booked status
*/
public function getBooked(): bool
{
return $this->booked;
}
/**
* Set name
*
* @return $this
*/
public function setName(string $name)
{
$this->name = (string) $name;
return $this;
}
/**
* Get primanota number
*/
public function getPN(): int
{
return $this->pn;
}
/**
* Set primanota number
*
* @param int|mixed $nr Will be parsed to an int.
* @return $this
*/
public function setPN($nr)
{
$this->pn = intval($nr);
return $this;
}
/**
* Get text key addition
*/
public function getTextKeyAddition(): int
{
return $this->textKeyAddition;
}
/**
* Set text key addition
*
* @param int|mixed $textKeyAddition Will be parsed to an int.
* @return $this
*/
public function setTextKeyAddition($textKeyAddition)
{
$this->textKeyAddition = intval($textKeyAddition);
return $this;
}
}

View file

@ -0,0 +1,256 @@
<?php
namespace Fhp\Model\StatementOfHoldings;
class Holding
{
/**
* @var string|null
*/
protected $isin;
/**
* @var string|null
*/
protected $wkn;
/**
* @var string|null
*/
protected $name;
/**
* @var float|null
*/
protected $acquisitionPrice;
/**
* @var float|null
*/
protected $price;
/**
* @var float|null
*/
protected $amount;
/**
* @var float|null
*/
protected $value;
/**
* @var \DateTime|null
*/
protected $date;
/**
* @var \DateTime|null
*/
protected $time;
/**
* @var string|null
*/
protected $currency;
/**
* Set ISIN
*
* @return $this
*/
public function setISIN(?string $isin)
{
$this->isin = $isin;
return $this;
}
/**
* Set WKN
*
* @return $this
*/
public function setWKN(?string $wkn)
{
$this->wkn = $wkn;
return $this;
}
/**
* Set Name
*
* @return $this
*/
public function setName(?string $name)
{
$this->name = $name;
return $this;
}
/**
* Set value
*
* @return $this
*/
public function setValue(?float $value)
{
$this->value = $value;
return $this;
}
/**
* Set price
*
* @return $this
*/
public function setPrice(?float $price)
{
$this->price = $price;
return $this;
}
/**
* Set acquisition price
*
* @return $this
*/
public function setAcquisitionPrice(?float $price)
{
$this->acquisitionPrice = $price;
return $this;
}
/**
* Set amount
*
* @return $this
*/
public function setAmount(?float $amount)
{
$this->amount = $amount;
return $this;
}
/**
* Set currency
*
* @return $this
*/
public function setCurrency(?string $currency)
{
$this->currency = $currency;
return $this;
}
/**
* Set date
*
* @return $this
*/
public function setDate(\DateTime $date)
{
$this->date = $date;
return $this;
}
/**
* Set time
*
* @return $this
*/
public function setTime(\DateTime $time)
{
$this->time = $time;
return $this;
}
/**
* Get ISIN
*/
public function getISIN(): ?string
{
return $this->isin;
}
/**
* Get WKN
*/
public function getWKN(): ?string
{
return $this->wkn;
}
/**
* Get Name
*/
public function getName(): ?string
{
return $this->name;
}
/**
* Get value
*/
public function getValue(): ?float
{
return $this->value;
}
/**
* Get acquisition price
*/
public function getAcquisitionPrice(): ?float
{
return $this->acquisitionPrice;
}
/**
* Get price
*/
public function getPrice(): ?float
{
return $this->price;
}
/**
* Get amount
*/
public function getAmount(): ?float
{
return $this->amount;
}
/**
* Get currency
*/
public function getCurrency(): ?string
{
return $this->currency;
}
/**
* Get time
*/
public function getTime(): ?\DateTime
{
return $this->time;
}
/**
* Get date
*/
public function getDate(): ?\DateTime
{
return $this->date;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Fhp\Model\StatementOfHoldings;
class StatementOfHoldings
{
/**
* @var Holding[]
*/
protected $holdings = [];
/**
* Get statements
*
* @return Holding[]
*/
public function getHoldings(): array
{
return $this->holdings;
}
public function addHolding(Holding $holding)
{
$this->holdings[] = $holding;
}
}

View file

@ -0,0 +1,27 @@
<?php
/** @noinspection PhpUnused */
namespace Fhp\Model;
/**
* For two-step authentication, users need to enter a TAN, which can be obtained in various ways. After choosing one of
* these ways, i.e. choosing a a {@link TanMode} (SMS, TAN generator device, and so on), the user might have to choose
* which of their TAN media they want to use within this mode, in case they have multiple. For instance, a user might
* have multiple mobile phone numbers configured for smsTAN, might have multiple TAN generators, or multiple iTAN lists.
* Each {@link TanMedium} instance describes one of these options.
*/
interface TanMedium
{
/**
* @return string A user-readable name for this TAN medium, which serves as its identifier at the same time. This is
* what the application needs to persist when it wants to remember the users decision for future transactions.
*/
public function getName(): string;
/**
* @return string|null In case this is a mobileTAN/smsTAN medium, this is its (possibly obfuscated) phone number.
*/
public function getPhoneNumber(): ?string;
// TODO Consider making more information from TanMediumListeV4 available here.
}

Some files were not shown because too many files have changed in this diff Show more